Files
AirCardIOS/web/src/community/report-dialog.test.ts
T
ClaudeandClaude Opus 5.5 a75be2d44c feat(logos): Infozeile mit Melden, Löschen und Admin-Aktionen
Task 9: die Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6).

- logos/logo-info.ts (neu): „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ (auch für Gäste; ein ausgeblendetes Logo lässt
  sich nicht melden). Der Hochlader sieht „Löschen“ mit Rückfrage (Titel mit Namen, Folgen im Text), Admins „<n> Meldungen“ mit den
  Gründen als Text (listLogoReports, einmal je Logo und Stand), „Ausblenden“/„Einblenden“ und „Löschen“. Gelöscht: deleteLogo,
  forgetLogo, Kachel weg, onSelect(null); ein-/ausgeblendet: setLogoVisibility, forgetLogo (ein Null-Bild bleibt nicht im Zwischen-
  speicher), die Zeile des Pickers wird fortgeschrieben (Einblenden setzt reportCount auf 0). Gewähltes Logo nicht auffindbar: „Das
  gewählte Logo ist nicht mehr verfügbar.“ (ein Netzfehler von getLogo sagt nichts). Zugleich die Meldezeile (role="alert") für Fehler
  von Herz, Hochladen und diesen Aktionen; ein Wechsel der Auswahl räumt sie ab. Immer nur eine Aktion zugleich; die Rechte werden
  nach der Rückfrage noch einmal geprüft; der Aufbau steht einmal da, update() ändert nur Texte und Sichtbarkeit (Fokus bleibt).
- logos/logo-picker.ts: hängt die Infozeile als Letztes unter das Raster (info.el). Herz- und Upload-Fehler gehen in die Infozeile,
  der Statusbereich des Pickers bleibt den Ladefehlern der Liste vorbehalten (ein Klick auf ein Herz löscht ihren Text nicht mehr).
  Aus dem Review von Task 8: requireLogin, das wirft, zeigt den Grund statt einer unbehandelten Ablehnung; select() nach dispose()
  ist wirkungslos. Löschen entfernt Kachel, Zeile, Herz und Eintrag der Liste, setzt den Cursor von „Mehr laden“ auf das Logo davor
  (Appwrite lehnt einen Cursor auf eine gelöschte Zeile ab), hebt die Auswahl über onSelect(null) auf und holt den Fokus nach
  „Kein Logo“, wenn er auf „Löschen“ lag. LogoPickerDeps erweitert um reportLogo, deleteLogo, setLogoVisibility, listLogoReports,
  confirm und reportDialog.
- community/report-dialog.ts: subject 'card' (Standard, unverändert) oder 'logo': Überschrift „Logo melden“, Einleitung „dieses
  Logo“, vier Gründe ohne „Echte Kartendaten“, Duplikat-Dank „Danke, du hast dieses Logo schon gemeldet.“. Der ganze Dialog geht
  durch t()/msg(); Fehler des Dienstes über apiErrorText.
- logos/logos.css: das Herz sitzt in der Textzeile unter der Bildfläche (nicht mehr auf dem Logo, folgt dem Theme), die Kachel füllt
  die Zelle (height: 100%), damit „Kein Logo“ so hoch ist wie sein Nachbar; Stile der Infozeile.
- editor/editor-page.ts: Aus dem Review von Task 8: der Ladefehler der Logos verschwindet, sobald ein Logo danach lädt, aber nur
  der, den der Logo-Zustand selbst ins Fehlerfeld geschrieben hat; jeder andere Text im Feld bleibt.
- i18n/en.ts: englische Texte zu allen neuen Beschriftungen und zum Melden-Dialog.
- Tests: logo-info.test.ts (neu), logo-picker.test.ts (Infozeile, Löschen, Ein-/Ausblenden, Fehler, Cursor, Fokus), report-dialog.test.ts
  (Logo und Englisch), editor-page.test.ts (Fehlerfeld). Vitest 52 Dateien, 1764 Tests grün, tsc und Build sauber.

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

