From 202254c09ef07a4f7f9413792521b2d50f396240 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:12:11 +0000 Subject: [PATCH] =?UTF-8?q?fix(editor):=20Karte=20ohne=20Logo=20zeichnen,?= =?UTF-8?q?=20wenn=20ein=20Logo=20nicht=20l=C3=A4dt;=20erneute=20Wahl=20ve?= =?UTF-8?q?rsucht=20es=20noch=20einmal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schlussreview F1. Scheiterte das Laden der Bilder (Netz, Server), bricht draw() ab, solange logos.key nicht zur Auswahl passt: Vorschau und Miniaturen blieben leer, und ein erneuter Klick auf dieselbe Kachel änderte nichts, weil failedKey dem Schlüssel entsprach. - Nach einem echten Ladefehler lädt der Editor einmal die Marke allein (loadCardAssets mit logo: null) und zeichnet die Karte damit ohne Logo (`failed`); scheitert auch das, fehlt die Marke ebenfalls. LOGO_ERROR bleibt stehen, die Auswahl auch. Ein anderes Logo wird nie mit den Bildern der gescheiterten Auswahl gezeichnet (der Schlüssel entscheidet wie bisher). - Der Picker meldet jeden Klick, auch auf das schon gewählte Logo: ist es das Logo, dessen Laden scheiterte, hebt der Editor die Sperre (failedKey) auf und versucht es noch einmal. Ein geladenes Logo wird dabei nicht neu geladen, ein Fehlschlag wiederholt sich nicht im Kreis. - Tests: Karte ohne Logo nach dem Fehler (Vorschau und vier Miniaturen), Wiederholung per Klick (Erfolg und erneuter Fehlschlag, kein Loop), Marke lädt auch nicht, anderes Logo nach dem Fehler, Klick auf geladenes Logo lädt nichts neu; Picker meldet den Klick auf die gewählte Kachel. Zwei bestehende Tests angepasst: „nicht gezeichnet nach Fehler“ gilt nicht mehr, und die Zahl der loadCardAssets-Aufrufe zählt jetzt den zusätzlichen Lauf für die Marke nicht mehr mit. Co-Authored-By: Claude Opus 5.5 --- web/src/editor/editor-page.test.ts | 129 ++++++++++++++++++++++++++++- web/src/editor/editor-page.ts | 36 ++++++-- web/src/logos/logo-picker.test.ts | 9 ++ 3 files changed, 165 insertions(+), 9 deletions(-) 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();