BotLeadList bekommt die Stufe (WorkersPage reicht sie durch). Unterhalb von BOTLEADS_AB
(Stufe 6) ruft sie weder api.botleads noch ein JWT, zeigt keine Ladeanzeige und keinen
Fehlerzustand: Szene mit Arne, Register-Buch, Reiter und Suche stehen da, das Register ist leer
("Noch keine Bot-Leads. Hier legt Arne Betriebe ohne Website ab, die Bea nicht fuer eine Mail
ausgewaehlt hat. Die Branchen-Raeume ... suchen noch nicht."), ohne Zahl am Reiter. Reiter,
Suche, Zuklappen und Alle anzeigen bleiben bedienbar und loesen nie einen Abruf aus. Ab Stufe 6
unveraendert (Live-Daten); der Test-Laeufer startet den Keller standardmaessig mit Stufe 6.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
199 lines
11 KiB
JavaScript
199 lines
11 KiB
JavaScript
// 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) },
|
|
}
|
|
// stufe 6: Live-Daten (Stufen-Sperre, hustlerStufe.js: unterhalb von Stufe 6 ruft der Keller nichts ab und zeigt sein leeres Register); die Tests der Stufe 3 geben sie ausdrücklich an
|
|
let props = { api, stufe: 6, ...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
|
|
}
|