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:
ClaudeandClaude Opus 5.5 committed 2026-10-04 23:58:49 +00:00
1 parent a75be2d44c
commit 1ff1873b27
23 files changed
+857 -169

No files matched your search

+184
View File
@@ -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']);
});
});
+104
View File
@@ -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.',
+360
View File
@@ -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();
});
});