398 lines
16 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.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// api.ts hängt am Appwrite-Client; der Dialog bekommt `send` von außen, ApiError wird nur als Klasse gebraucht.
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
import { setLang } from '../i18n/i18n';
import { ApiError, type ReportReason } from './api';
import { REPORT_REASONS, reportDialog, type ReportOutcome } from './report-dialog';
type Send = (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>;
const send = vi.fn<Send>();
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.report-dialog');
const buttonByText = (text: string) =>
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
const radios = () => [...(dialogEl()?.querySelectorAll<HTMLInputElement>('input[type="radio"]') ?? [])];
const choose = (value: string) => {
const radio = radios().find((r) => r.value === value) as HTMLInputElement;
radio.checked = true;
radio.dispatchEvent(new Event('change', { bubbles: true }));
};
const submit = () => dialogEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
function open(opts: { cardTitle?: string; signal?: AbortSignal; subject?: 'card' | 'logo' } = {}): Promise<ReportOutcome> {
return reportDialog({ send, ...opts });
}
beforeEach(() => {
send.mockReset();
document.body.innerHTML = '';
});
afterEach(() => {
document.body.innerHTML = '';
});
describe('Melden-Dialog', () => {
it('zeigt die fünf Gründe in der Reihenfolge der Spec, keiner ist vorgewählt', () => {
void open();
expect(dialogEl()?.open).toBe(true);
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte melden');
expect(REPORT_REASONS.map((r) => r.label)).toEqual([
'Unangemessen',
'Echte Kartendaten',
'Urheberrecht/Marke',
'Spam',
'Sonstiges',
]);
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
'Unangemessen',
'Echte Kartendaten',
'Urheberrecht/Marke',
'Spam',
'Sonstiges',
]);
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
expect(radios().some((r) => r.checked)).toBe(false);
expect(buttonByText('Abbrechen')).toBeTruthy();
expect(buttonByText('Melden')).toBeTruthy();
});
it('nennt den Kartentitel als Text, nie als HTML', () => {
void open({ cardTitle: '<img src=x onerror=alert(1)>' });
expect(dialogEl()!.querySelector('img')).toBeNull();
expect(dialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
});
it('ohne Grund: Hinweis, nichts wird gesendet', async () => {
void open();
submit();
await settle();
expect(errorText()).toBe('Bitte wähle einen Grund.');
expect(send).not.toHaveBeenCalled();
expect(dialogEl()?.open).toBe(true);
});
it.each([
['unangemessen', 'unangemessen'],
['kartendaten', 'kartendaten'],
['urheberrecht', 'urheberrecht'],
['spam', 'spam'],
['sonstiges', 'sonstiges'],
])('Grund %s geht als %s an die API', async (value, expected) => {
send.mockResolvedValue({ schonGemeldet: false });
void open();
choose(value);
submit();
await settle();
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(expected);
});
it('neue Meldung: Dank im Dialog, „Schließen“ beendet mit „reported“', async () => {
send.mockResolvedValue({ schonGemeldet: false });
const result = open();
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Danke für deine Meldung.');
expect(errorText()).toBe('');
expect(radios()).toHaveLength(0);
expect(buttonByText('Melden')).toBeUndefined();
expect(dialogEl()?.open).toBe(true);
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('reported');
expect(dialogEl()).toBeNull();
});
it('doppelte Meldung: „Danke, du hast diese Karte schon gemeldet.“ und „duplicate“', async () => {
send.mockResolvedValue({ schonGemeldet: true });
const result = open();
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Danke, du hast diese Karte schon gemeldet.');
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('duplicate');
});
it('Drosselung (429): der Servertext erscheint, der Dialog bleibt und lässt einen neuen Versuch zu', async () => {
send
.mockRejectedValueOnce(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.'))
.mockResolvedValueOnce({ schonGemeldet: false });
const result = open();
choose('spam');
submit();
await settle();
expect(errorText()).toBe('Zu viele Meldungen – bitte später nochmal.');
expect(dialogEl()?.open).toBe(true);
expect(buttonByText('Melden')!.disabled).toBe(false);
expect(radios().every((r) => !r.disabled)).toBe(true);
// Die Auswahl bleibt stehen.
expect(radios().find((r) => r.checked)?.value).toBe('spam');
submit();
await settle();
expect(send).toHaveBeenCalledTimes(2);
expect(errorText()).toBe('');
expect(statusText()).toBe('Danke für deine Meldung.');
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('reported');
});
it('andere Fehler des Dienstes zeigen ihren Servertext (z. B. 404, 503)', async () => {
send.mockRejectedValueOnce(new ApiError(404, 'Diese Karte gibt es nicht (mehr).'));
void open();
choose('spam');
submit();
await settle();
expect(errorText()).toBe('Diese Karte gibt es nicht (mehr).');
send.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
submit();
await settle();
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
});
it('ein unerwarteter Fehler (kein ApiError) bekommt einen allgemeinen deutschen Text', async () => {
send.mockRejectedValueOnce(new TypeError('offline'));
void open();
choose('spam');
submit();
await settle();
expect(errorText()).toBe('Keine Verbindung zum Server.');
});
it('während des Sendens ist alles gesperrt, ein zweites Absenden sendet nichts', async () => {
let finish!: (v: { schonGemeldet: boolean }) => void;
send.mockReturnValue(new Promise((r) => (finish = r)));
void open();
choose('spam');
submit();
await settle();
expect(buttonByText('Melden')!.disabled).toBe(true);
expect(radios().every((r) => r.disabled)).toBe(true);
expect(dialogEl()!.querySelector('form')!.getAttribute('aria-busy')).toBe('true');
submit();
await settle();
expect(send).toHaveBeenCalledTimes(1);
finish({ schonGemeldet: false });
await settle();
expect(statusText()).toBe('Danke für deine Meldung.');
});
it('Abbrechen schließt ohne zu senden: „cancelled“', async () => {
const result = open();
buttonByText('Abbrechen')!.click();
await expect(result).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
expect(send).not.toHaveBeenCalled();
});
it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen ebenfalls', async () => {
const first = open();
dialogEl()!.dispatchEvent(new Event('close'));
await expect(first).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
const second = open();
const dialog = dialogEl()!;
dialog.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialog.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await expect(second).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
});
it('ein Klick im Inneren schließt nicht', () => {
void open();
dialogEl()!.querySelector('.report-card')!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(dialogEl()?.open).toBe(true);
});
it('das Signal (Seite verlassen) schließt den Dialog; eine spät eintreffende Antwort ändert nichts', async () => {
let finish!: (v: { schonGemeldet: boolean }) => void;
send.mockReturnValue(new Promise((r) => (finish = r)));
const controller = new AbortController();
const result = open({ signal: controller.signal });
choose('spam');
submit();
await settle();
controller.abort();
await expect(result).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
finish({ schonGemeldet: false });
await settle();
expect(dialogEl()).toBeNull();
});
it('ein schon abgebrochenes Signal öffnet gar nicht erst', async () => {
const controller = new AbortController();
controller.abort();
await expect(open({ signal: controller.signal })).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
});
});
describe('Melden-Dialog für Logos (subject: „logo“)', () => {
const reasonLabels = () => [...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent);
it('Überschrift „Logo melden“, Einleitung nennt „dieses Logo“, vier Gründe ohne „Echte Kartendaten“', () => {
void open({ subject: 'logo' });
expect(dialogEl()?.open).toBe(true);
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Logo melden');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?');
expect(reasonLabels()).toEqual(['Unangemessen', 'Urheberrecht/Marke', 'Spam', 'Sonstiges']);
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'urheberrecht', 'spam', 'sonstiges']);
expect(radios().some((r) => r.checked)).toBe(false);
expect(buttonByText('Abbrechen')).toBeTruthy();
expect(buttonByText('Melden')).toBeTruthy();
});
it('die Gründe der Karten bleiben unverändert (REPORT_REASONS enthält weiter „Echte Kartendaten“)', () => {
expect(REPORT_REASONS.map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
});
it('der Titel der Karte spielt für Logos keine Rolle: die Einleitung nennt immer „dieses Logo“', () => {
void open({ subject: 'logo', cardTitle: 'Urlaub' });
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?');
});
it.each(['unangemessen', 'urheberrecht', 'spam', 'sonstiges'] as const)('Grund %s geht unverändert an send', async (value) => {
send.mockResolvedValue({ schonGemeldet: false });
void open({ subject: 'logo' });
choose(value);
submit();
await settle();
expect(send).toHaveBeenCalledExactlyOnceWith(value);
});
it('ohne Grund: derselbe Hinweis, nichts wird gesendet', async () => {
void open({ subject: 'logo' });
submit();
await settle();
expect(errorText()).toBe('Bitte wähle einen Grund.');
expect(send).not.toHaveBeenCalled();
});
it('neue Meldung: derselbe Dank, „reported“', async () => {
send.mockResolvedValue({ schonGemeldet: false });
const result = open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Danke für deine Meldung.');
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('reported');
});
it('doppelte Meldung: „Danke, du hast dieses Logo schon gemeldet.“ und „duplicate“', async () => {
send.mockResolvedValue({ schonGemeldet: true });
const result = open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Danke, du hast dieses Logo schon gemeldet.');
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('duplicate');
});
it('Fehler des Dienstes erscheinen wie bei Karten im Dialog, der offen bleibt', async () => {
send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
void open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(errorText()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
expect(dialogEl()?.open).toBe(true);
expect(buttonByText('Melden')!.disabled).toBe(false);
});
it('ein Dialog mit subject „card“ ist derselbe wie ohne Angabe', () => {
void open({ subject: 'card', cardTitle: 'Urlaub' });
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte melden');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du „Urlaub“ melden?');
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
});
});
describe('Melden-Dialog auf Englisch', () => {
beforeEach(() => setLang('en', { persist: false }));
it('Karte: Überschrift, Einleitung, Gründe, Knöpfe', () => {
void open({ cardTitle: 'Holiday' });
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report card');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report “Holiday”?');
expect(dialogEl()!.querySelector('legend')!.textContent).toBe('Reason for the report');
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
'Inappropriate',
'Real card data',
'Copyright/trademark',
'Spam',
'Other',
]);
expect(buttonByText('Cancel')).toBeTruthy();
expect(buttonByText('Report')).toBeTruthy();
// ohne Titel
dialogEl()!.remove();
void open();
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this card?');
});
it('Logo: Überschrift, Einleitung, vier Gründe, Dank und Duplikat', async () => {
send.mockResolvedValueOnce({ schonGemeldet: true });
const result = open({ subject: 'logo' });
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report logo');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this logo?');
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
'Inappropriate',
'Copyright/trademark',
'Spam',
'Other',
]);
submit(); // ohne Grund
await settle();
expect(errorText()).toBe('Please choose a reason.');
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Thanks, you’ve already reported this logo.');
buttonByText('Close')!.click();
await expect(result).resolves.toBe('duplicate');
});
it('Dank und Duplikat bei Karten', async () => {
send.mockResolvedValueOnce({ schonGemeldet: false }).mockResolvedValueOnce({ schonGemeldet: true });
const first = open();
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Thanks for your report.');
buttonByText('Close')!.click();
await first;
const second = open();
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Thanks, you’ve already reported this card.');
buttonByText('Close')!.click();
await second;
});
it('Fehler des Dienstes erscheinen übersetzt, wenn der Schlüssel bekannt ist', async () => {
send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
void open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(errorText()).toBe('This logo is no longer available.');
});
});