// 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 = '
';
});
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('dialog.auth-dialog')!;
const button = (text: string) => [...dialog().querySelectorAll('button')].find((b) => b.textContent === text)!;
const input = (name: string) => dialog().querySelector(`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('.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 = '';
});
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(`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()),
saveBlobAs: vi.fn(),
}));
vi.doMock('../editor/upload', async (importOriginal) => ({
...(await importOriginal()),
decodeImage: mocks.decodeImage,
}));
(await import('./i18n')).setLang('en', { persist: false });
history.replaceState(null, '', url);
document.body.innerHTML = '';
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('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((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((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();
});
});