Files
AirCardIOS/web/src/logos/logo-picker.test.ts
T
ClaudeandClaude Opus 5.5 e0f11f905e fix(editor): Vorschau und Miniaturen laden ein ausgeblendetes, eingeblendetes oder gelöschtes Logo neu
Blendete ein Admin das gewählte Logo im Picker aus (oder ein), forgetLogo leerte zwar den Zwischenspeicher, aber der Editor behielt seine schon
geladenen Logo-Bilder: Vorschau und Miniaturen zeigten weiter den alten Stand, bis sich Logo, Details oder Marke änderten.

- logo-picker.ts: neuer Rückruf `onLogoChanged(id)` (Typ LogoPickerHandlers). Der Picker ruft ihn, wenn die Infozeile ein Logo aus- oder eingeblendet hat
  (nach patchRow, die Zeile ist dann schon aktuell) und nach dem Löschen (nach onSelect(null)).
- editor-page.ts: Ist das gemeldete Logo das gewählte, verwirft der Editor seinen Logo-Zustand samt Sperren (logos, pendingKey, failedKey), blendet den Hinweis
  aus und ruft ensureLogos neu: Vorschau und alle vier Miniaturen werden mit dem neuen Stand gezeichnet, der Hinweis „nicht mehr verfügbar“ folgt. Ein
  anderes oder kein gewähltes Logo löst nichts aus; ein noch laufender Ladevorgang wird überholt (Token).
- editor-page.ts, followDownload: Stand die Karte nach einem Ladefehler ohne Logo da (`failed`), kehrte der Download früh zurück, obwohl er die Logos
  geladen hatte: das PNG hatte das Logo, die Vorschau nicht. Jetzt übernimmt die Vorschau es (Hinweis passend, failedKey leer).
- editor-page.ts, doSave: forgetLogo(config.logo) vor dem Laden der Bilder, wie Download und Veröffentlichen; ein inzwischen ausgeblendetes Logo kommt nicht
  mehr ins Vorschaubild der gespeicherten Karte.
- editor-page.ts: Beim Ladefehler für eine neue Auswahl blieb der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ der früheren Auswahl neben dem neuen
  Fehler stehen; die Logos der früheren Auswahl und ihr Hinweis fallen jetzt sofort weg (andere Hinweise bleiben).
- Tests: Picker (Rückruf nach Aus-/Einblenden und Löschen mit Reihenfolge, nicht bei Fehlern, Herz, Melden, nach dem Aufräumen) und Editor (Aus- und Einblenden,
  anderes oder kein Logo, überholter Ladevorgang, nach Ladefehler, mit dem echten Zwischenspeicher; Download-Übernahme, Speichern, alter Hinweis neben Fehler).

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

