Files
AirCardIOS/web/src/i18n/english-ui.test.ts
T
ClaudeandClaude Opus 5.5 7e66cd0f0f feat(i18n): Community, Startseite und Meine Karten auf Englisch
Task 11: alle sichtbaren Texte von community/, pages/home-page.ts, pages/community-page.ts, pages/cards-page.ts und pages/format.ts
(Texte, aria-label, title, Platzhalter, Alt-Texte, Rückfragen, Meldungen) gehen durch t()/msg(); en.ts bekommt 82 Einträge (214 → 296).

- Konstanten bleiben deutsche Strings per msg() und gehen beim Anzeigen durch t() (publish.ts, publish-dialog.ts, gallery.ts und die Seiten);
  geworfene Fehler tragen weiter den deutschen Text, damit die Vergleiche gehen. Funktionen, die Text für Nutzer liefern, übersetzen beim
  Zurückgeben (validatePublishForm, publishFailureText, formatDate, reportsLabel). Template-Literale sind Platzhalter geworden ({max},
  {reason}, {name}, {title}, {query}, {count}); der deutsche Text bleibt Zeichen für Zeichen derselbe.
- API-Fehler: jeder ApiError wird mit apiErrorText() angezeigt (Veröffentlichen-Dialog, Detailseite, Meine Karten), nach dem Code des Dienstes
  statt nach dem deutschen Servertext. Logik, die err.message vergleicht (publishCard, 404 beim Zurückziehen), bleibt unverändert.
- Bekannte Reste aus Task 10 behoben: LOGIN_NEEDED ging in publish-dialog.ts, community-page.ts und cards-page.ts ohne t() an die Oberfläche;
  die Meldungen zu Bild lesen/Pixelgrenze/fehlendem Logo (cards-page.ts, GERMAN_MESSAGES in publish-dialog.ts) erscheinen jetzt übersetzt;
  die Zahl der Meldungen in der Logo-Infozeile („3 reports“).
- pages/format.ts: formatDate liefert Deutsch dd.mm.yyyy und Englisch „Oct 4, 2026“ (Intl.DateTimeFormat en-US), reportsLabel „1 report“ /
  „3 reports“. „Herausgeber“ heißt als Bank „Issuer / bank“.
- logos/prepare-logo.ts: der Helfer problem() ist weg, die sechs Stellen schreiben new LogoError(t(MELDUNG)) selbst (gleiches Verhalten);
  so sieht der neue Prüfer unten jede Verwendung einer Konstante.
- i18n/coverage.test.ts: Die Heuristik für deutsche Literale deckt jetzt die ganze Oberfläche ab (account/, card/, community/, editor/, logos/,
  pages/, shell/, main.ts, i18n/api-messages.ts). Sie erkennt zusätzlich Wörter mit Klammer („Ausgeblendet (gemeldet)“) und nimmt rel und
  Namen in Klammerschreibweise (headers['X-Aircard-Jwt']) aus. Acht begründete Ausnahmen kommen dazu (zwei AppwriteException-Meldungen,
  vier interne Logo-Fehler, der Sprachknopf in der Zielsprache). Neu: ein Prüfer über den Syntaxbaum, der verlangt, dass jede Konstante
  mit msg() nur in t(), new Error(), Vergleichen, case und Listen/Mengen vorkommt (auch über Importe und Umbenennungen); ohne Ausnahmen.
- Tests: format.test.ts (beide Sprachen), Englisch-Blöcke in publish-dialog, gallery-grid, home-page, community-page, cards-page und
  logo-info (Texte, Fehlerwege, „Publishing failed – This card is already published.“, kein deutscher Wörterbuchtext auf der Seite). Die
  Hilfen texts()/germanLeft() liegen jetzt in i18n/english-check.ts (vorher in english-ui.test.ts). Der bestehende Englisch-Test in
  cards-page.test.ts sucht den Knopf „Download“ statt „Herunterladen“.
- Vitest 53 Dateien, 1812 Tests grün (vorher 1779), tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:34:10 +00:00

347 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { germanLeft, texts } from './english-check';
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;
}
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();
});
});