diff --git a/web/src/editor/editor-page.test.ts b/web/src/editor/editor-page.test.ts index 0f0977d..c44c2a2 100644 --- a/web/src/editor/editor-page.test.ts +++ b/web/src/editor/editor-page.test.ts @@ -85,7 +85,7 @@ 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 }; +let pickerHandlers: { onSelect(id: string | null): void; onLogoChanged(id: string): void }; beforeEach(() => { for (const fn of Object.values(mocks)) if (typeof fn === 'function') fn.mockReset(); @@ -1207,6 +1207,59 @@ describe('Community-Logo auf der Karte', () => { expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'A')).toBe(true); }); + it('ein Download, der die Logos doch lädt, übernimmt sie: Vorschau und Miniaturen zeigen das Logo jetzt wie das PNG, der Fehler ist weg', 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(); + downloadButton().click(); + await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled()); + expect(attempts()).toBe(2); // der Download lädt selbst + expect(mocks.exportCard.mock.calls[0][1].logo).toBe(FOUND.logo); // das PNG hat das Logo + await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo)); // die Vorschau folgt + expect(new Set(mocks.drawCard.mock.calls.map(([canvas]) => canvas)).size).toBe(5); + expect(errorText()).toBe(''); + expect(warningText()).toBe(''); + }); + + it('nach der Übernahme gilt die Karte als geladen: weitere Änderungen laden die Logos nicht noch einmal', async () => { + const attempts = flaky(1); + withImage(); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR)); + downloadButton().click(); + await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo)); + store.set({ cardName: 'Andere Karte' }); + pickerHandlers.onSelect('A'); // ein Klick auf das gewählte Logo + await new Promise((r) => setTimeout(r, 10)); + expect(attempts()).toBe(2); + expect(lastLogo()).toBe(FOUND.logo); + }); + + it('fehlt das Logo inzwischen (der Download meldet es als nicht verfügbar), übernimmt die Vorschau auch das: Hinweis statt Fehler, Karte ohne Logo', async () => { + let attempts = 0; + mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null }) => { + if (config.logo === 'A' && ++attempts === 1) throw new TypeError('Failed to fetch'); + return config.logo === 'A' ? { ...MISSING, brand: BRAND } : { image: null, logo: null, brand: BRAND, logoMissing: false }; + }); + withImage(); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR)); + await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); + downloadButton().click(); + await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled()); + await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE)); + expect(errorText()).toBe(''); + expect(lastLogo()).toBeNull(); + }); + 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(); @@ -1297,6 +1350,37 @@ describe('Community-Logo auf der Karte', () => { await vi.waitFor(() => expect(errorText()).toBe('')); }); + it('der Hinweis „Logo nicht mehr verfügbar“ einer früheren Auswahl bleibt nicht neben einem neuen Ladefehler stehen', async () => { + mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null }) => { + if (config.logo === 'A') throw new TypeError('Failed to fetch'); + return config.logo === 'gone' ? MISSING : { image: null, logo: null, brand: {}, logoMissing: false }; + }); + withImage(); + store.set({ config: withLogo('gone') }); + mount(); + await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE)); + store.set({ config: withLogo('A') }); + await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR)); + expect(warningText()).toBe(''); // sofort: der Hinweis galt dem Logo davor + await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); // die Karte steht ohne Logo da + expect(warningText()).toBe(''); + }); + + it('andere Hinweise (etwa die fehlende Bilddatei) überleben das: nur der Logo-Hinweis geht', async () => { + history.replaceState(null, '', '/gestalten?karte=k1'); + mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), logo: 'gone' } })); + mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' })); + mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null }) => { + if (config.logo === 'A') throw new TypeError('Failed to fetch'); + return config.logo === 'gone' ? MISSING : { image: null, logo: null, brand: {}, logoMissing: false }; + }); + mount(); + await vi.waitFor(() => expect(warningText()).toBe(`Das Bild dieser Karte fehlt. ${LOGO_GONE}`)); + store.set({ config: withLogo('A') }); + await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR)); + expect(warningText()).toBe('Das Bild dieser Karte fehlt.'); + }); + it('ein anderer Fehler im Feld bleibt stehen, auch wenn danach ein Logo lädt', async () => { mocks.loadCardAssets.mockImplementation(failA); store.set({ config: withLogo('A') }); @@ -1362,6 +1446,128 @@ describe('Community-Logo auf der Karte', () => { }); }); + describe('Das gewählte Logo wird ausgeblendet, eingeblendet oder gelöscht: Vorschau und Miniaturen laden es neu (onLogoChanged)', () => { + const lastLogo = () => mocks.drawCard.mock.calls.at(-1)?.[2].logo; + /** Ein Lader, dessen Antwort für das Logo „A“ sich ändern lässt: ausgeblendet = nicht verfügbar. */ + let hiddenNow: boolean; + beforeEach(() => { + hiddenNow = false; + mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null }) => (config.logo === 'A' && hiddenNow ? MISSING : FOUND)); + }); + + it('Ausblenden: das Logo wird neu geladen, Vorschau und alle vier Miniaturen werden ohne es gezeichnet, der Hinweis erscheint', async () => { + withImage(); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo)); + expect(warningText()).toBe(''); + + hiddenNow = true; + mocks.drawCard.mockClear(); + pickerHandlers.onLogoChanged('A'); + await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE)); + await vi.waitFor(() => 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'); // die Auswahl bleibt + expect(assets.logo).toBeNull(); + } + expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2); + expect(errorText()).toBe(''); + }); + + it('Einblenden: das Logo ist wieder da, der Hinweis geht, die Karte wird mit Logo gezeichnet', async () => { + hiddenNow = true; + withImage(); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE)); + await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); + expect(lastLogo()).toBeNull(); + + hiddenNow = false; + mocks.drawCard.mockClear(); + pickerHandlers.onLogoChanged('A'); + await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo)); + expect(warningText()).toBe(''); + expect(new Set(mocks.drawCard.mock.calls.map(([canvas]) => canvas)).size).toBe(5); + }); + + it('der Hinweis zum Logo geht gleich, der Größenhinweis des Downloads bleibt', async () => { + hiddenNow = true; + mocks.exportCard.mockResolvedValue(new Blob([new Uint8Array(4_000_001)], { type: 'image/png' })); + withImage(); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE)); + downloadButton().click(); + await vi.waitFor(() => expect(warningText()).toBe(`${PNG_SIZE_WARNING} ${LOGO_GONE}`)); + hiddenNow = false; + pickerHandlers.onLogoChanged('A'); + await vi.waitFor(() => expect(warningText()).toBe(PNG_SIZE_WARNING)); + }); + + it('ein anderes Logo als das gewählte: nichts wird neu geladen oder gezeichnet', async () => { + withImage(); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo)); + mocks.drawCard.mockClear(); + pickerHandlers.onLogoChanged('B'); + await new Promise((r) => setTimeout(r, 10)); + expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1); + expect(mocks.drawCard).not.toHaveBeenCalled(); + }); + + it('ohne gewähltes Logo (auch nach dem Löschen, wenn die Auswahl schon aufgehoben ist): nichts wird neu geladen', async () => { + withImage(); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo)); + pickerHandlers.onSelect(null); // Löschen: der Picker hebt die Auswahl auf … + await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2)); + pickerHandlers.onLogoChanged('A'); // … und meldet danach das gelöschte Logo + await new Promise((r) => setTimeout(r, 10)); + expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2); + }); + + it('ein noch laufender Ladevorgang wird überholt: seine späte Antwort ändert nichts mehr', async () => { + let finishFirst!: (v: typeof FOUND) => void; + mocks.loadCardAssets.mockImplementationOnce(() => new Promise((resolve) => (finishFirst = resolve as (v: typeof FOUND) => void))); + withImage(); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1)); + hiddenNow = true; + pickerHandlers.onLogoChanged('A'); // das Logo wurde ausgeblendet, während die Vorschau noch lädt + await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE)); + finishFirst(FOUND); // die alte Antwort mit Logo kommt zu spät + await new Promise((r) => setTimeout(r, 10)); + expect(warningText()).toBe(LOGO_GONE); + expect(lastLogo()).toBeNull(); + }); + + it('nach einem Ladefehler versucht die Meldung es noch einmal: gelingt es, verschwindet der Fehler', async () => { + let attempts = 0; + mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null }) => { + if (config.logo === 'A' && ++attempts === 1) throw new TypeError('Failed to fetch'); + return config.logo ? FOUND : { image: null, logo: null, brand: {}, logoMissing: false }; + }); + withImage(); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(errorText()).toBe('Die Logos konnten nicht geladen werden.')); + pickerHandlers.onLogoChanged('A'); + await vi.waitFor(() => expect(errorText()).toBe('')); + await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo)); + expect(attempts).toBe(2); + }); + + it('der Picker wird bei seiner Erzeugung mit beiden Rückrufen versorgt', () => { + mount(); + expect(Object.keys(pickerHandlers).sort()).toEqual(['onLogoChanged', 'onSelect']); + }); + }); + describe('Download und der Zwischenspeicher der Logos (ein ausgeblendetes Logo darf nicht ins PNG)', () => { const row = (patch: Record = {}) => ({ id: 'zw3', @@ -1413,6 +1619,46 @@ describe('Community-Logo auf der Karte', () => { expect(store.get().config.logo).toBe('zw3'); // die Auswahl bleibt }); + it('Ausblenden im Picker (der Zwischenspeicher ist geleert, der Editor wird gerufen): die Zeile wird frisch geholt, Vorschau und Hinweis folgen', async () => { + withImage(); + store.set({ config: withLogo('zw3') }); + mount(); + await vi.waitFor(drawnWithLogo); + mocks.getLogo.mockResolvedValue(row({ hidden: true })); + forgetLogo('zw3'); // das tut die Infozeile des Pickers vor dem Rückruf + mocks.drawCard.mockClear(); + pickerHandlers.onLogoChanged('zw3'); + await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE)); + await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); + expect(mocks.getLogo).toHaveBeenCalledTimes(2); + expect(lastDrawnLogo()).toBeNull(); + }); + + it('Speichern prüft Zeile und Bild des Logos frisch: ein inzwischen ausgeblendetes Logo kommt nicht ins Vorschaubild der Karte', async () => { + withImage(); + store.set({ config: withLogo('zw3') }); + mocks.save.mockResolvedValue(savedCard({ id: 'neu' })); + mount(); + await vi.waitFor(drawnWithLogo); + expect(mocks.getLogo).toHaveBeenCalledTimes(1); + + mocks.getLogo.mockResolvedValue(row({ hidden: true })); // jetzt wird es ausgeblendet + saveButton().click(); + await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled()); + expect(mocks.getLogo).toHaveBeenCalledTimes(2); + expect(mocks.exportCard.mock.calls[0][1].logo).toBeNull(); // das Vorschaubild: ohne Logo + expect(mocks.exportCard.mock.calls[0][2]).toMatchObject({ type: 'image/webp' }); + }); + + it('Speichern ohne gewähltes Logo holt nichts', async () => { + withImage(); + mocks.save.mockResolvedValue(savedCard({ id: 'neu' })); + mount(); + saveButton().click(); + await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled()); + expect(mocks.getLogo).not.toHaveBeenCalled(); + }); + it('der Hinweis steht zusammen mit dem Größenhinweis des Downloads', async () => { mocks.exportCard.mockResolvedValue(new Blob([new Uint8Array(4_000_001)], { type: 'image/png' })); withImage(); diff --git a/web/src/editor/editor-page.ts b/web/src/editor/editor-page.ts index e0862db..7a67e09 100644 --- a/web/src/editor/editor-page.ts +++ b/web/src/editor/editor-page.ts @@ -80,6 +80,17 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { if (logo === store.get().config.logo) failedKey = ''; patchConfig({ logo }); }, + // Das gewählte Logo wurde ausgeblendet, eingeblendet oder gelöscht (der Zwischenspeicher der Logos ist schon geleert): was der Editor + // von ihm gezeichnet hat, gilt nicht mehr. Es wird neu geladen, damit Vorschau, Miniaturen und Hinweis dem neuen Stand folgen. + onLogoChanged(id) { + const { config } = store.get(); + if (config.logo !== id) return; + logos = null; + pendingKey = ''; + failedKey = ''; + showWarning(); + ensureLogos(config); + }, }); options.logoSlot.append(logoPicker.el); @@ -195,11 +206,16 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { /** * Der Download hat Zeile und Bild des Logos frisch geholt (siehe download). Hat sich dabei geändert, ob es das Logo gibt (es wurde - * ausgeblendet oder gelöscht, oder ist wieder da), folgen Hinweis und Vorschau: das PNG und die Vorschau zeigen dasselbe. + * ausgeblendet oder gelöscht, oder ist wieder da), oder stand die Karte nach einem Ladefehler ohne Logo da, folgen Hinweis und + * Vorschau: das PNG und die Vorschau zeigen dasselbe. */ function followDownload(config: CardConfig, assets: LoadedAssets): void { - if (!logos || logos.key !== logoKey(config) || logos.missing === assets.logoMissing) return; - logos = { ...logos, logo: assets.logo, missing: assets.logoMissing }; + if (!logos || logos.key !== logoKey(config)) return; + // Nach einem Ladefehler steht die Karte ohne Logo da (`failed`): hat der Download die Logos doch geladen, übernimmt die Vorschau sie, + // damit sie dasselbe zeigt wie das PNG, und die Sperre dieses Schlüssels fällt weg. + if (logos.failed) failedKey = ''; + else if (logos.missing === assets.logoMissing) return; + logos = { logo: assets.logo, brand: assets.brand, missing: assets.logoMissing, key: logos.key }; showWarning(); schedule(); } @@ -231,6 +247,9 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { async function doSave(): Promise { const { config, image, imageBlob, imageChanged, cardName, saved } = clickSnapshot ?? takeSnapshot(); if (!image || !imageBlob) throw new Error(NO_IMAGE); + // Zeile und Bild des Logos neu vom Server prüfen (wie beim Download): ein inzwischen ausgeblendetes oder gelöschtes Logo käme sonst aus + // dem Zwischenspeicher ins Vorschaubild der gespeicherten Karte. + if (config.logo) forgetLogo(config.logo); const assets = await loadCardAssets(config, image); const previewBlob = await exportCard(config, assets, { width: PREVIEW_WIDTH, @@ -450,6 +469,11 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { if (disposed || token !== logoToken) return; pendingKey = ''; failedKey = key; + // Die Logos der früheren Auswahl gehören nicht zu diesem Zustand, auch nicht ihr Hinweis „nicht mehr verfügbar“. + if (logos?.key !== key) { + logos = null; + showWarning(); + } preview.setError(t(LOGO_ERROR)); logoErrorShown = true; drawWithoutLogo(config, key, token); diff --git a/web/src/logos/logo-picker.test.ts b/web/src/logos/logo-picker.test.ts index fc8865f..4dcc5cc 100644 --- a/web/src/logos/logo-picker.test.ts +++ b/web/src/logos/logo-picker.test.ts @@ -80,6 +80,7 @@ const fx = { confirm: vi.fn(), reportDialog: vi.fn(), onSelect: vi.fn<(id: string | null) => void>(), + onLogoChanged: vi.fn<(id: string) => void>(), }; const deps: LogoPickerDeps = { @@ -184,7 +185,7 @@ afterEach(() => { }); async function mount(): Promise { - picker = createLogoPicker({ onSelect: fx.onSelect }, deps); + picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps); host.append(picker.el); await flush(); return picker; @@ -784,7 +785,7 @@ describe('Auswahl', () => { 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); + picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps); host.append(picker.el); picker.select('b'); await flush(); @@ -802,7 +803,7 @@ describe('Auswahl', () => { fx.getLogo.mockResolvedValue(logo('x')); const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); fx.listLogos.mockReturnValue(list.promise); - picker = createLogoPicker({ onSelect: fx.onSelect }, deps); + picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps); host.append(picker.el); picker.select('x'); await flush(); @@ -816,7 +817,7 @@ describe('Auswahl', () => { 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); + picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps); host.append(picker.el); picker.select('x'); await flush(); @@ -1124,7 +1125,7 @@ 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); + picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps); host.append(picker.el); await flush(); expect(emptyText()).toBe(''); @@ -1372,7 +1373,7 @@ describe('dispose', () => { it('Antworten, die nach dem Aufräumen eintreffen, ändern nichts und werfen nichts', async () => { const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); fx.listLogos.mockReturnValue(list.promise); - picker = createLogoPicker({ onSelect: fx.onSelect }, deps); + picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps); host.append(picker.el); picker.select('x'); picker.dispose(); @@ -1507,7 +1508,7 @@ describe('Infozeile', () => { it('solange die Liste noch lädt und das Logo noch unbekannt ist, steht nichts da; danach die Zeile aus der Liste', async () => { const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); fx.listLogos.mockReturnValue(list.promise); - picker = createLogoPicker({ onSelect: fx.onSelect }, deps); + picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps); host.append(picker.el); picker.select('b'); await flush(); @@ -1909,3 +1910,99 @@ describe('Ausblenden und Einblenden (Admin, mit Raster und Auswahl)', () => { 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(); + 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(); + 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(); + }); +}); diff --git a/web/src/logos/logo-picker.ts b/web/src/logos/logo-picker.ts index b855910..7d5a35e 100644 --- a/web/src/logos/logo-picker.ts +++ b/web/src/logos/logo-picker.ts @@ -99,7 +99,18 @@ function defaultDeps(): LogoPickerDeps { }; } -export function createLogoPicker(handlers: { onSelect(id: string | null): void }, deps: LogoPickerDeps = defaultDeps()): LogoPicker { +/** Was der Picker dem Editor meldet. */ +export interface LogoPickerHandlers { + /** Ein Klick auf eine Kachel („Kein Logo“: null). Die Auswahl selbst gehört dem Editor. */ + onSelect(id: string | null): void; + /** + * Das Logo `id` wurde ausgeblendet, eingeblendet oder gelöscht (Infozeile): seine Zeile und sein Bild haben sich geändert. Hat der + * Editor es gerade gezeichnet, muss er es neu laden; der Zwischenspeicher der Logos ist dann schon geleert (forgetLogo). + */ + onLogoChanged(id: string): void; +} + +export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerDeps = defaultDeps()): LogoPicker { const abort = new AbortController(); let disposed = false; @@ -154,6 +165,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } patchRow(id, patch) { const row = known.get(id); if (row) setRow({ ...row, ...patch }); + handlers.onLogoChanged(id); // ausgeblendet oder eingeblendet: die Karte im Editor zeigt es sonst weiter wie vorher }, removeLogo, }, @@ -273,6 +285,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } // Der Cursor war das einzige geladene Logo: davor gibt es keins, an dem „Mehr laden“ ansetzen könnte, und die weiteren Seiten dürfen // nicht verloren gehen. Die Ansicht beginnt wieder bei Seite 1. if (wasCursor && nextCursor === null) void load('reset'); + handlers.onLogoChanged(id); // nach onSelect(null): der Editor zeichnet das Logo dann ohnehin nicht mehr } /** Das gewählte Logo steht direkt nach „Kein Logo“, solange es nicht in der geladenen Liste ist (und seine Zeile bekannt ist). */