2009 lines
85 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.
// 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(), 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: '' }));
vi.mock('../account/auth', () => ({
currentUser: () => null,
currentUserId: () => null,
isAdmin: () => false,
onAuthChange: () => () => {},
onLogout: () => () => {},
}));
vi.mock('../account/login-dialog', () => ({ requireLogin: vi.fn() }));
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';
import { setLang } from '../i18n/i18n';
import { createLogoPicker, type LogoPicker, type LogoPickerDeps } from './logo-picker';
import type { CommunityLogo } from './logos';
const logo = (id: string, patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
id,
name: `Logo ${id.toUpperCase()}`,
displayName: 'Kenso',
fileId: id,
ownerId: 'u9',
mono: false,
likeCount: 0,
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T12:00:00.000Z',
...patch,
});
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 user: string | null;
let admin: boolean;
const authListeners = new Set<() => void>();
const emitAuth = () => {
for (const fn of [...authListeners]) fn();
};
const fx = {
listLogos: vi.fn<LogoPickerDeps['listLogos']>(),
getLogo: vi.fn<LogoPickerDeps['getLogo']>(),
myHearts: vi.fn<LogoPickerDeps['myHearts']>(),
setHeart: vi.fn<LogoPickerDeps['setHeart']>(),
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>(),
onLogoChanged: vi.fn<(id: string) => void>(),
};
const deps: LogoPickerDeps = {
listLogos: fx.listLogos,
getLogo: fx.getLogo,
myHearts: fx.myHearts,
setHeart: fx.setHeart,
loadLogoBlob: fx.loadLogoBlob,
userId: () => user,
isAdmin: () => admin,
requireLogin: fx.requireLogin,
onAuthChange(fn) {
authListeners.add(fn);
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
// ersetzte `Image` meldet daraus die Maße (oder einen Ladefehler).
const blobOwner = new WeakMap<Blob, string>();
const urlOwner = new Map<string, string>();
const sizes = new Map<string, [number, number]>();
const broken = new Set<string>();
let created: string[];
let revoked: string[];
class FakeImage {
naturalWidth = 0;
naturalHeight = 0;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(value: string) {
queueMicrotask(() => {
const id = urlOwner.get(value) ?? '';
if (broken.has(id)) {
this.onerror?.();
return;
}
[this.naturalWidth, this.naturalHeight] = sizes.get(id) ?? [200, 80];
this.onload?.();
});
}
}
let host: HTMLElement;
let picker: LogoPicker;
beforeEach(() => {
for (const fn of [...Object.values(fx), mocks.rememberLogo, mocks.forgetLogo]) fn.mockReset();
user = 'u1';
admin = false;
authListeners.clear();
sizes.clear();
broken.clear();
urlOwner.clear();
created = [];
revoked = [];
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b'), logo('c')], nextCursor: null });
fx.getLogo.mockResolvedValue(null);
fx.myHearts.mockResolvedValue(new Set());
fx.setHeart.mockImplementation(async (_id, herz) => ({ herz, likeCount: herz ? 1 : 0 }));
fx.loadLogoBlob.mockImplementation(async (row) => {
const blob = new Blob(['png']);
blobOwner.set(blob, row.id);
return blob;
});
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);
urlOwner.set(url, blobOwner.get(blob) ?? '');
return url;
});
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
vi.stubGlobal('Image', FakeImage);
document.body.innerHTML = '';
host = document.createElement('div');
document.body.append(host);
});
afterEach(() => {
picker?.dispose();
host.remove();
vi.unstubAllGlobals();
});
async function mount(): Promise<LogoPicker> {
picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
host.append(picker.el);
await flush();
return picker;
}
// ---- Zugriffe auf das DOM -------------------------------------------------------------------------------------------------
const cells = () => [...picker.el.querySelectorAll<HTMLElement>('li.logo-cell')];
const cellName = (cell: HTMLElement) => cell.querySelector('.logo-tile-name')?.textContent;
/** Die Namen aller Kacheln in Reihenfolge, die erste ist „Kein Logo“. */
const names = () => cells().map(cellName);
const cell = (name: string) => cells().find((c) => cellName(c) === name)!;
const tile = (name: string) => cell(name).querySelector<HTMLButtonElement>('button.logo-tile')!;
const heart = (name: string) => cell(name).querySelector<HTMLButtonElement>('button.logo-heart')!;
const art = (name: string) => cell(name).querySelector<HTMLElement>('.logo-tile-art')!;
const pressedTiles = () => cells().filter((c) => c.querySelector('button.logo-tile')?.getAttribute('aria-pressed') === 'true').map(cellName);
const heartsOn = () => cells().filter((c) => c.querySelector('button.logo-heart')?.getAttribute('aria-pressed') === 'true').map(cellName);
const sortButtons = () => [...picker.el.querySelectorAll<HTMLButtonElement>('.logo-sort button')];
const sortButton = (label: string) => sortButtons().find((b) => b.textContent === label)!;
const pressedSort = () => sortButtons().filter((b) => b.getAttribute('aria-pressed') === 'true').map((b) => b.textContent);
const alertText = () => picker.el.querySelector('[role="alert"]')!.textContent;
const uploadButton = () => picker.el.querySelector<HTMLButtonElement>('button.logo-upload')!;
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 () => {
await mount();
const heading = picker.el.querySelector('h3')!;
expect(heading.textContent).toBe('Logo unten rechts');
expect(picker.el.getAttribute('aria-labelledby')).toBe(heading.id);
expect(picker.el.querySelector('.logo-badge')!.textContent).toBe('Community');
expect(uploadButton().type).toBe('button');
expect(uploadButton().querySelector('svg')).not.toBeNull(); // das Plus
expect(picker.el.querySelector('.logo-picker-note')!.textContent).toBe('Logos werden von der Community hochgeladen.');
});
it('das Raster ist eine Liste mit role="list" (list-style: none nähme sie sonst weg); den Namen trägt die Gruppe, nicht die Liste doppelt', async () => {
await mount();
const heading = picker.el.querySelector('h3')!;
const grid = picker.el.querySelector('ul.logo-grid')!;
expect(grid.getAttribute('role')).toBe('list');
expect(grid.hasAttribute('aria-labelledby')).toBe(false);
expect(picker.el.getAttribute('role')).toBe('group');
expect(picker.el.getAttribute('aria-labelledby')).toBe(heading.id);
expect(picker.el.querySelectorAll('[aria-labelledby]')).toHaveLength(0); // unterhalb der Gruppe verweist nichts mehr auf die Überschrift
});
it('die Überschrift lässt sich per Skript fokussieren (tabindex −1), ohne in der Tab-Reihenfolge zu stehen', async () => {
await mount();
expect(picker.el.querySelector('h3')!.getAttribute('tabindex')).toBe('-1');
});
it('Umschalter „Beliebt“ (gedrückt) und „Neu“; Nicht-Admins sehen „Gemeldet“ und „Ausgeblendet“ nicht', async () => {
await mount();
expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu']);
expect(pressedSort()).toEqual(['Beliebt']);
});
it('erste Kachel „Kein Logo“, dann je Logo eine Kachel mit Name, „von <Anzeigename>“ bzw. „von Anonym“ und Herz-Knopf mit der Zahl', async () => {
fx.listLogos.mockResolvedValue({
logos: [logo('a', { likeCount: 12 }), logo('b', { displayName: '' }), logo('c', { displayName: 'Anna', likeCount: 3 })],
nextCursor: null,
});
await mount();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(cell('Kein Logo').querySelector('button.logo-tile')!.classList.contains('logo-tile-none')).toBe(true);
expect(cell('Kein Logo').querySelector('.logo-heart')).toBeNull();
expect(['Logo A', 'Logo B', 'Logo C'].map((n) => cell(n).querySelector('.logo-tile-by')!.textContent)).toEqual([
'von Kenso',
'von Anonym',
'von Anna',
]);
expect(['Logo A', 'Logo B', 'Logo C'].map((n) => heart(n).textContent)).toEqual(['12', '0', '3']);
});
it('der Herz-Knopf ist ein eigener Knopf neben dem Auswahlknopf, nie darin verschachtelt', async () => {
await mount();
for (const c of cells().slice(1)) {
const select = c.querySelector<HTMLButtonElement>('button.logo-tile')!;
const like = c.querySelector<HTMLButtonElement>('button.logo-heart')!;
expect(select.contains(like)).toBe(false);
expect(like.contains(select)).toBe(false);
expect(like.type).toBe('button');
expect(like.querySelector('svg')).not.toBeNull();
expect(c.querySelector('button button')).toBeNull();
}
});
it('Herz-Knopf: Zustand in aria-pressed, Beschriftung mit Name und Zahl', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 4 })], nextCursor: null });
await mount();
expect(heart('Logo A').getAttribute('aria-pressed')).toBe('false');
expect(heart('Logo A').getAttribute('aria-label')).toBe('Herz für „Logo A“: 4');
});
it('Name und Anzeigename stehen nur als Text, nie als HTML', async () => {
fx.listLogos.mockResolvedValue({
logos: [logo('a', { name: '<img src=x onerror=alert(1)>', displayName: '<b>fett</b>' })],
nextCursor: null,
});
await mount();
const c = cells()[1];
expect(c.querySelector('.logo-tile-name')!.textContent).toBe('<img src=x onerror=alert(1)>');
expect(c.querySelector('.logo-tile-by')!.textContent).toBe('von <b>fett</b>');
expect(c.querySelector('img[src="x"]')).toBeNull();
expect(c.querySelector('b')).toBeNull();
});
it('die erste Seite wird beliebt sortiert und ungefiltert geladen', async () => {
await mount();
expect(fx.listLogos).toHaveBeenCalledExactlyOnceWith({ sort: 'popular', filter: 'all' });
});
it('Englisch: Überschrift, Umschalter, Kacheln und Knöpfe sind übersetzt', async () => {
setLang('en', { persist: false });
fx.listLogos.mockResolvedValue({ logos: [logo('a', { displayName: '' })], nextCursor: 'a' });
await mount();
expect(picker.el.querySelector('h3')!.textContent).toBe('Bottom-right logo');
expect(picker.el.querySelector('.logo-picker-note')!.textContent).toBe('Logos are uploaded by the community.');
expect(uploadButton().textContent).toBe('Upload');
expect(sortButtons().map((b) => b.textContent)).toEqual(['Popular', 'New']);
expect(names()).toEqual(['No logo', 'Logo A']);
expect(cell('Logo A').querySelector('.logo-tile-by')!.textContent).toBe('by Anonymous');
expect(heart('Logo A').getAttribute('aria-label')).toBe('Heart for “Logo A”: 0');
expect(moreButton().textContent).toBe('Load more');
});
});
describe('Sortierung', () => {
it('„Neu“ lädt mit sort:new neu, der Umschalter folgt, die Kacheln der alten Liste fallen weg', async () => {
await mount();
fx.listLogos.mockResolvedValue({ logos: [logo('z'), logo('a')], nextCursor: null });
sortButton('Neu').click();
expect(pressedSort()).toEqual(['Neu']);
await flush();
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'all' });
expect(names()).toEqual(['Kein Logo', 'Logo Z', 'Logo A']);
sortButton('Beliebt').click();
await flush();
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' });
});
it('ein Klick auf die schon gewählte Ansicht lädt nichts neu', async () => {
await mount();
sortButton('Beliebt').click();
await flush();
expect(fx.listLogos).toHaveBeenCalledTimes(1);
});
it('Admins sehen zusätzlich „Gemeldet“ und „Ausgeblendet“; sie laden mit filter:reported bzw. filter:hidden', async () => {
admin = true;
await mount();
expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu', 'Gemeldet', 'Ausgeblendet']);
sortButton('Gemeldet').click();
await flush();
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'reported' });
expect(pressedSort()).toEqual(['Gemeldet']);
sortButton('Ausgeblendet').click();
await flush();
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'hidden' });
expect(pressedSort()).toEqual(['Ausgeblendet']);
sortButton('Beliebt').click();
await flush();
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' });
});
it('eine späte Antwort der früheren Ansicht überschreibt die neuere nicht', async () => {
await mount();
const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValueOnce(slow.promise);
sortButton('Neu').click();
fx.listLogos.mockResolvedValueOnce({ logos: [logo('p')], nextCursor: null });
sortButton('Beliebt').click();
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo P']);
slow.resolve({ logos: [logo('q')], nextCursor: null });
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo P']);
expect(pressedSort()).toEqual(['Beliebt']);
});
it('verliert der Nutzer die Admin-Rolle (Abmelden), verschwinden die Admin-Knöpfe und die Liste wird wieder öffentlich geladen', async () => {
admin = true;
await mount();
sortButton('Gemeldet').click();
await flush();
admin = false;
user = null;
emitAuth();
await flush();
expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu']);
expect(pressedSort()).toEqual(['Beliebt']);
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' });
});
it('wird jemand Admin (Anmeldung), erscheinen die Admin-Knöpfe, ohne dass die Liste neu geladen wird', async () => {
user = null;
await mount();
admin = true;
user = 'u1';
emitAuth();
await flush();
expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu', 'Gemeldet', 'Ausgeblendet']);
expect(fx.listLogos).toHaveBeenCalledTimes(1);
});
});
describe('Mehr laden', () => {
const page2 = () => ({ logos: [logo('d'), logo('e')], nextCursor: null });
it('der Knopf erscheint nur, wenn es einen nextCursor gibt', async () => {
await mount();
expect(moreButton().hidden).toBe(true);
picker.dispose();
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
await mount();
expect(moreButton().hidden).toBe(false);
expect(moreButton().textContent).toBe('Mehr laden');
});
it('ein Klick hängt die nächste Seite hinten an (mit Cursor), danach verschwindet der Knopf', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' });
await mount();
const first = tile('Logo A');
fx.listLogos.mockResolvedValue(page2());
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', 'Logo E']);
expect(tile('Logo A')).toBe(first); // die vorhandenen Kacheln bleiben
expect(moreButton().hidden).toBe(true);
});
it('während des Ladens ist der Knopf gesperrt, ein zweiter Klick löst keine zweite Anfrage aus', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
await mount();
const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(slow.promise);
moreButton().click();
expect(moreButton().disabled).toBe(true);
moreButton().click();
moreButton().disabled = false; // selbst wenn der Klick den Knopf erreicht
moreButton().click();
expect(fx.listLogos).toHaveBeenCalledTimes(2);
slow.resolve(page2());
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo D', 'Logo E']);
});
it('ein Logo, das auf der nächsten Seite noch einmal vorkommt (die Rangfolge hat sich verschoben), bleibt an seinem Platz und steht nur einmal im Raster', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b'), logo('c')], nextCursor: 'c' });
await mount();
fx.listLogos.mockResolvedValue({ logos: [logo('b', { likeCount: 4 }), logo('d')], nextCursor: null });
moreButton().click();
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C', 'Logo D']);
expect(heart('Logo B').textContent).toBe('4'); // die jüngere Zeile gilt
});
it('scheitert das Nachladen: Meldung und „Nochmal versuchen“ holen genau diese Seite erneut, die Liste bleibt', 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.');
expect(names()).toEqual(['Kein Logo', 'Logo A']);
expect(retryButton().hidden).toBe(false);
expect(moreButton().hidden).toBe(true);
fx.listLogos.mockResolvedValue(page2());
retryButton().click();
await flush();
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'a' });
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo D', 'Logo E']);
expect(alertText()).toBe('');
expect(retryButton().hidden).toBe(true);
});
describe('Fokus: ein Knopf, der unter dem Fokus verschwindet, lässt ihn nicht verloren gehen', () => {
const heading = () => picker.el.querySelector<HTMLElement>('h3')!;
it('„Mehr laden“ lädt die letzte Seite und verschwindet: der Fokus geht auf die erste neue Kachel', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' });
await mount();
moreButton().focus();
expect(document.activeElement).toBe(moreButton());
fx.listLogos.mockResolvedValue(page2());
moreButton().click();
await flush();
expect(moreButton().hidden).toBe(true);
expect(document.activeElement).toBe(tile('Logo D'));
});
it('„Mehr laden“ bleibt (es gibt noch eine Seite): es bleibt auch gewählt, während es lädt und danach', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
await mount();
moreButton().focus();
const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(slow.promise);
moreButton().click();
// Ein gesperrter Knopf verliert den Fokus: solange er ihn hat, bleibt er bedienbar, ein zweiter Druck ist wirkungslos.
expect(moreButton().disabled).toBe(false);
expect(document.activeElement).toBe(moreButton());
moreButton().click();
expect(fx.listLogos).toHaveBeenCalledTimes(2);
slow.resolve({ logos: [logo('d')], nextCursor: 'd' });
await flush();
expect(moreButton().hidden).toBe(false);
expect(moreButton().disabled).toBe(false);
expect(document.activeElement).toBe(moreButton());
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo D']);
});
it('hat der Knopf den Fokus nicht, bleibt er während des Ladens gesperrt', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
await mount();
fx.listLogos.mockReturnValue(new Promise(() => {}));
moreButton().click(); // ein Mausklick im Test: der Knopf bekommt den Fokus dabei nicht
expect(document.activeElement).not.toBe(moreButton());
expect(moreButton().disabled).toBe(true);
});
it('scheitert das Nachladen unter dem Fokus, geht er auf „Nochmal versuchen“, das den Platz von „Mehr laden“ einnimmt', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
await mount();
moreButton().focus();
fx.listLogos.mockRejectedValueOnce(new Error('offline'));
moreButton().click();
await flush();
expect(moreButton().hidden).toBe(true);
expect(retryButton().hidden).toBe(false);
expect(document.activeElement).toBe(retryButton());
});
it('„Nochmal versuchen“ verschwindet beim Klick: der Fokus geht sofort auf die Überschrift, nicht ins Leere', async () => {
fx.listLogos.mockRejectedValueOnce(new Error('offline'));
await mount();
expect(retryButton().hidden).toBe(false);
retryButton().focus();
expect(document.activeElement).toBe(retryButton());
const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(slow.promise);
retryButton().click();
expect(retryButton().hidden).toBe(true);
expect(document.activeElement).toBe(heading());
slow.resolve({ logos: [logo('a')], nextCursor: null });
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo A']);
expect(document.activeElement).toBe(heading()); // niemand hat den Fokus wegbewegt: er bleibt
});
it('„Nochmal versuchen“ nach einem gescheiterten Nachladen: ebenso', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
await mount();
fx.listLogos.mockRejectedValueOnce(new Error('offline'));
moreButton().click();
await flush();
expect(retryButton().hidden).toBe(false);
retryButton().focus();
fx.listLogos.mockResolvedValue(page2());
retryButton().click();
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo D', 'Logo E']);
expect(picker.el.contains(document.activeElement)).toBe(true);
expect(document.activeElement).not.toBe(document.body);
expect(document.activeElement).not.toBe(retryButton());
});
it('der Fokus wird nicht weggenommen, wenn er nicht auf dem verschwindenden Knopf lag', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' });
await mount();
tile('Logo A').focus();
fx.listLogos.mockResolvedValue(page2());
moreButton().click();
await flush();
expect(moreButton().hidden).toBe(true);
expect(document.activeElement).toBe(tile('Logo A'));
});
it('lädt die letzte Seite nur Logos, die schon da waren, geht der Fokus auf die Überschrift', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' });
await mount();
moreButton().focus();
fx.listLogos.mockResolvedValue({ logos: [logo('b')], nextCursor: null });
moreButton().click();
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B']);
expect(document.activeElement).toBe(heading());
});
it('nach dem Aufräumen wird nichts mehr fokussiert', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
await mount();
moreButton().focus();
const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(slow.promise);
moreButton().click();
picker.dispose();
document.body.focus();
slow.resolve(page2());
await flush();
expect(document.activeElement).toBe(document.body);
});
});
describe('der Cursor ist weg (das Logo dahinter wurde von jemandem gelöscht oder ausgeblendet)', () => {
/** Appwrite: 400 `general_cursor_not_found`. */
const cursorGone = () => Object.assign(new Error('The current cursor could not be found.'), { code: 400, type: 'general_cursor_not_found' });
it('„Mehr laden“ beginnt von vorn: die erste Seite der Ansicht wird neu geladen, kein Fehler, kein „Nochmal versuchen“ mit dem toten Cursor', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' });
await mount();
fx.listLogos.mockRejectedValueOnce(cursorGone());
fx.listLogos.mockResolvedValueOnce({ logos: [logo('c'), logo('b', { likeCount: 3 })], nextCursor: null });
moreButton().click();
await flush();
// erste Anfrage: Seite 1, zweite: mit dem toten Cursor, dritte: wieder Seite 1 (ohne Cursor), gleiche Ansicht
expect(fx.listLogos.mock.calls.map(([opts]) => opts)).toEqual([
{ sort: 'popular', filter: 'all' },
{ sort: 'popular', filter: 'all', cursor: 'b' },
{ sort: 'popular', filter: 'all' },
]);
expect(names()).toEqual(['Kein Logo', 'Logo C', 'Logo B']);
expect(heart('Logo B').textContent).toBe('3'); // die frische Zeile gilt
expect(alertText()).toBe('');
expect(retryButton().hidden).toBe(true);
expect(moreButton().hidden).toBe(true); // die neue Seite war die letzte
});
it('die Ansicht bleibt (hier die Admin-Ansicht „Gemeldet“): neu geladen wird mit derselben Sortierung und demselben Filter', async () => {
admin = true;
await mount();
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
sortButton('Gemeldet').click();
await flush();
fx.listLogos.mockRejectedValueOnce(cursorGone());
fx.listLogos.mockResolvedValueOnce({ logos: [logo('z')], nextCursor: 'z' });
moreButton().click();
await flush();
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'reported' });
expect(names()).toEqual(['Kein Logo', 'Logo Z']);
expect(pressedSort()).toEqual(['Gemeldet']);
expect(moreButton().hidden).toBe(false); // und es geht mit dem neuen Cursor weiter
fx.listLogos.mockResolvedValueOnce({ logos: [logo('y')], nextCursor: null });
moreButton().click();
await flush();
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'reported', cursor: 'z' });
expect(names()).toEqual(['Kein Logo', 'Logo Z', 'Logo Y']);
});
it('die Kachel des gewählten Logos bleibt stehen (kein Flackern), die übrigen werden ersetzt', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' });
await mount();
picker.select('a');
const selected = tile('Logo A');
fx.listLogos.mockRejectedValueOnce(cursorGone());
fx.listLogos.mockResolvedValueOnce({ logos: [logo('a'), logo('c')], nextCursor: null });
moreButton().click();
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo C']);
expect(tile('Logo A')).toBe(selected);
expect(pressedTiles()).toEqual(['Logo A']);
});
it('scheitert das Neuladen selbst (Netz), steht der gewohnte Fehler mit „Nochmal versuchen“, das die erste Seite holt', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
await mount();
fx.listLogos.mockRejectedValueOnce(cursorGone());
fx.listLogos.mockRejectedValueOnce(new Error('offline'));
moreButton().click();
await flush();
expect(alertText()).toBe('Die Logos konnten nicht geladen werden.');
expect(retryButton().hidden).toBe(false);
fx.listLogos.mockResolvedValue({ logos: [logo('c')], nextCursor: null });
retryButton().click();
await flush();
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' });
expect(names()).toEqual(['Kein Logo', 'Logo C']);
expect(alertText()).toBe('');
});
it('nur dieser Fehlertyp löst das Neuladen aus: jeder andere Fehler beim Nachladen bleibt ein Fehler mit „Nochmal versuchen“ für denselben Cursor', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
await mount();
fx.listLogos.mockRejectedValueOnce(Object.assign(new Error('Bad request'), { code: 400, type: 'general_query_invalid' }));
moreButton().click();
await flush();
expect(fx.listLogos).toHaveBeenCalledTimes(2);
expect(alertText()).toBe('Die Logos konnten nicht geladen werden.');
expect(names()).toEqual(['Kein Logo', 'Logo A']);
expect(retryButton().hidden).toBe(false);
});
it('beim ersten Laden einer Ansicht gibt es keinen Cursor: derselbe Fehlertyp dort ist ein gewöhnlicher Ladefehler, kein Neuladen im Kreis', async () => {
fx.listLogos.mockRejectedValue(cursorGone());
await mount();
expect(fx.listLogos).toHaveBeenCalledTimes(1);
expect(alertText()).toBe('Die Logos konnten nicht geladen werden.');
expect(retryButton().hidden).toBe(false);
});
});
});
describe('Auswahl', () => {
it('ein Klick auf eine Kachel meldet onSelect(id), „Kein Logo“ meldet onSelect(null)', async () => {
await mount();
tile('Logo B').click();
expect(fx.onSelect).toHaveBeenLastCalledWith('b');
tile('Kein Logo').click();
expect(fx.onSelect).toHaveBeenLastCalledWith(null);
expect(fx.onSelect).toHaveBeenCalledTimes(2);
});
it('ein Klick auf die schon gewählte Kachel meldet onSelect noch einmal (der Editor versucht damit ein gescheitertes Laden des Logos erneut)', async () => {
await mount();
picker.select('b');
tile('Logo B').click();
tile('Logo B').click();
expect(fx.onSelect).toHaveBeenCalledTimes(2);
expect(fx.onSelect).toHaveBeenLastCalledWith('b');
});
it('ein Klick auf das Herz wählt das Logo nicht', async () => {
await mount();
heart('Logo B').click();
await flush();
expect(fx.onSelect).not.toHaveBeenCalled();
});
it('aria-pressed folgt select(): zuerst „Kein Logo“, dann das gewählte Logo, dann wieder keins', async () => {
await mount();
expect(pressedTiles()).toEqual(['Kein Logo']);
picker.select('b');
expect(pressedTiles()).toEqual(['Logo B']);
picker.select('c');
expect(pressedTiles()).toEqual(['Logo C']);
picker.select(null);
expect(pressedTiles()).toEqual(['Kein Logo']);
});
it('ein Klick markiert noch nichts: die Auswahl gehört dem Aufrufer (select)', async () => {
await mount();
tile('Logo B').click();
expect(pressedTiles()).toEqual(['Kein Logo']);
picker.select('b');
expect(pressedTiles()).toEqual(['Logo B']);
});
it('ein gewähltes Logo der Liste braucht kein getLogo; selected() liefert seine Zeile', async () => {
await mount();
expect(picker.selected()).toBeNull();
picker.select('b');
await flush();
expect(fx.getLogo).not.toHaveBeenCalled();
expect(picker.selected()).toEqual(logo('b'));
picker.select(null);
expect(picker.selected()).toBeNull();
});
it('ein Logo außerhalb der Liste wird per getLogo geholt und steht als zweite Kachel direkt nach „Kein Logo“', async () => {
fx.getLogo.mockResolvedValue(logo('x', { name: 'Fremdes Logo' }));
await mount();
picker.select('x');
expect(picker.selected()).toBeNull(); // noch unbekannt
await flush();
expect(fx.getLogo).toHaveBeenCalledExactlyOnceWith('x');
expect(names()).toEqual(['Kein Logo', 'Fremdes Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(pressedTiles()).toEqual(['Fremdes Logo']);
expect(picker.selected()).toMatchObject({ id: 'x', name: 'Fremdes Logo' });
});
it('der angeheftete Eintrag hat wie jede Kachel ein Herz; angemeldet wird sein Zustand geladen', async () => {
fx.getLogo.mockResolvedValue(logo('x', { name: 'Fremdes Logo', likeCount: 5 }));
fx.myHearts.mockImplementation(async (_u, ids) => new Set(ids.filter((id) => id === 'x')));
await mount();
picker.select('x');
await flush();
expect(fx.myHearts).toHaveBeenLastCalledWith('u1', ['x']);
expect(heart('Fremdes Logo').textContent).toBe('5');
expect(heartsOn()).toEqual(['Fremdes Logo']);
});
it('solange die Liste noch lädt, wird nicht nachgefragt: steht das Logo auf der ersten Seite, gibt es kein getLogo und keinen Sprung', async () => {
const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(list.promise);
picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
host.append(picker.el);
picker.select('b');
await flush();
expect(fx.getLogo).not.toHaveBeenCalled();
expect(names()).toEqual(['Kein Logo']);
list.resolve({ logos: [logo('a'), logo('b')], nextCursor: null });
await flush();
expect(fx.getLogo).not.toHaveBeenCalled();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B']);
expect(pressedTiles()).toEqual(['Logo B']);
expect(picker.selected()).toEqual(logo('b'));
});
it('steht das Logo nicht auf der ersten Seite, wird es nach dem Laden der Liste geholt und angeheftet', async () => {
fx.getLogo.mockResolvedValue(logo('x'));
const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(list.promise);
picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
host.append(picker.el);
picker.select('x');
await flush();
expect(fx.getLogo).not.toHaveBeenCalled();
list.resolve({ logos: [logo('a')], nextCursor: null });
await flush();
expect(fx.getLogo).toHaveBeenCalledExactlyOnceWith('x');
expect(names()).toEqual(['Kein Logo', 'Logo X', 'Logo A']);
});
it('scheitert das Laden der Liste, wird das gewählte Logo trotzdem geholt und angeheftet', async () => {
fx.getLogo.mockResolvedValue(logo('x'));
fx.listLogos.mockRejectedValue(new Error('offline'));
picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
host.append(picker.el);
picker.select('x');
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo X']);
});
it('ein Logo, das es nicht mehr gibt (getLogo → null): keine Kachel, nichts gewählt, selected() ist null, kein Fehler', async () => {
await mount();
picker.select('weg');
await flush();
expect(fx.getLogo).toHaveBeenCalledWith('weg');
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(pressedTiles()).toEqual([]); // auch „Kein Logo“ nicht: die Auswahl bleibt, bis jemand ein anderes wählt
expect(picker.selected()).toBeNull();
expect(alertText()).toBe('');
});
it('scheitert getLogo (Netz), bleibt die Oberfläche heil: keine Kachel, kein Fehlertext, kein Absturz', async () => {
fx.getLogo.mockRejectedValue(new Error('offline'));
await mount();
picker.select('x');
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(picker.selected()).toBeNull();
expect(alertText()).toBe('');
});
it('dieselbe Auswahl noch einmal (der Editor ruft select bei jeder Änderung) fragt nicht erneut nach', async () => {
fx.getLogo.mockResolvedValue(logo('x'));
await mount();
picker.select('x');
picker.select('x');
await flush();
picker.select('x');
await flush();
expect(fx.getLogo).toHaveBeenCalledTimes(1);
expect(names().filter((n) => n === 'Logo X')).toHaveLength(1);
});
it('wechselt die Auswahl, fällt der angeheftete Eintrag weg; „Kein Logo“ räumt ihn ebenfalls ab', async () => {
fx.getLogo.mockResolvedValue(logo('x'));
await mount();
picker.select('x');
await flush();
expect(names()).toContain('Logo X');
picker.select('b');
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
picker.select('x'); // schon bekannt: sofort wieder da, ohne neue Anfrage
expect(names()).toEqual(['Kein Logo', 'Logo X', 'Logo A', 'Logo B', 'Logo C']);
expect(fx.getLogo).toHaveBeenCalledTimes(1);
picker.select(null);
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
});
it('eine späte Antwort von getLogo für eine inzwischen verlassene Auswahl wird verworfen', async () => {
await mount();
const slow = deferred<CommunityLogo | null>();
fx.getLogo.mockReturnValueOnce(slow.promise);
picker.select('x');
picker.select('b');
slow.resolve(logo('x'));
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(picker.selected()).toEqual(logo('b'));
expect(fx.myHearts).not.toHaveBeenCalledWith('u1', ['x']); // für das verlassene Logo wird nichts weiter angefragt
});
it('taucht das angeheftete Logo nach einem Wechsel der Sortierung in der Liste auf, steht es nicht doppelt da', async () => {
fx.getLogo.mockResolvedValue(logo('x'));
await mount();
picker.select('x');
await flush();
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('x')], nextCursor: null });
sortButton('Neu').click();
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo X']);
expect(pressedTiles()).toEqual(['Logo X']);
});
it('bei einem Wechsel der Sortierung bleibt der angeheftete Eintrag stehen, solange er nicht in der neuen Liste ist', async () => {
fx.getLogo.mockResolvedValue(logo('x'));
await mount();
picker.select('x');
await flush();
const pinned = tile('Logo X');
fx.listLogos.mockResolvedValue({ logos: [logo('z')], nextCursor: null });
sortButton('Neu').click();
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo X', 'Logo Z']);
expect(tile('Logo X')).toBe(pinned);
});
});
describe('Herzen', () => {
it('angemeldet: ein Klick schaltet sofort um (aria-pressed, Zahl + 1), ruft setHeart(id, true) und übernimmt likeCount der Antwort', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 2 })], nextCursor: null });
const answer = deferred<{ herz: boolean; likeCount: number }>();
fx.setHeart.mockReturnValue(answer.promise);
await mount();
heart('Logo A').click();
expect(heart('Logo A').getAttribute('aria-pressed')).toBe('true'); // sofort, vor der Antwort
expect(heart('Logo A').textContent).toBe('3');
expect(fx.setHeart).toHaveBeenCalledExactlyOnceWith('a', true);
answer.resolve({ herz: true, likeCount: 7 }); // der Dienst hat neu gezählt
await flush();
expect(heart('Logo A').getAttribute('aria-pressed')).toBe('true');
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 () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 2 })], nextCursor: null });
fx.myHearts.mockResolvedValue(new Set(['a']));
await mount();
expect(heartsOn()).toEqual(['Logo A']);
const answer = deferred<{ herz: boolean; likeCount: number }>();
fx.setHeart.mockReturnValue(answer.promise);
heart('Logo A').click();
expect(heart('Logo A').getAttribute('aria-pressed')).toBe('false');
expect(heart('Logo A').textContent).toBe('1');
expect(fx.setHeart).toHaveBeenCalledExactlyOnceWith('a', false);
answer.resolve({ herz: false, likeCount: 1 });
await flush();
expect(heartsOn()).toEqual([]);
expect(heart('Logo A').textContent).toBe('1');
});
it('die Zahl wird nie negativ', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 0 })], nextCursor: null });
fx.myHearts.mockResolvedValue(new Set(['a'])); // veraltet: Zähler 0, Herz an
fx.setHeart.mockReturnValue(new Promise(() => {}));
await mount();
heart('Logo A').click();
expect(heart('Logo A').textContent).toBe('0');
});
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();
heart('Logo A').click();
await flush();
expect(heart('Logo A').getAttribute('aria-pressed')).toBe('false');
expect(heart('Logo A').textContent).toBe('2');
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 () => {
fx.setHeart.mockRejectedValueOnce(new Error('kaputt'));
await mount();
heart('Logo A').click();
await flush();
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(infoMessage()).toBe('');
await flush();
expect(fx.setHeart).toHaveBeenCalledTimes(2);
expect(heartsOn()).toEqual(['Logo A']);
});
it('ein zweiter Klick während der laufenden Anfrage löst keinen zweiten Aufruf aus', async () => {
const answer = deferred<{ herz: boolean; likeCount: number }>();
fx.setHeart.mockReturnValue(answer.promise);
await mount();
heart('Logo A').click();
heart('Logo A').click();
heart('Logo A').click();
expect(fx.setHeart).toHaveBeenCalledTimes(1);
expect(heartsOn()).toEqual(['Logo A']); // und der Zustand wurde nicht zurückgeschaltet
answer.resolve({ herz: true, likeCount: 1 });
await flush();
heart('Logo A').click(); // danach geht es wieder (jetzt: Herz weg)
expect(fx.setHeart).toHaveBeenCalledTimes(2);
expect(fx.setHeart).toHaveBeenLastCalledWith('a', false);
});
it('die Sperre gilt je Logo: das Herz eines anderen Logos geht gleichzeitig', async () => {
fx.setHeart.mockReturnValue(new Promise(() => {}));
await mount();
heart('Logo A').click();
heart('Logo B').click();
expect(fx.setHeart.mock.calls).toEqual([
['a', true],
['b', true],
]);
});
it('Gast: ein Klick öffnet den Login (requireLogin); danach wird das Herz gesetzt', async () => {
user = null;
await mount();
expect(fx.myHearts).not.toHaveBeenCalled(); // Gäste haben keine Herzen zu laden
const login = deferred<boolean>();
fx.requireLogin.mockReturnValue(login.promise);
heart('Logo A').click();
expect(fx.requireLogin).toHaveBeenCalledTimes(1);
expect(fx.setHeart).not.toHaveBeenCalled();
expect(heartsOn()).toEqual([]);
user = 'u1';
login.resolve(true);
await flush();
expect(fx.setHeart).toHaveBeenCalledExactlyOnceWith('a', true);
expect(heartsOn()).toEqual(['Logo A']);
});
it('Gast bricht den Login ab: kein setHeart, das Herz bleibt aus, kein Fehlertext, und es geht danach wieder', async () => {
user = null;
fx.requireLogin.mockResolvedValue(false);
await mount();
heart('Logo A').click();
await flush();
expect(fx.setHeart).not.toHaveBeenCalled();
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);
});
it('Gast: ein zweiter Klick, solange der Login-Dialog noch offen ist, öffnet keinen zweiten', async () => {
user = null;
fx.requireLogin.mockReturnValue(new Promise(() => {}));
await mount();
heart('Logo A').click();
heart('Logo A').click();
expect(fx.requireLogin).toHaveBeenCalledTimes(1);
});
it('nach dem Laden einer Seite (angemeldet) kommen die Herzen mit genau den IDs der Seite; sie stehen an den Kacheln', async () => {
fx.myHearts.mockResolvedValue(new Set(['b']));
await mount();
expect(fx.myHearts).toHaveBeenCalledExactlyOnceWith('u1', ['a', 'b', 'c']);
expect(heartsOn()).toEqual(['Logo B']);
});
it('beim Nachladen werden die Herzen nur für die neue Seite geholt', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' });
await mount();
fx.listLogos.mockResolvedValue({ logos: [logo('c'), logo('d')], nextCursor: null });
fx.myHearts.mockResolvedValue(new Set(['d']));
moreButton().click();
await flush();
expect(fx.myHearts).toHaveBeenLastCalledWith('u1', ['c', 'd']);
expect(heartsOn()).toEqual(['Logo D']);
});
it('scheitert das Laden der Herzen, bleibt die Liste da (die Herzen sind dann aus, kein Fehlertext)', async () => {
fx.myHearts.mockRejectedValue(new Error('offline'));
await mount();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(heartsOn()).toEqual([]);
expect(alertText()).toBe('');
});
it('Abmelden: alle Herzen sind aus', async () => {
fx.myHearts.mockResolvedValue(new Set(['a', 'c']));
await mount();
expect(heartsOn()).toEqual(['Logo A', 'Logo C']);
user = null;
emitAuth();
expect(heartsOn()).toEqual([]);
});
it('Anmelden: die Herzen aller gezeigten Logos werden geladen', async () => {
user = null;
await mount();
fx.myHearts.mockResolvedValue(new Set(['b']));
user = 'u1';
emitAuth();
await flush();
expect(fx.myHearts).toHaveBeenCalledExactlyOnceWith('u1', ['a', 'b', 'c']);
expect(heartsOn()).toEqual(['Logo B']);
});
it('eine späte Antwort von myHearts überschreibt kein Herz, das inzwischen geschaltet wurde', async () => {
const hearts = deferred<Set<string>>();
fx.myHearts.mockReturnValue(hearts.promise);
await mount();
heart('Logo A').click();
await flush();
expect(heartsOn()).toEqual(['Logo A']);
hearts.resolve(new Set(['c'])); // stammt von vor dem Klick: A kennt sie nicht, C schon
await flush();
expect(heartsOn()).toEqual(['Logo A', 'Logo C']);
});
it('Herzen einer Seite für den vorigen Nutzer werden nach einem Kontowechsel nicht übernommen', async () => {
const hearts = deferred<Set<string>>();
fx.myHearts.mockReturnValue(hearts.promise);
await mount();
user = null; // abgemeldet, bevor die Antwort kommt
emitAuth();
hearts.resolve(new Set(['a']));
await flush();
expect(heartsOn()).toEqual([]);
});
});
describe('Leer und Ladefehler', () => {
it('leere Liste: „Noch keine Community-Logos – lade das erste hoch.“ (erst nach dem Laden; „Kein Logo“ bleibt)', async () => {
const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(list.promise);
picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
host.append(picker.el);
await flush();
expect(emptyText()).toBe('');
list.resolve({ logos: [], nextCursor: null });
await flush();
expect(emptyText()).toBe('Noch keine Community-Logos – lade das erste hoch.');
expect(names()).toEqual(['Kein Logo']);
});
it('der Leer-Text steht nicht, wenn es Logos gibt oder das Laden scheiterte', async () => {
await mount();
expect(emptyText()).toBe('');
picker.dispose();
fx.listLogos.mockRejectedValue(new Error('offline'));
await mount();
expect(emptyText()).toBe('');
});
it('ein leerer Admin-Filter sagt das, statt zum Hochladen aufzufordern', async () => {
admin = true;
await mount();
fx.listLogos.mockResolvedValue({ logos: [], nextCursor: null });
sortButton('Gemeldet').click();
await flush();
expect(emptyText()).toBe('Keine Logos in dieser Ansicht.');
sortButton('Beliebt').click();
await flush();
expect(emptyText()).toBe('Noch keine Community-Logos – lade das erste hoch.');
});
it('Ladefehler: „Die Logos konnten nicht geladen werden.“ mit „Nochmal versuchen“, das neu lädt', async () => {
fx.listLogos.mockRejectedValueOnce(new Error('offline'));
await mount();
expect(alertText()).toBe('Die Logos konnten nicht geladen werden.');
expect(retryButton().hidden).toBe(false);
expect(retryButton().textContent).toBe('Nochmal versuchen');
expect(names()).toEqual(['Kein Logo']);
retryButton().click();
await flush();
expect(fx.listLogos).toHaveBeenCalledTimes(2);
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' });
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(alertText()).toBe('');
expect(retryButton().hidden).toBe(true);
});
it('der Statusbereich ist immer da (leer: kein Text), ohne Fehler ist „Nochmal versuchen“ verborgen', async () => {
await mount();
expect(alertText()).toBe('');
expect(retryButton().hidden).toBe(true);
});
it('Englisch: Leer-Text, Ladefehler und „Nochmal versuchen“', async () => {
setLang('en', { persist: false });
fx.listLogos.mockResolvedValueOnce({ logos: [], nextCursor: null });
await mount();
expect(emptyText()).toBe('No community logos yet – upload the first one.');
picker.dispose();
fx.listLogos.mockRejectedValueOnce(new Error('offline'));
await mount();
expect(alertText()).toBe('The logos couldn’t be loaded.');
expect(retryButton().textContent).toBe('Try again');
});
});
describe('Hochladen', () => {
const fresh = () => logo('n', { name: 'Neues Logo', ownerId: 'u1' });
it('angemeldet: der Dialog öffnet ohne Login; das Ergebnis steht als erste Logo-Kachel, rememberLogo und onSelect(id) laufen', async () => {
fx.uploadDialog.mockResolvedValue(fresh());
await mount();
uploadButton().click();
await flush();
expect(fx.requireLogin).not.toHaveBeenCalled();
expect(fx.uploadDialog).toHaveBeenCalledTimes(1);
expect(names()).toEqual(['Kein Logo', 'Neues Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(mocks.rememberLogo).toHaveBeenCalledExactlyOnceWith(fresh());
expect(fx.onSelect).toHaveBeenCalledExactlyOnceWith('n');
});
it('das neue Logo ist ein Logo mit Herz-Knopf (Zahl 0), und die Auswahl durch den Aufrufer markiert es ohne getLogo', async () => {
fx.uploadDialog.mockResolvedValue(fresh());
await mount();
uploadButton().click();
await flush();
expect(heart('Neues Logo').textContent).toBe('0');
picker.select('n');
await flush();
expect(pressedTiles()).toEqual(['Neues Logo']);
expect(fx.getLogo).not.toHaveBeenCalled();
expect(picker.selected()).toEqual(fresh());
});
it('Gast: zuerst requireLogin, dann der Dialog', async () => {
user = null;
const order: string[] = [];
fx.requireLogin.mockImplementation(async () => {
order.push('login');
return true;
});
fx.uploadDialog.mockImplementation(async () => {
order.push('dialog');
return null;
});
await mount();
uploadButton().click();
await flush();
expect(order).toEqual(['login', 'dialog']);
});
it('Gast bricht den Login ab: kein Dialog, nichts ändert sich, kein Fehlertext', async () => {
user = null;
fx.requireLogin.mockResolvedValue(false);
await mount();
uploadButton().click();
await flush();
expect(fx.uploadDialog).not.toHaveBeenCalled();
expect(fx.onSelect).not.toHaveBeenCalled();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(alertText()).toBe('');
});
it('Dialog abgebrochen (null): keine Kachel, keine Auswahl, kein rememberLogo; der Knopf geht danach wieder', async () => {
await mount();
uploadButton().click();
await flush();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(fx.onSelect).not.toHaveBeenCalled();
expect(mocks.rememberLogo).not.toHaveBeenCalled();
uploadButton().click();
await flush();
expect(fx.uploadDialog).toHaveBeenCalledTimes(2);
});
it('ein zweiter Klick, solange der Dialog offen ist, öffnet keinen zweiten', async () => {
fx.uploadDialog.mockReturnValue(new Promise(() => {}));
await mount();
uploadButton().click();
uploadButton().click();
await flush();
expect(fx.uploadDialog).toHaveBeenCalledTimes(1);
});
it('der Dialog bekommt ein Signal, das beim Aufräumen der Seite ausgelöst wird; ein später Treffer wird verworfen', async () => {
const result = deferred<CommunityLogo | null>();
fx.uploadDialog.mockReturnValue(result.promise);
await mount();
uploadButton().click();
await flush();
const signal = fx.uploadDialog.mock.calls[0][0];
expect(signal.aborted).toBe(false);
picker.dispose();
expect(signal.aborted).toBe(true);
result.resolve(fresh());
await flush();
expect(fx.onSelect).not.toHaveBeenCalled();
expect(mocks.rememberLogo).not.toHaveBeenCalled();
});
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(infoMessage()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
expect(alertText()).toBe('');
});
});
describe('Kachelbilder', () => {
it('jede Kachel zeigt das PNG ihres Logos: loadLogoBlob bekommt die Zeile, das <img> trägt die Object-URL (dekorativ, alt leer)', async () => {
await mount();
expect(fx.loadLogoBlob.mock.calls.map(([row]) => row.id)).toEqual(['a', 'b', 'c']);
const img = art('Logo A').querySelector('img')!;
expect(img.getAttribute('src')).toBe(created[0]);
expect(img.getAttribute('alt')).toBe('');
expect(art('Logo A').getAttribute('data-state')).toBe('ready');
});
it('ein einfarbiges Logo wird als Maske gezeigt (die Zeile mit mono kommt bei der Kachel an)', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a', { mono: true })], nextCursor: null });
await mount();
expect(art('Logo A').querySelector('img')).toBeNull();
expect(art('Logo A').querySelector('.logo-tile-mask')).not.toBeNull();
});
it('ein unbrauchbares Bild (über der Pixelgrenze) lässt die Kachel heil: Platzhalter, Name, Herz und Auswahl funktionieren', async () => {
sizes.set('c', [60_000, 60_000]);
await mount();
expect(art('Logo C').getAttribute('data-state')).toBe('unusable');
expect(art('Logo C').querySelector('img')).toBeNull();
expect(revoked).toEqual([created[2]]);
expect(art('Logo A').getAttribute('data-state')).toBe('ready');
expect(heart('Logo C').textContent).toBe('0');
tile('Logo C').click();
expect(fx.onSelect).toHaveBeenCalledWith('c');
});
it('Kacheln, die bei einem Wechsel der Sortierung wegfallen, geben ihre Object-URL frei; die neuen laden ihr Bild', async () => {
await mount();
expect(created).toHaveLength(3);
fx.listLogos.mockResolvedValue({ logos: [logo('z')], nextCursor: null });
sortButton('Neu').click();
await flush();
expect(revoked.sort()).toEqual([...created.slice(0, 3)].sort());
expect(art('Logo Z').getAttribute('data-state')).toBe('ready');
});
it('die Kachel des gewählten Logos behält ihr Bild beim Wechsel der Sortierung (kein Neuladen, kein Flackern)', async () => {
await mount();
picker.select('b');
fx.listLogos.mockResolvedValue({ logos: [logo('z')], nextCursor: null });
sortButton('Neu').click();
await flush();
expect(fx.loadLogoBlob.mock.calls.filter(([row]) => row.id === 'b')).toHaveLength(1);
expect(revoked).not.toContain(created[1]);
expect(art('Logo B').querySelector('img')!.getAttribute('src')).toBe(created[1]);
});
it('eine Datei, die erst nach dem Wegfall der Kachel ankommt, wird nicht mehr angelegt', async () => {
const slow = deferred<Blob>();
fx.loadLogoBlob.mockReturnValueOnce(slow.promise);
await mount();
fx.listLogos.mockResolvedValue({ logos: [logo('z')], nextCursor: null });
sortButton('Neu').click();
await flush();
const before = created.length;
slow.resolve(new Blob(['spät']));
await flush();
expect(created).toHaveLength(before);
});
});
describe('dispose', () => {
it('gibt alle Object-URLs frei und meldet sich von der Anmeldung ab', async () => {
await mount();
expect(authListeners.size).toBe(1);
picker.dispose();
expect(revoked.sort()).toEqual([...created].sort());
expect(authListeners.size).toBe(0);
picker.dispose(); // ein zweiter Aufruf schadet nicht
expect(revoked).toHaveLength(created.length);
});
it('Antworten, die nach dem Aufräumen eintreffen, ändern nichts und werfen nichts', async () => {
const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(list.promise);
picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
host.append(picker.el);
picker.select('x');
picker.dispose();
list.resolve({ logos: [logo('a')], nextCursor: null });
await flush();
expect(names()).toEqual(['Kein Logo']);
expect(created).toEqual([]);
expect(fx.getLogo).not.toHaveBeenCalled();
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);
await mount();
heart('Logo A').click();
picker.dispose();
answer.resolve({ herz: true, likeCount: 9 });
await flush();
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, onLogoChanged: fx.onLogoChanged }, 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('404 „logoGone“ (es gibt das Logo schon nicht mehr): gilt als gelöscht – Kachel weg, forgetLogo, Auswahl aufgehoben, kein Fehlertext', async () => {
fx.deleteLogo.mockRejectedValue(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
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(infoMessage()).toBe('');
expect(alertText()).toBe('');
expect(infoLine()).toBe('');
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('war das gelöschte Logo der Cursor und steht keins davor, beginnt die Liste von vorn (Seite 1 der Ansicht), statt „Mehr laden“ zu verlieren', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' })], nextCursor: 'a' });
await mount();
picker.select('a');
fx.listLogos.mockClear();
fx.listLogos.mockResolvedValue({ logos: [logo('x'), logo('y')], nextCursor: 'y' });
infoButton('Löschen').click();
await flush();
expect(fx.listLogos).toHaveBeenCalledExactlyOnceWith({ sort: 'popular', filter: 'all' }); // Seite 1, ohne Cursor
expect(names()).toEqual(['Kein Logo', 'Logo X', 'Logo Y']);
expect(moreButton().hidden).toBe(false); // es geht mit dem neuen Cursor weiter
fx.listLogos.mockResolvedValue({ logos: [logo('z')], nextCursor: null });
moreButton().click();
await flush();
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'y' });
});
it('dasselbe in der Admin-Ansicht „Gemeldet“: neu geladen wird mit derselben Sortierung und demselben Filter', async () => {
admin = true;
await mount();
fx.listLogos.mockResolvedValue({ logos: [logo('a', { reportCount: 2 })], nextCursor: 'a' });
sortButton('Gemeldet').click();
await flush();
picker.select('a');
fx.listLogos.mockClear();
fx.listLogos.mockResolvedValue({ logos: [logo('x', { reportCount: 1 })], nextCursor: null });
infoButton('Löschen').click();
await flush();
expect(fx.listLogos).toHaveBeenCalledExactlyOnceWith({ sort: 'new', filter: 'reported' });
expect(names()).toEqual(['Kein Logo', 'Logo X']);
expect(pressedSort()).toEqual(['Gemeldet']);
});
it('war das gelöschte Logo nicht der Cursor, oder war keine weitere Seite da, wird nichts neu geladen', async () => {
fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' }), logo('b')], nextCursor: 'b' });
await mount();
picker.select('a');
infoButton('Löschen').click();
await flush();
expect(fx.listLogos).toHaveBeenCalledTimes(1);
picker.dispose();
fx.listLogos.mockClear();
fx.listLogos.mockResolvedValue({ logos: [logo('c', { ownerId: 'u1' })], nextCursor: null });
await mount();
picker.select('c');
infoButton('Löschen').click();
await flush();
expect(fx.listLogos).toHaveBeenCalledTimes(1);
expect(moreButton().hidden).toBe(true);
});
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 });
});
});
describe('Der Editor erfährt, dass sich ein Logo geändert hat (onLogoChanged)', () => {
beforeEach(() => {
admin = true;
fx.listLogos.mockResolvedValue({
logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c', { hidden: true, reportCount: 3 })],
nextCursor: null,
});
});
it('„Ausblenden“ des gewählten Logos: onLogoChanged(id), erst nachdem die Zeile des Pickers hidden ist', async () => {
let hiddenWhenTold: boolean | undefined;
fx.onLogoChanged.mockImplementation(() => {
hiddenWhenTold = picker.selected()?.hidden;
});
await mount();
picker.select('a');
infoButton('Ausblenden').click();
await flush();
expect(fx.onLogoChanged).toHaveBeenCalledExactlyOnceWith('a');
expect(hiddenWhenTold).toBe(true);
expect(mocks.forgetLogo).toHaveBeenCalledWith('a'); // der Zwischenspeicher ist vorher geleert
});
it('„Einblenden“: ebenso, die Zeile ist dann sichtbar', async () => {
let hiddenWhenTold: boolean | undefined;
fx.onLogoChanged.mockImplementation(() => {
hiddenWhenTold = picker.selected()?.hidden;
});
await mount();
picker.select('c');
infoButton('Einblenden').click();
await flush();
expect(fx.onLogoChanged).toHaveBeenCalledExactlyOnceWith('c');
expect(hiddenWhenTold).toBe(false);
});
it('„Löschen“ des gewählten Logos: onLogoChanged(id) nach onSelect(null), der Editor hat die Auswahl dann schon aufgehoben', async () => {
const order: string[] = [];
fx.onSelect.mockImplementation((id) => void order.push(`select:${id}`));
fx.onLogoChanged.mockImplementation((id) => void order.push(`changed:${id}`));
await mount();
picker.select('b');
infoButton('Löschen').click();
await flush();
expect(order).toEqual(['select:null', 'changed:b']);
});
it('„Löschen“ eines Logos, das nicht gewählt war (die Auswahl wechselte währenddessen): onLogoChanged meldet das gelöschte Logo, onSelect schweigt', async () => {
const request = deferred<void>();
fx.deleteLogo.mockReturnValue(request.promise);
await mount();
picker.select('b');
infoButton('Löschen').click();
await flush();
picker.select('a');
request.resolve();
await flush();
expect(fx.onLogoChanged).toHaveBeenCalledExactlyOnceWith('b');
expect(fx.onSelect).not.toHaveBeenCalled();
});
it('scheitert die Aktion, meldet der Picker nichts', async () => {
fx.setLogoVisibility.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
fx.deleteLogo.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
await mount();
picker.select('b');
infoButton('Ausblenden').click();
await flush();
infoButton('Löschen').click();
await flush();
expect(fx.onLogoChanged).not.toHaveBeenCalled();
});
it('Herz, Melden und Auswahl melden nichts', async () => {
await mount();
picker.select('a');
heart('Logo A').click();
infoButton('Melden').click();
await flush();
expect(fx.onLogoChanged).not.toHaveBeenCalled();
});
it('nach dem Aufräumen der Seite meldet der Picker nichts mehr', async () => {
const request = deferred<void>();
fx.setLogoVisibility.mockReturnValue(request.promise);
await mount();
picker.select('a');
infoButton('Ausblenden').click();
await flush();
picker.dispose();
request.resolve();
await flush();
expect(fx.onLogoChanged).not.toHaveBeenCalled();
});
});