Hustler-Pixelwelt T10: Keller (Szene mit Arne nach Status, Register-Buch, Akte, Gründe)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-22 08:53:56 +00:00
1 parent ec63e5a43d
commit 1b3fdf5d97
14 files changed
+2247 -312

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
{
"quelle": "workers-dev-p6/src/scoring/business.js: Konstante GRUND (Stand d0d66f9). SCHWELLE ist `Punktzahl unter ${MIN_SCORE_MAIL}` mit MIN_SCORE_MAIL = 55. Die Texte stehen als fester Grund in der Bot-Lead-Liste des Dienstes (botLeadReason).",
"stand": "d0d66f9",
"gruende": {
"KEINE_MAIL": "keine veröffentlichte Mail",
"MX": "Adresse nicht zustellbar",
"KETTE": "Kette oder Franchise",
"WEBSITE_GUT": "Website in gutem Zustand",
"SCHWELLE": "Punktzahl unter 55",
"VERALTET": "veraltet (nicht ausgewählt)",
"BEKANNT": "schon bekannt (E-Mail oder Telefon)",
"BEKANNT_NAME": "schon bekannt (Name und PLZ)",
"GESPERRT": "Sperrliste",
"LESEN": "Lesen fehlgeschlagen",
"IMPRESSUM_FREMD": "Impressum passt nicht zum Betrieb",
"ABGEBROCHEN": "Lauf unterbrochen",
"BEWERTUNG_AUSGEFALLEN": "Bewertung ausgefallen",
"NAME_FEHLT": "Name fehlt"
}
}
+143 -205
View File
@@ -1,134 +1,22 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'
import { fakeReact } from './helpers/fakeReact.mjs'
import { loadModule } from './load.mjs'
import {
ITEM, KUERZ, PRAXIS, buttons, byClass, fakeApi, fakeFocus, nodes, settle, start, textOf,
} from './helpers/kellerRunner.mjs'
// BotLeadList läuft wirklich: im Mini-Läufer anstelle von React und DOM (Muster workersPultUi.test.mjs, Baustein helpers/fakeReact.mjs).
// Gerendert wird in einen Baum aus { tag, props, children }; Eingaben und Klicks ruft der Test von Hand auf, die Zeit (window.setTimeout)
// läuft von Hand, der Dienst ist eine Attrappe mit offenen Versprechen.
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
const settle = () => new Promise((resolve) => setImmediate(resolve))
// BotLeadList (Hustler-Pixelwelt T10: der Keller) läuft wirklich: im Mini-Läufer anstelle von React und DOM (Muster workersPultUi.test.mjs, Baustein helpers/fakeReact.mjs,
// gemeinsamer Läufer helpers/kellerRunner.mjs). Gerendert wird in einen Baum aus { tag, props, children }; Eingaben und Klicks ruft der Test von Hand auf, die Zeit
// (window.setTimeout) läuft von Hand, der Dienst ist eine Attrappe mit offenen Versprechen.
// Die fachlichen Zusicherungen der alten Tabelle gelten unverändert (Abruf, Suche mit Verzögerung, Seiten, Fokus beim Blättern, „Erneut versuchen“, Einzelansicht, Telefonregel,
// sichere Links, Leer- und Ladezustände). Umgestellt ist nur, wo sie stehen: Zeilen sind Knöpfe des Buchs (hk-zeile) statt Tabellenzeilen, der Raumfilter sind Reiter statt einer
// Auswahlliste, die Blätterknöpfe heißen „Vorherige Seite“ / „Nächste Seite“ (◂ ▸), Quelle und Raum-Beschriftung stehen in der Akte. Kommentare „UMGESTELLT“ nennen jede Änderung.
const { ROOM_FILTERS } = await loadModule('src/lib/botleadsView.js')
// Die Komponente wird einmal gebündelt; jeder Test hängt sie neu ein (eigener Zustand)
let bundle
const loadUi = () => (bundle ??= esbuild.build({
stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as BotLeadList } from './src/components/workers/BotLeadList.jsx'", resolveDir: ROOT, loader: 'js' },
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact],
}).then(importBundle))
// Baum durchsuchen
const nodes = (node, match, out = []) => {
if (node === null || typeof node === 'string') return out
if (match(node)) out.push(node)
for (const child of node.children) nodes(child, match, out)
return out
}
const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join(''))
const hasClass = (n, name) => String(n.props.className ?? '').split(' ').includes(name)
const buttons = (tree, label) => nodes(tree, (n) => n.tag === 'button' && textOf(n).trim() === label)
// Dienst-Attrappe: jeder Aufruf von botleads ist ein offenes Versprechen, das der Test selbst auflöst
function fakeApi() {
const calls = []
return { calls, botleads: (params) => new Promise((resolve, reject) => { calls.push({ params, resolve, reject }) }) }
}
// DOM-Attrappe für den Fokus (Muster workersPultUi.test.mjs): document mit body und activeElement, ref an select und den Blätterknöpfen liefert __fakeRef.
// Der Schlüssel ist „select“ oder die Beschriftung des Knopfs. Ein Element gilt nur, solange es im Baum steht (isConnected, wie im Browser); steht es beim
// nächsten Render nicht im Baum der Runde davor (Seitenzeile während des Ladens), ist es danach ein neues Element, so wie React die Knöpfe neu einhängt.
// focus() zählt nur für ein eingehängtes Element, merkt sich Schlüssel und Argument und setzt activeElement (Fokus liegt dann auf dem Element).
function fakeFocus() {
const body = { name: 'body' }
const doc = { body, documentElement: { name: 'html' }, activeElement: body }
const f = { doc, body, focused: [], options: [], tree: () => null, view: null, made: new Map() }
const keyOf = (tag, props) => (tag === 'button' ? String(props.children) : tag)
const live = (tag, key) => nodes(f.tree(), (n) => n.tag === tag && keyOf(tag, n.props) === key).length > 0
const had = Object.hasOwn(globalThis, 'document')
const saved = globalThis.document
globalThis.document = doc
globalThis.__fakeRef = (tag, props) => {
const key = keyOf(tag, props)
const known = f.made.get(key)
if (!known || !live(tag, key)) { // während dieser Runde zeigt f.tree() noch die Runde davor
f.made.set(key, {
key, props,
get disabled() { return this.props.disabled === true },
get isConnected() { return f.made.get(key) === this && live(tag, key) },
focus(options) { if (this.isConnected) { f.focused.push(key); f.options.push(options); doc.activeElement = this } },
})
}
f.made.get(key).props = props
return f.made.get(key)
}
// ein Element, das der Nutzer von Hand ansteuert (Suchfeld, Link …): eingehängt, bedienbar
f.other = (name) => ({ name, isConnected: true, disabled: false, focus() { doc.activeElement = this } })
f.attach = (view) => { f.view = view; f.tree = () => view.tree() }
// Der Nutzer drückt einen Knopf: der Browser gibt ihm den Fokus, dann läuft der Klick
// (Knöpfe ohne ref, z. B. „Erneut versuchen“, sind nur so lange Element, wie ein Knopf dieser Beschriftung im Baum steht)
f.press = async (label) => {
doc.activeElement = f.made.get(label) ?? { name: label, disabled: false, get isConnected() { return buttons(f.tree(), label).length > 0 }, focus() { doc.activeElement = this } }
await f.view.click(label)
}
f.restore = () => {
if (had) globalThis.document = saved
else delete globalThis.document
delete globalThis.__fakeRef
}
return f
}
const ITEM = {
id: 'p1', room: 'handwerk', roomLabel: 'Handwerk', name: 'Elektro Beispiel GmbH', postalCode: '67655', city: 'Kaiserslautern', score: 82,
reason: 'keine veröffentlichte Mail', sourceUrl: 'https://beispiel-elektro.de/impressum', phone: '0631 111', phoneSource: 'https://beispiel-elektro.de/impressum',
isHealth: false, foundAt: '2026-10-19T20:00:00Z', gekuerzt: false,
}
const PRAXIS = { ...ITEM, id: 'p2', room: 'aerzte', roomLabel: 'Ärzte & Praxen', name: 'Praxis Dr. Muster', isHealth: true, phone: '', phoneSource: '', sourceUrl: 'javascript:alert(1)', foundAt: '2026-10-18T20:00:00Z' }
const KUERZ = { ...ITEM, id: 'p3', name: '[gekürzt]', gekuerzt: true, phone: '', phoneSource: '', sourceUrl: '' }
// Ein Lauf: Komponente einhängen, window.setTimeout von Hand, Props zwischendurch änderbar (rerender)
async function start(api, initial = {}) {
const { __mount, jsx, BotLeadList } = await loadUi()
const timers = new Map()
let nextTimer = 1
const saved = globalThis.window
globalThis.window = {
setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id },
clearTimeout: (id) => { timers.delete(id) },
}
let props = { api, ...initial }
const mounted = __mount(jsx(() => jsx(BotLeadList, props), {}))
const view = {
timers,
tree: () => mounted.tree(),
text: () => textOf(view.tree()),
setProps(next) { props = { ...props, ...next }; mounted.rerender() },
rows: () => nodes(view.tree(), (n) => n.tag === 'tr').filter((tr) => nodes(tr, (n) => n.tag === 'td').length > 0),
cells: (tr) => nodes(tr, (n) => n.tag === 'td').map((td) => textOf(td).trim()),
select: () => nodes(view.tree(), (n) => n.tag === 'select')[0],
search: () => nodes(view.tree(), (n) => n.tag === 'input')[0],
errors: () => nodes(view.tree(), (n) => hasClass(n, 'msg') && hasClass(n, 'err')).map((n) => ({ text: textOf(n), role: n.props.role })),
async pick(room) { view.select().props.onChange({ target: { value: room } }); await settle() },
async type(value) { view.search().props.onChange({ target: { value } }); await settle() },
async click(label) {
const found = buttons(view.tree(), label)
assert.equal(found.length, 1, `Knopf „${label}“ (gefunden: ${found.length})`)
if (!found[0].props.disabled) found[0].props.onClick({ preventDefault() {} })
await settle()
},
disabled: (label) => buttons(view.tree(), label).map((n) => n.props.disabled === true),
fireTimers() { for (const [id, t] of [...timers]) { timers.delete(id); t.fn() } },
// der Dienst antwortet auf den n-ten Aufruf
async answer(n, data) { api.calls[n].resolve(data); await settle() },
async fail(n, err) { api.calls[n].reject(err); await settle() },
lateUpdates: () => mounted.lateUpdates(),
end() { mounted.unmount(); globalThis.window = saved },
}
await settle()
return view
}
const PREV = 'Vorherige Seite'
const NEXT = 'Nächste Seite'
const ROOM_VALUES = ROOM_FILTERS.map((f) => f.value) // ['', 'aerzte', 'handwerk', 'selbst', 'laden']
const idx = (room) => ROOM_VALUES.indexOf(room)
test('lädt beim Start (Raum alle, keine Suche, Seite 1), zeigt zuerst „Wird geladen“, dann die Zeilen', async () => {
const api = fakeApi()
@@ -140,24 +28,42 @@ test('lädt beim Start (Raum alle, keine Suche, Seite 1), zeigt zuerst „Wird g
assert.equal(ui.rows().length, 0)
await ui.answer(0, { items: [ITEM, PRAXIS], total: 2, pageSize: 50 })
assert.ok(!ui.text().includes('Wird geladen'))
assert.deepEqual(nodes(ui.tree(), (n) => n.tag === 'th').map((n) => textOf(n)), ['Betrieb', 'Raum', 'Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden'])
// UMGESTELLT: statt der Tabellenköpfe (Betrieb, Raum, Punkte, Grund, Telefon, Quelle, Gefunden) die Kopfzeile des Buchs; Raum steht als Symbol in der Zeile, Quelle in der Akte
assert.deepEqual(byClass(ui.tree(), 'hk-kopfzeile')[0].children.map(textOf), ['', 'Betrieb · Ort · gefunden', 'Punkte', 'Grund', 'Telefon'])
const rows = ui.rows()
assert.equal(rows.length, 2)
assert.deepEqual(ui.cells(rows[0]), ['Elektro Beispiel GmbH67655 Kaiserslautern', 'Handwerk', '82 Punkte', 'keine veröffentlichte Mail', '0631 111', 'Quelle öffnen', '19.10.2026'])
// Heilberuf: nie eine Nummer, unsichere Quelle ist kein Link
assert.deepEqual(ui.cells(rows[1]).slice(4), ['bewusst nicht gespeichert', '–', '18.10.2026'])
// UMGESTELLT: Ort und Funddatum in einer Zeile unter dem Namen, Punkte als Zahl neben dem Balken („82 Punkte“ steht in der Akte und im Label)
assert.deepEqual(ui.parts(rows[0]), {
name: 'Elektro Beispiel GmbH', sub: '67655 Kaiserslautern · 19.10.2026', score: '82', reason: 'keine veröffentlichte Mail', phone: '0631 111',
raumIcon: 'hk-ic hk-ic-raum-handwerk', label: 'Elektro Beispiel GmbH, 67655 Kaiserslautern, 82 Punkte, keine veröffentlichte Mail, gefunden am 19.10.2026',
})
// Heilberuf: nie eine Nummer
assert.equal(ui.parts(rows[1]).phone, 'bewusst nicht gespeichert')
assert.equal(ui.parts(rows[1]).raumIcon, 'hk-ic hk-ic-raum-aerzte')
assert.equal(ui.parts(rows[1]).sub, '67655 Kaiserslautern · 18.10.2026')
assert.equal(rows[0].props['aria-current'], undefined)
// Zeilen sind Knöpfe (öffnen die Akte), keine Links
assert.ok(rows.every((r) => r.props.type === 'button'))
} finally { ui.end() }
})
test('Quelle: Link nur über die geprüfte Adresse, neuer Tab ohne Rückbezug; kein Anruf-Link', async () => {
test('Quelle: Link nur über die geprüfte Adresse (jetzt in der Akte), neuer Tab ohne Rückbezug; kein Anruf-Link', async () => {
const api = fakeApi()
const ui = await start(api)
try {
await ui.answer(0, { items: [ITEM, PRAXIS, { ...ITEM, id: 'p4', sourceUrl: 'https://user:pw@evil.example/' }], total: 3 })
const links = nodes(ui.tree(), (n) => n.tag === 'a')
// UMGESTELLT: die Liste selbst hat gar keine Links mehr (die Zeilen sind Knöpfe); die Quelle öffnet die Akte
assert.equal(nodes(ui.tree(), (n) => n.tag === 'a').length, 0)
await ui.openRow(0)
let links = nodes(ui.tree(), (n) => n.tag === 'a')
assert.equal(links.length, 1)
assert.deepEqual([links[0].props.href, links[0].props.target, links[0].props.rel], ['https://beispiel-elektro.de/impressum', '_blank', 'noopener noreferrer'])
// zweiter Eintrag: javascript:-Adresse ist kein Link; dritter: Adresse mit Zugangsdaten ebenfalls nicht
await ui.click('Nächste Akte')
assert.equal(nodes(ui.tree(), (n) => n.tag === 'a').length, 0)
await ui.click('Nächste Akte')
links = nodes(ui.tree(), (n) => n.tag === 'a')
assert.equal(links.length, 0)
assert.equal(nodes(ui.tree(), (n) => n.props && typeof n.props.href === 'string' && n.props.href.startsWith('tel:')).length, 0)
} finally { ui.end() }
})
@@ -168,47 +74,74 @@ test('gekürzte Einträge sind grau und tragen „gekürzt“', async () => {
try {
await ui.answer(0, { items: [ITEM, KUERZ], total: 2 })
const [normal, cut] = ui.rows()
assert.equal(hasClass(normal, 'wk-bl-cut'), false)
assert.equal(hasClass(cut, 'wk-bl-cut'), true)
// UMGESTELLT: Klassen des Buchs (hk-zeile-alt statt wk-bl-cut)
assert.equal(normal.props.className.split(' ').includes('hk-zeile-alt'), false)
assert.equal(cut.props.className.split(' ').includes('hk-zeile-alt'), true)
assert.ok(textOf(cut).includes('gekürzt'))
assert.ok(!textOf(normal).includes('gekürzt'))
} finally { ui.end() }
})
test('Raumfilter: aus ROOM_FILTERS, neue Anfrage mit Seite 1; nur die neueste Anfrage zählt', async () => {
test('Raumfilter: Reiter aus ROOM_FILTERS, neue Anfrage mit Seite 1; nur die neueste Anfrage zählt', async () => {
const api = fakeApi()
const ui = await start(api)
try {
assert.deepEqual(nodes(ui.select(), (n) => n.tag === 'option').map((o) => [o.props.value, textOf(o)]), [['', 'Alle Räume'], ['aerzte', 'Ärzte & Praxen'], ['handwerk', 'Handwerk'], ['selbst', 'Selbstständige'], ['laden', 'Läden & Gastro']])
// UMGESTELLT: fünf Reiter mit aria-pressed (statt der Optionen einer Auswahlliste), Beschriftung = ROOM_FILTERS (aria-label), kurze sichtbare Namen, ein Symbol je Reiter
const reiter = ui.reiter()
assert.deepEqual(reiter.map((r) => r.props['aria-label']), ROOM_FILTERS.map((f) => f.label))
assert.deepEqual(reiter.map((r) => textOf(r)), ['Alle', 'Ärzte', 'Handwerk', 'Selbstständige', 'Läden'])
assert.deepEqual(reiter.map((r) => r.props['aria-pressed']), [true, false, false, false, false])
assert.ok(reiter.every((r) => r.props.type === 'button'))
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter')
await ui.click(NEXT)
assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 })
await ui.answer(1, { items: [PRAXIS], total: 120, pageSize: 50 })
// Raum wechseln: zurück auf Seite 1, genau eine neue Anfrage
await ui.pick('handwerk')
await ui.pick(idx('handwerk'))
assert.equal(api.calls.length, 3)
assert.deepEqual(api.calls[2].params, { room: 'handwerk', q: '', page: 1 })
assert.equal(ui.select().props.value, 'handwerk')
assert.equal(ui.activeIndex(), idx('handwerk')) // UMGESTELLT: der gedrückte Reiter statt select.value
// noch einmal gewechselt, bevor die erste Antwort kommt; die späte Antwort der älteren Anfrage darf nichts überschreiben
await ui.pick('laden')
await ui.pick(idx('laden'))
assert.deepEqual(api.calls[3].params, { room: 'laden', q: '', page: 1 })
await ui.answer(2, { items: [ITEM], total: 1 })
assert.equal(ui.rows().length, 0) // „laden“ lädt noch, der alte Raum zeigt nichts
assert.ok(ui.text().includes('Wird geladen'))
await ui.answer(3, { items: [PRAXIS], total: 1 })
assert.deepEqual(ui.rows().map((r) => ui.cells(r)[0]), ['Praxis Dr. Muster67655 Kaiserslautern'])
assert.deepEqual(ui.rows().map((r) => ui.parts(r).name), ['Praxis Dr. Muster'])
// derselbe Raum noch einmal gewählt: keine neue Anfrage
await ui.pick('laden')
await ui.pick(idx('laden'))
assert.equal(api.calls.length, 4)
} finally { ui.end() }
})
test('Raum-Reiter: die Zahl steht nur am aktiven Reiter (= total der Antwort), erst wenn die Antwort da ist', async () => {
const api = fakeApi()
const ui = await start(api)
try {
assert.deepEqual(ui.reiter().map((r) => r.props['aria-label']), ROOM_FILTERS.map((f) => f.label)) // beim Laden keine Zahl
await ui.answer(0, { items: [ITEM], total: 1234, pageSize: 50 })
const zahlen = ui.reiter().map((r) => byClass(r, 'hk-kl-reiter-zahl').map(textOf))
assert.deepEqual(zahlen, [['1.234'], [], [], [], []])
assert.equal(ui.reiter()[0].props['aria-label'], 'Alle Räume, 1.234 Einträge')
await ui.pick(idx('aerzte'))
assert.deepEqual(ui.reiter().map((r) => byClass(r, 'hk-kl-reiter-zahl').length), [0, 0, 0, 0, 0]) // die neue Anfrage läuft: keine Zahl
await ui.answer(1, { items: [PRAXIS], total: 1 })
assert.deepEqual(ui.reiter().map((r) => byClass(r, 'hk-kl-reiter-zahl').map(textOf)), [[], ['1'], [], [], []])
assert.equal(ui.reiter()[1].props['aria-label'], 'Ärzte & Praxen, 1 Eintrag')
// bei einem Fehler steht keine Zahl
await ui.pick(idx('selbst'))
await ui.fail(2, new Error('weg'))
assert.deepEqual(ui.reiter().map((r) => byClass(r, 'hk-kl-reiter-zahl').length), [0, 0, 0, 0, 0])
} finally { ui.end() }
})
test('Suche: 300 ms Verzögerung, nur die letzte Eingabe wird abgefragt, Seite springt auf 1', async () => {
const api = fakeApi()
const ui = await start(api)
try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter')
await ui.click(NEXT)
await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 })
assert.equal(api.calls.length, 2)
for (const value of ['b', 'bä', ' bäckerei ']) await ui.type(value)
@@ -222,6 +155,7 @@ test('Suche: 300 ms Verzögerung, nur die letzte Eingabe wird abgefragt, Seite s
await ui.type('bäckerei'); ui.fireTimers(); await settle()
assert.equal(api.calls.length, 3)
assert.equal(ui.search().props.maxLength, 100)
assert.equal(ui.search().props['aria-label'], 'Betriebe suchen')
} finally { ui.end() }
})
@@ -233,16 +167,16 @@ test('Fehler: der Satz aus der API steht als Meldung, keine Liste; der nächste
assert.deepEqual(ui.errors(), [{ text: 'Der Hustler-Dienst ist gerade nicht erreichbar.', role: 'alert' }])
assert.equal(ui.rows().length, 0)
assert.ok(!ui.text().includes('Noch keine Bot-Leads.'))
await ui.pick('laden')
await ui.pick(idx('laden'))
await ui.answer(1, { items: [ITEM], total: 1 })
assert.deepEqual(ui.errors(), [])
assert.equal(ui.rows().length, 1)
// ein Fehler ohne Satz zeigt trotzdem einen deutschen Text
await ui.pick('selbst')
await ui.pick(idx('selbst'))
await ui.fail(2, {})
assert.deepEqual(ui.errors().map((e) => e.text), ['Unbekannter Fehler'])
// ein später Fehler einer älteren Anfrage zählt nicht
await ui.pick('aerzte'); await ui.pick('handwerk')
await ui.pick(idx('aerzte')); await ui.pick(idx('handwerk'))
await ui.fail(3, new Error('zu spät'))
assert.deepEqual(ui.errors(), [])
} finally { ui.end() }
@@ -254,39 +188,42 @@ test('Leerzustände: ohne Filter „Noch keine Bot-Leads.“, mit Filter oder Su
try {
await ui.answer(0, { items: [], total: 0 })
assert.ok(ui.text().includes('Noch keine Bot-Leads.'))
assert.equal(nodes(ui.tree(), (n) => n.tag === 'table').length, 0)
assert.equal(byClass(ui.tree(), 'hk-liste').length, 0) // UMGESTELLT: keine Liste statt keine Tabelle
assert.ok(!ui.text().includes('Keine Einträge')) // keine Seitenzeile
await ui.pick('laden')
await ui.pick(idx('laden'))
await ui.answer(1, { items: [], total: 0 })
assert.ok(ui.text().includes('Keine Treffer.'))
assert.ok(!ui.text().includes('Noch keine Bot-Leads.'))
await ui.pick('')
await ui.pick(idx(''))
await ui.type('xyz'); ui.fireTimers(); await settle()
await ui.answer(3, { items: [], total: 0 })
assert.ok(ui.text().includes('Keine Treffer.'))
} finally { ui.end() }
})
test('Blättern: Seitenzeile, Zurück und Weiter, pageSize aus der Antwort', async () => {
test('Blättern: Seitenzeile, Zurück und Weiter (◂ ▸), pageSize aus der Antwort', async () => {
const api = fakeApi()
const ui = await start(api)
try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
assert.ok(ui.text().includes('Seite 1 von 3 · 120 Einträge'))
assert.deepEqual([ui.disabled('Zurück'), ui.disabled('Weiter')], [[true], [false]])
await ui.click('Zurück') // gesperrt: nichts passiert
assert.deepEqual([ui.disabled(PREV), ui.disabled(NEXT)], [[true], [false]])
await ui.click(PREV) // gesperrt: nichts passiert
assert.equal(api.calls.length, 1)
await ui.click('Weiter')
await ui.click(NEXT)
await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 })
assert.ok(ui.text().includes('Seite 2 von 3 · 120 Einträge'))
await ui.click('Weiter')
await ui.click(NEXT)
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual([ui.disabled('Zurück'), ui.disabled('Weiter')], [[false], [true]])
await ui.click('Zurück')
assert.deepEqual([ui.disabled(PREV), ui.disabled(NEXT)], [[false], [true]])
await ui.click(PREV)
assert.deepEqual(api.calls[3].params, { room: '', q: '', page: 2 })
// während einer Anfrage ist die Seitenzeile weg: kein zweiter Klick auf eine veraltete Seitenzahl
assert.equal(buttons(ui.tree(), 'Weiter').length, 0)
assert.equal(buttons(ui.tree(), NEXT).length, 0)
// die Knöpfe sind echte Knöpfe mit Beschriftung (nur das Symbol ist zu sehen)
await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 })
assert.ok(buttons(ui.tree(), NEXT).every((b) => b.props.type === 'button' && b.props['aria-label'] === NEXT))
} finally { ui.end() }
})
@@ -296,7 +233,7 @@ test('Blättern: eine Seite genügt ⇒ nur die Zahl, kein Blättern; Einzahl be
try {
await ui.answer(0, { items: [ITEM], total: 1, pageSize: 50 })
assert.ok(ui.text().includes('Seite 1 von 1 · 1 Eintrag'))
assert.equal(buttons(ui.tree(), 'Weiter').length + buttons(ui.tree(), 'Zurück').length, 0)
assert.equal(buttons(ui.tree(), NEXT).length + buttons(ui.tree(), PREV).length, 0)
} finally { ui.end() }
})
@@ -305,8 +242,8 @@ test('Blättern: ist die Seite nach einem Kürzen leer, springt die Liste auf di
const ui = await start(api)
try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter'); await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter')
await ui.click(NEXT); await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click(NEXT)
assert.deepEqual(api.calls[2].params, { room: '', q: '', page: 3 })
await ui.answer(2, { items: [], total: 60, pageSize: 50 }) // inzwischen nur noch zwei Seiten
assert.deepEqual(api.calls[3].params, { room: '', q: '', page: 2 })
@@ -323,19 +260,20 @@ test('bot: ruft die API mit id ab, hebt die Zeile hervor und bietet „Alle anze
const ui = await start(api, { bot: 'p1', onClearBot: () => { cleared += 1 } })
try {
assert.deepEqual(api.calls.map((c) => c.params), [{ id: 'p1' }])
assert.equal(nodes(ui.tree(), (n) => n.tag === 'select' || n.tag === 'input').length, 0) // Filter und Suche wirken auf einen Einzeleintrag nicht
assert.equal(nodes(ui.tree(), (n) => n.tag === 'input' || n.props['aria-pressed'] !== undefined).length, 0) // Filter (Reiter) und Suche wirken auf einen Einzeleintrag nicht
assert.ok(ui.text().includes('Reine Liste: keine Anrufe, kein Mailversand.'))
await ui.answer(0, { items: [ITEM], total: 1, pageSize: 50 })
const rows = ui.rows()
assert.equal(rows.length, 1)
assert.equal(rows[0].props['aria-current'], 'true')
assert.equal(buttons(ui.tree(), 'Weiter').length, 0) // keine Seitenzeile bei einem Einzeleintrag
assert.equal(buttons(ui.tree(), NEXT).length, 0) // keine Seitenzeile bei einem Einzeleintrag
await ui.click('Alle anzeigen')
assert.equal(cleared, 1)
// die Seite nimmt bot aus der Adresse: die Liste lädt wieder alle (Filter und Suche zurück)
ui.setProps({ bot: '' })
await settle()
assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 1 })
assert.equal(ui.select().props.value, '')
assert.equal(ui.activeIndex(), idx('')) // UMGESTELLT: „Alle Räume“ ist der gedrückte Reiter statt select.value ''
} finally { ui.end() }
})
@@ -379,21 +317,21 @@ test('Blättern (C5): nach dem Laden der neuen Seite bekommt die Seitenzeile den
try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(dom.focused, []) // nicht schon beim ersten Laden
await dom.press('Weiter')
await dom.press(NEXT)
assert.deepEqual(dom.focused, []) // während der Anfrage ist die Seitenzeile weg
assert.equal(dom.doc.activeElement.isConnected, false) // und mit ihr der Knopf, der den Fokus hatte: der Fokus ist verloren
await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 2 von 3
assert.deepEqual(dom.focused, ['Weiter'])
await dom.press('Weiter')
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 3 von 3: „Weiter“ ist gesperrt ⇒ „Zurück“
assert.deepEqual(dom.focused, ['Weiter', 'Zurück'])
await dom.press('Zurück')
assert.deepEqual(dom.focused, [NEXT])
await dom.press(NEXT)
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 3 von 3: „Nächste Seite“ ist gesperrt ⇒ „Vorherige Seite“
assert.deepEqual(dom.focused, [NEXT, PREV])
await dom.press(PREV)
await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 2
assert.deepEqual(dom.focused, ['Weiter', 'Zurück', 'Zurück'])
await dom.press('Zurück')
await ui.answer(4, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 1: „Zurück“ ist gesperrt ⇒ „Weiter“
assert.deepEqual(dom.focused, ['Weiter', 'Zurück', 'Zurück', 'Weiter'])
// N5: ohne { preventScroll: true } sprängen Chromium und Firefox ans Ende der neuen Seite (der Knopf steht unter der Tabelle)
assert.deepEqual(dom.focused, [NEXT, PREV, PREV])
await dom.press(PREV)
await ui.answer(4, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 1: „Vorherige Seite“ ist gesperrt ⇒ „Nächste Seite“
assert.deepEqual(dom.focused, [NEXT, PREV, PREV, NEXT])
// N5: ohne { preventScroll: true } sprängen Chromium und Firefox ans Ende der neuen Seite
assert.deepEqual(dom.options, [{ preventScroll: true }, { preventScroll: true }, { preventScroll: true }, { preventScroll: true }])
} finally { dom.restore(); ui.end() }
})
@@ -406,36 +344,36 @@ test('Blättern (N5): hat der Nutzer den Fokus inzwischen selbst woandershin ges
try {
await ui.answer(0, { items: [ITEM], total: 400, pageSize: 50 })
// 1. Der Nutzer klickt während des Ladens ins Suchfeld (Fokus dort, aber die Zeitgeber der Suche laufen noch nicht)
await dom.press('Weiter')
await dom.press(NEXT)
const search = dom.other('Suchfeld')
search.focus()
await ui.answer(1, { items: [ITEM], total: 400, pageSize: 50 })
assert.deepEqual(dom.focused, [])
assert.equal(dom.doc.activeElement, search)
// 2. … oder er wählt den Raumfilter mit der Tastatur an, ohne etwas zu ändern
await dom.press('Weiter')
dom.made.get('select').focus()
assert.deepEqual(dom.focused, ['select'])
// 2. … oder er wählt den Raum-Reiter mit der Tastatur an, ohne etwas zu ändern (UMGESTELLT: Reiter statt Auswahlliste)
await dom.press(NEXT)
dom.made.get('reiter').focus()
assert.deepEqual(dom.focused, ['reiter'])
await ui.answer(2, { items: [ITEM], total: 400, pageSize: 50 })
assert.deepEqual(dom.focused, ['select']) // kein weiterer Fokus auf die Seitenzeile
assert.equal(dom.doc.activeElement.key, 'select')
assert.deepEqual(dom.focused, ['reiter']) // kein weiterer Fokus auf die Seitenzeile
assert.equal(dom.doc.activeElement.key, 'reiter')
// 3. Fokus auf body: zurück an die Seitenzeile
dom.focused.length = 0; dom.options.length = 0
await dom.press('Weiter')
await dom.press(NEXT)
dom.doc.activeElement = dom.body
await ui.answer(3, { items: [ITEM], total: 400, pageSize: 50 })
assert.deepEqual(dom.focused, ['Weiter'])
assert.deepEqual(dom.focused, [NEXT])
assert.deepEqual(dom.options, [{ preventScroll: true }])
// 4. Fokus auf nichts (activeElement null, etwa ein Dokument ohne Fokus): ebenfalls zurück
await dom.press('Weiter')
await dom.press(NEXT)
dom.doc.activeElement = null
await ui.answer(4, { items: [ITEM], total: 400, pageSize: 50 })
assert.deepEqual(dom.focused, ['Weiter', 'Weiter'])
assert.deepEqual(dom.focused, [NEXT, NEXT])
// 5. ohne document (Server, Node): nichts geschieht, nichts bricht
delete globalThis.document
await ui.click('Weiter')
await ui.click(NEXT)
await ui.answer(5, { items: [ITEM], total: 400, pageSize: 50 })
assert.deepEqual(dom.focused, ['Weiter', 'Weiter'])
assert.deepEqual(dom.focused, [NEXT, NEXT])
} finally { dom.restore(); ui.end() }
})
@@ -446,12 +384,12 @@ test('Blättern (C5): der Fokus springt nicht, wenn der Nutzer inzwischen Raum o
dom.attach(ui)
try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter')
await ui.pick('handwerk') // während die Seite lädt, wechselt der Raum
await ui.click(NEXT)
await ui.pick(idx('handwerk')) // während die Seite lädt, wechselt der Raum
await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) // späte Antwort der älteren Anfrage
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(dom.focused, [])
await ui.click('Weiter')
await ui.click(NEXT)
await ui.type('bäckerei') // die Suche wird getippt, bevor die Seite da ist
await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(dom.focused, [])
@@ -460,10 +398,10 @@ test('Blättern (C5): der Fokus springt nicht, wenn der Nutzer inzwischen Raum o
await ui.answer(4, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(dom.focused, [])
// scheitert die Anfrage, gibt es keine Seitenzeile zum Fokussieren; die Merkung bleibt nicht für später hängen
await ui.click('Weiter')
await ui.click(NEXT)
await ui.fail(5, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'))
assert.deepEqual(dom.focused, [])
await ui.pick('laden')
await ui.pick(idx('laden'))
await ui.answer(6, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(dom.focused, [])
} finally { dom.restore(); ui.end() }
@@ -474,7 +412,7 @@ test('Fehler beim Blättern (N6): „Erneut versuchen“ wiederholt genau die Se
const ui = await start(api)
try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter')
await ui.click(NEXT)
assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 })
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0) // beim Laden gibt es keinen Knopf
await ui.fail(1, Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 }))
@@ -493,9 +431,9 @@ test('Fehler beim Blättern (N6): „Erneut versuchen“ wiederholt genau die Se
await ui.answer(2, { items: [PRAXIS], total: 120, pageSize: 50 })
assert.equal(api.calls.length, 3)
assert.deepEqual(ui.errors(), [])
assert.deepEqual(ui.rows().map((r) => ui.cells(r)[0]), ['Praxis Dr. Muster67655 Kaiserslautern'])
assert.deepEqual(ui.rows().map((r) => ui.parts(r).name), ['Praxis Dr. Muster'])
assert.ok(ui.text().includes('Seite 2 von 3 · 120 Einträge'))
assert.deepEqual([ui.disabled('Zurück'), ui.disabled('Weiter')], [[false], [false]])
assert.deepEqual([ui.disabled(PREV), ui.disabled(NEXT)], [[false], [false]])
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0)
} finally { ui.end() }
})
@@ -505,7 +443,7 @@ test('Fehler beim Blättern (N6): scheitert auch der zweite Versuch, bleibt der
const ui = await start(api)
try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter')
await ui.click(NEXT)
await ui.fail(1, new Error('zu langsam'))
await ui.click('Erneut versuchen')
await ui.fail(2, new Error('immer noch nicht'))
@@ -514,13 +452,13 @@ test('Fehler beim Blättern (N6): scheitert auch der zweite Versuch, bleibt der
await ui.click('Erneut versuchen')
assert.deepEqual(api.calls[3].params, { room: '', q: '', page: 2 })
// statt eines weiteren Versuchs ändert der Nutzer den Raum: neue Anfrage mit Seite 1, die späte Antwort des Versuchs zählt nicht
await ui.pick('handwerk')
await ui.pick(idx('handwerk'))
assert.deepEqual(api.calls[4].params, { room: 'handwerk', q: '', page: 1 })
await ui.answer(3, { items: [PRAXIS], total: 120, pageSize: 50 })
assert.equal(ui.rows().length, 0)
await ui.answer(4, { items: [ITEM], total: 1, pageSize: 50 })
assert.deepEqual(ui.errors(), [])
assert.deepEqual(ui.rows().map((r) => ui.cells(r)[0]), ['Elektro Beispiel GmbH67655 Kaiserslautern'])
assert.deepEqual(ui.rows().map((r) => ui.parts(r).name), ['Elektro Beispiel GmbH'])
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0)
} finally { ui.end() }
})
@@ -555,18 +493,18 @@ test('Fehler beim Blättern (N6): nach einem gelungenen Wiederholen bekommt die
dom.attach(ui)
try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await dom.press('Weiter')
await dom.press(NEXT)
await ui.fail(1, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'))
assert.deepEqual(dom.focused, []) // keine Seitenzeile, nichts zu fokussieren
await dom.press('Erneut versuchen') // der Knopf verschwindet beim Laden wie vorher die Seitenzeile
assert.deepEqual(dom.focused, [])
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(dom.focused, ['Weiter'])
assert.deepEqual(dom.focused, [NEXT])
assert.deepEqual(dom.options, [{ preventScroll: true }])
} finally { dom.restore(); ui.end() }
})
test('Alle anzeigen (W1): nach dem Verlassen der Einzelansicht bekommt der Raumfilter den Fokus, nicht beim Einhängen und nicht bei einem anderen Weg zur Liste', async () => {
test('Alle anzeigen (W1): nach dem Verlassen der Einzelansicht bekommt der aktive Raum-Reiter den Fokus, nicht beim Einhängen und nicht bei einem anderen Weg zur Liste', async () => {
const api = fakeApi()
const dom = fakeFocus()
let cleared = 0
@@ -579,12 +517,12 @@ test('Alle anzeigen (W1): nach dem Verlassen der Einzelansicht bekommt der Raumf
assert.equal(cleared, 1)
assert.deepEqual(dom.focused, []) // der Knopf steht noch, bis die Seite bot aus der Adresse nimmt
ui.setProps({ bot: '' }); await settle()
assert.deepEqual(dom.focused, ['select'])
assert.equal(ui.select().props['aria-label'], 'Raum')
assert.deepEqual(dom.focused, ['reiter']) // UMGESTELLT: der aktive Reiter („Alle Räume“) statt der Auswahlliste
assert.equal(ui.reiter()[ui.activeIndex()].props['aria-label'].startsWith('Alle Räume'), true)
// ein weiterer Wechsel in die Einzelansicht und zurück ohne den Knopf (Zurück-Taste des Browsers): kein Fokussprung
ui.setProps({ bot: 'p2' }); await settle()
ui.setProps({ bot: '' }); await settle()
assert.deepEqual(dom.focused, ['select'])
assert.deepEqual(dom.focused, ['reiter'])
} finally { dom.restore(); ui.end() }
})
+39 -17
View File
@@ -79,26 +79,48 @@ test('pageInfo: Seiten, Blättern und Beschriftung', () => {
assert.deepEqual(pageInfo({}), { pages: 1, hasPrev: false, hasNext: false, label: 'Keine Einträge' })
})
test('BotLeadList.jsx: Links nur aus der Zeilen-Ableitung, extern mit rel noopener, kein HTML-Einschub, Knöpfe mit type="button"', () => {
const src = read('src/components/workers/BotLeadList.jsx')
assert.match(src, /botleadRow\(/)
assert.match(src, /rel="noopener noreferrer"/); assert.match(src, /target="_blank"/)
// jeder href kommt aus row.sourceHref (botleadRow → safeHref), nie aus dem Eintrag selbst
assert.match(src, /href=\{row\.sourceHref\}/)
assert.doesNotMatch(src, /\bhref=\{(?!row\.sourceHref\})/)
assert.doesNotMatch(src, /\bhref="/)
assert.doesNotMatch(src, /dangerouslySetInnerHTML|innerHTML/)
// Quelltext des Kellers (BotLeadList.jsx und keller/*, Hustler-Pixelwelt T10): die Zeilen-Ableitung steckt in src/lib/hustlerKeller.js (eintragModell → botleadRow),
// Links gibt es nur in der Akte. UMGESTELLT gegenüber der alten Tabelle: die Prüfungen laufen über alle Dateien des Kellers statt über BotLeadList.jsx allein.
const KELLER_DATEIEN = [
'src/components/workers/BotLeadList.jsx',
'src/components/workers/keller/KellerSzene.jsx',
'src/components/workers/keller/RegisterBuch.jsx',
'src/components/workers/keller/Akte.jsx',
]
// Quelltext ohne Kommentare (die Kommentare nennen auch, was es nicht gibt, etwa „kein innerHTML“)
const ohneKommentare = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
test('Keller: Links nur aus der Zeilen-Ableitung, extern mit rel noopener, kein HTML-Einschub, Knöpfe mit type="button"', () => {
const quellen = KELLER_DATEIEN.map((p) => ({ p, src: ohneKommentare(read(p)) }))
const alle = quellen.map((q) => q.src).join('\n')
// die Ableitung läuft über botleadRow (safeHref für die Quelle, Telefonregel, Datum)
assert.match(ohneKommentare(read('src/components/workers/BotLeadList.jsx')), /eintragModell\(/)
assert.match(read('src/lib/hustlerKeller.js'), /botleadRow\(/)
// genau ein Link im ganzen Keller: die Quelle in der Akte, extern in neuem Tab ohne Rückbezug
const akte = ohneKommentare(read('src/components/workers/keller/Akte.jsx'))
assert.match(akte, /rel="noopener noreferrer"/); assert.match(akte, /target="_blank"/)
// jeder href kommt aus e.sourceHref (eintragModell → botleadRow → safeHref), nie aus dem Eintrag selbst
assert.match(akte, /href=\{e\.sourceHref\}/)
assert.equal((alle.match(/\bhref=/g) || []).length, 1)
assert.doesNotMatch(alle, /\bhref=\{(?!e\.sourceHref\})/)
assert.doesNotMatch(alle, /\bhref="/)
assert.equal((alle.match(/<a\b/g) || []).length, 1)
assert.doesNotMatch(alle, /dangerouslySetInnerHTML|innerHTML/)
// kein Anruf-Link, kein Mailversand
assert.doesNotMatch(src, /tel:|mailto:/)
assert.doesNotMatch(alle, /tel:|mailto:/)
// WOMS-Button hat keinen Standard-type: jeder Knopf nennt ihn
assert.equal((src.match(/<(?:button|Button)\b/g) || []).length > 0, true)
assert.equal((src.match(/<(?:button|Button)\b/g) || []).length, (src.match(/<(?:button|Button)\b[^>]*type="button"/g) || []).length)
for (const { p, src } of quellen) {
assert.equal((src.match(/<(?:button|Button)\b/g) || []).length, (src.match(/<(?:button|Button)\b[^>]*type="button"/g) || []).length, p)
}
assert.ok((alle.match(/<button\b/g) || []).length > 0)
// kein Polling
assert.doesNotMatch(src, /setInterval/)
assert.doesNotMatch(alle, /setInterval/)
})
test('BotLeadList.jsx: Zeilen ohne Kennung teilen sich keinen key (Ausweichwert aus der Position), aria-current nur in der Einzelansicht', () => {
const src = read('src/components/workers/BotLeadList.jsx')
assert.match(src, /key=\{row\.id \|\| /)
assert.match(src, /aria-current=\{single && row\.id === botId \?/)
test('Keller: Zeilen ohne Kennung teilen sich keinen key (Ausweichwert aus der Position), aria-current nur in der Einzelansicht', () => {
const src = ohneKommentare(read('src/components/workers/keller/RegisterBuch.jsx'))
assert.match(src, /key=\{z\.id \|\| /)
assert.match(src, /aktuell=\{einzeln && z\.id === botId\}/)
assert.match(src, /aria-current=\{aktuell \? 'true' : undefined\}/)
})
+197
View File
@@ -0,0 +1,197 @@
// Läufer für die Tests des Kellers (BotLeadList, keller/*): bündelt die Komponente mit esbuild und dem Mini-Läufer fakeReact (React und DOM gibt es unter Node nicht),
// rendert in einen Baum aus { tag, props, children }; Eingaben, Klicks und Tasten ruft der Test von Hand auf, die Zeit (window.setTimeout) läuft von Hand, der Dienst
// ist eine Attrappe mit offenen Versprechen. Gemeinsam für tests/unit/botleadList.test.mjs und tests/unit/hustlerKeller.test.mjs.
import assert from 'node:assert/strict'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'
import { fakeReact } from './fakeReact.mjs'
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')
const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
export const settle = () => new Promise((resolve) => setImmediate(resolve))
// Die Komponente wird einmal gebündelt; jeder Test hängt sie neu ein (eigener Zustand)
let bundle
export const loadUi = () => (bundle ??= esbuild.build({
stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as BotLeadList } from './src/components/workers/BotLeadList.jsx'", resolveDir: ROOT, loader: 'js' },
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact],
}).then(importBundle))
// ---------- Baum durchsuchen ----------
export const nodes = (node, match, out = []) => {
if (node === null || typeof node === 'string') return out
if (match(node)) out.push(node)
for (const child of node.children) nodes(child, match, out)
return out
}
export const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join(''))
export const classesOf = (n) => String(n.props.className ?? '').split(' ').filter(Boolean)
export const hasClass = (n, name) => classesOf(n).includes(name)
export const byClass = (tree, name) => nodes(tree, (n) => hasClass(n, name))
// Knopf nach Beschriftung: aria-label oder sichtbarer Text
export const buttons = (tree, label) => nodes(tree, (n) => n.tag === 'button' && (n.props['aria-label'] === label || textOf(n).trim() === label))
// alle hk-Klassen eines Baums
export const allClasses = (tree) => {
const set = new Set()
nodes(tree, (n) => { for (const c of classesOf(n)) if (c.startsWith('hk-')) set.add(c); return false })
return set
}
// Dienst-Attrappe: jeder Aufruf von botleads ist ein offenes Versprechen, das der Test selbst auflöst
export function fakeApi() {
const calls = []
return { calls, botleads: (params) => new Promise((resolve, reject) => { calls.push({ params, resolve, reject }) }) }
}
// DOM-Attrappe für den Fokus: document mit body, activeElement und querySelector; ref an dem, was die Komponente anfasst, liefert __fakeRef.
// Schlüssel eines Elements: „reiter“ (der aktive Raum-Reiter, aria-pressed), sonst die aria-label des Knopfs („Nächste Seite“, „Schließen“ …) oder sein Text, sonst der Tag.
// Ein Element gilt nur, solange es im Baum steht (isConnected, wie im Browser); steht es beim nächsten Render nicht im Baum der Runde davor (Seitenzeile während des
// Ladens), ist es danach ein neues Element, so wie React die Knöpfe neu einhängt. focus() zählt nur für ein eingehängtes Element, merkt sich Schlüssel und Argument
// und setzt activeElement. Zeilen des Buchs gibt es über querySelector('[data-hk-zeile="<Nr>"]') (Schlüssel „zeile <Nr>“), wie der Browser sie der Akte liefert.
export function fakeFocus() {
const body = { name: 'body' }
const doc = { body, documentElement: { name: 'html' }, activeElement: body }
const f = { doc, body, focused: [], options: [], tree: () => null, view: null, made: new Map() }
const keyOf = (tag, props) => {
if (tag !== 'button') return tag
if (props['aria-pressed'] !== undefined) return 'reiter'
return String(props['aria-label'] ?? props.children)
}
const live = (tag, key) => nodes(f.tree(), (n) => n.tag === tag && keyOf(tag, n.props) === key).length > 0
const had = Object.hasOwn(globalThis, 'document')
const saved = globalThis.document
globalThis.document = doc
globalThis.__fakeRef = (tag, props) => {
const key = keyOf(tag, props)
const known = f.made.get(key)
if (!known || !live(tag, key)) { // während dieser Runde zeigt f.tree() noch die Runde davor
f.made.set(key, {
key, props,
get disabled() { return this.props.disabled === true },
get isConnected() { return f.made.get(key) === this && live(tag, key) },
focus(options) { if (this.isConnected) { f.focused.push(key); f.options.push(options); doc.activeElement = this } },
})
}
f.made.get(key).props = props
return f.made.get(key)
}
doc.querySelector = (sel) => {
const m = /^\[data-hk-zeile="(\d+)"\]$/.exec(sel)
if (!m) return null
const nr = Number(m[1])
if (nodes(f.tree(), (n) => n.tag === 'button' && n.props['data-hk-zeile'] === nr).length === 0) return null
const key = `zeile ${nr}`
return { key, isConnected: true, disabled: false, focus(options) { f.focused.push(key); f.options.push(options); doc.activeElement = this } }
}
// ein Element, das der Nutzer von Hand ansteuert (Suchfeld, Link …): eingehängt, bedienbar
f.other = (name) => ({ name, isConnected: true, disabled: false, focus() { doc.activeElement = this } })
f.attach = (view) => { f.view = view; f.tree = () => view.tree() }
// Der Nutzer drückt einen Knopf: der Browser gibt ihm den Fokus, dann läuft der Klick
// (Knöpfe ohne ref, z. B. „Erneut versuchen“, sind nur so lange Element, wie ein Knopf dieser Beschriftung im Baum steht)
f.press = async (label) => {
doc.activeElement = f.made.get(label) ?? { name: label, disabled: false, get isConnected() { return buttons(f.tree(), label).length > 0 }, focus() { doc.activeElement = this } }
await f.view.click(label)
}
// Der Nutzer klickt eine Zeile des Buchs (Fokus auf der Zeile, dann der Klick)
f.pressRow = async (nr) => {
const key = `zeile ${nr}`
const el = { key, isConnected: true, disabled: false, focus(options) { f.focused.push(key); f.options.push(options); doc.activeElement = this } }
doc.activeElement = el
await f.view.openRow(nr)
return el
}
f.restore = () => {
if (had) globalThis.document = saved
else delete globalThis.document
delete globalThis.__fakeRef
}
return f
}
// ---------- Einträge des Dienstes (Form von GET /botleads) ----------
export const ITEM = {
id: 'p1', room: 'handwerk', roomLabel: 'Handwerk', name: 'Elektro Beispiel GmbH', postalCode: '67655', city: 'Kaiserslautern', score: 82,
reason: 'keine veröffentlichte Mail', sourceUrl: 'https://beispiel-elektro.de/impressum', phone: '0631 111', phoneSource: 'https://beispiel-elektro.de/impressum',
isHealth: false, foundAt: '2026-10-19T20:00:00Z', gekuerzt: false,
}
export const PRAXIS = { ...ITEM, id: 'p2', room: 'aerzte', roomLabel: 'Ärzte & Praxen', name: 'Praxis Dr. Muster', isHealth: true, phone: '', phoneSource: '', sourceUrl: 'javascript:alert(1)', foundAt: '2026-10-18T20:00:00Z' }
export const KUERZ = { ...ITEM, id: 'p3', name: '[gekürzt]', gekuerzt: true, phone: '', phoneSource: '', sourceUrl: '' }
// ---------- Ein Lauf: Komponente einhängen, window.setTimeout von Hand, Props zwischendurch änderbar (rerender) ----------
export async function start(api, initial = {}) {
const { __mount, jsx, BotLeadList } = await loadUi()
const timers = new Map()
let nextTimer = 1
const saved = globalThis.window
globalThis.window = {
setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id },
clearTimeout: (id) => { timers.delete(id) },
}
let props = { api, ...initial }
const mounted = __mount(jsx(() => jsx(BotLeadList, props), {}))
const view = {
timers,
tree: () => mounted.tree(),
text: () => textOf(view.tree()),
setProps(next) { props = { ...props, ...next }; mounted.rerender() },
// Zeilen des Buchs (Knöpfe) und ihre Teile
rows: () => nodes(view.tree(), (n) => n.tag === 'button' && hasClass(n, 'hk-zeile')),
parts: (row) => {
const zwei = byClass(row, 'hk-zwei-zeilen')
return {
name: textOf(byClass(row, 'hk-kl-name')[0]),
sub: textOf(byClass(row, 'hk-klein')[0]),
score: textOf(byClass(row, 'hk-kl-punkte-text')[0]),
reason: textOf(zwei[0]),
phone: textOf(zwei[1]),
raumIcon: row.children[0].props.className,
label: row.props['aria-label'],
}
},
// Raum-Reiter (Knöpfe mit aria-pressed) in der Reihenfolge der Leiste
reiter: () => nodes(view.tree(), (n) => n.tag === 'button' && n.props['aria-pressed'] !== undefined),
activeIndex: () => view.reiter().findIndex((r) => r.props['aria-pressed'] === true),
search: () => nodes(view.tree(), (n) => n.tag === 'input')[0],
errors: () => byClass(view.tree(), 'hk-kl-fehler').map((n) => ({ text: textOf(n), role: n.props.role })),
async pick(index) { view.reiter()[index].props.onClick({}); await settle() },
async type(value) { view.search().props.onChange({ target: { value } }); await settle() },
async click(label) {
const found = buttons(view.tree(), label)
assert.equal(found.length, 1, `Knopf „${label}“ (gefunden: ${found.length})`)
if (!found[0].props.disabled) found[0].props.onClick({ preventDefault() {} })
await settle()
},
async openRow(nr) { view.rows()[nr].props.onClick({ preventDefault() {} }); await settle() },
disabled: (label) => buttons(view.tree(), label).map((n) => n.props.disabled === true),
fireTimers() { for (const [id, t] of [...timers]) { timers.delete(id); t.fn() } },
// Akte: der Dialog, seine Teile, Tasten am äußeren Rahmen
akte: () => nodes(view.tree(), (n) => n.props.role === 'dialog')[0] ?? null,
akteDl: () => {
const dl = byClass(view.tree(), 'hk-kl-akte-dl')[0]
const out = {}
let name = null
for (const c of dl ? dl.children : []) {
if (c === null || typeof c === 'string') continue
if (c.tag === 'dt') name = textOf(c)
else if (c.tag === 'dd') out[name] = c
}
return out
},
async key(key, extra = {}) {
const ueber = byClass(view.tree(), 'hk-kl-akte-ueber')[0]
assert.ok(ueber, 'keine Akte offen')
const ev = { key, shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, currentTarget: ueber, prevented: false, preventDefault() { ev.prevented = true }, stopPropagation() {}, ...extra }
ueber.props.onKeyDown(ev)
await settle()
return ev
},
// der Dienst antwortet auf den n-ten Aufruf
async answer(n, data) { api.calls[n].resolve(data); await settle() },
async fail(n, err) { api.calls[n].reject(err); await settle() },
lateUpdates: () => mounted.lateUpdates(),
end() { mounted.unmount(); globalThis.window = saved },
}
await settle()
return view
}
+101
View File
@@ -0,0 +1,101 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { loadModule } from './load.mjs'
// Gründe der Bot-Lead-Liste (src/lib/botleadGruende.js, Hustler-Pixelwelt T10, Spec E12): Grund-Text des Dienstes -> Symbol und Erklärung für die Akte.
// Die Fixture tests/fixtures/botlead-gruende.json trägt alle 14 Texte des Dienstes (workers-dev-p6/src/scoring/business.js, GRUND, Stand d0d66f9); sie ist unabhängig vom Code unter Test.
const { grundInfo, grundBekannt, ALLGEMEIN, GRUND_SYMBOLE } = await loadModule('src/lib/botleadGruende.js')
const fixture = JSON.parse(readFileSync(new URL('../fixtures/botlead-gruende.json', import.meta.url), 'utf8'))
const read = (p) => readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
// Symbol je Grund (Tabelle GRUENDE der Zeichenfläche, keller/entwurf/logik.js; „Lauf unterbrochen“ bekommt das Pausen-Symbol)
const SYMBOL = {
KEINE_MAIL: 'brief-x', MX: 'brief-zurueck', KETTE: 'kette', WEBSITE_GUT: 'haken', SCHWELLE: 'pfeil-runter', VERALTET: 'uhr', BEKANNT: 'doppelt',
BEKANNT_NAME: 'doppelt', GESPERRT: 'stopp', LESEN: 'seite-kaputt', IMPRESSUM_FREMD: 'fremd', ABGEBROCHEN: 'pause', BEWERTUNG_AUSGEFALLEN: 'waage-kaputt', NAME_FEHLT: 'etikett-leer',
}
// Klassen aus keller.css und hustler-keller-ui.css (ohne Kommentare und url(...))
const cssKlassen = (datei) => new Set((read(datei).replace(/\/\*[\s\S]*?\*\//g, '').replace(/url\([^)]*\)/g, '').match(/\.hk-[A-Za-z0-9_-]+/g) ?? []).map((k) => k.slice(1)))
const CSS = new Set([...cssKlassen('src/styles/hustler/keller.css'), ...cssKlassen('src/styles/hustler/hustler-keller-ui.css')])
test('Fixture: 14 Texte des Dienstes mit Quelle und Stand', () => {
assert.match(fixture.quelle, /scoring\/business\.js/)
assert.match(fixture.quelle, /d0d66f9/)
assert.equal(fixture.stand, 'd0d66f9')
const texte = Object.values(fixture.gruende)
assert.equal(texte.length, 14)
assert.equal(new Set(texte).size, 14)
assert.ok(texte.every((t) => typeof t === 'string' && t.trim() === t && t !== ''))
assert.deepEqual(Object.keys(fixture.gruende).sort(), Object.keys(SYMBOL).sort())
assert.equal(fixture.gruende.SCHWELLE, 'Punktzahl unter 55') // so, wie der Dienst den Text mit der Grenze bildet
})
test('grundInfo: jeder Grund der Fixture hat sein Symbol und eine eigene Erklärung (nie der allgemeine Satz)', () => {
const saetze = new Set()
for (const [name, text] of Object.entries(fixture.gruende)) {
assert.equal(grundBekannt(text), true, name)
const info = grundInfo(text)
assert.equal(info.icon, `hk-ic hk-ic-${SYMBOL[name]}`, name)
assert.equal(typeof info.erklaerung, 'string')
assert.ok(info.erklaerung.length > 15 && info.erklaerung.endsWith('.'), `${name}: ${info.erklaerung}`)
assert.notEqual(info.erklaerung, ALLGEMEIN.erklaerung, name)
assert.deepEqual(Object.keys(info).sort(), ['erklaerung', 'icon'])
saetze.add(info.erklaerung)
}
assert.equal(saetze.size, 14) // auch die beiden „schon bekannt“ haben je einen eigenen Satz
})
test('grundInfo: Wortlaut der Erklärungen aus der Zeichenfläche, „Lauf unterbrochen“ im selben Ton', () => {
assert.equal(grundInfo('keine veröffentlichte Mail').erklaerung, 'Auf der Website steht keine E-Mail-Adresse. Ohne veröffentlichte Adresse schreibt der Hustler nie.')
assert.equal(grundInfo('Punktzahl unter 55').erklaerung, 'Zu wenige Punkte für eine Mail (Grenze 55).')
assert.equal(grundInfo('Sperrliste').erklaerung, 'Hat widersprochen oder steht auf der Sperrliste.')
assert.equal(grundInfo('Bewertung ausgefallen').erklaerung, 'Bea konnte den Betrieb nicht bewerten.')
const abbruch = grundInfo('Lauf unterbrochen')
assert.equal(abbruch.icon, 'hk-ic hk-ic-pause')
assert.match(abbruch.erklaerung, /unterbrochen/)
assert.match(abbruch.erklaerung, /keine Mail/)
})
test('grundInfo: die Grenze in „Punktzahl unter N“ kommt aus dem Text des Dienstes (ändert er sie, stimmt die Erklärung weiter)', () => {
assert.equal(grundInfo('Punktzahl unter 60').erklaerung, 'Zu wenige Punkte für eine Mail (Grenze 60).')
assert.equal(grundInfo('Punktzahl unter 60').icon, 'hk-ic hk-ic-pfeil-runter')
assert.equal(grundBekannt('Punktzahl unter 60'), true)
for (const nein of ['Punktzahl unter', 'Punktzahl unter x', 'Punktzahl unter 5 5', 'Punktzahl unter 0055', 'Punktzahl unter 55 Punkte']) assert.equal(grundBekannt(nein), false, nein)
})
test('grundInfo: Leerraum am Rand zählt nicht, Groß- und Kleinschreibung schon', () => {
assert.deepEqual(grundInfo(' Sperrliste \n'), grundInfo('Sperrliste'))
assert.equal(grundBekannt('sperrliste'), false)
})
test('grundInfo: unbekannter Text ergibt das neutrale Symbol und einen allgemeinen Satz, nie einen Fehler', () => {
for (const unbekannt of ['irgendein neuer Grund', '', ' ', null, undefined, 42, {}, [], 'constructor', '__proto__', 'toString', 'hasOwnProperty']) {
const info = grundInfo(unbekannt)
assert.deepEqual(info, { icon: 'hk-ic hk-ic-akte', erklaerung: ALLGEMEIN.erklaerung }, String(unbekannt))
assert.equal(grundBekannt(unbekannt), false)
}
assert.match(ALLGEMEIN.erklaerung, /^Der Hustler schreibt diesem Betrieb nicht\./)
})
test('Symbole: jede Klasse, die grundInfo liefern kann, steht in keller.css oder hustler-keller-ui.css', () => {
const klassen = new Set()
for (const text of [...Object.values(fixture.gruende), 'Punktzahl unter 70', 'unbekannt']) {
const { icon } = grundInfo(text)
assert.match(icon, /^hk-ic hk-ic-[a-z-]+$/)
for (const k of icon.split(' ')) klassen.add(k)
}
assert.ok(klassen.size >= 14)
for (const k of klassen) assert.ok(CSS.has(k), `${k} fehlt in keller.css / hustler-keller-ui.css`)
// GRUND_SYMBOLE nennt genau diese Symbole
assert.deepEqual([...GRUND_SYMBOLE].sort(), [...new Set([...Object.values(SYMBOL), 'akte'])].sort())
for (const s of GRUND_SYMBOLE) assert.ok(CSS.has(`hk-ic-${s}`), s)
})
test('botleadGruende.js: rein (kein React, kein DOM, keine Importe), keine Beispieldaten', () => {
const src = read('src/lib/botleadGruende.js').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
assert.doesNotMatch(src, /^\s*import\b/m)
assert.doesNotMatch(src, /\b(?:window|document|fetch)\b/)
assert.doesNotMatch(src, /innerHTML/)
})
+742
View File
@@ -0,0 +1,742 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync, statSync } from 'node:fs'
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() }
}
})
test('Arne ohne Zustand (Tabelle noch leer, kein Eintrag): er schläft; der Status wechselt mit den Daten der Seite', async () => {
const { ui } = await szene(undefined)
try {
assert.equal(sz(ui).props['data-hk-arne'], 'leerlauf')
assert.ok(has(ui, 'hk-kl-fig-schlafen') && has(ui, 'hk-kl-zzz'))
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: {} })
assert.ok(has(ui, 'hk-kl-fig-schlafen'))
} 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`)
}
}
})
+243
View File
@@ -0,0 +1,243 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { loadModule } from './load.mjs'
// Reine Daten und Ableitungen des Kellers (src/lib/hustlerKeller.js, Hustler-Pixelwelt T10, Spec E12/E13): Einpassen der Szene, Lage der Dinge, Arne nach Status,
// Raum-Reiter, Scout, Punkte-Anzeige, Zeilenmodell eines Bot-Leads.
const K = await loadModule('src/lib/hustlerKeller.js')
const V = await loadModule('src/lib/botleadsView.js')
const { SZENE, kellerEinpassen, FENSTER, DAMPF, BUCH_STEMPEL, ROHRPOST, SCHUBLADE, MAUS_STREIFEN, ARNE_RUHE, LEDS, REITER, raumIcon, scoutFuer, punkteInfo, eintragModell, arneAnsicht } = K
const { ROOM_FILTERS } = V
const read = (p) => readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
const ohneKommentare = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '')
const kellerCss = ohneKommentare(read('src/styles/hustler/keller.css'))
const uiCss = ohneKommentare(read('src/styles/hustler/hustler-keller-ui.css'))
const klassen = (css) => new Set((css.replace(/url\([^)]*\)/g, '').match(/\.hk-[A-Za-z0-9_-]+/g) ?? []).map((k) => k.slice(1)))
const CSS = new Set([...klassen(kellerCss), ...klassen(uiCss)])
const KEYFRAMES = new Set([...kellerCss.matchAll(/@keyframes\s+([\w-]+)/g)].map((m) => m[1]))
const px = (s) => Number.parseFloat(s)
function tiefEingefroren(wert, pfad) {
if (wert === null || typeof wert !== 'object') return
assert.ok(Object.isFrozen(wert), `${pfad} ist nicht eingefroren`)
for (const [k, v] of Object.entries(wert)) tiefEingefroren(v, `${pfad}.${k}`)
}
// ---------------------------------------------------------------------------------------------------------------
// Szene
test('kellerEinpassen: „cover“ links verankert, Maßstab = größeres Seitenverhältnis, Vorgabe ohne Messung', () => {
assert.deepEqual(SZENE, { breite: 640, hoehe: 360 })
assert.deepEqual(kellerEinpassen(1280, 720), { s: 2, x: 0, y: 0 })
assert.deepEqual(kellerEinpassen(1920, 1080), { s: 3, x: 0, y: 0 })
// breiter als hoch: der Maßstab folgt der Höhe, links bleibt verankert (x nie positiv, nur ein Teil des Überstands wandert)
const schmal = kellerEinpassen(1280, 900)
assert.equal(schmal.s, 2.5)
assert.equal(schmal.x, -48)
assert.equal(schmal.y, 0)
// sehr breit: der Maßstab folgt der Breite, die Szene steht tiefer (der untere Rand bleibt, höchstens 36 Szenen-Pixel gehen unten verloren)
const breit = kellerEinpassen(2560, 720)
assert.equal(breit.s, 4)
assert.equal(breit.x, 0)
assert.equal(breit.y, -576)
// Handy hochkant
const handy = kellerEinpassen(390, 844)
assert.ok(Math.abs(handy.s - 844 / 360) < 1e-9)
assert.ok(handy.x < 0 && handy.x > -(640 * handy.s - 390))
assert.equal(handy.y, 0)
// ohne brauchbare Größe: Vorgabe der Vorlage
for (const [b, h] of [[0, 0], [NaN, 720], [1280, NaN], [-1, 700], [1280, 0], [undefined, undefined], ['1280', '720']]) {
assert.deepEqual(kellerEinpassen(b, h), { s: 2, x: 0, y: 0 }, `${b} x ${h}`)
}
})
test('kellerEinpassen: die Szene deckt den Rahmen immer ganz (cover) und liegt nie rechts vom linken Rand', () => {
for (const [b, h] of [[1280, 720], [1920, 1080], [1048, 720], [390, 844], [768, 1024], [2560, 1440], [3440, 1440], [320, 568], [1000, 300]]) {
const { s, x, y } = kellerEinpassen(b, h)
assert.ok(640 * s >= b - 1e-9 && 360 * s >= h - 1e-9, `${b} x ${h}: Szene kleiner als der Rahmen`)
assert.ok(x <= 0 && x + 640 * s >= b - 1e-9, `${b} x ${h}: Lücke links oder rechts (x ${x})`)
assert.ok(y <= 0 && y + 360 * s >= h - 1e-9, `${b} x ${h}: Lücke oben oder unten (y ${y})`)
}
})
test('Lage der Dinge: alles liegt in der 640 x 360 großen Szene, Arne am Pult steht auf der Laufline, Daten sind eingefroren', () => {
for (const [name, o] of Object.entries({ FENSTER, DAMPF, BUCH_STEMPEL, ROHRPOST, SCHUBLADE, MAUS_STREIFEN, ARNE_RUHE })) {
assert.equal(o.position, 'absolute', name)
assert.ok(px(o.left) >= 0 && px(o.top) >= 0 && px(o.left) < 640 && px(o.top) < 360, name)
assert.match(o.left, /^\d+px$/); assert.match(o.top, /^\d+px$/)
}
// Maße der Vorlage (BotLeads.dc.html)
assert.deepEqual([FENSTER.left, FENSTER.top, FENSTER.width, FENSTER.height, FENSTER.overflow], ['23px', '34px', '46px', '29px', 'hidden'])
assert.deepEqual([ROHRPOST.left, ROHRPOST.top], ['130px', '224px'])
assert.deepEqual([SCHUBLADE.left, SCHUBLADE.top], ['214px', '279px'])
assert.deepEqual([BUCH_STEMPEL.left, BUCH_STEMPEL.top], ['60px', '252px'])
assert.deepEqual([DAMPF.left, DAMPF.top], ['26px', '247px'])
assert.deepEqual([MAUS_STREIFEN.left, MAUS_STREIFEN.top, MAUS_STREIFEN.width, MAUS_STREIFEN.height], ['30px', '336px', '238px', '24px'])
assert.equal(px(MAUS_STREIFEN.top) + px(MAUS_STREIFEN.height), 360)
// Haus-Figur 24 x 32: die Füße stehen auf der Laufline (318), Mitte beim Halt am Pult (128)
assert.equal(px(ARNE_RUHE.top) + 32, 318)
assert.equal(px(ARNE_RUHE.left) + 12, 128)
for (const [name, o] of Object.entries({ SZENE, FENSTER, DAMPF, BUCH_STEMPEL, ROHRPOST, SCHUBLADE, MAUS_STREIFEN, ARNE_RUHE, LEDS, REITER })) tiefEingefroren(o, name)
})
test('Blinkpunkte: sechs, jeder mit Keyframes aus keller.css (hk-k-k-led-…), Maße und Takte der Vorlage', () => {
assert.deepEqual(LEDS.map((l) => l.name), ['weiche-gruen', 'weiche-bernstein', 'sicherung-1', 'sicherung-2', 'sicherung-3', 'verteiler'])
for (const l of LEDS) {
const m = /^(hk-k-k-led-[a-z0-9-]+) (\d+)ms steps\(1,end\) infinite$/.exec(l.stil.animation)
assert.ok(m, l.stil.animation)
assert.equal(m[1], `hk-k-k-led-${l.name}`)
assert.ok(KEYFRAMES.has(m[1]), `${m[1]} fehlt in keller.css`)
assert.equal(l.stil.height, '2px')
assert.match(l.stil.background, /^#[0-9a-f]{6}$/)
assert.ok(px(l.stil.left) < 640 && px(l.stil.top) < 360)
}
assert.deepEqual(LEDS.map((l) => px(l.stil.animation.split(' ')[1])), [1800, 2600, 3000, 2200, 3400, 2000])
})
// ---------------------------------------------------------------------------------------------------------------
// Arne
const eintrag = (status) => (status === 'grau' ? { status: 'grau', task: '', lastTs: null, connected: false, counters: {} } : { status, task: '', lastTs: null, connected: true, counters: {} })
test('arneAnsicht: arbeitet ⇒ Ablauf-Schleife; sonst Haus-Figur nach Status (warten, schlafen, nicht), Fehler mit Marker', () => {
const arbeit = arneAnsicht({ arne: eintrag('arbeitet') })
assert.deepEqual(arbeit, { status: 'arbeitet', arbeitet: true, pose: 'arbeiten', alarm: false, punkte: false, zzz: false, label: 'Arne, Haiku, arbeitet' })
assert.deepEqual(arneAnsicht({ arne: eintrag('wartet') }), { status: 'wartet', arbeitet: false, pose: 'warten', alarm: false, punkte: true, zzz: false, label: 'Arne, Haiku, wartet auf Auftrag' })
assert.deepEqual(arneAnsicht({ arne: eintrag('leerlauf') }), { status: 'leerlauf', arbeitet: false, pose: 'schlafen', alarm: false, punkte: false, zzz: true, label: 'Arne, Haiku, Leerlauf' })
assert.deepEqual(arneAnsicht({ arne: eintrag('grau') }), { status: 'grau', arbeitet: false, pose: 'nicht', alarm: false, punkte: false, zzz: false, label: 'Arne, Haiku, noch nicht angeschlossen' })
assert.deepEqual(arneAnsicht({ arne: eintrag('fehler') }), { status: 'fehler', arbeitet: false, pose: 'nicht', alarm: true, punkte: false, zzz: false, label: 'Arne, Haiku, Fehler' })
})
test('arneAnsicht: nur „arbeitet“ arbeitet; ohne Tabelle, ohne Eintrag oder mit Unsinn schläft Arne (Leerlauf)', () => {
for (const workers of [undefined, null, {}, [], 'x', 5, { otto: eintrag('arbeitet') }, { arne: null }, { arne: 'arbeitet' }, { arne: { status: 'irgendwas' } }, { arne: {} }]) {
const a = arneAnsicht(workers)
assert.equal(a.arbeitet, false, JSON.stringify(workers))
assert.equal(a.status, 'leerlauf')
assert.equal(a.pose, 'schlafen')
assert.equal(a.alarm, false)
}
// ein Eintrag anderer Hustler ändert nichts: nur arne zählt
assert.equal(arneAnsicht({ arne: eintrag('wartet'), otto: eintrag('arbeitet'), ali: eintrag('fehler') }).status, 'wartet')
// arbeitet und gleichzeitig nicht angeschlossen: grau gewinnt (figurZustand)
assert.equal(arneAnsicht({ arne: { ...eintrag('arbeitet'), connected: false } }).status, 'grau')
})
// ---------------------------------------------------------------------------------------------------------------
// Register-Buch
test('REITER: Reihenfolge, Werte und Beschriftungen von ROOM_FILTERS, kurze sichtbare Namen, Symbol und Farbe je Reiter aus keller.css', () => {
assert.deepEqual(REITER.map((r) => r.value), ROOM_FILTERS.map((f) => f.value))
assert.deepEqual(REITER.map((r) => r.label), ROOM_FILTERS.map((f) => f.label))
assert.deepEqual(REITER.map((r) => r.kurz), ['Alle', 'Ärzte', 'Handwerk', 'Selbstständige', 'Läden'])
assert.deepEqual(REITER.map((r) => r.icon), ['hk-ic hk-ic-alle', 'hk-ic hk-ic-raum-aerzte', 'hk-ic hk-ic-raum-handwerk', 'hk-ic hk-ic-raum-selbst', 'hk-ic hk-ic-raum-laden'])
assert.deepEqual(REITER.map((r) => r.klasse), ['hk-reiter hk-reiter-alle', 'hk-reiter hk-reiter-aerzte', 'hk-reiter hk-reiter-handwerk', 'hk-reiter hk-reiter-selbst', 'hk-reiter hk-reiter-laden'])
for (const r of REITER) for (const k of `${r.icon} ${r.klasse}`.split(' ')) assert.ok(CSS.has(k), `${k} fehlt im CSS`)
})
test('raumIcon: Symbol je Raum, ein unbekannter Raum zeigt den Aktenstapel', () => {
assert.equal(raumIcon('aerzte'), 'hk-ic hk-ic-raum-aerzte')
assert.equal(raumIcon('handwerk'), 'hk-ic hk-ic-raum-handwerk')
assert.equal(raumIcon('selbst'), 'hk-ic hk-ic-raum-selbst')
assert.equal(raumIcon('laden'), 'hk-ic hk-ic-raum-laden')
for (const unbekannt of ['', 'netz', 'xyz', 'constructor', '__proto__', undefined, null, 3, {}]) assert.equal(raumIcon(unbekannt), 'hk-ic hk-ic-alle', String(unbekannt))
for (const room of ['aerzte', 'handwerk', 'selbst', 'laden', '']) for (const k of raumIcon(room).split(' ')) assert.ok(CSS.has(k), k)
})
test('scoutFuer: Raum → Hustler der Etage „Ohne Website“ (aerzte → Ali, handwerk → Hanna, selbst → Sven, laden → Lea), sonst leer', () => {
assert.equal(scoutFuer('aerzte'), 'Ali')
assert.equal(scoutFuer('handwerk'), 'Hanna')
assert.equal(scoutFuer('selbst'), 'Sven')
assert.equal(scoutFuer('laden'), 'Lea')
// Räume anderer Etagen (Rita im Netz, Arne im Archiv) und alles Unbekannte haben keinen Scout
for (const room of ['netz', 'archiv', 'bruecke', 'waage', '', 'xyz', 'constructor', undefined, null, 7]) assert.equal(scoutFuer(room), '', String(room))
})
test('punkteInfo: Zahl, Balkenbreite (48 und 96 px) und Farbe (ab 75 gut, ab 55 mittel, darunter schlecht, ohne Zahl leer)', () => {
assert.deepEqual(punkteInfo(82), { text: '82', breite: 39, breiteGross: 79, farbe: 'gut' })
assert.deepEqual(punkteInfo(75), { text: '75', breite: 36, breiteGross: 72, farbe: 'gut' })
assert.equal(punkteInfo(74).farbe, 'mittel')
assert.equal(punkteInfo(55).farbe, 'mittel')
assert.equal(punkteInfo(54).farbe, 'schlecht')
assert.deepEqual(punkteInfo(0), { text: '0', breite: 0, breiteGross: 0, farbe: 'schlecht' })
assert.deepEqual(punkteInfo(100), { text: '100', breite: 48, breiteGross: 96, farbe: 'gut' })
assert.equal(punkteInfo(81.6).text, '82') // gerundet wie scoreLabel in botleadRow
// der Balken bleibt im Rahmen, der Text nennt die Zahl des Dienstes
assert.deepEqual(punkteInfo(130), { text: '130', breite: 48, breiteGross: 96, farbe: 'gut' })
assert.deepEqual(punkteInfo(-5), { text: '-5', breite: 0, breiteGross: 0, farbe: 'schlecht' })
for (const nichts of [undefined, null, NaN, Infinity, 'x', '82', {}]) assert.deepEqual(punkteInfo(nichts), { text: '–', breite: 0, breiteGross: 0, farbe: 'leer' }, String(nichts))
})
const ITEM = {
id: 'p1', room: 'handwerk', roomLabel: 'Handwerk', name: 'Elektro Beispiel GmbH', postalCode: '67655', city: 'Kaiserslautern', score: 82,
reason: 'keine veröffentlichte Mail', sourceUrl: 'https://beispiel-elektro.de/impressum', phone: '0631 111', phoneSource: 'https://beispiel-elektro.de/impressum',
isHealth: false, foundAt: '2026-10-19T20:00:00Z', gekuerzt: false,
}
test('eintragModell: Zeilenmodell von botleadRow plus Raum-Symbol, Scout, Punkte, Grund mit Erklärung, Telefon-Symbol und Label', () => {
const m = eintragModell(ITEM)
assert.deepEqual({ ...m, punkte: undefined }, {
...V.botleadRow(ITEM),
raum: 'handwerk', raumIcon: 'hk-ic hk-ic-raum-handwerk', scout: 'Hanna', punkte: undefined,
grundIcon: 'hk-ic hk-ic-brief-x', erklaerung: 'Auf der Website steht keine E-Mail-Adresse. Ohne veröffentlichte Adresse schreibt der Hustler nie.',
telefonIcon: 'hk-ic hk-ic-telefon',
label: 'Elektro Beispiel GmbH, 67655 Kaiserslautern, 82 Punkte, keine veröffentlichte Mail, gefunden am 19.10.2026',
})
assert.deepEqual(m.punkte, punkteInfo(82))
assert.equal(m.sourceHref, 'https://beispiel-elektro.de/impressum')
assert.equal(m.phone, '0631 111')
})
test('eintragModell: Heilberuf nie mit Nummer (Schloss), unsichere Quelle ohne Link, ohne Quelle der Nummer „nicht verfügbar“, gekürzt mit Schere', () => {
const praxis = eintragModell({ ...ITEM, id: 'p2', room: 'aerzte', roomLabel: 'Ärzte & Praxen', isHealth: true, sourceUrl: 'javascript:alert(1)' })
assert.equal(praxis.phone, 'bewusst nicht gespeichert')
assert.equal(praxis.telefonIcon, 'hk-ic hk-ic-schloss')
assert.equal(praxis.sourceHref, '')
assert.equal(praxis.scout, 'Ali')
// ein Altbestand mit Nummer und Quelle verrät sie bei einem Heilberuf trotzdem nicht
assert.equal(eintragModell({ ...ITEM, isHealth: true }).phone, 'bewusst nicht gespeichert')
assert.equal(eintragModell({ ...ITEM, phoneSource: '' }).phone, 'nicht verfügbar')
assert.equal(eintragModell({ ...ITEM, phoneSource: '' }).telefonIcon, 'hk-ic hk-ic-telefon')
assert.equal(eintragModell({ ...ITEM, sourceUrl: 'https://user:pw@evil.example/' }).sourceHref, '')
assert.equal(eintragModell({ ...ITEM, sourceUrl: 'http://alt.example/kontakt' }).sourceHref, 'http://alt.example/kontakt')
const kuerz = eintragModell({ ...ITEM, name: '[gekürzt]', gekuerzt: true, phone: '', phoneSource: '', sourceUrl: '' })
assert.equal(kuerz.gekuerzt, true)
assert.equal(kuerz.telefonIcon, 'hk-ic hk-ic-schere')
assert.ok(kuerz.label.startsWith('[gekürzt] (gekürzt), '))
// jedes Symbol des Modells gibt es im CSS
for (const m of [eintragModell(ITEM), praxis, kuerz]) for (const k of `${m.raumIcon} ${m.grundIcon} ${m.telefonIcon}`.split(' ')) assert.ok(CSS.has(k), k)
})
test('eintragModell: fehlende oder kaputte Werte ergeben Platzhalter, nie undefined, NaN oder einen Fehler', () => {
for (const kaputt of [null, undefined, 42, 'x', [], {}, { room: 5, score: 'x', reason: null, name: 7 }]) {
const m = eintragModell(kaputt)
assert.equal(m.title, 'nicht verfügbar')
assert.equal(m.scoreLabel, '–')
assert.equal(m.reason, '–')
assert.equal(m.raum, '')
assert.equal(m.raumIcon, 'hk-ic hk-ic-alle')
assert.equal(m.scout, '')
assert.deepEqual(m.punkte, { text: '–', breite: 0, breiteGross: 0, farbe: 'leer' })
assert.equal(m.grundIcon, 'hk-ic hk-ic-akte') // Grund „–“ ist nicht bekannt: neutrales Symbol
assert.equal(m.sourceHref, '')
assert.ok(!/undefined|NaN/.test(JSON.stringify(m)), JSON.stringify(m))
assert.equal(typeof m.label, 'string')
}
// unbekannter Raum und unbekannter Grund
const m = eintragModell({ ...ITEM, room: 'xyz', reason: 'irgendein neuer Grund' })
assert.equal(m.scout, '')
assert.equal(m.raumIcon, 'hk-ic hk-ic-alle')
assert.equal(m.grundIcon, 'hk-ic hk-ic-akte')
assert.match(m.erklaerung, /Der Hustler schreibt diesem Betrieb nicht/)
})
test('hustlerKeller.js: rein (kein React, kein DOM, kein CSS und keine Bilder), nur Importe aus src/lib', () => {
const src = read('src/lib/hustlerKeller.js').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
const importe = [...src.matchAll(/^\s*import\b[^'"]*['"]([^'"]+)['"]/gm)].map((m) => m[1])
assert.deepEqual(importe.sort(), ['./botleadGruende.js', './botleadsView.js', './hustlerSicht.js', './workersRoster.js'])
assert.doesNotMatch(src, /\b(?:window|document|fetch|localStorage)\b/)
assert.doesNotMatch(src, /innerHTML/)
assert.doesNotMatch(src, /\.(?:css|png|svg|jpg)['"]/)
})