import test from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync, statSync } from 'node:fs'
import { inflateSync as zlibSync } from 'node:zlib'
import path from 'node:path'
import { loadModule } from './load.mjs'
import {
ITEM, KUERZ, PRAXIS, ROOT, allClasses, buttons, byClass, classesOf, fakeApi, fakeFocus, nodes, settle, start, textOf,
} from './helpers/kellerRunner.mjs'
// Der Keller (Hustler-Pixelwelt T10, Spec E12/E13/E14): Szene mit Arne nach Status (keller/KellerSzene.jsx), Register-Buch (keller/RegisterBuch.jsx), Akte
// (keller/Akte.jsx) und die Hülle BotLeadList.jsx (◂ Hochhaus, Schild, „Register zuklappen“). Abruf, Suche, Seiten, Fokus beim Blättern und Einzelansicht prüft
// botleadList.test.mjs; hier steht, was neu dazukommt, und die Ordnung von Klassen, Stilen und Quelltext.
const { ROOM_FILTERS } = await loadModule('src/lib/botleadsView.js')
const { grundInfo } = await loadModule('src/lib/botleadGruende.js')
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8')
const ohneKommentare = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
const SELBST = { ...ITEM, id: 'p5', room: 'selbst', roomLabel: 'Selbstständige', name: 'Fotostudio Muster', reason: 'Kette oder Franchise', score: 74, foundAt: '2026-10-17T20:00:00Z' }
const LADEN = { ...ITEM, id: 'p6', room: 'laden', roomLabel: 'Läden & Gastro', name: 'Bäckerei Probe', reason: 'Punktzahl unter 55', score: 40, phoneSource: '', foundAt: '2026-10-16T20:00:00Z' }
const SEITE = [ITEM, PRAXIS, KUERZ, SELBST, LADEN]
const eintrag = (status) => (status === 'grau' ? { status: 'grau', task: '', lastTs: null, connected: false, counters: {} } : { status, task: '', lastTs: null, connected: true, counters: {} })
const idx = (room) => ROOM_FILTERS.findIndex((f) => f.value === room)
// ---------------------------------------------------------------------------------------------------------------
// Szene: Arne nach Status
async function szene(workers) {
const api = fakeApi()
const ui = await start(api, workers === undefined ? {} : { workers })
return { api, ui }
}
const sz = (ui) => byClass(ui.tree(), 'hk-kl-szene')[0]
const has = (ui, name) => byClass(ui.tree(), name).length > 0
const RUHENDE = ['hk-sz-rohrpost', 'hk-sz-schublade', 'hk-sz-buch-stempel']
test('Szene: Wurzel .hk-keller, die Szene ist Zierde (aria-hidden) und hat alle Ebenen der Vorlage in der Reihenfolge der Vorlage', async () => {
const { ui } = await szene({ arne: eintrag('arbeitet') })
try {
const tree = ui.tree()
assert.equal(tree.tag, 'div')
assert.deepEqual(classesOf(tree), ['hk-keller'])
assert.equal(sz(ui).props['aria-hidden'], 'true')
// Ebenen: Raum, Fenster mit Schnee/Passant/Kreuz, Dampf, Stempel, Rohrpost, Schublade, Maus, Blinkpunkte, Licht (Tisch, zwei Lampen, Halle), Staub
const ebenen = byClass(sz(ui), 'hk-sz-ebene').map((n) => classesOf(n).find((c) => c !== 'hk-sz-ebene'))
assert.deepEqual(ebenen, ['hk-sz-bild', 'hk-sz-licht-tisch', 'hk-sz-licht-92', 'hk-sz-licht-236', 'hk-sz-licht-halle', 'hk-sz-staub'])
for (const c of ['hk-sz-fensterschnee', 'hk-sz-passant-weg', 'hk-sz-passant', 'hk-sz-kreuz', 'hk-sz-dampf', 'hk-sz-maus-weg', 'hk-sz-maus']) assert.equal(byClass(sz(ui), c).length, 1, c)
assert.equal(byClass(sz(ui), 'hk-sz-led').length, 6)
// die Reihenfolge der Dinge in der Bühne: Raum zuerst, Licht zuletzt (Licht liegt über Arne und Maus)
const kinder = byClass(sz(ui), 'hk-kl-buehne')[0].children.map((n) => classesOf(n).join(' '))
assert.ok(kinder.indexOf('hk-arne-weg') < kinder.indexOf('hk-sz-ebene hk-sz-licht-tisch'))
assert.equal(kinder[0], 'hk-sz-ebene hk-sz-bild')
assert.equal(kinder.at(-1), 'hk-sz-ebene hk-sz-staub')
// Blinkpunkte: Animationen mit den umbenannten Keyframes (hk-k-k-led-…)
const led = byClass(sz(ui), 'hk-sz-led').map((n) => n.props.style.animation.split(' ')[0])
assert.deepEqual(led, ['hk-k-k-led-weiche-gruen', 'hk-k-k-led-weiche-bernstein', 'hk-k-k-led-sicherung-1', 'hk-k-k-led-sicherung-2', 'hk-k-k-led-sicherung-3', 'hk-k-k-led-verteiler'])
} finally { ui.end() }
})
test('Arne arbeitet: Ablauf-Schleife samt Rohrpost, Stempel und Schublade, kein Haus-Figur, kein Marker', async () => {
const { ui } = await szene({ arne: eintrag('arbeitet') })
try {
assert.equal(sz(ui).props['data-hk-arne'], 'arbeitet')
assert.equal(byClass(sz(ui), 'hk-arne-weg').length, 1)
assert.equal(byClass(byClass(sz(ui), 'hk-arne-weg')[0], 'hk-arne-bild').length, 1)
assert.equal(has(ui, 'hk-kl-arne'), false)
assert.equal(has(ui, 'hk-alarm'), false)
for (const c of RUHENDE) {
const n = byClass(sz(ui), c)
assert.equal(n.length, 1, c)
assert.equal(classesOf(n[0]).includes('hk-kl-ruht'), false, `${c} läuft`)
}
} finally { ui.end() }
})
test('Arne arbeitet nicht: Haus-Figur in der Pose des Status am Pult, Rohrpost, Stempel und Schublade ruhen', async () => {
const faelle = [
{ status: 'wartet', pose: 'warten', punkte: true, zzz: false, alarm: false },
{ status: 'leerlauf', pose: 'schlafen', punkte: false, zzz: true, alarm: false },
{ status: 'grau', pose: 'nicht', punkte: false, zzz: false, alarm: false },
{ status: 'fehler', pose: 'nicht', punkte: false, zzz: false, alarm: true },
]
for (const f of faelle) {
const { ui } = await szene({ arne: eintrag(f.status) })
try {
assert.equal(sz(ui).props['data-hk-arne'], f.status)
assert.equal(has(ui, 'hk-arne-weg'), false, f.status)
assert.equal(has(ui, 'hk-arne-bild'), false, f.status)
const figur = byClass(sz(ui), 'hk-kl-fig')
assert.equal(figur.length, 1, f.status)
assert.deepEqual(classesOf(figur[0]), ['hk-spr', 'hk-kl-fig', `hk-kl-fig-${f.pose}`], f.status)
assert.equal(has(ui, 'hk-kl-punkte'), f.punkte, `${f.status} punkte`)
assert.equal(has(ui, 'hk-kl-zzz'), f.zzz, `${f.status} zzz`)
assert.equal(has(ui, 'hk-alarm'), f.alarm, `${f.status} alarm`)
if (f.alarm) assert.equal(textOf(byClass(sz(ui), 'hk-alarm')[0]), '!')
for (const c of RUHENDE) assert.ok(classesOf(byClass(sz(ui), c)[0]).includes('hk-kl-ruht'), `${f.status}: ${c} ruht nicht`)
// Arne steht an seinem Pult: Lage aus ARNE_RUHE
const platz = byClass(sz(ui), 'hk-kl-arne')[0].props.style
assert.deepEqual([platz.position, platz.left, platz.top], ['absolute', '116px', '286px'])
} finally { ui.end() }
}
})
// T13 R5 (ersetzt „er schläft“): solange es keinen Eintrag für Arne gibt, weiß die Seite nichts über ihn; er steht neutral am Pult – ohne Zzz (schläft), ohne Kaffeepunkte
// (wartet auf Auftrag), ohne Marker und ohne die „nicht“-Pose (nicht angeschlossen). Mit den ersten Daten wechselt er in den echten Zustand.
test('Arne ohne Zustand (Tabelle noch leer, kein Eintrag): er steht neutral am Pult, ohne Zzz, Punkte, Marker und „nicht angeschlossen“; der Status wechselt mit den Daten der Seite', async () => {
const neutral = (ui, wo) => {
assert.equal(sz(ui).props['data-hk-arne'], 'unbekannt', wo)
assert.deepEqual(byClass(sz(ui), 'hk-kl-fig').map(classesOf), [['hk-spr', 'hk-kl-fig', 'hk-kl-fig-warten']], wo) // ruhige Haltung, keine Schlaf- oder „nicht“-Pose
for (const c of ['hk-kl-zzz', 'hk-kl-punkte', 'hk-alarm', 'hk-kl-fig-schlafen', 'hk-kl-fig-nicht', 'hk-arne-weg', 'hk-arne-bild']) assert.equal(has(ui, c), false, `${wo}: ${c}`)
for (const c of RUHENDE) assert.ok(classesOf(byClass(sz(ui), c)[0]).includes('hk-kl-ruht'), `${wo}: ${c} läuft`) // Rohrpost, Stempel und Schublade ruhen
}
const { ui } = await szene(undefined)
try {
neutral(ui, 'ohne Tabelle')
ui.setProps({ workers: { arne: eintrag('arbeitet') } })
assert.ok(has(ui, 'hk-arne-weg') && !has(ui, 'hk-kl-fig'))
ui.setProps({ workers: { arne: eintrag('fehler') } })
assert.ok(!has(ui, 'hk-arne-weg') && has(ui, 'hk-kl-fig-nicht') && has(ui, 'hk-alarm'))
ui.setProps({ workers: { arne: eintrag('wartet') } })
assert.ok(has(ui, 'hk-kl-fig-warten') && has(ui, 'hk-kl-punkte') && !has(ui, 'hk-alarm'))
ui.setProps({ workers: { arne: eintrag('leerlauf') } })
assert.ok(has(ui, 'hk-kl-fig-schlafen') && has(ui, 'hk-kl-zzz') && !has(ui, 'hk-kl-punkte')) // ein echter Leerlauf schläft weiterhin
ui.setProps({ workers: { arne: eintrag('grau') } })
assert.ok(has(ui, 'hk-kl-fig-nicht') && !has(ui, 'hk-kl-zzz') && !has(ui, 'hk-alarm'))
// Tabelle leer ({}), Eintrag für Arne fehlt (nur andere Hustler), Eintrag ist keine Tabellenzeile: neutral
for (const workers of [{}, { otto: eintrag('arbeitet') }, { arne: null }, { arne: 'arbeitet' }, undefined]) {
ui.setProps({ workers })
neutral(ui, JSON.stringify(workers))
}
} finally { ui.end() }
})
// T13 R3: Prop paused (Hustler angehalten) → hk-steht an der Szene; die Regel der Seite (hustler-ui.css) hält darunter alle Animationen an (Arne, Rohrpost, Maus, Licht)
test('Szene (T13 R3): paused setzt hk-steht an der Keller-Szene (auch bei arbeitendem Arne), sonst nicht; unbrauchbare Werte gelten als „läuft“', async () => {
const klasse = async (initial) => {
const { ui } = await szene(undefined)
ui.end()
const lauf = await start(fakeApi(), initial)
try { return classesOf(sz(lauf)).join(' ') } finally { lauf.end() }
}
assert.equal(await klasse({ paused: true }), 'hk-kl-szene hk-steht')
assert.equal(await klasse({ paused: true, workers: { arne: eintrag('arbeitet') } }), 'hk-kl-szene hk-steht')
assert.equal(await klasse({ paused: false }), 'hk-kl-szene')
assert.equal(await klasse({}), 'hk-kl-szene')
for (const unsinn of ['ja', 1, null, {}]) assert.equal(await klasse({ paused: unsinn }), 'hk-kl-szene', String(unsinn))
// die Szene ist die Wurzel dieser Animationen und steckt unter .hk-seite (WorkersPage), wo die Regel gilt
assert.match(read('src/styles/hustler/hustler-ui.css'), /\.hk-seite \.hk-steht \*[^{]*\{[^}]*animation-play-state:\s*paused\s*!important/)
// der Wechsel von „läuft“ zu „steht“ und zurück gilt sofort
const lauf = await start(fakeApi(), {})
try {
assert.equal(classesOf(sz(lauf)).includes('hk-steht'), false)
lauf.setProps({ paused: true }); assert.equal(classesOf(sz(lauf)).includes('hk-steht'), true)
lauf.setProps({ paused: false }); assert.equal(classesOf(sz(lauf)).includes('hk-steht'), false)
} finally { lauf.end() }
})
// T13 M2: Bildschirmwechsel – die Überschrift „Bot-Leads“ nimmt beim Einhängen den Fokus
test('Keller (T13 M2): beim Einhängen nimmt die Überschrift „Bot-Leads“ den Fokus (tabIndex -1, preventScroll), nur einmal; ohne Element bricht nichts', async () => {
const aufrufe = []
const h1 = { focus: (options) => aufrufe.push(options) }
globalThis.__fakeRef = (tag) => (tag === 'h1' ? h1 : null)
const api = fakeApi()
const ui = await start(api, {})
try {
const titel = nodes(ui.tree(), (n) => n.tag === 'h1')
assert.equal(titel.length, 1); assert.equal(titel[0].props.tabIndex, -1); assert.equal(textOf(titel[0]), 'Bot-Leads')
assert.deepEqual(aufrufe, [{ preventScroll: true }])
// Laden, Raumwechsel, Suche und Akte holen den Fokus nicht wieder an die Überschrift
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.pick(1); await ui.pick(0)
await ui.type('Elektro'); ui.fireTimers(); await settle()
assert.equal(aufrufe.length, 1)
} finally { ui.end(); delete globalThis.__fakeRef }
const ohne = await start(fakeApi(), {}) // kein ref: nichts zu fokussieren
try { assert.equal(nodes(ohne.tree(), (n) => n.tag === 'h1').length, 1) } finally { ohne.end() }
assert.match(cssOhne('src/styles/hustler/hustler-keller-ui.css'), /\.hk-keller \.hk-kl-titel:focus-visible\s*\{[^}]*outline:\s*2px solid/)
})
// T13 K4 (Keller): „Register zuklappen“ sagt, ob das Register offen ist – die Prüfung war schon da, hier wird sie festgehalten
test('Keller (T13 K4): „Register zuklappen“ trägt aria-expanded und folgt dem Zustand', async () => {
const ui = await start(fakeApi(), {})
try {
assert.equal(buttons(ui.tree(), 'Register zuklappen')[0].props['aria-expanded'], true)
await ui.click('Register zuklappen')
assert.equal(buttons(ui.tree(), 'Register aufklappen')[0].props['aria-expanded'], false)
} finally { ui.end() }
})
// T13 W2: die Ortszeile kürzt mit „…“, der Browser-Tooltip nennt den vollen Ort samt Datum
test('Zeile (T13 W2): die Ortszeile trägt title mit vollem Ort und Funddatum; ohne Ort und Datum kein leeres title', async () => {
const ui = await start(fakeApi(), {})
try {
await ui.answer(0, { items: [{ ...ITEM, city: 'Kaiserslautern-Hohenecken-Am-Lauterstrom', postalCode: '67655' }, { ...ITEM, id: 'p9', city: '', postalCode: '', foundAt: '' }], total: 2 })
const zeilen = ui.rows()
const klein = (r) => byClass(r, 'hk-klein')[0]
assert.equal(klein(zeilen[0]).props.title, '67655 Kaiserslautern-Hohenecken-Am-Lauterstrom · 19.10.2026')
assert.equal(klein(zeilen[0]).props.title, textOf(klein(zeilen[0]))) // title = sichtbarer Text, nur ungekürzt
// ohne Ort und Datum: botleadRow liefert Platzhalter oder nichts; ein title ist nie leer
assert.ok(klein(zeilen[1]).props.title === undefined || klein(zeilen[1]).props.title !== '')
assert.equal(klein(zeilen[1]).props.title, textOf(klein(zeilen[1])) === '' ? undefined : textOf(klein(zeilen[1])))
} finally { ui.end() }
})
test('Szene: Einpassen „cover“ aus der Größe des Rahmens, folgt dem Beobachter und löst ihn beim Abbau', async () => {
const rect = { width: 1280, height: 720 }
const beobachter = []
let getrennt = 0
const hatte = Object.hasOwn(globalThis, 'ResizeObserver')
const alt = globalThis.ResizeObserver
globalThis.ResizeObserver = class { constructor(cb) { this.cb = cb; this.ziele = []; beobachter.push(this) } observe(el) { this.ziele.push(el) } disconnect() { getrennt += 1 } }
globalThis.__fakeRef = (tag, props) => (props.className === 'hk-kl-szene' ? { getBoundingClientRect: () => ({ ...rect }) } : null)
const api = fakeApi()
const ui = await start(api, {})
try {
const transform = () => byClass(ui.tree(), 'hk-kl-buehne')[0].props.style.transform
assert.equal(beobachter.length, 1)
assert.equal(beobachter[0].ziele.length, 1)
assert.equal(transform(), 'translate(0.0px, 0.0px) scale(2.0000)')
rect.width = 1920; rect.height = 1080
beobachter[0].cb(); await settle()
assert.equal(transform(), 'translate(0.0px, 0.0px) scale(3.0000)')
rect.width = 2560; rect.height = 720
beobachter[0].cb(); await settle()
assert.equal(transform(), 'translate(0.0px, -576.0px) scale(4.0000)')
// Handy hochkant: links verankert
rect.width = 390; rect.height = 844
beobachter[0].cb(); await settle()
assert.match(transform(), /^translate\(-166\.\d+px, 0\.0px\) scale\(2\.3444\)$/)
// ein Rahmen ohne Größe (noch nicht gelegt) lässt alles, wie es ist
const davor = transform()
rect.width = 0; rect.height = 0
beobachter[0].cb(); await settle()
assert.equal(transform(), davor)
assert.equal(getrennt, 0)
} finally { ui.end(); delete globalThis.__fakeRef; if (hatte) globalThis.ResizeObserver = alt; else delete globalThis.ResizeObserver }
assert.equal(getrennt, 1)
})
// ---------------------------------------------------------------------------------------------------------------
// Hülle: ◂ Hochhaus, Schild, Register zuklappen
test('◂ Hochhaus ruft onBack; ohne onBack bricht nichts', async () => {
let zurueck = 0
const mit = await start(fakeApi(), { onBack: () => { zurueck += 1 } })
try {
const knopf = buttons(mit.tree(), 'Zurück zum Hochhaus')
assert.equal(knopf.length, 1)
assert.equal(knopf[0].props.type, 'button')
assert.ok(textOf(knopf[0]).includes('Hochhaus'))
assert.deepEqual(byClass(knopf[0], 'hk-ic').map((n) => classesOf(n).join(' ')), ['hk-ic hk-ic-zurueck'])
await mit.click('Zurück zum Hochhaus')
assert.equal(zurueck, 1)
} finally { mit.end() }
const ohne = await start(fakeApi(), {})
try {
await ohne.click('Zurück zum Hochhaus') // keine Prop onBack: nichts geschieht, nichts bricht
} finally { ohne.end() }
// auch in der Einzelansicht und bei einem Fehler bleibt der Weg zurück
const einzeln = await start(fakeApi(), { bot: 'p1', onClearBot: () => {}, onBack: () => { zurueck += 1 } })
try {
await einzeln.fail(0, new Error('weg'))
await einzeln.click('Zurück zum Hochhaus')
assert.equal(zurueck, 2)
} finally { einzeln.end() }
})
test('Schild: Überschrift „Bot-Leads“ und die Zahl aus kpis.botLeads (1.234 mit Punkt), ohne brauchbare Zahl keine Zahl', async () => {
const faelle = [[{ botLeads: 112 }, '112'], [{ botLeads: 1234 }, '1.234'], [{ botLeads: 0 }, '0'], [undefined, null], [{}, null], [{ botLeads: NaN }, null], [{ botLeads: '5' }, null], [null, null]]
for (const [kpis, erwartet] of faelle) {
const api = fakeApi()
const ui = await start(api, { kpis })
try {
assert.equal(textOf(nodes(ui.tree(), (n) => n.tag === 'h1')[0]), 'Bot-Leads')
const zahl = byClass(ui.tree(), 'hk-kl-zahl')
assert.deepEqual(zahl.map(textOf), erwartet === null ? [] : [erwartet], JSON.stringify(kpis))
} finally { ui.end() }
}
})
test('Register zuklappen: nur die Szene bleibt, nichts wird neu abgerufen, Raum und Suche bleiben beim Aufklappen', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: [ITEM, PRAXIS], total: 2, pageSize: 50 })
await ui.pick(idx('handwerk'))
await ui.answer(1, { items: [ITEM], total: 1, pageSize: 50 })
await ui.type('elektro')
assert.equal(api.calls.length, 2)
const toggle = () => buttons(ui.tree(), 'Register zuklappen')
assert.equal(toggle().length, 1)
assert.equal(toggle()[0].props['aria-expanded'], true)
assert.equal(toggle()[0].props.type, 'button')
assert.equal(byClass(ui.tree(), 'hk-buch').length, 1)
await ui.click('Register zuklappen')
assert.equal(byClass(ui.tree(), 'hk-buch').length, 0)
assert.equal(ui.rows().length, 0)
assert.equal(ui.reiter().length, 0)
assert.equal(ui.search(), undefined)
assert.equal(byClass(ui.tree(), 'hk-kl-szene').length, 1) // die Szene steht ganz
const auf = buttons(ui.tree(), 'Register aufklappen')
assert.equal(auf.length, 1)
assert.equal(auf[0].props['aria-expanded'], false)
assert.ok(textOf(auf[0]).includes('Register aufklappen'))
assert.equal(buttons(ui.tree(), 'Register zuklappen').length, 0)
assert.equal(byClass(ui.tree(), 'hk-kl-schild').length, 1) // Kopfzeile bleibt
assert.equal(api.calls.length, 2) // nichts abgerufen
await ui.click('Register aufklappen')
assert.equal(api.calls.length, 2)
assert.equal(ui.rows().length, 1)
assert.equal(ui.activeIndex(), idx('handwerk'))
assert.equal(ui.search().props.value, 'elektro')
assert.equal(buttons(ui.tree(), 'Register zuklappen').length, 1)
} finally { ui.end() }
})
test('Register zuklappen: auch beim Laden, im Fehler und in der Einzelansicht; Antworten kommen trotzdem an', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.click('Register zuklappen') // die erste Antwort steht noch aus
await ui.answer(0, { items: [ITEM], total: 1 })
assert.equal(ui.rows().length, 0)
await ui.click('Register aufklappen')
assert.equal(ui.rows().length, 1)
assert.equal(api.calls.length, 1)
} finally { ui.end() }
})
// ---------------------------------------------------------------------------------------------------------------
// Akte
test('Akte: Dialog zur gewählten Zeile mit Raum, Scout, Name, Ort, Punkten, Grund samt Erklärung, Telefon, Quelle und Funddatum', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: SEITE, total: 5, pageSize: 50 })
assert.equal(ui.akte(), null)
await ui.openRow(0)
const dialog = ui.akte()
assert.equal(dialog.tag, 'section')
assert.equal(dialog.props.role, 'dialog')
assert.equal(dialog.props['aria-modal'], 'true')
assert.equal(dialog.props['aria-label'], 'Akte Elektro Beispiel GmbH')
assert.equal(dialog.props.tabIndex, -1)
assert.equal(textOf(byClass(dialog, 'hk-kl-akte-raum')[0]), 'Handwerk · gefunden von Hanna')
assert.equal(textOf(nodes(dialog, (n) => n.tag === 'h2')[0]), 'Elektro Beispiel GmbH')
assert.equal(textOf(byClass(dialog, 'hk-kl-akte-ort')[0]), '67655 Kaiserslautern')
const dl = ui.akteDl()
assert.deepEqual(Object.keys(dl), ['Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden'])
assert.equal(textOf(byClass(dl.Punkte, 'hk-kl-punkte-text')[0]), '82 Punkte')
assert.deepEqual(byClass(dl.Punkte, 'hk-kl-balken-fuell').map((n) => [classesOf(n).join(' '), n.props.style.width]), [['hk-kl-balken-fuell hk-kl-farbe-gut', 79]])
assert.equal(textOf(byClass(dl.Grund, 'hk-kl-akte-grundtext')[0]), 'keine veröffentlichte Mail')
assert.equal(textOf(byClass(dl.Grund, 'hk-kl-akte-erklaerung')[0]), grundInfo('keine veröffentlichte Mail').erklaerung)
assert.deepEqual(byClass(dl.Grund, 'hk-ic').map((n) => classesOf(n).join(' ')), ['hk-ic hk-ic-brief-x'])
assert.equal(textOf(dl.Telefon), '0631 111')
assert.deepEqual(byClass(dl.Telefon, 'hk-ic').map((n) => classesOf(n).join(' ')), ['hk-ic hk-ic-telefon'])
const link = nodes(dl.Quelle, (n) => n.tag === 'a')
assert.equal(link.length, 1)
assert.deepEqual([link[0].props.href, link[0].props.target, link[0].props.rel, link[0].props['aria-label']], ['https://beispiel-elektro.de/impressum', '_blank', 'noopener noreferrer', 'Quelle öffnen: Elektro Beispiel GmbH'])
assert.equal(textOf(link[0]), 'Quelle öffnen')
assert.equal(textOf(dl.Gefunden), '19.10.2026')
assert.ok(textOf(dialog).includes('Nur zum Nachsehen, hier wird nichts verschickt.'))
// Zeile und Akte gehören zusammen: die Akte kennt Nr. und Seite
assert.ok(textOf(byClass(dialog, 'hk-kl-sr')[0]).startsWith('Akte 1 von 5: Elektro Beispiel GmbH'))
} finally { ui.end() }
})
test('Akte: Heilberuf (Ärzte & Praxen, Ali): nie eine Nummer, Schloss; unsichere Quelle ist kein Link', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: SEITE, total: 5 })
await ui.openRow(1)
assert.equal(textOf(byClass(ui.akte(), 'hk-kl-akte-raum')[0]), 'Ärzte & Praxen · gefunden von Ali')
const dl = ui.akteDl()
assert.equal(textOf(dl.Telefon), 'bewusst nicht gespeichert')
assert.deepEqual(byClass(dl.Telefon, 'hk-ic').map((n) => classesOf(n).join(' ')), ['hk-ic hk-ic-schloss'])
assert.equal(nodes(dl.Quelle, (n) => n.tag === 'a').length, 0)
assert.equal(textOf(dl.Quelle), '–')
assert.equal(nodes(ui.akte(), (n) => n.tag === 'a').length, 0)
} finally { ui.end() }
})
test('Akte: auch ein Heilberuf mit Altbestand (Nummer und Quelle im Datensatz) zeigt die Nummer nie', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: [{ ...PRAXIS, phone: '0631 999', phoneSource: 'https://alt.example' }], total: 1 })
await ui.openRow(0)
assert.equal(textOf(ui.akteDl().Telefon), 'bewusst nicht gespeichert')
assert.ok(!textOf(ui.akte()).includes('0631 999'))
} finally { ui.end() }
})
test('Akte: Scout je Raum (Sven, Lea), Telefon ohne Quelle „nicht verfügbar“, unbekannter Raum ohne Scout, gekürzter Eintrag mit „gekürzt“', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: [...SEITE, { ...ITEM, id: 'p7', room: 'xyz', roomLabel: 'Sonstige' }], total: 6 })
await ui.openRow(3) // selbst
assert.equal(textOf(byClass(ui.akte(), 'hk-kl-akte-raum')[0]), 'Selbstständige · gefunden von Sven')
assert.equal(textOf(ui.akteDl().Telefon), '0631 111')
await ui.click('Nächste Akte') // laden: Nummer ohne Quelle gilt als nicht belegt
assert.equal(textOf(byClass(ui.akte(), 'hk-kl-akte-raum')[0]), 'Läden & Gastro · gefunden von Lea')
assert.equal(textOf(ui.akteDl().Telefon), 'nicht verfügbar')
assert.equal(textOf(byClass(ui.akteDl().Grund, 'hk-kl-akte-erklaerung')[0]), 'Zu wenige Punkte für eine Mail (Grenze 55).')
assert.deepEqual(byClass(ui.akteDl().Punkte, 'hk-kl-balken-fuell').map((n) => classesOf(n).join(' ')), ['hk-kl-balken-fuell hk-kl-farbe-schlecht'])
await ui.click('Nächste Akte') // Raum ohne Scout
assert.equal(textOf(byClass(ui.akte(), 'hk-kl-akte-raum')[0]), 'Sonstige')
await ui.openRow(2) // gekürzt
assert.deepEqual(Object.keys(ui.akteDl()), ['Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden', 'Kürzung'])
assert.equal(textOf(ui.akteDl().Kürzung), 'gekürzt')
assert.equal(textOf(ui.akteDl().Quelle), '–')
assert.equal(ui.akte().props['aria-label'], 'Akte [gekürzt]')
} finally { ui.end() }
})
test('Akte: unbekannter Grund zeigt das neutrale Symbol und den allgemeinen Satz', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: [{ ...ITEM, reason: 'ein ganz neuer Grund' }], total: 1 })
await ui.openRow(0)
const g = ui.akteDl().Grund
assert.equal(textOf(byClass(g, 'hk-kl-akte-grundtext')[0]), 'ein ganz neuer Grund')
assert.deepEqual(byClass(g, 'hk-ic').map((n) => classesOf(n).join(' ')), ['hk-ic hk-ic-akte'])
assert.match(textOf(byClass(g, 'hk-kl-akte-erklaerung')[0]), /^Der Hustler schreibt diesem Betrieb nicht\./)
} finally { ui.end() }
})
test('Akte: Blättern ◂ ▸ innerhalb der Seite (im Kreis), mit den Pfeiltasten, Alt+Pfeil bleibt dem Browser', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: [ITEM, PRAXIS, KUERZ], total: 120, pageSize: 50 })
const name = () => textOf(nodes(ui.akte(), (n) => n.tag === 'h2')[0])
const live = () => textOf(byClass(ui.akte(), 'hk-kl-sr')[0])
await ui.openRow(1)
assert.equal(name(), 'Praxis Dr. Muster')
assert.equal(live(), 'Akte 2 von 3: Praxis Dr. Muster')
await ui.click('Nächste Akte')
assert.equal(name(), '[gekürzt]')
await ui.click('Nächste Akte') // am Ende der Seite wieder von vorn
assert.equal(name(), 'Elektro Beispiel GmbH')
assert.equal(live(), 'Akte 1 von 3: Elektro Beispiel GmbH')
await ui.click('Vorherige Akte') // vom Anfang ans Ende
assert.equal(name(), '[gekürzt]')
const rechts = await ui.key('ArrowRight')
assert.equal(rechts.prevented, true)
assert.equal(name(), 'Elektro Beispiel GmbH')
const links = await ui.key('ArrowLeft')
assert.equal(links.prevented, true)
assert.equal(name(), '[gekürzt]')
for (const mod of [{ altKey: true }, { ctrlKey: true }, { metaKey: true }]) {
const ev = await ui.key('ArrowLeft', mod)
assert.equal(ev.prevented, false)
assert.equal(name(), '[gekürzt]')
}
// die Akte blättert nur in der Seite: kein neuer Abruf
assert.equal(api.calls.length, 1)
// beide Pfeile sind Knöpfe mit Beschriftung (Symbol ohne Text)
for (const label of ['Vorherige Akte', 'Nächste Akte']) {
const b = buttons(ui.akte(), label)
assert.equal(b.length, 1)
assert.equal(b[0].props.type, 'button')
}
// ein einzelner Eintrag: Blättern bleibt, wo es ist
} finally { ui.end() }
const api2 = fakeApi()
const eins = await start(api2, {})
try {
await eins.answer(0, { items: [ITEM], total: 1 })
await eins.openRow(0)
await eins.click('Nächste Akte')
assert.equal(textOf(nodes(eins.akte(), (n) => n.tag === 'h2')[0]), 'Elektro Beispiel GmbH')
await eins.click('Vorherige Akte')
assert.equal(textOf(nodes(eins.akte(), (n) => n.tag === 'h2')[0]), 'Elektro Beispiel GmbH')
} finally { eins.end() }
})
test('Akte schließen: Esc, Schließen-Knopf und Hintergrund; Esc wird verbraucht', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: SEITE, total: 5 })
await ui.openRow(0)
const esc = await ui.key('Escape')
assert.equal(esc.prevented, true)
assert.equal(ui.akte(), null)
await ui.openRow(0)
await ui.click('Schließen')
assert.equal(ui.akte(), null)
await ui.openRow(0)
const hinten = buttons(ui.tree(), 'Akte schließen')
assert.equal(hinten.length, 1)
assert.equal(hinten[0].props.tabIndex, -1) // der Hintergrund-Knopf kommt nicht in die Tab-Reihenfolge
await ui.click('Akte schließen')
assert.equal(ui.akte(), null)
// andere Tasten schließen nichts
await ui.openRow(0)
for (const k of ['Enter', ' ', 'a', 'ArrowUp']) await ui.key(k)
assert.notEqual(ui.akte(), null)
} finally { ui.end() }
})
test('Akte: Fokus auf „Schließen“ beim Öffnen, Tab bleibt im Dialog, beim Schließen zurück auf die Zeile (die zuletzt gezeigte)', async () => {
const api = fakeApi()
const dom = fakeFocus()
const ui = await start(api, {})
dom.attach(ui)
try {
await ui.answer(0, { items: [ITEM, PRAXIS, KUERZ], total: 3 })
await dom.pressRow(1)
assert.deepEqual(dom.focused, ['Schließen'])
assert.deepEqual(dom.options, [{ preventScroll: true }])
assert.equal(dom.doc.activeElement.key, 'Schließen')
// Fokusfalle: Tab am letzten Knopf (Nächste Akte) → Schließen; Shift+Tab am ersten → Nächste Akte; dazwischen darf der Browser selbst weiter
dom.doc.activeElement = dom.made.get('Nächste Akte')
let ev = await ui.key('Tab')
assert.equal(ev.prevented, true)
assert.equal(dom.doc.activeElement.key, 'Schließen')
ev = await ui.key('Tab', { shiftKey: true })
assert.equal(ev.prevented, true)
assert.equal(dom.doc.activeElement.key, 'Nächste Akte')
dom.doc.activeElement = dom.made.get('Schließen')
ev = await ui.key('Tab')
assert.equal(ev.prevented, false) // Schließen → Vorherige Akte: der Browser geht weiter
dom.doc.activeElement = dom.other('Vorherige Akte')
ev = await ui.key('Tab', { shiftKey: true })
assert.equal(ev.prevented, false)
// auch wenn der Fokus auf der Karte selbst liegt, führt Shift+Tab ans Ende
const karte = ui.akte()
dom.doc.activeElement = { name: 'karte' }
ev = await ui.key('Tab', { shiftKey: true, currentTarget: { name: 'karte' } })
assert.equal(ev.prevented, false)
dom.doc.activeElement = dom.other('x')
// in der Akte weiterblättern, dann Esc: der Fokus geht an die Zeile des zuletzt gezeigten Eintrags
await ui.click('Nächste Akte')
assert.equal(karte.props.role, 'dialog')
dom.focused.length = 0; dom.options.length = 0
await ui.key('Escape')
assert.equal(ui.akte(), null)
assert.deepEqual(dom.focused, ['zeile 2'])
assert.equal(dom.doc.activeElement.key, 'zeile 2')
} finally { dom.restore(); ui.end() }
})
test('Akte: findet der Browser die Zeile nicht mehr, geht der Fokus an das Element, das beim Öffnen den Fokus hatte; ohne beides bleibt er unberührt', async () => {
const api = fakeApi()
const dom = fakeFocus()
const ui = await start(api, {})
dom.attach(ui)
try {
await ui.answer(0, { items: [ITEM, PRAXIS], total: 2 })
dom.doc.querySelector = () => null
await dom.pressRow(0)
dom.focused.length = 0
await ui.key('Escape')
assert.deepEqual(dom.focused, ['zeile 0']) // der Opener (Zeilenknopf von pressRow)
// beim Öffnen hatte body den Fokus (Safari fokussiert Knöpfe nicht): ohne Zeile und Opener bleibt der Fokus, wo er ist
dom.doc.activeElement = dom.body
await ui.openRow(1)
dom.focused.length = 0
dom.doc.activeElement = dom.other('Irgendwo')
await ui.key('Escape')
assert.deepEqual(dom.focused, [])
// ohne document (Node, Server) bricht nichts
delete globalThis.document
await ui.openRow(0)
await ui.key('Escape')
assert.equal(ui.akte(), null)
} finally { dom.restore(); ui.end() }
})
test('Akte: eine neue Anfrage (Raum, Suche, Seite, Fehler) schließt sie; sie steht nie über „Wird geladen“ oder einer Fehlermeldung', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: SEITE, total: 120, pageSize: 50 })
await ui.openRow(0)
assert.notEqual(ui.akte(), null)
await ui.pick(idx('laden'))
assert.equal(ui.akte(), null)
assert.ok(ui.text().includes('Wird geladen'))
await ui.answer(1, { items: SEITE, total: 5 })
assert.equal(ui.akte(), null) // sie kommt nicht von selbst wieder
await ui.openRow(4)
await ui.type('x'); ui.fireTimers(); await settle()
assert.equal(ui.akte(), null)
await ui.fail(2, new Error('weg'))
assert.equal(ui.akte(), null)
assert.deepEqual(ui.errors().map((e) => e.text), ['weg'])
await ui.click('Erneut versuchen')
await ui.answer(3, { items: SEITE, total: 120, pageSize: 50 })
assert.equal(ui.akte(), null)
await ui.openRow(0)
await ui.click('Nächste Seite')
assert.equal(ui.akte(), null)
} finally { ui.end() }
})
test('Zeile: ein Knopf (Beschriftung für Screenreader, Nr. für die Fokus-Rückgabe), die Akte öffnet nur durch einen Klick', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: SEITE, total: 5 })
const rows = ui.rows()
assert.deepEqual(rows.map((r) => r.props['data-hk-zeile']), [0, 1, 2, 3, 4])
assert.equal(rows[1].props['aria-label'], 'Praxis Dr. Muster, 67655 Kaiserslautern, 82 Punkte, keine veröffentlichte Mail, gefunden am 18.10.2026')
assert.equal(rows[2].props['aria-label'], '[gekürzt] (gekürzt), 67655 Kaiserslautern, 82 Punkte, keine veröffentlichte Mail, gefunden am 19.10.2026')
assert.equal(ui.akte(), null)
// die Raum-Symbole je Zeile
assert.deepEqual(rows.map((r) => ui.parts(r).raumIcon), ['hk-ic hk-ic-raum-handwerk', 'hk-ic hk-ic-raum-aerzte', 'hk-ic hk-ic-raum-handwerk', 'hk-ic hk-ic-raum-selbst', 'hk-ic hk-ic-raum-laden'])
// Grund-Symbol je Zeile
const symbole = rows.map((r) => classesOf(byClass(r, 'hk-zelle')[1].children[0]).join(' '))
assert.deepEqual(symbole, ['hk-ic hk-ic-brief-x', 'hk-ic hk-ic-brief-x', 'hk-ic hk-ic-brief-x', 'hk-ic hk-ic-kette', 'hk-ic hk-ic-pfeil-runter'])
// Punkte-Balken: Farbe und Breite aus der Punktzahl
assert.deepEqual(rows.map((r) => byClass(r, 'hk-kl-balken-fuell').map((n) => `${classesOf(n).at(-1)} ${n.props.style.width}`)[0]), ['hk-kl-farbe-gut 39', 'hk-kl-farbe-gut 39', 'hk-kl-farbe-gut 39', 'hk-kl-farbe-mittel 36', 'hk-kl-farbe-schlecht 19'])
} finally { ui.end() }
})
// ---------------------------------------------------------------------------------------------------------------
// Klassen, Stile, Quelltext
const CSS_DATEIEN = ['src/styles/hustler/keller.css', 'src/styles/hustler/hustler-keller-ui.css', 'src/styles/hustler/hustler-ui.css']
const cssOhne = (p) => read(p).replace(/\/\*[\s\S]*?\*\//g, '')
const cssKlassen = (p) => new Set((cssOhne(p).replace(/url\([^)]*\)/g, '').match(/\.hk-[A-Za-z0-9_-]+/g) ?? []).map((k) => k.slice(1)))
const CSS_ALLE = new Set(CSS_DATEIEN.flatMap((p) => [...cssKlassen(p)]))
test('Klassen: jede gerenderte Klasse beginnt mit hk- und steht in keller.css, hustler-keller-ui.css oder hustler-ui.css (Alarm-Marker)', async () => {
const gesehen = new Set()
const sammle = (ui) => { for (const c of allClasses(ui.tree())) gesehen.add(c) }
const alleKlassen = (ui) => {
nodes(ui.tree(), (n) => { for (const c of classesOf(n)) assert.match(c, /^hk-[a-z0-9-]+$/, `Klasse ohne hk-: ${c}`); return false })
}
// alle Arne-Zustände
for (const s of ['arbeitet', 'wartet', 'leerlauf', 'grau', 'fehler']) {
const { ui } = await szene({ arne: eintrag(s) })
try { sammle(ui); alleKlassen(ui) } finally { ui.end() }
}
// Liste mit allen Räumen, gekürzt, Heilberuf, Akte (offen, mit Link und ohne)
const api = fakeApi()
const ui = await start(api, { kpis: { botLeads: 5 }, workers: { arne: eintrag('arbeitet') } })
try {
sammle(ui); alleKlassen(ui) // Laden
await ui.answer(0, { items: [...SEITE, { ...ITEM, id: 'p9', score: 20 }, { ...ITEM, id: 'p10', score: undefined }], total: 120, pageSize: 50 })
sammle(ui); alleKlassen(ui)
for (const nr of [0, 1, 2, 3, 4, 5, 6]) {
await ui.openRow(nr)
sammle(ui); alleKlassen(ui)
}
await ui.key('Escape')
await ui.click('Register zuklappen')
sammle(ui); alleKlassen(ui)
await ui.click('Register aufklappen')
await ui.pick(idx('laden'))
await ui.fail(1, new Error('weg'))
sammle(ui); alleKlassen(ui) // Fehler
await ui.pick(idx('selbst'))
await ui.answer(2, { items: [], total: 0 })
sammle(ui); alleKlassen(ui) // leer
} finally { ui.end() }
const einzeln = await start(fakeApi(), { bot: 'p1', onClearBot: () => {} })
try {
sammle(einzeln)
await einzeln.answer(0, { items: [ITEM], total: 1 })
sammle(einzeln); alleKlassen(einzeln) // Einzelansicht
} finally { einzeln.end() }
assert.ok(gesehen.size > 60, `nur ${gesehen.size} Klassen gesehen`)
const fehlen = [...gesehen].filter((c) => !CSS_ALLE.has(c))
assert.deepEqual(fehlen, [], `Klassen ohne Regel: ${fehlen.join(', ')}`)
// Zustandsklassen der Pose und der Punkte-Farbe kommen vor
for (const c of ['hk-kl-fig-warten', 'hk-kl-fig-schlafen', 'hk-kl-fig-nicht', 'hk-kl-farbe-gut', 'hk-kl-farbe-mittel', 'hk-kl-farbe-schlecht', 'hk-kl-farbe-leer', 'hk-alarm', 'hk-arne-weg', 'hk-zeile-alt', 'hk-reiter-an', 'hk-kl-tag']) {
assert.ok(gesehen.has(c), `${c} nie gerendert`)
}
})
test('Quelltext: keine CSS- und Bild-Importe in den Komponenten, kein innerHTML, keine Inline-Bilder', () => {
const dateien = ['src/components/workers/BotLeadList.jsx', 'src/components/workers/keller/KellerSzene.jsx', 'src/components/workers/keller/RegisterBuch.jsx', 'src/components/workers/keller/Akte.jsx', 'src/lib/hustlerKeller.js', 'src/lib/botleadGruende.js']
for (const p of dateien) {
const src = ohneKommentare(read(p))
assert.doesNotMatch(src, /^\s*import\s[^;]*?['"][^'"]*\.(?:css|scss|png|jpe?g|svg|gif|webp)['"]/m, `${p}: Import von CSS oder Bild`)
assert.doesNotMatch(src, /^\s*import\s+['"][^'"]*\.(?:css|png|svg)['"]/m, `${p}: Import von CSS oder Bild`)
assert.doesNotMatch(src, /\brequire\(/, p)
assert.doesNotMatch(src, /dangerouslySetInnerHTML|innerHTML|outerHTML|insertAdjacentHTML/, p)
assert.doesNotMatch(src, /url\(|data:image|\.png|\.svg/, `${p}: Bildadresse im Quelltext`)
assert.doesNotMatch(src, /\bnew Image\b|
= css.length) return
if (css[i] === '}') { i += 1; return }
const start = i
while (i < css.length && css[i] !== '{' && css[i] !== '}') i += 1
const kopf = css.slice(start, i).trim()
assert.equal(css[i], '{', `Regel ohne Block: ${kopf.slice(0, 60)}`)
i += 1
if (kopf.startsWith('@keyframes')) {
keyframes.push(kopf.slice('@keyframes'.length).trim())
let tiefe = 1
while (i < css.length && tiefe > 0) { if (css[i] === '{') tiefe += 1; if (css[i] === '}') tiefe -= 1; i += 1 }
} else if (kopf.startsWith('@media')) {
liste(kopf)
} else {
const b = i
while (i < css.length && css[i] !== '}') i += 1
regeln.push({ selektor: kopf, koerper: css.slice(b, i), kontext })
i += 1
}
}
}
liste('')
return { regeln, keyframes }
}
const UI_CSS = cssOhne('src/styles/hustler/hustler-keller-ui.css')
const ui = zerlege(UI_CSS)
test('hustler-keller-ui.css: jede Regel steht unter .hk-keller, jede Klasse beginnt mit hk-, eigene Klassen heißen hk-kl-…, Keyframes hk-kl-…', () => {
assert.ok(ui.regeln.length > 60, `nur ${ui.regeln.length} Regeln`)
for (const r of ui.regeln) {
for (const teil of r.selektor.split(',').map((s) => s.trim())) {
assert.ok(teil.startsWith('.hk-keller') || teil.startsWith('.hk-seite > .hk-keller'), `nicht unter .hk-keller: ${teil}`)
assert.ok(!/(^|[\s>+~(,])(?:html|body|:root)(?![\w-])/i.test(teil), `html/body/:root in ${teil}`)
for (const m of teil.matchAll(/\.(-?[A-Za-z_][\w-]*)/g)) assert.match(m[1], /^hk-/, `${teil}: ${m[1]}`)
}
}
for (const k of ui.keyframes) assert.match(k, /^hk-kl-/, k)
assert.equal(new Set(ui.keyframes).size, ui.keyframes.length)
assert.match(UI_CSS.split('\n')[0] ?? '', /^/)
assert.match(read('src/styles/hustler/hustler-keller-ui.css'), /^\/\* Keller \(BotLeadList\.jsx und keller\/\*/)
})
test('hustler-keller-ui.css: jede Bilddatei aus url(...) gibt es unter src/assets/hustler, jede genannte Animation ist ein Keyframe aus dieser Datei oder aus keller.css', () => {
const urls = [...UI_CSS.matchAll(/url\(\s*([^)]*?)\s*\)/g)].map((m) => m[1].replace(/^["']|["']$/g, ''))
assert.ok(urls.length >= 5)
const assets = path.join(ROOT, 'src/assets/hustler')
for (const u of urls) {
assert.match(u, /^\.\.\/\.\.\/assets\/hustler\/[\w./-]+\.png$/, u)
const ziel = path.resolve(path.join(ROOT, 'src/styles/hustler'), u)
assert.ok(ziel.startsWith(assets + path.sep) && existsSync(ziel) && statSync(ziel).isFile(), `${u} fehlt`)
}
assert.deepEqual([...new Set(urls)].map((u) => u.replace('../../assets/hustler/', '')).sort(), [
'figuren/muetze-nicht.png', 'figuren/muetze-schlafen.png', 'figuren/muetze-warten.png', 'figuren/punkte.png', 'figuren/zzz.png',
])
const kf = new Set([...ui.keyframes, ...zerlege(cssOhne('src/styles/hustler/keller.css')).keyframes])
const woerter = new Set(['none', 'infinite', 'linear', 'steps', 'end', 'ease', 'ease-in-out'])
let geprueft = 0
for (const r of ui.regeln) {
for (const d of r.koerper.split(';')) {
const k = d.indexOf(':')
if (k < 0 || !/^\s*(?:-webkit-)?animation(?:-name)?\s*$/.test(d.slice(0, k))) continue
for (const w of d.slice(k + 1).split(/[\s,]+/).filter(Boolean)) {
if (/^[+-]?[\d.]+(?:ms|s)?$/.test(w) || w.includes('(') || w.endsWith(')') || woerter.has(w)) continue
assert.ok(kf.has(w), `Animation ${w} ohne Keyframes`)
geprueft += 1
}
}
}
assert.ok(geprueft >= 5, `nur ${geprueft} Animationen`)
})
test('hustler-keller-ui.css: jede eigene Klasse (hk-kl-…) wird gebraucht, jede gerenderte eigene Klasse hat eine Regel; weniger Bewegung stellt Arnes Animationen ab', () => {
const quellen = ['src/components/workers/BotLeadList.jsx', 'src/components/workers/keller/KellerSzene.jsx', 'src/components/workers/keller/RegisterBuch.jsx', 'src/components/workers/keller/Akte.jsx', 'src/lib/hustlerKeller.js']
.map((p) => ohneKommentare(read(p))).join('\n')
const eigene = [...cssKlassen('src/styles/hustler/hustler-keller-ui.css')].filter((k) => k.startsWith('hk-kl-'))
assert.ok(eigene.length > 40)
// dynamisch gebildete Klassen (hk-kl-fig-, hk-kl-farbe-) stehen mit ihrem Vorsatz im Quelltext, alle Fälle als Regel (unten)
for (const k of eigene) assert.ok(quellen.includes(k) || /^hk-kl-(?:fig|farbe)-/.test(k), `${k} steht im CSS, aber in keiner Komponente`)
assert.ok(quellen.includes('hk-kl-fig-${') && quellen.includes('hk-kl-farbe-${'))
for (const pose of ['warten', 'schlafen', 'nicht']) assert.ok(eigene.includes(`hk-kl-fig-${pose}`))
for (const farbe of ['gut', 'mittel', 'schlecht', 'leer']) assert.ok(eigene.includes(`hk-kl-farbe-${farbe}`))
// jede Regel mit einer Animation (außer „none“) steht im Block „weniger Bewegung“
const reduce = ui.regeln.filter((r) => r.kontext.includes('prefers-reduced-motion'))
assert.ok(reduce.length >= 1)
const reduziert = reduce.map((r) => r.selektor).join(',')
for (const r of ui.regeln.filter((x) => !x.kontext.includes('prefers-reduced-motion'))) {
const anim = /(?:^|;)\s*animation\s*:\s*([^;]+)/.exec(r.koerper)
if (!anim || /^\s*none\b/.test(anim[1])) continue
for (const teil of r.selektor.split(',').map((s) => s.trim())) assert.ok(reduziert.includes(teil), `${teil} animiert, aber nicht in „weniger Bewegung“`)
}
for (const r of reduce) assert.match(r.koerper, /animation\s*:\s*none/)
})
test('hustler-keller-ui.css: Handy unter 720 px (E13): Buch in voller Breite über abgedunkelter Szene, Akte als Vollbild', () => {
const handy = ui.regeln.filter((r) => /max-width:\s*719px/.test(r.kontext))
assert.ok(handy.length >= 8)
const finde = (sel) => handy.find((r) => r.selektor.split(',').map((s) => s.trim()).includes(sel))
assert.match(finde('.hk-keller .hk-kl-dunkel').koerper, /display:\s*block[\s\S]*rgba\(6, 7, 18, 0\.62\)/)
assert.match(finde('.hk-keller .hk-kl-ui').koerper, /left:\s*8px[\s\S]*width:\s*auto/)
const akte = finde('.hk-keller .hk-akte.hk-kl-akte').koerper
assert.match(akte, /width:\s*100%/); assert.match(akte, /height:\s*100%/); assert.match(akte, /max-height:\s*none/)
assert.match(finde('.hk-keller .hk-kl-akte-ueber').koerper, /padding:\s*0/)
// die Abdunklung ist am Desktop aus
assert.match(ui.regeln.find((r) => r.selektor === '.hk-keller .hk-kl-dunkel' && r.kontext === '').koerper, /display:\s*none/)
// Reiter wischen seitlich, Spalten Punkte und Telefon entfallen
assert.match(finde('.hk-keller .hk-kl-reiter').koerper, /overflow-x:\s*auto/)
assert.ok(finde('.hk-keller .hk-kl-buch .hk-kl-spalte-punkte'))
})
test('hustler-keller-ui.css: Überschreibungen erzeugter Regeln haben höhere Spezifität (Reihenfolge der Stylesheets egal)', () => {
// Wo eine eigene Regel eine erzeugte aus keller.css überstimmt (Knopf, Zeile, Reiter, Akte, Symbol, Suche), steht eine zweite Klasse davor
const erzeugt = ['hk-knopf', 'hk-zeile', 'hk-kopfzeile', 'hk-reiter', 'hk-akte', 'hk-ic', 'hk-suche', 'hk-zelle', 'hk-sz-rohrpost', 'hk-sz-schublade', 'hk-sz-buch-stempel']
for (const r of ui.regeln) {
for (const teil of r.selektor.split(',').map((s) => s.trim())) {
const klassen = [...teil.matchAll(/\.(hk-[\w-]+)/g)].map((m) => m[1]).filter((k) => k !== 'hk-keller')
const trifft = klassen.filter((k) => erzeugt.includes(k))
if (trifft.length === 0) continue
// eine erzeugte Klasse im Selektor: mindestens zwei Klassen nach .hk-keller oder ein Elementname
assert.ok(klassen.length >= 2 || /\.hk-[\w-]+\s*:|input\./.test(teil) || /\sinput\.hk/.test(teil), `${teil}: nur eine erzeugte Klasse, Reihenfolge der Stylesheets entscheidet`)
}
}
})
// ---------------------------------------------------------------------------------------------------------------
// T13 (Endprüfung): Schrift, Tippflächen, Fokusring der Reiter, Stapelreihenfolge, Kontrast
const eigeneRegeln = (selektor, kontext = '') => ui.regeln.filter((r) => r.kontext === kontext && r.selektor.split(',').map((t) => t.trim()).includes(selektor))
const handyRegeln = (selektor) => ui.regeln.filter((r) => /max-width:\s*719px/.test(r.kontext) && r.selektor.split(',').map((t) => t.trim()).includes(selektor))
const eigenschaft = (regel, name) => new RegExp(`(?:^|;)\\s*${name}\\s*:\\s*([^;]+)`).exec(regel.koerper)?.[1].trim()
const pixel = (wert) => Number.parseFloat(wert)
test('hustler-keller-ui.css (T13 K5): Seitenzeile, Punkte-Zahl und Reiterzahl stehen in Pixelify Sans ab 13 px, nicht in Press Start 2P mit 8 px', () => {
for (const klasse of ['hk-kl-seiten', 'hk-kl-punkte-text', 'hk-kl-reiter-zahl']) {
const regeln = ui.regeln.filter((r) => r.selektor.split(',').some((t) => t.trim().endsWith(`.${klasse}`)))
assert.ok(regeln.length >= 1, klasse)
for (const r of regeln) {
const schrift = eigenschaft(r, 'font-family'); const groesse = eigenschaft(r, 'font-size')
if (r.kontext === '') { assert.ok(schrift && groesse, `${klasse}: Schrift und Größe stehen in der Grundregel`) }
if (schrift) { assert.match(schrift, /^'Pixelify Sans'/, klasse); assert.doesNotMatch(schrift, /Press Start 2P/, klasse) }
if (groesse) {
assert.ok(pixel(groesse) >= 13, `${klasse}: ${groesse}`)
const zeile = eigenschaft(r, 'line-height'); if (zeile) assert.ok(pixel(zeile) >= pixel(groesse), `${klasse}: Zeilenhöhe ${zeile}`)
}
}
}
})
test('hustler-keller-ui.css (T13 K7): am Handy sind Suchfeld und der Textlink der Akte mindestens 40 px hoch (Tippfläche), die Lupe bleibt im Feld mittig', () => {
const suche = handyRegeln('.hk-keller input.hk-suche')
assert.equal(suche.length, 1); assert.ok(pixel(eigenschaft(suche[0], 'min-height')) >= 40)
const link = handyRegeln('.hk-keller .hk-akte-link')
assert.equal(link.length, 1); assert.ok(pixel(eigenschaft(link[0], 'min-height')) >= 40)
// das Suchfeld ist am Desktop 36 px hoch (erzeugt); min-height gewinnt dort, wo es nötig ist; die Spezifität der Handy-Regel schlägt die erzeugte (input.hk-suche, 2 Klassen + Element)
assert.match(cssOhne('src/styles/hustler/keller.css'), /\.hk-keller \.hk-suche\{[^}]*height:36px/)
const lupe = handyRegeln('.hk-keller .hk-kl-lupe')
assert.equal(lupe.length, 1); assert.equal(pixel(eigenschaft(lupe[0], 'top')), 4) // 40 px Feld: 4 px oben, das 32-px-Symbol sitzt mittig (vorher 2 px bei 36 px)
// der Link steht in der Zeile des Textes und zentriert ihn (inline-flex), die 40 px sind also Fläche des Links; „Quelle öffnen“ trägt genau diese Klasse
assert.match(cssOhne('src/styles/hustler/keller.css'), /\.hk-keller \.hk-akte-link\{[^}]*display:inline-flex[^}]*align-items:center/)
assert.match(read('src/components/workers/keller/Akte.jsx'), / /min-height/.test(r.koerper)).length, 0, sel)
})
// Spezifität grob: Zahl der Klassen und Pseudoklassen (Elementnamen zählen weniger und sind hier nicht nötig)
const spezifitaet = (selektor) => (selektor.match(/\.[\w-]+/g) ?? []).length + (selektor.match(/:[\w-]+/g) ?? []).length
test('hustler-keller-ui.css (T13 M3): Raum-Reiter zeigen den Fokus innen (Umriss nach innen, gelbe Innenkante), auch der aktive; die Regel schlägt die erzeugte', () => {
const erzeugt = zerlege(cssOhne('src/styles/hustler/keller.css')).regeln.find((r) => r.selektor === '.hk-keller .hk-reiter:focus-visible')
assert.ok(erzeugt)
assert.match(erzeugt.koerper, /outline-offset:\s*2px/) // das war der Fehler: außen, am Handy vom wischbaren Reiterstreifen abgeschnitten
const normal = eigeneRegeln('.hk-keller .hk-kl-reiter .hk-reiter:focus-visible')
const aktiv = eigeneRegeln('.hk-keller .hk-kl-reiter .hk-reiter-an:focus-visible')
assert.equal(normal.length, 1); assert.equal(aktiv.length, 1)
assert.equal(eigenschaft(normal[0], 'outline-offset'), '-2px'); assert.match(eigenschaft(normal[0], 'outline'), /^2px solid #1a1630$/)
// der Ring ist innen (inset) und gelb; der Kasten-Schatten, der den Rand des Reiters bildet, bleibt erhalten (box-shadow ersetzt, nicht ergänzt)
assert.match(eigenschaft(normal[0], 'box-shadow'), /inset 0 0 0 4px #ffd25a$/); assert.match(eigenschaft(normal[0], 'box-shadow'), /^0 -2px 0 0 #2b2230, -2px 0 0 0 #2b2230, 2px 0 0 0 #2b2230/)
assert.match(eigenschaft(aktiv[0], 'box-shadow'), /inset 0 0 0 4px #ffd25a$/); assert.match(eigenschaft(aktiv[0], 'box-shadow'), /^0 -2px 0 0 #7a2e3a, -2px 0 0 0 #7a2e3a, 2px 0 0 0 #7a2e3a/)
// höhere Spezifität als die erzeugten Regeln (auch .hk-reiter-an:hover bleibt unberührt)
assert.ok(spezifitaet('.hk-keller .hk-kl-reiter .hk-reiter:focus-visible') > spezifitaet(erzeugt.selektor))
assert.ok(spezifitaet('.hk-keller .hk-kl-reiter .hk-reiter-an:focus-visible') > spezifitaet('.hk-keller .hk-reiter-an,.hk-keller .hk-reiter-an:hover'.split(',')[1]))
// der Reiter steckt wirklich im Streifen .hk-kl-reiter (RegisterBuch)
assert.match(read('src/components/workers/keller/RegisterBuch.jsx'), /className="hk-kl-reiter"[\s\S]*REITER\.map/)
})
test('hustler-keller-ui.css (T13 R4): die Akte liegt unter der Handy-Seitenleiste (z-index 20) und ihrem Hintergrund (15), aber über Szene und Buch', () => {
const akte = eigeneRegeln('.hk-keller .hk-kl-akte-ueber')[0]
const z = Number(eigenschaft(akte, 'z-index'))
assert.ok(z <= 9, `Akte z-index ${z}`)
const buch = Number(eigenschaft(eigeneRegeln('.hk-keller .hk-kl-buch')[0], 'z-index'))
assert.ok(z > buch, 'über dem Buch')
for (const r of ui.regeln) { const w = eigenschaft(r, 'z-index'); if (w) assert.ok(Number(w) <= 9, `${r.selektor}: z-index ${w}`) }
})
// Kontrast nach WCAG 2.x: relative Leuchtdichte, (hell + 0,05) / (dunkel + 0,05)
const kanal = (c) => { const v = c / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 }
const leuchtdichte = ([r, g, b]) => 0.2126 * kanal(r) + 0.7152 * kanal(g) + 0.0722 * kanal(b)
const kontrast = (a, b) => { const [x, y] = [leuchtdichte(a), leuchtdichte(b)]; return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) }
const hex = (t) => { const m = /#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})\b/i.exec(t); assert.ok(m, t); return [1, 2, 3].map((i) => Number.parseInt(m[i], 16)) }
const mische = (vorn, alpha, hinten) => vorn.map((v, i) => v * alpha + hinten[i] * (1 - alpha)) // Farbe mit Deckkraft über einem Grund
// RGBA-Pixel einer 8-Bit-PNG (nicht verzahnt; Filter 0–4): das Papier des Buchs liegt als Bild vor (buch.png, „fill“ in der Mitte)
function pngPixel(datei, x, y) {
const d = readFileSync(path.join(ROOT, datei))
const breite = d.readUInt32BE(16); const farbtyp = d[25]; const kanaele = { 2: 3, 6: 4 }[farbtyp]
assert.ok(kanaele && d[24] === 8 && d[28] === 0, 'PNG: 8 Bit, RGB oder RGBA, nicht verzahnt')
const teile = []
for (let i = 8; i < d.length;) { const l = d.readUInt32BE(i); if (d.toString('latin1', i + 4, i + 8) === 'IDAT') teile.push(d.subarray(i + 8, i + 8 + l)); i += 12 + l }
const roh = zlibSync(Buffer.concat(teile))
const zeile = breite * kanaele
let davor = Buffer.alloc(zeile)
for (let row = 0; row <= y; row += 1) {
const filter = roh[row * (zeile + 1)]; const aktuell = Buffer.from(roh.subarray(row * (zeile + 1) + 1, (row + 1) * (zeile + 1)))
for (let i = 0; i < zeile; i += 1) {
const a = i >= kanaele ? aktuell[i - kanaele] : 0; const b = davor[i]; const c = i >= kanaele ? davor[i - kanaele] : 0
const p = a + b - c; const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c)
aktuell[i] = (aktuell[i] + [0, a, b, (a + b) >> 1, pa <= pb && pa <= pc ? a : pb <= pc ? b : c][filter]) & 255
}
davor = aktuell
}
return [...davor.subarray(x * kanaele, x * kanaele + 3)]
}
test('Kontrast (T13 C5): grau gestellte Einträge („gekürzt“: Zeilentext und Marke) haben auf dem Papier des Buchs und auf der Hover-Tönung mindestens 4,5 : 1', () => {
const erzeugt = zerlege(cssOhne('src/styles/hustler/keller.css')).regeln
const alt = erzeugt.find((r) => r.selektor === '.hk-keller .hk-zeile-alt')
const marke = eigeneRegeln('.hk-keller .hk-kl-tag')[0]
const hover = erzeugt.find((r) => r.selektor === '.hk-keller .hk-zeile:hover')
assert.ok(alt && marke && hover)
const vordergruende = { 'hk-zeile-alt': hex(eigenschaft(alt, 'color')), 'hk-kl-tag': hex(eigenschaft(marke, 'color')) }
// Papier: Mitte des Buchs (buch.png, 48 x 48); dieselbe Farbe steht als Papierton im CSS (Reiter „Alle“, großer Punkte-Balken)
const papier = pngPixel('src/assets/hustler/keller/ui/buch.png', 24, 24)
assert.deepEqual(papier, hex(eigenschaft(erzeugt.find((r) => r.selektor === '.hk-keller .hk-reiter-alle'), 'background')))
const m = /rgba\(\s*(\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\s*\)/.exec(eigenschaft(hover, 'background'))
assert.ok(m, 'Hover-Tönung ist rgba(…)')
const hoverGrund = mische([Number(m[1]), Number(m[2]), Number(m[3])], Number(m[4]), papier)
for (const [name, farbe] of Object.entries(vordergruende)) {
const aufPapier = kontrast(farbe, papier); const aufHover = kontrast(farbe, hoverGrund)
assert.ok(aufPapier >= 4.5, `${name} auf Papier: ${aufPapier.toFixed(2)} : 1`)
assert.ok(aufHover >= 4.5, `${name} auf Hover: ${aufHover.toFixed(2)} : 1`)
}
// die Rechnung selbst stimmt (Gegenprobe mit bekannten Werten: Schwarz auf Weiß 21 : 1, gleiche Farben 1 : 1)
assert.ok(Math.abs(kontrast([0, 0, 0], [255, 255, 255]) - 21) < 1e-9); assert.equal(kontrast([10, 20, 30], [10, 20, 30]), 1)
})