Workers P3-Endprüfung W8: wiederkehrende Bedienelemente im Pult mit eindeutigem Namen
"Details", "Anzeige öffnen", das Entwurfsfeld und die Aktionsgruppe tragen den Titel der Zeile im
zugänglichen Namen ("Details zu <Titel>"), 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ec3022df4
commit
17fb02150e
3 files changed
+70
-10
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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 ? <span>{row.doneLine}</span> : row.ageLabel && <span>{row.ageLabel}</span>}
|
||||
{info && <span>{info}</span>}
|
||||
{row.sourceHref && (
|
||||
<a className="link" href={row.sourceHref} target="_blank" rel="noopener noreferrer">Anzeige öffnen</a>
|
||||
<a className="link" href={row.sourceHref} target="_blank" rel="noopener noreferrer" aria-label={rowName('Anzeige öffnen', row.title)}>Anzeige öffnen</a>
|
||||
)}
|
||||
{prospect && (
|
||||
<button type="button" className="link" onClick={() => onDetails(prospect.$id)}>Details</button>
|
||||
<button type="button" className="link" onClick={() => onDetails(prospect.$id)} aria-label={rowName('Details', row.title, ' zu ')}>Details</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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) && (
|
||||
<div className="row-actions wk-pult-actions">
|
||||
<div className="row-actions wk-pult-actions" role="group" aria-label={rowName('Aktionen', row.title)}>
|
||||
{row.canSend && row.kind === 'mail_antwort' && (
|
||||
mail
|
||||
? <a className="btn btn-primary" href={mail}>E-Mail schreiben</a>
|
||||
|
||||
@@ -317,7 +317,7 @@ test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text,
|
||||
let html = show([{ ...base, originalText: 'Original <img src=x onerror=alert(1)>', draft: 'Hallo <script>alert(1)</script>', title: 'T <b>x</b>' }])
|
||||
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 = '<button class="btn" type="button">Erledigt</button>' // der Filter „Erledigt“ steht ebenfalls auf der Seite: nur den Knopf der Zeile meinen
|
||||
assert.ok(!html.includes(erledigt)); assert.match(html, /<a class="link" href="https:\/\/www\.kleinanzeigen\.de\/s-anzeige\/x\/1-2-3" target="_blank" rel="noopener noreferrer">/)
|
||||
assert.ok(!html.includes(erledigt)); assert.match(html, /<a class="link" href="https:\/\/www\.kleinanzeigen\.de\/s-anzeige\/x\/1-2-3" target="_blank" rel="noopener noreferrer" aria-label="Anzeige öffnen: T <b>x<\/b>">/)
|
||||
assert.match(html, /<textarea class="textarea wk-pult-draft"[^>]*>Hallo <script>alert\(1\)<\/script><\/textarea>/)
|
||||
// Fremdtext erscheint nie als Markup
|
||||
assert.ok(!html.includes('<script>') && !html.includes('<img') && !html.includes('<b>x</b>')); assert.ok(html.includes('Original <img src=x onerror=alert(1)>'))
|
||||
@@ -386,3 +386,36 @@ test('PultList (gerendert): abgeschlossene Einträge zeigen Ergebnis, Person und
|
||||
assert.ok(open.includes('<span>vor 4 Tagen</span>')); assert.ok(!open.includes('Gesendet')); assert.ok(!open.includes('badge green'))
|
||||
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
|
||||
})
|
||||
|
||||
// Endprüfung P3 (W8): „Details“, „Anzeige öffnen“, Entwurfsfeld und Aktionsgruppe tragen den Titel der Zeile im zugänglichen Namen
|
||||
test('PultList (gerendert): wiederkehrende Bedienelemente haben je Zeile einen eigenen zugänglichen Namen, sichtbares Wort vorn (W8)', async () => {
|
||||
const { render } = await loadList()
|
||||
const show = (items) => {
|
||||
globalThis.__pultFeed = { items, loading: false, error: null, refresh: async () => {} }
|
||||
globalThis.__pultEmployees = []
|
||||
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
|
||||
}
|
||||
const base = { kind: 'plattform_antwort', status: 'offen', draft: 'Hallo', createdAt: new Date().toISOString(), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
|
||||
try {
|
||||
const html = show([{ ...base, $id: 'a', title: 'Suche Webdesigner' }, { ...base, $id: 'b', title: 'Homepage für Handwerker <b>x</b> & "Co"' }])
|
||||
for (const name of ['Details zu Suche Webdesigner', 'Anzeige öffnen: Suche Webdesigner', 'Entwurf der Antwort: Suche Webdesigner', 'Aktionen: Suche Webdesigner']) {
|
||||
assert.ok(html.includes(`aria-label="${name}"`), name)
|
||||
}
|
||||
// zwei Zeilen ⇒ zwei verschiedene Namen; Fremdtext im Namen ist als Attribut kodiert
|
||||
const names = [...html.matchAll(/aria-label="(Details zu [^"]*)"/g)].map((m) => m[1])
|
||||
assert.equal(names.length, 2); assert.equal(new Set(names).size, 2)
|
||||
assert.ok(html.includes('aria-label="Details zu Homepage für Handwerker <b>x</b> & "Co""'))
|
||||
assert.ok(!html.includes('<b>x</b>'))
|
||||
// das sichtbare Wort bleibt (WCAG 2.5.3) und steht im Namen vorn
|
||||
assert.match(html, /aria-label="Details zu Suche Webdesigner">Details<\/button>/)
|
||||
assert.match(html, /aria-label="Anzeige öffnen: Suche Webdesigner">Anzeige öffnen<\/a>/)
|
||||
// leerer oder fehlender Titel: schlichter Name, nie „zu undefined“
|
||||
for (const title of ['', ' ', undefined]) {
|
||||
const bare = show([{ ...base, $id: 'c', title }])
|
||||
assert.ok(!bare.includes('undefined'), String(title))
|
||||
assert.ok(!bare.includes('Details zu'), String(title)); assert.ok(!bare.includes('Anzeige öffnen:'), String(title))
|
||||
assert.ok(bare.includes('aria-label="Entwurf der Antwort"'), String(title)); assert.ok(bare.includes('aria-label="Aktionen"'), String(title))
|
||||
assert.match(bare, />Details<\/button>/)
|
||||
}
|
||||
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
|
||||
})
|
||||
Reference in new issue
Block a user