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
@@ -1,9 +1,10 @@
|
||||
// Bereich „Logo unten rechts“ (Spec § 3 und § 5): Raster, Sortierung, Auswahl, Herzen und Hochladen. Datenschicht, Anmeldung und
|
||||
// Dialog sind Attrappen; die Kachelbilder laufen über das echte logo-image.ts mit ersetztem `Image` (dessen Einzelfälle stehen in
|
||||
// logo-image.test.ts).
|
||||
// Bereich „Logo unten rechts“ (Spec § 3, § 5 und § 6): Raster, Sortierung, Auswahl, Herzen, Hochladen und die Infozeile zum gewählten
|
||||
// Logo (Melden, Löschen, Admin). Datenschicht, Anmeldung und Dialoge sind Attrappen; die Kachelbilder laufen über das echte
|
||||
// logo-image.ts mit ersetztem `Image` (dessen Einzelfälle stehen in logo-image.test.ts), die Infozeile über das echte logo-info.ts
|
||||
// (dessen Einzelfälle stehen in logo-info.test.ts: hier zählt das Zusammenspiel mit Raster, Auswahl und Herzen).
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ rememberLogo: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({ rememberLogo: vi.fn(), forgetLogo: vi.fn() }));
|
||||
|
||||
// Der Picker hängt (über die Standardwerte) an Appwrite, Anmeldung und Dialogen; hier kommt alles über `deps`.
|
||||
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||
@@ -19,6 +20,7 @@ vi.mock('./logo-dialog', () => ({ logoUploadDialog: vi.fn() }));
|
||||
vi.mock('./logo-asset', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./logo-asset')>()),
|
||||
rememberLogo: mocks.rememberLogo,
|
||||
forgetLogo: mocks.forgetLogo,
|
||||
}));
|
||||
|
||||
import { ApiError } from '../community/api';
|
||||
@@ -71,6 +73,12 @@ const fx = {
|
||||
loadLogoBlob: vi.fn<LogoPickerDeps['loadLogoBlob']>(),
|
||||
requireLogin: vi.fn<LogoPickerDeps['requireLogin']>(),
|
||||
uploadDialog: vi.fn<LogoPickerDeps['uploadDialog']>(),
|
||||
reportLogo: vi.fn<LogoPickerDeps['reportLogo']>(),
|
||||
deleteLogo: vi.fn<LogoPickerDeps['deleteLogo']>(),
|
||||
setLogoVisibility: vi.fn<LogoPickerDeps['setLogoVisibility']>(),
|
||||
listLogoReports: vi.fn<LogoPickerDeps['listLogoReports']>(),
|
||||
confirm: vi.fn<LogoPickerDeps['confirm']>(),
|
||||
reportDialog: vi.fn<LogoPickerDeps['reportDialog']>(),
|
||||
onSelect: vi.fn<(id: string | null) => void>(),
|
||||
};
|
||||
|
||||
@@ -88,6 +96,12 @@ const deps: LogoPickerDeps = {
|
||||
return () => authListeners.delete(fn);
|
||||
},
|
||||
uploadDialog: fx.uploadDialog,
|
||||
reportLogo: fx.reportLogo,
|
||||
deleteLogo: fx.deleteLogo,
|
||||
setLogoVisibility: fx.setLogoVisibility,
|
||||
listLogoReports: fx.listLogoReports,
|
||||
confirm: fx.confirm,
|
||||
reportDialog: fx.reportDialog,
|
||||
};
|
||||
|
||||
// Bildprüfung: `loadLogoBlob` liefert einen Blob, den die Attrappe einem Logo zuordnet; die Object-URL erbt das, und das
|
||||
@@ -122,7 +136,7 @@ let host: HTMLElement;
|
||||
let picker: LogoPicker;
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of [...Object.values(fx), mocks.rememberLogo]) fn.mockReset();
|
||||
for (const fn of [...Object.values(fx), mocks.rememberLogo, mocks.forgetLogo]) fn.mockReset();
|
||||
user = 'u1';
|
||||
admin = false;
|
||||
authListeners.clear();
|
||||
@@ -142,6 +156,12 @@ beforeEach(() => {
|
||||
});
|
||||
fx.requireLogin.mockResolvedValue(true);
|
||||
fx.uploadDialog.mockResolvedValue(null);
|
||||
fx.reportLogo.mockResolvedValue({ schonGemeldet: false });
|
||||
fx.deleteLogo.mockResolvedValue(undefined);
|
||||
fx.setLogoVisibility.mockResolvedValue(undefined);
|
||||
fx.listLogoReports.mockResolvedValue([]);
|
||||
fx.confirm.mockResolvedValue(true);
|
||||
fx.reportDialog.mockResolvedValue('cancelled');
|
||||
URL.createObjectURL = vi.fn((blob: Blob) => {
|
||||
const url = `blob:logo-${created.length + 1}`;
|
||||
created.push(url);
|
||||
@@ -190,6 +210,18 @@ const uploadButton = () => picker.el.querySelector<HTMLButtonElement>('button.lo
|
||||
const moreButton = () => picker.el.querySelector<HTMLButtonElement>('button.logo-more')!;
|
||||
const retryButton = () => picker.el.querySelector<HTMLButtonElement>('button.logo-retry')!;
|
||||
const emptyText = () => picker.el.querySelector('.logo-empty')!.textContent;
|
||||
// Infozeile (logo-info.ts): sichtbar ist, was weder selbst noch in einem Vorfahren mit `hidden` verborgen ist.
|
||||
const infoEl = () => picker.el.querySelector<HTMLElement>('.logo-info')!;
|
||||
const shown = (node: Element) => node.closest('[hidden]') === null;
|
||||
const infoLine = () => {
|
||||
const text = infoEl().querySelector('.logo-info-text')!;
|
||||
return shown(text) ? text.textContent : '';
|
||||
};
|
||||
const infoGone = () => infoEl().querySelector('.logo-info-gone')!.textContent;
|
||||
const infoMessage = () => infoEl().querySelector('[role="alert"]')!.textContent;
|
||||
const infoButtons = () => [...infoEl().querySelectorAll('button')].filter(shown).map((b) => b.textContent);
|
||||
const infoButton = (label: string) =>
|
||||
[...infoEl().querySelectorAll('button')].find((b) => b.textContent === label && shown(b)) as HTMLButtonElement;
|
||||
|
||||
describe('Aufbau', () => {
|
||||
it('Kopf: Überschrift „Logo unten rechts“, Plakette „Community“, Knopf „Hochladen“ und der Satz zur Community', async () => {
|
||||
@@ -645,6 +677,7 @@ describe('Herzen', () => {
|
||||
expect(heart('Logo A').textContent).toBe('7');
|
||||
expect(heart('Logo A').getAttribute('aria-label')).toBe('Herz für „Logo A“: 7');
|
||||
expect(alertText()).toBe('');
|
||||
expect(infoMessage()).toBe('');
|
||||
});
|
||||
|
||||
it('ein zweiter Klick nimmt das Herz zurück: setHeart(id, false), Zahl − 1', async () => {
|
||||
@@ -673,7 +706,7 @@ describe('Herzen', () => {
|
||||
expect(heart('Logo A').textContent).toBe('0');
|
||||
});
|
||||
|
||||
it('Fehler: das Herz springt samt Zahl zurück, der Grund steht im Statusbereich (role="alert")', async () => {
|
||||
it('Fehler: das Herz springt samt Zahl zurück, der Grund steht in der Infozeile (role="alert"), nicht im Statusbereich der Liste', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 2 })], nextCursor: null });
|
||||
fx.setHeart.mockRejectedValue(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
|
||||
await mount();
|
||||
@@ -681,7 +714,8 @@ describe('Herzen', () => {
|
||||
await flush();
|
||||
expect(heart('Logo A').getAttribute('aria-pressed')).toBe('false');
|
||||
expect(heart('Logo A').textContent).toBe('2');
|
||||
expect(alertText()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
|
||||
expect(infoMessage()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
|
||||
expect(alertText()).toBe('');
|
||||
});
|
||||
|
||||
it('Fehler anderer Art: allgemeiner Text; der Fehler verschwindet beim nächsten Versuch, der dann auch wieder möglich ist', async () => {
|
||||
@@ -689,10 +723,10 @@ describe('Herzen', () => {
|
||||
await mount();
|
||||
heart('Logo A').click();
|
||||
await flush();
|
||||
expect(alertText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||
expect(infoMessage()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||
expect(heartsOn()).toEqual([]);
|
||||
heart('Logo A').click(); // die Anfrage ist zu Ende: der Knopf ist wieder frei
|
||||
expect(alertText()).toBe('');
|
||||
expect(infoMessage()).toBe('');
|
||||
await flush();
|
||||
expect(fx.setHeart).toHaveBeenCalledTimes(2);
|
||||
expect(heartsOn()).toEqual(['Logo A']);
|
||||
@@ -752,6 +786,7 @@ describe('Herzen', () => {
|
||||
expect(heartsOn()).toEqual([]);
|
||||
expect(heart('Logo A').textContent).toBe('0');
|
||||
expect(alertText()).toBe('');
|
||||
expect(infoMessage()).toBe('');
|
||||
heart('Logo A').click(); // kein hängender Zustand: der nächste Klick fragt wieder nach dem Login
|
||||
await flush();
|
||||
expect(fx.requireLogin).toHaveBeenCalledTimes(2);
|
||||
@@ -1000,12 +1035,13 @@ describe('Hochladen', () => {
|
||||
expect(mocks.rememberLogo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lässt sich der Dialog gar nicht öffnen (Fehler), steht der Grund im Statusbereich', async () => {
|
||||
it('lässt sich der Dialog gar nicht öffnen (Fehler), steht der Grund in der Infozeile (der Statusbereich der Liste bleibt für Ladefehler)', async () => {
|
||||
fx.uploadDialog.mockRejectedValue(new Error('kein <dialog>'));
|
||||
await mount();
|
||||
uploadButton().click();
|
||||
await flush();
|
||||
expect(alertText()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||
expect(infoMessage()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||
expect(alertText()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1099,6 +1135,18 @@ describe('dispose', () => {
|
||||
expect(fx.myHearts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('select() nach dem Aufräumen ändert nichts und fragt nichts mehr an', async () => {
|
||||
await mount();
|
||||
picker.dispose();
|
||||
picker.select('b');
|
||||
picker.select('x');
|
||||
await flush();
|
||||
expect(pressedTiles()).toEqual(['Kein Logo']);
|
||||
expect(fx.getLogo).not.toHaveBeenCalled();
|
||||
expect(picker.selected()).toBeNull();
|
||||
expect(infoLine()).toBe('');
|
||||
});
|
||||
|
||||
it('eine Herz-Antwort nach dem Aufräumen wird verworfen', async () => {
|
||||
const answer = deferred<{ herz: boolean; likeCount: number }>();
|
||||
fx.setHeart.mockReturnValue(answer.promise);
|
||||
@@ -1110,3 +1158,436 @@ describe('dispose', () => {
|
||||
expect(heart('Logo A').textContent).toBe('1'); // der Stand von vor dem Aufräumen, kein Fehler
|
||||
});
|
||||
});
|
||||
|
||||
describe('Herzen: Fehler und Anmeldung', () => {
|
||||
it('Gast: lässt sich der Login-Dialog nicht öffnen (requireLogin wirft), steht der Grund in der Infozeile; das Herz bleibt aus, und es geht danach wieder', async () => {
|
||||
user = null;
|
||||
fx.requireLogin.mockRejectedValueOnce(new Error('kein <dialog>'));
|
||||
await mount();
|
||||
heart('Logo A').click();
|
||||
await flush();
|
||||
expect(fx.setHeart).not.toHaveBeenCalled();
|
||||
expect(heartsOn()).toEqual([]);
|
||||
expect(infoMessage()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||
heart('Logo A').click(); // kein hängender Zustand
|
||||
await flush();
|
||||
expect(fx.requireLogin).toHaveBeenCalledTimes(2);
|
||||
expect(fx.setHeart).toHaveBeenCalledExactlyOnceWith('a', true);
|
||||
expect(infoMessage()).toBe('');
|
||||
});
|
||||
|
||||
it('ein Klick auf Herz oder „Hochladen“ löscht den Ladefehler der Liste nicht: der steht allein im Statusbereich', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
|
||||
await mount();
|
||||
fx.listLogos.mockRejectedValueOnce(new Error('offline'));
|
||||
moreButton().click();
|
||||
await flush();
|
||||
expect(alertText()).toBe('Die Logos konnten nicht geladen werden.');
|
||||
heart('Logo A').click();
|
||||
await flush();
|
||||
expect(alertText()).toBe('Die Logos konnten nicht geladen werden.');
|
||||
uploadButton().click();
|
||||
await flush();
|
||||
expect(alertText()).toBe('Die Logos konnten nicht geladen werden.');
|
||||
expect(retryButton().hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('der Herz-Fehler steht auch dann in der Infozeile, wenn das Logo gar nicht gewählt ist', async () => {
|
||||
fx.setHeart.mockRejectedValue(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
|
||||
await mount();
|
||||
picker.select('c');
|
||||
heart('Logo A').click();
|
||||
await flush();
|
||||
expect(infoMessage()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
|
||||
expect(infoLine()).toBe('Logo C · von Kenso · ♥ 0');
|
||||
picker.select('b'); // eine andere Auswahl räumt den alten Fehler ab
|
||||
expect(infoMessage()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Infozeile', () => {
|
||||
it('sitzt als Letztes im Bereich, unter dem Raster und „Mehr laden“; ohne Auswahl ist sie leer', async () => {
|
||||
await mount();
|
||||
expect(picker.el.lastElementChild).toBe(infoEl());
|
||||
const grid = picker.el.querySelector('ul.logo-grid')!;
|
||||
expect(grid.compareDocumentPosition(infoEl()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(infoLine()).toBe('');
|
||||
expect(infoButtons()).toEqual([]);
|
||||
expect(infoMessage()).toBe('');
|
||||
});
|
||||
|
||||
it('select(id) zeigt „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ – auch für Gäste; „Kein Logo“ leert sie', async () => {
|
||||
user = null;
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 7 }), logo('b', { displayName: '' })], nextCursor: null });
|
||||
await mount();
|
||||
picker.select('a');
|
||||
expect(infoLine()).toBe('Logo A · von Kenso · ♥ 7');
|
||||
expect(infoButtons()).toEqual(['Melden']);
|
||||
picker.select('b');
|
||||
expect(infoLine()).toBe('Logo B · von Anonym · ♥ 0');
|
||||
picker.select(null);
|
||||
expect(infoLine()).toBe('');
|
||||
expect(infoButtons()).toEqual([]);
|
||||
});
|
||||
|
||||
it('der Zähler der Infozeile folgt dem Herz: sofort und mit der Antwort des Dienstes', async () => {
|
||||
const answer = deferred<{ herz: boolean; likeCount: number }>();
|
||||
fx.setHeart.mockReturnValue(answer.promise);
|
||||
await mount();
|
||||
picker.select('a');
|
||||
heart('Logo A').click();
|
||||
expect(infoLine()).toBe('Logo A · von Kenso · ♥ 1');
|
||||
answer.resolve({ herz: true, likeCount: 5 });
|
||||
await flush();
|
||||
expect(infoLine()).toBe('Logo A · von Kenso · ♥ 5');
|
||||
});
|
||||
|
||||
it('ein Logo außerhalb der Liste: die Zeile erscheint, sobald getLogo es geholt hat; vorher steht dort nichts', async () => {
|
||||
const slow = deferred<CommunityLogo | null>();
|
||||
fx.getLogo.mockReturnValue(slow.promise);
|
||||
await mount();
|
||||
picker.select('x');
|
||||
await flush();
|
||||
expect(infoLine()).toBe('');
|
||||
expect(infoGone()).toBe('');
|
||||
slow.resolve(logo('x', { name: 'Fremdes Logo', displayName: 'Anna', likeCount: 2 }));
|
||||
await flush();
|
||||
expect(infoLine()).toBe('Fremdes Logo · von Anna · ♥ 2');
|
||||
});
|
||||
|
||||
it('solange die Liste noch lädt und das Logo noch unbekannt ist, steht nichts da; danach die Zeile aus der Liste', async () => {
|
||||
const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
|
||||
fx.listLogos.mockReturnValue(list.promise);
|
||||
picker = createLogoPicker({ onSelect: fx.onSelect }, deps);
|
||||
host.append(picker.el);
|
||||
picker.select('b');
|
||||
await flush();
|
||||
expect(infoLine()).toBe('');
|
||||
expect(infoGone()).toBe('');
|
||||
list.resolve({ logos: [logo('a'), logo('b', { likeCount: 3 })], nextCursor: null });
|
||||
await flush();
|
||||
expect(infoLine()).toBe('Logo B · von Kenso · ♥ 3');
|
||||
});
|
||||
|
||||
it('ein Logo, das es nicht mehr gibt (getLogo → null): „Das gewählte Logo ist nicht mehr verfügbar.“; eine andere Wahl nimmt den Hinweis weg', async () => {
|
||||
await mount();
|
||||
picker.select('weg');
|
||||
await flush();
|
||||
expect(infoGone()).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
|
||||
expect(infoLine()).toBe('');
|
||||
expect(infoButtons()).toEqual([]);
|
||||
picker.select('b');
|
||||
expect(infoGone()).toBe('');
|
||||
expect(infoLine()).toBe('Logo B · von Kenso · ♥ 0');
|
||||
picker.select('weg'); // noch einmal: die Antwort von vorhin gilt nicht mehr, es wird neu gefragt
|
||||
expect(infoGone()).toBe('');
|
||||
await flush();
|
||||
expect(infoGone()).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
|
||||
expect(fx.getLogo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('scheitert getLogo (Netz), sagt die Infozeile nichts: das Logo kann es ja geben', async () => {
|
||||
fx.getLogo.mockRejectedValue(new Error('offline'));
|
||||
await mount();
|
||||
picker.select('x');
|
||||
await flush();
|
||||
expect(infoGone()).toBe('');
|
||||
expect(infoLine()).toBe('');
|
||||
});
|
||||
|
||||
it('eine späte „gibt es nicht“-Antwort für eine inzwischen verlassene Auswahl bleibt ohne Wirkung', async () => {
|
||||
const slow = deferred<CommunityLogo | null>();
|
||||
fx.getLogo.mockReturnValueOnce(slow.promise);
|
||||
await mount();
|
||||
picker.select('x');
|
||||
picker.select('b');
|
||||
slow.resolve(null);
|
||||
await flush();
|
||||
expect(infoGone()).toBe('');
|
||||
expect(infoLine()).toBe('Logo B · von Kenso · ♥ 0');
|
||||
});
|
||||
|
||||
it('„Melden“ öffnet den Dialog für Logos (mit dem Signal des Pickers) und sendet reportLogo(id, Grund)', async () => {
|
||||
await mount();
|
||||
picker.select('b');
|
||||
infoButton('Melden').click();
|
||||
await flush();
|
||||
expect(fx.reportDialog).toHaveBeenCalledTimes(1);
|
||||
const opts = fx.reportDialog.mock.calls[0][0];
|
||||
expect(opts.subject).toBe('logo');
|
||||
await opts.send('urheberrecht');
|
||||
expect(fx.reportLogo).toHaveBeenCalledExactlyOnceWith('b', 'urheberrecht');
|
||||
expect(opts.signal!.aborted).toBe(false);
|
||||
picker.dispose();
|
||||
expect(opts.signal!.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('Anmelden und Abmelden: „Löschen“ erscheint für den Hochlader und verschwindet wieder', async () => {
|
||||
user = null;
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' })], nextCursor: null });
|
||||
await mount();
|
||||
picker.select('a');
|
||||
expect(infoButtons()).toEqual(['Melden']);
|
||||
user = 'u1';
|
||||
emitAuth();
|
||||
await flush();
|
||||
expect(infoButtons()).toEqual(['Melden', 'Löschen']);
|
||||
user = null;
|
||||
emitAuth();
|
||||
expect(infoButtons()).toEqual(['Melden']);
|
||||
});
|
||||
|
||||
it('Admin: Anzahl und Gründe der Meldungen, „Ausblenden“ und „Löschen“ – aus der Zeile des Pickers und listLogoReports', async () => {
|
||||
admin = true;
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a', { reportCount: 2 })], nextCursor: null });
|
||||
fx.listLogoReports.mockResolvedValue([
|
||||
{ id: 'r1', logoId: 'a', reason: 'spam', createdAt: '2026-10-04T12:00:00.000Z' },
|
||||
{ id: 'r2', logoId: 'a', reason: 'sonstiges', createdAt: '2026-10-04T12:01:00.000Z' },
|
||||
]);
|
||||
await mount();
|
||||
picker.select('a');
|
||||
await flush();
|
||||
expect(fx.listLogoReports).toHaveBeenCalledExactlyOnceWith('a');
|
||||
expect(infoEl().querySelector('.logo-info-reports')!.textContent).toBe('2 Meldungen');
|
||||
expect([...infoEl().querySelectorAll('.logo-info-reasons li')].map((li) => li.textContent)).toEqual(['Spam', 'Sonstiges']);
|
||||
expect(infoButtons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
|
||||
});
|
||||
|
||||
it('Englisch: Zeile und Knöpfe sind übersetzt', async () => {
|
||||
setLang('en', { persist: false });
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1', displayName: '' })], nextCursor: null });
|
||||
await mount();
|
||||
picker.select('a');
|
||||
expect(infoLine()).toBe('Logo A · by Anonymous · ♥ 0');
|
||||
expect(infoButtons()).toEqual(['Report', 'Delete']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Löschen (mit Raster und Auswahl)', () => {
|
||||
beforeEach(() => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c')], nextCursor: null });
|
||||
});
|
||||
|
||||
it('bestätigt: deleteLogo, forgetLogo, die Kachel ist weg, die Auswahl wird aufgehoben (onSelect(null)); die übrigen bleiben', async () => {
|
||||
await mount();
|
||||
picker.select('b');
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
expect(fx.deleteLogo).toHaveBeenCalledExactlyOnceWith('b');
|
||||
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('b');
|
||||
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo C']);
|
||||
expect(fx.onSelect).toHaveBeenCalledExactlyOnceWith(null);
|
||||
expect(picker.selected()).toBeNull();
|
||||
expect(infoLine()).toBe('');
|
||||
expect(infoGone()).toBe('');
|
||||
expect(infoMessage()).toBe('');
|
||||
expect(moreButton().hidden).toBe(true); // es gab keine weitere Seite, und das Löschen erfindet keine
|
||||
expect(revoked).toContain(created[1]); // das Bild der Kachel ist freigegeben
|
||||
});
|
||||
|
||||
it('der Editor übernimmt onSelect(null) mit select(null): „Kein Logo“ ist gewählt, nichts bleibt hängen', async () => {
|
||||
await mount();
|
||||
picker.select('b');
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
picker.select(null);
|
||||
expect(pressedTiles()).toEqual(['Kein Logo']);
|
||||
expect(infoLine()).toBe('');
|
||||
expect(fx.getLogo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('abgelehnt: nichts passiert', async () => {
|
||||
fx.confirm.mockResolvedValue(false);
|
||||
await mount();
|
||||
picker.select('b');
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
expect(fx.deleteLogo).not.toHaveBeenCalled();
|
||||
expect(mocks.forgetLogo).not.toHaveBeenCalled();
|
||||
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
|
||||
expect(fx.onSelect).not.toHaveBeenCalled();
|
||||
expect(infoMessage()).toBe('');
|
||||
});
|
||||
|
||||
it('Fehler: der Text steht in der Infozeile; Kachel, Zeile und Auswahl bleiben', async () => {
|
||||
fx.deleteLogo.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
|
||||
await mount();
|
||||
picker.select('b');
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
expect(infoMessage()).toBe('Dazu hast du keine Berechtigung.');
|
||||
expect(alertText()).toBe('');
|
||||
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
|
||||
expect(fx.onSelect).not.toHaveBeenCalled();
|
||||
expect(mocks.forgetLogo).not.toHaveBeenCalled();
|
||||
expect(picker.selected()).toMatchObject({ id: 'b' });
|
||||
});
|
||||
|
||||
it('Löschen sehen nur Hochlader und Admins', async () => {
|
||||
await mount();
|
||||
picker.select('a'); // gehört u9
|
||||
expect(infoButtons()).toEqual(['Melden']);
|
||||
picker.select('b'); // gehört u1 = ich
|
||||
expect(infoButtons()).toEqual(['Melden', 'Löschen']);
|
||||
});
|
||||
|
||||
it('das letzte Logo gelöscht: der Leer-Text erscheint', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' })], nextCursor: null });
|
||||
await mount();
|
||||
picker.select('a');
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
expect(names()).toEqual(['Kein Logo']);
|
||||
expect(emptyText()).toBe('Noch keine Community-Logos – lade das erste hoch.');
|
||||
});
|
||||
|
||||
it('ein angeheftetes Logo (nicht in der Liste) lässt sich löschen: seine Kachel verschwindet', async () => {
|
||||
fx.getLogo.mockResolvedValue(logo('x', { name: 'Fremdes Logo', ownerId: 'u1' }));
|
||||
await mount();
|
||||
picker.select('x');
|
||||
await flush();
|
||||
expect(names()).toContain('Fremdes Logo');
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
|
||||
expect(fx.onSelect).toHaveBeenCalledExactlyOnceWith(null);
|
||||
});
|
||||
|
||||
it('hat sich die Auswahl während der Anfrage geändert, bleibt die neue Auswahl unberührt (kein onSelect)', async () => {
|
||||
const request = deferred<void>();
|
||||
fx.deleteLogo.mockReturnValue(request.promise);
|
||||
await mount();
|
||||
picker.select('b');
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
picker.select('c');
|
||||
request.resolve();
|
||||
await flush();
|
||||
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo C']);
|
||||
expect(fx.onSelect).not.toHaveBeenCalled();
|
||||
expect(pressedTiles()).toEqual(['Logo C']);
|
||||
expect(infoLine()).toBe('Logo C · von Kenso · ♥ 0');
|
||||
});
|
||||
|
||||
it('war das gelöschte Logo der Cursor für „Mehr laden“ (die Anfrage braucht ein Logo, das es gibt), setzt das Nachladen beim Logo davor an', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b'), logo('c', { ownerId: 'u1' })], nextCursor: 'c' });
|
||||
await mount();
|
||||
picker.select('c');
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
expect(moreButton().hidden).toBe(false);
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('d')], nextCursor: null });
|
||||
moreButton().click();
|
||||
await flush();
|
||||
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'b' });
|
||||
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo D']);
|
||||
});
|
||||
|
||||
it('ein anderes gelöschtes Logo lässt den Cursor für „Mehr laden“ stehen', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c')], nextCursor: 'c' });
|
||||
await mount();
|
||||
picker.select('b');
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('d')], nextCursor: null });
|
||||
moreButton().click();
|
||||
await flush();
|
||||
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'c' });
|
||||
});
|
||||
|
||||
it('ein Logo mit derselben ID, das später auftaucht, erbt das Herz des gelöschten nicht', async () => {
|
||||
fx.myHearts.mockResolvedValueOnce(new Set(['b']));
|
||||
await mount();
|
||||
expect(heartsOn()).toEqual(['Logo B']);
|
||||
picker.select('b');
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
picker.select(null);
|
||||
fx.myHearts.mockReturnValue(new Promise(() => {})); // die Herzen der neuen Liste kommen noch
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('b', { name: 'Neues B' })], nextCursor: null });
|
||||
sortButton('Neu').click();
|
||||
await flush();
|
||||
expect(names()).toEqual(['Kein Logo', 'Neues B']);
|
||||
expect(heartsOn()).toEqual([]);
|
||||
});
|
||||
|
||||
it('der Fokus wandert von „Löschen“, das verschwindet, auf „Kein Logo“', async () => {
|
||||
await mount();
|
||||
picker.select('b');
|
||||
infoButton('Löschen').focus();
|
||||
expect(document.activeElement).toBe(infoButton('Löschen'));
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
expect(document.activeElement).toBe(tile('Kein Logo'));
|
||||
});
|
||||
|
||||
it('der Fokus bleibt, wo er ist, wenn er nicht in der Infozeile lag', async () => {
|
||||
await mount();
|
||||
picker.select('b');
|
||||
infoButton('Löschen').click();
|
||||
tile('Logo C').focus();
|
||||
await flush();
|
||||
expect(document.activeElement).toBe(tile('Logo C'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ausblenden und Einblenden (Admin, mit Raster und Auswahl)', () => {
|
||||
beforeEach(() => {
|
||||
admin = true;
|
||||
fx.listLogos.mockResolvedValue({
|
||||
logos: [logo('a'), logo('b', { reportCount: 3 }), logo('c', { hidden: true, reportCount: 3 })],
|
||||
nextCursor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('„Ausblenden“: setLogoVisibility(id, false), forgetLogo; die Zeile des Pickers ist hidden, die Kachel bleibt', async () => {
|
||||
await mount();
|
||||
picker.select('b');
|
||||
infoButton('Ausblenden').click();
|
||||
await flush();
|
||||
expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('b', false);
|
||||
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('b');
|
||||
expect(picker.selected()).toMatchObject({ id: 'b', hidden: true, reportCount: 3 });
|
||||
expect(names()).toContain('Logo B');
|
||||
expect(infoButtons()).toEqual(['Löschen', 'Einblenden']);
|
||||
expect(infoEl().querySelector('.logo-info-reports')!.textContent).toBe('3 Meldungen');
|
||||
});
|
||||
|
||||
it('„Einblenden“: setLogoVisibility(id, true), forgetLogo; die Zeile ist sichtbar und hat 0 Meldungen', async () => {
|
||||
await mount();
|
||||
picker.select('c');
|
||||
expect(infoButtons()).toEqual(['Löschen', 'Einblenden']);
|
||||
infoButton('Einblenden').click();
|
||||
await flush();
|
||||
expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('c', true);
|
||||
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('c');
|
||||
expect(picker.selected()).toMatchObject({ id: 'c', hidden: false, reportCount: 0 });
|
||||
expect(infoButtons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
|
||||
expect(infoEl().querySelector('.logo-info-reports')!.textContent).toBe('0 Meldungen');
|
||||
});
|
||||
|
||||
it('Fehler: der Text steht in der Infozeile, die Zeile bleibt unverändert', async () => {
|
||||
fx.setLogoVisibility.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
|
||||
await mount();
|
||||
picker.select('b');
|
||||
infoButton('Ausblenden').click();
|
||||
await flush();
|
||||
expect(infoMessage()).toBe('Der Dienst ist gerade nicht erreichbar.');
|
||||
expect(picker.selected()).toMatchObject({ id: 'b', hidden: false });
|
||||
expect(mocks.forgetLogo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('der Herz-Zähler, der sich während der Anfrage ändert, geht nicht verloren (die Zeile wird mit dem aktuellen Stand fortgeschrieben)', async () => {
|
||||
const request = deferred<void>();
|
||||
fx.setLogoVisibility.mockReturnValue(request.promise);
|
||||
fx.setHeart.mockResolvedValue({ herz: true, likeCount: 9 });
|
||||
await mount();
|
||||
picker.select('b');
|
||||
infoButton('Ausblenden').click();
|
||||
heart('Logo B').click();
|
||||
await flush();
|
||||
request.resolve();
|
||||
await flush();
|
||||
expect(picker.selected()).toMatchObject({ id: 'b', hidden: true, likeCount: 9 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user