Files
AirCardIOS/web/src/logos/logo-info.test.ts
T
ClaudeandClaude Opus 5.5 0e4759a05a fix(logos): Infozeile zeigt laufende Aktionen an, Fehler gehören zur Auswahl, Gründe nach „Einblenden“ verworfen
- logo-info.ts, run: Die Aktion merkt sich die Auswahl beim Start. Ein Fehler, der erst eintrifft, nachdem der Nutzer ein anderes Logo gewählt hat,
  erschien sonst in dessen Meldezeile (render() leert sie nur beim Wechsel, nicht danach). Jetzt steht er nur da, wenn noch dasselbe Logo gewählt ist.
  Ergebnisse, die an einer ID hängen (patchRow, removeLogo, forgetLogo), gelten wie bisher dem Logo, für das die Aktion lief.
- logo-info.ts: Solange eine Aktion läuft (Rückfrage, Dialog oder Anfrage), trägt die Infozeile aria-busy="true" und die anderen Aktionsknöpfe sind
  gesperrt. Der gedrückte Knopf bleibt bedienbar und behält den Fokus (ein gesperrter verlöre ihn, und der Picker holt den Fokus von „Löschen“ nur,
  wenn er in der Infozeile liegt). Danach ist alles wieder frei, auch nach einem Fehler und nach dem Verlassen der Seite.
- logo-info.ts, renderReports: Bei 0 Meldungen (etwa nach „Einblenden“, das sie löscht) wird der Gründe-Zwischenspeicher geleert. Kamen später genau so
  viele Meldungen hinzu wie vorher (gleicher Schlüssel <id>:<n>), wurden die alten, längst gelöschten Gründe gezeigt.
- Tests: Zustand während jeder Aktion (Löschen mit Rückfrage und Anfrage, Melden, Ausblenden, Fehler, abort), Fokus, wirkungslose Klicks; Fehler nach
  Wechsel der Auswahl (anderes Logo, „Kein Logo“, Aus-/Einblenden) und bei gleicher Auswahl; Gründe nach „Einblenden“ samt später Antwort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:49:04 +00:00

986 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<typeof import('./logo-asset')>()),
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> = {}): 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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((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<CommunityLogo>) => {
if (row?.id === id) row = { ...row, ...patch };
}),
removeLogo: vi.fn<LogoInfoHost['removeLogo']>(),
} satisfies LogoInfoHost;
const fx = {
reportLogo: vi.fn<LogoInfoDeps['reportLogo']>(),
deleteLogo: vi.fn<LogoInfoDeps['deleteLogo']>(),
setLogoVisibility: vi.fn<LogoInfoDeps['setLogoVisibility']>(),
listLogoReports: vi.fn<LogoInfoDeps['listLogoReports']>(),
confirm: vi.fn<LogoInfoDeps['confirm']>(),
reportDialog: vi.fn<LogoInfoDeps['reportDialog']>(),
};
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<LogoInfo> {
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: „<Name> · von <Anzeigename> · ♥ <Zahl>“ 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: '<img src=x onerror=alert(1)>', displayName: '<b>fett</b>' }));
expect(line()).toBe('<img src=x onerror=alert(1)> · von <b>fett</b> · ♥ 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<HTMLElement>('.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 <dialog>'));
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<void>();
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 <dialog>'));
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<boolean>();
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<void>();
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<boolean>();
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<void>();
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('„<n> 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<LogoReport[]>();
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<LogoReport[]>();
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<void>();
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<boolean>();
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<void>();
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<void>();
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<void>();
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<void>();
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<void>();
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<void>();
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<void>();
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<void>();
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.');
});
});