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

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