import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { User } from '../account/auth'; import type { SavedCard } from '../account/cards'; import { DEFAULT_CONFIG, type TemplateId } from '../card/config'; import { createStore, initialEditorState } from '../editor/state'; import type { GalleryCard } from '../community/gallery'; import { germanLeft } from '../i18n/english-check'; import { setLang } from '../i18n/i18n'; const mocks = vi.hoisted(() => ({ list: vi.fn(), loadPreview: vi.fn(), loadImage: vi.fn(), rename: vi.fn(), remove: vi.fn(), getCurrentUser: vi.fn(), requireLogin: vi.fn(), navigate: vi.fn(), decodeImage: vi.fn(), loadCardAssets: vi.fn(), exportCard: vi.fn(), saveBlobAs: vi.fn(), publishSavedCard: vi.fn(), withdrawCard: vi.fn(), myPublications: vi.fn(), getLogo: vi.fn(), loadLogoBlob: vi.fn(), authListeners: new Set<(user: User | null) => void>(), })); vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); vi.mock('../account/auth', () => ({ getCurrentUser: mocks.getCurrentUser, currentUserId: () => 'u1', onAuthChange: (fn: (user: User | null) => void) => { mocks.authListeners.add(fn); return () => mocks.authListeners.delete(fn); }, })); vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin })); vi.mock('./router', () => ({ navigate: mocks.navigate })); vi.mock('../editor/upload', () => ({ READ_MESSAGE: 'Das Bild konnte nicht gelesen werden.', PIXELS_MESSAGE: 'Das Bild ist zu groß (max. 40 Megapixel).', decodeImage: mocks.decodeImage, })); vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard })); vi.mock('../editor/download', async (importOriginal) => ({ ...(await importOriginal()), saveBlobAs: mocks.saveBlobAs, })); vi.mock('../account/cards', async (importOriginal) => ({ ...(await importOriginal()), cardsService: () => ({ list: mocks.list, loadPreview: mocks.loadPreview, loadImage: mocks.loadImage, rename: mocks.rename, remove: mocks.remove, }), })); vi.mock('../community/publish', async (importOriginal) => ({ ...(await importOriginal()), publishSavedCard: mocks.publishSavedCard, })); vi.mock('../community/api', async (importOriginal) => ({ ...(await importOriginal()), withdrawCard: mocks.withdrawCard, })); vi.mock('../community/gallery', () => ({ myPublications: mocks.myPublications })); // Zeile und Datei eines Community-Logos kommen aus der Datenschicht; nur der Zwischenspeicher darüber (logo-asset.ts) ist echt. vi.mock('../logos/logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob })); import { ApiError } from '../community/api'; import { LOGO_MISSING_PUBLISH } from '../community/publish'; import { forgetLogo, loadLogoAsset } from '../logos/logo-asset'; import type { CommunityLogo } from '../logos/logos'; import { mountCardsPage } from './cards-page'; const user: User = { $id: 'u1', email: 'a@b.de' }; const card = (id: string, name: string, patch: Partial = {}): SavedCard => ({ id, name, imageFileId: `img-${id}`, previewFileId: `prev-${id}`, updatedAt: '2026-10-04T12:00:00.000Z', config: structuredClone(DEFAULT_CONFIG), ...patch, }); let root: HTMLElement; let unmount: (() => void) | null; let store: ReturnType; let urlCounter: number; const revoked: string[] = []; beforeEach(() => { for (const fn of Object.values(mocks)) if (typeof fn === 'function' && 'mockReset' in fn) fn.mockReset(); mocks.authListeners.clear(); revoked.length = 0; urlCounter = 0; URL.createObjectURL = vi.fn(() => `blob:preview-${++urlCounter}`); URL.revokeObjectURL = vi.fn((url: string) => { revoked.push(url); }); document.body.innerHTML = '
'; root = document.getElementById('app-root') as HTMLElement; store = createStore(initialEditorState()); mocks.getCurrentUser.mockResolvedValue(user); mocks.requireLogin.mockResolvedValue(true); mocks.myPublications.mockResolvedValue(new Map()); mocks.loadPreview.mockImplementation(async () => new Blob(['p'], { type: 'image/webp' })); unmount = null; }); afterEach(() => { unmount?.(); }); const mount = () => { unmount = mountCardsPage(root, store); }; const text = () => root.textContent ?? ''; const tiles = () => [...root.querySelectorAll('.card-tile')]; const tileFor = (id: string) => tiles().find((t) => t.dataset.card === id)!; const button = (scope: ParentNode, label: string) => [...scope.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement; const dialog = () => document.querySelector('dialog.confirm-dialog'); const submit = (form: HTMLFormElement) => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); describe('Meine Karten: Zustände', () => { it('abgemeldet: Hinweis und Anmelden-Knopf, keine Anfrage an den Server', async () => { mocks.getCurrentUser.mockResolvedValue(null); mount(); await vi.waitFor(() => expect(text()).toContain('Melde dich an, um deine Karten zu sehen.')); expect(mocks.list).not.toHaveBeenCalled(); button(root, 'Anmelden').click(); expect(mocks.requireLogin).toHaveBeenCalledWith('login'); }); it('nach dem Anmelden (Konto-Menü oder Knopf) wird die Liste geladen', async () => { mocks.getCurrentUser.mockResolvedValue(null); mocks.list.mockResolvedValue([card('a', 'Alpha')]); mount(); await vi.waitFor(() => expect(text()).toContain('Melde dich an')); mocks.getCurrentUser.mockResolvedValue(user); for (const fn of mocks.authListeners) fn(user); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); }); it('leer: Hinweis und Link zum Editor', async () => { mocks.list.mockResolvedValue([]); mount(); await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.')); const link = root.querySelector('a[data-link]')!; expect(link.getAttribute('href')).toBe('/gestalten'); }); it('Fehler beim Laden: Meldung und „Nochmal versuchen“', async () => { mocks.list.mockRejectedValueOnce(new TypeError('offline')).mockResolvedValueOnce([card('a', 'Alpha')]); mount(); await vi.waitFor(() => expect(text()).toContain('Keine Verbindung zum Server.')); button(root, 'Nochmal versuchen').click(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); }); it('Sitzung läuft beim Laden ab: Anmelden, dann wird die Liste angezeigt', async () => { const unauthorized = Object.assign(new Error('Unauthorized'), { code: 401 }); mocks.list.mockRejectedValueOnce(unauthorized).mockResolvedValueOnce([card('a', 'Alpha')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); expect(mocks.requireLogin).toHaveBeenCalledOnce(); }); it('Sitzung läuft ab und der Dialog wird geschlossen: Anmelden-Ansicht', async () => { mocks.list.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 })); mocks.requireLogin.mockResolvedValue(false); mount(); await vi.waitFor(() => expect(text()).toContain('Melde dich an, um deine Karten zu sehen.')); }); it('Abmelden räumt die Liste weg und gibt die Vorschau-URLs frei', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mount(); await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull()); for (const fn of mocks.authListeners) fn(null); expect(text()).toContain('Melde dich an, um deine Karten zu sehen.'); expect(tiles()).toHaveLength(0); expect(revoked).toEqual(['blob:preview-1']); }); }); describe('Meine Karten: Kacheln', () => { it('zeigt Name, Datum und Vorschau', async () => { const updatedAt = new Date(2026, 9, 4, 12).toISOString(); mocks.list.mockResolvedValue([card('a', 'Alpha', { updatedAt }), card('b', 'Beta')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); expect(tileFor('a').querySelector('.card-name')!.textContent).toBe('Alpha'); expect(tileFor('a').querySelector('.card-date')!.textContent).toBe('04.10.2026'); await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull()); await vi.waitFor(() => expect(tileFor('b').querySelector('img')).not.toBeNull()); expect(tileFor('a').querySelector('img')!.getAttribute('src')).toMatch(/^blob:preview-/); expect(mocks.loadPreview).toHaveBeenCalledTimes(2); }); it('eine Vorschau, die nicht lädt, zeigt „Keine Vorschau“, die Kachel bleibt bedienbar', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.loadPreview.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' })); mount(); await vi.waitFor(() => expect(tileFor('a').textContent).toContain('Keine Vorschau')); expect(button(tileFor('a'), 'Öffnen').disabled).toBe(false); }); it('lädt höchstens vier Vorschauen gleichzeitig', async () => { const cards = Array.from({ length: 10 }, (_, i) => card(`c${i}`, `Karte ${i}`)); mocks.list.mockResolvedValue(cards); let active = 0; let peak = 0; mocks.loadPreview.mockImplementation(async () => { active += 1; peak = Math.max(peak, active); await new Promise((r) => setTimeout(r, 5)); active -= 1; return new Blob(['p'], { type: 'image/webp' }); }); mount(); await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(10)); expect(peak).toBeLessThanOrEqual(4); expect(peak).toBeGreaterThan(1); }); it('beim Verlassen der Seite werden alle Vorschau-URLs freigegeben', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mount(); await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(2)); unmount?.(); unmount = null; expect([...revoked].sort()).toEqual(['blob:preview-1', 'blob:preview-2']); }); it('eine Vorschau, die erst nach dem Verlassen ankommt, bekommt keine URL', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); let finish!: (b: Blob) => void; mocks.loadPreview.mockReturnValue(new Promise((r) => (finish = r))); mount(); await vi.waitFor(() => expect(mocks.loadPreview).toHaveBeenCalled()); unmount?.(); unmount = null; finish(new Blob(['p'], { type: 'image/webp' })); await new Promise((r) => setTimeout(r, 0)); expect(URL.createObjectURL).not.toHaveBeenCalled(); }); }); describe('Meine Karten: Öffnen und Herunterladen', () => { it('Öffnen navigiert in den Editor', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Öffnen').click(); expect(mocks.navigate).toHaveBeenCalledWith('/gestalten?karte=a'); }); it('Herunterladen: Bild laden, als PNG rendern und unter aircard-.png speichern', async () => { const png = new Blob(['png'], { type: 'image/png' }); const image = { source: {}, w: 3000, h: 2000 }; mocks.list.mockResolvedValue([card('a', 'Mein Urlaub', { config: { ...DEFAULT_CONFIG, crop: { x: 99999, y: 0, zoom: 1 } } })]); mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/jpeg' })); mocks.decodeImage.mockResolvedValue(image); mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: false }); mocks.exportCard.mockResolvedValue(png); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Herunterladen').click(); await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled()); expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-mein-urlaub.png'); expect(mocks.exportCard).toHaveBeenCalledWith(expect.anything(), expect.anything(), { width: 1536, type: 'image/png' }); // Der gespeicherte Ausschnitt wird auf das Bild begrenzt, bevor gerendert wird. const rendered = mocks.exportCard.mock.calls[0][0]; expect(rendered.crop.x).toBeLessThan(99999); await vi.waitFor(() => expect(button(tileFor('a'), 'Herunterladen').disabled).toBe(false)); expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe(''); }); it('Herunterladen eines über 40 Megapixel großen Bildes zeigt die Größen-Meldung an der Kachel', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/png' })); mocks.decodeImage.mockRejectedValue(new Error('Das Bild ist zu groß (max. 40 Megapixel).')); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Herunterladen').click(); await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Das Bild ist zu groß (max. 40 Megapixel).'), ); expect(mocks.saveBlobAs).not.toHaveBeenCalled(); }); it('Herunterladen ohne Bilddatei zeigt die Meldung an der Kachel', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' })); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Herunterladen').click(); await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Das Bild dieser Karte fehlt.')); expect(mocks.saveBlobAs).not.toHaveBeenCalled(); expect(button(tileFor('a'), 'Herunterladen').disabled).toBe(false); }); }); describe('Meine Karten: Herunterladen mit fehlendem Logo', () => { const image = { source: {}, w: 3000, h: 2000 }; const png = new Blob(['png'], { type: 'image/png' }); /** Eine Karte, die ein Logo wählt, das es nicht mehr gibt (loadCardAssets meldet logoMissing). */ const onTemplate = (template: TemplateId) => card('a', 'Alpha', { config: { ...structuredClone(DEFAULT_CONFIG), template, logo: 'gone' } }); const msg = () => tileFor('a').querySelector('.card-msg')!; beforeEach(() => { mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/jpeg' })); mocks.decodeImage.mockResolvedValue(image); mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: true }); mocks.exportCard.mockResolvedValue(png); }); async function download(template: TemplateId, label = 'Herunterladen'): Promise { mocks.list.mockResolvedValue([onTemplate(template)]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), label).click(); } it.each(['minimal', 'classic', 'signature'] as const)( 'Vorlage %s zeichnet das Logo: die Meldung steht an der Kachel, es wird nichts heruntergeladen', async (template) => { await download(template); await vi.waitFor(() => expect(msg().textContent).toBe(LOGO_MISSING_PUBLISH)); expect(msg().textContent).toBe( 'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.', ); expect(msg().classList.contains('is-warning')).toBe(false); expect(mocks.exportCard).not.toHaveBeenCalled(); expect(mocks.saveBlobAs).not.toHaveBeenCalled(); // Der Knopf ist danach wieder bedienbar und trägt seine Beschriftung. const again = button(tileFor('a'), 'Herunterladen'); expect(again.disabled).toBe(false); expect(tileFor('a').getAttribute('aria-busy')).toBeNull(); }, ); it('„Nur Bild“ (image) zeichnet nie ein Logo: der Download läuft ganz normal, ohne Meldung', async () => { await download('image'); await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png')); expect(mocks.exportCard).toHaveBeenCalledTimes(1); expect(msg().textContent).toBe(''); }); it('nach der Meldung geht ein zweiter Versuch (Logo wieder da) durch, die Meldung ist dann weg', async () => { await download('classic'); await vi.waitFor(() => expect(msg().textContent).toBe(LOGO_MISSING_PUBLISH)); mocks.loadCardAssets.mockResolvedValue({ image, logo: { source: {}, w: 200, h: 100 }, brand: null, logoMissing: false }); button(tileFor('a'), 'Herunterladen').click(); await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledTimes(1)); expect(msg().textContent).toBe(''); }); it('Englisch: dieselbe Meldung, übersetzt', async () => { setLang('en', { persist: false }); await download('signature', 'Download'); await vi.waitFor(() => expect(msg().textContent).toBe('This card’s logo is no longer available. Open the card in the editor and choose another one.'), ); expect(mocks.saveBlobAs).not.toHaveBeenCalled(); }); it('eine Karte ohne gewähltes Logo wird normal heruntergeladen', async () => { mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: false }); await download('classic'); await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png')); expect(msg().textContent).toBe(''); }); }); describe('Meine Karten: Herunterladen und der Zwischenspeicher der Logos (ein ausgeblendetes Logo darf nicht ins PNG)', () => { const image = { source: {}, w: 3000, h: 2000 }; const png = new Blob(['png'], { type: 'image/png' }); const logoRow = (patch: Partial = {}): CommunityLogo => ({ id: 'zw2', name: 'Mein Logo', displayName: 'Kenso', fileId: 'zw2', ownerId: 'u1', mono: false, likeCount: 0, hidden: false, reportCount: 0, createdAt: '2026-10-04T10:00:00.000Z', ...patch, }); const msg = () => tileFor('a').querySelector('.card-msg')!; beforeEach(() => { mocks.list.mockResolvedValue([card('a', 'Alpha', { config: { ...structuredClone(DEFAULT_CONFIG), template: 'classic', logo: 'zw2' } })]); mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/jpeg' })); mocks.decodeImage.mockResolvedValue(image); mocks.exportCard.mockResolvedValue(png); mocks.getLogo.mockResolvedValue(logoRow()); mocks.loadLogoBlob.mockResolvedValue(new Blob(['logo'], { type: 'image/png' })); // Wie der echte Lader (card/render.ts: loadCardAssets), mit dem echten Zwischenspeicher der Logos darunter. mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null; details: 'light' | 'dark' }, img: unknown) => { const logo = config.logo ? await loadLogoAsset(config.logo, config.details) : null; return { image: img, logo, brand: null, logoMissing: Boolean(config.logo) && logo === null }; }); vi.stubGlobal('createImageBitmap', async () => ({ width: 200, height: 100 })); }); afterEach(() => { forgetLogo('zw2'); vi.unstubAllGlobals(); }); async function download(): Promise { mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Herunterladen').click(); } it('ein Logo, das nach dem ersten Laden (Editor) ausgeblendet wurde, wird vor dem Download neu geprüft: Meldung an der Kachel, kein PNG', async () => { await expect(loadLogoAsset('zw2', 'light')).resolves.not.toBeNull(); // der Editor hat es geladen: Zeile und Bild stecken im Zwischenspeicher mocks.getLogo.mockResolvedValue(logoRow({ hidden: true })); // dann wurde es ausgeblendet await download(); await vi.waitFor(() => expect(msg().textContent).toBe(LOGO_MISSING_PUBLISH)); expect(mocks.getLogo).toHaveBeenCalledTimes(2); expect(mocks.exportCard).not.toHaveBeenCalled(); expect(mocks.saveBlobAs).not.toHaveBeenCalled(); }); it('ein weiterhin sichtbares Logo wird mit frisch geholter Zeile und Datei heruntergeladen', async () => { await expect(loadLogoAsset('zw2', 'light')).resolves.not.toBeNull(); await download(); await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png')); expect(mocks.getLogo).toHaveBeenCalledTimes(2); expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(2); expect(mocks.exportCard.mock.calls[0][1]).toMatchObject({ logo: { w: 200, h: 100 } }); expect(msg().textContent).toBe(''); }); it('eine Karte ohne Logo holt nichts vom Server', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha', { config: { ...structuredClone(DEFAULT_CONFIG), template: 'classic', logo: null } })]); await download(); await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png')); expect(mocks.getLogo).not.toHaveBeenCalled(); }); }); describe('Meine Karten: Sitzung weg, Appwrite antwortet mit 404 statt 401', () => { const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' }); const rowGone = () => Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' }); /** Beim Laden der Seite ist der Nutzer noch da; danach (Nutzerprüfung nach dem 404) nicht mehr. */ const sessionEndsAfterLoad = () => mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null); it('Vorschau: erst anmelden, dann wird die Vorschau geladen (statt „Keine Vorschau“)', async () => { sessionEndsAfterLoad(); mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.loadPreview.mockReset(); mocks.loadPreview .mockRejectedValueOnce(fileGone()) .mockImplementation(async () => new Blob(['p'], { type: 'image/webp' })); mount(); await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull()); expect(mocks.requireLogin).toHaveBeenCalledOnce(); expect(mocks.loadPreview).toHaveBeenCalledTimes(2); }); it('Herunterladen: erst anmelden, dann wird das Bild geladen (statt „Das Bild dieser Karte fehlt.“)', async () => { const png = new Blob(['png'], { type: 'image/png' }); const image = { source: {}, w: 3000, h: 2000 }; sessionEndsAfterLoad(); mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.loadImage.mockRejectedValueOnce(fileGone()).mockResolvedValueOnce(new Blob(['i'], { type: 'image/jpeg' })); mocks.decodeImage.mockResolvedValue(image); mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: false }); mocks.exportCard.mockResolvedValue(png); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Herunterladen').click(); await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png')); expect(mocks.requireLogin).toHaveBeenCalledOnce(); expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe(''); }); it('Löschen: die Karte gilt nicht als „schon gelöscht“, sondern wird nach dem Anmelden wirklich gelöscht', async () => { sessionEndsAfterLoad(); mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mocks.remove.mockRejectedValueOnce(rowGone()).mockResolvedValueOnce(undefined); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false)); button(tileFor('b'), 'Löschen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); expect(mocks.requireLogin).toHaveBeenCalledOnce(); expect(mocks.remove).toHaveBeenCalledTimes(2); }); it('Löschen, Anmeldung abgebrochen: die Karte bleibt, Hinweis an der Kachel', async () => { sessionEndsAfterLoad(); mocks.requireLogin.mockResolvedValue(false); mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.remove.mockRejectedValue(rowGone()); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); await vi.waitFor(() => expect(button(tileFor('a'), 'Löschen').disabled).toBe(false)); button(tileFor('a'), 'Löschen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Bitte melde dich an, um fortzufahren.'), ); expect(tiles()).toHaveLength(1); }); }); describe('Meine Karten: Umbenennen', () => { const startRename = async () => { mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); button(tileFor('b'), 'Umbenennen').click(); return tileFor('b').querySelector('input')!; }; it('zeigt eine Eingabe mit dem Namen, Enter speichert, die Karte steht danach vorn', async () => { mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name.trim(), { updatedAt: '2026-10-06T08:00:00.000Z' })); const input = await startRename(); expect(input.value).toBe('Beta'); expect(document.activeElement).toBe(input); input.value = ' Gamma '; submit(input.closest('form')!); await vi.waitFor(() => expect(tiles().map((t) => t.dataset.card)).toEqual(['b', 'a'])); expect(mocks.rename).toHaveBeenCalledWith('b', ' Gamma '); expect(tileFor('b').querySelector('.card-name')!.textContent).toBe('Gamma'); expect(tileFor('b').querySelector('input')).toBeNull(); }); it('der vorhandene Name ist beim Öffnen komplett markiert (Tippen ersetzt ihn, statt davor einzufügen)', async () => { const input = await startRename(); expect(document.activeElement).toBe(input); expect(input.selectionStart).toBe(0); expect(input.selectionEnd).toBe('Beta'.length); }); it('Esc bricht ab, ohne etwas zu speichern', async () => { const input = await startRename(); input.value = 'Anders'; input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); expect(tileFor('b').querySelector('input')).toBeNull(); expect(tileFor('b').querySelector('.card-name')!.textContent).toBe('Beta'); expect(mocks.rename).not.toHaveBeenCalled(); }); it('„Abbrechen“ bricht ab; ein unveränderter Name löst keine Anfrage aus', async () => { const input = await startRename(); button(tileFor('b'), 'Abbrechen').click(); expect(tileFor('b').querySelector('input')).toBeNull(); button(tileFor('b'), 'Umbenennen').click(); const again = tileFor('b').querySelector('input')!; again.value = ' Beta '; submit(again.closest('form')!); expect(tileFor('b').querySelector('input')).toBeNull(); expect(mocks.rename).not.toHaveBeenCalled(); expect(input).not.toBe(again); }); it('ein Fehler lässt die Eingabe offen und zeigt die Meldung', async () => { mocks.rename.mockRejectedValue(new TypeError('offline')); const input = await startRename(); input.value = 'Gamma'; submit(input.closest('form')!); await vi.waitFor(() => expect(tileFor('b').querySelector('.card-msg')!.textContent).toBe('Keine Verbindung zum Server.')); const open = tileFor('b').querySelector('input')!; expect(open.value).toBe('Gamma'); expect(open.readOnly).toBe(false); }); it('hält die Karte im Editor-Zustand aktuell (sonst überschreibt Speichern den neuen Namen)', async () => { store.set({ saved: { id: 'b', name: 'Beta', imageFileId: 'img-b', previewFileId: 'prev-b', updatedAt: '2026-10-04T12:00:00.000Z' }, cardName: 'Beta', }); mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name, { updatedAt: '2026-10-06T08:00:00.000Z' })); const input = await startRename(); input.value = 'Gamma'; submit(input.closest('form')!); await vi.waitFor(() => expect(store.get().cardName).toBe('Gamma')); expect(store.get().saved).toMatchObject({ id: 'b', name: 'Gamma', updatedAt: '2026-10-06T08:00:00.000Z' }); }); }); describe('Meine Karten: Löschen', () => { afterEach(() => { vi.unstubAllGlobals(); }); /** Klickt „Löschen“ an der Kachel (sobald der Knopf wieder bedienbar ist) und wartet auf den Dialog. */ const openDialog = async (id: string) => { await vi.waitFor(() => expect(button(tileFor(id), 'Löschen').disabled).toBe(false)); button(tileFor(id), 'Löschen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); }; const start = async () => { mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(2)); }; it('fragt in einem eigenen Dialog nach und löscht erst nach „Löschen“', async () => { mocks.remove.mockResolvedValue(undefined); const confirm = vi.fn(); vi.stubGlobal('confirm', confirm); await start(); await openDialog('b'); expect(dialog()!.querySelector('h2')!.textContent).toBe('Karte „Beta“ löschen?'); expect(mocks.remove).not.toHaveBeenCalled(); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); expect(mocks.remove).toHaveBeenCalledWith(expect.objectContaining({ id: 'b', imageFileId: 'img-b', previewFileId: 'prev-b' })); expect(tiles()[0].dataset.card).toBe('a'); expect(dialog()).toBeNull(); expect(confirm).not.toHaveBeenCalled(); expect(revoked).toHaveLength(1); }); it('„Abbrechen“, Esc und Klick auf den Hintergrund löschen nichts', async () => { await start(); await openDialog('b'); button(dialog()!, 'Abbrechen').click(); await vi.waitFor(() => expect(dialog()).toBeNull()); await openDialog('b'); dialog()!.dispatchEvent(new Event('close')); await vi.waitFor(() => expect(dialog()).toBeNull()); await openDialog('b'); dialog()!.dispatchEvent(new MouseEvent('click', { bubbles: true })); await vi.waitFor(() => expect(dialog()).toBeNull()); expect(mocks.remove).not.toHaveBeenCalled(); expect(tiles()).toHaveLength(2); await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false)); }); it('ein Fehler beim Löschen lässt die Karte stehen und zeigt die Meldung', async () => { mocks.remove.mockRejectedValue(new TypeError('offline')); await start(); await openDialog('b'); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(tileFor('b').querySelector('.card-msg')!.textContent).toBe('Keine Verbindung zum Server.')); expect(tiles()).toHaveLength(2); expect(button(tileFor('b'), 'Löschen').disabled).toBe(false); }); it('eine schon gelöschte Karte (row_not_found) verschwindet ohne Fehlermeldung', async () => { mocks.remove.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'row_not_found' })); await start(); await openDialog('b'); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); }); it('die letzte Karte gelöscht: Leer-Ansicht', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.remove.mockResolvedValue(undefined); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); await openDialog('a'); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.')); }); it('die im Editor geöffnete Karte wird dort „ungespeichert“, damit Speichern eine neue anlegt', async () => { store.set({ saved: { id: 'b', name: 'Beta', imageFileId: 'img-b', previewFileId: 'prev-b', updatedAt: '2026-10-04T12:00:00.000Z' }, }); mocks.remove.mockResolvedValue(undefined); await start(); await openDialog('b'); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); expect(store.get().saved).toBeNull(); }); it('eine andere Karte im Editor bleibt unberührt', async () => { const saved = { id: 'a', name: 'Alpha', imageFileId: 'img-a', previewFileId: 'prev-a', updatedAt: '2026-10-04T12:00:00.000Z' }; store.set({ saved }); mocks.remove.mockResolvedValue(undefined); await start(); await openDialog('b'); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); expect(store.get().saved).toEqual(saved); }); }); describe('Meine Karten: Seite wird verlassen', () => { const savedRef = (id: string, name: string) => ({ id, name, imageFileId: `img-${id}`, previewFileId: `prev-${id}`, updatedAt: '2026-10-04T12:00:00.000Z', }); afterEach(() => { vi.unstubAllGlobals(); }); it('ein offener Löschen-Dialog wird geschlossen, nichts wird gelöscht', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Löschen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); unmount?.(); unmount = null; expect(dialog()).toBeNull(); await new Promise((r) => setTimeout(r, 0)); expect(mocks.remove).not.toHaveBeenCalled(); }); it('Umbenennen, das erst nach dem Verlassen fertig wird, stellt den Editor-Zustand trotzdem richtig', async () => { store.set({ saved: savedRef('a', 'Alpha'), cardName: 'Alpha' }); mocks.list.mockResolvedValue([card('a', 'Alpha')]); let finish!: (c: SavedCard) => void; mocks.rename.mockReturnValue(new Promise((r) => (finish = r))); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Umbenennen').click(); const input = tileFor('a').querySelector('input')!; input.value = 'Neu'; submit(input.closest('form')!); await vi.waitFor(() => expect(mocks.rename).toHaveBeenCalled()); unmount?.(); unmount = null; finish(card('a', 'Neu', { updatedAt: '2026-10-06T08:00:00.000Z' })); await vi.waitFor(() => expect(store.get().cardName).toBe('Neu')); expect(store.get().saved).toMatchObject({ id: 'a', name: 'Neu' }); }); it('Löschen, das erst nach dem Verlassen fertig wird, löst den Editor trotzdem von der Karte', async () => { store.set({ saved: savedRef('a', 'Alpha'), savedConfig: structuredClone(DEFAULT_CONFIG) }); mocks.list.mockResolvedValue([card('a', 'Alpha')]); let finish!: () => void; mocks.remove.mockReturnValue(new Promise((r) => (finish = r))); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Löschen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(mocks.remove).toHaveBeenCalled()); unmount?.(); unmount = null; finish(); await vi.waitFor(() => expect(store.get().saved).toBeNull()); expect(store.get().savedConfig).toBeNull(); }); it('eine zweite Vorschau derselben Datei legt keine zweite URL an (sonst bliebe die erste ungenutzt)', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); const pending: Array<(b: Blob) => void> = []; mocks.loadPreview.mockImplementation(() => new Promise((r) => pending.push(r))); mount(); await vi.waitFor(() => expect(pending).toHaveLength(1)); // Abmelden und wieder anmelden lädt die Liste neu, während die erste Vorschau noch unterwegs ist. for (const fn of mocks.authListeners) fn(null); for (const fn of mocks.authListeners) fn(user); await vi.waitFor(() => expect(pending).toHaveLength(2)); pending[0](new Blob(['p'], { type: 'image/webp' })); await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull()); pending[1](new Blob(['p'], { type: 'image/webp' })); await new Promise((r) => setTimeout(r, 0)); expect(URL.createObjectURL).toHaveBeenCalledTimes(1); unmount?.(); unmount = null; expect(revoked).toEqual(['blob:preview-1']); }); }); const published = (id: string, sourceCardId: string, patch: Partial = {}): GalleryCard => ({ id, title: 'Mein Motiv', issuer: '', tags: [], displayName: '', imageFileId: `gi-${id}`, previewFileId: `gp-${id}`, sourceCardId, ownerId: 'u1', hidden: false, reportCount: 0, createdAt: '2026-10-04T12:00:00.000Z', ...patch, }); const labels = (scope: ParentNode) => [...scope.querySelectorAll('.card-buttons button, .card-buttons a')].map((n) => n.textContent); const badge = (id: string) => tileFor(id).querySelector('.card-pub')?.textContent ?? ''; const publishDialogEl = () => document.querySelector('dialog.publish-dialog'); const dlgInput = (name: string) => publishDialogEl()!.querySelector(`input[name="${name}"]`)!; const sendForm = () => publishDialogEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); const fillDialog = (title = 'Mein Motiv', values: Record = {}) => { dlgInput('title').value = title; for (const [name, value] of Object.entries(values)) dlgInput(name).value = value; dlgInput('consent').checked = true; }; const dlgError = () => publishDialogEl()!.querySelector('[role="alert"]')!.textContent; const openPublish = async (id: string) => { await vi.waitFor(() => expect(button(tileFor(id), 'Veröffentlichen').disabled).toBe(false)); button(tileFor(id), 'Veröffentlichen').click(); await vi.waitFor(() => expect(publishDialogEl()?.open).toBe(true)); }; describe('Meine Karten: Veröffentlichung (Status an der Kachel)', () => { const pubMap = (...cards: GalleryCard[]) => new Map(cards.map((c) => [c.sourceCardId, c])); const view = (id: string) => tileFor(id).querySelector('a[data-action="view"]'); const mountWith = async (publications: Map = new Map()) => { mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mocks.myPublications.mockResolvedValue(publications); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); await vi.waitFor(() => expect(button(tileFor('a'), 'Löschen')).toBeTruthy()); await vi.waitFor(() => expect(tileFor('a').querySelector('[data-action="publish"], [data-action="withdraw"]')).not.toBeNull()); }; it('lädt den Status einmal je Seitenaufbau (für die angemeldete Nutzer-ID), nicht abgemeldet', async () => { await mountWith(); expect(mocks.myPublications).toHaveBeenCalledTimes(1); expect(mocks.myPublications).toHaveBeenCalledWith('u1'); unmount?.(); unmount = null; mocks.myPublications.mockClear(); mocks.getCurrentUser.mockResolvedValue(null); mount(); await vi.waitFor(() => expect(text()).toContain('Melde dich an')); expect(mocks.myPublications).not.toHaveBeenCalled(); }); it('nicht veröffentlicht: „Veröffentlichen“, kein Badge', async () => { await mountWith(); expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Veröffentlichen', 'Löschen']); expect(badge('a')).toBe(''); expect(view('a')).toBeNull(); }); it('veröffentlicht: Badge „Veröffentlicht“, „Ansehen“ (/community/) und „Zurückziehen“ statt „Veröffentlichen“', async () => { await mountWith(pubMap(published('g1', 'a'))); expect(badge('a')).toBe('Veröffentlicht'); expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Ansehen', 'Zurückziehen', 'Löschen']); expect(view('a')!.getAttribute('href')).toBe('/community/g1'); expect(view('a')!.hasAttribute('data-link')).toBe(true); // Die andere Kachel bleibt unberührt. expect(badge('b')).toBe(''); expect(labels(tileFor('b'))).toContain('Veröffentlichen'); }); it('ausgeblendet (gemeldet): Badge „Ausgeblendet (gemeldet)“ und „Zurückziehen“, aber kein „Ansehen“ und kein „Veröffentlichen“', async () => { await mountWith(pubMap(published('g1', 'a', { hidden: true, reportCount: 3 }))); expect(badge('a')).toBe('Ausgeblendet (gemeldet)'); expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Zurückziehen', 'Löschen']); expect(view('a')).toBeNull(); }); it('solange der Status lädt, gibt es weder „Veröffentlichen“ noch „Zurückziehen“; danach erscheint der Stand', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); let finish!: (m: Map) => void; mocks.myPublications.mockReturnValue(new Promise((r) => (finish = r))); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Löschen']); finish(pubMap(published('g1', 'a'))); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); }); it('Status lässt sich nicht laden: Hinweis mit „Nochmal versuchen“, „Veröffentlichen“ bleibt möglich (409 fängt Doppeltes ab)', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.myPublications.mockRejectedValueOnce(new TypeError('offline')); mount(); await vi.waitFor(() => expect(text()).toContain('Der Veröffentlichungsstatus konnte nicht geladen werden.')); expect(labels(tileFor('a'))).toContain('Veröffentlichen'); mocks.myPublications.mockResolvedValue(pubMap(published('g1', 'a'))); button(root, 'Nochmal versuchen').click(); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); expect(text()).not.toContain('konnte nicht geladen werden'); }); it('Abmelden räumt den Status weg, nach dem Anmelden wird er neu geholt', async () => { await mountWith(pubMap(published('g1', 'a'))); for (const fn of mocks.authListeners) fn(null); expect(tiles()).toHaveLength(0); mocks.myPublications.mockResolvedValue(new Map()); for (const fn of mocks.authListeners) fn(user); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen')); expect(badge('a')).toBe(''); expect(mocks.myPublications).toHaveBeenCalledTimes(2); }); it('der Status überlebt Umbenennen (Liste wird neu aufgebaut) ohne neue Anfrage', async () => { mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name, { updatedAt: '2026-10-06T08:00:00.000Z' })); await mountWith(pubMap(published('g1', 'a'))); button(tileFor('a'), 'Umbenennen').click(); const input = tileFor('a').querySelector('input')!; input.value = 'Anders'; submit(input.closest('form')!); await vi.waitFor(() => expect(tileFor('a').querySelector('.card-name')!.textContent).toBe('Anders')); expect(badge('a')).toBe('Veröffentlicht'); expect(mocks.myPublications).toHaveBeenCalledTimes(1); }); const GENERIC_HINT = 'Falls diese Karte veröffentlicht ist, bleibt die Veröffentlichung bestehen – zurückziehen kannst du sie auf ihrer Community-Seite.'; const openDelete = async (id: string) => { button(tileFor(id), 'Löschen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); }; it('Löschen einer veröffentlichten Karte (Status bekannt) nennt, dass die Veröffentlichung bleibt und wo man sie zurückzieht', async () => { await mountWith(pubMap(published('g1', 'a'))); await openDelete('a'); expect(dialog()!.textContent).toContain('bleibt in der Community-Galerie'); expect(dialog()!.textContent).toContain('Community-Seite'); expect(dialog()!.textContent).not.toContain('Falls diese Karte'); button(dialog()!, 'Abbrechen').click(); await vi.waitFor(() => expect(dialog()).toBeNull()); // Bekannt unveröffentlicht: kein Hinweis. await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false)); await openDelete('b'); expect(dialog()!.textContent).not.toContain('Community'); }); it('Löschen, solange der Status noch lädt: allgemeiner Hinweis', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.myPublications.mockReturnValue(new Promise(() => {})); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); await openDelete('a'); expect(dialog()!.textContent).toContain(GENERIC_HINT); }); it('Löschen, wenn der Status nicht geladen werden konnte: allgemeiner Hinweis', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.myPublications.mockRejectedValue(new TypeError('offline')); mount(); await vi.waitFor(() => expect(text()).toContain('konnte nicht geladen werden')); await openDelete('a'); expect(dialog()!.textContent).toContain(GENERIC_HINT); }); it('Status-Abfrage, die ein gleichzeitiges Zurückziehen überholt, lässt den Status nie in „lädt“ hängen', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mocks.myPublications.mockRejectedValueOnce(new TypeError('offline')); mount(); await vi.waitFor(() => expect(text()).toContain('konnte nicht geladen werden')); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen')); // Im Fehlerzustand lässt sich veröffentlichen; danach gibt es „Zurückziehen“. mocks.publishSavedCard.mockResolvedValue({ id: 'g9' }); await openPublish('a'); fillDialog(); sendForm(); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); // Zurückziehen läuft (Anfrage unterwegs) ... let finishWithdraw!: () => void; mocks.withdrawCard.mockReturnValue(new Promise((r) => (finishWithdraw = r))); await vi.waitFor(() => expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false)); button(tileFor('a'), 'Zurückziehen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); button(dialog()!, 'Zurückziehen').click(); await vi.waitFor(() => expect(mocks.withdrawCard).toHaveBeenCalledWith('g9')); // ... währenddessen „Nochmal versuchen“ (die Abfrage hängt noch) ... let finishFetch!: (m: Map) => void; mocks.myPublications.mockReturnValueOnce(new Promise((r) => (finishFetch = r))); button(root, 'Nochmal versuchen').click(); expect(labels(tileFor('b'))).not.toContain('Veröffentlichen'); // Status gilt wieder als unbekannt // ... und das Zurückziehen wird fertig, bevor die Abfrage antwortet. finishWithdraw(); await vi.waitFor(() => expect(badge('a')).toBe('')); finishFetch(new Map()); await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Veröffentlichen')); expect(labels(tileFor('a'))).toContain('Veröffentlichen'); expect(text()).not.toContain('konnte nicht geladen werden'); }); }); describe('Meine Karten: Veröffentlichen', () => { const start = async () => { mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen')); }; it('„Veröffentlichen“ öffnet den Dialog: leere Felder, nie die E-Mail im Anzeigenamen', async () => { await start(); await openPublish('a'); expect(dlgInput('title').value).toBe(''); expect(dlgInput('displayName').value).toBe(''); expect(dlgInput('displayName').placeholder).toBe('Anonym'); expect(publishDialogEl()!.textContent).not.toContain('a@b.de'); expect(publishDialogEl()!.textContent).toContain('„Alpha“'); expect(mocks.publishSavedCard).not.toHaveBeenCalled(); }); it('Abbrechen veröffentlicht nichts, Fokus zurück auf „Veröffentlichen“', async () => { await start(); await openPublish('a'); button(publishDialogEl()!, 'Abbrechen').click(); await vi.waitFor(() => expect(publishDialogEl()).toBeNull()); expect(mocks.publishSavedCard).not.toHaveBeenCalled(); await vi.waitFor(() => expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen'))); }); it('ohne Pflicht-Checkbox wird nichts veröffentlicht', async () => { await start(); await openPublish('a'); dlgInput('title').value = 'Mein Motiv'; sendForm(); expect(dlgError()).toBe('Bitte bestätige die Erklärung zum Bild.'); expect(mocks.publishSavedCard).not.toHaveBeenCalled(); }); it('Erfolg: Badge „Veröffentlicht“ und „Ansehen“ erscheinen sofort, ohne die Liste neu zu laden', async () => { mocks.publishSavedCard.mockResolvedValue({ id: 'g9' }); await start(); await openPublish('a'); fillDialog('Mein Motiv', { issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' }); sendForm(); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); expect(mocks.publishSavedCard).toHaveBeenCalledWith( expect.objectContaining({ id: 'a', name: 'Alpha' }), { title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' }, ); expect(publishDialogEl()).toBeNull(); const link = tileFor('a').querySelector('a[data-action="view"]')!; expect(link.textContent).toBe('Ansehen'); expect(link.getAttribute('href')).toBe('/community/g9'); expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Ansehen', 'Zurückziehen', 'Löschen']); // Alle Knöpfe der Kachel sind wieder bedienbar, die andere Kachel ist unberührt. expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false); expect(labels(tileFor('b'))).toContain('Veröffentlichen'); expect(mocks.list).toHaveBeenCalledTimes(1); expect(mocks.myPublications).toHaveBeenCalledTimes(1); // Der Fokus geht auf „Ansehen“ (der auslösende Knopf ist weg). expect(document.activeElement).toBe(link); }); it('während des Veröffentlichens sind die Kachel-Knöpfe gesperrt und der Dialog lässt sich nicht schließen', async () => { let finish!: (r: { id: string }) => void; mocks.publishSavedCard.mockReturnValue(new Promise((r) => (finish = r))); await start(); await openPublish('a'); fillDialog(); sendForm(); expect(button(publishDialogEl()!, 'Veröffentlichen').disabled).toBe(true); expect(button(tileFor('a'), 'Löschen').disabled).toBe(true); expect(tileFor('a').getAttribute('aria-busy')).toBe('true'); finish({ id: 'g9' }); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); expect(tileFor('a').hasAttribute('aria-busy')).toBe(false); }); it('Fehler (400): Text im Dialog, kein Status geändert, keine Nachprüfung, neuer Versuch möglich', async () => { mocks.publishSavedCard.mockRejectedValueOnce(new ApiError(400, 'Der Titel enthält ungültige Zeichen.')); await start(); await openPublish('a'); fillDialog(); sendForm(); await vi.waitFor(() => expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Titel enthält ungültige Zeichen.')); expect(publishDialogEl()?.open).toBe(true); expect(badge('a')).toBe(''); expect(mocks.myPublications).toHaveBeenCalledTimes(1); mocks.publishSavedCard.mockResolvedValueOnce({ id: 'g9' }); sendForm(); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); }); it('409: „Diese Karte ist schon veröffentlicht.“, der Status wird neu geholt und zeigt „Veröffentlicht“', async () => { mocks.publishSavedCard.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.')); await start(); await openPublish('a'); fillDialog(); mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]])); sendForm(); await vi.waitFor(() => expect(publishDialogEl()!.textContent).toContain('Diese Karte ist schon veröffentlicht.')); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); expect(mocks.myPublications).toHaveBeenCalledTimes(2); button(publishDialogEl()!, 'Schließen').click(); await vi.waitFor(() => expect(publishDialogEl()).toBeNull()); expect(tileFor('a').querySelector('a[data-action="view"]')!.getAttribute('href')).toBe('/community/g1'); expect(mocks.publishSavedCard).toHaveBeenCalledOnce(); }); describe('Ausgang offen (503, 429, 408, 5xx): Nachprüfung vor der Fehlermeldung', () => { it('war die Karte doch veröffentlicht: Erfolgszustand statt Fehler', async () => { mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); await start(); await openPublish('a'); fillDialog(); mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]])); sendForm(); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); expect(publishDialogEl()).toBeNull(); expect(tileFor('a').querySelector('a[data-action="view"]')!.getAttribute('href')).toBe('/community/g1'); expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe(''); expect(mocks.myPublications).toHaveBeenCalledTimes(2); }); it.each([408, 429, 500, 504])('auch bei %i wird nachgeprüft', async (code) => { mocks.publishSavedCard.mockRejectedValue(new ApiError(code, 'Offen.')); await start(); await openPublish('a'); fillDialog(); mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]])); sendForm(); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); }); it('war sie es nicht: Fehlermeldung im Dialog, Status unverändert', async () => { mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); await start(); await openPublish('a'); fillDialog(); sendForm(); await vi.waitFor(() => expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'), ); expect(mocks.myPublications).toHaveBeenCalledTimes(2); expect(badge('a')).toBe(''); expect(publishDialogEl()?.open).toBe(true); }); it('scheitert auch die Nachprüfung: die ursprüngliche Meldung bleibt', async () => { mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); await start(); await openPublish('a'); fillDialog(); mocks.myPublications.mockRejectedValue(new TypeError('offline')); sendForm(); await vi.waitFor(() => expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'), ); expect(badge('a')).toBe(''); }); it('die Nachprüfung geht nicht verloren, wenn währenddessen eine andere Kachel ihren Status ändert', async () => { mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); mocks.withdrawCard.mockResolvedValue(undefined); mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mocks.myPublications.mockResolvedValue(new Map([['b', published('g2', 'b')]])); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Zurückziehen')); await openPublish('a'); fillDialog(); let finishCheck!: (m: Map) => void; mocks.myPublications.mockReturnValueOnce(new Promise((r) => (finishCheck = r))); sendForm(); await vi.waitFor(() => expect(mocks.myPublications).toHaveBeenCalledTimes(2)); // Während die Nachprüfung läuft, zieht der Nutzer in einer anderen Kachel eine Karte zurück. button(tileFor('b'), 'Zurückziehen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); button(dialog()!, 'Zurückziehen').click(); await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Veröffentlichen')); finishCheck(new Map([['a', published('g1', 'a')]])); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); expect(publishDialogEl()).toBeNull(); expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe(''); }); it('die Nachprüfung findet nur die Veröffentlichung dieser Karte (eine andere zählt nicht)', async () => { mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); await start(); await openPublish('a'); fillDialog(); mocks.myPublications.mockResolvedValue(new Map([['b', published('g2', 'b')]])); sendForm(); await vi.waitFor(() => expect(dlgError()).toContain('Der Dienst ist gerade nicht erreichbar.')); expect(badge('a')).toBe(''); expect(dlgError()).toContain('Der Dienst ist gerade nicht erreichbar.'); }); }); it('abgelaufene Sitzung (401): Anmelden, dann wird veröffentlicht', async () => { mocks.publishSavedCard .mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 })) .mockResolvedValueOnce({ id: 'g9' }); await start(); await openPublish('a'); fillDialog(); sendForm(); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); expect(mocks.requireLogin).toHaveBeenCalledOnce(); expect(mocks.publishSavedCard).toHaveBeenCalledTimes(2); }); it('Anmeldung abgebrochen: Hinweis im Dialog, nichts veröffentlicht', async () => { mocks.requireLogin.mockResolvedValue(false); mocks.publishSavedCard.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 })); await start(); await openPublish('a'); fillDialog(); sendForm(); await vi.waitFor(() => expect(dlgError()).toBe('Bitte melde dich an, um fortzufahren.')); expect(badge('a')).toBe(''); }); it('die Seite wird verlassen: der Dialog geht zu, eine spätere Antwort ändert nichts mehr', async () => { let finish!: (r: { id: string }) => void; mocks.publishSavedCard.mockReturnValue(new Promise((r) => (finish = r))); await start(); await openPublish('a'); fillDialog(); sendForm(); unmount?.(); unmount = null; expect(publishDialogEl()).toBeNull(); finish({ id: 'g9' }); await new Promise((r) => setTimeout(r, 0)); expect(root.querySelector('.card-badge')).toBeNull(); }); it('die Seite wird verlassen, solange der Dialog offen ist: er geht zu, nichts wird veröffentlicht', async () => { await start(); await openPublish('a'); unmount?.(); unmount = null; expect(publishDialogEl()).toBeNull(); expect(mocks.publishSavedCard).not.toHaveBeenCalled(); }); it('Titel und Namen erscheinen als Text, nie als HTML', async () => { mocks.list.mockResolvedValue([card('a', '')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); await openPublish('a'); expect(publishDialogEl()!.querySelector('img')).toBeNull(); expect(publishDialogEl()!.textContent).toContain(''); }); }); describe('Meine Karten: Zurückziehen', () => { const start = async (patch: Partial = {}) => { mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a', patch)]])); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Zurückziehen')); }; const openConfirm = async () => { button(tileFor('a'), 'Zurückziehen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); }; it('fragt nach, zieht dann die Veröffentlichung (Galerie-ID) zurück; die Kachel zeigt wieder „Veröffentlichen“', async () => { mocks.withdrawCard.mockResolvedValue(undefined); await start(); await openConfirm(); expect(dialog()!.querySelector('h2')!.textContent).toBe('Karte „Mein Motiv“ zurückziehen?'); expect(dialog()!.textContent).toContain('Deine private Karte bleibt erhalten.'); expect(mocks.withdrawCard).not.toHaveBeenCalled(); button(dialog()!, 'Zurückziehen').click(); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen')); expect(mocks.withdrawCard).toHaveBeenCalledWith('g1'); expect(badge('a')).toBe(''); expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Veröffentlichen', 'Löschen']); expect(dialog()).toBeNull(); expect(mocks.list).toHaveBeenCalledTimes(1); expect(mocks.myPublications).toHaveBeenCalledTimes(1); // Fokus auf dem neuen „Veröffentlichen“-Knopf. expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen')); }); it('ausgeblendete Kacheln behalten „Zurückziehen“: danach lässt sich neu veröffentlichen', async () => { mocks.withdrawCard.mockResolvedValue(undefined); await start({ hidden: true, reportCount: 3 }); expect(badge('a')).toBe('Ausgeblendet (gemeldet)'); await openConfirm(); button(dialog()!, 'Zurückziehen').click(); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen')); expect(mocks.withdrawCard).toHaveBeenCalledWith('g1'); expect(badge('a')).toBe(''); }); it('„Abbrechen“ zieht nichts zurück', async () => { await start(); await openConfirm(); button(dialog()!, 'Abbrechen').click(); await vi.waitFor(() => expect(dialog()).toBeNull()); expect(mocks.withdrawCard).not.toHaveBeenCalled(); expect(badge('a')).toBe('Veröffentlicht'); await vi.waitFor(() => expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false)); }); it('Fehler: Text des Dienstes an der Kachel, Status bleibt', async () => { mocks.withdrawCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); await start(); await openConfirm(); button(dialog()!, 'Zurückziehen').click(); await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Der Dienst ist gerade nicht erreichbar.'), ); expect(badge('a')).toBe('Veröffentlicht'); expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false); }); it('die Veröffentlichung gibt es schon nicht mehr (404): Ziel erreicht, die Kachel zeigt „Veröffentlichen“', async () => { mocks.withdrawCard.mockRejectedValue(new ApiError(404, 'Diese Karte gibt es nicht.')); await start(); await openConfirm(); button(dialog()!, 'Zurückziehen').click(); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen')); expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe(''); }); it('abgelaufene Sitzung (401): Anmelden, dann wird zurückgezogen', async () => { mocks.withdrawCard .mockRejectedValueOnce(new ApiError(401, 'Bitte melde dich an.')) .mockResolvedValueOnce(undefined); await start(); await openConfirm(); button(dialog()!, 'Zurückziehen').click(); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen')); expect(mocks.requireLogin).toHaveBeenCalledOnce(); expect(mocks.withdrawCard).toHaveBeenCalledTimes(2); }); it('Seite verlassen mit offener Rückfrage: nichts wird zurückgezogen', async () => { await start(); await openConfirm(); unmount?.(); unmount = null; expect(dialog()).toBeNull(); await new Promise((r) => setTimeout(r, 0)); expect(mocks.withdrawCard).not.toHaveBeenCalled(); }); }); describe('Meine Karten auf Englisch', () => { beforeEach(() => setLang('en', { persist: false })); const withdrawNote = 'The published card stays in the community gallery until you withdraw it – after deleting this card, you can only do that on its community page.'; const maybeNote = 'If this card is published, it stays in the gallery – you can withdraw it on its community page.'; it('Zustände: Laden, abgemeldet, leer, Fehler', async () => { mocks.getCurrentUser.mockReturnValue(new Promise(() => {})); mount(); expect(text()).toContain('Loading cards …'); expect(root.querySelector('h1')!.textContent).toBe('My cards'); expect(root.querySelector('section')!.getAttribute('aria-label')).toBe('Saved cards'); unmount?.(); mocks.getCurrentUser.mockResolvedValue(null); mount(); await vi.waitFor(() => expect(text()).toContain('Sign in to see your cards.')); expect(button(root, 'Sign in')).toBeTruthy(); expect(germanLeft(root)).toEqual([]); unmount?.(); mocks.getCurrentUser.mockResolvedValue(user); mocks.list.mockResolvedValue([]); mount(); await vi.waitFor(() => expect(text()).toContain('No cards saved yet.')); expect(root.querySelector('a[data-link]')!.textContent).toBe('Go to editor'); unmount?.(); mocks.list.mockRejectedValue(new TypeError('offline')); mount(); await vi.waitFor(() => expect(text()).toContain('Can’t reach the server.')); expect(button(root, 'Try again')).toBeTruthy(); expect(germanLeft(root)).toEqual([]); }); it('Kacheln: Datum, Vorschau-Texte, Knöpfe, Badges, Umbenennen und der Fehler beim Status', async () => { const updatedAt = new Date(2026, 9, 4, 12).toISOString(); mocks.list.mockResolvedValue([card('a', 'Alpha', { updatedAt }), card('b', 'Beta'), card('c', 'Gamma'), card('d', 'Delta')]); mocks.myPublications.mockResolvedValue( new Map([ ['a', published('g1', 'a')], ['c', published('g3', 'c', { hidden: true })], ]), ); mocks.loadPreview.mockImplementation(async (c: SavedCard) => { if (c.id === 'b') throw Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }); if (c.id === 'd') return new Promise(() => {}); return new Blob(['p'], { type: 'image/webp' }); }); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(4)); await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull()); expect(tileFor('a').querySelector('.card-date')!.textContent).toBe('Oct 4, 2026'); expect(tileFor('a').querySelector('img')!.getAttribute('alt')).toBe('Preview of “Alpha”'); await vi.waitFor(() => expect(tileFor('b').textContent).toContain('No preview')); expect(tileFor('d').textContent).toContain('Loading preview …'); await vi.waitFor(() => expect(badge('a')).toBe('Published')); expect(labels(tileFor('a'))).toEqual(['Open', 'Download', 'Rename', 'View', 'Withdraw', 'Delete']); expect(labels(tileFor('b'))).toEqual(['Open', 'Download', 'Rename', 'Publish', 'Delete']); expect(badge('c')).toBe('Hidden (reported)'); expect(labels(tileFor('c'))).toEqual(['Open', 'Download', 'Rename', 'Withdraw', 'Delete']); button(tileFor('b'), 'Rename').click(); expect(tileFor('b').querySelector('input')!.getAttribute('aria-label')).toBe('New name for “Beta”'); expect([...tileFor('b').querySelectorAll('.card-rename button')].map((b) => b.textContent)).toEqual(['Save', 'Cancel']); expect(germanLeft(root)).toEqual([]); unmount?.(); mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.myPublications.mockRejectedValue(new TypeError('offline')); mount(); await vi.waitFor(() => expect(text()).toContain('The publishing status couldn’t be loaded.')); expect(button(root, 'Try again')).toBeTruthy(); expect(germanLeft(root)).toEqual([]); }); it('Löschen: Rückfrage mit den Hinweisen zur Veröffentlichung (bekannt, unbekannt, keine)', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]])); mount(); await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Publish')); const ask = async (id: string) => { button(tileFor(id), 'Delete').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); const shown = dialog()!; return { title: shown.querySelector('h2')!.textContent, text: shown.querySelector('p')!.textContent, buttons: [...shown.querySelectorAll('button')].map((b) => b.textContent) }; }; const cancel = async () => { button(dialog()!, 'Cancel').click(); await vi.waitFor(() => expect(dialog()).toBeNull()); }; expect(await ask('a')).toEqual({ title: 'Delete card “Alpha”?', text: `The card and its image will be deleted permanently. ${withdrawNote}`, buttons: ['Cancel', 'Delete'], }); await cancel(); expect((await ask('b')).text).toBe('The card and its image will be deleted permanently.'); await cancel(); unmount?.(); // der Status ist noch unbekannt: allgemeiner Hinweis mocks.myPublications.mockReturnValue(new Promise(() => {})); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); expect((await ask('a')).text).toBe(`The card and its image will be deleted permanently. ${maybeNote}`); await cancel(); }); it('Zurückziehen: Rückfrage und der Fehler des Dienstes über seinen Code', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]])); mocks.withdrawCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', undefined, 'unavailable')); mount(); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Withdraw')); button(tileFor('a'), 'Withdraw').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); expect(dialog()!.querySelector('h2')!.textContent).toBe('Withdraw card “Mein Motiv”?'); expect(dialog()!.querySelector('p')!.textContent).toBe( 'The card disappears from the gallery, and its image and reports are deleted. Your private card is kept.', ); expect([...dialog()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Cancel', 'Withdraw']); button(dialog()!, 'Withdraw').click(); await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('The service is currently unavailable.')); // ohne bekannten Code zeigt Englisch die allgemeine Meldung zum Status mocks.withdrawCard.mockRejectedValue(new ApiError(403, 'Nur für Administratoren.')); await vi.waitFor(() => expect(button(tileFor('a'), 'Withdraw').disabled).toBe(false)); button(tileFor('a'), 'Withdraw').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); button(dialog()!, 'Withdraw').click(); await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('You are not allowed to do that.')); expect(germanLeft(root)).toEqual([]); }); it('Herunterladen: „Creating …“ unterwegs, die Fehler (Lesen, Pixel, Anmeldung) als Text der Seite', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.loadImage.mockReturnValueOnce(new Promise(() => {})); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Download').click(); expect(labels(tileFor('a'))).toContain('Creating …'); unmount?.(); mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/png' })); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); const msg = () => tileFor('a').querySelector('.card-msg')!.textContent; mocks.decodeImage.mockRejectedValueOnce(new Error('Das Bild konnte nicht gelesen werden.')); button(tileFor('a'), 'Download').click(); await vi.waitFor(() => expect(msg()).toBe('The image couldn’t be read.')); mocks.decodeImage.mockRejectedValueOnce(new Error('Das Bild ist zu groß (max. 40 Megapixel).')); await vi.waitFor(() => expect(button(tileFor('a'), 'Download').disabled).toBe(false)); button(tileFor('a'), 'Download').click(); await vi.waitFor(() => expect(msg()).toBe('The image is too large (max. 40 megapixels).')); // die Sitzung ist weg und der Login-Dialog wird geschlossen unmount?.(); mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null); mocks.requireLogin.mockResolvedValue(false); mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' })); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Download').click(); await vi.waitFor(() => expect(msg()).toBe('Please sign in to continue.')); expect(germanLeft(root)).toEqual([]); }); });