Files
tickte-system/tests/unit/workersPultUi.test.mjs
T
ClaudeandClaude Sonnet 5.5 17fb02150e 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>
2026-10-18 10:46:20 +00:00

422 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'
// Die React-Teile sind unter Node nicht ladbar (JSX, Router): geprüft wird der Quelltext (Muster adminRoute.test.mjs); die Logik
// steckt in P3-T18. Nur usePultFeed läuft wirklich, mit einem kleinen Hook-Läufer anstelle von React (siehe unten).
const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
test('WorkersPage hängt den Reiter freigabe an hochhaus an und zeigt dort die PultList', () => {
const s = read('src/pages/WorkersPage.jsx')
assert.match(s, /id: 'hochhaus'/); assert.match(s, /id: 'freigabe'/); assert.match(s, /import PultList from/); assert.match(s, /<PultList[^>]*api=\{api\}/)
})
test('PultList: sichere Links, kein innerHTML, Entwurf bearbeitbar, Filter aus PULT_FILTERS', () => {
const s = read('src/components/workers/PultList.jsx')
assert.doesNotMatch(s, /dangerouslySetInnerHTML/)
assert.match(s, /rel="noopener noreferrer"/); assert.match(s, /PULT_FILTERS/); assert.match(s, /copyAndOpen/)
assert.doesNotMatch(s, /readOnly[^>]*wk-pult-draft|wk-pult-draft[^>]*readOnly/)
})
test('usePultFeed: 30-s-Takt nur bei sichtbarem Tab', () => {
const s = read('src/hooks/usePultFeed.js')
assert.match(s, /30000/); assert.match(s, /document\.visibilityState/); assert.match(s, /visibilitychange/)
})
test('CSS: wk-pult-Klassen und Handy-Umbruch; jede im JSX benutzte wk-Klasse ist definiert', () => {
const css = read('src/styles/workers.css')
assert.match(css, /\.wk-pult-row/); assert.match(css, /@media \(max-width: 860px\)[\s\S]*\.wk-pult-row/)
for (const f of ['PultList', 'ProspectPanel']) { const p = 'src/components/workers/' + f + '.jsx'; if (fs.existsSync(new URL('../../' + p, import.meta.url))) for (const m of read(p).matchAll(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), `${f}: ${m[0]}`) }
})
test('PultList: Aktionen über pultAction, "Als gesendet" sendet den bearbeiteten Text, Verwerfen fragt zuerst nach, Meldungen ok|err', () => {
const s = read('src/components/workers/PultList.jsx')
assert.match(s, /api\.pultAction\(row\.id, 'claim'\)/); assert.match(s, /api\.pultAction\(row\.id, 'sent', \{ text \}\)/)
assert.match(s, /api\.pultAction\(row\.id, 'discard', \{\}\)/); assert.match(s, /api\.pultAction\(row\.id, 'done'\)/)
// Kopieren & öffnen: Text ist der Inhalt des Textfelds (nicht row.copyText), das Öffnen geschieht ohne Referrer und ohne Rückbezug
assert.match(s, /copyAndOpen\(\{ text, url: row\.sourceHref, copy: copyTextChecked, open: openTab \}\)/)
assert.match(s, /window\.open\(url, '_blank', 'noopener,noreferrer'\)/)
assert.match(s, /Wirklich verwerfen\?/); assert.match(s, /CONFIRM_MS = 4000/)
for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Erledigt', 'Originalanfrage', 'Nichts wartet auf dich', 'Details']) assert.ok(s.includes(t), t)
assert.match(s, /className=\{`msg \$\{/); assert.match(s, /'ok'/); assert.match(s, /'err'/)
// der Mail-Link trägt den bearbeiteten Text, nicht den gespeicherten Entwurf
assert.match(s, /mailtoHref\(\{[\s\S]*?body: text \}\)/)
// der Text erscheint nur als Textknoten
assert.doesNotMatch(s, /innerHTML/)
})
// ---- usePultFeed läuft wirklich: Mini-Läufer für Hooks statt React, falsche document/window mit von Hand weitergestelltem Takt ----
const FAKE_REACT = `
let active = null
export function __mount(hook, getProps) {
const run = { hooks: [], index: 0, effects: [], dirty: false, rendering: false, unmounted: false, late: 0, result: undefined }
run.render = () => {
if (run.rendering) { run.dirty = true; return }
run.rendering = true
do {
run.dirty = false
run.index = 0
active = run
run.result = hook(getProps())
active = null
for (const effect of run.effects.splice(0)) effect()
} while (run.dirty)
run.rendering = false
}
run.render()
return {
result: () => run.result,
rerender: () => run.render(),
lateUpdates: () => run.late,
unmount: () => {
run.unmounted = true
for (const h of run.hooks) if (h && typeof h.cleanup === 'function') h.cleanup()
},
}
}
export function useState(initial) {
const run = active
const i = run.index++
if (!run.hooks[i]) run.hooks[i] = { value: typeof initial === 'function' ? initial() : initial }
const slot = run.hooks[i]
return [slot.value, (next) => {
if (run.unmounted) { run.late += 1; return }
const value = typeof next === 'function' ? next(slot.value) : next
if (Object.is(value, slot.value)) return
slot.value = value
run.render()
}]
}
export function useRef(initial) {
const run = active
const i = run.index++
if (!run.hooks[i]) run.hooks[i] = { current: initial }
return run.hooks[i]
}
export function useCallback(fn, deps) {
const run = active
const i = run.index++
const slot = run.hooks[i]
if (slot && deps.every((d, k) => Object.is(d, slot.deps[k]))) return slot.fn
run.hooks[i] = { fn, deps }
return fn
}
export function useEffect(fn, deps) {
const run = active
const i = run.index++
if (!run.hooks[i]) run.hooks[i] = { deps: undefined, cleanup: undefined }
const slot = run.hooks[i]
if (slot.deps && deps && deps.every((d, k) => Object.is(d, slot.deps[k]))) return
run.effects.push(() => {
if (typeof slot.cleanup === 'function') slot.cleanup()
slot.deps = deps
slot.cleanup = fn()
})
}
`
async function loadFeed() {
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const fakeReact = {
name: 'fake-react',
setup(build) {
build.onResolve({ filter: /^react$/ }, () => ({ path: 'react', namespace: 'fake-react' }))
build.onLoad({ filter: /.*/, namespace: 'fake-react' }, () => ({ contents: FAKE_REACT, loader: 'js' }))
},
}
const out = await esbuild.build({
stdin: { contents: "export { usePultFeed } from './src/hooks/usePultFeed.js'\nexport { __mount } from 'react'", resolveDir: root, loader: 'js' },
bundle: true, format: 'esm', write: false, platform: 'neutral', plugins: [fakeReact],
})
return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
}
// document und window nur für die Dauer eines Tests: Takt (setInterval) und visibilitychange werden von Hand ausgelöst
function fakeBrowser() {
const listeners = new Map()
const timers = new Map()
let nextTimer = 1
const doc = {
visibilityState: 'visible',
addEventListener: (type, fn) => listeners.set(type, [...(listeners.get(type) ?? []), fn]),
removeEventListener: (type, fn) => listeners.set(type, (listeners.get(type) ?? []).filter((f) => f !== fn)),
}
const win = {
setInterval: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id },
clearInterval: (id) => { timers.delete(id) },
}
const saved = { document: globalThis.document, window: globalThis.window }
globalThis.document = doc
globalThis.window = win
return {
doc,
timers,
listenerCount: (type) => (listeners.get(type) ?? []).length,
fire: (type) => { for (const fn of listeners.get(type) ?? []) fn() },
tick: () => { for (const t of [...timers.values()]) t.fn() },
restore: () => { globalThis.document = saved.document; globalThis.window = saved.window },
}
}
// api.pult-Attrappe: jeder Aufruf ist ein offenes Versprechen, das der Test selbst auflöst
function fakeApi() {
const calls = []
return {
calls,
pult: (status) => new Promise((resolve, reject) => { calls.push({ status, resolve, reject }) }),
}
}
const settle = () => new Promise((resolve) => setImmediate(resolve))
const itemsOf = (...ids) => ({ items: ids.map((id) => ({ $id: id })) })
test('usePultFeed: lädt beim Start, zeigt die Einträge, deutscher Fehler bleibt neben den letzten Einträgen stehen', async () => {
const { usePultFeed, __mount } = await loadFeed()
const b = fakeBrowser()
try {
const api = fakeApi()
const hook = __mount(() => usePultFeed({ api, statuses: 'offen,in_arbeit' }), () => ({}))
assert.deepEqual(api.calls.map((c) => c.status), ['offen,in_arbeit'])
assert.deepEqual([hook.result().loading, hook.result().items, hook.result().error], [true, [], null])
api.calls[0].resolve(itemsOf('a', 'b')); await settle()
assert.deepEqual([hook.result().loading, hook.result().items.map((i) => i.$id), hook.result().error], [false, ['a', 'b'], null])
// Takt: ein Abruf schlägt fehl, die Einträge bleiben, der Satz des Dienstes erscheint
assert.equal([...b.timers.values()][0].ms, 30000)
b.tick()
api.calls[1].reject(Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })); await settle()
assert.deepEqual([hook.result().items.map((i) => i.$id), hook.result().error], [['a', 'b'], 'Der Hustler-Dienst ist gerade nicht erreichbar.'])
// der nächste Erfolg räumt den Fehler weg
b.tick(); api.calls[2].resolve(itemsOf('a')); await settle()
assert.deepEqual([hook.result().items.map((i) => i.$id), hook.result().error], [['a'], null])
hook.unmount()
} finally { b.restore() }
})
test('usePultFeed: Takt und Neuladen nur bei sichtbarem Tab; zurück im Vordergrund wird sofort geladen', async () => {
const { usePultFeed, __mount } = await loadFeed()
const b = fakeBrowser()
try {
const api = fakeApi()
const hook = __mount(() => usePultFeed({ api, statuses: 'offen' }), () => ({}))
api.calls[0].resolve(itemsOf('a')); await settle()
b.doc.visibilityState = 'hidden'
b.tick(); b.fire('visibilitychange') // versteckt: weder Takt noch Wechsel-Ereignis rufen den Dienst
assert.equal(api.calls.length, 1)
b.doc.visibilityState = 'visible'; b.fire('visibilitychange')
assert.equal(api.calls.length, 2) // zurück im Vordergrund: sofort
b.tick()
assert.equal(api.calls.length, 3)
hook.unmount()
assert.equal(b.timers.size, 0); assert.equal(b.listenerCount('visibilitychange'), 0) // aufgeräumt
} finally { b.restore() }
})
test('usePultFeed: nur die letzte Anfrage zählt (späte alte Antwort wird verworfen); refresh() holt sofort neu', async () => {
const { usePultFeed, __mount } = await loadFeed()
const b = fakeBrowser()
try {
const api = fakeApi()
const hook = __mount(() => usePultFeed({ api, statuses: 'offen' }), () => ({}))
const first = api.calls[0]
const refreshed = hook.result().refresh() // zweite Anfrage, während die erste noch läuft
assert.equal(api.calls.length, 2)
api.calls[1].resolve(itemsOf('neu')); await refreshed; await settle()
first.resolve(itemsOf('alt')); await settle() // die ältere Antwort kommt zuletzt und darf nichts überschreiben
assert.deepEqual(hook.result().items.map((i) => i.$id), ['neu'])
// ein später Fehler der alten Anfrage zählt ebenfalls nicht
b.tick(); const late = api.calls[2]
b.tick(); api.calls[3].resolve(itemsOf('neuer')); await settle()
late.reject(new Error('zu spät')); await settle()
assert.deepEqual([hook.result().items.map((i) => i.$id), hook.result().error], [['neuer'], null])
hook.unmount()
} finally { b.restore() }
})
test('usePultFeed: Wechsel des Filters zeigt nie die Einträge des alten Filters und verwirft dessen späte Antwort', async () => {
const { usePultFeed, __mount } = await loadFeed()
const b = fakeBrowser()
try {
const api = fakeApi()
let statuses = 'offen,in_arbeit'
const hook = __mount(() => usePultFeed({ api, statuses }), () => ({}))
api.calls[0].resolve(itemsOf('a')); await settle()
statuses = 'veraltet'; hook.rerender()
assert.equal(api.calls.at(-1).status, 'veraltet')
assert.deepEqual([hook.result().loading, hook.result().items], [true, []])
statuses = 'gesendet,verworfen,erledigt'; hook.rerender() // noch einmal gewechselt, bevor 'veraltet' antwortet
api.calls.find((c) => c.status === 'veraltet').resolve(itemsOf('v')); await settle()
assert.deepEqual([hook.result().loading, hook.result().items], [true, []])
api.calls.at(-1).resolve(itemsOf('e')); await settle()
assert.deepEqual([hook.result().loading, hook.result().items.map((i) => i.$id)], [false, ['e']])
hook.unmount()
} finally { b.restore() }
})
test('usePultFeed: nach dem Abbau setzt eine späte Antwort nichts mehr', async () => {
const { usePultFeed, __mount } = await loadFeed()
const b = fakeBrowser()
try {
const api = fakeApi()
const hook = __mount(() => usePultFeed({ api, statuses: 'offen' }), () => ({}))
hook.unmount()
api.calls[0].resolve(itemsOf('a')); await settle()
assert.equal(hook.lateUpdates(), 0)
// auch ein später Fehler setzt nichts
const second = fakeApi()
const again = __mount(() => usePultFeed({ api: second, statuses: 'offen' }), () => ({}))
again.unmount()
second.calls[0].reject(new Error('zu spät')); await settle()
assert.equal(again.lateUpdates(), 0)
} finally { b.restore() }
})
// ---- PultList wird mit echtem React auf einen Text gerendert (renderToStaticMarkup); der Feed, die Mitarbeiterliste und die Zwischenablage sind Attrappen ----
const STUBS = {
usePultFeed: 'export function usePultFeed() { return globalThis.__pultFeed }',
useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }',
CopyableCredential: 'export async function copyTextChecked() {}',
}
async function loadList() {
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const stubs = {
name: 'pult-stubs',
setup(build) {
build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-stub' }))
build.onLoad({ filter: /.*/, namespace: 'pult-stub' }, (a) => ({ contents: STUBS[a.path], loader: 'js' }))
},
}
const out = await esbuild.build({
stdin: {
contents: "import { createElement } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport PultList from './src/components/workers/PultList.jsx'\nexport const render = (props) => renderToStaticMarkup(createElement(PultList, props))",
resolveDir: root, loader: 'js',
},
bundle: true, format: 'esm', platform: 'node', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [stubs],
// react-dom/server ist CommonJS und holt sich Node-Module über require: im Data-URL-Modul gibt es keins
banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(process.cwd() + '/');" },
})
return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
}
test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text, sichere Links, Mail-Link mit genau einer Adresse', async () => {
const { render } = await loadList()
const now = Date.now()
const iso = (ms) => new Date(ms).toISOString()
const me = { id: 'u1', name: 'Justin' }
const show = (items, extra = {}) => {
globalThis.__pultFeed = { items, loading: false, error: null, refresh: async () => {}, ...extra }
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
return render({ api: {}, me })
}
const base = { $id: 'a', kind: 'plattform_antwort', status: 'offen', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 3 * 3600e3), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein', city: 'Namborn', postalCode: '66640', score: 72 } }
try {
// offene Plattform-Antwort: alle vier Aktionen, Link mit rel, Kurzinfo, Details; das Textfeld trägt den Entwurf
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 &amp; ö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" aria-label="Anzeige öffnen: T &lt;b&gt;x&lt;\/b&gt;">/)
assert.match(html, /<textarea class="textarea wk-pult-draft"[^>]*>Hallo &lt;script&gt;alert\(1\)&lt;\/script&gt;<\/textarea>/)
// Fremdtext erscheint nie als Markup
assert.ok(!html.includes('<script>') && !html.includes('<img') && !html.includes('<b>x</b>')); assert.ok(html.includes('Original &lt;img src=x onerror=alert(1)&gt;'))
// Kollege hat übernommen: Name statt „Ich kümmere mich“, der Rest bleibt bedienbar (der Dienst entscheidet mit 409); javascript:-Adresse gibt keinen Link, nur „Kopieren“
html = show([{ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2', sourceUrl: 'javascript:alert(1)' }])
assert.ok(html.includes('übernommen von Kenso')); assert.ok(!html.includes('Ich kümmere mich')); assert.ok(html.includes('Als gesendet markieren'))
assert.ok(!html.includes('href="javascript:')); assert.ok(!html.includes('Anzeige öffnen')); assert.match(html, />Kopieren<\/button>/)
// veraltet und „läuft bald ab“
html = show([{ ...base, status: 'veraltet' }, { ...base, $id: 'b', staleAt: iso(now + 5 * 3600e3) }])
assert.ok(html.includes('<span class="badge orange">veraltet</span>')); assert.ok(html.includes('läuft in 5 Std. ab'))
// nicht sendbare Art: Erledigt statt Senden, aber Verwerfen bleibt; abgeschlossene Einträge haben keine Aktion
html = show([{ ...base, kind: 'unsicher', draft: '' }])
assert.ok(html.includes(erledigt)); assert.ok(html.includes('Verwerfen')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('<textarea'))
html = show([{ ...base, status: 'gesendet' }])
assert.ok(!html.includes('row-actions')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen')); assert.ok(html.includes('<summary>Entwurf</summary>'))
// Mail-Antwort: Link mit genau einer Adresse und kodiertem Text; eine eingeschleuste zweite Adresse ergibt keinen Link
const mail = { ...base, kind: 'mail_antwort', draft: 'Guten Tag\nBis bald', sourceUrl: '' }
html = show([{ ...mail, prospect: { $id: 'p2', name: 'Firma', email: 'x@firma.example' } }])
assert.match(html, /<a class="btn btn-primary" href="mailto:x@firma\.example\?subject=Re%3A%20Firma&amp;body=Guten%20Tag%0D%0ABis%20bald">E-Mail schreiben<\/a>/)
assert.ok(!html.includes('Kopieren'))
html = show([{ ...mail, prospect: { $id: 'p2', name: 'Firma', email: 'x@firma.example?bcc=z@w.de' } }])
assert.ok(!html.includes('mailto:')); assert.ok(html.includes('Keine gültige E-Mail-Adresse hinterlegt.'))
// Zustände: laden, leer je Filter, Fehler ohne und mit Einträgen
assert.ok(show([], { loading: true }).includes('Wird geladen'))
assert.ok(show([]).includes('Nichts wartet auf dich'))
html = show([], { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' })
assert.ok(html.includes('Das Pult ist gerade nicht erreichbar')); assert.ok(html.includes('Der Hustler-Dienst ist gerade nicht erreichbar.')); assert.ok(html.includes('Neu laden'))
html = show([base], { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' })
assert.ok(html.includes('class="msg warn"')); assert.ok(html.includes('Suche Webdesigner'))
// die Filter kommen aus PULT_FILTERS, „Wartet“ ist vorgewählt
html = show([])
assert.match(html, /<button type="button" class="on" aria-pressed="true">Wartet<\/button><button type="button" aria-pressed="false">Veraltet<\/button><button type="button" aria-pressed="false">Erledigt<\/button>/)
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
})
// Endprüfung P3 (W5): Reiter „Erledigt“ – was wurde aus dem Eintrag, wer, wann
test('PultList (gerendert): abgeschlossene Einträge zeigen Ergebnis, Person und Zeit der Erledigung statt des Alters ab Anlage (W5)', async () => {
const { render } = await loadList()
const now = Date.now()
const iso = (ms) => new Date(ms).toISOString()
const show = (items) => {
globalThis.__pultFeed = { items, loading: false, error: null, refresh: async () => {} }
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Anna' }]
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
}
const base = { kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 4 * 24 * 3600e3), doneBy: 'mensch:u2', doneAt: iso(now - 2 * 3600e3) }
try {
const html = show([
{ ...base, $id: 'a', status: 'gesendet' },
{ ...base, $id: 'b', status: 'verworfen', title: 'Zweite' },
{ ...base, $id: 'c', status: 'erledigt', kind: 'unsicher', title: 'Dritte', doneBy: 'max' },
])
assert.ok(html.includes('<span class="badge green">Gesendet</span>'))
assert.ok(html.includes('<span class="badge">Verworfen</span>')); assert.ok(html.includes('<span class="badge">Erledigt</span>'))
assert.ok(html.includes('<span>Gesendet von Anna · vor 2 Std.</span>')); assert.ok(html.includes('<span>Verworfen von Anna · vor 2 Std.</span>'))
assert.ok(html.includes('<span>Erledigt von Max (automatisch) · vor 2 Std.</span>'))
assert.ok(!html.includes('vor 4 Tagen')) // das Alter ab Anlage steht bei Abgeschlossenem nicht mehr da
assert.ok(!html.includes('Kollege'))
// offene Zeile: wie bisher Alter ab Anlage, kein Ergebnis-Badge
const open = show([{ ...base, $id: 'd', status: 'offen', doneBy: undefined, doneAt: undefined }])
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 &lt;b&gt;x&lt;/b&gt; &amp; &quot;Co&quot;"'))
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 }
})