// Bereich „Logo unten rechts“ (Spec § 3, § 5 und § 6): Raster, Sortierung, Auswahl, Herzen, Hochladen und die Infozeile zum gewählten // Logo (Melden, Löschen, Admin). Datenschicht, Anmeldung und Dialoge sind Attrappen; die Kachelbilder laufen über das echte // logo-image.ts mit ersetztem `Image` (dessen Einzelfälle stehen in logo-image.test.ts), die Infozeile über das echte logo-info.ts // (dessen Einzelfälle stehen in logo-info.test.ts: hier zählt das Zusammenspiel mit Raster, Auswahl und Herzen). import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ rememberLogo: vi.fn(), forgetLogo: vi.fn() })); // Der Picker hängt (über die Standardwerte) an Appwrite, Anmeldung und Dialogen; hier kommt alles über `deps`. vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); vi.mock('../account/auth', () => ({ currentUser: () => null, currentUserId: () => null, isAdmin: () => false, onAuthChange: () => () => {}, onLogout: () => () => {}, })); vi.mock('../account/login-dialog', () => ({ requireLogin: vi.fn() })); vi.mock('./logo-dialog', () => ({ logoUploadDialog: vi.fn() })); vi.mock('./logo-asset', async (importOriginal) => ({ ...(await importOriginal()), rememberLogo: mocks.rememberLogo, forgetLogo: mocks.forgetLogo, })); import { ApiError } from '../community/api'; import { setLang } from '../i18n/i18n'; import { createLogoPicker, type LogoPicker, type LogoPickerDeps } from './logo-picker'; import type { CommunityLogo } from './logos'; const logo = (id: string, patch: Partial = {}): CommunityLogo => ({ id, name: `Logo ${id.toUpperCase()}`, displayName: 'Kenso', fileId: id, ownerId: 'u9', mono: false, likeCount: 0, hidden: false, reportCount: 0, createdAt: '2026-10-04T12:00:00.000Z', ...patch, }); function deferred() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } const flush = async () => { for (let i = 0; i < 50; i++) await Promise.resolve(); }; // ---- Attrappen ------------------------------------------------------------------------------------------------------------ let user: string | null; let admin: boolean; const authListeners = new Set<() => void>(); const emitAuth = () => { for (const fn of [...authListeners]) fn(); }; const fx = { listLogos: vi.fn(), getLogo: vi.fn(), myHearts: vi.fn(), setHeart: vi.fn(), loadLogoBlob: vi.fn(), requireLogin: vi.fn(), uploadDialog: vi.fn(), reportLogo: vi.fn(), deleteLogo: vi.fn(), setLogoVisibility: vi.fn(), listLogoReports: vi.fn(), confirm: vi.fn(), reportDialog: vi.fn(), onSelect: vi.fn<(id: string | null) => void>(), }; const deps: LogoPickerDeps = { listLogos: fx.listLogos, getLogo: fx.getLogo, myHearts: fx.myHearts, setHeart: fx.setHeart, loadLogoBlob: fx.loadLogoBlob, userId: () => user, isAdmin: () => admin, requireLogin: fx.requireLogin, onAuthChange(fn) { authListeners.add(fn); return () => authListeners.delete(fn); }, uploadDialog: fx.uploadDialog, reportLogo: fx.reportLogo, deleteLogo: fx.deleteLogo, setLogoVisibility: fx.setLogoVisibility, listLogoReports: fx.listLogoReports, confirm: fx.confirm, reportDialog: fx.reportDialog, }; // Bildprüfung: `loadLogoBlob` liefert einen Blob, den die Attrappe einem Logo zuordnet; die Object-URL erbt das, und das // ersetzte `Image` meldet daraus die Maße (oder einen Ladefehler). const blobOwner = new WeakMap(); const urlOwner = new Map(); const sizes = new Map(); const broken = new Set(); let created: string[]; let revoked: string[]; class FakeImage { naturalWidth = 0; naturalHeight = 0; onload: (() => void) | null = null; onerror: (() => void) | null = null; set src(value: string) { queueMicrotask(() => { const id = urlOwner.get(value) ?? ''; if (broken.has(id)) { this.onerror?.(); return; } [this.naturalWidth, this.naturalHeight] = sizes.get(id) ?? [200, 80]; this.onload?.(); }); } } let host: HTMLElement; let picker: LogoPicker; beforeEach(() => { for (const fn of [...Object.values(fx), mocks.rememberLogo, mocks.forgetLogo]) fn.mockReset(); user = 'u1'; admin = false; authListeners.clear(); sizes.clear(); broken.clear(); urlOwner.clear(); created = []; revoked = []; fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b'), logo('c')], nextCursor: null }); fx.getLogo.mockResolvedValue(null); fx.myHearts.mockResolvedValue(new Set()); fx.setHeart.mockImplementation(async (_id, herz) => ({ herz, likeCount: herz ? 1 : 0 })); fx.loadLogoBlob.mockImplementation(async (row) => { const blob = new Blob(['png']); blobOwner.set(blob, row.id); return blob; }); fx.requireLogin.mockResolvedValue(true); fx.uploadDialog.mockResolvedValue(null); fx.reportLogo.mockResolvedValue({ schonGemeldet: false }); fx.deleteLogo.mockResolvedValue(undefined); fx.setLogoVisibility.mockResolvedValue(undefined); fx.listLogoReports.mockResolvedValue([]); fx.confirm.mockResolvedValue(true); fx.reportDialog.mockResolvedValue('cancelled'); URL.createObjectURL = vi.fn((blob: Blob) => { const url = `blob:logo-${created.length + 1}`; created.push(url); urlOwner.set(url, blobOwner.get(blob) ?? ''); return url; }); URL.revokeObjectURL = vi.fn((url: string) => { revoked.push(url); }); vi.stubGlobal('Image', FakeImage); document.body.innerHTML = ''; host = document.createElement('div'); document.body.append(host); }); afterEach(() => { picker?.dispose(); host.remove(); vi.unstubAllGlobals(); }); async function mount(): Promise { picker = createLogoPicker({ onSelect: fx.onSelect }, deps); host.append(picker.el); await flush(); return picker; } // ---- Zugriffe auf das DOM ------------------------------------------------------------------------------------------------- const cells = () => [...picker.el.querySelectorAll('li.logo-cell')]; const cellName = (cell: HTMLElement) => cell.querySelector('.logo-tile-name')?.textContent; /** Die Namen aller Kacheln in Reihenfolge, die erste ist „Kein Logo“. */ const names = () => cells().map(cellName); const cell = (name: string) => cells().find((c) => cellName(c) === name)!; const tile = (name: string) => cell(name).querySelector('button.logo-tile')!; const heart = (name: string) => cell(name).querySelector('button.logo-heart')!; const art = (name: string) => cell(name).querySelector('.logo-tile-art')!; const pressedTiles = () => cells().filter((c) => c.querySelector('button.logo-tile')?.getAttribute('aria-pressed') === 'true').map(cellName); const heartsOn = () => cells().filter((c) => c.querySelector('button.logo-heart')?.getAttribute('aria-pressed') === 'true').map(cellName); const sortButtons = () => [...picker.el.querySelectorAll('.logo-sort button')]; const sortButton = (label: string) => sortButtons().find((b) => b.textContent === label)!; const pressedSort = () => sortButtons().filter((b) => b.getAttribute('aria-pressed') === 'true').map((b) => b.textContent); const alertText = () => picker.el.querySelector('[role="alert"]')!.textContent; const uploadButton = () => picker.el.querySelector('button.logo-upload')!; const moreButton = () => picker.el.querySelector('button.logo-more')!; const retryButton = () => picker.el.querySelector('button.logo-retry')!; const emptyText = () => picker.el.querySelector('.logo-empty')!.textContent; // Infozeile (logo-info.ts): sichtbar ist, was weder selbst noch in einem Vorfahren mit `hidden` verborgen ist. const infoEl = () => picker.el.querySelector('.logo-info')!; const shown = (node: Element) => node.closest('[hidden]') === null; const infoLine = () => { const text = infoEl().querySelector('.logo-info-text')!; return shown(text) ? text.textContent : ''; }; const infoGone = () => infoEl().querySelector('.logo-info-gone')!.textContent; const infoMessage = () => infoEl().querySelector('[role="alert"]')!.textContent; const infoButtons = () => [...infoEl().querySelectorAll('button')].filter(shown).map((b) => b.textContent); const infoButton = (label: string) => [...infoEl().querySelectorAll('button')].find((b) => b.textContent === label && shown(b)) as HTMLButtonElement; describe('Aufbau', () => { it('Kopf: Überschrift „Logo unten rechts“, Plakette „Community“, Knopf „Hochladen“ und der Satz zur Community', async () => { await mount(); const heading = picker.el.querySelector('h3')!; expect(heading.textContent).toBe('Logo unten rechts'); expect(picker.el.getAttribute('aria-labelledby')).toBe(heading.id); expect(picker.el.querySelector('.logo-badge')!.textContent).toBe('Community'); expect(uploadButton().type).toBe('button'); expect(uploadButton().querySelector('svg')).not.toBeNull(); // das Plus expect(picker.el.querySelector('.logo-picker-note')!.textContent).toBe('Logos werden von der Community hochgeladen.'); }); it('Umschalter „Beliebt“ (gedrückt) und „Neu“; Nicht-Admins sehen „Gemeldet“ und „Ausgeblendet“ nicht', async () => { await mount(); expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu']); expect(pressedSort()).toEqual(['Beliebt']); }); it('erste Kachel „Kein Logo“, dann je Logo eine Kachel mit Name, „von “ bzw. „von Anonym“ und Herz-Knopf mit der Zahl', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 12 }), logo('b', { displayName: '' }), logo('c', { displayName: 'Anna', likeCount: 3 })], nextCursor: null, }); await mount(); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); expect(cell('Kein Logo').querySelector('button.logo-tile')!.classList.contains('logo-tile-none')).toBe(true); expect(cell('Kein Logo').querySelector('.logo-heart')).toBeNull(); expect(['Logo A', 'Logo B', 'Logo C'].map((n) => cell(n).querySelector('.logo-tile-by')!.textContent)).toEqual([ 'von Kenso', 'von Anonym', 'von Anna', ]); expect(['Logo A', 'Logo B', 'Logo C'].map((n) => heart(n).textContent)).toEqual(['12', '0', '3']); }); it('der Herz-Knopf ist ein eigener Knopf neben dem Auswahlknopf, nie darin verschachtelt', async () => { await mount(); for (const c of cells().slice(1)) { const select = c.querySelector('button.logo-tile')!; const like = c.querySelector('button.logo-heart')!; expect(select.contains(like)).toBe(false); expect(like.contains(select)).toBe(false); expect(like.type).toBe('button'); expect(like.querySelector('svg')).not.toBeNull(); expect(c.querySelector('button button')).toBeNull(); } }); it('Herz-Knopf: Zustand in aria-pressed, Beschriftung mit Name und Zahl', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 4 })], nextCursor: null }); await mount(); expect(heart('Logo A').getAttribute('aria-pressed')).toBe('false'); expect(heart('Logo A').getAttribute('aria-label')).toBe('Herz für „Logo A“: 4'); }); it('Name und Anzeigename stehen nur als Text, nie als HTML', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a', { name: '', displayName: 'fett' })], nextCursor: null, }); await mount(); const c = cells()[1]; expect(c.querySelector('.logo-tile-name')!.textContent).toBe(''); expect(c.querySelector('.logo-tile-by')!.textContent).toBe('von fett'); expect(c.querySelector('img[src="x"]')).toBeNull(); expect(c.querySelector('b')).toBeNull(); }); it('die erste Seite wird beliebt sortiert und ungefiltert geladen', async () => { await mount(); expect(fx.listLogos).toHaveBeenCalledExactlyOnceWith({ sort: 'popular', filter: 'all' }); }); it('Englisch: Überschrift, Umschalter, Kacheln und Knöpfe sind übersetzt', async () => { setLang('en', { persist: false }); fx.listLogos.mockResolvedValue({ logos: [logo('a', { displayName: '' })], nextCursor: 'a' }); await mount(); expect(picker.el.querySelector('h3')!.textContent).toBe('Bottom-right logo'); expect(picker.el.querySelector('.logo-picker-note')!.textContent).toBe('Logos are uploaded by the community.'); expect(uploadButton().textContent).toBe('Upload'); expect(sortButtons().map((b) => b.textContent)).toEqual(['Popular', 'New']); expect(names()).toEqual(['No logo', 'Logo A']); expect(cell('Logo A').querySelector('.logo-tile-by')!.textContent).toBe('by Anonymous'); expect(heart('Logo A').getAttribute('aria-label')).toBe('Heart for “Logo A”: 0'); expect(moreButton().textContent).toBe('Load more'); }); }); describe('Sortierung', () => { it('„Neu“ lädt mit sort:new neu, der Umschalter folgt, die Kacheln der alten Liste fallen weg', async () => { await mount(); fx.listLogos.mockResolvedValue({ logos: [logo('z'), logo('a')], nextCursor: null }); sortButton('Neu').click(); expect(pressedSort()).toEqual(['Neu']); await flush(); expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'all' }); expect(names()).toEqual(['Kein Logo', 'Logo Z', 'Logo A']); sortButton('Beliebt').click(); await flush(); expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' }); }); it('ein Klick auf die schon gewählte Ansicht lädt nichts neu', async () => { await mount(); sortButton('Beliebt').click(); await flush(); expect(fx.listLogos).toHaveBeenCalledTimes(1); }); it('Admins sehen zusätzlich „Gemeldet“ und „Ausgeblendet“; sie laden mit filter:reported bzw. filter:hidden', async () => { admin = true; await mount(); expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu', 'Gemeldet', 'Ausgeblendet']); sortButton('Gemeldet').click(); await flush(); expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'reported' }); expect(pressedSort()).toEqual(['Gemeldet']); sortButton('Ausgeblendet').click(); await flush(); expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'hidden' }); expect(pressedSort()).toEqual(['Ausgeblendet']); sortButton('Beliebt').click(); await flush(); expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' }); }); it('eine späte Antwort der früheren Ansicht überschreibt die neuere nicht', async () => { await mount(); const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); fx.listLogos.mockReturnValueOnce(slow.promise); sortButton('Neu').click(); fx.listLogos.mockResolvedValueOnce({ logos: [logo('p')], nextCursor: null }); sortButton('Beliebt').click(); await flush(); expect(names()).toEqual(['Kein Logo', 'Logo P']); slow.resolve({ logos: [logo('q')], nextCursor: null }); await flush(); expect(names()).toEqual(['Kein Logo', 'Logo P']); expect(pressedSort()).toEqual(['Beliebt']); }); it('verliert der Nutzer die Admin-Rolle (Abmelden), verschwinden die Admin-Knöpfe und die Liste wird wieder öffentlich geladen', async () => { admin = true; await mount(); sortButton('Gemeldet').click(); await flush(); admin = false; user = null; emitAuth(); await flush(); expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu']); expect(pressedSort()).toEqual(['Beliebt']); expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' }); }); it('wird jemand Admin (Anmeldung), erscheinen die Admin-Knöpfe, ohne dass die Liste neu geladen wird', async () => { user = null; await mount(); admin = true; user = 'u1'; emitAuth(); await flush(); expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu', 'Gemeldet', 'Ausgeblendet']); expect(fx.listLogos).toHaveBeenCalledTimes(1); }); }); describe('Mehr laden', () => { const page2 = () => ({ logos: [logo('d'), logo('e')], nextCursor: null }); it('der Knopf erscheint nur, wenn es einen nextCursor gibt', async () => { await mount(); expect(moreButton().hidden).toBe(true); picker.dispose(); fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' }); await mount(); expect(moreButton().hidden).toBe(false); expect(moreButton().textContent).toBe('Mehr laden'); }); it('ein Klick hängt die nächste Seite hinten an (mit Cursor), danach verschwindet der Knopf', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' }); await mount(); const first = tile('Logo A'); fx.listLogos.mockResolvedValue(page2()); moreButton().click(); await flush(); expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'b' }); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo D', 'Logo E']); expect(tile('Logo A')).toBe(first); // die vorhandenen Kacheln bleiben expect(moreButton().hidden).toBe(true); }); it('während des Ladens ist der Knopf gesperrt, ein zweiter Klick löst keine zweite Anfrage aus', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' }); await mount(); const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); fx.listLogos.mockReturnValue(slow.promise); moreButton().click(); expect(moreButton().disabled).toBe(true); moreButton().click(); moreButton().disabled = false; // selbst wenn der Klick den Knopf erreicht moreButton().click(); expect(fx.listLogos).toHaveBeenCalledTimes(2); slow.resolve(page2()); await flush(); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo D', 'Logo E']); }); it('ein Logo, das auf der nächsten Seite noch einmal vorkommt (die Rangfolge hat sich verschoben), bleibt an seinem Platz und steht nur einmal im Raster', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b'), logo('c')], nextCursor: 'c' }); await mount(); fx.listLogos.mockResolvedValue({ logos: [logo('b', { likeCount: 4 }), logo('d')], nextCursor: null }); moreButton().click(); await flush(); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C', 'Logo D']); expect(heart('Logo B').textContent).toBe('4'); // die jüngere Zeile gilt }); it('scheitert das Nachladen: Meldung und „Nochmal versuchen“ holen genau diese Seite erneut, die Liste bleibt', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' }); await mount(); fx.listLogos.mockRejectedValueOnce(new Error('offline')); moreButton().click(); await flush(); expect(alertText()).toBe('Die Logos konnten nicht geladen werden.'); expect(names()).toEqual(['Kein Logo', 'Logo A']); expect(retryButton().hidden).toBe(false); expect(moreButton().hidden).toBe(true); fx.listLogos.mockResolvedValue(page2()); retryButton().click(); await flush(); expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'a' }); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo D', 'Logo E']); expect(alertText()).toBe(''); expect(retryButton().hidden).toBe(true); }); describe('der Cursor ist weg (das Logo dahinter wurde von jemandem gelöscht oder ausgeblendet)', () => { /** Appwrite: 400 `general_cursor_not_found`. */ const cursorGone = () => Object.assign(new Error('The current cursor could not be found.'), { code: 400, type: 'general_cursor_not_found' }); it('„Mehr laden“ beginnt von vorn: die erste Seite der Ansicht wird neu geladen, kein Fehler, kein „Nochmal versuchen“ mit dem toten Cursor', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' }); await mount(); fx.listLogos.mockRejectedValueOnce(cursorGone()); fx.listLogos.mockResolvedValueOnce({ logos: [logo('c'), logo('b', { likeCount: 3 })], nextCursor: null }); moreButton().click(); await flush(); // erste Anfrage: Seite 1, zweite: mit dem toten Cursor, dritte: wieder Seite 1 (ohne Cursor), gleiche Ansicht expect(fx.listLogos.mock.calls.map(([opts]) => opts)).toEqual([ { sort: 'popular', filter: 'all' }, { sort: 'popular', filter: 'all', cursor: 'b' }, { sort: 'popular', filter: 'all' }, ]); expect(names()).toEqual(['Kein Logo', 'Logo C', 'Logo B']); expect(heart('Logo B').textContent).toBe('3'); // die frische Zeile gilt expect(alertText()).toBe(''); expect(retryButton().hidden).toBe(true); expect(moreButton().hidden).toBe(true); // die neue Seite war die letzte }); it('die Ansicht bleibt (hier die Admin-Ansicht „Gemeldet“): neu geladen wird mit derselben Sortierung und demselben Filter', async () => { admin = true; await mount(); fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' }); sortButton('Gemeldet').click(); await flush(); fx.listLogos.mockRejectedValueOnce(cursorGone()); fx.listLogos.mockResolvedValueOnce({ logos: [logo('z')], nextCursor: 'z' }); moreButton().click(); await flush(); expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'reported' }); expect(names()).toEqual(['Kein Logo', 'Logo Z']); expect(pressedSort()).toEqual(['Gemeldet']); expect(moreButton().hidden).toBe(false); // und es geht mit dem neuen Cursor weiter fx.listLogos.mockResolvedValueOnce({ logos: [logo('y')], nextCursor: null }); moreButton().click(); await flush(); expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'reported', cursor: 'z' }); expect(names()).toEqual(['Kein Logo', 'Logo Z', 'Logo Y']); }); it('die Kachel des gewählten Logos bleibt stehen (kein Flackern), die übrigen werden ersetzt', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' }); await mount(); picker.select('a'); const selected = tile('Logo A'); fx.listLogos.mockRejectedValueOnce(cursorGone()); fx.listLogos.mockResolvedValueOnce({ logos: [logo('a'), logo('c')], nextCursor: null }); moreButton().click(); await flush(); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo C']); expect(tile('Logo A')).toBe(selected); expect(pressedTiles()).toEqual(['Logo A']); }); it('scheitert das Neuladen selbst (Netz), steht der gewohnte Fehler mit „Nochmal versuchen“, das die erste Seite holt', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' }); await mount(); fx.listLogos.mockRejectedValueOnce(cursorGone()); fx.listLogos.mockRejectedValueOnce(new Error('offline')); moreButton().click(); await flush(); expect(alertText()).toBe('Die Logos konnten nicht geladen werden.'); expect(retryButton().hidden).toBe(false); fx.listLogos.mockResolvedValue({ logos: [logo('c')], nextCursor: null }); retryButton().click(); await flush(); expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' }); expect(names()).toEqual(['Kein Logo', 'Logo C']); expect(alertText()).toBe(''); }); it('nur dieser Fehlertyp löst das Neuladen aus: jeder andere Fehler beim Nachladen bleibt ein Fehler mit „Nochmal versuchen“ für denselben Cursor', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' }); await mount(); fx.listLogos.mockRejectedValueOnce(Object.assign(new Error('Bad request'), { code: 400, type: 'general_query_invalid' })); moreButton().click(); await flush(); expect(fx.listLogos).toHaveBeenCalledTimes(2); expect(alertText()).toBe('Die Logos konnten nicht geladen werden.'); expect(names()).toEqual(['Kein Logo', 'Logo A']); expect(retryButton().hidden).toBe(false); }); it('beim ersten Laden einer Ansicht gibt es keinen Cursor: derselbe Fehlertyp dort ist ein gewöhnlicher Ladefehler, kein Neuladen im Kreis', async () => { fx.listLogos.mockRejectedValue(cursorGone()); await mount(); expect(fx.listLogos).toHaveBeenCalledTimes(1); expect(alertText()).toBe('Die Logos konnten nicht geladen werden.'); expect(retryButton().hidden).toBe(false); }); }); }); describe('Auswahl', () => { it('ein Klick auf eine Kachel meldet onSelect(id), „Kein Logo“ meldet onSelect(null)', async () => { await mount(); tile('Logo B').click(); expect(fx.onSelect).toHaveBeenLastCalledWith('b'); tile('Kein Logo').click(); expect(fx.onSelect).toHaveBeenLastCalledWith(null); expect(fx.onSelect).toHaveBeenCalledTimes(2); }); it('ein Klick auf die schon gewählte Kachel meldet onSelect noch einmal (der Editor versucht damit ein gescheitertes Laden des Logos erneut)', async () => { await mount(); picker.select('b'); tile('Logo B').click(); tile('Logo B').click(); expect(fx.onSelect).toHaveBeenCalledTimes(2); expect(fx.onSelect).toHaveBeenLastCalledWith('b'); }); it('ein Klick auf das Herz wählt das Logo nicht', async () => { await mount(); heart('Logo B').click(); await flush(); expect(fx.onSelect).not.toHaveBeenCalled(); }); it('aria-pressed folgt select(): zuerst „Kein Logo“, dann das gewählte Logo, dann wieder keins', async () => { await mount(); expect(pressedTiles()).toEqual(['Kein Logo']); picker.select('b'); expect(pressedTiles()).toEqual(['Logo B']); picker.select('c'); expect(pressedTiles()).toEqual(['Logo C']); picker.select(null); expect(pressedTiles()).toEqual(['Kein Logo']); }); it('ein Klick markiert noch nichts: die Auswahl gehört dem Aufrufer (select)', async () => { await mount(); tile('Logo B').click(); expect(pressedTiles()).toEqual(['Kein Logo']); picker.select('b'); expect(pressedTiles()).toEqual(['Logo B']); }); it('ein gewähltes Logo der Liste braucht kein getLogo; selected() liefert seine Zeile', async () => { await mount(); expect(picker.selected()).toBeNull(); picker.select('b'); await flush(); expect(fx.getLogo).not.toHaveBeenCalled(); expect(picker.selected()).toEqual(logo('b')); picker.select(null); expect(picker.selected()).toBeNull(); }); it('ein Logo außerhalb der Liste wird per getLogo geholt und steht als zweite Kachel direkt nach „Kein Logo“', async () => { fx.getLogo.mockResolvedValue(logo('x', { name: 'Fremdes Logo' })); await mount(); picker.select('x'); expect(picker.selected()).toBeNull(); // noch unbekannt await flush(); expect(fx.getLogo).toHaveBeenCalledExactlyOnceWith('x'); expect(names()).toEqual(['Kein Logo', 'Fremdes Logo', 'Logo A', 'Logo B', 'Logo C']); expect(pressedTiles()).toEqual(['Fremdes Logo']); expect(picker.selected()).toMatchObject({ id: 'x', name: 'Fremdes Logo' }); }); it('der angeheftete Eintrag hat wie jede Kachel ein Herz; angemeldet wird sein Zustand geladen', async () => { fx.getLogo.mockResolvedValue(logo('x', { name: 'Fremdes Logo', likeCount: 5 })); fx.myHearts.mockImplementation(async (_u, ids) => new Set(ids.filter((id) => id === 'x'))); await mount(); picker.select('x'); await flush(); expect(fx.myHearts).toHaveBeenLastCalledWith('u1', ['x']); expect(heart('Fremdes Logo').textContent).toBe('5'); expect(heartsOn()).toEqual(['Fremdes Logo']); }); it('solange die Liste noch lädt, wird nicht nachgefragt: steht das Logo auf der ersten Seite, gibt es kein getLogo und keinen Sprung', async () => { const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); fx.listLogos.mockReturnValue(list.promise); picker = createLogoPicker({ onSelect: fx.onSelect }, deps); host.append(picker.el); picker.select('b'); await flush(); expect(fx.getLogo).not.toHaveBeenCalled(); expect(names()).toEqual(['Kein Logo']); list.resolve({ logos: [logo('a'), logo('b')], nextCursor: null }); await flush(); expect(fx.getLogo).not.toHaveBeenCalled(); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B']); expect(pressedTiles()).toEqual(['Logo B']); expect(picker.selected()).toEqual(logo('b')); }); it('steht das Logo nicht auf der ersten Seite, wird es nach dem Laden der Liste geholt und angeheftet', async () => { fx.getLogo.mockResolvedValue(logo('x')); const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); fx.listLogos.mockReturnValue(list.promise); picker = createLogoPicker({ onSelect: fx.onSelect }, deps); host.append(picker.el); picker.select('x'); await flush(); expect(fx.getLogo).not.toHaveBeenCalled(); list.resolve({ logos: [logo('a')], nextCursor: null }); await flush(); expect(fx.getLogo).toHaveBeenCalledExactlyOnceWith('x'); expect(names()).toEqual(['Kein Logo', 'Logo X', 'Logo A']); }); it('scheitert das Laden der Liste, wird das gewählte Logo trotzdem geholt und angeheftet', async () => { fx.getLogo.mockResolvedValue(logo('x')); fx.listLogos.mockRejectedValue(new Error('offline')); picker = createLogoPicker({ onSelect: fx.onSelect }, deps); host.append(picker.el); picker.select('x'); await flush(); expect(names()).toEqual(['Kein Logo', 'Logo X']); }); it('ein Logo, das es nicht mehr gibt (getLogo → null): keine Kachel, nichts gewählt, selected() ist null, kein Fehler', async () => { await mount(); picker.select('weg'); await flush(); expect(fx.getLogo).toHaveBeenCalledWith('weg'); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); expect(pressedTiles()).toEqual([]); // auch „Kein Logo“ nicht: die Auswahl bleibt, bis jemand ein anderes wählt expect(picker.selected()).toBeNull(); expect(alertText()).toBe(''); }); it('scheitert getLogo (Netz), bleibt die Oberfläche heil: keine Kachel, kein Fehlertext, kein Absturz', async () => { fx.getLogo.mockRejectedValue(new Error('offline')); await mount(); picker.select('x'); await flush(); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); expect(picker.selected()).toBeNull(); expect(alertText()).toBe(''); }); it('dieselbe Auswahl noch einmal (der Editor ruft select bei jeder Änderung) fragt nicht erneut nach', async () => { fx.getLogo.mockResolvedValue(logo('x')); await mount(); picker.select('x'); picker.select('x'); await flush(); picker.select('x'); await flush(); expect(fx.getLogo).toHaveBeenCalledTimes(1); expect(names().filter((n) => n === 'Logo X')).toHaveLength(1); }); it('wechselt die Auswahl, fällt der angeheftete Eintrag weg; „Kein Logo“ räumt ihn ebenfalls ab', async () => { fx.getLogo.mockResolvedValue(logo('x')); await mount(); picker.select('x'); await flush(); expect(names()).toContain('Logo X'); picker.select('b'); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); picker.select('x'); // schon bekannt: sofort wieder da, ohne neue Anfrage expect(names()).toEqual(['Kein Logo', 'Logo X', 'Logo A', 'Logo B', 'Logo C']); expect(fx.getLogo).toHaveBeenCalledTimes(1); picker.select(null); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); }); it('eine späte Antwort von getLogo für eine inzwischen verlassene Auswahl wird verworfen', async () => { await mount(); const slow = deferred(); fx.getLogo.mockReturnValueOnce(slow.promise); picker.select('x'); picker.select('b'); slow.resolve(logo('x')); await flush(); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); expect(picker.selected()).toEqual(logo('b')); expect(fx.myHearts).not.toHaveBeenCalledWith('u1', ['x']); // für das verlassene Logo wird nichts weiter angefragt }); it('taucht das angeheftete Logo nach einem Wechsel der Sortierung in der Liste auf, steht es nicht doppelt da', async () => { fx.getLogo.mockResolvedValue(logo('x')); await mount(); picker.select('x'); await flush(); fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('x')], nextCursor: null }); sortButton('Neu').click(); await flush(); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo X']); expect(pressedTiles()).toEqual(['Logo X']); }); it('bei einem Wechsel der Sortierung bleibt der angeheftete Eintrag stehen, solange er nicht in der neuen Liste ist', async () => { fx.getLogo.mockResolvedValue(logo('x')); await mount(); picker.select('x'); await flush(); const pinned = tile('Logo X'); fx.listLogos.mockResolvedValue({ logos: [logo('z')], nextCursor: null }); sortButton('Neu').click(); await flush(); expect(names()).toEqual(['Kein Logo', 'Logo X', 'Logo Z']); expect(tile('Logo X')).toBe(pinned); }); }); describe('Herzen', () => { it('angemeldet: ein Klick schaltet sofort um (aria-pressed, Zahl + 1), ruft setHeart(id, true) und übernimmt likeCount der Antwort', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 2 })], nextCursor: null }); const answer = deferred<{ herz: boolean; likeCount: number }>(); fx.setHeart.mockReturnValue(answer.promise); await mount(); heart('Logo A').click(); expect(heart('Logo A').getAttribute('aria-pressed')).toBe('true'); // sofort, vor der Antwort expect(heart('Logo A').textContent).toBe('3'); expect(fx.setHeart).toHaveBeenCalledExactlyOnceWith('a', true); answer.resolve({ herz: true, likeCount: 7 }); // der Dienst hat neu gezählt await flush(); expect(heart('Logo A').getAttribute('aria-pressed')).toBe('true'); expect(heart('Logo A').textContent).toBe('7'); expect(heart('Logo A').getAttribute('aria-label')).toBe('Herz für „Logo A“: 7'); expect(alertText()).toBe(''); expect(infoMessage()).toBe(''); }); it('ein zweiter Klick nimmt das Herz zurück: setHeart(id, false), Zahl − 1', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 2 })], nextCursor: null }); fx.myHearts.mockResolvedValue(new Set(['a'])); await mount(); expect(heartsOn()).toEqual(['Logo A']); const answer = deferred<{ herz: boolean; likeCount: number }>(); fx.setHeart.mockReturnValue(answer.promise); heart('Logo A').click(); expect(heart('Logo A').getAttribute('aria-pressed')).toBe('false'); expect(heart('Logo A').textContent).toBe('1'); expect(fx.setHeart).toHaveBeenCalledExactlyOnceWith('a', false); answer.resolve({ herz: false, likeCount: 1 }); await flush(); expect(heartsOn()).toEqual([]); expect(heart('Logo A').textContent).toBe('1'); }); it('die Zahl wird nie negativ', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 0 })], nextCursor: null }); fx.myHearts.mockResolvedValue(new Set(['a'])); // veraltet: Zähler 0, Herz an fx.setHeart.mockReturnValue(new Promise(() => {})); await mount(); heart('Logo A').click(); expect(heart('Logo A').textContent).toBe('0'); }); it('Fehler: das Herz springt samt Zahl zurück, der Grund steht in der Infozeile (role="alert"), nicht im Statusbereich der Liste', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 2 })], nextCursor: null }); fx.setHeart.mockRejectedValue(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone')); await mount(); heart('Logo A').click(); await flush(); expect(heart('Logo A').getAttribute('aria-pressed')).toBe('false'); expect(heart('Logo A').textContent).toBe('2'); expect(infoMessage()).toBe('Dieses Logo ist nicht (mehr) verfügbar.'); expect(alertText()).toBe(''); }); it('Fehler anderer Art: allgemeiner Text; der Fehler verschwindet beim nächsten Versuch, der dann auch wieder möglich ist', async () => { fx.setHeart.mockRejectedValueOnce(new Error('kaputt')); await mount(); heart('Logo A').click(); await flush(); expect(infoMessage()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); expect(heartsOn()).toEqual([]); heart('Logo A').click(); // die Anfrage ist zu Ende: der Knopf ist wieder frei expect(infoMessage()).toBe(''); await flush(); expect(fx.setHeart).toHaveBeenCalledTimes(2); expect(heartsOn()).toEqual(['Logo A']); }); it('ein zweiter Klick während der laufenden Anfrage löst keinen zweiten Aufruf aus', async () => { const answer = deferred<{ herz: boolean; likeCount: number }>(); fx.setHeart.mockReturnValue(answer.promise); await mount(); heart('Logo A').click(); heart('Logo A').click(); heart('Logo A').click(); expect(fx.setHeart).toHaveBeenCalledTimes(1); expect(heartsOn()).toEqual(['Logo A']); // und der Zustand wurde nicht zurückgeschaltet answer.resolve({ herz: true, likeCount: 1 }); await flush(); heart('Logo A').click(); // danach geht es wieder (jetzt: Herz weg) expect(fx.setHeart).toHaveBeenCalledTimes(2); expect(fx.setHeart).toHaveBeenLastCalledWith('a', false); }); it('die Sperre gilt je Logo: das Herz eines anderen Logos geht gleichzeitig', async () => { fx.setHeart.mockReturnValue(new Promise(() => {})); await mount(); heart('Logo A').click(); heart('Logo B').click(); expect(fx.setHeart.mock.calls).toEqual([ ['a', true], ['b', true], ]); }); it('Gast: ein Klick öffnet den Login (requireLogin); danach wird das Herz gesetzt', async () => { user = null; await mount(); expect(fx.myHearts).not.toHaveBeenCalled(); // Gäste haben keine Herzen zu laden const login = deferred(); fx.requireLogin.mockReturnValue(login.promise); heart('Logo A').click(); expect(fx.requireLogin).toHaveBeenCalledTimes(1); expect(fx.setHeart).not.toHaveBeenCalled(); expect(heartsOn()).toEqual([]); user = 'u1'; login.resolve(true); await flush(); expect(fx.setHeart).toHaveBeenCalledExactlyOnceWith('a', true); expect(heartsOn()).toEqual(['Logo A']); }); it('Gast bricht den Login ab: kein setHeart, das Herz bleibt aus, kein Fehlertext, und es geht danach wieder', async () => { user = null; fx.requireLogin.mockResolvedValue(false); await mount(); heart('Logo A').click(); await flush(); expect(fx.setHeart).not.toHaveBeenCalled(); expect(heartsOn()).toEqual([]); expect(heart('Logo A').textContent).toBe('0'); expect(alertText()).toBe(''); expect(infoMessage()).toBe(''); heart('Logo A').click(); // kein hängender Zustand: der nächste Klick fragt wieder nach dem Login await flush(); expect(fx.requireLogin).toHaveBeenCalledTimes(2); }); it('Gast: ein zweiter Klick, solange der Login-Dialog noch offen ist, öffnet keinen zweiten', async () => { user = null; fx.requireLogin.mockReturnValue(new Promise(() => {})); await mount(); heart('Logo A').click(); heart('Logo A').click(); expect(fx.requireLogin).toHaveBeenCalledTimes(1); }); it('nach dem Laden einer Seite (angemeldet) kommen die Herzen mit genau den IDs der Seite; sie stehen an den Kacheln', async () => { fx.myHearts.mockResolvedValue(new Set(['b'])); await mount(); expect(fx.myHearts).toHaveBeenCalledExactlyOnceWith('u1', ['a', 'b', 'c']); expect(heartsOn()).toEqual(['Logo B']); }); it('beim Nachladen werden die Herzen nur für die neue Seite geholt', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' }); await mount(); fx.listLogos.mockResolvedValue({ logos: [logo('c'), logo('d')], nextCursor: null }); fx.myHearts.mockResolvedValue(new Set(['d'])); moreButton().click(); await flush(); expect(fx.myHearts).toHaveBeenLastCalledWith('u1', ['c', 'd']); expect(heartsOn()).toEqual(['Logo D']); }); it('scheitert das Laden der Herzen, bleibt die Liste da (die Herzen sind dann aus, kein Fehlertext)', async () => { fx.myHearts.mockRejectedValue(new Error('offline')); await mount(); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); expect(heartsOn()).toEqual([]); expect(alertText()).toBe(''); }); it('Abmelden: alle Herzen sind aus', async () => { fx.myHearts.mockResolvedValue(new Set(['a', 'c'])); await mount(); expect(heartsOn()).toEqual(['Logo A', 'Logo C']); user = null; emitAuth(); expect(heartsOn()).toEqual([]); }); it('Anmelden: die Herzen aller gezeigten Logos werden geladen', async () => { user = null; await mount(); fx.myHearts.mockResolvedValue(new Set(['b'])); user = 'u1'; emitAuth(); await flush(); expect(fx.myHearts).toHaveBeenCalledExactlyOnceWith('u1', ['a', 'b', 'c']); expect(heartsOn()).toEqual(['Logo B']); }); it('eine späte Antwort von myHearts überschreibt kein Herz, das inzwischen geschaltet wurde', async () => { const hearts = deferred>(); fx.myHearts.mockReturnValue(hearts.promise); await mount(); heart('Logo A').click(); await flush(); expect(heartsOn()).toEqual(['Logo A']); hearts.resolve(new Set(['c'])); // stammt von vor dem Klick: A kennt sie nicht, C schon await flush(); expect(heartsOn()).toEqual(['Logo A', 'Logo C']); }); it('Herzen einer Seite für den vorigen Nutzer werden nach einem Kontowechsel nicht übernommen', async () => { const hearts = deferred>(); fx.myHearts.mockReturnValue(hearts.promise); await mount(); user = null; // abgemeldet, bevor die Antwort kommt emitAuth(); hearts.resolve(new Set(['a'])); await flush(); expect(heartsOn()).toEqual([]); }); }); describe('Leer und Ladefehler', () => { it('leere Liste: „Noch keine Community-Logos – lade das erste hoch.“ (erst nach dem Laden; „Kein Logo“ bleibt)', async () => { const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); fx.listLogos.mockReturnValue(list.promise); picker = createLogoPicker({ onSelect: fx.onSelect }, deps); host.append(picker.el); await flush(); expect(emptyText()).toBe(''); list.resolve({ logos: [], nextCursor: null }); await flush(); expect(emptyText()).toBe('Noch keine Community-Logos – lade das erste hoch.'); expect(names()).toEqual(['Kein Logo']); }); it('der Leer-Text steht nicht, wenn es Logos gibt oder das Laden scheiterte', async () => { await mount(); expect(emptyText()).toBe(''); picker.dispose(); fx.listLogos.mockRejectedValue(new Error('offline')); await mount(); expect(emptyText()).toBe(''); }); it('ein leerer Admin-Filter sagt das, statt zum Hochladen aufzufordern', async () => { admin = true; await mount(); fx.listLogos.mockResolvedValue({ logos: [], nextCursor: null }); sortButton('Gemeldet').click(); await flush(); expect(emptyText()).toBe('Keine Logos in dieser Ansicht.'); sortButton('Beliebt').click(); await flush(); expect(emptyText()).toBe('Noch keine Community-Logos – lade das erste hoch.'); }); it('Ladefehler: „Die Logos konnten nicht geladen werden.“ mit „Nochmal versuchen“, das neu lädt', async () => { fx.listLogos.mockRejectedValueOnce(new Error('offline')); await mount(); expect(alertText()).toBe('Die Logos konnten nicht geladen werden.'); expect(retryButton().hidden).toBe(false); expect(retryButton().textContent).toBe('Nochmal versuchen'); expect(names()).toEqual(['Kein Logo']); retryButton().click(); await flush(); expect(fx.listLogos).toHaveBeenCalledTimes(2); expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' }); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); expect(alertText()).toBe(''); expect(retryButton().hidden).toBe(true); }); it('der Statusbereich ist immer da (leer: kein Text), ohne Fehler ist „Nochmal versuchen“ verborgen', async () => { await mount(); expect(alertText()).toBe(''); expect(retryButton().hidden).toBe(true); }); it('Englisch: Leer-Text, Ladefehler und „Nochmal versuchen“', async () => { setLang('en', { persist: false }); fx.listLogos.mockResolvedValueOnce({ logos: [], nextCursor: null }); await mount(); expect(emptyText()).toBe('No community logos yet – upload the first one.'); picker.dispose(); fx.listLogos.mockRejectedValueOnce(new Error('offline')); await mount(); expect(alertText()).toBe('The logos couldn’t be loaded.'); expect(retryButton().textContent).toBe('Try again'); }); }); describe('Hochladen', () => { const fresh = () => logo('n', { name: 'Neues Logo', ownerId: 'u1' }); it('angemeldet: der Dialog öffnet ohne Login; das Ergebnis steht als erste Logo-Kachel, rememberLogo und onSelect(id) laufen', async () => { fx.uploadDialog.mockResolvedValue(fresh()); await mount(); uploadButton().click(); await flush(); expect(fx.requireLogin).not.toHaveBeenCalled(); expect(fx.uploadDialog).toHaveBeenCalledTimes(1); expect(names()).toEqual(['Kein Logo', 'Neues Logo', 'Logo A', 'Logo B', 'Logo C']); expect(mocks.rememberLogo).toHaveBeenCalledExactlyOnceWith(fresh()); expect(fx.onSelect).toHaveBeenCalledExactlyOnceWith('n'); }); it('das neue Logo ist ein Logo mit Herz-Knopf (Zahl 0), und die Auswahl durch den Aufrufer markiert es ohne getLogo', async () => { fx.uploadDialog.mockResolvedValue(fresh()); await mount(); uploadButton().click(); await flush(); expect(heart('Neues Logo').textContent).toBe('0'); picker.select('n'); await flush(); expect(pressedTiles()).toEqual(['Neues Logo']); expect(fx.getLogo).not.toHaveBeenCalled(); expect(picker.selected()).toEqual(fresh()); }); it('Gast: zuerst requireLogin, dann der Dialog', async () => { user = null; const order: string[] = []; fx.requireLogin.mockImplementation(async () => { order.push('login'); return true; }); fx.uploadDialog.mockImplementation(async () => { order.push('dialog'); return null; }); await mount(); uploadButton().click(); await flush(); expect(order).toEqual(['login', 'dialog']); }); it('Gast bricht den Login ab: kein Dialog, nichts ändert sich, kein Fehlertext', async () => { user = null; fx.requireLogin.mockResolvedValue(false); await mount(); uploadButton().click(); await flush(); expect(fx.uploadDialog).not.toHaveBeenCalled(); expect(fx.onSelect).not.toHaveBeenCalled(); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); expect(alertText()).toBe(''); }); it('Dialog abgebrochen (null): keine Kachel, keine Auswahl, kein rememberLogo; der Knopf geht danach wieder', async () => { await mount(); uploadButton().click(); await flush(); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); expect(fx.onSelect).not.toHaveBeenCalled(); expect(mocks.rememberLogo).not.toHaveBeenCalled(); uploadButton().click(); await flush(); expect(fx.uploadDialog).toHaveBeenCalledTimes(2); }); it('ein zweiter Klick, solange der Dialog offen ist, öffnet keinen zweiten', async () => { fx.uploadDialog.mockReturnValue(new Promise(() => {})); await mount(); uploadButton().click(); uploadButton().click(); await flush(); expect(fx.uploadDialog).toHaveBeenCalledTimes(1); }); it('der Dialog bekommt ein Signal, das beim Aufräumen der Seite ausgelöst wird; ein später Treffer wird verworfen', async () => { const result = deferred(); fx.uploadDialog.mockReturnValue(result.promise); await mount(); uploadButton().click(); await flush(); const signal = fx.uploadDialog.mock.calls[0][0]; expect(signal.aborted).toBe(false); picker.dispose(); expect(signal.aborted).toBe(true); result.resolve(fresh()); await flush(); expect(fx.onSelect).not.toHaveBeenCalled(); expect(mocks.rememberLogo).not.toHaveBeenCalled(); }); it('lässt sich der Dialog gar nicht öffnen (Fehler), steht der Grund in der Infozeile (der Statusbereich der Liste bleibt für Ladefehler)', async () => { fx.uploadDialog.mockRejectedValue(new Error('kein ')); await mount(); uploadButton().click(); await flush(); expect(infoMessage()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); expect(alertText()).toBe(''); }); }); describe('Kachelbilder', () => { it('jede Kachel zeigt das PNG ihres Logos: loadLogoBlob bekommt die Zeile, das trägt die Object-URL (dekorativ, alt leer)', async () => { await mount(); expect(fx.loadLogoBlob.mock.calls.map(([row]) => row.id)).toEqual(['a', 'b', 'c']); const img = art('Logo A').querySelector('img')!; expect(img.getAttribute('src')).toBe(created[0]); expect(img.getAttribute('alt')).toBe(''); expect(art('Logo A').getAttribute('data-state')).toBe('ready'); }); it('ein einfarbiges Logo wird als Maske gezeigt (die Zeile mit mono kommt bei der Kachel an)', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a', { mono: true })], nextCursor: null }); await mount(); expect(art('Logo A').querySelector('img')).toBeNull(); expect(art('Logo A').querySelector('.logo-tile-mask')).not.toBeNull(); }); it('ein unbrauchbares Bild (über der Pixelgrenze) lässt die Kachel heil: Platzhalter, Name, Herz und Auswahl funktionieren', async () => { sizes.set('c', [60_000, 60_000]); await mount(); expect(art('Logo C').getAttribute('data-state')).toBe('unusable'); expect(art('Logo C').querySelector('img')).toBeNull(); expect(revoked).toEqual([created[2]]); expect(art('Logo A').getAttribute('data-state')).toBe('ready'); expect(heart('Logo C').textContent).toBe('0'); tile('Logo C').click(); expect(fx.onSelect).toHaveBeenCalledWith('c'); }); it('Kacheln, die bei einem Wechsel der Sortierung wegfallen, geben ihre Object-URL frei; die neuen laden ihr Bild', async () => { await mount(); expect(created).toHaveLength(3); fx.listLogos.mockResolvedValue({ logos: [logo('z')], nextCursor: null }); sortButton('Neu').click(); await flush(); expect(revoked.sort()).toEqual([...created.slice(0, 3)].sort()); expect(art('Logo Z').getAttribute('data-state')).toBe('ready'); }); it('die Kachel des gewählten Logos behält ihr Bild beim Wechsel der Sortierung (kein Neuladen, kein Flackern)', async () => { await mount(); picker.select('b'); fx.listLogos.mockResolvedValue({ logos: [logo('z')], nextCursor: null }); sortButton('Neu').click(); await flush(); expect(fx.loadLogoBlob.mock.calls.filter(([row]) => row.id === 'b')).toHaveLength(1); expect(revoked).not.toContain(created[1]); expect(art('Logo B').querySelector('img')!.getAttribute('src')).toBe(created[1]); }); it('eine Datei, die erst nach dem Wegfall der Kachel ankommt, wird nicht mehr angelegt', async () => { const slow = deferred(); fx.loadLogoBlob.mockReturnValueOnce(slow.promise); await mount(); fx.listLogos.mockResolvedValue({ logos: [logo('z')], nextCursor: null }); sortButton('Neu').click(); await flush(); const before = created.length; slow.resolve(new Blob(['spät'])); await flush(); expect(created).toHaveLength(before); }); }); describe('dispose', () => { it('gibt alle Object-URLs frei und meldet sich von der Anmeldung ab', async () => { await mount(); expect(authListeners.size).toBe(1); picker.dispose(); expect(revoked.sort()).toEqual([...created].sort()); expect(authListeners.size).toBe(0); picker.dispose(); // ein zweiter Aufruf schadet nicht expect(revoked).toHaveLength(created.length); }); it('Antworten, die nach dem Aufräumen eintreffen, ändern nichts und werfen nichts', async () => { const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); fx.listLogos.mockReturnValue(list.promise); picker = createLogoPicker({ onSelect: fx.onSelect }, deps); host.append(picker.el); picker.select('x'); picker.dispose(); list.resolve({ logos: [logo('a')], nextCursor: null }); await flush(); expect(names()).toEqual(['Kein Logo']); expect(created).toEqual([]); expect(fx.getLogo).not.toHaveBeenCalled(); expect(fx.myHearts).not.toHaveBeenCalled(); }); it('select() nach dem Aufräumen ändert nichts und fragt nichts mehr an', async () => { await mount(); picker.dispose(); picker.select('b'); picker.select('x'); await flush(); expect(pressedTiles()).toEqual(['Kein Logo']); expect(fx.getLogo).not.toHaveBeenCalled(); expect(picker.selected()).toBeNull(); expect(infoLine()).toBe(''); }); it('eine Herz-Antwort nach dem Aufräumen wird verworfen', async () => { const answer = deferred<{ herz: boolean; likeCount: number }>(); fx.setHeart.mockReturnValue(answer.promise); await mount(); heart('Logo A').click(); picker.dispose(); answer.resolve({ herz: true, likeCount: 9 }); await flush(); expect(heart('Logo A').textContent).toBe('1'); // der Stand von vor dem Aufräumen, kein Fehler }); }); describe('Herzen: Fehler und Anmeldung', () => { it('Gast: lässt sich der Login-Dialog nicht öffnen (requireLogin wirft), steht der Grund in der Infozeile; das Herz bleibt aus, und es geht danach wieder', async () => { user = null; fx.requireLogin.mockRejectedValueOnce(new Error('kein ')); await mount(); heart('Logo A').click(); await flush(); expect(fx.setHeart).not.toHaveBeenCalled(); expect(heartsOn()).toEqual([]); expect(infoMessage()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); heart('Logo A').click(); // kein hängender Zustand await flush(); expect(fx.requireLogin).toHaveBeenCalledTimes(2); expect(fx.setHeart).toHaveBeenCalledExactlyOnceWith('a', true); expect(infoMessage()).toBe(''); }); it('ein Klick auf Herz oder „Hochladen“ löscht den Ladefehler der Liste nicht: der steht allein im Statusbereich', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' }); await mount(); fx.listLogos.mockRejectedValueOnce(new Error('offline')); moreButton().click(); await flush(); expect(alertText()).toBe('Die Logos konnten nicht geladen werden.'); heart('Logo A').click(); await flush(); expect(alertText()).toBe('Die Logos konnten nicht geladen werden.'); uploadButton().click(); await flush(); expect(alertText()).toBe('Die Logos konnten nicht geladen werden.'); expect(retryButton().hidden).toBe(false); }); it('der Herz-Fehler steht auch dann in der Infozeile, wenn das Logo gar nicht gewählt ist', async () => { fx.setHeart.mockRejectedValue(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone')); await mount(); picker.select('c'); heart('Logo A').click(); await flush(); expect(infoMessage()).toBe('Dieses Logo ist nicht (mehr) verfügbar.'); expect(infoLine()).toBe('Logo C · von Kenso · ♥ 0'); picker.select('b'); // eine andere Auswahl räumt den alten Fehler ab expect(infoMessage()).toBe(''); }); }); describe('Infozeile', () => { it('sitzt als Letztes im Bereich, unter dem Raster und „Mehr laden“; ohne Auswahl ist sie leer', async () => { await mount(); expect(picker.el.lastElementChild).toBe(infoEl()); const grid = picker.el.querySelector('ul.logo-grid')!; expect(grid.compareDocumentPosition(infoEl()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(infoLine()).toBe(''); expect(infoButtons()).toEqual([]); expect(infoMessage()).toBe(''); }); it('select(id) zeigt „ · von · ♥ “ und „Melden“ – auch für Gäste; „Kein Logo“ leert sie', async () => { user = null; fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 7 }), logo('b', { displayName: '' })], nextCursor: null }); await mount(); picker.select('a'); expect(infoLine()).toBe('Logo A · von Kenso · ♥ 7'); expect(infoButtons()).toEqual(['Melden']); picker.select('b'); expect(infoLine()).toBe('Logo B · von Anonym · ♥ 0'); picker.select(null); expect(infoLine()).toBe(''); expect(infoButtons()).toEqual([]); }); it('der Zähler der Infozeile folgt dem Herz: sofort und mit der Antwort des Dienstes', async () => { const answer = deferred<{ herz: boolean; likeCount: number }>(); fx.setHeart.mockReturnValue(answer.promise); await mount(); picker.select('a'); heart('Logo A').click(); expect(infoLine()).toBe('Logo A · von Kenso · ♥ 1'); answer.resolve({ herz: true, likeCount: 5 }); await flush(); expect(infoLine()).toBe('Logo A · von Kenso · ♥ 5'); }); it('ein Logo außerhalb der Liste: die Zeile erscheint, sobald getLogo es geholt hat; vorher steht dort nichts', async () => { const slow = deferred(); fx.getLogo.mockReturnValue(slow.promise); await mount(); picker.select('x'); await flush(); expect(infoLine()).toBe(''); expect(infoGone()).toBe(''); slow.resolve(logo('x', { name: 'Fremdes Logo', displayName: 'Anna', likeCount: 2 })); await flush(); expect(infoLine()).toBe('Fremdes Logo · von Anna · ♥ 2'); }); it('solange die Liste noch lädt und das Logo noch unbekannt ist, steht nichts da; danach die Zeile aus der Liste', async () => { const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); fx.listLogos.mockReturnValue(list.promise); picker = createLogoPicker({ onSelect: fx.onSelect }, deps); host.append(picker.el); picker.select('b'); await flush(); expect(infoLine()).toBe(''); expect(infoGone()).toBe(''); list.resolve({ logos: [logo('a'), logo('b', { likeCount: 3 })], nextCursor: null }); await flush(); expect(infoLine()).toBe('Logo B · von Kenso · ♥ 3'); }); it('ein Logo, das es nicht mehr gibt (getLogo → null): „Das gewählte Logo ist nicht mehr verfügbar.“; eine andere Wahl nimmt den Hinweis weg', async () => { await mount(); picker.select('weg'); await flush(); expect(infoGone()).toBe('Das gewählte Logo ist nicht mehr verfügbar.'); expect(infoLine()).toBe(''); expect(infoButtons()).toEqual([]); picker.select('b'); expect(infoGone()).toBe(''); expect(infoLine()).toBe('Logo B · von Kenso · ♥ 0'); picker.select('weg'); // noch einmal: die Antwort von vorhin gilt nicht mehr, es wird neu gefragt expect(infoGone()).toBe(''); await flush(); expect(infoGone()).toBe('Das gewählte Logo ist nicht mehr verfügbar.'); expect(fx.getLogo).toHaveBeenCalledTimes(2); }); it('scheitert getLogo (Netz), sagt die Infozeile nichts: das Logo kann es ja geben', async () => { fx.getLogo.mockRejectedValue(new Error('offline')); await mount(); picker.select('x'); await flush(); expect(infoGone()).toBe(''); expect(infoLine()).toBe(''); }); it('eine späte „gibt es nicht“-Antwort für eine inzwischen verlassene Auswahl bleibt ohne Wirkung', async () => { const slow = deferred(); fx.getLogo.mockReturnValueOnce(slow.promise); await mount(); picker.select('x'); picker.select('b'); slow.resolve(null); await flush(); expect(infoGone()).toBe(''); expect(infoLine()).toBe('Logo B · von Kenso · ♥ 0'); }); it('„Melden“ öffnet den Dialog für Logos (mit dem Signal des Pickers) und sendet reportLogo(id, Grund)', async () => { await mount(); picker.select('b'); infoButton('Melden').click(); await flush(); expect(fx.reportDialog).toHaveBeenCalledTimes(1); const opts = fx.reportDialog.mock.calls[0][0]; expect(opts.subject).toBe('logo'); await opts.send('urheberrecht'); expect(fx.reportLogo).toHaveBeenCalledExactlyOnceWith('b', 'urheberrecht'); expect(opts.signal!.aborted).toBe(false); picker.dispose(); expect(opts.signal!.aborted).toBe(true); }); it('Anmelden und Abmelden: „Löschen“ erscheint für den Hochlader und verschwindet wieder', async () => { user = null; fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' })], nextCursor: null }); await mount(); picker.select('a'); expect(infoButtons()).toEqual(['Melden']); user = 'u1'; emitAuth(); await flush(); expect(infoButtons()).toEqual(['Melden', 'Löschen']); user = null; emitAuth(); expect(infoButtons()).toEqual(['Melden']); }); it('Admin: Anzahl und Gründe der Meldungen, „Ausblenden“ und „Löschen“ – aus der Zeile des Pickers und listLogoReports', async () => { admin = true; fx.listLogos.mockResolvedValue({ logos: [logo('a', { reportCount: 2 })], nextCursor: null }); fx.listLogoReports.mockResolvedValue([ { id: 'r1', logoId: 'a', reason: 'spam', createdAt: '2026-10-04T12:00:00.000Z' }, { id: 'r2', logoId: 'a', reason: 'sonstiges', createdAt: '2026-10-04T12:01:00.000Z' }, ]); await mount(); picker.select('a'); await flush(); expect(fx.listLogoReports).toHaveBeenCalledExactlyOnceWith('a'); expect(infoEl().querySelector('.logo-info-reports')!.textContent).toBe('2 Meldungen'); expect([...infoEl().querySelectorAll('.logo-info-reasons li')].map((li) => li.textContent)).toEqual(['Spam', 'Sonstiges']); expect(infoButtons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); }); it('Englisch: Zeile und Knöpfe sind übersetzt', async () => { setLang('en', { persist: false }); fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1', displayName: '' })], nextCursor: null }); await mount(); picker.select('a'); expect(infoLine()).toBe('Logo A · by Anonymous · ♥ 0'); expect(infoButtons()).toEqual(['Report', 'Delete']); }); }); describe('Löschen (mit Raster und Auswahl)', () => { beforeEach(() => { fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c')], nextCursor: null }); }); it('bestätigt: deleteLogo, forgetLogo, die Kachel ist weg, die Auswahl wird aufgehoben (onSelect(null)); die übrigen bleiben', async () => { await mount(); picker.select('b'); infoButton('Löschen').click(); await flush(); expect(fx.deleteLogo).toHaveBeenCalledExactlyOnceWith('b'); expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('b'); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo C']); expect(fx.onSelect).toHaveBeenCalledExactlyOnceWith(null); expect(picker.selected()).toBeNull(); expect(infoLine()).toBe(''); expect(infoGone()).toBe(''); expect(infoMessage()).toBe(''); expect(moreButton().hidden).toBe(true); // es gab keine weitere Seite, und das Löschen erfindet keine expect(revoked).toContain(created[1]); // das Bild der Kachel ist freigegeben }); it('der Editor übernimmt onSelect(null) mit select(null): „Kein Logo“ ist gewählt, nichts bleibt hängen', async () => { await mount(); picker.select('b'); infoButton('Löschen').click(); await flush(); picker.select(null); expect(pressedTiles()).toEqual(['Kein Logo']); expect(infoLine()).toBe(''); expect(fx.getLogo).not.toHaveBeenCalled(); }); it('abgelehnt: nichts passiert', async () => { fx.confirm.mockResolvedValue(false); await mount(); picker.select('b'); infoButton('Löschen').click(); await flush(); expect(fx.deleteLogo).not.toHaveBeenCalled(); expect(mocks.forgetLogo).not.toHaveBeenCalled(); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); expect(fx.onSelect).not.toHaveBeenCalled(); expect(infoMessage()).toBe(''); }); it('Fehler: der Text steht in der Infozeile; Kachel, Zeile und Auswahl bleiben', async () => { fx.deleteLogo.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')); await mount(); picker.select('b'); infoButton('Löschen').click(); await flush(); expect(infoMessage()).toBe('Dazu hast du keine Berechtigung.'); expect(alertText()).toBe(''); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); expect(fx.onSelect).not.toHaveBeenCalled(); expect(mocks.forgetLogo).not.toHaveBeenCalled(); expect(picker.selected()).toMatchObject({ id: 'b' }); }); it('Löschen sehen nur Hochlader und Admins', async () => { await mount(); picker.select('a'); // gehört u9 expect(infoButtons()).toEqual(['Melden']); picker.select('b'); // gehört u1 = ich expect(infoButtons()).toEqual(['Melden', 'Löschen']); }); it('das letzte Logo gelöscht: der Leer-Text erscheint', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' })], nextCursor: null }); await mount(); picker.select('a'); infoButton('Löschen').click(); await flush(); expect(names()).toEqual(['Kein Logo']); expect(emptyText()).toBe('Noch keine Community-Logos – lade das erste hoch.'); }); it('ein angeheftetes Logo (nicht in der Liste) lässt sich löschen: seine Kachel verschwindet', async () => { fx.getLogo.mockResolvedValue(logo('x', { name: 'Fremdes Logo', ownerId: 'u1' })); await mount(); picker.select('x'); await flush(); expect(names()).toContain('Fremdes Logo'); infoButton('Löschen').click(); await flush(); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); expect(fx.onSelect).toHaveBeenCalledExactlyOnceWith(null); }); it('hat sich die Auswahl während der Anfrage geändert, bleibt die neue Auswahl unberührt (kein onSelect)', async () => { const request = deferred(); fx.deleteLogo.mockReturnValue(request.promise); await mount(); picker.select('b'); infoButton('Löschen').click(); await flush(); picker.select('c'); request.resolve(); await flush(); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo C']); expect(fx.onSelect).not.toHaveBeenCalled(); expect(pressedTiles()).toEqual(['Logo C']); expect(infoLine()).toBe('Logo C · von Kenso · ♥ 0'); }); it('war das gelöschte Logo der Cursor für „Mehr laden“ (die Anfrage braucht ein Logo, das es gibt), setzt das Nachladen beim Logo davor an', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b'), logo('c', { ownerId: 'u1' })], nextCursor: 'c' }); await mount(); picker.select('c'); infoButton('Löschen').click(); await flush(); expect(moreButton().hidden).toBe(false); fx.listLogos.mockResolvedValue({ logos: [logo('d')], nextCursor: null }); moreButton().click(); await flush(); expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'b' }); expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo D']); }); it('ein anderes gelöschtes Logo lässt den Cursor für „Mehr laden“ stehen', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c')], nextCursor: 'c' }); await mount(); picker.select('b'); infoButton('Löschen').click(); await flush(); fx.listLogos.mockResolvedValue({ logos: [logo('d')], nextCursor: null }); moreButton().click(); await flush(); expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'c' }); }); it('ein Logo mit derselben ID, das später auftaucht, erbt das Herz des gelöschten nicht', async () => { fx.myHearts.mockResolvedValueOnce(new Set(['b'])); await mount(); expect(heartsOn()).toEqual(['Logo B']); picker.select('b'); infoButton('Löschen').click(); await flush(); picker.select(null); fx.myHearts.mockReturnValue(new Promise(() => {})); // die Herzen der neuen Liste kommen noch fx.listLogos.mockResolvedValue({ logos: [logo('b', { name: 'Neues B' })], nextCursor: null }); sortButton('Neu').click(); await flush(); expect(names()).toEqual(['Kein Logo', 'Neues B']); expect(heartsOn()).toEqual([]); }); it('der Fokus wandert von „Löschen“, das verschwindet, auf „Kein Logo“', async () => { await mount(); picker.select('b'); infoButton('Löschen').focus(); expect(document.activeElement).toBe(infoButton('Löschen')); infoButton('Löschen').click(); await flush(); expect(document.activeElement).toBe(tile('Kein Logo')); }); it('der Fokus bleibt, wo er ist, wenn er nicht in der Infozeile lag', async () => { await mount(); picker.select('b'); infoButton('Löschen').click(); tile('Logo C').focus(); await flush(); expect(document.activeElement).toBe(tile('Logo C')); }); }); describe('Ausblenden und Einblenden (Admin, mit Raster und Auswahl)', () => { beforeEach(() => { admin = true; fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b', { reportCount: 3 }), logo('c', { hidden: true, reportCount: 3 })], nextCursor: null, }); }); it('„Ausblenden“: setLogoVisibility(id, false), forgetLogo; die Zeile des Pickers ist hidden, die Kachel bleibt', async () => { await mount(); picker.select('b'); infoButton('Ausblenden').click(); await flush(); expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('b', false); expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('b'); expect(picker.selected()).toMatchObject({ id: 'b', hidden: true, reportCount: 3 }); expect(names()).toContain('Logo B'); expect(infoButtons()).toEqual(['Löschen', 'Einblenden']); expect(infoEl().querySelector('.logo-info-reports')!.textContent).toBe('3 Meldungen'); }); it('„Einblenden“: setLogoVisibility(id, true), forgetLogo; die Zeile ist sichtbar und hat 0 Meldungen', async () => { await mount(); picker.select('c'); expect(infoButtons()).toEqual(['Löschen', 'Einblenden']); infoButton('Einblenden').click(); await flush(); expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('c', true); expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('c'); expect(picker.selected()).toMatchObject({ id: 'c', hidden: false, reportCount: 0 }); expect(infoButtons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); expect(infoEl().querySelector('.logo-info-reports')!.textContent).toBe('0 Meldungen'); }); it('Fehler: der Text steht in der Infozeile, die Zeile bleibt unverändert', async () => { fx.setLogoVisibility.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable')); await mount(); picker.select('b'); infoButton('Ausblenden').click(); await flush(); expect(infoMessage()).toBe('Der Dienst ist gerade nicht erreichbar.'); expect(picker.selected()).toMatchObject({ id: 'b', hidden: false }); expect(mocks.forgetLogo).not.toHaveBeenCalled(); }); it('der Herz-Zähler, der sich während der Anfrage ändert, geht nicht verloren (die Zeile wird mit dem aktuellen Stand fortgeschrieben)', async () => { const request = deferred(); fx.setLogoVisibility.mockReturnValue(request.promise); fx.setHeart.mockResolvedValue({ herz: true, likeCount: 9 }); await mount(); picker.select('b'); infoButton('Ausblenden').click(); heart('Logo B').click(); await flush(); request.resolve(); await flush(); expect(picker.selected()).toMatchObject({ id: 'b', hidden: true, likeCount: 9 }); }); });