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, /