Logo-Kacheln, Logo-Infozeile und Detailseite zeigen keinen Ersatz („von Anonym“, alter Anzeigename) mehr, der gleich auf „von @name“ wechselt: Solange die Abfrage für den Hochlader läuft, ist die Zeile leer und behält ihre Höhe (U+200B vor dem leeren Inhalt in logos.css und community.css); danach steht der Name, ohne Profil oder bei einem Fehler der Ersatz. Schon bekannte Hochlader zeigen ihre Zeile sofort, ein gemeldeter Name beendet das Warten. Die Infozeile lässt dabei Zeile und Trenner weg („<Name> · ♥ <Zahl>“), LogoInfoHost bekommt byline(logo) statt names(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2475 lines
106 KiB
TypeScript
2475 lines
106 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']>(),
|
||
usernamesFor: vi.fn<LogoPickerDeps['usernamesFor']>(),
|
||
requireUsername: vi.fn<LogoPickerDeps['requireUsername']>(),
|
||
onSelect: vi.fn<(id: string | null) => void>(),
|
||
onLogoChanged: vi.fn<(id: string) => void>(),
|
||
};
|
||
|
||
// Die Benutzernamen: `usernamesFor` liefert die Namen der Konten (eine Abfrage für alle IDs), `onUsernameChange` meldet das eigene Umbenennen.
|
||
const nameListeners = new Set<(userId: string, username: string) => void>();
|
||
const emitName = (userId: string, username: string) => {
|
||
for (const fn of [...nameListeners]) fn(userId, username);
|
||
};
|
||
|
||
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,
|
||
usernamesFor: fx.usernamesFor,
|
||
onUsernameChange(fn) {
|
||
nameListeners.add(fn);
|
||
return () => nameListeners.delete(fn);
|
||
},
|
||
requireUsername: fx.requireUsername,
|
||
};
|
||
|
||
// 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();
|
||
nameListeners.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');
|
||
fx.usernamesFor.mockResolvedValue(new Map()); // kein Konto hat ein Profil: es gelten die alten Anzeigenamen
|
||
fx.requireUsername.mockResolvedValue(true);
|
||
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 <alter Anzeigename>“ bzw. „von Anonym“ (ohne Profil) 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('Benutzernamen („von @name“)', () => {
|
||
const by = (name: string) => cell(name).querySelector('.logo-tile-by')!.textContent;
|
||
const owned = (id: string, ownerId: string, displayName = '') => logo(id, { ownerId, displayName });
|
||
|
||
beforeEach(() => {
|
||
fx.listLogos.mockResolvedValue({ logos: [owned('a', 'u1'), owned('b', 'u2', 'Anna'), owned('c', 'u3')], nextCursor: null });
|
||
});
|
||
|
||
it('eine einzige Abfrage für alle Hochlader der geladenen Seite, ohne Doppelte und ohne Einträge ohne Konto-ID', async () => {
|
||
fx.listLogos.mockResolvedValue({
|
||
logos: [owned('a', 'u1'), owned('b', 'u2'), owned('c', 'u1'), owned('d', '')],
|
||
nextCursor: null,
|
||
});
|
||
await mount();
|
||
expect(fx.usernamesFor).toHaveBeenCalledExactlyOnceWith(['u1', 'u2']);
|
||
});
|
||
|
||
it('eine leere Seite oder nur Einträge ohne Konto-ID: keine Abfrage', async () => {
|
||
fx.listLogos.mockResolvedValue({ logos: [owned('a', '')], nextCursor: null });
|
||
await mount();
|
||
expect(fx.usernamesFor).not.toHaveBeenCalled();
|
||
picker.dispose();
|
||
fx.listLogos.mockResolvedValue({ logos: [], nextCursor: null });
|
||
await mount();
|
||
expect(fx.usernamesFor).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('die Kachel zeigt „von @name“, wo es ein Profil gibt; sonst den alten Anzeigenamen (ohne @), sonst „von Anonym“', async () => {
|
||
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||
await mount();
|
||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Kenso', 'von Anna', 'von Anonym']);
|
||
});
|
||
|
||
it('ein Profil geht dem alten Anzeigenamen des Eintrags vor', async () => {
|
||
fx.listLogos.mockResolvedValue({ logos: [owned('a', 'u1', 'Alter Name')], nextCursor: null });
|
||
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||
await mount();
|
||
expect(by('Logo A')).toBe('von @Kenso');
|
||
});
|
||
|
||
it('die Liste wartet nicht auf die Namen: die Kacheln stehen sofort, ihre Zeile „von …“ folgt mit den Namen', async () => {
|
||
const lookup = deferred<Map<string, string>>();
|
||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||
await mount();
|
||
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
|
||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['', '', '']);
|
||
lookup.resolve(new Map([['u1', 'Kenso'], ['u2', 'Mia']]));
|
||
await flush();
|
||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Kenso', 'von @Mia', 'von Anonym']);
|
||
});
|
||
|
||
describe('solange der Name seines Hochladers noch gesucht wird, ist die Zeile „von …“ leer (kein Ersatz, der gleich wieder wechselt)', () => {
|
||
const line = (name: string) => cell(name).querySelector('.logo-tile-by')!;
|
||
|
||
it('gefunden: danach „von @name“, im selben Element; leer heißt ohne jeden Knoten (die Zeilenhöhe hält die CSS-Regel für :empty)', async () => {
|
||
const lookup = deferred<Map<string, string>>();
|
||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||
await mount();
|
||
const span = line('Logo A');
|
||
expect(span.textContent).toBe('');
|
||
expect(span.childNodes).toHaveLength(0); // sonst griffe :empty nicht
|
||
lookup.resolve(new Map([['u1', 'Kenso']]));
|
||
await flush();
|
||
expect(line('Logo A')).toBe(span);
|
||
expect(span.textContent).toBe('von @Kenso');
|
||
});
|
||
|
||
it('kein Profil: erst jetzt der Ersatz, der alte Anzeigename (ohne @) bzw. „von Anonym“', async () => {
|
||
const lookup = deferred<Map<string, string>>();
|
||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||
await mount();
|
||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['', '', '']);
|
||
lookup.resolve(new Map());
|
||
await flush();
|
||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von Anonym', 'von Anna', 'von Anonym']);
|
||
});
|
||
|
||
it('die Suche scheitert: erst jetzt der Ersatz, kein Fehlertext', async () => {
|
||
const lookup = deferred<Map<string, string>>();
|
||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||
await mount();
|
||
expect(by('Logo B')).toBe('');
|
||
lookup.reject(new TypeError('Failed to fetch'));
|
||
await flush();
|
||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von Anonym', 'von Anna', 'von Anonym']);
|
||
expect(alertText()).toBe('');
|
||
expect(infoMessage()).toBe('');
|
||
});
|
||
|
||
it('nur die Hochlader, deren Name noch gesucht wird, sind leer; die anderen Zeilen stehen schon (Mehr laden: Namen und „kein Profil“ von der ersten Seite gelten sofort)', async () => {
|
||
fx.listLogos.mockResolvedValueOnce({ logos: [owned('a', 'u1'), owned('b', 'u2', 'Anna')], nextCursor: 'b' });
|
||
fx.listLogos.mockResolvedValueOnce({
|
||
logos: [owned('c', 'u3'), owned('d', 'u1'), owned('e', 'u2', 'Berta')],
|
||
nextCursor: null,
|
||
});
|
||
fx.usernamesFor.mockResolvedValueOnce(new Map([['u1', 'Kenso']])); // erste Seite: u1 hat ein Profil, u2 keins
|
||
const second = deferred<Map<string, string>>();
|
||
fx.usernamesFor.mockReturnValueOnce(second.promise);
|
||
await mount();
|
||
expect(['Logo A', 'Logo B'].map(by)).toEqual(['von @Kenso', 'von Anna']);
|
||
moreButton().click();
|
||
await flush();
|
||
expect(by('Logo D')).toBe('von @Kenso'); // der Name steht schon
|
||
expect(by('Logo E')).toBe('von Berta'); // „kein Profil“ steht schon fest
|
||
expect(by('Logo C')).toBe(''); // u3 wird noch gesucht
|
||
second.resolve(new Map([['u3', 'Zora']]));
|
||
await flush();
|
||
expect(by('Logo C')).toBe('von @Zora');
|
||
expect(by('Logo D')).toBe('von @Kenso');
|
||
});
|
||
|
||
it('Einträge ohne Konto-ID haben nichts zu suchen: ihre Zeile steht sofort', async () => {
|
||
fx.listLogos.mockResolvedValue({ logos: [owned('a', 'u1'), owned('b', '', 'Anna'), owned('c', '')], nextCursor: null });
|
||
fx.usernamesFor.mockReturnValue(new Promise(() => {}));
|
||
await mount();
|
||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['', 'von Anna', 'von Anonym']);
|
||
});
|
||
|
||
it('die Infozeile: ohne Zeile „von …“ („<Name> · ♥ <Zahl>“), danach mit dem Namen oder dem Ersatz', async () => {
|
||
const lookup = deferred<Map<string, string>>();
|
||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||
await mount();
|
||
picker.select('a');
|
||
expect(infoLine()).toBe('Logo A · ♥ 0');
|
||
picker.select('b');
|
||
expect(infoLine()).toBe('Logo B · ♥ 0');
|
||
lookup.resolve(new Map([['u1', 'Kenso']]));
|
||
await flush();
|
||
expect(infoLine()).toBe('Logo B · von Anna · ♥ 0'); // u2: kein Profil, der alte Anzeigename
|
||
picker.select('a');
|
||
expect(infoLine()).toBe('Logo A · von @Kenso · ♥ 0');
|
||
});
|
||
|
||
it('die Infozeile bei gescheiterter Suche: der Ersatz', async () => {
|
||
const lookup = deferred<Map<string, string>>();
|
||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||
await mount();
|
||
picker.select('b');
|
||
expect(infoLine()).toBe('Logo B · ♥ 0');
|
||
lookup.reject(new Error('weg'));
|
||
await flush();
|
||
expect(infoLine()).toBe('Logo B · von Anna · ♥ 0');
|
||
expect(infoMessage()).toBe('');
|
||
});
|
||
|
||
it('ein gemeldeter Name (Umbenennen, Lesen des eigenen Profils) beendet das Warten dieses Kontos sofort, die anderen bleiben leer', async () => {
|
||
const lookup = deferred<Map<string, string>>();
|
||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||
await mount();
|
||
picker.select('a');
|
||
emitName('u1', 'Kenso');
|
||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Kenso', '', '']);
|
||
expect(infoLine()).toBe('Logo A · von @Kenso · ♥ 0');
|
||
lookup.resolve(new Map([['u1', 'Kenso']]));
|
||
await flush();
|
||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Kenso', 'von Anna', 'von Anonym']);
|
||
});
|
||
|
||
it('das angeheftete Logo (gewählt, nicht in der Liste): leer, bis sein Hochlader nachgeschlagen ist', async () => {
|
||
fx.getLogo.mockResolvedValue(owned('x', 'u7'));
|
||
const lookup = deferred<Map<string, string>>();
|
||
fx.usernamesFor.mockResolvedValueOnce(new Map()); // die Liste
|
||
fx.usernamesFor.mockReturnValueOnce(lookup.promise); // das angeheftete Logo
|
||
await mount();
|
||
picker.select('x');
|
||
await flush();
|
||
expect(by('Logo X')).toBe('');
|
||
expect(infoLine()).toBe('Logo X · ♥ 0');
|
||
lookup.resolve(new Map([['u7', 'Zora']]));
|
||
await flush();
|
||
expect(by('Logo X')).toBe('von @Zora');
|
||
expect(infoLine()).toBe('Logo X · von @Zora · ♥ 0');
|
||
});
|
||
|
||
it('ein frisch hochgeladenes Logo eines noch unbekannten Hochladers: leer, bis sein Name da ist', async () => {
|
||
const lookup = deferred<Map<string, string>>();
|
||
fx.usernamesFor.mockResolvedValueOnce(new Map()); // die Liste
|
||
fx.usernamesFor.mockReturnValueOnce(lookup.promise); // das neue Logo
|
||
fx.uploadDialog.mockResolvedValue(logo('n', { name: 'Neues Logo', ownerId: 'u5', displayName: '' }));
|
||
await mount();
|
||
uploadButton().click();
|
||
await flush();
|
||
expect(by('Neues Logo')).toBe('');
|
||
lookup.resolve(new Map([['u5', 'Kenso']]));
|
||
await flush();
|
||
expect(by('Neues Logo')).toBe('von @Kenso');
|
||
});
|
||
|
||
it('wird die Seite aufgeräumt, bevor die Suche fertig ist, ändert eine spätere Antwort nichts', async () => {
|
||
const lookup = deferred<Map<string, string>>();
|
||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||
await mount();
|
||
picker.dispose();
|
||
lookup.resolve(new Map([['u1', 'Kenso']]));
|
||
await flush();
|
||
expect(by('Logo A')).toBe('');
|
||
});
|
||
});
|
||
|
||
it('der Name als Text, nie als HTML', async () => {
|
||
fx.usernamesFor.mockResolvedValue(new Map([['u1', '<b>fett</b>']]));
|
||
await mount();
|
||
expect(by('Logo A')).toBe('von @<b>fett</b>');
|
||
expect(cell('Logo A').querySelector('b')).toBeNull();
|
||
});
|
||
|
||
it('die Infozeile des gewählten Logos zeigt „von @name“, auch wenn die Namen erst nach der Auswahl eintreffen', async () => {
|
||
const lookup = deferred<Map<string, string>>();
|
||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||
await mount();
|
||
picker.select('a');
|
||
expect(infoLine()).toBe('Logo A · ♥ 0'); // der Name wird noch gesucht: keine Zeile „von …“
|
||
lookup.resolve(new Map([['u1', 'Kenso']]));
|
||
await flush();
|
||
expect(infoLine()).toBe('Logo A · von @Kenso · ♥ 0');
|
||
picker.select('b');
|
||
expect(infoLine()).toBe('Logo B · von Anna · ♥ 0'); // u2 hat kein Profil
|
||
});
|
||
|
||
it('schlägt die Abfrage der Namen fehl, bleibt es beim Ersatz: kein Fehlertext, nichts bricht, Herzen und Auswahl gehen weiter', async () => {
|
||
fx.usernamesFor.mockRejectedValue(new TypeError('Failed to fetch'));
|
||
await mount();
|
||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von Anonym', 'von Anna', 'von Anonym']);
|
||
expect(alertText()).toBe('');
|
||
picker.select('a');
|
||
expect(infoLine()).toBe('Logo A · von Anonym · ♥ 0');
|
||
expect(infoMessage()).toBe('');
|
||
expect(fx.myHearts).toHaveBeenCalled();
|
||
heart('Logo A').click();
|
||
await flush();
|
||
expect(fx.setHeart).toHaveBeenCalledWith('a', true);
|
||
});
|
||
|
||
it('„Mehr laden“: eine weitere Abfrage für die Hochlader der neuen Seite', async () => {
|
||
fx.listLogos.mockResolvedValueOnce({ logos: [owned('a', 'u1'), owned('b', 'u2')], nextCursor: 'b' });
|
||
fx.listLogos.mockResolvedValueOnce({ logos: [owned('c', 'u3'), owned('d', 'u1')], nextCursor: null });
|
||
fx.usernamesFor.mockImplementation(async (ids) => new Map(ids.map((id) => [id, `Name-${id}`])));
|
||
await mount();
|
||
expect(fx.usernamesFor).toHaveBeenCalledTimes(1);
|
||
moreButton().click();
|
||
await flush();
|
||
expect(fx.usernamesFor).toHaveBeenCalledTimes(2);
|
||
expect(fx.usernamesFor).toHaveBeenLastCalledWith(['u3', 'u1']);
|
||
expect(['Logo A', 'Logo B', 'Logo C', 'Logo D'].map(by)).toEqual(['von @Name-u1', 'von @Name-u2', 'von @Name-u3', 'von @Name-u1']);
|
||
});
|
||
|
||
it('ein Wechsel der Ansicht lädt die Liste neu und fragt die Namen dieser Seite', async () => {
|
||
await mount();
|
||
sortButton('Neu').click();
|
||
await flush();
|
||
expect(fx.usernamesFor).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('das gewählte Logo außerhalb der Liste (angeheftet) bekommt seinen Namen nach', async () => {
|
||
fx.getLogo.mockResolvedValue(owned('x', 'u7'));
|
||
fx.usernamesFor.mockImplementation(async (ids) => new Map(ids.includes('u7') ? [['u7', 'Zora']] : []));
|
||
await mount();
|
||
picker.select('x');
|
||
await flush();
|
||
expect(by('Logo X')).toBe('von @Zora');
|
||
expect(infoLine()).toBe('Logo X · von @Zora · ♥ 0');
|
||
});
|
||
|
||
it('ein frisch hochgeladenes Logo zeigt den Namen seines Hochladers (eine eigene Abfrage, wenn er der Liste noch fehlt)', async () => {
|
||
fx.usernamesFor.mockImplementation(async (ids) => new Map(ids.includes('u5') ? [['u5', 'Kenso']] : []));
|
||
fx.uploadDialog.mockResolvedValue(logo('n', { name: 'Neues Logo', ownerId: 'u5', displayName: '' }));
|
||
await mount();
|
||
expect(fx.usernamesFor).toHaveBeenCalledTimes(1);
|
||
uploadButton().click();
|
||
await flush();
|
||
expect(fx.usernamesFor).toHaveBeenCalledTimes(2);
|
||
expect(fx.usernamesFor).toHaveBeenLastCalledWith(['u5']);
|
||
expect(by('Neues Logo')).toBe('von @Kenso');
|
||
});
|
||
|
||
it('eine neue Auswahl oder ein Herz zeichnet die Kachel neu, schreibt die Zeile „von …“ aber nicht neu (derselbe Textknoten)', async () => {
|
||
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||
await mount();
|
||
const span = cell('Logo A').querySelector('.logo-tile-by')!;
|
||
const text = span.firstChild;
|
||
picker.select('a');
|
||
picker.select('b');
|
||
heart('Logo A').click();
|
||
await flush();
|
||
expect(cell('Logo A').querySelector('.logo-tile-by')).toBe(span);
|
||
expect(span.firstChild).toBe(text);
|
||
expect(by('Logo A')).toBe('von @Kenso');
|
||
});
|
||
|
||
it('eigenes Umbenennen: Kacheln und Infozeile des Kontos zeigen sofort den neuen Namen, andere Einträge bleiben', async () => {
|
||
fx.listLogos.mockResolvedValue({ logos: [owned('a', 'u1'), owned('b', 'u2', 'Anna'), owned('c', 'u1')], nextCursor: null });
|
||
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Alt']]));
|
||
await mount();
|
||
picker.select('a');
|
||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Alt', 'von Anna', 'von @Alt']);
|
||
expect(infoLine()).toBe('Logo A · von @Alt · ♥ 0');
|
||
|
||
emitName('u1', 'Neu');
|
||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Neu', 'von Anna', 'von @Neu']);
|
||
expect(infoLine()).toBe('Logo A · von @Neu · ♥ 0');
|
||
});
|
||
|
||
it('der Name eines Kontos, das noch kein Profil hatte (Konto ohne Namen wählt einen), ersetzt den Ersatz sofort', async () => {
|
||
await mount();
|
||
expect(by('Logo A')).toBe('von Anonym');
|
||
emitName('u1', 'Kenso');
|
||
expect(by('Logo A')).toBe('von @Kenso');
|
||
expect(by('Logo B')).toBe('von Anna');
|
||
});
|
||
|
||
it('dieselbe Meldung noch einmal (auch das Lesen des eigenen Profils meldet) ändert nichts: die Kachel wird nicht neu beschrieben', async () => {
|
||
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||
await mount();
|
||
const span = cell('Logo A').querySelector('.logo-tile-by')!;
|
||
const text = span.firstChild;
|
||
emitName('u1', 'Kenso');
|
||
emitName('u1', 'Kenso');
|
||
expect(cell('Logo A').querySelector('.logo-tile-by')).toBe(span);
|
||
expect(span.firstChild).toBe(text);
|
||
expect(by('Logo A')).toBe('von @Kenso');
|
||
});
|
||
|
||
it('eine Umbenennung vor dem Eintreffen einer älteren Antwort der Abfrage bleibt bestehen', async () => {
|
||
const lookup = deferred<Map<string, string>>();
|
||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||
await mount();
|
||
emitName('u1', 'Neu');
|
||
lookup.resolve(new Map([['u1', 'Neu']])); // `usernamesFor` selbst liefert nie Älteres als das Umbenannte
|
||
await flush();
|
||
expect(by('Logo A')).toBe('von @Neu');
|
||
});
|
||
|
||
it('aufgeräumt: der Hörer ist abgemeldet, eine später eintreffende Antwort ändert nichts und wirft nichts', async () => {
|
||
const lookup = deferred<Map<string, string>>();
|
||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||
await mount();
|
||
expect(nameListeners.size).toBe(1);
|
||
picker.dispose();
|
||
expect(nameListeners.size).toBe(0);
|
||
lookup.resolve(new Map([['u1', 'Kenso']]));
|
||
await flush();
|
||
expect(by('Logo A')).toBe(''); // die Antwort kam nach dem Aufräumen: nichts ändert sich
|
||
emitName('u1', 'Neu');
|
||
expect(by('Logo A')).toBe('');
|
||
});
|
||
|
||
it('Englisch: „by @name“, „by Anna“, „by Anonymous“', async () => {
|
||
setLang('en', { persist: false });
|
||
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||
await mount();
|
||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['by @Kenso', 'by Anna', 'by Anonymous']);
|
||
picker.select('a');
|
||
expect(infoLine()).toBe('Logo A · by @Kenso · ♥ 0');
|
||
});
|
||
});
|
||
|
||
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);
|
||
const focusSpy = vi.spyOn(heading(), 'focus');
|
||
retryButton().click();
|
||
expect(retryButton().hidden).toBe(true);
|
||
expect(document.activeElement).toBe(heading());
|
||
expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
|
||
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('vor dem Dialog wird der Benutzername verlangt (Spec § 5); das Signal der Seite geht mit, damit das Verlassen der Seite den Namensdialog schließt', async () => {
|
||
const order: string[] = [];
|
||
fx.requireUsername.mockImplementation(async () => {
|
||
order.push('name');
|
||
return true;
|
||
});
|
||
fx.uploadDialog.mockImplementation(async () => {
|
||
order.push('dialog');
|
||
return null;
|
||
});
|
||
await mount();
|
||
uploadButton().click();
|
||
await flush();
|
||
expect(order).toEqual(['name', 'dialog']);
|
||
const signal = fx.requireUsername.mock.calls[0][0];
|
||
expect(signal).toBe(fx.uploadDialog.mock.calls[0][0]);
|
||
picker.dispose();
|
||
expect(signal.aborted).toBe(true);
|
||
});
|
||
|
||
it('Gast: erst die Anmeldung, dann der Name, dann der Dialog', async () => {
|
||
user = null;
|
||
const order: string[] = [];
|
||
fx.requireLogin.mockImplementation(async () => (order.push('login'), true));
|
||
fx.requireUsername.mockImplementation(async () => (order.push('name'), true));
|
||
fx.uploadDialog.mockImplementation(async () => (order.push('dialog'), null));
|
||
await mount();
|
||
uploadButton().click();
|
||
await flush();
|
||
expect(order).toEqual(['login', 'name', 'dialog']);
|
||
});
|
||
|
||
it('der Nutzer bricht den Namensdialog ab (false): der Upload-Dialog öffnet nicht, nichts ändert sich, kein Fehlertext', async () => {
|
||
fx.requireUsername.mockResolvedValue(false);
|
||
await mount();
|
||
uploadButton().click();
|
||
await flush();
|
||
expect(fx.requireUsername).toHaveBeenCalledTimes(1);
|
||
expect(fx.uploadDialog).not.toHaveBeenCalled();
|
||
expect(fx.onSelect).not.toHaveBeenCalled();
|
||
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
|
||
expect(alertText()).toBe('');
|
||
expect(infoMessage()).toBe('');
|
||
// der Knopf geht danach wieder, und ein neuer Versuch fragt noch einmal
|
||
fx.requireUsername.mockResolvedValue(true);
|
||
uploadButton().click();
|
||
await flush();
|
||
expect(fx.requireUsername).toHaveBeenCalledTimes(2);
|
||
expect(fx.uploadDialog).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('Gast bricht die Anmeldung ab: der Name wird gar nicht erst verlangt', async () => {
|
||
user = null;
|
||
fx.requireLogin.mockResolvedValue(false);
|
||
await mount();
|
||
uploadButton().click();
|
||
await flush();
|
||
expect(fx.requireUsername).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('lässt sich der Namensdialog nicht öffnen (Fehler), steht der Grund in der Infozeile und es öffnet kein Upload-Dialog', async () => {
|
||
fx.requireUsername.mockRejectedValue(new Error('kein <dialog>'));
|
||
await mount();
|
||
uploadButton().click();
|
||
await flush();
|
||
expect(infoMessage()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||
expect(fx.uploadDialog).not.toHaveBeenCalled();
|
||
expect(alertText()).toBe('');
|
||
});
|
||
|
||
it('wird die Seite verlassen, während der Namensdialog offen ist, öffnet danach kein Upload-Dialog mehr', async () => {
|
||
const asking = deferred<boolean>();
|
||
fx.requireUsername.mockReturnValue(asking.promise);
|
||
await mount();
|
||
uploadButton().click();
|
||
await flush();
|
||
picker.dispose();
|
||
asking.resolve(true);
|
||
await flush();
|
||
expect(fx.uploadDialog).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('ein zweiter Klick, solange der Namensdialog offen ist, öffnet keinen zweiten', async () => {
|
||
fx.requireUsername.mockReturnValue(new Promise(() => {}));
|
||
await mount();
|
||
uploadButton().click();
|
||
uploadButton().click();
|
||
await flush();
|
||
expect(fx.requireUsername).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|