fix(editor): Vorschau und Miniaturen laden ein ausgeblendetes, eingeblendetes oder gelöschtes Logo neu

Blendete ein Admin das gewählte Logo im Picker aus (oder ein), forgetLogo leerte zwar den Zwischenspeicher, aber der Editor behielt seine schon
geladenen Logo-Bilder: Vorschau und Miniaturen zeigten weiter den alten Stand, bis sich Logo, Details oder Marke änderten.

- logo-picker.ts: neuer Rückruf `onLogoChanged(id)` (Typ LogoPickerHandlers). Der Picker ruft ihn, wenn die Infozeile ein Logo aus- oder eingeblendet hat
  (nach patchRow, die Zeile ist dann schon aktuell) und nach dem Löschen (nach onSelect(null)).
- editor-page.ts: Ist das gemeldete Logo das gewählte, verwirft der Editor seinen Logo-Zustand samt Sperren (logos, pendingKey, failedKey), blendet den Hinweis
  aus und ruft ensureLogos neu: Vorschau und alle vier Miniaturen werden mit dem neuen Stand gezeichnet, der Hinweis „nicht mehr verfügbar“ folgt. Ein
  anderes oder kein gewähltes Logo löst nichts aus; ein noch laufender Ladevorgang wird überholt (Token).
- editor-page.ts, followDownload: Stand die Karte nach einem Ladefehler ohne Logo da (`failed`), kehrte der Download früh zurück, obwohl er die Logos
  geladen hatte: das PNG hatte das Logo, die Vorschau nicht. Jetzt übernimmt die Vorschau es (Hinweis passend, failedKey leer).
- editor-page.ts, doSave: forgetLogo(config.logo) vor dem Laden der Bilder, wie Download und Veröffentlichen; ein inzwischen ausgeblendetes Logo kommt nicht
  mehr ins Vorschaubild der gespeicherten Karte.
- editor-page.ts: Beim Ladefehler für eine neue Auswahl blieb der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ der früheren Auswahl neben dem neuen
  Fehler stehen; die Logos der früheren Auswahl und ihr Hinweis fallen jetzt sofort weg (andere Hinweise bleiben).
- Tests: Picker (Rückruf nach Aus-/Einblenden und Löschen mit Reihenfolge, nicht bei Fehlern, Herz, Melden, nach dem Aufräumen) und Editor (Aus- und Einblenden,
  anderes oder kein Logo, überholter Ladevorgang, nach Ladefehler, mit dem echten Zwischenspeicher; Download-Übernahme, Speichern, alter Hinweis neben Fehler).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 05:49:04 +00:00
1 parent 0e4759a05a
commit e0f11f905e
4 files changed
+392 -12

No files matched your search

