// Speichern und Öffnen im Editor (Karten-Dienst, Anmeldung und Renderer sind ersetzt). import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { User } from '../account/auth'; import type { SavedCard } from '../account/cards'; import { DEFAULT_CONFIG } from '../card/config'; import type { CardImage } from '../card/render'; import { createStore, initialEditorState, type EditorStore } from './state'; const mocks = vi.hoisted(() => ({ get: vi.fn(), save: vi.fn(), loadImage: vi.fn(), getCurrentUser: vi.fn(), requireLogin: vi.fn(), decodeImage: vi.fn(), loadCardAssets: vi.fn(), exportCard: vi.fn(), logoutListeners: new Set<() => void>(), })); vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); vi.mock('../account/auth', () => ({ getCurrentUser: mocks.getCurrentUser, currentUserId: () => 'u1', onAuthChange: () => () => {}, onLogout: (fn: () => void) => { mocks.logoutListeners.add(fn); return () => mocks.logoutListeners.delete(fn); }, })); vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin })); vi.mock('../account/cards', () => ({ cardsService: () => ({ get: mocks.get, save: mocks.save, loadImage: mocks.loadImage }), })); vi.mock('../card/render', () => ({ drawCard: vi.fn(), loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard, })); vi.mock('./upload', async (importOriginal) => ({ ...(await importOriginal()), decodeImage: mocks.decodeImage, })); import { mountEditor } from './editor-page'; const user: User = { $id: 'u1', email: 'a@b.de' }; const image = (w = 3000, h = 2000): CardImage => ({ source: {} as CanvasImageSource, w, h }); const png = () => new Blob(['png'], { type: 'image/png' }); const savedCard = (patch: Partial = {}): SavedCard => ({ id: 'k1', name: 'Urlaub', imageFileId: 'img1', previewFileId: 'prev1', updatedAt: '2026-10-04T12:00:00.000Z', config: { ...structuredClone(DEFAULT_CONFIG), template: 'minimal', crop: { x: 99999, y: -99999, zoom: 2 } }, ...patch, }); let root: HTMLElement; let store: EditorStore; let unmount: (() => void) | null; beforeEach(() => { for (const fn of Object.values(mocks)) if (typeof fn === 'function') fn.mockReset(); mocks.logoutListeners.clear(); vi.stubGlobal('requestAnimationFrame', () => 0); vi.stubGlobal('cancelAnimationFrame', () => {}); history.replaceState(null, '', '/'); document.body.innerHTML = '
'; root = document.getElementById('app-root') as HTMLElement; store = createStore(initialEditorState()); mocks.getCurrentUser.mockResolvedValue(user); mocks.requireLogin.mockResolvedValue(true); mocks.loadCardAssets.mockResolvedValue({ image: null, network: {}, brand: {} }); mocks.exportCard.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' })); unmount = null; }); afterEach(() => { unmount?.(); vi.useRealTimers(); vi.unstubAllGlobals(); }); const mount = () => { unmount = mountEditor(root, store); }; const saveButton = () => root.querySelector('.btn-save')!; const replaceButton = () => [...root.querySelectorAll('button')].find((b) => b.textContent === 'Bild ersetzen') as HTMLButtonElement; const fileInput = () => root.querySelector('input[type="file"]')!; const errorText = () => root.querySelector('.msg-error')!.textContent; const warningText = () => root.querySelector('.msg-warning')!.textContent; const noticeText = () => root.querySelector('.msg-notice')!.textContent; const discardDialogEl = () => document.querySelector('dialog.confirm-dialog'); /** Der Editor mit einem gewählten Bild, bereit zum Speichern. */ function withImage(blob = png(), img = image()): { blob: Blob; img: CardImage } { store.set({ image: img, imageBlob: blob, imageChanged: true }); return { blob, img }; } describe('Speichern', () => { it('speichert eine neue Karte mit Schnappschuss, Vorschau und Nutzer-Prüfung', async () => { const { blob } = withImage(); store.set({ cardName: 'Mein Urlaub', config: { ...store.get().config, network: 'jcb' } }); const result = savedCard({ id: 'neu', name: 'Mein Urlaub' }); mocks.save.mockResolvedValue(result); mount(); expect(saveButton().textContent).toBe('Speichern'); saveButton().click(); expect(saveButton().disabled).toBe(true); expect(saveButton().textContent).toBe('Speichert …'); await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert')); expect(mocks.getCurrentUser).toHaveBeenCalled(); expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ network: 'jcb' }), expect.anything(), { width: 480, type: 'image/webp', quality: 0.85, }); const input = mocks.save.mock.calls[0][0]; expect(input).toMatchObject({ name: 'Mein Urlaub', image: blob, imageChanged: true, previous: null }); expect(input.config.network).toBe('jcb'); expect(input.preview.type).toBe('image/webp'); const s = store.get(); expect(s.saved).toEqual({ id: 'neu', name: 'Mein Urlaub', imageFileId: 'img1', previewFileId: 'prev1', updatedAt: '2026-10-04T12:00:00.000Z', }); expect(s.imageChanged).toBe(false); expect(s.saving).toBe(false); expect(saveButton().disabled).toBe(false); }); it('„Gespeichert“ verschwindet nach zwei Sekunden', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); withImage(); mocks.save.mockResolvedValue(savedCard()); mount(); saveButton().click(); // vi.waitFor würde die falschen Uhren selbst vorstellen; deshalb nur die Zusagen abarbeiten. for (let i = 0; i < 50 && saveButton().textContent !== 'Gespeichert'; i += 1) await vi.advanceTimersByTimeAsync(0); expect(saveButton().textContent).toBe('Gespeichert'); await vi.advanceTimersByTimeAsync(1999); expect(saveButton().textContent).toBe('Gespeichert'); await vi.advanceTimersByTimeAsync(1); expect(saveButton().textContent).toBe('Speichern'); }); it('bestehende Karte: previous und imageChanged gehen an den Dienst, danach ist imageChanged false', async () => { withImage(); const ref = { id: 'k1', name: 'Urlaub', imageFileId: 'img0', previewFileId: 'prev0', updatedAt: '2026-10-03T12:00:00.000Z' }; store.set({ saved: ref, cardName: 'Urlaub', imageChanged: false }); mocks.save.mockResolvedValue(savedCard({ imageFileId: 'img0', previewFileId: 'prev9' })); mount(); saveButton().click(); await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert')); expect(mocks.save.mock.calls[0][0]).toMatchObject({ previous: ref, imageChanged: false }); expect(store.get().saved?.previewFileId).toBe('prev9'); }); it('ohne Bild: Meldung, kein Speichern', () => { mount(); // Der Knopf ist ohne Bild gesperrt; der Aufruf von außen (z. B. Tastatur-Enter in einem Formular) wird trotzdem abgefangen. saveButton().disabled = false; saveButton().click(); expect(errorText()).toBe('Bitte zuerst ein Bild wählen.'); expect(mocks.save).not.toHaveBeenCalled(); expect(mocks.getCurrentUser).not.toHaveBeenCalled(); }); it('Doppelklick speichert nur einmal', async () => { withImage(); let finish!: (c: SavedCard) => void; mocks.save.mockReturnValue(new Promise((r) => (finish = r))); mount(); saveButton().click(); saveButton().disabled = false; // selbst wenn der Klick den Knopf erreicht saveButton().click(); await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled()); finish(savedCard()); await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert')); expect(mocks.save).toHaveBeenCalledTimes(1); }); it('nicht angemeldet: Login-Dialog, nach dem Anmelden wird gespeichert; abgebrochen: nichts', async () => { withImage(); mocks.getCurrentUser.mockResolvedValue(null); mocks.requireLogin.mockResolvedValueOnce(false).mockResolvedValueOnce(true); mocks.save.mockResolvedValue(savedCard()); mount(); saveButton().click(); await vi.waitFor(() => expect(saveButton().textContent).toBe('Speichern')); expect(mocks.requireLogin).toHaveBeenCalledTimes(1); expect(mocks.save).not.toHaveBeenCalled(); expect(store.get().saving).toBe(false); expect(errorText()).toBe(''); saveButton().click(); await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert')); expect(mocks.save).toHaveBeenCalledTimes(1); }); it('Sitzung abgelaufen (401): einmal anmelden und genau einmal wiederholen', async () => { withImage(); mocks.save .mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 })) .mockResolvedValueOnce(savedCard()); mount(); saveButton().click(); await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert')); expect(mocks.requireLogin).toHaveBeenCalledTimes(1); expect(mocks.save).toHaveBeenCalledTimes(2); expect(errorText()).toBe(''); }); it('Fehler: Meldung mit Präfix, Editor-Zustand bleibt, erneutes Speichern geht', async () => { const { blob } = withImage(); mocks.save.mockRejectedValueOnce(new TypeError('offline')).mockResolvedValueOnce(savedCard()); mount(); saveButton().click(); await vi.waitFor(() => expect(errorText()).toBe('Speichern fehlgeschlagen – Keine Verbindung zum Server.')); expect(saveButton().disabled).toBe(false); expect(saveButton().textContent).toBe('Speichern'); expect(store.get().imageBlob).toBe(blob); expect(store.get().saved).toBeNull(); saveButton().click(); await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert')); expect(errorText()).toBe(''); }); it('während des Speicherns sind „Bild ersetzen“, die Dateiwahl und Ablegen gesperrt', async () => { const { blob, img } = withImage(); let finish!: (c: SavedCard) => void; mocks.save.mockReturnValue(new Promise((r) => (finish = r))); mount(); saveButton().click(); await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled()); expect(replaceButton().disabled).toBe(true); expect(fileInput().disabled).toBe(true); // Ein Bild, das jetzt abgelegt wird, wird ignoriert. const file = new File(['x'], 'neu.png', { type: 'image/png' }); const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown }; drop.dataTransfer = { types: ['Files'], files: [file] }; root.querySelector('.editor')!.dispatchEvent(drop); await new Promise((r) => setTimeout(r, 0)); expect(mocks.decodeImage).not.toHaveBeenCalled(); expect(store.get().imageBlob).toBe(blob); expect(store.get().image).toBe(img); finish(savedCard()); await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert')); expect(replaceButton().disabled).toBe(false); expect(fileInput().disabled).toBe(false); }); it('nach dem Speichern darf das Bild wieder ersetzt werden und die Karte bleibt „bestehend“', async () => { withImage(); mocks.save.mockResolvedValue(savedCard()); mount(); saveButton().click(); await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert')); mocks.decodeImage.mockResolvedValue(image(1000, 1000)); const file = new File(['x'], 'neu.png', { type: 'image/png' }); const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown }; drop.dataTransfer = { types: ['Files'], files: [file] }; root.querySelector('.editor')!.dispatchEvent(drop); await vi.waitFor(() => expect(store.get().imageChanged).toBe(true)); expect(store.get().saved?.id).toBe('k1'); }); it('ein Speichern, das erst fertig wird, nachdem eine andere Karte geöffnet wurde, überschreibt deren Zustand nicht', async () => { withImage(); let finish!: (c: SavedCard) => void; mocks.save.mockReturnValue(new Promise((r) => (finish = r))); mount(); saveButton().click(); await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled()); // Eine andere Karte übernimmt den Editor (z. B. Öffnen aus „Meine Karten“). const otherBlob = png(); const otherRef = { id: 'andere', name: 'Andere', imageFileId: 'i', previewFileId: 'p', updatedAt: '2026-10-04T12:00:00.000Z' }; store.set({ imageBlob: otherBlob, saved: otherRef, cardName: 'Andere', imageChanged: false }); finish(savedCard({ id: 'neu' })); await vi.waitFor(() => expect(store.get().saving).toBe(false)); expect(store.get().saved).toEqual(otherRef); expect(store.get().cardName).toBe('Andere'); }); }); describe('Karte öffnen (/gestalten?karte=)', () => { const open = (id: string) => history.replaceState(null, '', `/gestalten?karte=${id}`); it('lädt Karte und Bild, begrenzt den Ausschnitt und setzt den Zustand', async () => { open('k1'); const card = savedCard(); const img = image(3000, 2000); const blob = new Blob(['i'], { type: 'image/jpeg' }); mocks.get.mockResolvedValue(card); mocks.loadImage.mockResolvedValue(blob); mocks.decodeImage.mockResolvedValue(img); mount(); expect(noticeText()).toBe('Karte wird geladen …'); await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); expect(mocks.get).toHaveBeenCalledWith('k1'); expect(mocks.loadImage).toHaveBeenCalledWith(card); expect(mocks.decodeImage).toHaveBeenCalledWith(blob); const s = store.get(); expect(s.image).toBe(img); expect(s.imageBlob).toBe(blob); expect(s.imageChanged).toBe(false); expect(s.cardName).toBe('Urlaub'); expect(s.config.template).toBe('minimal'); expect(s.config.crop.zoom).toBe(2); expect(Math.abs(s.config.crop.x)).toBeLessThan(99999); expect(Math.abs(s.config.crop.y)).toBeLessThan(99999); expect(s.saved).toEqual({ id: 'k1', name: 'Urlaub', imageFileId: 'img1', previewFileId: 'prev1', updatedAt: '2026-10-04T12:00:00.000Z', }); expect(noticeText()).toBe(''); expect(errorText()).toBe(''); }); it('lädt nicht erneut, wenn der Editor die Karte schon hält', async () => { open('k1'); store.set({ saved: { id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' } }); mount(); await new Promise((r) => setTimeout(r, 0)); expect(mocks.get).not.toHaveBeenCalled(); }); it('ohne ?karte wird nichts geladen', async () => { mount(); await new Promise((r) => setTimeout(r, 0)); expect(mocks.get).not.toHaveBeenCalled(); }); it('fehlt die Bilddatei, öffnet die Karte ohne Bild mit Hinweis', async () => { open('k1'); mocks.get.mockResolvedValue(savedCard()); mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' })); mount(); await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); const s = store.get(); expect(s.image).toBeNull(); expect(s.imageBlob).toBeNull(); expect(s.imageChanged).toBe(false); expect(s.cardName).toBe('Urlaub'); expect(s.config.template).toBe('minimal'); expect(warningText()).toBe('Das Bild dieser Karte fehlt.'); expect(noticeText()).toBe(''); expect(mocks.decodeImage).not.toHaveBeenCalled(); }); it('Karte nicht gefunden (angemeldet): Meldung, ein leerer Editor wird zurückgesetzt', async () => { open('gibtsnicht'); store.set({ config: { ...store.get().config, template: 'minimal' }, cardName: 'Altlast' }); mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' })); mount(); await vi.waitFor(() => expect(errorText()).toBe('Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.'), ); expect(store.get()).toEqual(initialEditorState()); expect(noticeText()).toBe(''); expect(mocks.requireLogin).not.toHaveBeenCalled(); }); it('Karte nicht gefunden (angemeldet) bei gefülltem Editor: nur die Meldung, der Editor bleibt', async () => { open('gibtsnicht'); const { blob, img } = withImage(); store.set({ cardName: 'Entwurf' }); const before = store.get(); mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' })); mount(); await vi.waitFor(() => expect(errorText()).toBe('Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.'), ); expect(store.get()).toBe(before); expect(store.get().imageBlob).toBe(blob); expect(store.get().image).toBe(img); expect(store.get().cardName).toBe('Entwurf'); expect(noticeText()).toBe(''); expect(discardDialogEl()).toBeNull(); }); it('Karte nicht gefunden bei gefülltem Editor mit gespeicherter Karte: auch hier bleibt alles', async () => { open('gibtsnicht'); withImage(); store.set({ saved: { id: 'k9', name: 'Neun', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' }, imageChanged: false, }); const before = store.get(); mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' })); mount(); await vi.waitFor(() => expect(errorText()).not.toBe('')); expect(store.get()).toBe(before); }); describe('ohne Sitzung (Appwrite antwortet Gästen mit 404, nicht 401)', () => { it('Gast: zuerst anmelden, dann erst laden; danach wird die Karte geöffnet', async () => { open('k1'); const order: string[] = []; mocks.getCurrentUser.mockImplementation(async () => { order.push('user'); return null; }); mocks.requireLogin.mockImplementation(async () => { order.push('login'); return true; }); mocks.get.mockImplementation(async () => { order.push('get'); return savedCard(); }); mocks.loadImage.mockResolvedValue(png()); mocks.decodeImage.mockResolvedValue(image()); mount(); await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); expect(order).toEqual(['user', 'login', 'get']); expect(mocks.requireLogin).toHaveBeenCalledWith('login'); expect(errorText()).toBe(''); }); it('Gast bricht den Dialog ab: Hinweis, nichts wird geladen, der Editor bleibt unberührt', async () => { open('k1'); const { blob } = withImage(); const before = store.get(); mocks.getCurrentUser.mockResolvedValue(null); mocks.requireLogin.mockResolvedValue(false); mount(); await vi.waitFor(() => expect(warningText()).toBe('Melde dich an, um diese Karte zu öffnen.')); expect(mocks.get).not.toHaveBeenCalled(); expect(store.get()).toBe(before); expect(store.get().imageBlob).toBe(blob); expect(noticeText()).toBe(''); expect(errorText()).toBe(''); }); it('Sitzung läuft mitten im Laden ab (404 row_not_found, Nutzerprüfung ergibt null): anmelden und einmal wiederholen', async () => { open('k1'); mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null); mocks.get .mockRejectedValueOnce(Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' })) .mockResolvedValueOnce(savedCard()); mocks.loadImage.mockResolvedValue(png()); mocks.decodeImage.mockResolvedValue(image()); mount(); await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); expect(mocks.requireLogin).toHaveBeenCalledTimes(1); expect(mocks.get).toHaveBeenCalledTimes(2); }); it('Bilddatei 404 ohne Sitzung: anmelden, das Bild wird danach geladen', async () => { open('k1'); mocks.get.mockResolvedValue(savedCard()); mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null); mocks.loadImage .mockRejectedValueOnce(Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' })) .mockResolvedValueOnce(png()); mocks.decodeImage.mockResolvedValue(image()); mount(); await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); expect(mocks.requireLogin).toHaveBeenCalledTimes(1); expect(store.get().image).not.toBeNull(); expect(warningText()).toBe(''); }); it('angemeldet und Bilddatei wirklich weg: die Karte öffnet ohne Bild, kein Dialog', async () => { open('k1'); mocks.get.mockResolvedValue(savedCard()); mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' })); mount(); await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); expect(mocks.requireLogin).not.toHaveBeenCalled(); expect(warningText()).toBe('Das Bild dieser Karte fehlt.'); }); }); describe('ungespeicherte Änderungen im Editor', () => { const dialogButton = (label: string) => [...discardDialogEl()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement; const loadable = () => { mocks.get.mockResolvedValue(savedCard()); mocks.loadImage.mockResolvedValue(png()); mocks.decodeImage.mockResolvedValue(image(1200, 800)); }; it('ein neues, nie gespeichertes Bild: Rückfrage; „Verwerfen“ übernimmt die geöffnete Karte', async () => { open('k1'); loadable(); withImage(); mount(); await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true)); expect(discardDialogEl()!.querySelector('h2')!.textContent).toBe('Ungespeicherte Änderungen verwerfen?'); // Solange gefragt wird, bleibt der Entwurf. expect(store.get().saved).toBeNull(); expect(store.get().imageChanged).toBe(true); dialogButton('Verwerfen').click(); await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); expect(store.get().imageChanged).toBe(false); expect(store.get().cardName).toBe('Urlaub'); expect(discardDialogEl()).toBeNull(); expect(noticeText()).toBe(''); }); it('„Abbrechen“: Entwurf bleibt, ?karte= verschwindet aus der Adresse, die Karte wird nicht übernommen', async () => { open('k1'); loadable(); const { blob, img } = withImage(); const before = store.get(); mount(); await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true)); dialogButton('Abbrechen').click(); await vi.waitFor(() => expect(discardDialogEl()).toBeNull()); await vi.waitFor(() => expect(noticeText()).toBe('')); expect(store.get()).toBe(before); expect(store.get().imageBlob).toBe(blob); expect(store.get().image).toBe(img); expect(location.pathname + location.search).toBe('/gestalten'); }); it('Esc im Dialog verwirft nichts', async () => { open('k1'); loadable(); withImage(); const before = store.get(); mount(); await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true)); discardDialogEl()!.dispatchEvent(new Event('close')); await vi.waitFor(() => expect(discardDialogEl()).toBeNull()); await vi.waitFor(() => expect(location.search).toBe('')); expect(store.get()).toBe(before); }); it('geänderte Konfiguration einer anderen gespeicherten Karte: ebenfalls Rückfrage', async () => { open('k1'); loadable(); withImage(); const config = store.get().config; store.set({ saved: { id: 'k7', name: 'Sieben', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' }, savedConfig: config, imageChanged: false, config: { ...config, template: 'minimal' }, }); mount(); await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true)); dialogButton('Verwerfen').click(); await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); }); it('eine andere gespeicherte Karte ohne Änderungen wird ohne Rückfrage ersetzt', async () => { open('k1'); loadable(); withImage(); const config = store.get().config; store.set({ saved: { id: 'k7', name: 'Sieben', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' }, savedConfig: config, imageChanged: false, }); mount(); await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); expect(discardDialogEl()).toBeNull(); }); it('ein leerer Editor wird ohne Rückfrage befüllt', async () => { open('k1'); loadable(); mount(); await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); expect(discardDialogEl()).toBeNull(); }); it('verlässt der Nutzer die Seite, während gefragt wird, schließt der Dialog und nichts wird übernommen', async () => { open('k1'); loadable(); withImage(); const before = store.get(); mount(); await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true)); unmount?.(); unmount = null; await vi.waitFor(() => expect(discardDialogEl()).toBeNull()); await new Promise((r) => setTimeout(r, 0)); expect(store.get()).toBe(before); }); }); it('anderer Fehler (Netz): Meldung, Editor unverändert', async () => { open('k1'); mocks.get.mockRejectedValue(new TypeError('offline')); mount(); await vi.waitFor(() => expect(errorText()).toBe('Keine Verbindung zum Server.')); expect(store.get().saved).toBeNull(); }); it('ein Bild, das sich nicht dekodieren lässt, meldet das, ohne die Karte zu übernehmen', async () => { open('k1'); mocks.get.mockResolvedValue(savedCard()); mocks.loadImage.mockResolvedValue(png()); mocks.decodeImage.mockRejectedValue(new Error('Das Bild konnte nicht gelesen werden.')); mount(); await vi.waitFor(() => expect(errorText()).toBe('Das Bild konnte nicht gelesen werden.')); expect(store.get().saved).toBeNull(); }); it('ein Bild über 40 Megapixel meldet die Größen-Meldung, ohne die Karte zu übernehmen', async () => { open('k1'); mocks.get.mockResolvedValue(savedCard()); mocks.loadImage.mockResolvedValue(png()); mocks.decodeImage.mockRejectedValue(new Error('Das Bild ist zu groß (max. 40 Megapixel).')); mount(); await vi.waitFor(() => expect(errorText()).toBe('Das Bild ist zu groß (max. 40 Megapixel).')); expect(store.get().saved).toBeNull(); }); it('verlässt der Nutzer die Seite während des Ladens, wird nichts mehr übernommen', async () => { open('k1'); let finish!: (c: SavedCard) => void; mocks.get.mockReturnValue(new Promise((r) => (finish = r))); mount(); unmount?.(); unmount = null; finish(savedCard()); await new Promise((r) => setTimeout(r, 0)); expect(mocks.loadImage).not.toHaveBeenCalled(); expect(store.get().saved).toBeNull(); }); it('wählt der Nutzer während des Ladens selbst ein Bild, gewinnt dieses', async () => { open('k1'); let finish!: (c: SavedCard) => void; mocks.get.mockReturnValue(new Promise((r) => (finish = r))); mocks.decodeImage.mockResolvedValue(image(500, 500)); mount(); const file = new File(['x'], 'neu.png', { type: 'image/png' }); const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown }; drop.dataTransfer = { types: ['Files'], files: [file] }; root.querySelector('.editor')!.dispatchEvent(drop); await vi.waitFor(() => expect(store.get().image?.w).toBe(500)); finish(savedCard()); await new Promise((r) => setTimeout(r, 0)); expect(mocks.loadImage).not.toHaveBeenCalled(); expect(store.get().saved).toBeNull(); expect(noticeText()).toBe(''); }); it('eine unbrauchbare Datei bricht das Laden der Karte nicht ab', async () => { open('k1'); mocks.get.mockResolvedValue(savedCard()); mocks.loadImage.mockResolvedValue(png()); mocks.decodeImage.mockResolvedValue(image()); mount(); const file = new File(['x'], 'neu.gif', { type: 'image/gif' }); const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown }; drop.dataTransfer = { types: ['Files'], files: [file] }; root.querySelector('.editor')!.dispatchEvent(drop); await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); }); }); describe('Bindung an eine gespeicherte Karte', () => { const ref = (patch: Record = {}) => ({ id: 'k1', name: 'Urlaub', imageFileId: 'img1', previewFileId: 'prev1', updatedAt: '2026-10-04T12:00:00.000Z', ...patch, }); const boundRow = () => root.querySelector('.bound-row')!; const boundLabel = () => root.querySelector('.bound-label')!; const newCardButton = () => [...root.querySelectorAll('button')].find((b) => b.textContent === 'Neue Karte') as HTMLButtonElement; const discardDialog = () => document.querySelector('dialog.confirm-dialog'); const dialogButton = (label: string) => [...discardDialog()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement; /** Ein Editor, der eine gespeicherte Karte hält (nichts Ungespeichertes). */ function boundEditor(patch: Partial> = {}) { const { blob, img } = withImage(); const config = store.get().config; store.set({ saved: ref(), savedConfig: config, cardName: 'Urlaub', imageChanged: false, ...patch }); return { blob, img, config }; } describe('Anzeige „Bearbeitet: “', () => { it('ist ohne gespeicherte Karte verborgen', () => { withImage(); mount(); expect(boundRow().hidden).toBe(true); expect(boundLabel().textContent).toBe(''); }); it('zeigt den Namen der Karte, solange `saved` gesetzt ist', () => { boundEditor(); mount(); expect(boundRow().hidden).toBe(false); expect(boundLabel().textContent).toBe('Bearbeitet: Urlaub'); expect(newCardButton().hidden).toBe(false); }); it('erscheint nach dem ersten Speichern und folgt einer Umbenennung', async () => { withImage(); mocks.save.mockResolvedValue(savedCard({ id: 'neu', name: 'Meine Karte' })); mount(); saveButton().click(); await vi.waitFor(() => expect(boundLabel().textContent).toBe('Bearbeitet: Meine Karte')); store.set({ saved: ref({ id: 'neu', name: 'Umbenannt' }) }); expect(boundLabel().textContent).toBe('Bearbeitet: Umbenannt'); }); it('zeigt den Namen nur als Text, nie als HTML', () => { boundEditor({ saved: ref({ name: 'fett' }) }); mount(); expect(boundLabel().textContent).toBe('Bearbeitet: fett'); expect(boundLabel().children).toHaveLength(0); }); it('ist nach dem Öffnen einer Karte da', async () => { history.replaceState(null, '', '/gestalten?karte=k1'); mocks.get.mockResolvedValue(savedCard()); mocks.loadImage.mockResolvedValue(png()); mocks.decodeImage.mockResolvedValue(image()); mount(); expect(boundRow().hidden).toBe(true); await vi.waitFor(() => expect(boundRow().hidden).toBe(false)); expect(boundLabel().textContent).toBe('Bearbeitet: Urlaub'); }); it('„Neue Karte“ ist während des Speicherns gesperrt', async () => { boundEditor(); let finish!: (c: SavedCard) => void; mocks.save.mockReturnValue(new Promise((r) => (finish = r))); mount(); saveButton().click(); await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled()); expect(newCardButton().disabled).toBe(true); finish(savedCard()); await vi.waitFor(() => expect(newCardButton().disabled).toBe(false)); }); }); describe('„Neue Karte“', () => { it('setzt den Editor zurück (frische Objekte) und entfernt ?karte= aus der Adresse', () => { history.replaceState(null, '', '/gestalten?karte=k1'); const { config } = boundEditor(); mount(); newCardButton().click(); expect(discardDialog()).toBeNull(); // nichts Ungespeichertes: keine Rückfrage const s = store.get(); expect(s).toEqual(initialEditorState()); expect(s.saved).toBeNull(); expect(s.savedConfig).toBeNull(); expect(s.image).toBeNull(); expect(s.imageBlob).toBeNull(); expect(s.config).toEqual(DEFAULT_CONFIG); expect(s.config).not.toBe(DEFAULT_CONFIG); expect(s.config.crop).not.toBe(DEFAULT_CONFIG.crop); expect(s.config).not.toBe(config); expect(location.pathname + location.search).toBe('/gestalten'); expect(boundRow().hidden).toBe(true); }); it('lässt die Adresse unberührt, wenn sie keine Karte nennt', () => { boundEditor(); mount(); newCardButton().click(); expect(location.pathname + location.search).toBe('/'); expect(store.get().saved).toBeNull(); }); it('bricht eine noch ladende Karte ab', async () => { history.replaceState(null, '', '/gestalten?karte=k2'); boundEditor(); let finish!: (c: SavedCard) => void; mocks.get.mockReturnValue(new Promise((r) => (finish = r))); mount(); await vi.waitFor(() => expect(mocks.get).toHaveBeenCalled()); newCardButton().click(); expect(store.get().saved).toBeNull(); finish(savedCard({ id: 'k2' })); await new Promise((r) => setTimeout(r, 0)); expect(mocks.loadImage).not.toHaveBeenCalled(); expect(store.get().saved).toBeNull(); expect(noticeText()).toBe(''); }); it('verwirft kein noch dekodiertes Bild in den leeren Editor', async () => { boundEditor(); let decoded!: (i: CardImage) => void; mocks.decodeImage.mockReturnValue(new Promise((r) => (decoded = r))); mount(); const file = new File(['x'], 'neu.png', { type: 'image/png' }); const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown }; drop.dataTransfer = { types: ['Files'], files: [file] }; root.querySelector('.editor')!.dispatchEvent(drop); await vi.waitFor(() => expect(mocks.decodeImage).toHaveBeenCalled()); // imageChanged ist noch false (das Bild ist nicht fertig), also keine Rückfrage newCardButton().click(); decoded(image()); await new Promise((r) => setTimeout(r, 0)); expect(store.get().image).toBeNull(); }); it('fragt bei einem ersetzten Bild nach; „Abbrechen“ ändert nichts', async () => { const { blob } = boundEditor(); store.set({ imageChanged: true }); mount(); newCardButton().click(); await vi.waitFor(() => expect(discardDialog()?.open).toBe(true)); expect(discardDialog()!.querySelector('h2')!.textContent).toBe('Ungespeicherte Änderungen verwerfen?'); dialogButton('Abbrechen').click(); await vi.waitFor(() => expect(discardDialog()).toBeNull()); expect(store.get().saved?.id).toBe('k1'); expect(store.get().imageBlob).toBe(blob); await vi.waitFor(() => expect(newCardButton().disabled).toBe(false)); }); it('fragt bei geänderter Konfiguration nach; „Verwerfen“ setzt zurück', async () => { boundEditor(); store.set({ config: { ...store.get().config, template: 'minimal' } }); mount(); newCardButton().click(); await vi.waitFor(() => expect(discardDialog()?.open).toBe(true)); dialogButton('Verwerfen').click(); await vi.waitFor(() => expect(store.get().saved).toBeNull()); expect(store.get()).toEqual(initialEditorState()); expect(discardDialog()).toBeNull(); }); it('Esc im Rückfrage-Dialog verwirft nichts', async () => { boundEditor(); store.set({ imageChanged: true }); mount(); newCardButton().click(); await vi.waitFor(() => expect(discardDialog()?.open).toBe(true)); discardDialog()!.dispatchEvent(new Event('close')); await vi.waitFor(() => expect(discardDialog()).toBeNull()); expect(store.get().saved?.id).toBe('k1'); }); it('fragt nach dem Speichern nicht mehr (der gespeicherte Stand ist der aktuelle)', async () => { withImage(); mocks.save.mockResolvedValue(savedCard({ id: 'neu' })); mount(); saveButton().click(); await vi.waitFor(() => expect(boundRow().hidden).toBe(false)); await vi.waitFor(() => expect(newCardButton().disabled).toBe(false)); newCardButton().click(); expect(discardDialog()).toBeNull(); expect(store.get().saved).toBeNull(); }); it('fragt nach dem Öffnen einer Karte nicht, solange nichts geändert wurde', async () => { history.replaceState(null, '', '/gestalten?karte=k1'); mocks.get.mockResolvedValue(savedCard()); mocks.loadImage.mockResolvedValue(png()); mocks.decodeImage.mockResolvedValue(image(3000, 2000)); mount(); await vi.waitFor(() => expect(boundRow().hidden).toBe(false)); newCardButton().click(); expect(discardDialog()).toBeNull(); expect(store.get().saved).toBeNull(); }); it('nach „Neue Karte“ legt Speichern eine neue Karte an (createRow, kein updateRow)', async () => { const actual = await vi.importActual('../account/cards'); const calls: string[] = []; const api = { createFile: vi.fn(async (file: File) => { calls.push(`createFile ${file.name}`); return { $id: `f${calls.length}` }; }), deleteFile: vi.fn(async (id: string) => { calls.push(`deleteFile ${id}`); }), fetchFile: vi.fn(), createRow: vi.fn(async (data: Record) => { calls.push('createRow'); return { $id: 'row-neu', $updatedAt: '2026-10-05T10:00:00.000Z', ...data }; }), updateRow: vi.fn(async () => { calls.push('updateRow'); throw new Error('updateRow darf nicht aufgerufen werden'); }), getRow: vi.fn(), deleteRow: vi.fn(), listRows: vi.fn(), }; const service = actual.createCardsService(api as never, () => 'u1'); mocks.save.mockImplementation((input) => service.save(input)); // Karte 1 ist gespeichert und im Editor; der Nutzer beginnt eine neue Karte und legt ein anderes Foto ab. boundEditor(); mount(); newCardButton().click(); mocks.decodeImage.mockResolvedValue(image(1000, 1000)); const file = new File(['zweites foto'], 'zwei.png', { type: 'image/png' }); const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown }; drop.dataTransfer = { types: ['Files'], files: [file] }; root.querySelector('.editor')!.dispatchEvent(drop); await vi.waitFor(() => expect(store.get().image?.w).toBe(1000)); saveButton().click(); await vi.waitFor(() => expect(store.get().saved?.id).toBe('row-neu')); expect(calls).toEqual(['createFile image.png', 'createFile preview.webp', 'createRow']); expect(api.updateRow).not.toHaveBeenCalled(); expect(api.deleteFile).not.toHaveBeenCalled(); // das Original von Karte 1 bleibt unangetastet }); }); describe('Abmelden während des Ladens', () => { it('eine noch ladende Karte landet nicht mehr im Editor', async () => { history.replaceState(null, '', '/gestalten?karte=k1'); let finish!: (c: SavedCard) => void; mocks.get.mockReturnValue(new Promise((r) => (finish = r))); mount(); await vi.waitFor(() => expect(mocks.get).toHaveBeenCalled()); for (const fn of mocks.logoutListeners) fn(); finish(savedCard()); await new Promise((r) => setTimeout(r, 0)); expect(mocks.loadImage).not.toHaveBeenCalled(); expect(store.get().saved).toBeNull(); expect(noticeText()).toBe(''); }); it('der Editor meldet sich beim Verlassen der Seite vom Abmelde-Signal ab', () => { mount(); expect(mocks.logoutListeners.size).toBe(1); unmount?.(); unmount = null; expect(mocks.logoutListeners.size).toBe(0); }); }); }); describe('Speichern: Schnappschuss beim Klick', () => { it('die Wiederholung nach einem 401 speichert genau das, was angeklickt wurde', async () => { withImage(); store.set({ cardName: 'Beim Klick', config: { ...store.get().config, template: 'classic' } }); mocks.save .mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 })) .mockResolvedValueOnce(savedCard()); // Während der Login-Dialog offen ist, ändert der Nutzer (hinter dem Dialog) den Editor. mocks.requireLogin.mockImplementation(async () => { store.set({ cardName: 'Danach', config: { ...store.get().config, template: 'image' } }); return true; }); mount(); saveButton().click(); await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert')); expect(mocks.save).toHaveBeenCalledTimes(2); for (const [input] of mocks.save.mock.calls) { expect(input.name).toBe('Beim Klick'); expect(input.config.template).toBe('classic'); } expect(mocks.exportCard.mock.calls[1][0].template).toBe('classic'); }); });