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) })