Schlussreview F2. Hat jemand das Logo gelöscht oder ausgeblendet, hinter dem „Mehr laden“ ansetzt (der Cursor), antwortet Appwrite mit
400 `general_cursor_not_found`. „Nochmal versuchen“ wiederholte dann denselben Cursor und scheiterte immer wieder.
- Dieser Fehlertyp beim Nachladen löst jetzt `load('reset')` aus: die erste Seite der aktuellen Ansicht (gleiche Sortierung, gleicher Filter)
wird neu geladen, die Kachel des gewählten Logos bleibt stehen. Kein Fehlertext, kein „Nochmal versuchen“ für den toten Cursor.
- Jeder andere Fehler beim Nachladen und jeder Fehler beim Laden der ersten Seite bleibt wie bisher (Meldung und „Nochmal versuchen“);
die erste Seite hat keinen Cursor, ein Neuladen im Kreis ist ausgeschlossen.
- Tests mit einem Fehler {code: 400, type: 'general_cursor_not_found'}: Neuladen von Seite 1 mit Anfragefolge, Ansicht „Gemeldet“ bleibt,
gewählte Kachel bleibt, Neuladen scheitert selbst, anderer Fehlertyp, Fehlertyp beim ersten Laden.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1700 lines
72 KiB
TypeScript
1700 lines
72 KiB
TypeScript
// 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>(),
|
||
};
|
||
|
||
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 }, 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('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('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 }, 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 }, 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 }, 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 }, 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 }, 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 }, 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 });
|
||
});
|
||
});
|