diff --git a/web/src/editor/dom.ts b/web/src/editor/dom.ts
index 0109732..d7a774f 100644
--- a/web/src/editor/dom.ts
+++ b/web/src/editor/dom.ts
@@ -36,6 +36,8 @@ const ICONS = {
'' +
'',
globe: '',
+ heart: '',
+ plus: '',
} as const;
export type IconName = keyof typeof ICONS;
diff --git a/web/src/editor/editor-page.test.ts b/web/src/editor/editor-page.test.ts
index 5160284..02b5361 100644
--- a/web/src/editor/editor-page.test.ts
+++ b/web/src/editor/editor-page.test.ts
@@ -1,5 +1,5 @@
// Speichern und Öffnen im Editor (Karten-Dienst, Anmeldung und Renderer sind ersetzt).
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import type { User } from '../account/auth';
import type { SavedCard } from '../account/cards';
import { DEFAULT_CONFIG } from '../card/config';
@@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
saveBlobAs: vi.fn(),
getGalleryCard: vi.fn(),
loadGalleryImage: vi.fn(),
+ createLogoPicker: vi.fn(),
logoutListeners: new Set<() => void>(),
}));
@@ -51,6 +52,8 @@ vi.mock('../community/gallery', () => ({
getGalleryCard: mocks.getGalleryCard,
loadGalleryImage: mocks.loadGalleryImage,
}));
+// Der Bereich „Logo unten rechts“ hat seine eigenen Tests (logos/logo-picker.test.ts); hier zählt nur die Verdrahtung.
+vi.mock('../logos/logo-picker', () => ({ createLogoPicker: mocks.createLogoPicker }));
vi.mock('./upload', async (importOriginal) => ({
...(await importOriginal()),
decodeImage: mocks.decodeImage,
@@ -75,6 +78,9 @@ const savedCard = (patch: Partial = {}): SavedCard => ({
let root: HTMLElement;
let store: EditorStore;
let unmount: (() => void) | null;
+/** Der Attrappen-Picker und die Funktionen, die der Editor ihm mitgegeben hat. */
+let picker: { el: HTMLElement; select: Mock; selected: Mock; dispose: Mock };
+let pickerHandlers: { onSelect(id: string | null): void };
beforeEach(() => {
for (const fn of Object.values(mocks)) if (typeof fn === 'function') fn.mockReset();
@@ -89,6 +95,11 @@ beforeEach(() => {
mocks.requireLogin.mockResolvedValue(true);
mocks.loadCardAssets.mockResolvedValue({ image: null, logo: null, brand: {}, logoMissing: false });
mocks.exportCard.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' }));
+ picker = { el: document.createElement('div'), select: vi.fn(), selected: vi.fn(() => null), dispose: vi.fn() };
+ mocks.createLogoPicker.mockImplementation((handlers: typeof pickerHandlers) => {
+ pickerHandlers = handlers;
+ return picker;
+ });
unmount = null;
});
@@ -1113,6 +1124,28 @@ describe('Community-Logo auf der Karte', () => {
expect(mocks.loadCardAssets).toHaveBeenCalledTimes(attempts);
});
+ it('ein einmal gescheiterter Ladevorgang blockiert das Logo nicht für immer: A scheitert, B lädt, A wird erneut gewählt → A wird neu geladen und gezeichnet', async () => {
+ let attemptsForA = 0;
+ mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null }) => {
+ if (config.logo === 'A' && ++attemptsForA === 1) throw new TypeError('Failed to fetch');
+ return FOUND;
+ });
+ store.set({ config: withLogo('A') });
+ mount();
+ await vi.waitFor(() => expect(errorText()).toBe('Die Logos konnten nicht geladen werden.'));
+ expect(mocks.drawCard).not.toHaveBeenCalled();
+
+ store.set({ config: withLogo('B') });
+ await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
+ expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'B')).toBe(true);
+
+ mocks.drawCard.mockClear();
+ store.set({ config: withLogo('A') });
+ await vi.waitFor(() => expect(attemptsForA).toBe(2));
+ await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
+ expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'A')).toBe(true);
+ });
+
it('gespeicherte Karte mit gelöschtem Logo: öffnet ohne Fehler, zeigt den Hinweis, behält die Auswahl und lässt sich herunterladen', async () => {
history.replaceState(null, '', '/gestalten?karte=k1');
mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), template: 'classic', logo: 'gone' } }));
@@ -1203,6 +1236,74 @@ describe('Community-Logo auf der Karte', () => {
});
});
+describe('Logo-Auswahl („Logo unten rechts“)', () => {
+ it('der Picker wird einmal erzeugt und sitzt im Platz oben in der Optionsspalte (logoSlot)', () => {
+ mount();
+ expect(mocks.createLogoPicker).toHaveBeenCalledTimes(1);
+ const slot = root.querySelector('.editor-logo-slot')!;
+ expect(slot.contains(picker.el)).toBe(true);
+ expect(slot.parentElement!.classList.contains('editor-options')).toBe(true);
+ });
+
+ it('eine Wahl im Picker setzt config.logo, „Kein Logo“ setzt es zurück; der Rest der Konfiguration bleibt', () => {
+ store.set({ config: { ...store.get().config, template: 'classic', details: 'dark' } });
+ mount();
+ pickerHandlers.onSelect('abc123');
+ expect(store.get().config).toMatchObject({ logo: 'abc123', template: 'classic', details: 'dark' });
+ pickerHandlers.onSelect(null);
+ expect(store.get().config).toMatchObject({ logo: null, template: 'classic', details: 'dark' });
+ });
+
+ it('die Wahl lädt die Karten-Bilder mit dem neuen Logo (der Editor zeichnet sie neu)', async () => {
+ mount();
+ await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1));
+ pickerHandlers.onSelect('abc123');
+ await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2));
+ expect(mocks.loadCardAssets.mock.calls[1][0]).toMatchObject({ logo: 'abc123' });
+ });
+
+ it('sync: der Picker bekommt config.logo beim Aufbau und bei jeder Änderung', () => {
+ store.set({ config: { ...store.get().config, logo: 'abc123' } });
+ mount();
+ expect(picker.select).toHaveBeenLastCalledWith('abc123');
+ store.set({ config: { ...store.get().config, logo: 'def456' } });
+ expect(picker.select).toHaveBeenLastCalledWith('def456');
+ store.set({ config: { ...store.get().config, logo: null } });
+ expect(picker.select).toHaveBeenLastCalledWith(null);
+ });
+
+ it('eine geöffnete gespeicherte Karte: der Picker bekommt ihr Logo', async () => {
+ history.replaceState(null, '', '/gestalten?karte=k1');
+ mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), logo: 'gespeichert' } }));
+ mocks.loadImage.mockResolvedValue(png());
+ mocks.decodeImage.mockResolvedValue(image());
+ mount();
+ await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
+ expect(picker.select).toHaveBeenLastCalledWith('gespeichert');
+ });
+
+ it('„Neue Karte“ setzt den Editor zurück: der Picker bekommt wieder null', () => {
+ store.set({
+ config: { ...store.get().config, logo: 'abc123' },
+ saved: { id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
+ savedConfig: { ...store.get().config, logo: 'abc123' },
+ cardName: 'Urlaub',
+ });
+ mount();
+ expect(picker.select).toHaveBeenLastCalledWith('abc123');
+ root.querySelector('.bound-row button')!.click();
+ expect(picker.select).toHaveBeenLastCalledWith(null);
+ });
+
+ it('die Seite zu verlassen räumt den Picker genau einmal auf (dispose)', () => {
+ mount();
+ expect(picker.dispose).not.toHaveBeenCalled();
+ unmount!();
+ unmount = null;
+ expect(picker.dispose).toHaveBeenCalledTimes(1);
+ });
+});
+
describe('Vorlage übernehmen (/gestalten?vorlage=)', () => {
const openTemplate = (id: string) => history.replaceState(null, '', `/gestalten?vorlage=${id}`);
const galleryCard = (patch: Record = {}) => ({
diff --git a/web/src/editor/editor-page.ts b/web/src/editor/editor-page.ts
index c9ab8d6..524e532 100644
--- a/web/src/editor/editor-page.ts
+++ b/web/src/editor/editor-page.ts
@@ -12,6 +12,7 @@ import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage }
import { getGalleryCard, loadGalleryImage } from '../community/gallery';
import { t } from '../i18n/i18n';
import { LOGO_MISSING } from '../logos/logo-asset';
+import { createLogoPicker } from '../logos/logo-picker';
import { dropCardParam, dropTemplateParam } from './card-url';
import { confirmDialog } from './confirm-dialog';
import { downloadFileName, pngSizeWarning, saveBlobAs } from './download';
@@ -71,6 +72,9 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
onDetails: (details: DetailsTone) => patchConfig({ details }),
onBrand: (brand: BrandId) => patchConfig({ brand }),
});
+ // „Logo unten rechts“: die Wahl setzt config.logo, markiert wird sie über sync (picker.select).
+ const logoPicker = createLogoPicker({ onSelect: (logo) => patchConfig({ logo }) });
+ options.logoSlot.append(logoPicker.el);
let exporting = false;
@@ -388,7 +392,8 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
// Logos und Schrift: werden nur bei Wechsel von Community-Logo, Details oder Marke geladen (Logos sind zwischengespeichert).
// Fehlt das gewählte Community-Logo, wird die Karte ohne es gezeichnet und der Hinweis gezeigt, solange das so bleibt; ein echter
- // Ladefehler (Netz, Server) zeigt dagegen LOGO_ERROR, und derselbe Schlüssel wird nicht noch einmal versucht.
+ // Ladefehler (Netz, Server) zeigt dagegen LOGO_ERROR, und derselbe Schlüssel wird nicht im Kreis neu versucht – erst eine andere
+ // Wahl (Logo, Details, Marke) hebt die Sperre auf, damit sich ein Logo nach einem Netzfehler wieder wählen lässt.
let logos: (Logos & { key: string }) | null = null;
let pendingKey = '';
let failedKey = '';
@@ -396,6 +401,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
function ensureLogos(config: CardConfig): void {
const key = logoKey(config);
if (logos?.key === key || pendingKey === key || failedKey === key) return;
+ failedKey = '';
pendingKey = key;
const token = ++logoToken;
loadCardAssets(config, null).then(
@@ -442,6 +448,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
function sync(s: EditorState): void {
templates.select(s.config.template);
options.select(s.config);
+ logoPicker.select(s.config.logo);
preview.sync({ hasImage: s.image !== null, zoom: s.config.crop.zoom, saving: s.saving, saved: savedFlash, boundName: s.saved?.name ?? null, exporting });
}
@@ -481,6 +488,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
clearTimeout(flashTimer);
offLogout();
unsubscribe();
+ logoPicker.dispose();
if (frame) cancelAnimationFrame(frame);
frame = 0;
};
diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts
index c83e140..52c5056 100644
--- a/web/src/i18n/en.ts
+++ b/web/src/i18n/en.ts
@@ -74,6 +74,23 @@ export const EN: Readonly> = {
'Der Anzeigename ist zu lang (höchstens {max} Zeichen).': 'The display name is too long (at most {max} characters).',
'Bitte bestätige, dass du das Logo teilen darfst.': 'Please confirm that you’re allowed to share the logo.',
+ // Bereich „Logo unten rechts“ im Editor (logos/logo-picker.ts). „Community“, „Hochladen“ und „Anonym“ stehen weiter oben.
+ 'Logo unten rechts': 'Bottom-right logo',
+ 'Logos werden von der Community hochgeladen.': 'Logos are uploaded by the community.',
+ 'Sortierung': 'Sort order',
+ 'Beliebt': 'Popular',
+ 'Neu': 'New',
+ 'Gemeldet': 'Reported',
+ 'Ausgeblendet': 'Hidden',
+ 'Kein Logo': 'No logo',
+ 'von {name}': 'by {name}',
+ 'Herz für „{name}“: {count}': 'Heart for “{name}”: {count}',
+ 'Noch keine Community-Logos – lade das erste hoch.': 'No community logos yet – upload the first one.',
+ 'Keine Logos in dieser Ansicht.': 'No logos in this view.',
+ 'Die Logos konnten nicht geladen werden.': 'The logos couldn’t be loaded.',
+ 'Nochmal versuchen': 'Try again',
+ 'Mehr laden': 'Load more',
+
// API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich.
'Bitte melde dich an.': 'Please sign in.',
'Nicht gefunden.': 'Not found.',
diff --git a/web/src/logos/logo-asset.ts b/web/src/logos/logo-asset.ts
index 5bc03aa..2d9b8ed 100644
--- a/web/src/logos/logo-asset.ts
+++ b/web/src/logos/logo-asset.ts
@@ -12,9 +12,10 @@ export const LOGO_MISSING = msg('Das gewählte Logo ist nicht mehr verfügbar.')
/**
* Obergrenze für Breite × Höhe eines geladenen Logos. Der Server nimmt höchstens 720 × 250 an (rund 180 000 Pixel); alles
- * Größere kann nur eine veränderte Datei sein. Es gilt als nicht verfügbar und wird weder in einen Canvas noch auf die Karte gezeichnet.
+ * Größere kann nur eine veränderte Datei sein. Es gilt als nicht verfügbar und wird weder in einen Canvas noch auf die Karte gezeichnet
+ * (und auch nicht als Kachel im Logo-Raster, siehe logo-picker.ts).
*/
-const MAX_LOGO_PIXELS = 1_000_000;
+export const MAX_LOGO_PIXELS = 1_000_000;
type Decoded = LogoAsset;
diff --git a/web/src/logos/logo-image.test.ts b/web/src/logos/logo-image.test.ts
new file mode 100644
index 0000000..b3e4256
--- /dev/null
+++ b/web/src/logos/logo-image.test.ts
@@ -0,0 +1,224 @@
+// Kachelbild eines Community-Logos: laden, Pixelgrenze, Platzhalter und Object-URL. Datei und `Image` sind ersetzt.
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// `logo-asset` (Quelle der Pixelgrenze) hängt an Appwrite und Anmeldung.
+vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
+vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
+
+import { MAX_LOGO_PIXELS } from './logo-asset';
+import { showLogoImage } from './logo-image';
+import type { CommunityLogo } from './logos';
+
+const logo = (patch: Partial = {}): CommunityLogo => ({
+ id: 'l1',
+ name: 'Beispielbank',
+ displayName: 'Kenso',
+ fileId: 'l1',
+ ownerId: 'u1',
+ mono: false,
+ likeCount: 0,
+ hidden: false,
+ reportCount: 0,
+ createdAt: '2026-10-04T12:00:00.000Z',
+ ...patch,
+});
+
+const flush = async () => {
+ for (let i = 0; i < 30; i++) await Promise.resolve();
+};
+
+/** Was das ersetzte `Image` meldet. Mit `hold` bleibt die Prüfung offen, bis der Test `held` auslöst. */
+let image: { width: number; height: number; fails: boolean; hold: boolean };
+let held: Array<() => void>;
+let created: string[];
+let revoked: string[];
+
+class FakeImage {
+ naturalWidth = 0;
+ naturalHeight = 0;
+ onload: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+
+ set src(_value: string) {
+ const settle = () => {
+ if (image.fails) {
+ this.onerror?.();
+ return;
+ }
+ this.naturalWidth = image.width;
+ this.naturalHeight = image.height;
+ this.onload?.();
+ };
+ if (image.hold) held.push(settle);
+ else queueMicrotask(settle);
+ }
+}
+
+const load = vi.fn<(row: CommunityLogo) => Promise>();
+let art: HTMLElement;
+
+beforeEach(() => {
+ image = { width: 200, height: 80, fails: false, hold: false };
+ held = [];
+ created = [];
+ revoked = [];
+ load.mockReset().mockResolvedValue(new Blob(['png']));
+ URL.createObjectURL = vi.fn(() => {
+ created.push(`blob:logo-${created.length + 1}`);
+ return created[created.length - 1];
+ });
+ URL.revokeObjectURL = vi.fn((url: string) => {
+ revoked.push(url);
+ });
+ vi.stubGlobal('Image', FakeImage);
+ art = document.createElement('span');
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+const state = () => art.getAttribute('data-state');
+
+describe('showLogoImage: Anzeige', () => {
+ it('lädt die Datei des Logos; erst „loading“, danach „ready“', async () => {
+ const row = logo();
+ showLogoImage(art, row, load);
+ expect(state()).toBe('loading');
+ expect(load).toHaveBeenCalledExactlyOnceWith(row);
+ await flush();
+ expect(state()).toBe('ready');
+ });
+
+ it('mehrfarbig: ein dekoratives
(alt leer, nicht ziehbar) auf die Object-URL des PNGs', async () => {
+ showLogoImage(art, logo(), load);
+ await flush();
+ const img = art.querySelector('img')!;
+ expect(img.getAttribute('src')).toBe(created[0]);
+ expect(img.getAttribute('alt')).toBe('');
+ expect(img.getAttribute('draggable')).toBe('false');
+ expect(art.children).toHaveLength(1);
+ });
+
+ it('einfarbig: ein Element mit Maske auf die Object-URL, gefüllt in Weiß, ohne
', async () => {
+ showLogoImage(art, logo({ mono: true }), load);
+ await flush();
+ expect(art.querySelector('img')).toBeNull();
+ const mask = art.querySelector('.logo-tile-mask')!;
+ expect(mask.style.getPropertyValue('--logo-url')).toBe(`url("${created[0]}")`);
+ expect(mask.style.getPropertyValue('background-color')).toBe('#FFFFFF');
+ expect(mask.getAttribute('aria-hidden')).toBe('true');
+ });
+
+ it('die Object-URL bleibt, solange das Bild steht (der Aufrufer gibt sie über die Rückgabe frei)', async () => {
+ showLogoImage(art, logo(), load);
+ await flush();
+ expect(created).toHaveLength(1);
+ expect(revoked).toEqual([]);
+ });
+});
+
+describe('showLogoImage: Pixelgrenze und Platzhalter', () => {
+ it('Grenze: 1 000 000 Pixel (die Datei ist höchstens 720 × 250 groß)', () => {
+ expect(MAX_LOGO_PIXELS).toBe(1_000_000);
+ });
+
+ it('genau 1 000 000 Pixel gehen durch', async () => {
+ image = { ...image, width: 1000, height: 1000 };
+ showLogoImage(art, logo(), load);
+ await flush();
+ expect(state()).toBe('ready');
+ expect(art.querySelector('img')).not.toBeNull();
+ });
+
+ it.each([
+ ['1000 × 1001', 1000, 1001],
+ ['60 000 × 60 000 (Dekompressionsbombe)', 60_000, 60_000],
+ ['Maße 0 (kein echtes Bild)', 0, 0],
+ ])('%s: nicht angezeigt (Platzhalter), die URL wird freigegeben', async (_name, width, height) => {
+ image = { ...image, width, height };
+ showLogoImage(art, logo(), load);
+ await flush();
+ expect(state()).toBe('unusable');
+ expect(art.children).toHaveLength(0);
+ expect(revoked).toEqual(created);
+ expect(created).toHaveLength(1);
+ });
+
+ it('auch ein einfarbiges Logo über der Grenze wird nicht angezeigt (keine Maske)', async () => {
+ image = { ...image, width: 5000, height: 5000 };
+ showLogoImage(art, logo({ mono: true }), load);
+ await flush();
+ expect(state()).toBe('unusable');
+ expect(art.querySelector('.logo-tile-mask')).toBeNull();
+ expect(revoked).toEqual(created);
+ });
+
+ it('nicht dekodierbar: Platzhalter, URL freigegeben', async () => {
+ image = { ...image, fails: true };
+ showLogoImage(art, logo(), load);
+ await flush();
+ expect(state()).toBe('unusable');
+ expect(revoked).toEqual(created);
+ });
+
+ it('die Datei lässt sich nicht laden (404, Netz): Platzhalter, es wird keine URL angelegt, nichts wirft', async () => {
+ load.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
+ showLogoImage(art, logo(), load);
+ await flush();
+ expect(state()).toBe('unusable');
+ expect(created).toEqual([]);
+ load.mockRejectedValue(new TypeError('Failed to fetch'));
+ showLogoImage(art, logo(), load);
+ await flush();
+ expect(created).toEqual([]);
+ });
+});
+
+describe('showLogoImage: freigeben', () => {
+ it('die Rückgabe gibt die angezeigte URL genau einmal frei; ein zweiter Aufruf schadet nicht', async () => {
+ const release = showLogoImage(art, logo(), load);
+ await flush();
+ release();
+ expect(revoked).toEqual([created[0]]);
+ release();
+ expect(revoked).toEqual([created[0]]);
+ });
+
+ it('freigegeben, bevor die Datei ankommt: es wird nichts angelegt und nichts angezeigt', async () => {
+ let arrive!: (blob: Blob) => void;
+ load.mockReturnValue(new Promise((resolve) => (arrive = resolve)));
+ const release = showLogoImage(art, logo(), load);
+ release();
+ arrive(new Blob(['spät']));
+ await flush();
+ expect(created).toEqual([]);
+ expect(art.children).toHaveLength(0);
+ expect(state()).toBe('loading'); // die Fläche gehört niemandem mehr
+ });
+
+ it('freigegeben während der Bildprüfung: nichts angezeigt, die schon angelegte URL wird freigegeben', async () => {
+ image = { ...image, hold: true };
+ const release = showLogoImage(art, logo(), load);
+ await flush();
+ expect(created).toHaveLength(1); // Datei da, Prüfung offen
+ release();
+ expect(revoked).toEqual([]); // noch gehört die URL keinem angezeigten Bild
+ for (const settle of held) settle();
+ await flush();
+ expect(revoked).toEqual(created);
+ expect(art.children).toHaveLength(0);
+ expect(state()).toBe('loading');
+ });
+
+ it('eine abgelehnte Prüfung nach dem Freigeben macht den Platzhalter nicht mehr sichtbar', async () => {
+ image = { ...image, hold: true, fails: true };
+ const release = showLogoImage(art, logo(), load);
+ await flush();
+ release();
+ for (const settle of held) settle();
+ await flush();
+ expect(state()).toBe('loading');
+ expect(revoked).toEqual(created);
+ });
+});
diff --git a/web/src/logos/logo-image.ts b/web/src/logos/logo-image.ts
new file mode 100644
index 0000000..ccc390b
--- /dev/null
+++ b/web/src/logos/logo-image.ts
@@ -0,0 +1,70 @@
+// Das PNG eines Community-Logos als Bild einer Kachel: laden, auf die Pixelgrenze prüfen und auf der Bildfläche zeigen. Die Object-URL
+// gehört dem Aufrufer, der sie über die zurückgegebene Funktion freigibt. Fremde Dateien sind ungeprüft: Bilder über der Grenze oder
+// nicht lesbare zeigt es nicht (neutraler Platzhalter), es stürzt nie ab.
+import { TONE_COLOR } from '../card/config';
+import { el } from '../editor/dom';
+import { MAX_LOGO_PIXELS } from './logo-asset';
+import type { CommunityLogo } from './logos';
+
+/**
+ * Lädt die Object-URL in ein `Image` und lehnt ab, was sich nicht lesen lässt oder mehr als `MAX_LOGO_PIXELS` Pixel hat (so wie
+ * `checkGalleryImage` die Galerie-Bilder prüft): ein winziges, stark komprimiertes PNG kann sonst Gigabytes an Pixeln enthalten
+ * und den Tab einfrieren. Die Maße sind nach dem Laden bekannt, das Bild ist aber noch nicht dekodiert.
+ */
+function checkImage(url: string): Promise {
+ return new Promise((resolve, reject) => {
+ const probe = new Image();
+ probe.onload = () => {
+ const { naturalWidth: w, naturalHeight: h } = probe;
+ if (w > 0 && h > 0 && w * h <= MAX_LOGO_PIXELS) resolve();
+ else reject(new Error('Das Logo ist zu groß.'));
+ };
+ probe.onerror = () => reject(new Error('Das Logo konnte nicht gelesen werden.'));
+ probe.src = url;
+ });
+}
+
+/** Einfarbiges Logo: nur die Deckkraft des PNGs zählt (Maske auf dessen Object-URL), gefüllt in Weiß wie auf der dunklen Karte. */
+function maskOf(url: string): HTMLElement {
+ const mask = el('span', { class: 'logo-tile-mask', 'aria-hidden': 'true' });
+ mask.style.setProperty('--logo-url', `url("${url}")`);
+ mask.style.setProperty('background-color', TONE_COLOR.light);
+ return mask;
+}
+
+/**
+ * Zeigt das Logo auf `art` (dekorativ: mehrfarbig als Bild, einfarbig als weiße Maske) und setzt dort `data-state`: `loading`, dann
+ * `ready` oder `unusable` (Ladefehler, nicht lesbar, zu groß: die Fläche bleibt leer, der Platzhalter kommt aus dem Stylesheet).
+ * `load` holt die Datei. Die zurückgegebene Funktion gibt die Object-URL frei; eine Datei, die erst danach eintrifft, wird nicht mehr
+ * angezeigt und ihre URL sofort wieder freigegeben.
+ */
+export function showLogoImage(art: HTMLElement, logo: CommunityLogo, load: (logo: CommunityLogo) => Promise): () => void {
+ let shown: string | null = null;
+ let released = false;
+ art.setAttribute('data-state', 'loading');
+
+ void (async () => {
+ let url: string | null = null;
+ try {
+ const blob = await load(logo);
+ if (released) return;
+ url = URL.createObjectURL(blob);
+ await checkImage(url);
+ if (!released) {
+ shown = url;
+ art.replaceChildren(logo.mono ? maskOf(url) : el('img', { class: 'logo-tile-img', src: url, alt: '', draggable: 'false' }));
+ art.setAttribute('data-state', 'ready');
+ return;
+ }
+ } catch {
+ if (!released) art.setAttribute('data-state', 'unusable');
+ }
+ if (url) URL.revokeObjectURL(url); // nicht angezeigt (abgelehnt oder die Kachel ist inzwischen weg)
+ })();
+
+ return () => {
+ released = true;
+ if (shown) URL.revokeObjectURL(shown);
+ shown = null;
+ };
+}
diff --git a/web/src/logos/logo-picker.test.ts b/web/src/logos/logo-picker.test.ts
new file mode 100644
index 0000000..a81e420
--- /dev/null
+++ b/web/src/logos/logo-picker.test.ts
@@ -0,0 +1,1112 @@
+// Bereich „Logo unten rechts“ (Spec § 3 und § 5): Raster, Sortierung, Auswahl, Herzen und Hochladen. Datenschicht, Anmeldung und
+// Dialog sind Attrappen; die Kachelbilder laufen über das echte logo-image.ts mit ersetztem `Image` (dessen Einzelfälle stehen in
+// logo-image.test.ts).
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({ rememberLogo: 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()),
+ rememberLogo: mocks.rememberLogo,
+}));
+
+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 => ({
+ 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() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason: unknown) => void;
+ const promise = new Promise((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(),
+ getLogo: vi.fn(),
+ myHearts: vi.fn(),
+ setHeart: vi.fn(),
+ loadLogoBlob: vi.fn(),
+ requireLogin: vi.fn(),
+ uploadDialog: vi.fn(),
+ onSelect: vi.fn<(id: string | null) => void>(),
+};
+
+const deps: LogoPickerDeps = {
+ listLogos: fx.listLogos,
+ getLogo: fx.getLogo,
+ myHearts: fx.myHearts,
+ setHeart: fx.setHeart,
+ loadLogoBlob: fx.loadLogoBlob,
+ userId: () => user,
+ isAdmin: () => admin,
+ requireLogin: fx.requireLogin,
+ onAuthChange(fn) {
+ authListeners.add(fn);
+ return () => authListeners.delete(fn);
+ },
+ uploadDialog: fx.uploadDialog,
+};
+
+// 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();
+const urlOwner = new Map();
+const sizes = new Map();
+const broken = new Set();
+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]) fn.mockReset();
+ user = 'u1';
+ admin = false;
+ authListeners.clear();
+ sizes.clear();
+ broken.clear();
+ urlOwner.clear();
+ created = [];
+ revoked = [];
+ fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b'), logo('c')], nextCursor: null });
+ fx.getLogo.mockResolvedValue(null);
+ fx.myHearts.mockResolvedValue(new Set());
+ fx.setHeart.mockImplementation(async (_id, herz) => ({ herz, likeCount: herz ? 1 : 0 }));
+ fx.loadLogoBlob.mockImplementation(async (row) => {
+ const blob = new Blob(['png']);
+ blobOwner.set(blob, row.id);
+ return blob;
+ });
+ fx.requireLogin.mockResolvedValue(true);
+ fx.uploadDialog.mockResolvedValue(null);
+ 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 {
+ picker = createLogoPicker({ onSelect: fx.onSelect }, deps);
+ host.append(picker.el);
+ await flush();
+ return picker;
+}
+
+// ---- Zugriffe auf das DOM -------------------------------------------------------------------------------------------------
+
+const cells = () => [...picker.el.querySelectorAll('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('button.logo-tile')!;
+const heart = (name: string) => cell(name).querySelector('button.logo-heart')!;
+const art = (name: string) => cell(name).querySelector('.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('.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('button.logo-upload')!;
+const moreButton = () => picker.el.querySelector('button.logo-more')!;
+const retryButton = () => picker.el.querySelector('button.logo-retry')!;
+const emptyText = () => picker.el.querySelector('.logo-empty')!.textContent;
+
+describe('Aufbau', () => {
+ it('Kopf: Überschrift „Logo unten rechts“, Plakette „Community“, Knopf „Hochladen“ und der Satz zur Community', async () => {
+ await mount();
+ const heading = picker.el.querySelector('h3')!;
+ expect(heading.textContent).toBe('Logo unten rechts');
+ expect(picker.el.getAttribute('aria-labelledby')).toBe(heading.id);
+ expect(picker.el.querySelector('.logo-badge')!.textContent).toBe('Community');
+ expect(uploadButton().type).toBe('button');
+ expect(uploadButton().querySelector('svg')).not.toBeNull(); // das Plus
+ expect(picker.el.querySelector('.logo-picker-note')!.textContent).toBe('Logos werden von der Community hochgeladen.');
+ });
+
+ it('Umschalter „Beliebt“ (gedrückt) und „Neu“; Nicht-Admins sehen „Gemeldet“ und „Ausgeblendet“ nicht', async () => {
+ await mount();
+ expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu']);
+ expect(pressedSort()).toEqual(['Beliebt']);
+ });
+
+ it('erste Kachel „Kein Logo“, dann je Logo eine Kachel mit Name, „von “ bzw. „von Anonym“ und Herz-Knopf mit der Zahl', async () => {
+ fx.listLogos.mockResolvedValue({
+ logos: [logo('a', { likeCount: 12 }), logo('b', { displayName: '' }), logo('c', { displayName: 'Anna', likeCount: 3 })],
+ nextCursor: null,
+ });
+ await mount();
+ expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
+ expect(cell('Kein Logo').querySelector('button.logo-tile')!.classList.contains('logo-tile-none')).toBe(true);
+ expect(cell('Kein Logo').querySelector('.logo-heart')).toBeNull();
+ expect(['Logo A', 'Logo B', 'Logo C'].map((n) => cell(n).querySelector('.logo-tile-by')!.textContent)).toEqual([
+ 'von Kenso',
+ 'von Anonym',
+ 'von Anna',
+ ]);
+ expect(['Logo A', 'Logo B', 'Logo C'].map((n) => heart(n).textContent)).toEqual(['12', '0', '3']);
+ });
+
+ it('der Herz-Knopf ist ein eigener Knopf neben dem Auswahlknopf, nie darin verschachtelt', async () => {
+ await mount();
+ for (const c of cells().slice(1)) {
+ const select = c.querySelector('button.logo-tile')!;
+ const like = c.querySelector('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: '
', displayName: 'fett' })],
+ nextCursor: null,
+ });
+ await mount();
+ const c = cells()[1];
+ expect(c.querySelector('.logo-tile-name')!.textContent).toBe('
');
+ expect(c.querySelector('.logo-tile-by')!.textContent).toBe('von fett');
+ expect(c.querySelector('img[src="x"]')).toBeNull();
+ expect(c.querySelector('b')).toBeNull();
+ });
+
+ it('die erste Seite wird beliebt sortiert und ungefiltert geladen', async () => {
+ await mount();
+ expect(fx.listLogos).toHaveBeenCalledExactlyOnceWith({ sort: 'popular', filter: 'all' });
+ });
+
+ it('Englisch: Überschrift, Umschalter, Kacheln und Knöpfe sind übersetzt', async () => {
+ setLang('en', { persist: false });
+ fx.listLogos.mockResolvedValue({ logos: [logo('a', { displayName: '' })], nextCursor: 'a' });
+ await mount();
+ expect(picker.el.querySelector('h3')!.textContent).toBe('Bottom-right logo');
+ expect(picker.el.querySelector('.logo-picker-note')!.textContent).toBe('Logos are uploaded by the community.');
+ expect(uploadButton().textContent).toBe('Upload');
+ expect(sortButtons().map((b) => b.textContent)).toEqual(['Popular', 'New']);
+ expect(names()).toEqual(['No logo', 'Logo A']);
+ expect(cell('Logo A').querySelector('.logo-tile-by')!.textContent).toBe('by Anonymous');
+ expect(heart('Logo A').getAttribute('aria-label')).toBe('Heart for “Logo A”: 0');
+ expect(moreButton().textContent).toBe('Load more');
+ });
+});
+
+describe('Sortierung', () => {
+ it('„Neu“ lädt mit sort:new neu, der Umschalter folgt, die Kacheln der alten Liste fallen weg', async () => {
+ await mount();
+ fx.listLogos.mockResolvedValue({ logos: [logo('z'), logo('a')], nextCursor: null });
+ sortButton('Neu').click();
+ expect(pressedSort()).toEqual(['Neu']);
+ await flush();
+ expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'all' });
+ expect(names()).toEqual(['Kein Logo', 'Logo Z', 'Logo A']);
+ sortButton('Beliebt').click();
+ await flush();
+ expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' });
+ });
+
+ it('ein Klick auf die schon gewählte Ansicht lädt nichts neu', async () => {
+ await mount();
+ sortButton('Beliebt').click();
+ await flush();
+ expect(fx.listLogos).toHaveBeenCalledTimes(1);
+ });
+
+ it('Admins sehen zusätzlich „Gemeldet“ und „Ausgeblendet“; sie laden mit filter:reported bzw. filter:hidden', async () => {
+ admin = true;
+ await mount();
+ expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu', 'Gemeldet', 'Ausgeblendet']);
+ sortButton('Gemeldet').click();
+ await flush();
+ expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'reported' });
+ expect(pressedSort()).toEqual(['Gemeldet']);
+ sortButton('Ausgeblendet').click();
+ await flush();
+ expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'hidden' });
+ expect(pressedSort()).toEqual(['Ausgeblendet']);
+ sortButton('Beliebt').click();
+ await flush();
+ expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' });
+ });
+
+ it('eine späte Antwort der früheren Ansicht überschreibt die neuere nicht', async () => {
+ await mount();
+ const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
+ fx.listLogos.mockReturnValueOnce(slow.promise);
+ sortButton('Neu').click();
+ fx.listLogos.mockResolvedValueOnce({ logos: [logo('p')], nextCursor: null });
+ sortButton('Beliebt').click();
+ await flush();
+ expect(names()).toEqual(['Kein Logo', 'Logo P']);
+ slow.resolve({ logos: [logo('q')], nextCursor: null });
+ await flush();
+ expect(names()).toEqual(['Kein Logo', 'Logo P']);
+ expect(pressedSort()).toEqual(['Beliebt']);
+ });
+
+ it('verliert der Nutzer die Admin-Rolle (Abmelden), verschwinden die Admin-Knöpfe und die Liste wird wieder öffentlich geladen', async () => {
+ admin = true;
+ await mount();
+ sortButton('Gemeldet').click();
+ await flush();
+ admin = false;
+ user = null;
+ emitAuth();
+ await flush();
+ expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu']);
+ expect(pressedSort()).toEqual(['Beliebt']);
+ expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' });
+ });
+
+ it('wird jemand Admin (Anmeldung), erscheinen die Admin-Knöpfe, ohne dass die Liste neu geladen wird', async () => {
+ user = null;
+ await mount();
+ admin = true;
+ user = 'u1';
+ emitAuth();
+ await flush();
+ expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu', 'Gemeldet', 'Ausgeblendet']);
+ expect(fx.listLogos).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('Mehr laden', () => {
+ const page2 = () => ({ logos: [logo('d'), logo('e')], nextCursor: null });
+
+ it('der Knopf erscheint nur, wenn es einen nextCursor gibt', async () => {
+ await mount();
+ expect(moreButton().hidden).toBe(true);
+ picker.dispose();
+ fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
+ await mount();
+ expect(moreButton().hidden).toBe(false);
+ expect(moreButton().textContent).toBe('Mehr laden');
+ });
+
+ it('ein Klick hängt die nächste Seite hinten an (mit Cursor), danach verschwindet der Knopf', async () => {
+ fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' });
+ await mount();
+ const first = tile('Logo A');
+ fx.listLogos.mockResolvedValue(page2());
+ moreButton().click();
+ await flush();
+ expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'b' });
+ expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo D', 'Logo E']);
+ expect(tile('Logo A')).toBe(first); // die vorhandenen Kacheln bleiben
+ expect(moreButton().hidden).toBe(true);
+ });
+
+ it('während des Ladens ist der Knopf gesperrt, ein zweiter Klick löst keine zweite Anfrage aus', async () => {
+ fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
+ await mount();
+ const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
+ fx.listLogos.mockReturnValue(slow.promise);
+ moreButton().click();
+ expect(moreButton().disabled).toBe(true);
+ moreButton().click();
+ moreButton().disabled = false; // selbst wenn der Klick den Knopf erreicht
+ moreButton().click();
+ expect(fx.listLogos).toHaveBeenCalledTimes(2);
+ slow.resolve(page2());
+ await flush();
+ expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo D', 'Logo E']);
+ });
+
+ it('ein Logo, das auf der nächsten Seite noch einmal vorkommt (die Rangfolge hat sich verschoben), bleibt an seinem Platz und steht nur einmal im Raster', async () => {
+ fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b'), logo('c')], nextCursor: 'c' });
+ await mount();
+ fx.listLogos.mockResolvedValue({ logos: [logo('b', { likeCount: 4 }), logo('d')], nextCursor: null });
+ moreButton().click();
+ await flush();
+ expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C', 'Logo D']);
+ expect(heart('Logo B').textContent).toBe('4'); // die jüngere Zeile gilt
+ });
+
+ it('scheitert das Nachladen: Meldung und „Nochmal versuchen“ holen genau diese Seite erneut, die Liste bleibt', async () => {
+ fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
+ await mount();
+ fx.listLogos.mockRejectedValueOnce(new Error('offline'));
+ moreButton().click();
+ await flush();
+ expect(alertText()).toBe('Die Logos konnten nicht geladen werden.');
+ expect(names()).toEqual(['Kein Logo', 'Logo A']);
+ expect(retryButton().hidden).toBe(false);
+ expect(moreButton().hidden).toBe(true);
+ fx.listLogos.mockResolvedValue(page2());
+ retryButton().click();
+ await flush();
+ expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'a' });
+ expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo D', 'Logo E']);
+ expect(alertText()).toBe('');
+ expect(retryButton().hidden).toBe(true);
+ });
+});
+
+describe('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 das Herz wählt das Logo nicht', async () => {
+ await mount();
+ heart('Logo B').click();
+ await flush();
+ expect(fx.onSelect).not.toHaveBeenCalled();
+ });
+
+ it('aria-pressed folgt select(): zuerst „Kein Logo“, dann das gewählte Logo, dann wieder keins', async () => {
+ await mount();
+ expect(pressedTiles()).toEqual(['Kein Logo']);
+ picker.select('b');
+ expect(pressedTiles()).toEqual(['Logo B']);
+ picker.select('c');
+ expect(pressedTiles()).toEqual(['Logo C']);
+ picker.select(null);
+ expect(pressedTiles()).toEqual(['Kein Logo']);
+ });
+
+ it('ein Klick markiert noch nichts: die Auswahl gehört dem Aufrufer (select)', async () => {
+ await mount();
+ tile('Logo B').click();
+ expect(pressedTiles()).toEqual(['Kein Logo']);
+ picker.select('b');
+ expect(pressedTiles()).toEqual(['Logo B']);
+ });
+
+ it('ein gewähltes Logo der Liste braucht kein getLogo; selected() liefert seine Zeile', async () => {
+ await mount();
+ expect(picker.selected()).toBeNull();
+ picker.select('b');
+ await flush();
+ expect(fx.getLogo).not.toHaveBeenCalled();
+ expect(picker.selected()).toEqual(logo('b'));
+ picker.select(null);
+ expect(picker.selected()).toBeNull();
+ });
+
+ it('ein Logo außerhalb der Liste wird per getLogo geholt und steht als zweite Kachel direkt nach „Kein Logo“', async () => {
+ fx.getLogo.mockResolvedValue(logo('x', { name: 'Fremdes Logo' }));
+ await mount();
+ picker.select('x');
+ expect(picker.selected()).toBeNull(); // noch unbekannt
+ await flush();
+ expect(fx.getLogo).toHaveBeenCalledExactlyOnceWith('x');
+ expect(names()).toEqual(['Kein Logo', 'Fremdes Logo', 'Logo A', 'Logo B', 'Logo C']);
+ expect(pressedTiles()).toEqual(['Fremdes Logo']);
+ expect(picker.selected()).toMatchObject({ id: 'x', name: 'Fremdes Logo' });
+ });
+
+ it('der angeheftete Eintrag hat wie jede Kachel ein Herz; angemeldet wird sein Zustand geladen', async () => {
+ fx.getLogo.mockResolvedValue(logo('x', { name: 'Fremdes Logo', likeCount: 5 }));
+ fx.myHearts.mockImplementation(async (_u, ids) => new Set(ids.filter((id) => id === 'x')));
+ await mount();
+ picker.select('x');
+ await flush();
+ expect(fx.myHearts).toHaveBeenLastCalledWith('u1', ['x']);
+ expect(heart('Fremdes Logo').textContent).toBe('5');
+ expect(heartsOn()).toEqual(['Fremdes Logo']);
+ });
+
+ it('solange die Liste noch lädt, wird nicht nachgefragt: steht das Logo auf der ersten Seite, gibt es kein getLogo und keinen Sprung', async () => {
+ const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
+ fx.listLogos.mockReturnValue(list.promise);
+ picker = createLogoPicker({ onSelect: fx.onSelect }, deps);
+ host.append(picker.el);
+ picker.select('b');
+ await flush();
+ expect(fx.getLogo).not.toHaveBeenCalled();
+ expect(names()).toEqual(['Kein Logo']);
+ list.resolve({ logos: [logo('a'), logo('b')], nextCursor: null });
+ await flush();
+ expect(fx.getLogo).not.toHaveBeenCalled();
+ expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B']);
+ expect(pressedTiles()).toEqual(['Logo B']);
+ expect(picker.selected()).toEqual(logo('b'));
+ });
+
+ it('steht das Logo nicht auf der ersten Seite, wird es nach dem Laden der Liste geholt und angeheftet', async () => {
+ fx.getLogo.mockResolvedValue(logo('x'));
+ const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
+ fx.listLogos.mockReturnValue(list.promise);
+ picker = createLogoPicker({ onSelect: fx.onSelect }, deps);
+ host.append(picker.el);
+ picker.select('x');
+ await flush();
+ expect(fx.getLogo).not.toHaveBeenCalled();
+ list.resolve({ logos: [logo('a')], nextCursor: null });
+ await flush();
+ expect(fx.getLogo).toHaveBeenCalledExactlyOnceWith('x');
+ expect(names()).toEqual(['Kein Logo', 'Logo X', 'Logo A']);
+ });
+
+ it('scheitert das Laden der Liste, wird das gewählte Logo trotzdem geholt und angeheftet', async () => {
+ fx.getLogo.mockResolvedValue(logo('x'));
+ fx.listLogos.mockRejectedValue(new Error('offline'));
+ picker = createLogoPicker({ onSelect: fx.onSelect }, deps);
+ host.append(picker.el);
+ picker.select('x');
+ await flush();
+ expect(names()).toEqual(['Kein Logo', 'Logo X']);
+ });
+
+ it('ein Logo, das es nicht mehr gibt (getLogo → null): keine Kachel, nichts gewählt, selected() ist null, kein Fehler', async () => {
+ await mount();
+ picker.select('weg');
+ await flush();
+ expect(fx.getLogo).toHaveBeenCalledWith('weg');
+ expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
+ expect(pressedTiles()).toEqual([]); // auch „Kein Logo“ nicht: die Auswahl bleibt, bis jemand ein anderes wählt
+ expect(picker.selected()).toBeNull();
+ expect(alertText()).toBe('');
+ });
+
+ it('scheitert getLogo (Netz), bleibt die Oberfläche heil: keine Kachel, kein Fehlertext, kein Absturz', async () => {
+ fx.getLogo.mockRejectedValue(new Error('offline'));
+ await mount();
+ picker.select('x');
+ await flush();
+ expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
+ expect(picker.selected()).toBeNull();
+ expect(alertText()).toBe('');
+ });
+
+ it('dieselbe Auswahl noch einmal (der Editor ruft select bei jeder Änderung) fragt nicht erneut nach', async () => {
+ fx.getLogo.mockResolvedValue(logo('x'));
+ await mount();
+ picker.select('x');
+ picker.select('x');
+ await flush();
+ picker.select('x');
+ await flush();
+ expect(fx.getLogo).toHaveBeenCalledTimes(1);
+ expect(names().filter((n) => n === 'Logo X')).toHaveLength(1);
+ });
+
+ it('wechselt die Auswahl, fällt der angeheftete Eintrag weg; „Kein Logo“ räumt ihn ebenfalls ab', async () => {
+ fx.getLogo.mockResolvedValue(logo('x'));
+ await mount();
+ picker.select('x');
+ await flush();
+ expect(names()).toContain('Logo X');
+ picker.select('b');
+ expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
+ picker.select('x'); // schon bekannt: sofort wieder da, ohne neue Anfrage
+ expect(names()).toEqual(['Kein Logo', 'Logo X', 'Logo A', 'Logo B', 'Logo C']);
+ expect(fx.getLogo).toHaveBeenCalledTimes(1);
+ picker.select(null);
+ expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
+ });
+
+ it('eine späte Antwort von getLogo für eine inzwischen verlassene Auswahl wird verworfen', async () => {
+ await mount();
+ const slow = deferred();
+ 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('');
+ });
+
+ 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 im Statusbereich (role="alert")', 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(alertText()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
+ });
+
+ 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(alertText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
+ expect(heartsOn()).toEqual([]);
+ heart('Logo A').click(); // die Anfrage ist zu Ende: der Knopf ist wieder frei
+ expect(alertText()).toBe('');
+ 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();
+ 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('');
+ 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>();
+ 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>();
+ fx.myHearts.mockReturnValue(hearts.promise);
+ await mount();
+ user = null; // abgemeldet, bevor die Antwort kommt
+ emitAuth();
+ hearts.resolve(new Set(['a']));
+ await flush();
+ expect(heartsOn()).toEqual([]);
+ });
+});
+
+describe('Leer und Ladefehler', () => {
+ it('leere Liste: „Noch keine Community-Logos – lade das erste hoch.“ (erst nach dem Laden; „Kein Logo“ bleibt)', async () => {
+ const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
+ fx.listLogos.mockReturnValue(list.promise);
+ picker = createLogoPicker({ onSelect: fx.onSelect }, deps);
+ host.append(picker.el);
+ await flush();
+ expect(emptyText()).toBe('');
+ list.resolve({ logos: [], nextCursor: null });
+ await flush();
+ expect(emptyText()).toBe('Noch keine Community-Logos – lade das erste hoch.');
+ expect(names()).toEqual(['Kein Logo']);
+ });
+
+ it('der Leer-Text steht nicht, wenn es Logos gibt oder das Laden scheiterte', async () => {
+ await mount();
+ expect(emptyText()).toBe('');
+ picker.dispose();
+ fx.listLogos.mockRejectedValue(new Error('offline'));
+ await mount();
+ expect(emptyText()).toBe('');
+ });
+
+ it('ein leerer Admin-Filter sagt das, statt zum Hochladen aufzufordern', async () => {
+ admin = true;
+ await mount();
+ fx.listLogos.mockResolvedValue({ logos: [], nextCursor: null });
+ sortButton('Gemeldet').click();
+ await flush();
+ expect(emptyText()).toBe('Keine Logos in dieser Ansicht.');
+ sortButton('Beliebt').click();
+ await flush();
+ expect(emptyText()).toBe('Noch keine Community-Logos – lade das erste hoch.');
+ });
+
+ it('Ladefehler: „Die Logos konnten nicht geladen werden.“ mit „Nochmal versuchen“, das neu lädt', async () => {
+ fx.listLogos.mockRejectedValueOnce(new Error('offline'));
+ await mount();
+ expect(alertText()).toBe('Die Logos konnten nicht geladen werden.');
+ expect(retryButton().hidden).toBe(false);
+ expect(retryButton().textContent).toBe('Nochmal versuchen');
+ expect(names()).toEqual(['Kein Logo']);
+ retryButton().click();
+ await flush();
+ expect(fx.listLogos).toHaveBeenCalledTimes(2);
+ expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' });
+ expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
+ expect(alertText()).toBe('');
+ expect(retryButton().hidden).toBe(true);
+ });
+
+ it('der Statusbereich ist immer da (leer: kein Text), ohne Fehler ist „Nochmal versuchen“ verborgen', async () => {
+ await mount();
+ expect(alertText()).toBe('');
+ expect(retryButton().hidden).toBe(true);
+ });
+
+ it('Englisch: Leer-Text, Ladefehler und „Nochmal versuchen“', async () => {
+ setLang('en', { persist: false });
+ fx.listLogos.mockResolvedValueOnce({ logos: [], nextCursor: null });
+ await mount();
+ expect(emptyText()).toBe('No community logos yet – upload the first one.');
+ picker.dispose();
+ fx.listLogos.mockRejectedValueOnce(new Error('offline'));
+ await mount();
+ expect(alertText()).toBe('The logos couldn’t be loaded.');
+ expect(retryButton().textContent).toBe('Try again');
+ });
+});
+
+describe('Hochladen', () => {
+ const fresh = () => logo('n', { name: 'Neues Logo', ownerId: 'u1' });
+
+ it('angemeldet: der Dialog öffnet ohne Login; das Ergebnis steht als erste Logo-Kachel, rememberLogo und onSelect(id) laufen', async () => {
+ fx.uploadDialog.mockResolvedValue(fresh());
+ await mount();
+ uploadButton().click();
+ await flush();
+ expect(fx.requireLogin).not.toHaveBeenCalled();
+ expect(fx.uploadDialog).toHaveBeenCalledTimes(1);
+ expect(names()).toEqual(['Kein Logo', 'Neues Logo', 'Logo A', 'Logo B', 'Logo C']);
+ expect(mocks.rememberLogo).toHaveBeenCalledExactlyOnceWith(fresh());
+ expect(fx.onSelect).toHaveBeenCalledExactlyOnceWith('n');
+ });
+
+ it('das neue Logo ist ein Logo mit Herz-Knopf (Zahl 0), und die Auswahl durch den Aufrufer markiert es ohne getLogo', async () => {
+ fx.uploadDialog.mockResolvedValue(fresh());
+ await mount();
+ uploadButton().click();
+ await flush();
+ expect(heart('Neues Logo').textContent).toBe('0');
+ picker.select('n');
+ await flush();
+ expect(pressedTiles()).toEqual(['Neues Logo']);
+ expect(fx.getLogo).not.toHaveBeenCalled();
+ expect(picker.selected()).toEqual(fresh());
+ });
+
+ it('Gast: zuerst requireLogin, dann der Dialog', async () => {
+ user = null;
+ const order: string[] = [];
+ fx.requireLogin.mockImplementation(async () => {
+ order.push('login');
+ return true;
+ });
+ fx.uploadDialog.mockImplementation(async () => {
+ order.push('dialog');
+ return null;
+ });
+ await mount();
+ uploadButton().click();
+ await flush();
+ expect(order).toEqual(['login', 'dialog']);
+ });
+
+ it('Gast bricht den Login ab: kein Dialog, nichts ändert sich, kein Fehlertext', async () => {
+ user = null;
+ fx.requireLogin.mockResolvedValue(false);
+ await mount();
+ uploadButton().click();
+ await flush();
+ expect(fx.uploadDialog).not.toHaveBeenCalled();
+ expect(fx.onSelect).not.toHaveBeenCalled();
+ expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
+ expect(alertText()).toBe('');
+ });
+
+ it('Dialog abgebrochen (null): keine Kachel, keine Auswahl, kein rememberLogo; der Knopf geht danach wieder', async () => {
+ await mount();
+ uploadButton().click();
+ await flush();
+ expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
+ expect(fx.onSelect).not.toHaveBeenCalled();
+ expect(mocks.rememberLogo).not.toHaveBeenCalled();
+ uploadButton().click();
+ await flush();
+ expect(fx.uploadDialog).toHaveBeenCalledTimes(2);
+ });
+
+ it('ein zweiter Klick, solange der Dialog offen ist, öffnet keinen zweiten', async () => {
+ fx.uploadDialog.mockReturnValue(new Promise(() => {}));
+ await mount();
+ uploadButton().click();
+ uploadButton().click();
+ await flush();
+ expect(fx.uploadDialog).toHaveBeenCalledTimes(1);
+ });
+
+ it('der Dialog bekommt ein Signal, das beim Aufräumen der Seite ausgelöst wird; ein später Treffer wird verworfen', async () => {
+ const result = deferred();
+ 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 im Statusbereich', async () => {
+ fx.uploadDialog.mockRejectedValue(new Error('kein