feat(i18n): Konto, Editor und Karte auf Englisch
Task 10: alle sichtbaren Texte von account/, editor/, card/ und der Seite „Passwort neu setzen“ (Texte, aria-label, title, Meldungen,
Rückfragen) gehen durch t()/msg(); en.ts bekommt 87 Einträge.
- account/: Konto-Menü, Login-Dialog (Titel, Reiter, Felder, Meldungen) und errors.ts (errorMessage übersetzt beim Zurückgeben).
validatePassword nutzt {min}/{max}. Der Login-Dialog wird nur einmal gebaut: die Beschriftung des Schließen-Knopfs wird bei jedem
Öffnen in der aktuellen Sprache gesetzt. LOGIN_NEEDED und DEFAULT_CARD_NAME bleiben deutsche Konstanten (msg), angezeigt werden sie
mit t(); der Standardname einer neuen Karte folgt damit der Sprache („My card“).
- editor/: Vorlagen-, Options- und Vorschauspalte, Bild-Upload, Speichern, Download, Rückfragen. Konstanten (upload.ts, download.ts,
editor-page.ts) bleiben deutsche Strings per msg(); validateImageFile, pngSizeWarning und das Speichern („Speichern fehlgeschlagen –
{reason}“) liefern den Text schon übersetzt, Fehler tragen weiter die deutsche Konstante (Vergleiche mit READ_MESSAGE/PIXELS_MESSAGE
gehen weiter) und werden beim Anzeigen mit t(error.message) übersetzt. Zoom „100 %“ / „100%“ und „Bearbeitet: {name}“ mit Platzhaltern.
- card/: Bezeichnungen der Marken (msg) und die Meldungen des Renderers (msg, Anzeige im Editor über t()).
- pages/reset-page.ts, account/login-dialog.ts: Die Meldung „Die Passwörter stimmen nicht überein.“ ist nicht mehr der Schlüssel zum
Fokus: es zählt die Bedingung selbst, nicht der Vergleich mit dem (nun übersetzten) Text.
- i18n/coverage.test.ts: Heuristik für deutsche Text-Literale außerhalb von t()/msg() (Umlaut/ß, mindestens zwei Wörter, einzelnes
großgeschriebenes Wort; gelesen wird der Syntaxbaum) für account/, editor/, card/ und pages/reset-page.ts; Ausnahmen mit Grund, jede
muss noch zutreffen; Scanner mit eigenen Tests. Die Liste der Bereiche ist eine Konstante für Task 11.
- i18n/english-ui.test.ts (neu): Stichproben auf Englisch (Konto-Menü, Login-Dialog, Passwort-Seite, Editor inkl. Fehlerwege) und
„kein deutscher Wörterbuchtext auf der englischen Seite“.
- test-setup.ts: Wer Module mit vi.resetModules() frisch lädt, bekam eine neue Kopie von i18n.ts mit der Browsersprache (happy-dom: en-US);
der Browser meldet im Test jetzt Deutsch, damit auch diese Tests deutsch beginnen. main.test.ts: nach dem Wechsel auf Englisch heißt
der Knopf des Konto-Menüs „Sign in“.
- Tests: Vitest 53 Dateien, 1779 Tests grün, tsc und Build sauber.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a75be2d44c
commit
1ff1873b27
23 files changed
+857
-169
No files matched your search
@@ -57,6 +57,112 @@ for (const [path, code] of sources()) {
|
||||
dynamic.push(...found.dynamic.map((call) => `${path}: ${call}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereiche, die schon vollständig übersetzt sind: Ordner (mit „/“ am Ende) oder einzelne Dateien, relativ zu src/.
|
||||
* Dort darf kein deutsches Text-Literal mehr außerhalb von t()/msg() stehen; ein weiterer übersetzter Bereich kommt hier dazu.
|
||||
*/
|
||||
const TRANSLATED_AREAS = ['account/', 'editor/', 'card/', 'pages/reset-page.ts'];
|
||||
|
||||
/**
|
||||
* Literale, die wie deutscher Text aussehen, aber nie angezeigt werden. Jede Ausnahme nennt ihren Grund; der Test unten
|
||||
* verlangt, dass sie noch zutrifft, damit die Liste nicht veraltet.
|
||||
*/
|
||||
const ALLOWED_LITERALS: ReadonlyArray<{ file: string; text: string; reason: string }> = [
|
||||
{
|
||||
file: 'account/auth.ts',
|
||||
text: 'Unerwartete Antwort',
|
||||
reason: 'Meldung eines internen Fehlers (Antwort ohne $id/email); angezeigt wird nur der allgemeine Text aus errorMessage()',
|
||||
},
|
||||
{
|
||||
file: 'account/cards.ts',
|
||||
text: 'Unerwartete Antwort',
|
||||
reason: 'Meldung der AppwriteException für eine Datei, die kein Bild ist; errorMessage() liest nur Typ und Code, nie diese Meldung',
|
||||
},
|
||||
{
|
||||
file: 'account/cards.ts',
|
||||
text: 'Row not found',
|
||||
reason: 'Englische Meldung der selbst gebauten AppwriteException (wie vom Server); errorMessage() liest nur den Typ',
|
||||
},
|
||||
{
|
||||
file: 'account/cards.ts',
|
||||
text: 'Bildtyp nicht erlaubt: ',
|
||||
reason: 'Programmierfehler-Meldung (nur Blob-Typen aus dem Editor kommen an); angezeigt wird der allgemeine Text aus errorMessage()',
|
||||
},
|
||||
{
|
||||
file: 'account/with-login.ts',
|
||||
text: 'Anmeldung abgebrochen',
|
||||
reason: 'Meldung von LoginCancelled; die Aufrufer erkennen den Fehler mit instanceof und zeigen LOGIN_NEEDED',
|
||||
},
|
||||
{
|
||||
file: 'card/marks.ts',
|
||||
text: 'Logo konnte nicht geladen werden: ',
|
||||
reason: 'Entwicklermeldung mit interner ID; Nutzer sehen die eigenen Texte der Seiten (im Editor LOGO_ERROR, „Die Logos konnten nicht geladen werden.“)',
|
||||
},
|
||||
];
|
||||
|
||||
/** Liegt die Datei in einem Bereich, in dem alle Texte schon übersetzt sein müssen? */
|
||||
const inTranslatedArea = (path: string): boolean =>
|
||||
TRANSLATED_AREAS.some((area) => path === area || (area.endsWith('/') && path.startsWith(area)));
|
||||
|
||||
/**
|
||||
* Sieht ein Text-Literal wie deutscher Text aus? Ja bei einem Umlaut oder ß, bei mindestens zwei Wörtern („Bitte melde dich an.“;
|
||||
* ein Wort hat mindestens zwei Buchstaben, Satzzeichen am Ende zählen nicht mit) und bei einem einzelnen großgeschriebenen
|
||||
* Wort („Hell“, „Speichert …“, „E-Mail“). Ein Wort mit Bindestrich-Teilen („auth-btn“) und CamelCase („ArrowLeft“) sind Bezeichner.
|
||||
*/
|
||||
function looksGerman(text: string): boolean {
|
||||
if (/[äöüÄÖÜß]/.test(text)) return true;
|
||||
if (text.split(/\s+/).filter((word) => /^\p{L}{2,}[.,:;!?…]*$/u.test(word)).length >= 2) return true;
|
||||
const single = text.replace(/[\s.,:;!?…]+$/u, '');
|
||||
return /^\p{Lu}[\p{L}-]*$/u.test(single) && /\p{Ll}/u.test(single) && !/\p{Ll}\p{Lu}/u.test(single);
|
||||
}
|
||||
|
||||
const EQUALITY = [ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken];
|
||||
const SEARCH_METHODS = ['includes', 'startsWith', 'endsWith', 'indexOf'];
|
||||
|
||||
/** Steht das Literal dort, wo es übersetzt wird oder nie angezeigt wird (Importpfad, Typ, Eigenschaftsname, Vergleichswert …)? */
|
||||
function isExemptPosition(node: ts.Node): boolean {
|
||||
const parent = node.parent;
|
||||
if (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent) || ts.isLiteralTypeNode(parent)) return true;
|
||||
if (ts.isCaseClause(parent) || (ts.isBinaryExpression(parent) && EQUALITY.includes(parent.operatorToken.kind))) return true; // key === 'Escape'
|
||||
if (ts.isPropertyAssignment(parent)) {
|
||||
// { 'aria-label': … } (der Name, nicht der Wert) und { class: 'card tile' } (CSS-Klassen)
|
||||
return parent.name === node || ['class', 'className'].includes(ts.isIdentifier(parent.name) ? parent.name.text : '');
|
||||
}
|
||||
if (!ts.isCallExpression(parent)) return false;
|
||||
const callee = parent.expression;
|
||||
if (ts.isIdentifier(callee)) return ['t', 'msg'].includes(callee.text) && parent.arguments[0] === node;
|
||||
if (!ts.isPropertyAccessExpression(callee)) return false;
|
||||
// console.warn('…') und Suchen in Daten: liste.includes('Files'), text.startsWith('…')
|
||||
const isConsole = ts.isIdentifier(callee.expression) && callee.expression.text === 'console';
|
||||
return isConsole || SEARCH_METHODS.includes(callee.name.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Literale (Zeichenketten und Teile von Template-Literalen), die wie deutscher Text aussehen und nicht durch t()/msg() gehen.
|
||||
* Gelesen wird der Syntaxbaum: Kommentare und reguläre Ausdrücke zählen nicht.
|
||||
*/
|
||||
function findUntranslated(code: string): string[] {
|
||||
const out: string[] = [];
|
||||
const file = ts.createSourceFile('quelle.ts', code, ts.ScriptTarget.Latest, true);
|
||||
const check = (node: ts.Node, text: string): void => {
|
||||
if (looksGerman(text) && !isExemptPosition(node)) out.push(text);
|
||||
};
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) check(node, node.text);
|
||||
else if (ts.isTemplateExpression(node)) {
|
||||
for (const text of [node.head.text, ...node.templateSpans.map((span) => span.literal.text)]) check(node, text);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(file);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Dateien der übersetzten Bereiche → ihre verdächtigen Literale.
|
||||
const untranslated: Array<{ file: string; text: string }> = sources()
|
||||
.filter(([path]) => inTranslatedArea(path))
|
||||
.flatMap(([path, code]) => findUntranslated(code).map((text) => ({ file: path, text })));
|
||||
|
||||
describe('Abdeckung der Übersetzungen (t/msg ↔ en.ts)', () => {
|
||||
it('der Scanner findet die Texte der Oberfläche (sonst bestünden die Prüfungen leer)', () => {
|
||||
expect(used.size).toBeGreaterThan(10);
|
||||
@@ -121,3 +227,81 @@ describe('Scanner für t() und msg()', () => {
|
||||
expect(findTexts(code).texts).toEqual(['Innen', 'Außen', 'Wenn', 'Sonst']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deutsche Texte außerhalb von t()/msg() (übersetzte Bereiche)', () => {
|
||||
it('die übersetzten Bereiche gibt es und enthalten Quelldateien (sonst bestünde die Prüfung leer)', () => {
|
||||
const files = sources().filter(([path]) => inTranslatedArea(path));
|
||||
expect(files.length).toBeGreaterThan(20);
|
||||
expect(files.some(([path]) => path === 'pages/reset-page.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('kein deutsches Text-Literal steht noch außerhalb von t()/msg(), außer den begründeten Ausnahmen', () => {
|
||||
const allowed = (file: string, text: string) => ALLOWED_LITERALS.some((entry) => entry.file === file && entry.text === text);
|
||||
const open = untranslated.filter(({ file, text }) => !allowed(file, text)).map(({ file, text }) => `${file}: ${text}`);
|
||||
expect(open).toEqual([]);
|
||||
});
|
||||
|
||||
it('jede Ausnahme hat einen Grund und trifft noch ein Literal', () => {
|
||||
for (const entry of ALLOWED_LITERALS) expect(entry.reason.trim(), `${entry.file}: ${entry.text}`).not.toBe('');
|
||||
const stale = ALLOWED_LITERALS.filter((entry) => !untranslated.some(({ file, text }) => file === entry.file && text === entry.text));
|
||||
expect(stale.map((entry) => `${entry.file}: ${entry.text}`)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scanner für deutsche Literale außerhalb von t() und msg()', () => {
|
||||
it('findet Umlaute, mindestens zwei Wörter und einzelne großgeschriebene Wörter, auch mit Satzzeichen und in Template-Teilen', () => {
|
||||
const code = [
|
||||
"const a = 'Schließen';",
|
||||
"const b = 'Bitte melde dich an.';",
|
||||
"const c = 'Speichert …';",
|
||||
"const d = 'Anmelden';",
|
||||
'const e = `Das Passwort muss ${min} bis ${max} Zeichen lang sein.`;',
|
||||
"const f = 'E-Mail';",
|
||||
"const g = 'Bearbeitet: ';",
|
||||
'const h = `Nur Bild`;',
|
||||
].join('\n');
|
||||
expect(findUntranslated(code)).toEqual([
|
||||
'Schließen',
|
||||
'Bitte melde dich an.',
|
||||
'Speichert …',
|
||||
'Anmelden',
|
||||
'Das Passwort muss ',
|
||||
' Zeichen lang sein.',
|
||||
'E-Mail',
|
||||
'Bearbeitet: ',
|
||||
'Nur Bild',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignoriert Texte in t()/msg(), Kommentare, Importpfade, Typen, Eigenschaftsnamen, console, Vergleiche und Suchen', () => {
|
||||
const code = [
|
||||
"import './konto-menü.css';",
|
||||
"export * from './über-uns';",
|
||||
"type Ansicht = 'Anmelden' | 'Registrieren';",
|
||||
"// const kommentar = 'Das ist ein Kommentar';",
|
||||
"const a = t('Schließen') + msg('Bitte melde dich an.') + t('Speichern', { was: 1 });",
|
||||
"const b = { 'aria-label': 1, 'Zwei Wörter': 2 };",
|
||||
"console.warn('Das ist nur eine Konsolenmeldung');",
|
||||
"if (event.key !== 'Escape' && kind === 'Anmelden') x();",
|
||||
"switch (key) { case 'Schließen': break; }",
|
||||
"const c = types.includes('Files') && text.startsWith('Das ist ein Satz') && list.indexOf('Zwei Wörter');",
|
||||
'const d = /[äöüß]/.test(text);',
|
||||
].join('\n');
|
||||
expect(findUntranslated(code)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignoriert technische Zeichenketten: CSS-Klassen, Kleinbuchstaben-Wörter, Formate, Bezeichner, Zahlen', () => {
|
||||
const code = [
|
||||
"el('button', { type: 'button', class: 'auth-btn auth-btn-small', 'aria-describedby': 'reset-hint reset-error' }, '×');",
|
||||
"el('div', { class: 'card tile' });",
|
||||
"const a = 'user_invalid_credentials' + 'image/png' + '#account-slot button' + 'ArrowLeft' + 'LoginCancelled' + 'PLATINUM';",
|
||||
"const b = '100 %' + '1536 × 969 PNG' + '•••• •••• •••• 8888' + 'M548.75 379.89a98.21 98.21 0 0 1 0 98.21';",
|
||||
'const c = `aircard-${name}.png` + `data:image/svg+xml;charset=utf-8,${svg}`;',
|
||||
].join('\n');
|
||||
expect(findUntranslated(code)).toEqual([]);
|
||||
});
|
||||
|
||||
it('übersetzt wird erst im Argument von t()/msg(): eine Bedingung davor zählt als offener Text', () => {
|
||||
expect(findUntranslated("const a = t(ok ? 'Gespeichert' : 'Speichern');")).toEqual(['Gespeichert', 'Speichern']);
|
||||
});
|
||||
});
|
||||
@@ -120,6 +120,110 @@ export const EN: Readonly<Record<string, string>> = {
|
||||
'Danke, du hast dieses Logo schon gemeldet.': 'Thanks, you’ve already reported this logo.',
|
||||
'Schließen': 'Close',
|
||||
|
||||
// Konto-Menü, Anmelden-Dialog und Seite „Passwort neu setzen“ (account/account-menu.ts, account/login-dialog.ts, pages/reset-page.ts).
|
||||
// „Abbrechen“ und „Schließen“ stehen weiter oben.
|
||||
'Anmelden': 'Sign in',
|
||||
'Registrieren': 'Sign up',
|
||||
'Abmelden': 'Sign out',
|
||||
'Konto': 'Account',
|
||||
'Konto-Menü': 'Account menu',
|
||||
'Passwort vergessen': 'Forgot password',
|
||||
'Passwort vergessen?': 'Forgot password?',
|
||||
'E-Mail': 'Email',
|
||||
'Passwort': 'Password',
|
||||
'Passwort wiederholen': 'Confirm password',
|
||||
'Mindestens 8 Zeichen.': 'At least 8 characters.',
|
||||
'Zurück zur Anmeldung': 'Back to sign in',
|
||||
'Link senden': 'Send link',
|
||||
'Die Passwörter stimmen nicht überein.': 'The passwords don’t match.',
|
||||
'Wir haben dir einen Link geschickt. Er gilt eine Stunde.': 'We’ve sent you a link. It’s valid for one hour.',
|
||||
'Bitte melde dich an, um fortzufahren.': 'Please sign in to continue.',
|
||||
'Passwort neu setzen': 'Set new password',
|
||||
'Neues Passwort': 'New password',
|
||||
'Neues Passwort wiederholen': 'Confirm new password',
|
||||
'Passwort speichern': 'Save password',
|
||||
'Neuen Link anfordern': 'Request a new link',
|
||||
'Passwort geändert. Du kannst dich jetzt anmelden.': 'Password changed. You can now sign in.',
|
||||
|
||||
// Fehlermeldungen zu Konto und Karten (account/errors.ts, account/auth.ts)
|
||||
'Der Link ist ungültig oder abgelaufen.': 'The link is invalid or has expired.',
|
||||
'E-Mail oder Passwort ist falsch.': 'Email or password is incorrect.',
|
||||
'Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.': 'This password is too common. Please choose a different one.',
|
||||
'Das Passwort muss 8 bis 256 Zeichen lang sein.': 'The password must be 8 to 256 characters long.',
|
||||
'Das Passwort muss {min} bis {max} Zeichen lang sein.': 'The password must be {min} to {max} characters long.',
|
||||
'Diese E-Mail ist schon registriert. Melde dich an.': 'This email is already registered. Please sign in.',
|
||||
'Zu dieser E-Mail gibt es kein Konto.': 'There is no account for this email.',
|
||||
'Bitte gib eine gültige E-Mail-Adresse ein.': 'Please enter a valid email address.',
|
||||
'Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.':
|
||||
'This card no longer exists or doesn’t belong to your account.',
|
||||
'Das Bild dieser Karte fehlt.': 'This card’s image is missing.',
|
||||
'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.':
|
||||
'This site isn’t enabled on the server. Please contact the site owner.',
|
||||
'Du bist schon angemeldet. Bitte lade die Seite neu.': 'You’re already signed in. Please reload the page.',
|
||||
'Zurzeit sind keine neuen Konten möglich.': 'New accounts can’t be created right now.',
|
||||
'Zu viele Versuche – bitte kurz warten.': 'Too many attempts – please wait a moment.',
|
||||
'Keine Verbindung zum Server.': 'Can’t reach the server.',
|
||||
'Meine Karte': 'My card',
|
||||
|
||||
// Editor, Spalte „Vorlage“ (editor/templates-panel.ts)
|
||||
'Vorlage': 'Template',
|
||||
'Nur Bild': 'Image only',
|
||||
'Bild unverändert lassen': 'Keep the image as is',
|
||||
'Minimalistisch': 'Minimalist',
|
||||
'Dein Bild steht im Mittelpunkt': 'Your image takes center stage',
|
||||
'Klassisch': 'Classic',
|
||||
'Die vertrauten Kartendetails': 'The familiar card details',
|
||||
'Signature': 'Signature',
|
||||
'Ein bisschen mehr Persönlichkeit': 'A little more personality',
|
||||
|
||||
// Editor, Spalte „Individuell gestalten“ (editor/options-panel.ts, card/marks.ts). „Hell“, „Dunkel“ und „Kein Logo“ stehen weiter oben.
|
||||
'Individuell gestalten': 'Customize',
|
||||
'Details': 'Details',
|
||||
'Logo': 'Logo',
|
||||
'AirCard-IOS · Symbol': 'AirCard-IOS · Symbol',
|
||||
'AirCard-IOS · Text': 'AirCard-IOS · Text',
|
||||
|
||||
// Editor, Vorschau und Knöpfe (editor/editor-page.ts, editor/preview.ts)
|
||||
'Kartenmotiv gestalten': 'Create a card design',
|
||||
'Kartenvorschau': 'Card preview',
|
||||
'Kartenvorschau. Mit der Maus ziehen oder die Pfeiltasten benutzen, um das Bild zu verschieben.':
|
||||
'Card preview. Drag with the mouse or use the arrow keys to move the image.',
|
||||
'Bilddatei wählen': 'Choose image file',
|
||||
'Bild auswählen': 'Choose image',
|
||||
'Ziehe deine Idee hierher': 'Drag your idea here',
|
||||
'PNG / JPG / WebP · Max. 5 MiB': 'PNG / JPG / WebP · Max. 5 MiB',
|
||||
'Zoom': 'Zoom',
|
||||
'{percent} %': '{percent}%',
|
||||
'Bild ersetzen': 'Replace image',
|
||||
'Herunterladen': 'Download',
|
||||
'Speichern': 'Save',
|
||||
'Speichert …': 'Saving …',
|
||||
'Gespeichert': 'Saved',
|
||||
'Erst ein Bild wählen': 'Choose an image first',
|
||||
'Neue Karte': 'New card',
|
||||
'Bearbeitet: {name}': 'Editing: {name}',
|
||||
'Dein Bild bleibt auf deinem Gerät, bis du speicherst.': 'Your image stays on your device until you save.',
|
||||
|
||||
// Editor, Meldungen und Rückfragen (editor/editor-page.ts, editor/upload.ts, editor/download.ts, editor/save.ts, card/render.ts)
|
||||
'Bitte zuerst ein Bild wählen.': 'Please choose an image first.',
|
||||
'Bitte ein PNG-, JPG- oder WebP-Bild wählen.': 'Please choose a PNG, JPG or WebP image.',
|
||||
'Das Bild ist größer als 5 MiB.': 'The image is larger than 5 MiB.',
|
||||
'Das Bild konnte nicht gelesen werden.': 'The image couldn’t be read.',
|
||||
'Das PNG ist größer als 4 MB – Wallet nimmt eventuell nur kleinere Bilder.':
|
||||
'The PNG is larger than 4 MB – Wallet may only accept smaller images.',
|
||||
'Das PNG konnte nicht erzeugt werden.': 'The PNG couldn’t be created.',
|
||||
'Das Bild konnte nicht erzeugt werden.': 'The image couldn’t be created.',
|
||||
'Die Vorschau konnte nicht gezeichnet werden.': 'The preview couldn’t be drawn.',
|
||||
'Canvas wird in diesem Browser nicht unterstützt.': 'This browser doesn’t support canvas.',
|
||||
'Speichern fehlgeschlagen – {reason}': 'Saving failed – {reason}',
|
||||
'Karte wird geladen …': 'Loading card …',
|
||||
'Vorlage wird geladen …': 'Loading template …',
|
||||
'Diese Vorlage ist nicht (mehr) verfügbar.': 'This template is no longer available.',
|
||||
'Melde dich an, um diese Karte zu öffnen.': 'Sign in to open this card.',
|
||||
'Ungespeicherte Änderungen verwerfen?': 'Discard unsaved changes?',
|
||||
'Alles, was du seit dem letzten Speichern geändert hast, geht verloren.': 'Everything you’ve changed since the last save will be lost.',
|
||||
'Verwerfen': 'Discard',
|
||||
|
||||
// API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich.
|
||||
'Bitte melde dich an.': 'Please sign in.',
|
||||
'Nicht gefunden.': 'Not found.',
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
// Stichproben der englischen Oberfläche von Konto, Editor und Passwort-Seite (Task 10): Die Texte erscheinen bei Englisch, und was an
|
||||
// Texten hängt (Abgleich der Passwörter, übersetzte Fehler, Beschriftung des wiederverwendeten Login-Dialogs) arbeitet in beiden
|
||||
// Sprachen gleich. Wer Module mit `vi.resetModules()` frisch lädt, stellt die Sprache an der frischen Kopie von i18n.ts ein.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EN } from './en';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
acct: {
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
createEmailPasswordSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
createRecovery: vi.fn(),
|
||||
updateRecovery: vi.fn(),
|
||||
},
|
||||
getCard: vi.fn(),
|
||||
saveCard: vi.fn(),
|
||||
getCurrentUser: vi.fn(),
|
||||
requireLogin: vi.fn(),
|
||||
decodeImage: vi.fn(),
|
||||
loadCardAssets: vi.fn(),
|
||||
exportCard: vi.fn(),
|
||||
getGalleryCard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../account/appwrite', () => ({ account: mocks.acct, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type });
|
||||
const submit = (form: HTMLFormElement | null) => form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
|
||||
/** Frische Kopie von i18n.ts in der gewünschten Sprache (die Module danach laden dieselbe Kopie). */
|
||||
async function freshI18n(lang: 'de' | 'en') {
|
||||
vi.resetModules();
|
||||
const i18n = await import('./i18n');
|
||||
i18n.setLang(lang, { persist: false });
|
||||
return i18n;
|
||||
}
|
||||
|
||||
/** Alle sichtbaren Texte (Textknoten) und Textattribute unter `root`. */
|
||||
function texts(root: ParentNode): string[] {
|
||||
const out: string[] = [];
|
||||
const walker = document.createTreeWalker(root as Node, NodeFilter.SHOW_TEXT);
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) if (node.textContent?.trim()) out.push(node.textContent.trim());
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
for (const attr of ['aria-label', 'title', 'placeholder', 'alt']) if (el.getAttribute(attr)) out.push(el.getAttribute(attr) as string);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Deutsche Wörterbuchtexte, die auf einer englischen Seite stehen: also Texte, die an `t()` vorbeigegangen sind. */
|
||||
const germanLeft = (root: ParentNode) => texts(root).filter((text) => Object.hasOwn(EN, text) && EN[text] !== text);
|
||||
|
||||
describe('Fehlertexte und Standardname auf Englisch', () => {
|
||||
it('errorMessage und validatePassword übersetzen beim Zurückgeben', async () => {
|
||||
await freshI18n('en');
|
||||
const { errorMessage } = await import('../account/errors');
|
||||
const { validatePassword } = await import('../account/auth');
|
||||
expect(errorMessage(fail(401, 'user_invalid_credentials'), 'login')).toBe('Email or password is incorrect.');
|
||||
expect(errorMessage(fail(404, 'user_not_found'), 'reset')).toBe('The link is invalid or has expired.');
|
||||
expect(errorMessage(fail(429, ''), 'login')).toBe('Too many attempts – please wait a moment.');
|
||||
expect(errorMessage(new TypeError('x'), 'load')).toBe('Can’t reach the server.');
|
||||
expect(errorMessage(new Error('x'), 'load')).toBe('Something went wrong. Please try again.');
|
||||
expect(validatePassword('kurz')).toBe('The password must be 8 to 256 characters long.');
|
||||
});
|
||||
|
||||
it('der Standardname einer neuen Karte folgt der Sprache, die Konstante bleibt der deutsche Schlüssel', async () => {
|
||||
const i18n = await freshI18n('en');
|
||||
const { DEFAULT_CARD_NAME, normalizeName } = await import('../account/cards');
|
||||
const { initialEditorState } = await import('../editor/state');
|
||||
expect(normalizeName(' ')).toBe('My card');
|
||||
expect(initialEditorState().cardName).toBe('My card');
|
||||
expect(DEFAULT_CARD_NAME).toBe('Meine Karte');
|
||||
i18n.setLang('de', { persist: false });
|
||||
expect(normalizeName(' ')).toBe('Meine Karte');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Konto-Menü und Login-Dialog auf Englisch', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="account-slot"></div>';
|
||||
});
|
||||
|
||||
it('das Menü zeigt „Sign in“ bzw. Konto-Menü mit englischen Texten und Fehlern', async () => {
|
||||
await freshI18n('en');
|
||||
const { mountAccountMenu } = await import('../account/account-menu');
|
||||
const auth = await import('../account/auth');
|
||||
const slot = document.getElementById('account-slot')!;
|
||||
const menu = mountAccountMenu(slot);
|
||||
menu.ready();
|
||||
expect(texts(slot)).toEqual(['Sign in']);
|
||||
mocks.acct.get.mockResolvedValue({ $id: 'u1', email: 'kenso@example.com' });
|
||||
await auth.getCurrentUser();
|
||||
expect(texts(slot)).toEqual(expect.arrayContaining(['Account menu', 'Account', 'My cards', 'Sign out']));
|
||||
expect(germanLeft(slot)).toEqual([]);
|
||||
mocks.acct.deleteSession.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
[...slot.querySelectorAll('button')].find((b) => b.textContent === 'Sign out')!.click();
|
||||
await vi.waitFor(() => expect(slot.querySelector('.account-error')!.textContent).toBe('Can’t reach the server.'));
|
||||
menu.dispose();
|
||||
});
|
||||
|
||||
it('alle drei Ansichten, der Abgleich der Passwörter (Fokus am Feld) und die Beschriftung nach dem Sprachwechsel', async () => {
|
||||
const i18n = await freshI18n('de');
|
||||
const { requireLogin } = await import('../account/login-dialog');
|
||||
const dialog = () => document.querySelector<HTMLDialogElement>('dialog.auth-dialog')!;
|
||||
const button = (text: string) => [...dialog().querySelectorAll('button')].find((b) => b.textContent === text)!;
|
||||
const input = (name: string) => dialog().querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
|
||||
const alert = () => dialog().querySelector('[role="alert"]')!.textContent;
|
||||
|
||||
// Der Dialog wird einmal gebaut: Deutsch öffnen und schließen, dann auf Englisch wieder öffnen.
|
||||
const first = requireLogin('login');
|
||||
expect(dialog().querySelector('.auth-close')!.getAttribute('aria-label')).toBe('Schließen');
|
||||
dialog().querySelector<HTMLButtonElement>('.auth-close')!.click();
|
||||
await first;
|
||||
i18n.setLang('en', { persist: false });
|
||||
void requireLogin('login');
|
||||
expect(dialog().querySelector('h2')!.textContent).toBe('Sign in');
|
||||
expect(dialog().querySelector('.auth-close')!.getAttribute('aria-label')).toBe('Close');
|
||||
expect(dialog().querySelector('[role="tablist"]')!.getAttribute('aria-label')).toBe('Account');
|
||||
expect(texts(dialog())).toEqual(expect.arrayContaining(['Sign in', 'Sign up', 'Email', 'Password', 'Forgot password?']));
|
||||
expect(germanLeft(dialog())).toEqual([]);
|
||||
|
||||
button('Sign up').click();
|
||||
expect(dialog().querySelector('h2')!.textContent).toBe('Sign up');
|
||||
expect(texts(dialog())).toEqual(expect.arrayContaining(['At least 8 characters.', 'Confirm password']));
|
||||
input('email').value = 'a@b.de';
|
||||
input('password').value = 'passwort123';
|
||||
input('password-repeat').value = 'passwort124';
|
||||
submit(dialog().querySelector('form'));
|
||||
expect(alert()).toBe('The passwords don’t match.');
|
||||
expect(document.activeElement).toBe(input('password-repeat'));
|
||||
input('password').value = 'kurz';
|
||||
input('password-repeat').value = 'kurz';
|
||||
submit(dialog().querySelector('form'));
|
||||
expect(alert()).toBe('The password must be 8 to 256 characters long.');
|
||||
expect(document.activeElement).toBe(input('password'));
|
||||
expect(germanLeft(dialog())).toEqual([]);
|
||||
|
||||
// vorhandene E-Mail: Meldung mit dem Link „Sign in“
|
||||
mocks.acct.create.mockRejectedValue(fail(409, 'user_already_exists'));
|
||||
input('password').value = 'passwort123';
|
||||
input('password-repeat').value = 'passwort123';
|
||||
submit(dialog().querySelector('form'));
|
||||
await vi.waitFor(() => expect(alert()).toBe('This email is already registered. Please sign in. Sign in'));
|
||||
|
||||
button('Sign in').click();
|
||||
button('Forgot password?').click();
|
||||
expect(dialog().querySelector('h2')!.textContent).toBe('Forgot password');
|
||||
expect(texts(dialog())).toEqual(expect.arrayContaining(['Send link', 'Back to sign in']));
|
||||
mocks.acct.createRecovery.mockResolvedValue({});
|
||||
submit(dialog().querySelector('form'));
|
||||
await vi.waitFor(() => expect(dialog().querySelector('[role="status"]')!.textContent).toBe('We’ve sent you a link. It’s valid for one hour.'));
|
||||
expect(germanLeft(dialog())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Seite „Passwort neu setzen“ auf Englisch', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<main id="app-root"></main>';
|
||||
});
|
||||
|
||||
it('ohne gültigen Link', async () => {
|
||||
await freshI18n('en');
|
||||
history.replaceState(null, '', '/passwort-neu');
|
||||
const { mountResetPage } = await import('../pages/reset-page');
|
||||
const root = document.getElementById('app-root')!;
|
||||
mountResetPage(root);
|
||||
expect(root.querySelector('h1')!.textContent).toBe('Set new password');
|
||||
expect(root.querySelector('[role="alert"]')!.textContent).toBe('The link is invalid or has expired.');
|
||||
expect(texts(root)).toContain('Request a new link');
|
||||
expect(germanLeft(root)).toEqual([]);
|
||||
});
|
||||
|
||||
it('mit Link: Formular, Abgleich der Passwörter (Fokus am Feld) und Erfolg', async () => {
|
||||
await freshI18n('en');
|
||||
history.replaceState(null, '', '/passwort-neu?userId=u1&secret=s3cret');
|
||||
const { mountResetPage } = await import('../pages/reset-page');
|
||||
const root = document.getElementById('app-root')!;
|
||||
mountResetPage(root);
|
||||
expect(texts(root)).toEqual(expect.arrayContaining(['New password', 'Confirm new password', 'At least 8 characters.', 'Save password']));
|
||||
expect(germanLeft(root)).toEqual([]);
|
||||
const input = (name: string) => root.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
|
||||
input('password').value = 'passwort123';
|
||||
input('password-repeat').value = 'passwort124';
|
||||
submit(root.querySelector('form'));
|
||||
expect(root.querySelector('[role="alert"]')!.textContent).toBe('The passwords don’t match.');
|
||||
expect(document.activeElement).toBe(input('password-repeat'));
|
||||
input('password').value = 'kurz';
|
||||
input('password-repeat').value = 'kurz';
|
||||
submit(root.querySelector('form'));
|
||||
expect(root.querySelector('[role="alert"]')!.textContent).toBe('The password must be 8 to 256 characters long.');
|
||||
expect(document.activeElement).toBe(input('password'));
|
||||
mocks.acct.updateRecovery.mockResolvedValue({});
|
||||
input('password').value = 'passwort123';
|
||||
input('password-repeat').value = 'passwort123';
|
||||
submit(root.querySelector('form'));
|
||||
await vi.waitFor(() => expect(root.querySelector('[role="status"]')!.textContent).toBe('Password changed. You can now sign in.'));
|
||||
expect(germanLeft(root)).toEqual([]);
|
||||
history.replaceState(null, '', '/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor auf Englisch', () => {
|
||||
let root: HTMLElement;
|
||||
|
||||
/** Lädt den Editor frisch in der Sprache `en`; Karten-Dienst, Anmeldung, Renderer und Logo-Auswahl sind ersetzt. */
|
||||
async function mountEnglishEditor(url = '/gestalten') {
|
||||
vi.resetModules();
|
||||
vi.doMock('../account/auth', () => ({
|
||||
getCurrentUser: mocks.getCurrentUser,
|
||||
currentUserId: () => 'u1',
|
||||
onAuthChange: () => () => {},
|
||||
onLogout: () => () => {},
|
||||
}));
|
||||
vi.doMock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin }));
|
||||
vi.doMock('../account/cards', () => ({ cardsService: () => ({ get: mocks.getCard, save: mocks.saveCard, loadImage: vi.fn() }) }));
|
||||
vi.doMock('../card/render', () => ({ drawCard: vi.fn(), loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
|
||||
vi.doMock('../community/gallery', () => ({ getGalleryCard: mocks.getGalleryCard, loadGalleryImage: vi.fn() }));
|
||||
vi.doMock('../logos/logo-picker', () => ({
|
||||
createLogoPicker: () => ({ el: document.createElement('div'), select: vi.fn(), selected: vi.fn(), dispose: vi.fn() }),
|
||||
}));
|
||||
vi.doMock('../editor/download', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../editor/download')>()),
|
||||
saveBlobAs: vi.fn(),
|
||||
}));
|
||||
vi.doMock('../editor/upload', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../editor/upload')>()),
|
||||
decodeImage: mocks.decodeImage,
|
||||
}));
|
||||
(await import('./i18n')).setLang('en', { persist: false });
|
||||
history.replaceState(null, '', url);
|
||||
document.body.innerHTML = '<main id="app-root"></main>';
|
||||
root = document.getElementById('app-root')!;
|
||||
const { mountEditor } = await import('../editor/editor-page');
|
||||
const { createStore, initialEditorState } = await import('../editor/state');
|
||||
const store = createStore(initialEditorState());
|
||||
return { store, unmount: mountEditor(root, store) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.getCurrentUser.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
mocks.requireLogin.mockResolvedValue(true);
|
||||
mocks.loadCardAssets.mockResolvedValue({ image: null, logo: null, brand: {}, logoMissing: false });
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0);
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const id of ['auth', 'login-dialog', 'cards']) vi.doUnmock(`../account/${id}`);
|
||||
vi.doUnmock('../card/render');
|
||||
vi.doUnmock('../community/gallery');
|
||||
vi.doUnmock('../logos/logo-picker');
|
||||
vi.doUnmock('../editor/download');
|
||||
vi.doUnmock('../editor/upload');
|
||||
vi.unstubAllGlobals();
|
||||
history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
const errorText = () => root.querySelector('.msg-error')!.textContent;
|
||||
const button = (text: string) => [...root.querySelectorAll('button')].find((b) => b.textContent === text)!;
|
||||
const pick = (file: File) => {
|
||||
const input = root.querySelector<HTMLInputElement>('input[type="file"]')!;
|
||||
Object.defineProperty(input, 'files', { configurable: true, value: [file] });
|
||||
input.dispatchEvent(new Event('change'));
|
||||
};
|
||||
|
||||
it('die Seite ist englisch; Dateifehler, Speichern, Download und Rückfrage ebenso', async () => {
|
||||
const { store, unmount } = await mountEnglishEditor();
|
||||
expect(store.get().cardName).toBe('My card');
|
||||
expect(germanLeft(root)).toEqual([]);
|
||||
expect(texts(root)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Template',
|
||||
'Image only',
|
||||
'Card preview',
|
||||
'Choose image',
|
||||
'Drag your idea here',
|
||||
'Save',
|
||||
'Customize',
|
||||
'Light',
|
||||
'Dark',
|
||||
'No logo',
|
||||
'Logo – AirCard-IOS · Symbol',
|
||||
]),
|
||||
);
|
||||
|
||||
// Typ und Größe prüft validateImageFile (übersetzt beim Zurückgeben); die Fehler von decodeImage tragen den deutschen Schlüssel
|
||||
pick(new File(['x'], 'a.gif', { type: 'image/gif' }));
|
||||
expect(errorText()).toBe('Please choose a PNG, JPG or WebP image.');
|
||||
const { PIXELS_MESSAGE, READ_MESSAGE } = await import('../editor/upload');
|
||||
mocks.decodeImage.mockRejectedValueOnce(new Error(PIXELS_MESSAGE));
|
||||
pick(new File(['x'], 'a.png', { type: 'image/png' }));
|
||||
await vi.waitFor(() => expect(errorText()).toBe('The image is too large (max. 40 megapixels).'));
|
||||
mocks.decodeImage.mockRejectedValueOnce(new Error(READ_MESSAGE));
|
||||
pick(new File(['x'], 'a.png', { type: 'image/png' }));
|
||||
await vi.waitFor(() => expect(errorText()).toBe('The image couldn’t be read.'));
|
||||
|
||||
// Mit Bild: Zoom in der englischen Schreibweise, Speichern scheitert, dann klappt es
|
||||
const image = { source: {} as CanvasImageSource, w: 3000, h: 2000 };
|
||||
store.set({ image, imageBlob: new Blob(['png'], { type: 'image/png' }), imageChanged: true });
|
||||
expect(root.querySelector('.zoom-value')!.textContent).toBe('100%');
|
||||
mocks.saveCard.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
button('Save').click();
|
||||
await vi.waitFor(() => expect(errorText()).toBe('Saving failed – Can’t reach the server.'));
|
||||
mocks.saveCard.mockResolvedValueOnce({ id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x', config: store.get().config });
|
||||
button('Save').click();
|
||||
await vi.waitFor(() => expect(button('Saved')).toBeDefined());
|
||||
expect(root.querySelector('.bound-label')!.textContent).toBe('Editing: Urlaub');
|
||||
|
||||
// Download: Logos nicht ladbar, dann ein PNG über der Wallet-Grenze, dann ein Export-Fehler mit deutscher Meldung aus card/render.ts
|
||||
mocks.loadCardAssets.mockRejectedValueOnce(new Error('Failed to fetch'));
|
||||
button('Download').click();
|
||||
await vi.waitFor(() => expect(errorText()).toBe('The logos couldn’t be loaded.'));
|
||||
mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: {}, logoMissing: false });
|
||||
mocks.exportCard.mockResolvedValueOnce(new Blob([new Uint8Array(4_000_100)], { type: 'image/png' }));
|
||||
button('Download').click();
|
||||
const warning = 'The PNG is larger than 4 MB – Wallet may only accept smaller images.';
|
||||
await vi.waitFor(() => expect(root.querySelector('.msg-warning')!.textContent).toBe(warning));
|
||||
mocks.exportCard.mockRejectedValueOnce(new Error('Das Bild konnte nicht erzeugt werden.'));
|
||||
button('Download').click();
|
||||
await vi.waitFor(() => expect(errorText()).toBe('The image couldn’t be created.'));
|
||||
|
||||
// „Neue Karte“ mit ungespeicherten Änderungen fragt nach
|
||||
store.set({ imageChanged: true });
|
||||
button('New card').click();
|
||||
await vi.waitFor(() => expect(document.querySelector('dialog.confirm-dialog')).not.toBeNull());
|
||||
const dialog = document.querySelector('dialog.confirm-dialog')!;
|
||||
expect(texts(dialog)).toEqual(['Discard unsaved changes?', 'Everything you’ve changed since the last save will be lost.', 'Cancel', 'Discard']);
|
||||
unmount();
|
||||
dialog.remove();
|
||||
});
|
||||
|
||||
it('Karte oder Vorlage öffnen: Ladehinweis, abgebrochene Anmeldung, fehlende Karte, fehlende Vorlage', async () => {
|
||||
let known!: (user: null) => void;
|
||||
mocks.getCurrentUser.mockReturnValue(new Promise<null>((resolve) => (known = resolve)));
|
||||
mocks.requireLogin.mockResolvedValue(false);
|
||||
let editor = await mountEnglishEditor('/gestalten?karte=abc');
|
||||
expect(root.querySelector('.msg-notice')!.textContent).toBe('Loading card …');
|
||||
known(null);
|
||||
await vi.waitFor(() => expect(root.querySelector('.msg-warning')!.textContent).toBe('Sign in to open this card.'));
|
||||
editor.unmount();
|
||||
|
||||
mocks.getCurrentUser.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
mocks.getCard.mockRejectedValue(fail(404, 'row_not_found'));
|
||||
editor = await mountEnglishEditor('/gestalten?karte=abc');
|
||||
await vi.waitFor(() => expect(errorText()).toBe('This card no longer exists or doesn’t belong to your account.'));
|
||||
editor.unmount();
|
||||
|
||||
let answer!: (card: null) => void;
|
||||
mocks.getGalleryCard.mockReturnValue(new Promise<null>((resolve) => (answer = resolve)));
|
||||
editor = await mountEnglishEditor('/gestalten?vorlage=abc');
|
||||
expect(root.querySelector('.msg-notice')!.textContent).toBe('Loading template …');
|
||||
answer(null);
|
||||
await vi.waitFor(() => expect(errorText()).toBe('This template is no longer available.'));
|
||||
editor.unmount();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user