Files
tickte-system/tests/unit/hustlerKeller.test.mjs
ClaudeandClaude Sonnet 5.5 0924a8d2ae Hustler-Pixelwelt T13: Korrekturen Pult und Keller (Sperre laufender Aktionen, Fokus, Kontrast, Handy-Tippflächen, Pause)
Pult: laufende Anfragen je Eintrags-Id in PultList (laufRef, Brief meldet Beginn/Ende): Brief schließen und denselben Eintrag
wieder öffnen sperrt alle Aktionsknöpfe, kein zweites reply/paste/start-build (P1); spätes Ergebnis nennt den Titel (P1b);
Original und Entwurf pre-wrap (P3); Überschrift nimmt beim Einhängen den Fokus (M2); „Pult zuklappen“ mit aria-expanded (K4);
paused -> hk-steht an der Szene (R3); Brief z-index 9 (R4); Textlinks am Handy 40 px (K7).
Keller: Überschrift-Fokus (M2), Reiter-Fokusring innen (M3), Seitenzeile/Punkte/Reiterzahl in Pixelify Sans 13 px (K5), Suchfeld und
Akte-Link 40 px am Handy (K7), Ortszeile mit title (W2), paused -> hk-steht (R3), Akte z-index 9 (R4); Arne ohne Eintrag neutral (R5).
Tests: Sperre laufender Aktionen, Titel im späten Ergebnis, Fokus, aria-expanded, paused, pre-wrap, z-index, Tippflächen, Reiter-Fokus,
Schrift, Kontrast (WCAG) von .hk-zeile-alt/.hk-kl-tag auf Papier und Hover, Arne neutral (alte „Arne schläft ohne Eintrag“-Prüfung ersetzt).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 09:40:06 +00:00

950 lines
56 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 { 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|<img\b/, p)
}
// BotLeadList und die Teile importieren nur React, die eigenen Teile und Dateien aus src/lib
const erlaubt = /^(?:react|\.\/keller\/(?:Akte|KellerSzene|RegisterBuch)|\.\.\/\.\.\/lib\/[A-Za-z]+|\.\.\/\.\.\/\.\.\/lib\/[A-Za-z]+)$/
for (const p of dateien.slice(0, 4)) {
for (const m of ohneKommentare(read(p)).matchAll(/^\s*import\b[^'"]*['"]([^'"]+)['"]/gm)) assert.match(m[1], erlaubt, `${p}: ${m[1]}`)
}
})
// Zerlegt eine CSS-Datei in Regeln { selektor, koerper, kontext } und Keyframe-Namen
function zerlege(css) {
const regeln = []
const keyframes = []
let i = 0
function liste(kontext) {
for (;;) {
while (i < css.length && /\s/.test(css[i])) i += 1
if (i >= 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-<Pose>, hk-kl-farbe-<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'), /<a className="hk-akte-link" href=\{e\.sourceHref\}/)
// am Desktop ändert sich nichts: die Mindesthöhe steht nur im Handy-Block
for (const sel of ['.hk-keller input.hk-suche', '.hk-keller .hk-akte-link']) assert.equal(eigeneRegeln(sel).filter((r) => /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)
})