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

+104 -7
View File
@@ -80,6 +80,7 @@ const fx = {
confirm: vi.fn<LogoPickerDeps['confirm']>(),
reportDialog: vi.fn<LogoPickerDeps['reportDialog']>(),
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<LogoPicker> {
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<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();
});
});