+247 -1
View File
@@ -85,7 +85,7 @@ let store: EditorStore;
let unmount: (() => void) | null; let unmount: (() => void) | null;
/** Der Attrappen-Picker und die Funktionen, die der Editor ihm mitgegeben hat. */ /** Der Attrappen-Picker und die Funktionen, die der Editor ihm mitgegeben hat. */
let picker: { el: HTMLElement; select: Mock; selected: Mock; dispose: Mock }; 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(() => { beforeEach(() => {
for (const fn of Object.values(mocks)) if (typeof fn === 'function') fn.mockReset(); 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); 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 () => { 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); const attempts = flaky(Infinity);
withImage(); withImage();
@@ -1297,6 +1350,37 @@ describe('Community-Logo auf der Karte', () => {
await vi.waitFor(() => expect(errorText()).toBe('')); 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 () => { it('ein anderer Fehler im Feld bleibt stehen, auch wenn danach ein Logo lädt', async () => {
mocks.loadCardAssets.mockImplementation(failA); mocks.loadCardAssets.mockImplementation(failA);
store.set({ config: withLogo('A') }); 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)', () => { describe('Download und der Zwischenspeicher der Logos (ein ausgeblendetes Logo darf nicht ins PNG)', () => {
const row = (patch: Record<string, unknown> = {}) => ({ const row = (patch: Record<string, unknown> = {}) => ({
id: 'zw3', id: 'zw3',
@@ -1413,6 +1619,46 @@ describe('Community-Logo auf der Karte', () => {
expect(store.get().config.logo).toBe('zw3'); // die Auswahl bleibt 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 () => { 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' })); mocks.exportCard.mockResolvedValue(new Blob([new Uint8Array(4_000_001)], { type: 'image/png' }));
withImage(); withImage();
+27 -3
View File
@@ -80,6 +80,17 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
if (logo === store.get().config.logo) failedKey = ''; if (logo === store.get().config.logo) failedKey = '';
patchConfig({ logo }); 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); 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 * 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 { function followDownload(config: CardConfig, assets: LoadedAssets): void {
if (!logos || logos.key !== logoKey(config) || logos.missing === assets.logoMissing) return; if (!logos || logos.key !== logoKey(config)) return;
logos = { ...logos, logo: assets.logo, missing: assets.logoMissing }; // 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(); showWarning();
schedule(); schedule();
} }
@@ -231,6 +247,9 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
async function doSave(): Promise<void> { async function doSave(): Promise<void> {
const { config, image, imageBlob, imageChanged, cardName, saved } = clickSnapshot ?? takeSnapshot(); const { config, image, imageBlob, imageChanged, cardName, saved } = clickSnapshot ?? takeSnapshot();
if (!image || !imageBlob) throw new Error(NO_IMAGE); 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 assets = await loadCardAssets(config, image);
const previewBlob = await exportCard(config, assets, { const previewBlob = await exportCard(config, assets, {
width: PREVIEW_WIDTH, width: PREVIEW_WIDTH,
@@ -450,6 +469,11 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
if (disposed || token !== logoToken) return; if (disposed || token !== logoToken) return;
pendingKey = ''; pendingKey = '';
failedKey = key; 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)); preview.setError(t(LOGO_ERROR));
logoErrorShown = true; logoErrorShown = true;
drawWithoutLogo(config, key, token); drawWithoutLogo(config, key, token);
+104 -7
View File
@@ -80,6 +80,7 @@ const fx = {
confirm: vi.fn<LogoPickerDeps['confirm']>(), confirm: vi.fn<LogoPickerDeps['confirm']>(),
reportDialog: vi.fn<LogoPickerDeps['reportDialog']>(), reportDialog: vi.fn<LogoPickerDeps['reportDialog']>(),
onSelect: vi.fn<(id: string | null) => void>(), onSelect: vi.fn<(id: string | null) => void>(),
onLogoChanged: vi.fn<(id: string) => void>(),
}; };
const deps: LogoPickerDeps = { const deps: LogoPickerDeps = {
@@ -184,7 +185,7 @@ afterEach(() => {
}); });
async function mount(): Promise<LogoPicker> { async function mount(): Promise<LogoPicker> {
picker = createLogoPicker({ onSelect: fx.onSelect }, deps); picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
host.append(picker.el); host.append(picker.el);
await flush(); await flush();
return picker; 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 () => { 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 }>(); const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(list.promise); fx.listLogos.mockReturnValue(list.promise);
picker = createLogoPicker({ onSelect: fx.onSelect }, deps); picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
host.append(picker.el); host.append(picker.el);
picker.select('b'); picker.select('b');
await flush(); await flush();
@@ -802,7 +803,7 @@ describe('Auswahl', () => {
fx.getLogo.mockResolvedValue(logo('x')); fx.getLogo.mockResolvedValue(logo('x'));
const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(list.promise); fx.listLogos.mockReturnValue(list.promise);
picker = createLogoPicker({ onSelect: fx.onSelect }, deps); picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
host.append(picker.el); host.append(picker.el);
picker.select('x'); picker.select('x');
await flush(); await flush();
@@ -816,7 +817,7 @@ describe('Auswahl', () => {
it('scheitert das Laden der Liste, wird das gewählte Logo trotzdem geholt und angeheftet', async () => { it('scheitert das Laden der Liste, wird das gewählte Logo trotzdem geholt und angeheftet', async () => {
fx.getLogo.mockResolvedValue(logo('x')); fx.getLogo.mockResolvedValue(logo('x'));
fx.listLogos.mockRejectedValue(new Error('offline')); 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); host.append(picker.el);
picker.select('x'); picker.select('x');
await flush(); 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 () => { 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 }>(); const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(list.promise); fx.listLogos.mockReturnValue(list.promise);
picker = createLogoPicker({ onSelect: fx.onSelect }, deps); picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
host.append(picker.el); host.append(picker.el);
await flush(); await flush();
expect(emptyText()).toBe(''); expect(emptyText()).toBe('');
@@ -1372,7 +1373,7 @@ describe('dispose', () => {
it('Antworten, die nach dem Aufräumen eintreffen, ändern nichts und werfen nichts', async () => { it('Antworten, die nach dem Aufräumen eintreffen, ändern nichts und werfen nichts', async () => {
const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(list.promise); fx.listLogos.mockReturnValue(list.promise);
picker = createLogoPicker({ onSelect: fx.onSelect }, deps); picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
host.append(picker.el); host.append(picker.el);
picker.select('x'); picker.select('x');
picker.dispose(); 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 () => { 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 }>(); const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
fx.listLogos.mockReturnValue(list.promise); fx.listLogos.mockReturnValue(list.promise);
picker = createLogoPicker({ onSelect: fx.onSelect }, deps); picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
host.append(picker.el); host.append(picker.el);
picker.select('b'); picker.select('b');
await flush(); 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 }); expect(picker.selected()).toMatchObject({ id: 'b', hidden: true, likeCount: 9 });
}); });
}); });
describe('Der Editor erfährt, dass sich ein Logo geändert hat (onLogoChanged)', () => {
beforeEach(() => {
admin = true;
fx.listLogos.mockResolvedValue({
logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c', { hidden: true, reportCount: 3 })],
nextCursor: null,
});
});
it('„Ausblenden“ des gewählten Logos: onLogoChanged(id), erst nachdem die Zeile des Pickers hidden ist', async () => {
let hiddenWhenTold: boolean | undefined;
fx.onLogoChanged.mockImplementation(() => {
hiddenWhenTold = picker.selected()?.hidden;
});
await mount();
picker.select('a');
infoButton('Ausblenden').click();
await flush();
expect(fx.onLogoChanged).toHaveBeenCalledExactlyOnceWith('a');
expect(hiddenWhenTold).toBe(true);
expect(mocks.forgetLogo).toHaveBeenCalledWith('a'); // der Zwischenspeicher ist vorher geleert
});
it('„Einblenden“: ebenso, die Zeile ist dann sichtbar', async () => {
let hiddenWhenTold: boolean | undefined;
fx.onLogoChanged.mockImplementation(() => {
hiddenWhenTold = picker.selected()?.hidden;
});
await mount();
picker.select('c');
infoButton('Einblenden').click();
await flush();
expect(fx.onLogoChanged).toHaveBeenCalledExactlyOnceWith('c');
expect(hiddenWhenTold).toBe(false);
});
it('„Löschen“ des gewählten Logos: onLogoChanged(id) nach onSelect(null), der Editor hat die Auswahl dann schon aufgehoben', async () => {
const order: string[] = [];
fx.onSelect.mockImplementation((id) => void order.push(`select:${id}`));
fx.onLogoChanged.mockImplementation((id) => void order.push(`changed:${id}`));
await mount();
picker.select('b');
infoButton('Löschen').click();
await flush();
expect(order).toEqual(['select:null', 'changed:b']);
});
it('„Löschen“ eines Logos, das nicht gewählt war (die Auswahl wechselte währenddessen): onLogoChanged meldet das gelöschte Logo, onSelect schweigt', async () => {
const request = deferred<void>();
fx.deleteLogo.mockReturnValue(request.promise);
await mount();
picker.select('b');
infoButton('Löschen').click();
await flush();
picker.select('a');
request.resolve();
await flush();
expect(fx.onLogoChanged).toHaveBeenCalledExactlyOnceWith('b');
expect(fx.onSelect).not.toHaveBeenCalled();
});
it('scheitert die Aktion, meldet der Picker nichts', async () => {
fx.setLogoVisibility.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
fx.deleteLogo.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
await mount();
picker.select('b');
infoButton('Ausblenden').click();
await flush();
infoButton('Löschen').click();
await flush();
expect(fx.onLogoChanged).not.toHaveBeenCalled();
});
it('Herz, Melden und Auswahl melden nichts', async () => {
await mount();
picker.select('a');
heart('Logo A').click();
infoButton('Melden').click();
await flush();
expect(fx.onLogoChanged).not.toHaveBeenCalled();
});
it('nach dem Aufräumen der Seite meldet der Picker nichts mehr', async () => {
const request = deferred<void>();
fx.setLogoVisibility.mockReturnValue(request.promise);
await mount();
picker.select('a');
infoButton('Ausblenden').click();
await flush();
picker.dispose();
request.resolve();
await flush();
expect(fx.onLogoChanged).not.toHaveBeenCalled();
});
});
+14 -1
View File
@@ -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(); const abort = new AbortController();
let disposed = false; let disposed = false;
@@ -154,6 +165,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
patchRow(id, patch) { patchRow(id, patch) {
const row = known.get(id); const row = known.get(id);
if (row) setRow({ ...row, ...patch }); if (row) setRow({ ...row, ...patch });
handlers.onLogoChanged(id); // ausgeblendet oder eingeblendet: die Karte im Editor zeigt es sonst weiter wie vorher
}, },
removeLogo, 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 // 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. // nicht verloren gehen. Die Ansicht beginnt wieder bei Seite 1.
if (wasCursor && nextCursor === null) void load('reset'); 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). */ /** Das gewählte Logo steht direkt nach „Kein Logo“, solange es nicht in der geladenen Liste ist (und seine Zeile bekannt ist). */