// Infozeile zum gewählten Logo (Spec § 3 und § 6): Anzeige, Melden, Löschen und die Admin-Aktionen. Die Zeile des Logos und die Auswahl // gehören dem Picker (hier eine Attrappe als `host`); Datenschicht, Rückfrage und Melden-Dialog sind Attrappen. Das Zusammenspiel // mit dem echten Picker steht in logo-picker.test.ts. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ forgetLogo: vi.fn() })); // Die Infozeile hängt (über Melden-Dialog und API-Fehler) an Appwrite und Anmeldung; 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('./logo-asset', async (importOriginal) => ({ ...(await importOriginal()), forgetLogo: mocks.forgetLogo, })); import { ApiError } from '../community/api'; import { setLang } from '../i18n/i18n'; import { createLogoInfo, failureText, type LogoInfo, type LogoInfoDeps, type LogoInfoHost } from './logo-info'; import type { CommunityLogo, LogoReport } from './logos'; const logo = (patch: Partial = {}): CommunityLogo => ({ id: 'a', name: 'Logo A', displayName: 'Kenso', fileId: 'a', ownerId: 'u9', mono: false, likeCount: 12, hidden: false, reportCount: 0, createdAt: '2026-10-04T12:00:00.000Z', ...patch, }); const report = (reason: string, id = reason): LogoReport => ({ id, logoId: 'a', reason, createdAt: '2026-10-04T12:00:00.000Z' }); 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 selectedId: string | null; let row: CommunityLogo | null; let gone: boolean; let user: string | null; let admin: boolean; let abort: AbortController; let info: LogoInfo; const host = { selectedId: () => selectedId, selected: () => row, gone: () => gone, patchRow: vi.fn((id: string, patch: Partial) => { if (row?.id === id) row = { ...row, ...patch }; }), removeLogo: vi.fn(), } satisfies LogoInfoHost; const fx = { reportLogo: vi.fn(), deleteLogo: vi.fn(), setLogoVisibility: vi.fn(), listLogoReports: vi.fn(), confirm: vi.fn(), reportDialog: vi.fn(), }; const deps: LogoInfoDeps = { ...fx, userId: () => user, isAdmin: () => admin }; beforeEach(() => { for (const fn of [...Object.values(fx), mocks.forgetLogo, host.patchRow, host.removeLogo]) fn.mockReset(); host.patchRow.mockImplementation((id, patch) => { if (row?.id === id) row = { ...row, ...patch }; }); selectedId = null; row = null; gone = false; user = 'u1'; admin = false; abort = new AbortController(); fx.reportLogo.mockResolvedValue({ schonGemeldet: false }); fx.deleteLogo.mockResolvedValue(undefined); fx.setLogoVisibility.mockResolvedValue(undefined); fx.listLogoReports.mockResolvedValue([]); fx.confirm.mockResolvedValue(true); fx.reportDialog.mockResolvedValue('cancelled'); document.body.innerHTML = ''; }); afterEach(() => { document.body.innerHTML = ''; }); /** Baut die Infozeile; `shown` ist das gewählte Logo (null: keins). */ async function mount(shown: CommunityLogo | null = logo()): Promise { row = shown; selectedId = shown?.id ?? null; info = createLogoInfo(host, deps, abort.signal); document.body.append(info.el); await flush(); return info; } /** Zeigt der Picker ein anderes Logo (oder keins), meldet er es mit update(). */ function choose(next: CommunityLogo | null): void { row = next; selectedId = next?.id ?? null; info.update(); } // ---- Zugriffe auf das DOM ------------------------------------------------------------------------------------------------- /** Sichtbar: weder selbst noch ein Vorfahre ist mit `hidden` verborgen. */ const visible = (node: Element) => node.closest('[hidden]') === null; const line = () => { const text = info.el.querySelector('.logo-info-text')!; return visible(text) ? text.textContent : ''; }; const goneText = () => info.el.querySelector('.logo-info-gone')!.textContent; const message = () => info.el.querySelector('[role="alert"]')!.textContent; const buttons = () => [...info.el.querySelectorAll('button')].filter(visible).map((b) => b.textContent); const button = (label: string) => [...info.el.querySelectorAll('button')].find((b) => b.textContent === label && visible(b)) as HTMLButtonElement; const reportCount = () => { const text = info.el.querySelector('.logo-info-reports')!; return visible(text) ? text.textContent : ''; }; const reasons = () => [...info.el.querySelectorAll('.logo-info-reasons li')].map((li) => li.textContent); const reasonsNote = () => info.el.querySelector('.logo-info-note')!.textContent; const hiddenNote = () => { const note = info.el.querySelector('.logo-info-hidden')!; return visible(note) ? note.textContent : ''; }; describe('Anzeige', () => { it('ohne Auswahl ist die Infozeile leer: kein Text, keine Knöpfe, keine Meldung', async () => { await mount(null); expect(line()).toBe(''); expect(buttons()).toEqual([]); expect(goneText()).toBe(''); expect(hiddenNote()).toBe(''); expect(message()).toBe(''); }); it('gewähltes Logo: „ · von · ♥ “ und „Melden“ – auch für Gäste', async () => { user = null; await mount(); expect(line()).toBe('Logo A · von Kenso · ♥ 12'); expect(buttons()).toEqual(['Melden']); expect(button('Melden').type).toBe('button'); }); it('ohne Anzeigename steht „von Anonym“', async () => { await mount(logo({ displayName: '' })); expect(line()).toBe('Logo A · von Anonym · ♥ 12'); }); it('Name und Anzeigename stehen nur als Text, nie als HTML', async () => { await mount(logo({ name: '', displayName: 'fett' })); expect(line()).toBe(' · von fett · ♥ 12'); expect(info.el.querySelector('img')).toBeNull(); expect(info.el.querySelector('b')).toBeNull(); }); it('update() folgt der Zeile des Pickers: neue Zahl, anderes Logo, danach wieder keins', async () => { await mount(); row = logo({ likeCount: 13 }); info.update(); expect(line()).toBe('Logo A · von Kenso · ♥ 13'); choose(logo({ id: 'b', name: 'Logo B', displayName: 'Anna', likeCount: 0 })); expect(line()).toBe('Logo B · von Anna · ♥ 0'); choose(null); expect(line()).toBe(''); expect(buttons()).toEqual([]); }); it('ein Logo, dessen Zeile noch fehlt (wird gerade geholt), zeigt weder Zeile noch Hinweis', async () => { await mount(null); selectedId = 'x'; // gewählt, die Zeile ist noch unbekannt info.update(); expect(line()).toBe(''); expect(goneText()).toBe(''); expect(buttons()).toEqual([]); }); it('gewähltes Logo nicht auffindbar: „Das gewählte Logo ist nicht mehr verfügbar.“, ohne Knöpfe', async () => { await mount(null); selectedId = 'x'; gone = true; info.update(); expect(goneText()).toBe('Das gewählte Logo ist nicht mehr verfügbar.'); expect(line()).toBe(''); expect(buttons()).toEqual([]); expect(info.el.querySelector('.logo-info-gone')!.getAttribute('role')).toBe('status'); // Taucht die Zeile doch auf, ist der Hinweis weg. row = logo({ id: 'x', name: 'Logo X' }); gone = false; info.update(); expect(goneText()).toBe(''); expect(line()).toBe('Logo X · von Kenso · ♥ 12'); }); it('eine Zeile, die der Picker inzwischen kennt, geht dem Hinweis „nicht mehr verfügbar“ vor, auch wenn gone() noch wahr meldet', async () => { await mount(null); selectedId = 'x'; gone = true; info.update(); expect(goneText()).toBe('Das gewählte Logo ist nicht mehr verfügbar.'); row = logo({ id: 'x', name: 'Logo X' }); info.update(); expect(goneText()).toBe(''); expect(line()).toBe('Logo X · von Kenso · ♥ 12'); }); it('ohne einen sichtbaren Knopf ist auch die Knopfleiste verborgen (sonst bliebe ein leerer Abstand in der Karte)', async () => { const actions = () => info.el.querySelector('.logo-info-actions')!; user = 'u2'; // weder Hochlader noch Admin await mount(logo({ hidden: true })); // melden lässt es sich nicht, ein- und ausblenden nur ein Admin expect(buttons()).toEqual([]); expect(actions().hidden).toBe(true); expect(hiddenNote()).toBe('Ausgeblendet'); user = 'u9'; // der Hochlader darf löschen info.update(); expect(buttons()).toEqual(['Löschen']); expect(actions().hidden).toBe(false); }); it('Englisch: Zeile, Knopf und Hinweis sind übersetzt', async () => { setLang('en', { persist: false }); await mount(logo({ displayName: '' })); expect(line()).toBe('Logo A · by Anonymous · ♥ 12'); expect(buttons()).toEqual(['Report']); choose(null); selectedId = 'x'; gone = true; info.update(); expect(goneText()).toBe('The selected logo is no longer available.'); }); }); describe('Melden', () => { it('öffnet den Dialog für Logos und sendet reportLogo(id, Grund) – auch als Gast', async () => { user = null; await mount(); button('Melden').click(); await flush(); expect(fx.reportDialog).toHaveBeenCalledTimes(1); const opts = fx.reportDialog.mock.calls[0][0]; expect(opts.subject).toBe('logo'); expect(opts.signal).toBe(abort.signal); fx.reportLogo.mockResolvedValue({ schonGemeldet: true }); await expect(opts.send('spam')).resolves.toEqual({ schonGemeldet: true }); expect(fx.reportLogo).toHaveBeenCalledExactlyOnceWith('a', 'spam'); }); it('ein zweiter Klick, solange der Dialog offen ist, öffnet keinen zweiten; danach geht es wieder', async () => { const open = deferred<'reported'>(); fx.reportDialog.mockReturnValue(open.promise); await mount(); button('Melden').click(); button('Melden').click(); await flush(); expect(fx.reportDialog).toHaveBeenCalledTimes(1); open.resolve('reported'); await flush(); button('Melden').click(); expect(fx.reportDialog).toHaveBeenCalledTimes(2); }); it('ein Dialog, der sich nicht öffnen lässt, zeigt einen Fehlertext; der Knopf geht danach wieder', async () => { fx.reportDialog.mockRejectedValueOnce(new Error('kein ')); await mount(); button('Melden').click(); await flush(); expect(message()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); button('Melden').click(); // der nächste Versuch räumt den Text ab expect(message()).toBe(''); await flush(); expect(fx.reportDialog).toHaveBeenCalledTimes(2); }); it('ein ausgeblendetes Logo lässt sich nicht melden (der Dienst kennt es für Besucher nicht)', async () => { user = 'u9'; await mount(logo({ hidden: true })); expect(buttons()).toEqual(['Löschen']); expect(hiddenNote()).toBe('Ausgeblendet'); }); }); describe('Löschen durch den Hochlader', () => { it('„Löschen“ sehen nur der Hochlader und Admins; Gäste und andere Nutzer nur „Melden“', async () => { user = 'u9'; // ownerId des Beispiel-Logos await mount(); expect(buttons()).toEqual(['Melden', 'Löschen']); user = 'u2'; info.update(); expect(buttons()).toEqual(['Melden']); user = null; info.update(); expect(buttons()).toEqual(['Melden']); expect(reportCount()).toBe(''); expect(button('Ausblenden')).toBeUndefined(); }); it('ein Logo ohne bekannten Hochlader gehört niemandem (auch nicht einem Konto mit leerer ID)', async () => { user = ''; await mount(logo({ ownerId: '' })); expect(buttons()).toEqual(['Melden']); }); it('fragt mit Namen nach; bestätigt: deleteLogo, forgetLogo und removeLogo in dieser Reihenfolge', async () => { user = 'u9'; const order: string[] = []; fx.confirm.mockImplementation(async () => { order.push('confirm'); return true; }); fx.deleteLogo.mockImplementation(async () => { order.push('delete'); }); mocks.forgetLogo.mockImplementation(() => { order.push('forget'); }); host.removeLogo.mockImplementation(() => { order.push('remove'); }); await mount(); button('Löschen').click(); await flush(); expect(order).toEqual(['confirm', 'delete', 'forget', 'remove']); expect(fx.confirm).toHaveBeenCalledExactlyOnceWith( { title: 'Logo „Logo A“ löschen?', text: 'Das Logo, seine Herzen und Meldungen werden endgültig gelöscht. Gespeicherte Karten zeigen es danach nicht mehr. Bereits veröffentlichte Kartenbilder bleiben unverändert.', confirmLabel: 'Löschen', cancelLabel: 'Abbrechen', }, abort.signal, ); expect(fx.deleteLogo).toHaveBeenCalledExactlyOnceWith('a'); expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a'); expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a'); expect(message()).toBe(''); }); it('abgelehnt: es passiert nichts, auch kein Fehlertext', async () => { user = 'u9'; fx.confirm.mockResolvedValue(false); await mount(); button('Löschen').click(); await flush(); expect(fx.deleteLogo).not.toHaveBeenCalled(); expect(mocks.forgetLogo).not.toHaveBeenCalled(); expect(host.removeLogo).not.toHaveBeenCalled(); expect(message()).toBe(''); expect(buttons()).toEqual(['Melden', 'Löschen']); }); it('Fehler des Dienstes: der Text steht in der Meldezeile, nichts wird entfernt; ein neuer Versuch geht', async () => { user = 'u9'; fx.deleteLogo.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')); await mount(); button('Löschen').click(); await flush(); expect(message()).toBe('Dazu hast du keine Berechtigung.'); expect(mocks.forgetLogo).not.toHaveBeenCalled(); expect(host.removeLogo).not.toHaveBeenCalled(); button('Löschen').click(); await flush(); expect(message()).toBe(''); expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a'); }); it('404 „logoGone“ (das Logo gibt es schon nicht mehr, etwa weil die Antwort einer ersten Anfrage verloren ging): gilt als Erfolg wie ein gelungenes Löschen', async () => { user = 'u9'; const order: string[] = []; fx.deleteLogo.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone')); mocks.forgetLogo.mockImplementation(() => void order.push('forget')); host.removeLogo.mockImplementation(() => void order.push('remove')); await mount(); button('Löschen').click(); await flush(); expect(order).toEqual(['forget', 'remove']); expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a'); expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a'); expect(message()).toBe(''); // kein Fehlertext }); it('404 „logoGone“ gilt für den Admin ebenso, auch bei einem fremden Logo', async () => { admin = true; fx.deleteLogo.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone')); await mount(); button('Löschen').click(); await flush(); expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a'); expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a'); expect(message()).toBe(''); }); it('404 „logoGone“ nach dem Verlassen der Seite: der Zwischenspeicher vergisst das Logo trotzdem, die Kachel wird nicht mehr angefasst', async () => { user = 'u9'; const request = deferred(); fx.deleteLogo.mockReturnValue(request.promise); await mount(); button('Löschen').click(); await flush(); abort.abort(); request.reject(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone')); await flush(); expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a'); expect(host.removeLogo).not.toHaveBeenCalled(); }); it.each([ ['ein 404 ohne diesen Schlüssel (etwa ein Dienst, der die Anfrage nicht kennt)', new ApiError(404, 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', true, 'generic')], ['ein 404 ohne Schlüssel', new ApiError(404, 'Nicht gefunden', true)], ['logoGone mit einem anderen Status', new ApiError(500, 'Dieses Logo ist nicht (mehr) verfügbar.', false, 'logoGone')], ['ein 403', new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')], ])('%s bleibt ein Fehler: Text in der Meldezeile, nichts wird vergessen oder entfernt', async (_name, error) => { user = 'u9'; fx.deleteLogo.mockRejectedValueOnce(error); await mount(); button('Löschen').click(); await flush(); expect(message()).not.toBe(''); expect(mocks.forgetLogo).not.toHaveBeenCalled(); expect(host.removeLogo).not.toHaveBeenCalled(); }); it('ein Fehler anderer Art (Netz) bekommt den allgemeinen Text', async () => { user = 'u9'; fx.deleteLogo.mockRejectedValueOnce(new TypeError('offline')); await mount(); button('Löschen').click(); await flush(); expect(message()).toBe('Keine Verbindung zum Server.'); }); it('eine Rückfrage, die sich nicht öffnen lässt, löscht nichts und sagt es', async () => { user = 'u9'; fx.confirm.mockRejectedValueOnce(new Error('kein ')); await mount(); button('Löschen').click(); await flush(); expect(fx.deleteLogo).not.toHaveBeenCalled(); expect(message()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); }); it('meldet sich der Nutzer während der Rückfrage ab, wird nichts gelöscht', async () => { user = 'u9'; const answer = deferred(); fx.confirm.mockReturnValue(answer.promise); await mount(); button('Löschen').click(); user = null; info.update(); answer.resolve(true); await flush(); expect(fx.deleteLogo).not.toHaveBeenCalled(); expect(host.removeLogo).not.toHaveBeenCalled(); }); it('solange eine Rückfrage oder Anfrage läuft, bleiben weitere Klicks ohne Wirkung', async () => { user = 'u9'; const request = deferred(); fx.deleteLogo.mockReturnValue(request.promise); await mount(); button('Löschen').click(); button('Löschen').click(); // die Rückfrage ist noch nicht beantwortet await flush(); button('Löschen').click(); // die Anfrage läuft button('Melden').click(); await flush(); expect(fx.confirm).toHaveBeenCalledTimes(1); expect(fx.deleteLogo).toHaveBeenCalledTimes(1); expect(fx.reportDialog).not.toHaveBeenCalled(); request.resolve(); await flush(); expect(host.removeLogo).toHaveBeenCalledTimes(1); }); it('verlässt der Nutzer die Seite während der Rückfrage, wird nichts gelöscht', async () => { user = 'u9'; const answer = deferred(); fx.confirm.mockReturnValue(answer.promise); await mount(); button('Löschen').click(); abort.abort(); answer.resolve(true); await flush(); expect(fx.deleteLogo).not.toHaveBeenCalled(); }); it('verlässt der Nutzer die Seite während der Anfrage, vergisst der Zwischenspeicher das Logo trotzdem, die Kachel wird nicht mehr angefasst', async () => { user = 'u9'; const request = deferred(); fx.deleteLogo.mockReturnValue(request.promise); await mount(); button('Löschen').click(); await flush(); abort.abort(); request.resolve(); await flush(); expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a'); expect(host.removeLogo).not.toHaveBeenCalled(); }); }); describe('Admin', () => { beforeEach(() => { admin = true; }); it('Nicht-Admins sehen keine Meldungen und kein Ausblenden und fragen die Gründe nicht ab', async () => { admin = false; await mount(logo({ reportCount: 3 })); expect(reportCount()).toBe(''); expect(buttons()).toEqual(['Melden']); expect(fx.listLogoReports).not.toHaveBeenCalled(); }); it('„ Meldungen“ mit den Gründen als Text; dazu „Ausblenden“ und „Löschen“ (auch für fremde Logos)', async () => { fx.listLogoReports.mockResolvedValue([report('spam'), report('urheberrecht'), report('Eigener Grund')]); await mount(logo({ reportCount: 3 })); expect(fx.listLogoReports).toHaveBeenCalledExactlyOnceWith('a'); expect(reportCount()).toBe('3 Meldungen'); expect(reasons()).toEqual(['Spam', 'Urheberrecht/Marke', 'Eigener Grund']); expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); }); it('„1 Meldung“ im Singular', async () => { fx.listLogoReports.mockResolvedValue([report('unangemessen')]); await mount(logo({ reportCount: 1 })); expect(reportCount()).toBe('1 Meldung'); expect(reasons()).toEqual(['Unangemessen']); }); it('ohne Meldungen: „0 Meldungen“ und keine Anfrage', async () => { await mount(logo({ reportCount: 0 })); expect(reportCount()).toBe('0 Meldungen'); expect(reasons()).toEqual([]); expect(fx.listLogoReports).not.toHaveBeenCalled(); }); it('die Gründe werden je Logo nur einmal geholt, auch bei vielen Aktualisierungen', async () => { fx.listLogoReports.mockResolvedValue([report('spam')]); await mount(logo({ reportCount: 1 })); row = logo({ reportCount: 1, likeCount: 99 }); info.update(); info.update(); await flush(); expect(fx.listLogoReports).toHaveBeenCalledTimes(1); expect(reasons()).toEqual(['Spam']); choose(logo({ id: 'b', reportCount: 1 })); await flush(); expect(fx.listLogoReports).toHaveBeenLastCalledWith('b'); choose(logo({ reportCount: 1 })); // zurück zu A: frisch holen await flush(); expect(fx.listLogoReports).toHaveBeenCalledTimes(3); }); it('ändert sich die Zahl der Meldungen (die Zeile wurde neu geladen), werden die Gründe neu geholt', async () => { fx.listLogoReports.mockResolvedValueOnce([report('spam')]); await mount(logo({ reportCount: 1 })); expect(reasons()).toEqual(['Spam']); fx.listLogoReports.mockResolvedValueOnce([report('spam'), report('sonstiges')]); row = logo({ reportCount: 2 }); info.update(); await flush(); expect(reportCount()).toBe('2 Meldungen'); expect(reasons()).toEqual(['Spam', 'Sonstiges']); expect(fx.listLogoReports).toHaveBeenCalledTimes(2); }); it('wird dasselbe Logo nach „kein Logo“ erneut gewählt, werden die Gründe frisch geholt – auch nach einem Ladefehler', async () => { fx.listLogoReports.mockRejectedValueOnce(new Error('offline')); await mount(logo({ reportCount: 1 })); expect(reasonsNote()).toBe('Die Gründe konnten nicht geladen werden.'); choose(null); fx.listLogoReports.mockResolvedValueOnce([report('spam')]); choose(logo({ reportCount: 1 })); await flush(); expect(fx.listLogoReports).toHaveBeenCalledTimes(2); expect(reasonsNote()).toBe(''); expect(reasons()).toEqual(['Spam']); }); it('die Gründe lassen sich nicht laden: Hinweis dazu, die Zahl bleibt', async () => { fx.listLogoReports.mockRejectedValue(new Error('offline')); await mount(logo({ reportCount: 3 })); expect(reportCount()).toBe('3 Meldungen'); expect(reasons()).toEqual([]); expect(reasonsNote()).toBe('Die Gründe konnten nicht geladen werden.'); // Ein anderes Logo, das sich laden lässt, hat den Hinweis nicht. fx.listLogoReports.mockResolvedValue([report('spam')]); choose(logo({ id: 'b', reportCount: 1 })); await flush(); expect(reasonsNote()).toBe(''); expect(reasons()).toEqual(['Spam']); }); it('eine späte Antwort für ein inzwischen verlassenes Logo wird verworfen', async () => { const slow = deferred(); fx.listLogoReports.mockReturnValueOnce(slow.promise); await mount(logo({ reportCount: 2 })); fx.listLogoReports.mockResolvedValueOnce([report('sonstiges')]); choose(logo({ id: 'b', reportCount: 1 })); await flush(); expect(reasons()).toEqual(['Sonstiges']); slow.resolve([report('spam'), report('spam', 'zwei')]); await flush(); expect(reasons()).toEqual(['Sonstiges']); }); it('„Ausblenden“: setLogoVisibility(id, false), forgetLogo, die Zeile wird hidden; die Anzeige folgt', async () => { const order: string[] = []; fx.setLogoVisibility.mockImplementation(async () => { order.push('visibility'); }); mocks.forgetLogo.mockImplementation(() => { order.push('forget'); }); host.patchRow.mockImplementation((id, patch) => { order.push('patch'); if (row?.id === id) row = { ...row, ...patch }; }); fx.listLogoReports.mockResolvedValue([report('spam')]); await mount(logo({ reportCount: 1 })); button('Ausblenden').click(); await flush(); expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('a', false); expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a'); expect(host.patchRow).toHaveBeenCalledExactlyOnceWith('a', { hidden: true }); expect(order).toEqual(['visibility', 'forget', 'patch']); expect(buttons()).toEqual(['Löschen', 'Einblenden']); // „Melden“ gibt es für ein ausgeblendetes Logo nicht expect(hiddenNote()).toBe('Ausgeblendet'); expect(reportCount()).toBe('1 Meldung'); // Ausblenden ändert die Meldungen nicht expect(reasons()).toEqual(['Spam']); expect(message()).toBe(''); }); it('„Einblenden“: setLogoVisibility(id, true), forgetLogo; die Meldungen sind danach gelöscht (0)', async () => { fx.listLogoReports.mockResolvedValue([report('spam'), report('sonstiges')]); await mount(logo({ hidden: true, reportCount: 2 })); expect(buttons()).toEqual(['Löschen', 'Einblenden']); expect(reasons()).toEqual(['Spam', 'Sonstiges']); button('Einblenden').click(); await flush(); expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('a', true); expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a'); expect(host.patchRow).toHaveBeenCalledExactlyOnceWith('a', { hidden: false, reportCount: 0 }); expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); expect(hiddenNote()).toBe(''); expect(reportCount()).toBe('0 Meldungen'); expect(reasons()).toEqual([]); expect(fx.listLogoReports).toHaveBeenCalledTimes(1); // nichts mehr zu holen }); it('nach „Einblenden“ (0 Meldungen) sind die alten Gründe verworfen: kommen später wieder Meldungen, werden sie frisch geholt, auch bei gleicher Zahl', async () => { fx.listLogoReports.mockResolvedValueOnce([report('spam'), report('sonstiges')]); await mount(logo({ hidden: true, reportCount: 2 })); expect(reasons()).toEqual(['Spam', 'Sonstiges']); button('Einblenden').click(); await flush(); expect(reportCount()).toBe('0 Meldungen'); expect(reasons()).toEqual([]); expect(fx.listLogoReports).toHaveBeenCalledTimes(1); // Zwei neue Meldungen, wieder genau zwei: gleicher Schlüssel wie vorher, aber andere Gründe. fx.listLogoReports.mockResolvedValueOnce([report('urheberrecht', 'neu1'), report('unangemessen', 'neu2')]); row = logo({ reportCount: 2 }); info.update(); await flush(); expect(fx.listLogoReports).toHaveBeenCalledTimes(2); expect(reasons()).toEqual(['Urheberrecht/Marke', 'Unangemessen']); }); it('eine späte Antwort der alten Gründe, die nach dem Einblenden eintrifft, wird verworfen', async () => { const slow = deferred(); fx.listLogoReports.mockReturnValueOnce(slow.promise); await mount(logo({ hidden: true, reportCount: 2 })); button('Einblenden').click(); await flush(); slow.resolve([report('spam'), report('sonstiges')]); await flush(); expect(reportCount()).toBe('0 Meldungen'); expect(reasons()).toEqual([]); }); it('Fehler beim Ein- oder Ausblenden: Text in der Meldezeile, die Zeile bleibt, kein forgetLogo', async () => { fx.setLogoVisibility.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable')); await mount(logo()); button('Ausblenden').click(); await flush(); expect(message()).toBe('Der Dienst ist gerade nicht erreichbar.'); expect(host.patchRow).not.toHaveBeenCalled(); expect(mocks.forgetLogo).not.toHaveBeenCalled(); expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); button('Ausblenden').click(); await flush(); expect(message()).toBe(''); expect(buttons()).toEqual(['Löschen', 'Einblenden']); }); it('der Admin löscht wie der Hochlader, auch ein fremdes Logo', async () => { await mount(logo({ ownerId: 'u5' })); button('Löschen').click(); await flush(); expect(fx.confirm).toHaveBeenCalledTimes(1); expect(fx.deleteLogo).toHaveBeenCalledExactlyOnceWith('a'); expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a'); }); it('wird jemand Admin (Anmeldung), erscheinen die Admin-Teile nach update(); verliert er die Rolle, verschwinden sie', async () => { admin = false; fx.listLogoReports.mockResolvedValue([report('spam')]); await mount(logo({ reportCount: 1 })); expect(buttons()).toEqual(['Melden']); admin = true; info.update(); await flush(); expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); expect(reasons()).toEqual(['Spam']); admin = false; info.update(); expect(buttons()).toEqual(['Melden']); expect(reportCount()).toBe(''); }); it('Englisch: Meldungen, Gründe und Knöpfe sind übersetzt', async () => { setLang('en', { persist: false }); fx.listLogoReports.mockResolvedValue([report('spam')]); await mount(logo({ reportCount: 1, ownerId: 'u1' })); expect(buttons()).toEqual(['Report', 'Delete', 'Hide']); expect(reasons()).toEqual(['Spam']); expect(reportCount()).toBe('1 report'); row = logo({ hidden: true, reportCount: 3 }); info.update(); expect(reportCount()).toBe('3 reports'); row = logo({ hidden: true, reportCount: 1 }); info.update(); expect(buttons()).toEqual(['Delete', 'Unhide']); expect(hiddenNote()).toBe('Hidden'); }); }); describe('Aktionen: Zustand während sie laufen', () => { const busy = () => info.el.getAttribute('aria-busy'); const disabled = () => ['Melden', 'Löschen', 'Ausblenden'].filter((label) => button(label)?.disabled); beforeEach(() => { admin = true; // alle drei Knöpfe: Melden, Löschen, Ausblenden }); it('im Ruhezustand: nicht beschäftigt, kein Knopf gesperrt', async () => { await mount(); expect(busy()).toBe('false'); expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); expect(disabled()).toEqual([]); }); it('läuft eine Anfrage (Löschen): aria-busy="true" an der Infozeile, die anderen Knöpfe sind gesperrt, der gedrückte nicht und behält den Fokus', async () => { const request = deferred(); fx.deleteLogo.mockReturnValue(request.promise); await mount(); button('Löschen').focus(); button('Löschen').click(); await flush(); // die Rückfrage ist beantwortet, die Anfrage läuft expect(fx.deleteLogo).toHaveBeenCalledTimes(1); expect(busy()).toBe('true'); expect(disabled()).toEqual(['Melden', 'Ausblenden']); expect(button('Löschen').disabled).toBe(false); expect(document.activeElement).toBe(button('Löschen')); request.resolve(); await flush(); expect(busy()).toBe('false'); expect(disabled()).toEqual([]); }); it('schon die Rückfrage vor dem Löschen zählt: beschäftigt, solange sie offen ist', async () => { const answer = deferred(); fx.confirm.mockReturnValue(answer.promise); await mount(); button('Löschen').click(); expect(busy()).toBe('true'); expect(disabled()).toEqual(['Melden', 'Ausblenden']); answer.resolve(false); // abgelehnt await flush(); expect(busy()).toBe('false'); expect(disabled()).toEqual([]); }); it('der Melden-Dialog zählt ebenso; der gedrückte Knopf ist „Melden“', async () => { const open = deferred<'reported'>(); fx.reportDialog.mockReturnValue(open.promise); await mount(); button('Melden').click(); expect(busy()).toBe('true'); expect(disabled()).toEqual(['Löschen', 'Ausblenden']); open.resolve('reported'); await flush(); expect(busy()).toBe('false'); expect(disabled()).toEqual([]); }); it('Ausblenden: der gedrückte Knopf ist „Ausblenden“; nach dem Umschalten ist alles wieder frei', async () => { const request = deferred(); fx.setLogoVisibility.mockReturnValue(request.promise); await mount(); button('Ausblenden').click(); expect(busy()).toBe('true'); expect(disabled()).toEqual(['Melden', 'Löschen']); request.resolve(); await flush(); expect(busy()).toBe('false'); expect(disabled()).toEqual([]); expect(buttons()).toEqual(['Löschen', 'Einblenden']); }); it('scheitert die Aktion, ist alles wieder frei, und der Fehler steht da', async () => { fx.deleteLogo.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')); await mount(); button('Löschen').click(); await flush(); expect(message()).toBe('Dazu hast du keine Berechtigung.'); expect(busy()).toBe('false'); expect(disabled()).toEqual([]); }); it('nach dem Verlassen der Seite (abort) ist die Infozeile auch wieder frei', async () => { const request = deferred(); fx.deleteLogo.mockReturnValue(request.promise); await mount(); button('Löschen').click(); await flush(); abort.abort(); request.resolve(); await flush(); expect(busy()).toBe('false'); expect(disabled()).toEqual([]); }); it('ein Klick auf einen gesperrten oder auf denselben Knopf noch einmal bleibt ohne Wirkung', async () => { const request = deferred(); fx.deleteLogo.mockReturnValue(request.promise); await mount(); button('Löschen').click(); await flush(); button('Melden').click(); // gesperrt: ein Klick auf einen gesperrten Knopf erreicht nichts button('Ausblenden').click(); button('Löschen').click(); // läuft schon await flush(); expect(fx.reportDialog).not.toHaveBeenCalled(); expect(fx.setLogoVisibility).not.toHaveBeenCalled(); expect(fx.confirm).toHaveBeenCalledTimes(1); request.resolve(); await flush(); }); }); describe('Aktionen: Ergebnis gehört zur Auswahl, für die sie gestartet wurden', () => { it('ein Fehler, der erst eintrifft, nachdem ein anderes Logo gewählt wurde, steht nicht unter dem neuen Logo', async () => { user = 'u9'; const request = deferred(); fx.deleteLogo.mockReturnValue(request.promise); await mount(logo({ id: 'a', name: 'Logo A' })); button('Löschen').click(); await flush(); choose(logo({ id: 'b', name: 'Logo B' })); // der Nutzer wählt inzwischen B request.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')); await flush(); expect(message()).toBe(''); expect(line()).toBe('Logo B · von Kenso · ♥ 12'); }); it('wählt der Nutzer „Kein Logo“ und danach wieder dasselbe Logo, ist es für die Anzeige eine neue Auswahl: kein später Fehler dort', async () => { user = 'u9'; const request = deferred(); fx.deleteLogo.mockReturnValue(request.promise); await mount(logo({ id: 'a' })); button('Löschen').click(); await flush(); choose(null); request.reject(new TypeError('offline')); await flush(); expect(message()).toBe(''); }); it('bleibt die Auswahl dieselbe, steht der Fehler da (auch wenn sich die Zeile zwischendurch aktualisiert hat)', async () => { user = 'u9'; const request = deferred(); fx.deleteLogo.mockReturnValue(request.promise); await mount(logo({ id: 'a' })); button('Löschen').click(); await flush(); row = logo({ id: 'a', likeCount: 13 }); info.update(); request.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')); await flush(); expect(message()).toBe('Dazu hast du keine Berechtigung.'); }); it('ein Fehler beim Ein- oder Ausblenden folgt derselben Regel', async () => { admin = true; const request = deferred(); fx.setLogoVisibility.mockReturnValue(request.promise); await mount(logo({ id: 'a' })); button('Ausblenden').click(); await flush(); choose(logo({ id: 'b', name: 'Logo B' })); request.reject(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable')); await flush(); expect(message()).toBe(''); }); it('ein gelungenes Ein- oder Ausblenden ändert die Zeile des Logos, für das es gestartet wurde, auch wenn inzwischen ein anderes gewählt ist', async () => { admin = true; const request = deferred(); fx.setLogoVisibility.mockReturnValue(request.promise); await mount(logo({ id: 'a' })); button('Ausblenden').click(); await flush(); choose(logo({ id: 'b', name: 'Logo B' })); request.resolve(); await flush(); expect(host.patchRow).toHaveBeenCalledExactlyOnceWith('a', { hidden: true }); expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a'); expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); // B ist unberührt }); }); describe('Meldezeile', () => { it('zeigt einen Text in einer Live-Region (role="alert") und löscht ihn mit einem leeren Text', async () => { await mount(); expect(info.el.querySelector('[role="alert"]')!.classList.contains('auth-msg-error')).toBe(true); info.setMessage('Etwas ging schief.'); expect(message()).toBe('Etwas ging schief.'); info.setMessage(''); expect(message()).toBe(''); }); it('der Text steht auch ohne Auswahl (Fehler am Herz eines nicht gewählten Logos) und überlebt update()', async () => { await mount(null); info.setMessage('Dieses Logo ist nicht (mehr) verfügbar.'); info.update(); expect(message()).toBe('Dieses Logo ist nicht (mehr) verfügbar.'); }); it('eine andere Auswahl löscht den Text; dieselbe Auswahl lässt ihn stehen', async () => { await mount(); info.setMessage('Etwas ging schief.'); info.update(); row = logo({ likeCount: 13 }); info.update(); expect(message()).toBe('Etwas ging schief.'); choose(logo({ id: 'b', name: 'Logo B' })); expect(message()).toBe(''); }); }); describe('failureText', () => { it('Fehler des Dienstes über ihren Schlüssel, alles andere über errorMessage', () => { expect(failureText(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'))).toBe('Dieses Logo ist nicht (mehr) verfügbar.'); expect(failureText(new TypeError('offline'))).toBe('Keine Verbindung zum Server.'); setLang('en', { persist: false }); expect(failureText(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'))).toBe('This logo is no longer available.'); }); });