From 17fb02150ee240cd9fda00a67285cab5cac58443 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:46:20 +0000 Subject: [PATCH] =?UTF-8?q?Workers=20P3-Endpr=C3=BCfung=20W8:=20wiederkehr?= =?UTF-8?q?ende=20Bedienelemente=20im=20Pult=20mit=20eindeutigem=20Namen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Details", "Anzeige öffnen", das Entwurfsfeld und die Aktionsgruppe tragen den Titel der Zeile im zugänglichen Namen ("Details zu "), das sichtbare Wort steht vorn (WCAG 2.5.3); ohne Titel gilt der schlichte Name. Der Prüfstand sucht die Knöpfe unter den neuen Namen und prüft zusätzlich die Eindeutigkeit der Namen (W8) und die Abschlusszeile im Reiter Erledigt (W5). Co-Authored-By: Claude Sonnet 5.5 --- scripts/workers-harness/shot.cjs | 29 +++++++++++++++++++----- src/components/workers/PultList.jsx | 16 +++++++++---- tests/unit/workersPultUi.test.mjs | 35 ++++++++++++++++++++++++++++- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/scripts/workers-harness/shot.cjs b/scripts/workers-harness/shot.cjs index 51c8a93..6703ddf 100644 --- a/scripts/workers-harness/shot.cjs +++ b/scripts/workers-harness/shot.cjs @@ -582,7 +582,7 @@ async function freigabe(browser, base) { const platMeta = await textOf(plat.locator('.wk-pult-meta')) check('freigabe: Plattform-Zeile: Alter, „läuft in 20 Std. ab“, Ort und Score', platMeta.includes('vor 5 Std.') && platMeta.includes('läuft in 20 Std. ab') && platMeta.includes('67655 Kaiserslautern · Score 72'), platMeta) check('freigabe: Plattform-Zeile: Art-Badge „Antwort auf Plattform-Anfrage“', (await textOf(plat.locator('.wk-pult-head'))).includes('Antwort auf Plattform-Anfrage')) - const link = plat.getByRole('link', { name: 'Anzeige öffnen', exact: true }) + const link = plat.getByRole('link', { name: `Anzeige öffnen: ${T_PLAT}`, exact: true }) check('freigabe: „Anzeige öffnen“ ist ein sicherer Link (https, neuer Tab, noopener noreferrer)', (await link.getAttribute('href')) === HAIR_URL && (await link.getAttribute('target')) === '_blank' && (await link.getAttribute('rel')) === 'noopener noreferrer') check('freigabe: Plattform-Zeile (offen): Kopieren & öffnen, Ich kümmere mich, Als gesendet markieren, Verwerfen', same(await buttonNames(plat), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen']), await buttonNames(plat)) const mailTo = await mail.getByRole('link', { name: 'E-Mail schreiben', exact: true }).getAttribute('href') @@ -593,6 +593,17 @@ async function freigabe(browser, base) { check('freigabe: Mail-Zeile: der Mail-Link trägt den bearbeiteten Text aus dem Feld', mailEdited === 'mailto:vorstand@sv-namborn.example?subject=Re%3A%20SV%20Namborn&body=' + encodeURIComponent('Neuer Text für die Mail.'), mailEdited) check('freigabe: Kollegen-Zeile: „übernommen von Justin“, kein „Ich kümmere mich“', (await textOf(koll.locator('.wk-pult-meta'))).includes('übernommen von Justin') && (await koll.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0) check('freigabe: Zeile ohne Antwort („unsicher“): nur Ich kümmere mich, Erledigt, Verwerfen, kein Textfeld', same(await buttonNames(uns), ['Ich kümmere mich', 'Erledigt', 'Verwerfen']) && (await uns.locator('textarea').count()) === 0, await buttonNames(uns)) + // Wiederkehrende Bedienelemente tragen den Titel der Zeile im Namen (Screenreader-Liste), das sichtbare Wort steht vorn + const repeated = await page.evaluate(() => ({ + details: [...document.querySelectorAll('.wk-pult-meta button.link')].map((el) => [el.textContent.trim(), el.getAttribute('aria-label')]), + sources: [...document.querySelectorAll('.wk-pult-meta a.link')].map((el) => [el.textContent.trim(), el.getAttribute('aria-label')]), + drafts: [...document.querySelectorAll('textarea.wk-pult-draft')].map((el) => el.getAttribute('aria-label')), + })) + const unique = (list) => new Set(list).size === list.length + check('freigabe: „Details“, „Anzeige öffnen“ und Entwurfsfeld haben je Zeile einen eigenen Namen (sichtbares Wort vorn)', + repeated.details.length > 1 && repeated.details.every(([text, name]) => text === 'Details' && typeof name === 'string' && name.startsWith('Details zu ')) && unique(repeated.details.map((d) => d[1])) + && repeated.sources.length > 1 && repeated.sources.every(([text, name]) => text === 'Anzeige öffnen' && typeof name === 'string' && name.startsWith('Anzeige öffnen: ')) && unique(repeated.sources.map((d) => d[1])) + && repeated.drafts.length > 1 && repeated.drafts.every((name) => typeof name === 'string' && name.startsWith('Entwurf der Antwort: ')) && unique(repeated.drafts), repeated) await topOfPage(page) await shoot(page, 'desktop-freigabe-wartet.png') await shoot(page, 'desktop-freigabe-wartet-ganz.png', { fullPage: true }) @@ -700,6 +711,14 @@ async function freigabe(browser, base) { await filterButtons.nth(2).click() await waitTitles(page, [T_MAIL, T_PLAT, T_UNS, T_GES, T_ERL]) check('freigabe: „Erledigt“ zeigt gesendet, verworfen, erledigt (fünf Zeilen), ohne Aktionsknöpfe', (await page.locator('.wk-pult-actions').count()) === 0) + const doneMeta = await page.evaluate(() => [...document.querySelectorAll('.wk-pult-row')].map((row) => ({ + badge: [...row.querySelectorAll('.wk-pult-head .badge')].map((b) => b.textContent.trim()).filter((t) => ['Gesendet', 'Verworfen', 'Erledigt'].includes(t)), + meta: row.querySelector('.wk-pult-meta').textContent.trim(), + }))) + check('freigabe: „Erledigt“: jede Zeile nennt Ergebnis (Badge), Person und Zeitpunkt der Erledigung, nicht das Alter ab Anlage', + doneMeta.length === 5 && doneMeta.every((r) => r.badge.length === 1 && new RegExp(`^${r.badge[0]} von (Kenso|Justin) · (gerade eben|vor \\d+ (Min\\.|Std\\.|Tag|Tagen))`).test(r.meta)), doneMeta) + check('freigabe: „Erledigt“: Foto-Zeile „Gesendet von Kenso · vor 1 Tag“, Beschwerde „Erledigt von Justin · vor 3 Tagen“', + (await textOf(rowOf(page, T_GES).locator('.wk-pult-meta'))).startsWith('Gesendet von Kenso · vor 1 Tag') && (await textOf(rowOf(page, T_ERL).locator('.wk-pult-meta'))).startsWith('Erledigt von Justin · vor 3 Tagen')) await shoot(page, 'desktop-freigabe-erledigt.png') // 10. Leerzustand „Wartet“ und Ausfall des Pults mit „Neu laden“ @@ -733,7 +752,7 @@ async function interessent(browser, base) { await openPult(page, base) // 1. „Details“ öffnet das Panel als Dialog - const opener = rowOf(page, T_MAIL).getByRole('button', { name: 'Details', exact: true }) + const opener = rowOf(page, T_MAIL).getByRole('button', { name: `Details zu ${T_MAIL}`, exact: true }) await opener.evaluate((el) => el.setAttribute('data-probe', 'details-opener')) await opener.click() const dialog = page.getByRole('dialog', { name: 'SV Namborn', exact: true }) @@ -783,7 +802,7 @@ async function interessent(browser, base) { check('interessent: das Kreuz oben schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page)) // 4. Interessent ohne Score, ohne Anfragetext, ohne Verlauf - await rowOf(page, T_KOLL).getByRole('button', { name: 'Details', exact: true }).click() + await rowOf(page, T_KOLL).getByRole('button', { name: `Details zu ${T_KOLL}`, exact: true }).click() const dialog2 = page.getByRole('dialog', { name: 'Meisterbetrieb Weber', exact: true }) await dialog2.waitFor() await dialog2.locator('.wk-prospect').waitFor() @@ -798,7 +817,7 @@ async function interessent(browser, base) { // 5. Dienst meldet einen Fehler: Satz im Panel, „Neu laden“ holt den Interessenten await setPultFault(page, 'prospect', { status: 404, message: 'Interessent nicht gefunden.' }) - await rowOf(page, T_PLAT).getByRole('button', { name: 'Details', exact: true }).click() + await rowOf(page, T_PLAT).getByRole('button', { name: `Details zu ${T_PLAT}`, exact: true }).click() const dialog3 = page.getByRole('dialog', { name: 'Interessent', exact: true }) await dialog3.getByRole('alert').waitFor() check('interessent: Fehler vom Dienst: der Satz steht im Panel (role=alert) mit „Neu laden“', (await textOf(dialog3.getByRole('alert'))) === 'Interessent nicht gefunden.' && (await dialog3.getByRole('button', { name: 'Neu laden', exact: true }).count()) === 1, await textOf(dialog3)) @@ -920,7 +939,7 @@ async function handyFreigabe(browser, base) { await shoot(page, 'handy-freigabe-originalanfrage.png') // Panel am Handy: Bottom-Sheet im Fenster, lange Wörter brechen um, Schließen erreichbar - const opener = rowOf(page, T_MAIL).getByRole('button', { name: 'Details', exact: true }) + const opener = rowOf(page, T_MAIL).getByRole('button', { name: `Details zu ${T_MAIL}`, exact: true }) await opener.evaluate((el) => el.setAttribute('data-probe', 'details-opener')) await opener.scrollIntoViewIfNeeded() await opener.click() diff --git a/src/components/workers/PultList.jsx b/src/components/workers/PultList.jsx index 0ffb16f..0d839ec 100644 --- a/src/components/workers/PultList.jsx +++ b/src/components/workers/PultList.jsx @@ -34,6 +34,14 @@ function copyNote(result, hasUrl) { return { kind: 'err', text: 'Kopieren und Öffnen haben nicht geklappt.' } } +// Zugänglicher Name für ein Bedienelement, das in jeder Zeile wiederkehrt: das sichtbare Wort steht vorn (WCAG 2.5.3 „Beschriftung im +// Namen“), dahinter der Titel der Zeile, damit eine Liste aller „Details“-Knöpfe im Screenreader unterscheidbar ist. Ohne Titel +// (leer oder fehlend) gilt der schlichte Name, nie „… zu undefined“. +function rowName(label, title, joiner = ': ') { + const t = typeof title === 'string' ? title.trim() : '' + return t === '' ? label : `${label}${joiner}${t}` +} + // Eine Zeile des Pults. Die Zeile hält nur, was ihr gehört (läuft gerade etwas, Rückfrage, Meldung); der bearbeitete Text liegt in der // Liste, damit er einen Wechsel des Filters und das Neuladen überlebt. api.pultAction führt aus, der Dienst entscheidet über Rechte und // Reihenfolge (409 mit deutschem Satz); danach wird die Liste neu geholt. @@ -135,10 +143,10 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails {row.doneLine ? {row.doneLine} : row.ageLabel && {row.ageLabel}} {info && {info}} {row.sourceHref && ( - Anzeige öffnen + Anzeige öffnen )} {prospect && ( - + )} @@ -154,7 +162,7 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails className="textarea wk-pult-draft" value={text} onChange={(e) => onText(row.id, e.target.value)} - aria-label="Entwurf der Antwort" + aria-label={rowName('Entwurf der Antwort', row.title)} /> ) : ( item.draft && ( @@ -166,7 +174,7 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails )} {(row.canSend || row.canClaim || row.canDone || row.canDiscard) && ( -
+
{row.canSend && row.kind === 'mail_antwort' && ( mail ? E-Mail schreiben diff --git a/tests/unit/workersPultUi.test.mjs b/tests/unit/workersPultUi.test.mjs index c967509..49168bd 100644 --- a/tests/unit/workersPultUi.test.mjs +++ b/tests/unit/workersPultUi.test.mjs @@ -317,7 +317,7 @@ test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text, let html = show([{ ...base, originalText: 'Original ', draft: 'Hallo ', title: 'T x' }]) for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Details', '66640 Namborn · Score 72', 'vor 3 Std.']) assert.ok(html.includes(t), t) const erledigt = '' // der Filter „Erledigt“ steht ebenfalls auf der Seite: nur den Knopf der Zeile meinen - assert.ok(!html.includes(erledigt)); assert.match(html, //) + assert.ok(!html.includes(erledigt)); assert.match(html, //) assert.match(html, /