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:
1 parent
ec63e5a43d
commit
1b3fdf5d97
14 files changed
+2247
-312
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user