// 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(); }); });