Files
tickte-system/tests/unit/hustlerDialog.test.mjs
ClaudeandClaude Sonnet 5.5 6eb4dd3c51 Hustler-Stufe: Keller begehbar ab Stufe 3, Bot-Leads abrufbar erst ab Stufe 6
Die Stufen-Sperre trennt jetzt zwei Dinge: KELLER_BEGEHBAR_AB = 3 (Keller-Etage als Link,
?tab=botleads gueltig, Spur-Links in den Keller) und BOTLEADS_AB = 6 (Abruf von GET /botleads).
kellerOffen/STUFE_KELLER entfallen, dafuer kellerBegehbar() und botleadsAbrufbar().
workersApi.botleads bleibt unter Stufe 6 hart gesperrt, der Text heisst nun
BOTLEADS_GESPERRT_TEXT. Die Keller-Ansicht selbst (leeres Register ohne Abruf) folgt im
naechsten Commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:21:59 +00:00

772 lines
43 KiB
JavaScript
Raw Permalink 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'
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,<b>x</b>' },
{ 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)))
})
})
// Stufen-Sperre (hustlerStufe.js): unterhalb von Stufe 3 (KELLER_BEGEHBAR_AB) führt auch kein Spur-Link in den Keller; der Eintrag bleibt als Text stehen
test('Spur-Links in den Keller (?tab=botleads, auch über /workers und /leads) sind auf Stufe 1 und 2 Text und ab Stufe 3 Router-Links; alle anderen Links bleiben', async () => {
const events = [
{ id: '1', ts: '2026-10-22T09:00:00Z', text: 'Keller', link: '/hustler?tab=botleads&bot=abc123' },
{ id: '2', ts: '2026-10-22T08:59:00Z', text: 'Alt', link: '/workers?tab=botleads&bot=abc123' },
{ id: '3', ts: '2026-10-22T08:58:00Z', text: 'Leads', link: '/leads?tab=botleads' },
{ id: '4', ts: '2026-10-22T08:57:00Z', text: 'Pult', link: '/hustler?tab=freigabe' },
{ id: '5', ts: '2026-10-22T08:56:00Z', text: 'Ticket', link: '/tickets/100001' },
{ id: '6', ts: '2026-10-22T08:55:00Z', text: 'Extern', link: 'https://example.org/hustler?tab=botleads' },
]
const ziele = (li) => nodes(li, (n) => n.tag === 'a' || n.tag === 'router-link')
for (const stufe of [1, 2]) {
await lauf({ props: { workerId: 'lea', stufe }, trace: { trace: { events }, loading: false, error: null } }, (ui) => {
const zeilen = ui.spurZeilen(); const byText = (t) => zeilen.find((z) => z.text === t)
assert.equal(zeilen.length, 6)
for (const t of ['Keller', 'Alt', 'Leads']) assert.equal(ziele(byText(t).li).length, 0, `Stufe ${stufe}: ${t} bleibt Text`)
assert.deepEqual(ziele(byText('Pult').li).map((n) => n.props.to), ['/hustler?tab=freigabe'])
assert.deepEqual(ziele(byText('Ticket').li).map((n) => n.props.to), ['/tickets/100001'])
assert.deepEqual(ziele(byText('Extern').li).map((n) => n.props.href), ['https://example.org/hustler?tab=botleads'])
assert.ok(!JSON.stringify(ui.find((x) => x.tag === 'a' || x.tag === 'router-link').map((n) => n.props)).includes('botleads&bot'))
})
}
for (const stufe of [3, 5, 6, 9]) {
await lauf({ props: { workerId: 'lea', stufe }, trace: { trace: { events }, loading: false, error: null } }, (ui) => {
const zeilen = ui.spurZeilen(); const byText = (t) => zeilen.find((z) => z.text === t)
assert.deepEqual(ziele(byText('Keller').li).map((n) => n.props.to), ['/hustler?tab=botleads&bot=abc123'])
assert.deepEqual(ziele(byText('Alt').li).map((n) => n.props.to), ['/workers?tab=botleads&bot=abc123'])
assert.deepEqual(ziele(byText('Leads').li).map((n) => n.props.to), ['/leads?tab=botleads'])
})
}
})
// ---------------------------------------------------------------------------------------------------------------------------------------------
// 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, /<a href=\{safe\.href\} target="_blank" rel="noopener noreferrer">/); assert.match(src, /<Link to=\{safe\.href\}>/)
assert.doesNotMatch(src, /\b(?:href|to)=\{(?!safe\.href\})/) // kein anderes Ziel, nie aus dem Ereignis selbst
assert.equal((src.match(/<a\b/g) || []).length, 1); assert.equal((src.match(/<Link\b/g) || []).length, 1)
const knoepfe = (src.match(/<button\b/g) || []).length
assert.ok(knoepfe >= 4, 'Hintergrund, Schließen, ◂ und ▸')
assert.equal((src.match(/<button type="button"/g) || []).length, knoepfe)
})
test('hustler-detail.css: Kopfkommentar, alle Regeln unter .hk-hochhaus, alle Klassen mit hk-, Handy-Regeln unter 720 px', () => {
const css = fs.readFileSync(path.join(ROOT, 'src/styles/hustler/hustler-detail.css'), 'utf8')
assert.ok(css.trimStart().startsWith('/*'), 'Kopfkommentar')
const ohneKommentar = css.replace(/\/\*[\s\S]*?\*\//g, '')
const vorspanne = [...ohneKommentar.matchAll(/([^{}]+)\{/g)].map((m) => m[1].trim())
assert.ok(vorspanne.length > 20)
for (const v of vorspanne) {
if (v.startsWith('@')) continue
for (const selektor of v.split(',')) assert.ok(selektor.trim().startsWith('.hk-hochhaus '), `Selektor ohne Wurzel: ${selektor.trim()}`)
}
for (const m of ohneKommentar.matchAll(/\.([A-Za-z][\w-]*)/g)) assert.ok(m[1].startsWith('hk-'), `Klasse ${m[1]} ohne hk-`)
assert.ok(/@media \(max-width: 719px\)/.test(css), 'Handy unter 720 px')
// keine Bilder und keine Schriften hier: die kommen aus den erzeugten Dateien
assert.ok(!/url\(|@font-face|@import/.test(ohneKommentar))
})