fix(logos): Infozeile zeigt laufende Aktionen an, Fehler gehören zur Auswahl, Gründe nach „Einblenden“ verworfen

- logo-info.ts, run: Die Aktion merkt sich die Auswahl beim Start. Ein Fehler, der erst eintrifft, nachdem der Nutzer ein anderes Logo gewählt hat,
  erschien sonst in dessen Meldezeile (render() leert sie nur beim Wechsel, nicht danach). Jetzt steht er nur da, wenn noch dasselbe Logo gewählt ist.
  Ergebnisse, die an einer ID hängen (patchRow, removeLogo, forgetLogo), gelten wie bisher dem Logo, für das die Aktion lief.
- logo-info.ts: Solange eine Aktion läuft (Rückfrage, Dialog oder Anfrage), trägt die Infozeile aria-busy="true" und die anderen Aktionsknöpfe sind
  gesperrt. Der gedrückte Knopf bleibt bedienbar und behält den Fokus (ein gesperrter verlöre ihn, und der Picker holt den Fokus von „Löschen“ nur,
  wenn er in der Infozeile liegt). Danach ist alles wieder frei, auch nach einem Fehler und nach dem Verlassen der Seite.
- logo-info.ts, renderReports: Bei 0 Meldungen (etwa nach „Einblenden“, das sie löscht) wird der Gründe-Zwischenspeicher geleert. Kamen später genau so
  viele Meldungen hinzu wie vorher (gleicher Schlüssel <id>:<n>), wurden die alten, längst gelöschten Gründe gezeigt.
- Tests: Zustand während jeder Aktion (Löschen mit Rückfrage und Anfrage, Melden, Ausblenden, Fehler, abort), Fokus, wirkungslose Klicks; Fehler nach
  Wechsel der Auswahl (anderes Logo, „Kein Logo“, Aus-/Einblenden) und bei gleicher Auswahl; Gründe nach „Einblenden“ samt später Antwort.

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 02b01c9deb
commit 0e4759a05a
2 files changed
+244 -12

No files matched your search

