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:
1 parent
0e4759a05a
commit
e0f11f905e
4 files changed
+392
-12
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user