diff --git a/web/src/editor/editor-page.test.ts b/web/src/editor/editor-page.test.ts index 5222f34..97b5795 100644 --- a/web/src/editor/editor-page.test.ts +++ b/web/src/editor/editor-page.test.ts @@ -1133,8 +1133,11 @@ describe('Community-Logo auf der Karte', () => { store.set({ config: withLogo('A') }); mount(); await vi.waitFor(() => expect(errorText()).toBe('Die Logos konnten nicht geladen werden.')); - expect(mocks.drawCard).not.toHaveBeenCalled(); + // Die Karte steht trotzdem da, nur ohne das Logo (Näheres siehe „Ladefehler eines Logos“ unten). + await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); + expect(mocks.drawCard.mock.calls.every(([, config, assets]) => config.logo === 'A' && assets.logo === null)).toBe(true); + mocks.drawCard.mockClear(); store.set({ config: withLogo('B') }); await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'B')).toBe(true); @@ -1146,6 +1149,127 @@ describe('Community-Logo auf der Karte', () => { expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'A')).toBe(true); }); + describe('Ladefehler eines Logos (Netz, Server): die Karte steht trotzdem da, eine erneute Wahl versucht es noch einmal', () => { + const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.'; + const BRAND = { tag: 'marke' }; + /** + * Wie der echte Lader: ohne gewähltes Logo kommt nur die Marke (sie ist Teil der Seite), mit Logo auch dieses. Das Logo „A“ + * scheitert bei den ersten `failures` Versuchen. Liefert die Zahl der bisherigen Versuche für „A“. + */ + function flaky(failures: number): () => number { + let attempts = 0; + mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null }) => { + if (config.logo === 'A' && ++attempts <= failures) throw new TypeError('Failed to fetch'); + return { image: null, logo: config.logo ? FOUND.logo : null, brand: BRAND, logoMissing: false }; + }); + return () => attempts; + } + /** Die Logos, mit denen zuletzt gezeichnet wurde (Vorschau und Miniaturen zeichnen alle gleich). */ + const lastLogo = () => mocks.drawCard.mock.calls.at(-1)?.[2].logo; + + it('Vorschau und Miniaturen werden ohne Logo gezeichnet (mit der Marke), der Fehler und die Auswahl bleiben', async () => { + flaky(Infinity); + withImage(); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR)); + await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); + // die Vorschau und die vier Vorlagen-Miniaturen, alle mit der gewählten Konfiguration, aber ohne Logo + expect(new Set(mocks.drawCard.mock.calls.map(([canvas]) => canvas)).size).toBe(5); + for (const [, config, assets] of mocks.drawCard.mock.calls) { + expect(config.logo).toBe('A'); + expect(assets).toMatchObject({ logo: null, brand: BRAND }); + } + expect(errorText()).toBe(LOGO_ERROR); + expect(warningText()).toBe(''); // das Logo gibt es ja, es lässt sich nur gerade nicht laden + expect(store.get().config.logo).toBe('A'); + }); + + it('ein Klick auf das schon gewählte Logo versucht es noch einmal: lädt es, wird es gezeichnet und der Fehler verschwindet', async () => { + const attempts = flaky(1); + withImage(); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR)); + await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); + expect(lastLogo()).toBeNull(); + + mocks.drawCard.mockClear(); + pickerHandlers.onSelect('A'); // der Klick auf die gewählte Kachel (der Picker meldet jeden Klick) + await vi.waitFor(() => expect(attempts()).toBe(2)); + await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo)); + expect(errorText()).toBe(''); + expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'A')).toBe(true); + }); + + it('scheitert auch die Wiederholung, bleibt es bei Fehler und Karte ohne Logo; es gibt keinen Wiederholungs-Loop, jede Wahl versucht es genau einmal', async () => { + const attempts = flaky(Infinity); + withImage(); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR)); + pickerHandlers.onSelect('A'); + await vi.waitFor(() => expect(attempts()).toBe(2)); + await new Promise((r) => setTimeout(r, 10)); + expect(attempts()).toBe(2); + expect(errorText()).toBe(LOGO_ERROR); + expect(lastLogo()).toBeNull(); + store.set({ cardName: 'Andere Karte' }); // eine andere Änderung versucht es nicht erneut + await new Promise((r) => setTimeout(r, 10)); + expect(attempts()).toBe(2); + pickerHandlers.onSelect('A'); + await vi.waitFor(() => expect(attempts()).toBe(3)); + }); + + it('lädt auch die Marke nicht, wird die Karte trotzdem gezeichnet (ohne Logo und Marke) und der Fehler steht da', async () => { + mocks.loadCardAssets.mockRejectedValue(new TypeError('Failed to fetch')); + withImage(); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR)); + await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); + for (const [, config, assets] of mocks.drawCard.mock.calls) { + expect(config.logo).toBe('A'); + expect(assets).toMatchObject({ logo: null, brand: null }); + } + expect(errorText()).toBe(LOGO_ERROR); + }); + + it('ein anderes Logo nach dem Fehler wird nie mit den Bildern der gescheiterten Auswahl gezeichnet, sondern erst mit den eigenen', async () => { + flaky(Infinity); + withImage(); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR)); + await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); + mocks.drawCard.mockClear(); + store.set({ config: withLogo('B') }); + await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo)); + expect(mocks.drawCard.mock.calls.every(([, config, assets]) => config.logo === 'B' && assets.logo === FOUND.logo)).toBe(true); + }); + + it('ein Klick auf das gewählte Logo, das geladen ist, lädt nichts neu', async () => { + mocks.loadCardAssets.mockResolvedValue(FOUND); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1)); + await new Promise((r) => setTimeout(r, 0)); + pickerHandlers.onSelect('A'); + await new Promise((r) => setTimeout(r, 10)); + expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1); + }); + + it('ein Klick auf ein anderes Logo ändert nichts an der Sperre des gescheiterten: es lädt das neue, ohne das alte zu wiederholen', async () => { + const attempts = flaky(Infinity); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR)); + pickerHandlers.onSelect('B'); + await vi.waitFor(() => expect(errorText()).toBe('')); + expect(attempts()).toBe(1); + }); + }); + describe('Fehler „Die Logos konnten nicht geladen werden.“ und das gemeinsame Fehlerfeld', () => { const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.'; /** Logo „A“ lässt sich nicht laden, alle anderen schon. */ @@ -1179,7 +1303,8 @@ describe('Community-Logo auf der Karte', () => { expect(errorText()).toBe('Bitte zuerst ein Bild wählen.'); // … und gehört nicht dem Logo-Zustand: ein erfolgreicher Ladevorgang löscht ihn nicht. store.set({ config: withLogo('B') }); - await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2)); + // (Nach dem Fehler für „A“ lädt der Editor einmal die Marke allein, um die Karte ohne Logo zu zeichnen: darum nicht nach der Zahl der Aufrufe fragen.) + await vi.waitFor(() => expect(mocks.loadCardAssets.mock.calls.some(([config]) => config.logo === 'B')).toBe(true)); await new Promise((r) => setTimeout(r, 10)); expect(errorText()).toBe('Bitte zuerst ein Bild wählen.'); }); diff --git a/web/src/editor/editor-page.ts b/web/src/editor/editor-page.ts index 04f8bb3..22644c9 100644 --- a/web/src/editor/editor-page.ts +++ b/web/src/editor/editor-page.ts @@ -73,8 +73,14 @@ 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 }) }); + // „Logo unten rechts“: die Wahl setzt config.logo, markiert wird sie über sync (picker.select). Der Picker meldet auch den Klick auf das + // schon gewählte Logo: scheiterte dessen Laden, hebt das die Sperre auf und ensureLogos versucht es noch einmal. + const logoPicker = createLogoPicker({ + onSelect(logo) { + if (logo === store.get().config.logo) failedKey = ''; + patchConfig({ logo }); + }, + }); options.logoSlot.append(logoPicker.el); let exporting = false; @@ -402,16 +408,17 @@ 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 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; + // 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; die Karte wird auch dann gezeichnet, ebenfalls ohne Logo (`failed`, siehe + // drawWithoutLogo), damit Vorschau und Miniaturen nicht leer bleiben. Derselbe Schlüssel wird nicht im Kreis neu versucht: erst eine + // andere Wahl (Logo, Details, Marke) oder ein erneuter Klick auf das gewählte Logo (siehe onSelect) hebt die Sperre auf. + let logos: (Logos & { key: string; failed?: boolean }) | null = null; let pendingKey = ''; let failedKey = ''; let logoToken = 0; function ensureLogos(config: CardConfig): void { const key = logoKey(config); - if (logos?.key === key || pendingKey === key || failedKey === key) return; + if ((logos?.key === key && !logos.failed) || pendingKey === key || failedKey === key) return; failedKey = ''; pendingKey = key; const token = ++logoToken; @@ -430,10 +437,25 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { failedKey = key; preview.setError(t(LOGO_ERROR)); logoErrorShown = true; + drawWithoutLogo(config, key, token); }, ); } + /** + * Nach einem Ladefehler: die Karte ohne Logo zeichnen. Geladen wird nur die Marke (sie gehört zur Seite und lädt fast immer); + * scheitert auch das, fehlt sie ebenfalls. Gilt nur, solange noch dieser Ladevorgang der jüngste ist. + */ + function drawWithoutLogo(config: CardConfig, key: string, token: number): void { + if (logos?.key === key) return; // eine Wiederholung für denselben Schlüssel scheiterte erneut: die Karte steht schon so da + const adopt = (brand: Logos['brand']) => { + if (disposed || token !== logoToken) return; + logos = { logo: null, brand, missing: false, key, failed: true }; + schedule(); + }; + loadCardAssets({ ...config, logo: null }, null).then((assets) => adopt(assets.brand), () => adopt(null)); + } + // Zeichnen: höchstens einmal je Animationsbild, egal wie oft sich der Zustand ändert. let frame = 0; function schedule(): void { diff --git a/web/src/logos/logo-picker.test.ts b/web/src/logos/logo-picker.test.ts index a52b3d6..fe17911 100644 --- a/web/src/logos/logo-picker.test.ts +++ b/web/src/logos/logo-picker.test.ts @@ -472,6 +472,15 @@ describe('Auswahl', () => { 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();