+216
View File
@@ -673,6 +673,37 @@ describe('Admin', () => {
expect(fx.listLogoReports).toHaveBeenCalledTimes(1); // nichts mehr zu holen
});
it('nach „Einblenden“ (0 Meldungen) sind die alten Gründe verworfen: kommen später wieder Meldungen, werden sie frisch geholt, auch bei gleicher Zahl', async () => {
fx.listLogoReports.mockResolvedValueOnce([report('spam'), report('sonstiges')]);
await mount(logo({ hidden: true, reportCount: 2 }));
expect(reasons()).toEqual(['Spam', 'Sonstiges']);
button('Einblenden').click();
await flush();
expect(reportCount()).toBe('0 Meldungen');
expect(reasons()).toEqual([]);
expect(fx.listLogoReports).toHaveBeenCalledTimes(1);
// Zwei neue Meldungen, wieder genau zwei: gleicher Schlüssel wie vorher, aber andere Gründe.
fx.listLogoReports.mockResolvedValueOnce([report('urheberrecht', 'neu1'), report('unangemessen', 'neu2')]);
row = logo({ reportCount: 2 });
info.update();
await flush();
expect(fx.listLogoReports).toHaveBeenCalledTimes(2);
expect(reasons()).toEqual(['Urheberrecht/Marke', 'Unangemessen']);
});
it('eine späte Antwort der alten Gründe, die nach dem Einblenden eintrifft, wird verworfen', async () => {
const slow = deferred<LogoReport[]>();
fx.listLogoReports.mockReturnValueOnce(slow.promise);
await mount(logo({ hidden: true, reportCount: 2 }));
button('Einblenden').click();
await flush();
slow.resolve([report('spam'), report('sonstiges')]);
await flush();
expect(reportCount()).toBe('0 Meldungen');
expect(reasons()).toEqual([]);
});
it('Fehler beim Ein- oder Ausblenden: Text in der Meldezeile, die Zeile bleibt, kein forgetLogo', async () => {
fx.setLogoVisibility.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
await mount(logo());
@@ -730,6 +761,191 @@ describe('Admin', () => {
});
});
describe('Aktionen: Zustand während sie laufen', () => {
const busy = () => info.el.getAttribute('aria-busy');
const disabled = () => ['Melden', 'Löschen', 'Ausblenden'].filter((label) => button(label)?.disabled);
beforeEach(() => {
admin = true; // alle drei Knöpfe: Melden, Löschen, Ausblenden
});
it('im Ruhezustand: nicht beschäftigt, kein Knopf gesperrt', async () => {
await mount();
expect(busy()).toBe('false');
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
expect(disabled()).toEqual([]);
});
it('läuft eine Anfrage (Löschen): aria-busy="true" an der Infozeile, die anderen Knöpfe sind gesperrt, der gedrückte nicht und behält den Fokus', async () => {
const request = deferred<void>();
fx.deleteLogo.mockReturnValue(request.promise);
await mount();
button('Löschen').focus();
button('Löschen').click();
await flush(); // die Rückfrage ist beantwortet, die Anfrage läuft
expect(fx.deleteLogo).toHaveBeenCalledTimes(1);
expect(busy()).toBe('true');
expect(disabled()).toEqual(['Melden', 'Ausblenden']);
expect(button('Löschen').disabled).toBe(false);
expect(document.activeElement).toBe(button('Löschen'));
request.resolve();
await flush();
expect(busy()).toBe('false');
expect(disabled()).toEqual([]);
});
it('schon die Rückfrage vor dem Löschen zählt: beschäftigt, solange sie offen ist', async () => {
const answer = deferred<boolean>();
fx.confirm.mockReturnValue(answer.promise);
await mount();
button('Löschen').click();
expect(busy()).toBe('true');
expect(disabled()).toEqual(['Melden', 'Ausblenden']);
answer.resolve(false); // abgelehnt
await flush();
expect(busy()).toBe('false');
expect(disabled()).toEqual([]);
});
it('der Melden-Dialog zählt ebenso; der gedrückte Knopf ist „Melden“', async () => {
const open = deferred<'reported'>();
fx.reportDialog.mockReturnValue(open.promise);
await mount();
button('Melden').click();
expect(busy()).toBe('true');
expect(disabled()).toEqual(['Löschen', 'Ausblenden']);
open.resolve('reported');
await flush();
expect(busy()).toBe('false');
expect(disabled()).toEqual([]);
});
it('Ausblenden: der gedrückte Knopf ist „Ausblenden“; nach dem Umschalten ist alles wieder frei', async () => {
const request = deferred<void>();
fx.setLogoVisibility.mockReturnValue(request.promise);
await mount();
button('Ausblenden').click();
expect(busy()).toBe('true');
expect(disabled()).toEqual(['Melden', 'Löschen']);
request.resolve();
await flush();
expect(busy()).toBe('false');
expect(disabled()).toEqual([]);
expect(buttons()).toEqual(['Löschen', 'Einblenden']);
});
it('scheitert die Aktion, ist alles wieder frei, und der Fehler steht da', async () => {
fx.deleteLogo.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
await mount();
button('Löschen').click();
await flush();
expect(message()).toBe('Dazu hast du keine Berechtigung.');
expect(busy()).toBe('false');
expect(disabled()).toEqual([]);
});
it('nach dem Verlassen der Seite (abort) ist die Infozeile auch wieder frei', async () => {
const request = deferred<void>();
fx.deleteLogo.mockReturnValue(request.promise);
await mount();
button('Löschen').click();
await flush();
abort.abort();
request.resolve();
await flush();
expect(busy()).toBe('false');
expect(disabled()).toEqual([]);
});
it('ein Klick auf einen gesperrten oder auf denselben Knopf noch einmal bleibt ohne Wirkung', async () => {
const request = deferred<void>();
fx.deleteLogo.mockReturnValue(request.promise);
await mount();
button('Löschen').click();
await flush();
button('Melden').click(); // gesperrt: ein Klick auf einen gesperrten Knopf erreicht nichts
button('Ausblenden').click();
button('Löschen').click(); // läuft schon
await flush();
expect(fx.reportDialog).not.toHaveBeenCalled();
expect(fx.setLogoVisibility).not.toHaveBeenCalled();
expect(fx.confirm).toHaveBeenCalledTimes(1);
request.resolve();
await flush();
});
});
describe('Aktionen: Ergebnis gehört zur Auswahl, für die sie gestartet wurden', () => {
it('ein Fehler, der erst eintrifft, nachdem ein anderes Logo gewählt wurde, steht nicht unter dem neuen Logo', async () => {
user = 'u9';
const request = deferred<void>();
fx.deleteLogo.mockReturnValue(request.promise);
await mount(logo({ id: 'a', name: 'Logo A' }));
button('Löschen').click();
await flush();
choose(logo({ id: 'b', name: 'Logo B' })); // der Nutzer wählt inzwischen B
request.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
await flush();
expect(message()).toBe('');
expect(line()).toBe('Logo B · von Kenso · ♥ 12');
});
it('wählt der Nutzer „Kein Logo“ und danach wieder dasselbe Logo, ist es für die Anzeige eine neue Auswahl: kein später Fehler dort', async () => {
user = 'u9';
const request = deferred<void>();
fx.deleteLogo.mockReturnValue(request.promise);
await mount(logo({ id: 'a' }));
button('Löschen').click();
await flush();
choose(null);
request.reject(new TypeError('offline'));
await flush();
expect(message()).toBe('');
});
it('bleibt die Auswahl dieselbe, steht der Fehler da (auch wenn sich die Zeile zwischendurch aktualisiert hat)', async () => {
user = 'u9';
const request = deferred<void>();
fx.deleteLogo.mockReturnValue(request.promise);
await mount(logo({ id: 'a' }));
button('Löschen').click();
await flush();
row = logo({ id: 'a', likeCount: 13 });
info.update();
request.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
await flush();
expect(message()).toBe('Dazu hast du keine Berechtigung.');
});
it('ein Fehler beim Ein- oder Ausblenden folgt derselben Regel', async () => {
admin = true;
const request = deferred<void>();
fx.setLogoVisibility.mockReturnValue(request.promise);
await mount(logo({ id: 'a' }));
button('Ausblenden').click();
await flush();
choose(logo({ id: 'b', name: 'Logo B' }));
request.reject(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
await flush();
expect(message()).toBe('');
});
it('ein gelungenes Ein- oder Ausblenden ändert die Zeile des Logos, für das es gestartet wurde, auch wenn inzwischen ein anderes gewählt ist', async () => {
admin = true;
const request = deferred<void>();
fx.setLogoVisibility.mockReturnValue(request.promise);
await mount(logo({ id: 'a' }));
button('Ausblenden').click();
await flush();
choose(logo({ id: 'b', name: 'Logo B' }));
request.resolve();
await flush();
expect(host.patchRow).toHaveBeenCalledExactlyOnceWith('a', { hidden: true });
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); // B ist unberührt
});
});
describe('Meldezeile', () => {
it('zeigt einen Text in einer Live-Region (role="alert") und löscht ihn mit einem leeren Text', async () => {
await mount();