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 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 () => { 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')); fx.setLogoVisibility.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
await mount(logo()); 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', () => { describe('Meldezeile', () => {
it('zeigt einen Text in einer Live-Region (role="alert") und löscht ihn mit einem leeren Text', async () => { it('zeigt einen Text in einer Live-Region (role="alert") und löscht ihn mit einem leeren Text', async () => {
await mount(); await mount();
+28 -12
View File
@@ -4,7 +4,8 @@
// der Statusbereich des Pickers den Ladefehlern der Liste vorbehalten. // der Statusbereich des Pickers den Ladefehlern der Liste vorbehalten.
// Die Zeilen und die Auswahl gehören dem Picker (`host`); die Infozeile liest sie und ändert sie nur über `patchRow` und `removeLogo`. // Die Zeilen und die Auswahl gehören dem Picker (`host`); die Infozeile liest sie und ändert sie nur über `patchRow` und `removeLogo`.
// Der Aufbau steht einmal da und `update()` frischt nur Texte und Sichtbarkeit auf: ein Knopf, der gerade den Fokus hat, behält ihn. // Der Aufbau steht einmal da und `update()` frischt nur Texte und Sichtbarkeit auf: ein Knopf, der gerade den Fokus hat, behält ihn.
// Alle Texte gehen als Text in den DOM, nie als HTML. // Läuft eine Aktion, ist die Infozeile `aria-busy` und die übrigen Knöpfe sind gesperrt (der gedrückte nicht, er behält den Fokus);
// ein Fehler erscheint nur, wenn noch dasselbe Logo gewählt ist wie beim Start. Alle Texte gehen als Text in den DOM, nie als HTML.
import '../account/account.css'; import '../account/account.css';
import './logos.css'; import './logos.css';
import { errorMessage } from '../account/errors'; import { errorMessage } from '../account/errors';
@@ -78,7 +79,7 @@ interface Reasons {
/** `signal` schließt offene Dialoge und beendet laufende Aktionen (Seite verlassen). */ /** `signal` schließt offene Dialoge und beendet laufende Aktionen (Seite verlassen). */
export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: AbortSignal): LogoInfo { export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: AbortSignal): LogoInfo {
/** Eine Aktion (Rückfrage, Dialog oder Anfrage) läuft: weitere Klicks bleiben ohne Wirkung. */ /** Eine Aktion (Rückfrage, Dialog oder Anfrage) läuft: weitere Klicks bleiben ohne Wirkung (die Knöpfe sind dann auch gesperrt). */
let busy = false; let busy = false;
/** Für diese Auswahl wurde zuletzt gezeichnet: ein Wechsel räumt Meldung und Gründe ab. */ /** Für diese Auswahl wurde zuletzt gezeichnet: ein Wechsel räumt Meldung und Gründe ab. */
let shownId: string | null = null; let shownId: string | null = null;
@@ -103,7 +104,8 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A
// Immer da (leer nehmen sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt. // Immer da (leer nehmen sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt.
const goneEl = el('p', { class: 'auth-msg logo-info-gone', role: 'status' }); const goneEl = el('p', { class: 'auth-msg logo-info-gone', role: 'status' });
const messageEl = el('p', { class: 'auth-msg auth-msg-error logo-info-msg', role: 'alert' }); const messageEl = el('p', { class: 'auth-msg auth-msg-error logo-info-msg', role: 'alert' });
const root = el('div', { class: 'logo-info' }, cardEl, goneEl, messageEl); const root = el('div', { class: 'logo-info', 'aria-busy': 'false' }, cardEl, goneEl, messageEl);
const actionButtons = [reportButton, deleteButton, visibilityButton];
// ---- Anzeige -------------------------------------------------------------------------------------------------------------- // ---- Anzeige --------------------------------------------------------------------------------------------------------------
@@ -136,7 +138,10 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A
function renderReports(logo: CommunityLogo): void { function renderReports(logo: CommunityLogo): void {
reportsEl.textContent = reportsLabel(logo.reportCount); reportsEl.textContent = reportsLabel(logo.reportCount);
const key = `${logo.id}:${logo.reportCount}`; const key = `${logo.id}:${logo.reportCount}`;
if (logo.reportCount > 0 && reasons?.key !== key) { if (logo.reportCount === 0) {
// Keine Meldungen (auch nach „Einblenden“, das sie löscht): nichts, was später bei gleicher Zahl wiederverwendet werden dürfte.
reasons = null;
} else if (reasons?.key !== key) {
reasons = { key, status: 'loading', list: [] }; reasons = { key, status: 'loading', list: [] };
void loadReasons(logo.id, key); void loadReasons(logo.id, key);
} }
@@ -174,17 +179,28 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A
// ---- Aktionen ------------------------------------------------------------------------------------------------------------- // ---- Aktionen -------------------------------------------------------------------------------------------------------------
/** Führt eine Aktion aus: immer nur eine, die Meldezeile wird vorher geleert, ein Fehler erscheint dort als Text. */ /** Setzt, ob eine Aktion läuft: `aria-busy` an der Infozeile und die anderen Aktionsknöpfe gesperrt. Der gedrückte bleibt bedienbar und behält so den Fokus. */
async function run(action: () => Promise<void>): Promise<void> { function setBusy(value: boolean, pressed?: HTMLButtonElement): void {
busy = value;
root.setAttribute('aria-busy', String(value));
for (const button of actionButtons) button.disabled = value && button !== pressed;
}
/**
* Führt eine Aktion aus: immer nur eine, die Meldezeile wird vorher geleert, ein Fehler erscheint dort als Text. Er gehört zum Logo,
* für das die Aktion gestartet wurde: hat der Nutzer inzwischen ein anderes gewählt, steht er nicht unter diesem.
*/
async function run(action: () => Promise<void>, pressed: HTMLButtonElement): Promise<void> {
if (busy) return; if (busy) return;
busy = true; const startedFor = host.selectedId();
setBusy(true, pressed);
setMessage(''); setMessage('');
try { try {
await action(); await action();
} catch (err) { } catch (err) {
if (!signal.aborted) setMessage(failureText(err)); if (!signal.aborted && host.selectedId() === startedFor) setMessage(failureText(err));
} finally { } finally {
busy = false; setBusy(false);
} }
} }
@@ -234,9 +250,9 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A
render(); render();
} }
reportButton.addEventListener('click', () => void run(report)); reportButton.addEventListener('click', () => void run(report, reportButton));
deleteButton.addEventListener('click', () => void run(remove)); deleteButton.addEventListener('click', () => void run(remove, deleteButton));
visibilityButton.addEventListener('click', () => void run(toggleVisibility)); visibilityButton.addEventListener('click', () => void run(toggleVisibility, visibilityButton));
render(); render();
return { el: root, update: render, setMessage }; return { el: root, update: render, setMessage };