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' import { fakeReact } from './helpers/fakeReact.mjs' import { loadModule } from './load.mjs' // HustlerDialog (Detailansicht, Spec E10/E14) läuft wirklich: im Mini-Läufer anstelle von React (helpers/fakeReact.mjs), gerendert wird in einen Baum // aus { tag, props, children }. react-router-dom (Link) und der Spur-Abruf (hooks/useWorkersFeed) sind Attrappen, damit der Test die Spur selbst // liefert; document, Fokus und die Uhr (Date.now) sind ebenfalls Attrappen. Klicks und Tasten ruft der Test von Hand auf. const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`) const NBSP = String.fromCharCode(160) const ROUTER = "import { jsx } from 'react'\nexport function Link({ to, children }) { return jsx('router-link', { to, children }) }\n" const TRACE = ` export function useWorkerTrace(api, workerId) { globalThis.__traceCalls.push({ api, workerId }) return globalThis.__traceState } ` const attrappen = { name: 'hustler-dialog-attrappen', setup(build) { build.onResolve({ filter: /^react-router-dom$/ }, () => ({ path: 'router', namespace: 'attrappe' })) build.onResolve({ filter: /hooks\/useWorkersFeed$/ }, () => ({ path: 'trace', namespace: 'attrappe' })) build.onLoad({ filter: /^router$/, namespace: 'attrappe' }, () => ({ contents: ROUTER, loader: 'js' })) build.onLoad({ filter: /^trace$/, namespace: 'attrappe' }, () => ({ contents: TRACE, loader: 'js' })) }, } let bundle const loadUi = () => (bundle ??= esbuild.build({ stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as HustlerDialog } from './src/components/workers/hochhaus/HustlerDialog.jsx'", resolveDir: ROOT, loader: 'js' }, bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact, attrappen], }).then(importBundle)) const { ROLES } = await loadModule('src/lib/workersRoster.js') const { DETAIL } = await loadModule('src/lib/hustlerWelt.js') // Baum durchsuchen const nodes = (node, match, out = []) => { if (node === null || typeof node === 'string') return out if (match(node)) out.push(node) for (const child of node.children) nodes(child, match, out) return out } const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join('')) const hasClass = (n, name) => String(n.props.className ?? '').split(' ').includes(name) const classes = (n) => String(n.props.className ?? '').split(' ').filter((c) => c !== '') // DOM-Attrappe für den Fokus: document mit body, activeElement und querySelector; ref an Rahmen, Hintergrund, Dialog und die zwei Knöpfe liefert __fakeRef. // Jedes Element merkt sich die Argumente seiner focus()-Aufrufe und setzt beim Fokussieren activeElement. function fakeDom() { const body = { name: 'body', isConnected: true, focusCalls: [] } const doc = { body, activeElement: body, asked: [], found: new Map(), querySelector(selector) { doc.asked.push(selector); return doc.found.get(selector) ?? null } } const element = (name, extra = {}) => ({ name, isConnected: true, focusCalls: [], focus(options) { this.focusCalls.push(options); doc.activeElement = this }, ...extra, }) const made = new Map() const had = Object.hasOwn(globalThis, 'document') const saved = globalThis.document globalThis.document = doc globalThis.__fakeRef = (tag, props) => { const key = props.role ?? props['aria-label'] ?? tag if (!made.has(key)) made.set(key, element(key)) return made.get(key) } return { doc, element, ref: (key) => made.get(key), restore() { if (had) globalThis.document = saved else delete globalThis.document delete globalThis.__fakeRef }, } } // Ein Lauf: Dialog einhängen. props = { workerId, workers, ... }, trace = Ergebnis des Spur-Abrufs, Uhr von Hand (clock), Props und Spur zwischendurch änderbar. const NOW = Date.parse('2026-10-22T12:00:00Z') async function start({ props = {}, trace = { trace: null, loading: false, error: null }, dom = null } = {}) { const { __mount, jsx, HustlerDialog } = await loadUi() const realNow = Date.now const view = { clock: NOW, closed: 0, wechsel: [], dom } Date.now = () => view.clock globalThis.__traceCalls = [] globalThis.__traceState = trace const api = { name: 'api' } let current = { api, workerId: 'lea', workers: {}, onClose: () => { view.closed += 1 }, onWechsel: (id) => { view.wechsel.push(id) }, ...props } const mounted = __mount(jsx(() => jsx(HustlerDialog, current), {})) Object.assign(view, { api, traceCalls: () => globalThis.__traceCalls, tree: () => mounted.tree(), text: () => textOf(mounted.tree()), setProps(next) { current = { ...current, ...next }; mounted.rerender() }, setTrace(next) { globalThis.__traceState = next; mounted.rerender() }, find: (match) => nodes(mounted.tree(), match), one(match, what) { const found = nodes(mounted.tree(), match) assert.equal(found.length, 1, `${what} (gefunden: ${found.length})`) return found[0] }, byClass: (name) => nodes(mounted.tree(), (n) => hasClass(n, name)), dialog: () => view.one((n) => n.tag === 'section' && n.props.role === 'dialog', 'Dialog'), rahmen: () => view.one((n) => hasClass(n, 'hk-dialog-ueber'), 'Rahmen'), hinten: () => view.one((n) => hasClass(n, 'hk-dialog-hinten'), 'Hintergrund'), knopf: (label) => view.one((n) => n.tag === 'button' && n.props['aria-label'] === label, `Knopf ${label}`), figur: () => view.one((n) => classes(n).includes('hk-spr') && classes(n).some((c) => c.startsWith('hk-gr-')), 'Figur'), // Fakten aus der Liste: { Modell: 'Claude …', Etage: '…', … } fakten() { const dl = view.one((n) => n.tag === 'dl', 'Faktenliste') const out = {} for (let i = 0; i + 1 < dl.children.length; i += 2) out[textOf(dl.children[i])] = textOf(dl.children[i + 1]) return out }, spurZeilen: () => view.byClass('hk-dialog-spurzeile').map((li) => ({ zeit: textOf(view.sub(li, 'hk-dialog-zeit')), text: textOf(view.sub(li, 'hk-dialog-spurtext')), li })), sub: (node, name) => nodes(node, (n) => hasClass(n, name))[0], taste(key, extra = {}) { const e = { key, shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, prevented: false, stopped: false, preventDefault() { this.prevented = true }, stopPropagation() { this.stopped = true }, ...extra } view.rahmen().props.onKeyDown(e) return e }, end() { mounted.unmount(); Date.now = realNow; delete globalThis.__traceCalls; delete globalThis.__traceState; if (dom) dom.restore() }, }) return view } const lauf = async (opts, fn) => { const ui = await start(opts) try { await fn(ui) } finally { ui.end() } } const EINTRAG = (status, extra = {}) => ({ status, task: '', lastTs: null, connected: status !== 'grau', counters: {}, ...extra }) // --------------------------------------------------------------------------------------------------------------------------------------------- // Felder und Szene je Status test('arbeitender Haiku (Lea): Dialog-Rolle, Name, Status, Fakten, Büro-Szene mit Figur und Monitor, Nummer', async () => { const workers = { lea: EINTRAG('arbeitet', { task: 'Liest das Impressum von „Café Morgenrot“', counters: { gelesen: 3 } }) } const trace = { trace: { worker: { modelId: 'claude-haiku-5-5' }, today: { costUsd: 0.21, tokens: { in: 20000, out: 4800 }, counters: { gelesen: 7, passend: 2 } }, events: [] }, loading: false, error: null } await lauf({ props: { workerId: 'lea', workers }, trace }, (ui) => { const d = ui.dialog() assert.equal(d.props['aria-modal'], 'true') assert.equal(d.props['aria-label'], 'Hustler Lea') assert.equal(textOf(ui.one((n) => n.tag === 'h2', 'Überschrift')), 'Lea') const status = ui.one((n) => hasClass(n, 'hk-dialog-status'), 'Status') assert.equal(textOf(status).trim(), 'Arbeitet') assert.equal(status.props.style['--hk-status'], '#4ade5a') assert.deepEqual(ui.fakten(), { Modell: 'Claude Haiku 5.5', Etage: 'Ohne Website · Läden & Gastro', Aufgabe: 'Branchen-Scout für Läden und Gastro ohne oder mit veralteter Website.', Gerade: 'Liest das Impressum von „Café Morgenrot“', Heute: '7 gelesen · 2 passend', Kosten: `0,21${NBSP}$ · 24.800 Tokens`, }) assert.equal(textOf(ui.one((n) => hasClass(n, 'hk-dialog-nummer'), 'Nummer')), '9 / 19') // Szene und Figur const szene = ui.one((n) => hasClass(n, 'hk-sz'), 'Szene') assert.equal(szene.props.className, 'hk-sz hk-sz-buero') const figur = ui.figur() assert.equal(figur.props.className, 'hk-spr hk-gr-muetze-arbeiten') assert.deepEqual(figur.props.style, { left: DETAIL.figur.buero.left, top: DETAIL.figur.buero.top }) assert.equal(figur.props['aria-hidden'], 'true') const monitor = ui.one((n) => hasClass(n, 'hk-monitor-gross'), 'Monitor') assert.deepEqual(monitor.props.style, { left: DETAIL.monitor.left, top: DETAIL.monitor.top }) for (const weg of ['hk-dampf', 'hk-zzz-gross', 'hk-wirbel', 'hk-dialog-schild', 'hk-alarm', 'hk-gedimmt']) assert.equal(ui.byClass(weg).length, 0, `${weg} nicht da`) assert.ok(ui.text().includes('Spur')) }) }) test('Fakten ohne Spur-Daten: Modell aus dem Roster, Gerade und Heute aus dem Zustandseintrag, sonst Strich', async () => { const workers = { bea: EINTRAG('wartet', { task: 'Wartet auf Leas Kandidaten.', counters: { bewertet: 2 } }) } await lauf({ props: { workerId: 'bea', workers } }, (ui) => { assert.deepEqual(ui.fakten(), { Modell: 'Claude Sonnet 5.5', Etage: 'Bewertung · Waage', Aufgabe: 'Bewertet Anfragen und Kandidaten und verteilt Top 3 und Rest.', Gerade: 'Wartet auf Leas Kandidaten.', Heute: '2 bewertet', Kosten: '– · – Tokens', }) // wartet: Kaffeedampf, keine anderen Zusätze assert.equal(ui.byClass('hk-dampf').length, 1) assert.deepEqual(ui.byClass('hk-dampf')[0].props.style, { left: DETAIL.dampf.left, top: DETAIL.dampf.top }) assert.equal(ui.byClass('hk-monitor-gross').length + ui.byClass('hk-zzz-gross').length, 0) assert.equal(ui.figur().props.className, 'hk-spr hk-gr-kappe-warten') }) }) test('Leerlauf: Zzz neben der schlafenden Figur', async () => { await lauf({ props: { workerId: 'hanna', workers: { hanna: EINTRAG('leerlauf') } } }, (ui) => { assert.equal(ui.figur().props.className, 'hk-spr hk-gr-muetze-schlafen') const zzz = ui.one((n) => hasClass(n, 'hk-zzz-gross'), 'Zzz') assert.deepEqual(zzz.props.style, { left: DETAIL.zzz.left, top: DETAIL.zzz.top }) assert.equal(ui.byClass('hk-monitor-gross').length + ui.byClass('hk-dampf').length, 0) const status = ui.one((n) => hasClass(n, 'hk-dialog-status'), 'Status') assert.equal(textOf(status).trim(), 'Leerlauf') assert.equal(status.props.style['--hk-status'], '#9aa6d6') }) }) test('grauer Such-Scout (Kim, nicht angeschlossen): Stecker-Szene, stehende Figur, Schild „noch nicht angeschlossen“, kein Monitor', async () => { await lauf({ props: { workerId: 'kim', workers: { kim: EINTRAG('grau', { connected: false }) } } }, (ui) => { assert.equal(ui.one((n) => hasClass(n, 'hk-sz'), 'Szene').props.className, 'hk-sz hk-sz-nicht') const figur = ui.figur() assert.equal(figur.props.className, 'hk-spr hk-gr-muetze-nicht') assert.deepEqual(figur.props.style, { left: DETAIL.figur.nicht.left, top: DETAIL.figur.nicht.top }) const schild = ui.one((n) => hasClass(n, 'hk-dialog-schild'), 'Schild') assert.equal(textOf(schild), 'noch nicht angeschlossen') assert.deepEqual(schild.props.style, { left: DETAIL.schild.left, top: DETAIL.schild.top, width: DETAIL.schild.w, height: DETAIL.schild.h }) for (const weg of ['hk-monitor-gross', 'hk-dampf', 'hk-zzz-gross', 'hk-wirbel', 'hk-alarm']) assert.equal(ui.byClass(weg).length, 0, `${weg} nicht da`) const status = ui.one((n) => hasClass(n, 'hk-dialog-status'), 'Status') assert.equal(textOf(status).trim(), 'Noch nicht angeschlossen') assert.equal(status.props.style['--hk-status'], '#a4a2ad') assert.equal(ui.fakten().Etage, 'Anfragen · Kleinanzeigen') }) }) test('Fehler: stehende Figur im Büro mit rotem „!“-Marker (hk-alarm hk-alarm-gross), Status „Fehler“ in Rot', async () => { await lauf({ props: { workerId: 'rita', workers: { rita: EINTRAG('fehler', { task: 'Suche fehlgeschlagen' }) } } }, (ui) => { assert.equal(ui.one((n) => hasClass(n, 'hk-sz'), 'Szene').props.className, 'hk-sz hk-sz-buero') const figur = ui.figur() assert.equal(figur.props.className, 'hk-spr hk-gr-muetze-nicht') assert.deepEqual(figur.props.style, { left: DETAIL.figur.buero.left, top: DETAIL.figur.buero.top }) const alarm = ui.one((n) => hasClass(n, 'hk-alarm'), 'Alarm') assert.equal(alarm.props.className, 'hk-alarm hk-alarm-gross') assert.equal(alarm.props['aria-hidden'], 'true') assert.equal(textOf(alarm), '!') assert.ok(figur.children.includes(alarm), 'der Marker sitzt an der Figur (über dem Kopf)') const status = ui.one((n) => hasClass(n, 'hk-dialog-status'), 'Status') assert.equal(textOf(status).trim(), 'Fehler') assert.equal(status.props.style['--hk-status'], '#ff5a5a') assert.equal(ui.fakten().Gerade, 'Suche fehlgeschlagen') assert.equal(ui.byClass('hk-schild').length + ui.byClass('hk-dialog-schild').length + ui.byClass('hk-monitor-gross').length, 0) }) }) test('Overseer: heißt „Overseer“, Brücken-Szene mit Wirbel; bei Leerlauf gedimmt, bei „arbeitet“ voll', async () => { await lauf({ props: { workerId: 'otto', workers: { otto: EINTRAG('leerlauf') } } }, (ui) => { assert.equal(ui.dialog().props['aria-label'], 'Hustler Overseer') assert.equal(textOf(ui.one((n) => n.tag === 'h2', 'Überschrift')), 'Overseer') assert.equal(ui.one((n) => hasClass(n, 'hk-sz'), 'Szene').props.className, 'hk-sz hk-sz-bruecke') const figur = ui.figur() assert.equal(figur.props.className, 'hk-spr hk-gr-overseer hk-gedimmt') assert.deepEqual(figur.props.style, { left: DETAIL.figur.bruecke.left, top: DETAIL.figur.bruecke.top }) assert.deepEqual(ui.one((n) => hasClass(n, 'hk-wirbel'), 'Wirbel').props.style, { left: DETAIL.wirbel.left, top: DETAIL.wirbel.top }) // kein Zzz, kein Dampf, kein Monitor, kein Schild (Overseer ist ein Kristall) for (const weg of ['hk-zzz-gross', 'hk-dampf', 'hk-monitor-gross', 'hk-dialog-schild']) assert.equal(ui.byClass(weg).length, 0, `${weg} nicht da`) assert.equal(ui.fakten().Etage, 'Kommandobrücke · Brücke') assert.equal(textOf(ui.one((n) => hasClass(n, 'hk-dialog-nummer'), 'Nummer')), '1 / 19') ui.setProps({ workers: { otto: EINTRAG('arbeitet') } }) assert.equal(ui.figur().props.className, 'hk-spr hk-gr-overseer') assert.equal(ui.byClass('hk-wirbel').length, 1) }) }) test('unbekannter Hustler oder fehlender Eintrag: kein Absturz, Name = Kennung, Leerlauf-Figur im Büro', async () => { await lauf({ props: { workerId: 'constructor', workers: {} } }, (ui) => { assert.equal(ui.dialog().props['aria-label'], 'Hustler constructor') assert.equal(ui.fakten().Etage, '–') assert.equal(ui.fakten().Gerade, '–') assert.equal(ui.one((n) => hasClass(n, 'hk-sz'), 'Szene').props.className, 'hk-sz hk-sz-buero') }) await lauf({ props: { workerId: 'lea', workers: undefined } }, (ui) => { assert.equal(ui.figur().props.className, 'hk-spr hk-gr-muetze-schlafen') }) }) test('workerId null: nichts wird angezeigt, der Abruf bekommt null', async () => { await lauf({ props: { workerId: null } }, (ui) => { assert.equal(ui.tree(), null) assert.deepEqual(ui.traceCalls().map((c) => c.workerId), [null]) }) }) test('alle 19 Hustler in allen 5 Status: Klassen der Szene, Figur und Zusätze stehen im CSS, jeder bekommt Name und Nummer', async () => { const css = ['hochhaus.css', 'hustler-ui.css', 'hustler-detail.css'].map((f) => fs.readFileSync(path.join(ROOT, 'src/styles/hustler', f), 'utf8')).join('\n') const bekannt = new Set([...css.matchAll(/\.(hk-[a-z0-9-]+)/g)].map((m) => m[1])) const stati = [['arbeitet', {}], ['wartet', {}], ['leerlauf', {}], ['grau', { connected: false }], ['fehler', {}]] const ui = await start({ props: { workerId: 'otto', workers: {} } }) try { for (const rolle of ROLES) { for (const [status, extra] of stati) { ui.setProps({ workerId: rolle.workerId, workers: { [rolle.workerId]: EINTRAG(status, extra) } }) const alle = ui.find(() => true) for (const n of alle) for (const c of classes(n)) if (c.startsWith('hk-')) assert.ok(bekannt.has(c), `${rolle.workerId}/${status}: Klasse ${c} fehlt im CSS`) assert.ok(textOf(ui.one((n) => n.tag === 'h2', 'Überschrift')).length > 0) assert.equal(textOf(ui.one((n) => hasClass(n, 'hk-dialog-nummer'), 'Nummer')), `${ROLES.indexOf(rolle) + 1} / 19`) assert.equal(ui.figur().props.style.left !== undefined, true) } } } finally { ui.end() } }) // --------------------------------------------------------------------------------------------------------------------------------------------- // Spur: Laden, Fehler, leer, Links test('Spur: während des ersten Ladens „Spur wird geladen …“, nicht „Noch keine Einträge.“', async () => { await lauf({ props: { workerId: 'lea' }, trace: { trace: null, loading: true, error: null } }, (ui) => { assert.ok(ui.text().includes('Spur wird geladen …')) assert.ok(!ui.text().includes('Noch keine Einträge.')) assert.equal(ui.spurZeilen().length, 0) assert.equal(ui.find((n) => n.props.role === 'alert').length, 0) // Fakten stehen schon (Roster + Zustandseintrag), auch ohne Spur assert.equal(ui.fakten().Modell, 'Claude Haiku 5.5') ui.setTrace({ trace: { events: [] }, loading: false, error: null }) assert.ok(!ui.text().includes('Spur wird geladen')) }) }) test('Spur: keine Ereignisse -> „Noch keine Einträge.“', async () => { await lauf({ props: { workerId: 'lea' }, trace: { trace: { events: [] }, loading: false, error: null } }, (ui) => { assert.ok(ui.text().includes('Noch keine Einträge.')) assert.equal(ui.byClass('hk-dialog-spurliste').length, 0) }) }) test('Spur: Fehler des Abrufs als role=alert; ohne Daten kein „Noch keine Einträge.“, mit alter Spur bleibt sie stehen', async () => { await lauf({ props: { workerId: 'lea' }, trace: { trace: null, loading: false, error: 'Der Hustler-Dienst ist nicht erreichbar.' } }, (ui) => { const alerts = ui.find((n) => n.props.role === 'alert') assert.equal(alerts.length, 1) assert.equal(textOf(alerts[0]), 'Der Hustler-Dienst ist nicht erreichbar.') assert.ok(!ui.text().includes('Noch keine Einträge.')) assert.ok(!ui.text().includes('Spur wird geladen')) ui.setTrace({ trace: { events: [{ id: 'e1', ts: '2026-10-22T01:02:00Z', text: 'Lauf gestartet' }] }, loading: false, error: 'Zeitüberschreitung' }) assert.equal(ui.find((n) => n.props.role === 'alert').length, 1) assert.deepEqual(ui.spurZeilen().map((z) => [z.zeit, z.text]), [['03:02', 'Lauf gestartet']]) ui.setTrace({ trace: { events: [{ id: 'e1', ts: '2026-10-22T01:02:00Z', text: 'Lauf gestartet' }] }, loading: false, error: null }) assert.equal(ui.find((n) => n.props.role === 'alert').length, 0) }) }) test('Spur: neueste zuerst mit Zeit und Text; der Abruf läuft mit api und Hustler-Kennung, beim Blättern für den neuen Hustler', async () => { const events = [ { id: 'a', ts: '2026-10-21T10:00:00Z', text: 'Gestern' }, { id: 'b', ts: '2026-10-22T01:02:00Z', text: 'Heute früh' }, { id: 'c', ts: '2026-10-22T09:30:00Z', text: 'Heute Vormittag' }, ] await lauf({ props: { workerId: 'lea' }, trace: { trace: { events }, loading: false, error: null } }, (ui) => { assert.deepEqual(ui.spurZeilen().map((z) => [z.zeit, z.text]), [['11:30', 'Heute Vormittag'], ['03:02', 'Heute früh'], ['21.10. 12:00', 'Gestern']]) assert.equal(ui.byClass('hk-dialog-spurliste')[0].tag, 'ol') assert.deepEqual(ui.traceCalls().map((c) => [c.api, c.workerId]), [[ui.api, 'lea']]) ui.setProps({ workerId: 'bea' }) assert.equal(ui.traceCalls().at(-1).workerId, 'bea') }) }) test('Spur-Links: unsichere (javascript:, //host, /\\host, data:) bleiben Text; extern öffnet in neuem Tab mit noopener; intern über den Router-Link', async () => { const events = [ { id: '1', ts: '2026-10-22T09:00:00Z', text: 'Skript', link: 'javascript:alert(1)' }, { id: '2', ts: '2026-10-22T08:59:00Z', text: 'Extern', link: 'https://example.org/pfad?x=1' }, { id: '3', ts: '2026-10-22T08:58:00Z', text: 'Intern', link: '/leads/abc?tab=1' }, { id: '4', ts: '2026-10-22T08:57:00Z', text: 'Fremder Host', link: '//evil.example/x' }, { id: '5', ts: '2026-10-22T08:56:00Z', text: 'Rückstrich', link: '/\\evil.example' }, { id: '6', ts: '2026-10-22T08:55:00Z', text: 'Daten', link: 'data:text/html,x' }, { id: '7', ts: '2026-10-22T08:54:00Z', text: 'Ohne Link' }, { id: '8', ts: '2026-10-22T08:53:00Z', text: 'Steuerzeichen', link: '/ok\n.evil' }, ] await lauf({ props: { workerId: 'lea' }, trace: { trace: { events }, loading: false, error: null } }, (ui) => { const zeilen = ui.spurZeilen() assert.equal(zeilen.length, 8) const ziele = (li) => nodes(li, (n) => n.tag === 'a' || n.tag === 'router-link') const byText = (t) => zeilen.find((z) => z.text === t) for (const t of ['Skript', 'Fremder Host', 'Rückstrich', 'Daten', 'Ohne Link', 'Steuerzeichen']) assert.equal(ziele(byText(t).li).length, 0, `${t} bleibt Text`) const extern = ziele(byText('Extern').li) assert.equal(extern.length, 1) assert.equal(extern[0].tag, 'a') assert.equal(extern[0].props.href, 'https://example.org/pfad?x=1') assert.equal(extern[0].props.target, '_blank') assert.equal(extern[0].props.rel, 'noopener noreferrer') const intern = ziele(byText('Intern').li) assert.equal(intern.length, 1) assert.equal(intern[0].tag, 'router-link') assert.equal(intern[0].props.to, '/leads/abc?tab=1') assert.equal(intern[0].props.href, undefined) // nirgends ein gefährliches Ziel im Baum for (const n of ui.find((x) => x.tag === 'a' || x.tag === 'router-link')) assert.ok(!/^javascript:|^data:|^\/\//i.test(String(n.props.href ?? n.props.to))) }) }) // --------------------------------------------------------------------------------------------------------------------------------------------- // Tasten, Blättern, Hintergrund test('Esc schließt (onClose, Taste behandelt und nicht weitergereicht); „Schließen“ schließt ebenfalls', async () => { await lauf({ props: { workerId: 'lea' } }, (ui) => { const e = ui.taste('Escape') assert.equal(ui.closed, 1) assert.equal(e.prevented, true) assert.equal(e.stopped, true) ui.knopf('Schließen').props.onClick({}) assert.equal(ui.closed, 2) // andere Tasten tun nichts for (const k of ['Enter', 'a', ' ', 'ArrowUp', 'ArrowDown']) { const x = ui.taste(k) assert.equal(x.prevented, false, k) } assert.equal(ui.closed, 2) assert.deepEqual(ui.wechsel, []) }) }) test('←/→ blättern in Roster-Reihenfolge mit Umlauf (otto ↔ arne); ◂ ▸ tun dasselbe; Strg/Alt/Meta + Pfeil bleibt dem Browser', async () => { await lauf({ props: { workerId: 'otto' } }, (ui) => { const links = ui.taste('ArrowLeft') assert.deepEqual(ui.wechsel, ['arne']) assert.equal(links.prevented, true) assert.equal(links.stopped, true) ui.taste('ArrowRight') assert.deepEqual(ui.wechsel, ['arne', 'rita']) ui.setProps({ workerId: 'arne' }) ui.taste('ArrowRight') assert.deepEqual(ui.wechsel.at(-1), 'otto') ui.setProps({ workerId: 'lea' }) ui.taste('ArrowRight') ui.taste('ArrowLeft') assert.deepEqual(ui.wechsel.slice(-2), ['bea', 'sven']) // ◂ ▸ ui.knopf('Nächster Hustler').props.onClick({}) ui.knopf('Vorheriger Hustler').props.onClick({}) assert.deepEqual(ui.wechsel.slice(-2), ['bea', 'sven']) ui.setProps({ workerId: 'arne' }) ui.knopf('Nächster Hustler').props.onClick({}) assert.equal(ui.wechsel.at(-1), 'otto') ui.setProps({ workerId: 'otto' }) ui.knopf('Vorheriger Hustler').props.onClick({}) assert.equal(ui.wechsel.at(-1), 'arne') // mit Modifikator nicht abfangen const n = ui.wechsel.length for (const mod of ['altKey', 'ctrlKey', 'metaKey']) { const e = ui.taste('ArrowLeft', { [mod]: true }) assert.equal(e.prevented, false, mod) } assert.equal(ui.wechsel.length, n) // Blättern schließt nicht assert.equal(ui.closed, 0) }) }) test('Hintergrund-Klick schließt erst 400 ms nach dem Öffnen (Uhr über Date.now)', async () => { await lauf({ props: { workerId: 'lea' } }, (ui) => { const hinten = ui.hinten() assert.equal(hinten.props['aria-label'], 'Detailansicht schließen') assert.equal(hinten.props.tabIndex, -1) hinten.props.onClick({}) assert.equal(ui.closed, 0, 'sofort nach dem Öffnen: nichts') ui.clock += 399 ui.hinten().props.onClick({}) assert.equal(ui.closed, 0, '399 ms: nichts') ui.clock += 1 ui.hinten().props.onClick({}) assert.equal(ui.closed, 0, 'genau 400 ms: noch nichts') ui.clock += 1 ui.hinten().props.onClick({}) assert.equal(ui.closed, 1, '401 ms: schließt') // „Schließen“ und Esc sind nicht gesperrt ui.knopf('Schließen').props.onClick({}) assert.equal(ui.closed, 2) }) }) test('Hintergrund-Sperre gilt ab dem Öffnen: wird der Dialog später (neu) geöffnet, beginnt die Sperre von vorn', async () => { await lauf({ props: { workerId: null } }, (ui) => { ui.clock += 10000 ui.setProps({ workerId: 'lea' }) ui.hinten().props.onClick({}) assert.equal(ui.closed, 0, 'frisch geöffnet') ui.clock += 500 ui.hinten().props.onClick({}) assert.equal(ui.closed, 1) }) }) // --------------------------------------------------------------------------------------------------------------------------------------------- // Fokus: auf „Schließen“ beim Öffnen, Falle, Rückgabe test('Fokus beim Öffnen: auf „Schließen“ (ohne Scrollen); die Falle hält Tab und Umschalt+Tab im Dialog', async () => { const dom = fakeDom() const opener = dom.element('figur') dom.doc.activeElement = opener await lauf({ props: { workerId: 'lea' }, dom }, (ui) => { const erster = dom.ref('Schließen') const letzter = dom.ref('Nächster Hustler') assert.ok(erster && letzter, 'refs an Schließen und ▸') assert.equal(dom.doc.activeElement, erster) assert.deepEqual(erster.focusCalls, [{ preventScroll: true }]) // Tab auf dem letzten Knopf (▸) -> zurück zu „Schließen“ dom.doc.activeElement = letzter let e = ui.taste('Tab') assert.equal(e.prevented, true) assert.equal(dom.doc.activeElement, erster) // Umschalt+Tab auf „Schließen“, auf dem Dialog und auf dem Hintergrund -> ▸ for (const start of [erster, dom.ref('dialog'), dom.ref('Detailansicht schließen')]) { dom.doc.activeElement = start e = ui.taste('Tab', { shiftKey: true }) assert.equal(e.prevented, true, start.name) assert.equal(dom.doc.activeElement, letzter, start.name) } // dazwischen läuft Tab von selbst (Links der Spur, ◂): nichts abfangen const mitte = dom.element('◂') dom.doc.activeElement = mitte for (const shiftKey of [false, true]) { e = ui.taste('Tab', { shiftKey }) assert.equal(e.prevented, false) assert.equal(dom.doc.activeElement, mitte) } // Tab auf „Schließen“ und Umschalt+Tab auf ▸ laufen ebenfalls von selbst dom.doc.activeElement = erster assert.equal(ui.taste('Tab').prevented, false) dom.doc.activeElement = letzter assert.equal(ui.taste('Tab', { shiftKey: true }).prevented, false) }) }) test('Blättern lässt den Fokus, wo er ist (die Knöpfe bleiben dieselben Elemente)', async () => { const dom = fakeDom() await lauf({ props: { workerId: 'lea' }, dom }, (ui) => { const letzter = dom.ref('Nächster Hustler') letzter.focus() const aufrufe = letzter.focusCalls.length ui.setProps({ workerId: 'bea' }) assert.equal(dom.doc.activeElement, letzter) assert.equal(letzter.focusCalls.length, aufrufe) assert.equal(dom.ref('Schließen').focusCalls.length, 1, 'nur das Öffnen fokussiert „Schließen“') }) }) test('Fokus-Rückgabe: beim Schließen geht der Fokus an das Element zurück, das beim Öffnen aktiv war', async () => { const dom = fakeDom() const opener = dom.element('figur lea') dom.doc.activeElement = opener const ui = await start({ props: { workerId: 'lea' }, dom }) try { assert.notEqual(dom.doc.activeElement, opener) ui.setProps({ workerId: 'bea' }) // Blättern ändert den Rückgabeort nicht assert.equal(opener.focusCalls.length, 0) ui.setProps({ workerId: null }) // die Seite schließt assert.equal(ui.tree(), null) assert.equal(dom.doc.activeElement, opener) assert.deepEqual(opener.focusCalls, [{ preventScroll: true }]) assert.deepEqual(dom.doc.asked, [], 'kein Suchen, solange das Element noch da ist') } finally { ui.end() } }) test('Fokus-Rückgabe beim Abbau der Komponente (Seite wechselt den Bildschirm) und beim erneuten Öffnen mit neuem Opener', async () => { const dom = fakeDom() const erster = dom.element('figur 1') const zweiter = dom.element('figur 2') dom.doc.activeElement = erster const ui = await start({ props: { workerId: 'lea' }, dom }) ui.setProps({ workerId: null }) assert.equal(dom.doc.activeElement, erster) dom.doc.activeElement = zweiter ui.setProps({ workerId: 'kim' }) assert.notEqual(dom.doc.activeElement, zweiter) assert.equal(dom.ref('Schließen').focusCalls.length, 2, 'beim zweiten Öffnen wieder auf „Schließen“') ui.end() // Abbau assert.equal(dom.doc.activeElement, zweiter) assert.deepEqual(zweiter.focusCalls, [{ preventScroll: true }]) }) test('Fokus-Rückgabe: ist das Element nicht mehr im Dokument, geht der Fokus an die Figur des zuletzt gezeigten Hustlers', async () => { const dom = fakeDom() const opener = dom.element('figur lea') const figurArne = dom.element('figur arne') dom.doc.found.set('[data-hk-hustler="arne"]', figurArne) dom.doc.activeElement = opener const ui = await start({ props: { workerId: 'lea' }, dom }) try { ui.setProps({ workerId: 'arne' }) opener.isConnected = false ui.setProps({ workerId: null }) assert.deepEqual(dom.doc.asked, ['[data-hk-hustler="arne"]']) assert.equal(dom.doc.activeElement, figurArne) assert.deepEqual(figurArne.focusCalls, [{ preventScroll: true }]) assert.equal(opener.focusCalls.length, 0) } finally { ui.end() } }) test('Fokus-Rückgabe: ohne Opener (Fokus lag auf body) ebenfalls die Figur; findet sich keine, passiert nichts', async () => { const dom = fakeDom() const figurLea = dom.element('figur lea') dom.doc.found.set('[data-hk-hustler="lea"]', figurLea) const ui = await start({ props: { workerId: 'lea' }, dom }) // activeElement = body ui.setProps({ workerId: null }) assert.equal(dom.doc.activeElement, figurLea) assert.deepEqual(figurLea.focusCalls, [{ preventScroll: true }]) dom.doc.activeElement = dom.doc.body ui.setProps({ workerId: 'bea' }) assert.equal(dom.doc.activeElement, dom.ref('Schließen')) ui.end() // [data-hk-hustler="bea"] gibt es nicht assert.deepEqual(dom.doc.asked.at(-1), '[data-hk-hustler="bea"]') assert.equal(dom.doc.activeElement, dom.ref('Schließen'), 'nichts gefunden: der Fokus bleibt, kein Absturz') }) test('Fokus-Rückgabe: Kennungen mit Sonderzeichen gehen nie in einen Selektor', async () => { const dom = fakeDom() const ui = await start({ props: { workerId: 'x"] , [data-x="y' }, dom }) ui.end() assert.deepEqual(dom.doc.asked, []) }) // --------------------------------------------------------------------------------------------------------------------------------------------- // Quelltext und Stil // --------------------------------------------------------------------------------------------------------------------------------------------- // Korrekturen der Endprüfung (T13): Ansage beim Blättern, Pause, Handy quer, Überlagerung, weniger Bewegung test('M6 Blättern wird angesagt: Live-Region (polite) ist beim Öffnen leer, nach ◂ ▸ und ←/→ steht Name, Status und Nummer darin; Öffnen sagt nichts an', async () => { const workers = { lea: EINTRAG('arbeitet'), bea: EINTRAG('wartet'), sven: EINTRAG('fehler'), otto: EINTRAG('leerlauf'), arne: EINTRAG('grau', { connected: false }) } await lauf({ props: { workerId: 'lea', workers } }, (ui) => { const region = () => ui.one((n) => n.props['aria-live'] === 'polite', 'Live-Region') assert.equal(region().props['aria-atomic'], 'true'); assert.ok(hasClass(region(), 'hk-nur-sr')) assert.equal(textOf(region()), '', 'beim Öffnen nichts ansagen') ui.knopf('Nächster Hustler').props.onClick({}) assert.equal(textOf(region()), 'Bea, Wartet auf Auftrag, 10 von 19') assert.deepEqual(ui.wechsel, ['bea']) ui.setProps({ workerId: 'bea' }) assert.equal(textOf(region()), 'Bea, Wartet auf Auftrag, 10 von 19', 'bleibt stehen, bis neu geblättert wird') ui.taste('ArrowLeft') assert.equal(textOf(region()), 'Lea, Arbeitet, 9 von 19') ui.setProps({ workerId: 'lea' }) ui.taste('ArrowLeft') assert.equal(textOf(region()), 'Sven, Fehler, 8 von 19') ui.setProps({ workerId: 'otto' }) ui.knopf('Vorheriger Hustler').props.onClick({}) assert.equal(textOf(region()), 'Arne, Noch nicht angeschlossen, 19 von 19', 'Umlauf') // die Ansage ist eine Momentaufnahme: ein späterer Statuswechsel meldet sich nicht von selbst ui.setProps({ workerId: 'arne', workers: { ...workers, arne: EINTRAG('arbeitet') } }) assert.equal(textOf(region()), 'Arne, Noch nicht angeschlossen, 19 von 19') // der Dialog selbst kennt den neuen Namen assert.equal(ui.dialog().props['aria-label'], 'Hustler Arne') // ein Hustler ohne Eintrag in der Tabelle: Leerlauf ui.setProps({ workerId: 'lea', workers: {} }) ui.knopf('Nächster Hustler').props.onClick({}) assert.equal(textOf(region()), 'Bea, Leerlauf, 10 von 19') // Dialog zu und neu auf: wieder leer ui.setProps({ workerId: null }) ui.setProps({ workerId: 'kim', workers }) assert.equal(textOf(region()), '') }) }) test('R3 Pause: bei paused trägt die Überlagerung hk-steht (alle Animationen im Dialog stehen), sonst nicht', async () => { await lauf({ props: { workerId: 'lea', workers: { lea: EINTRAG('arbeitet') }, paused: false } }, (ui) => { assert.ok(!hasClass(ui.rahmen(), 'hk-steht')) ui.setProps({ paused: true }) assert.ok(hasClass(ui.rahmen(), 'hk-steht')); assert.ok(hasClass(ui.rahmen(), 'hk-dialog-ueber')) ui.setProps({ paused: 'ja' }) assert.ok(!hasClass(ui.rahmen(), 'hk-steht'), 'nur echtes true') ui.setProps({ paused: undefined }) assert.ok(!hasClass(ui.rahmen(), 'hk-steht')) }) // die globale Regel hält Animationen unter .hk-steht an, und die Detailansicht liegt unter der Seiten-Wurzel const regel = fs.readFileSync(path.join(ROOT, 'src/styles/hustler/hustler-ui.css'), 'utf8') assert.match(regel, /\.hk-seite \.hk-steht \*[^{]*\{\s*animation-play-state:\s*paused !important/) }) test('M5 Handy quer: Vollbild auch bei niedriger Höhe (max-width 719 ODER max-height 500), die Zeilen-Anordnung gilt nur quer; Überlagerung unter der Handy-Navigation', () => { const css = fs.readFileSync(path.join(ROOT, 'src/styles/hustler/hustler-detail.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') // Vollbild-Block: beide Bedingungen, durch Komma verbunden const vollbild = css.match(/@media \(max-width: 719px\), \(max-height: 500px\) \{([\s\S]*?)\n\}/) assert.ok(vollbild, 'Media-Abfrage für Vollbild') assert.match(vollbild[1], /\.hk-hochhaus \.hk-dialog-ueber \{ padding: 0; \}/) assert.match(vollbild[1], /width: 100%; max-width: 100%; height: 100%; max-height: 100%/) assert.match(vollbild[1], /container-type: inline-size/) // Quer: Szene links, Daten rechts, nur im Querformat und niedrig const quer = css.match(/@media \(max-height: 500px\) and \(orientation: landscape\) \{([\s\S]*?)\n\}/) assert.ok(quer, 'Media-Abfrage für quer') assert.match(quer[1], /flex-direction: row/); assert.match(quer[1], /45cqw/) // die alte Bedingung (nur schmale Handys) ist weg assert.doesNotMatch(css, /@media \(max-width: 719px\) and \(max-height: 520px\)/) // R4: z-index unter Seitenleiste (20) und Abdunkler (15) assert.match(css, /\.hk-hochhaus \.hk-dialog-ueber \{[^}]*z-index: 9;/) assert.doesNotMatch(css, /z-index:\s*(1[0-9]|[2-9][0-9])/) }) test('M4 weniger Bewegung: alle Sprites der Detailansicht (Figur, Monitor, Dampf, Zzz, Wirbel) tragen hk-spr und stehen damit auf dem ersten Bild; der Fehler-Marker hat seine eigene Regel', async () => { const css = fs.readFileSync(path.join(ROOT, 'src/styles/hustler/hustler-ui.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') const reduziert = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce)')) assert.match(reduziert, /\.hk-seite \.hk-hochhaus \.hk-spr \{ animation: none; \}/) assert.match(reduziert, /\.hk-seite \.hk-alarm \{ animation: none; \}/) const vorlage = fs.readFileSync(path.join(ROOT, 'src/styles/hustler/hochhaus.css'), 'utf8') const animiert = new Set([...vorlage.replace(/@media \(prefers-reduced-motion: reduce\)\{[\s\S]*?\n\}\n/, '').matchAll(/\.hk-hochhaus (\.hk-[\w-]+)\{[^}]*animation:/g)].map((m) => m[1].slice(1))) for (const [id, status] of [['lea', 'arbeitet'], ['hanna', 'wartet'], ['bea', 'leerlauf'], ['kim', 'grau'], ['otto', 'wartet'], ['pia', 'fehler']]) { await lauf({ props: { workerId: id, workers: { [id]: EINTRAG(status, { connected: status !== 'grau' }) } } }, (ui) => { const bewegt = ui.find((n) => typeof n.tag === 'string' && classes(n).some((c) => animiert.has(c))) assert.ok(bewegt.length >= 1, `${id}: mindestens die Figur`) for (const n of bewegt) assert.ok(classes(n).includes('hk-spr') || classes(n).includes('hk-alarm'), `${id}: ${classes(n).join(' ')} nicht von einer Reduziert-Regel erfasst`) }) } }) test('Komponente: kein innerHTML, kein CSS- oder Bild-Import, Links nur über safeLink', () => { const quelle = fs.readFileSync(path.join(ROOT, 'src/components/workers/hochhaus/HustlerDialog.jsx'), 'utf8') const src = quelle.replace(/(^|\s)\/\/.*$/gm, '$1') // Zeilenkommentare dürfen das Wort erwähnen assert.ok(!/dangerouslySetInnerHTML|innerHTML|outerHTML|insertAdjacentHTML|document\.write/.test(src)) assert.ok(!/import[^\n]*\.(css|png|svg|gif|jpg)['"]/.test(src)) assert.ok(/safeLink/.test(src)) // jede Klasse im JSX trägt den Vorsatz hk- for (const m of src.matchAll(/className=(?:"([^"]*)"|\{`([^`]*)`\})/g)) { for (const c of (m[1] ?? m[2]).split(/\s+/)) if (c !== '' && !c.startsWith('${')) assert.ok(c.startsWith('hk-'), `Klasse ${c} ohne hk-`) } }) // Übernommen aus der Prüfung der früheren Worker-Karte (Review Focus 5, PF-08): Ziele nur aus dem safeLink-Ergebnis, extern mit noopener, jeder Knopf mit type. test('Komponente: jedes Link-Ziel (href, to) kommt aus dem safeLink-Ergebnis, externe Links mit rel noopener, jeder Knopf type="button"', () => { const quelle = fs.readFileSync(path.join(ROOT, 'src/components/workers/hochhaus/HustlerDialog.jsx'), 'utf8') const src = quelle.replace(/(^|\s)\/\/.*$/gm, '$1') assert.match(src, /safeLink\(/) assert.match(src, //); assert.match(src, //) assert.doesNotMatch(src, /\b(?:href|to)=\{(?!safe\.href\})/) // kein anderes Ziel, nie aus dem Ereignis selbst assert.equal((src.match(/= 4, 'Hintergrund, Schließen, ◂ und ▸') assert.equal((src.match(/