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>
This commit is contained in:
1 parent
4cf9fe192d
commit
a75be2d44c
10 files changed
+1976
-81
No files matched your search
@@ -0,0 +1,709 @@
|
||||
// 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('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('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']);
|
||||
row = logo({ hidden: true, reportCount: 1 });
|
||||
info.update();
|
||||
expect(buttons()).toEqual(['Delete', 'Unhide']);
|
||||
expect(hiddenNote()).toBe('Hidden');
|
||||
});
|
||||
});
|
||||
|
||||
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.');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user