workers: Reiter Bot-Leads mit Filter, Grund, Telefon und Quelle

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-20 10:20:51 +00:00
1 parent d1b7eddd10
commit 3d830ced38
12 files changed
+806 -5

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
// outDir muss ein neuer oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo sein.
// Die Schrift /fonts/* bleibt außen vor: der Server von shot.cjs liefert sie aus public/fonts.
// Drei Module der App hängen an Appwrite (Anmeldung, Mitarbeiterliste, Sitzungs-JWT) und werden nur im Prüfstand ausgetauscht, damit die
// echte Seite WorkersPage (Reiter Hochhaus und Freigabe) samt PultList und ProspectPanel ohne Netz läuft: AuthContext -> stub-auth.js,
// echte Seite WorkersPage (Reiter Hochhaus, Freigabe und Bot-Leads) samt PultList, ProspectPanel und BotLeadList ohne Netz läuft: AuthContext -> stub-auth.js,
// useEmployees -> stub-employees.js, workersClient -> stub-client.js. Am Quelltext der App ändert das nichts.
import esbuild from 'esbuild'
import fs from 'node:fs'
+3 -2
View File
@@ -4,8 +4,9 @@
// ?fault=none|down|session|hang schaltet zwischen Board und Skript-API den echten API-Client (createWorkersApi) mit einer Fetch-Weiche
// (scripted-fetch.js): window.__wk.control.fault lässt sich zur Laufzeit auf null | 'down' | 'session' | 'hang' setzen (Ausfall-Szenario).
// Ohne den Parameter bekommt das Board die Skript-API direkt (Szenarien A bis E).
// ?page=1 zeigt statt des Boards die echte Seite WorkersPage (Reiter Hochhaus und Freigabe, PultList, ProspectPanel); AuthContext,
// useEmployees und workersClient sind dort durch stub-*.js ersetzt (build.mjs). ?tab=freigabe öffnet den Reiter gleich (Deep-Link).
// ?page=1 zeigt statt des Boards die echte Seite WorkersPage (Reiter Hochhaus, Freigabe und Bot-Leads; PultList, ProspectPanel, BotLeadList); AuthContext,
// useEmployees und workersClient sind dort durch stub-*.js ersetzt (build.mjs). ?tab=freigabe öffnet den Reiter gleich (Deep-Link), ebenso ?tab=botleads
// (die Skript-API liefert dort eine leere Liste: gebaut wird der Reiter, gefüllt wird er im Prüfstand nicht).
// Mit ?page=1&fault=none läuft die Seite über den echten API-Client und die Fetch-Weiche (Pult, Aktionen, Interessent).
// ?p4=1 (nur mit ?page=1) nimmt den Datensatz für die Gespräche und die Werkstatt (P4: als Mensch antworten, Vorschau freigeben und bauen,
// Antwort des Kunden einfügen) statt der P3-Einträge.
+1
View File
@@ -728,6 +728,7 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu
return clone({ item })
},
// Bot-Leads (P6): leere Liste; der Reiter wird im Prüfstand gebaut und gezeigt, aber nicht mit Daten gefüllt (workersHarness.test.mjs pinnt die leere Antwort)
async botleads() {
return { items: [], total: 0 }
},
+1 -1
View File
@@ -563,7 +563,7 @@ async function freigabe(browser, base) {
await page.getByRole('tab').first().waitFor()
await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19)
const tabNames = (await page.getByRole('tab').allTextContents()).map((t) => t.trim())
check('freigabe: Reiterleiste mit „Hochhaus“ und „Freigabe“', same(tabNames, ['Hochhaus', 'Freigabe']), tabNames)
check('freigabe: Reiterleiste mit „Hochhaus“, „Freigabe“ und „Bot-Leads“', same(tabNames, ['Hochhaus', 'Freigabe', 'Bot-Leads']), tabNames)
check('freigabe: Start auf „Hochhaus“ (Hochhaus mit 19 Figuren, Reiter gewählt)', (await page.getByRole('tab', { name: 'Hochhaus', exact: true }).getAttribute('aria-selected')) === 'true')
await page.getByRole('tab', { name: 'Freigabe', exact: true }).click()
await page.waitForFunction(() => document.querySelectorAll('li.wk-pult-row').length === 4)
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from 'react'
import EmptyState from '../ui/EmptyState'
import { ROOM_FILTERS, botleadRow, pageInfo } from '../../lib/botleadsView'
const SEARCH_DELAY_MS = 300 // so lange wartet die Suche nach dem letzten Tastendruck, bevor sie den Dienst fragt
const SEARCH_MAX = 100 // der Dienst kürzt die Suche auf 100 Zeichen
const UNKNOWN_ERROR = 'Unbekannter Fehler'
const COLUMNS = ['Betrieb', 'Raum', 'Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden']
const START_VIEW = Object.freeze({ room: '', q: '', page: 1 }) // Raumfilter, geltende Suche, Seite: ein Wert, damit eine Änderung genau eine Anfrage auslöst
// Reiter „Bot-Leads“: Betriebe, die nicht angeschrieben werden (z. B. keine veröffentlichte Mail), mit Grund. Reine Liste, nichts wird gesendet oder angerufen.
// Die Liste holt sich ihre Daten bei jeder Änderung von Raum, Suche, Seite oder bot selbst; es gibt kein Polling. Nur die jüngste Anfrage zählt: eine späte
// Antwort (auch ein später Fehler) einer älteren Anfrage oder nach dem Abbau wird verworfen.
// bot (aus ?bot= der Adresse, Link aus dem Ereignisstrom): zeigt genau diesen Eintrag, hervorgehoben, mit „Alle anzeigen“ (onClearBot nimmt bot aus der Adresse).
// Alle Texte der Zeilen sind Textknoten; Links entstehen nur aus row.sourceHref (botleadRow prüft die Adresse mit safeHref: nur http und https, keine Zugangsdaten).
export default function BotLeadList({ api, bot, onClearBot }) {
const botId = typeof bot === 'string' ? bot : ''
const single = botId !== ''
const [view, setView] = useState(START_VIEW)
const [draft, setDraft] = useState('') // Inhalt des Suchfelds; in view.q steht erst die Suche, die nach der Verzögerung gilt
const [result, setResult] = useState({ key: null, items: [], total: 0, pageSize: undefined, error: null })
const timerRef = useRef(null)
const seqRef = useRef(0)
const key = JSON.stringify(single ? [botId] : [view.room, view.q, view.page])
useEffect(() => {
seqRef.current += 1
const mine = seqRef.current
// Ein früheres Ergebnis zu genau diesem Schlüssel (hin und zurück gewechselt, bevor der andere Schlüssel antwortete) ist veraltet: erst die neue Antwort zählt
setResult((prev) => (prev.key === key ? { ...prev, key: null } : prev))
const load = async () => {
try {
const data = await api.botleads(single ? { id: botId } : { room: view.room, q: view.q, page: view.page })
if (mine !== seqRef.current) return
const items = Array.isArray(data?.items) ? data.items : []
const total = Number.isFinite(data?.total) ? data.total : items.length
// Die Liste ist inzwischen kürzer (Kürzen über Nacht): statt einer leeren Seite auf die letzte Seite springen
const { pages } = pageInfo({ total, page: view.page, pageSize: data?.pageSize })
if (!single && items.length === 0 && total > 0 && view.page > pages) {
setView((v) => ({ ...v, page: pages }))
return
}
setResult({ key, items, total, pageSize: data?.pageSize, error: null })
} catch (err) {
if (mine !== seqRef.current) return
setResult({ key, items: [], total: 0, pageSize: undefined, error: err?.message || UNKNOWN_ERROR })
}
}
load()
return () => { seqRef.current += 1 } // eine noch offene Anfrage zählt nicht mehr
}, [api, key])
useEffect(() => () => window.clearTimeout(timerRef.current), [])
const onRoom = (e) => {
const room = e.target.value
setView((v) => ({ ...v, room, page: 1 }))
}
const onSearch = (e) => {
const value = e.target.value
setDraft(value)
window.clearTimeout(timerRef.current)
timerRef.current = window.setTimeout(() => setView((v) => ({ ...v, q: value.trim(), page: 1 })), SEARCH_DELAY_MS)
}
const loading = result.key !== key
const rows = result.items.map((item) => botleadRow(item))
const info = pageInfo({ total: result.total, page: view.page, pageSize: result.pageSize })
const filtered = view.room !== '' || view.q !== ''
let body
if (loading) {
body = <div className="loading" role="status">Wird geladen …</div>
} else if (result.error) {
body = <div className="msg err" role="alert">{result.error}</div>
} else if (rows.length === 0) {
if (single) body = <EmptyState title="Dieser Eintrag ist nicht (mehr) in der Liste." hint="Er wurde gekürzt oder ist kein Bot-Lead." />
else if (filtered) body = <EmptyState title="Keine Treffer." hint="Für diesen Raum und diese Suche gibt es keine Bot-Leads." />
else body = <EmptyState title="Noch keine Bot-Leads." hint="Betriebe, die nicht angeschrieben werden, erscheinen hier mit ihrem Grund." />
} else {
body = (
<div className="wk-bl-scroll" role="region" aria-label="Bot-Leads" tabIndex={0}>
<table className="table wk-bl-table">
<thead>
<tr>{COLUMNS.map((name) => <th key={name} scope="col">{name}</th>)}</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className={row.gekuerzt ? 'wk-bl-cut' : undefined} aria-current={row.id === botId ? 'true' : undefined}>
<td>
<div className="cell-title">{row.title}{row.gekuerzt && <span className="wk-bl-tag">gekürzt</span>}</div>
{row.place && <div className="cell-sub">{row.place}</div>}
</td>
<td>{row.roomLabel}</td>
<td className="wk-bl-nowrap">{row.scoreLabel}</td>
<td>{row.reason}</td>
<td>{row.phone}</td>
<td className="wk-bl-nowrap">
{row.sourceHref
? <a href={row.sourceHref} target="_blank" rel="noopener noreferrer" aria-label={`Quelle öffnen: ${row.title}`}>Quelle öffnen</a>
: '–'}
</td>
<td className="wk-bl-nowrap">{row.found}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
return (
<div className="wk-bl">
<p className="wk-hint">Reine Liste: keine Anrufe, kein Mailversand.</p>
{single ? (
<div className="toolbar wk-bl-single">
<span className="wk-hint">Einzelner Eintrag aus dem Ereignisstrom</span>
<button type="button" className="btn" onClick={onClearBot}>Alle anzeigen</button>
</div>
) : (
<div className="toolbar">
<select className="select" aria-label="Raum" value={view.room} onChange={onRoom}>
{ROOM_FILTERS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
</select>
<input className="input search" type="search" aria-label="Betriebe suchen" placeholder="Name, Ort oder Grund" maxLength={SEARCH_MAX} value={draft} onChange={onSearch} />
</div>
)}
{body}
{!single && !loading && !result.error && result.total > 0 && (
<div className="table-foot">
<span>{info.label}</span>
{info.pages > 1 && (
<span className="wk-bl-pager">
<button type="button" className="btn" disabled={!info.hasPrev} onClick={() => setView((v) => ({ ...v, page: v.page - 1 }))}>Zurück</button>
<button type="button" className="btn" disabled={!info.hasNext} onClick={() => setView((v) => ({ ...v, page: v.page + 1 }))}>Weiter</button>
</span>
)}
</div>
)}
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
// Reine Anzeige-Ableitungen des Reiters „Bot-Leads“ der Hustler-Seite: Raumfilter, sichere Quelle, Telefon, Zeilenmodell, Seitenzeile.
// Kein React, kein Appwrite, kein window/document: unter Node testbar (loadModule). Die Daten liefert GET /api/workers/botleads (Übersicht §6.2).
import { dateDe } from './financeFormat'
import { safeUrl } from './financeView'
import { formatCount } from './workersView'
const DASH = '–'
const NOT_AVAILABLE = 'nicht verfügbar'
const NOT_STORED = 'bewusst nicht gespeichert' // Heilberuf: der Dienst speichert und liefert nie eine Nummer
const DEFAULT_PAGE_SIZE = 50 // Rückfall, falls die Antwort des Dienstes keine pageSize nennt
/** Filter nach Raum: value ist der Raum-Schlüssel der API ('' = alle Räume). */
export const ROOM_FILTERS = Object.freeze([
{ value: '', label: 'Alle Räume' },
{ value: 'aerzte', label: 'Ärzte & Praxen' },
{ value: 'handwerk', label: 'Handwerk' },
{ value: 'selbst', label: 'Selbstständige' },
{ value: 'laden', label: 'Läden & Gastro' },
])
const text = (v) => (typeof v === 'string' ? v.trim() : '')
/**
* http(s)-Adresse oder '': schützt vor `javascript:`, `data:` und Ähnlichem aus den Daten des Dienstes. Adressen mit Zugangsdaten
* (https://user:pw@host/, https://betrieb.de@evil.example/) fallen ebenfalls weg, denn sie sähen nach dem Betrieb aus und führten woandershin.
*/
export function safeHref(url) {
const href = safeUrl(url)
if (!href) return ''
const { username, password } = new URL(href)
return username || password ? '' : href
}
/** Telefon-Anzeige: Heilberuf nie, sonst die Nummer nur mit Quelle (ohne Quelle gilt sie als nicht belegt). */
export function phoneLabel(item) {
if (item?.isHealth) return NOT_STORED
const phone = text(item?.phone)
return phone && text(item?.phoneSource) ? phone : NOT_AVAILABLE
}
/** Zeilenmodell eines Eintrags der Liste; fehlende Werte ergeben Platzhalter, nie undefined oder NaN. */
export function botleadRow(item) {
const it = item && typeof item === 'object' ? item : {}
return {
id: typeof it.id === 'string' ? it.id : '',
title: text(it.name) || NOT_AVAILABLE,
place: [text(it.postalCode), text(it.city)].filter(Boolean).join(' '),
roomLabel: text(it.roomLabel) || DASH,
scoreLabel: Number.isFinite(it.score) ? `${Math.round(it.score)} Punkte` : DASH,
reason: text(it.reason) || DASH,
phone: phoneLabel(it),
sourceHref: safeHref(it.sourceUrl),
found: dateDe(it.foundAt), // Berliner Datum TT.MM.JJJJ
gekuerzt: Boolean(it.gekuerzt),
}
}
/** Seitenzeile unter der Liste: Seitenzahl, Blättern und Beschriftung. pageSize kommt aus der Antwort, sonst 50. */
export function pageInfo({ total, page, pageSize } = {}) {
const count = Number.isFinite(total) && total > 0 ? Math.floor(total) : 0
const size = Number.isFinite(pageSize) && pageSize >= 1 ? Math.floor(pageSize) : DEFAULT_PAGE_SIZE
const current = Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1
const pages = Math.max(1, Math.ceil(count / size))
const label = count === 0 ? 'Keine Einträge' : `Seite ${current} von ${pages} · ${formatCount(count)} ${count === 1 ? 'Eintrag' : 'Einträge'}`
return { pages, hasPrev: current > 1, hasNext: current < pages, label }
}
+6 -1
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { PageHeader, Tabs } from '../components/ui'
import BotLeadList from '../components/workers/BotLeadList'
import PultList from '../components/workers/PultList'
import WorkersBoard from '../components/workers/WorkersBoard'
import { useAuth } from '../context/AuthContext'
@@ -8,20 +9,23 @@ import { workersApi } from '../lib/workersClient'
import { pickTab } from '../lib/workersView'
import '../styles/workers.css'
// Reiter der Seite. Später kommt botleads (P6) dazu; die Reiterleiste erscheint erst ab zwei Reitern.
// Reiter der Seite (Hochhaus, Freigabe von P3, Bot-Leads von P6); die Reiterleiste erscheint erst ab zwei Reitern.
const WORKERS_TABS = [
{ id: 'hochhaus', label: 'Hochhaus' },
{ id: 'freigabe', label: 'Freigabe' },
{ id: 'botleads', label: 'Bot-Leads' },
]
const api = workersApi
// Seite „Hustler“ (Dateiname bleibt WorkersPage): Titel, Reiterwahl über ?tab= und der Inhalt des gewählten Reiters.
// ?tab=botleads&bot=<Id> (Link aus dem Ereignisstrom) zeigt einen einzelnen Bot-Lead; ein Reiterwechsel lässt bot weg.
// Auth und Dienst-API kommen von hier; das Board selbst kennt weder Anmeldung noch Appwrite.
export default function WorkersPage() {
const { isAdmin, user } = useAuth()
const [params, setParams] = useSearchParams()
const active = pickTab(WORKERS_TABS, params.get('tab'))
const bot = params.get('bot') ?? ''
// Tab-Titel nur solange die Seite offen ist; beim Verlassen kommt der vorige Titel zurück
useEffect(() => {
@@ -38,6 +42,7 @@ export default function WorkersPage() {
)}
{active.id === 'hochhaus' && <WorkersBoard api={api} isAdmin={isAdmin} />}
{active.id === 'freigabe' && <PultList api={api} me={{ id: user?.$id, name: user?.name }} />}
{active.id === 'botleads' && <BotLeadList api={api} bot={bot} onClearBot={() => setParams({ tab: 'botleads' })} />}
</div>
)
}
+29
View File
@@ -235,6 +235,32 @@
.wk-prospect-msg { min-width: 0; }
.wk-prospect-msg-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; margin-bottom: 4px; font-size: 13px; color: var(--text-2); }
/* ---------- Bot-Leads ---------- */
.wk-bl { min-width: 0; }
.wk-bl > .wk-hint { margin: 0 0 12px; }
.wk-bl .search { max-width: 100%; }
.wk-bl-single { gap: 8px 16px; }
/* Die Tabelle scrollt auf dem Handy waagerecht in ihrem Rahmen, nicht die Seite */
.wk-bl-scroll { max-width: 100%; overflow-x: auto; }
.wk-bl-table { min-width: 760px; }
/* break-word statt anywhere: anywhere ließe die Spalten auf ein Zeichen schrumpfen („Handwer-k“); lange Namen brechen, der Rahmen scrollt */
.wk-bl-table td { overflow-wrap: break-word; }
.wk-bl-nowrap { white-space: nowrap; font-variant-numeric: tabular-nums; }
/* Gekürzte Einträge (Daten nach der Frist entfernt) bleiben als graue Zeile stehen */
.wk-bl-cut td { color: var(--text-3); }
.wk-bl-tag {
display: inline-block;
padding: 0 6px;
border-radius: var(--radius);
font-size: 12px;
line-height: 20px;
font-weight: 500;
background: var(--grey-bg);
color: var(--grey-fg);
}
.wk-bl-pager { display: flex; gap: 8px; }
/* ---------- Schmale Bildschirme ---------- */
@media (max-width: 1100px) {
@@ -263,6 +289,9 @@
Der Dialog hängt nicht unter einer wk-Klasse, die Regel beginnt deshalb mit der Klasse des Knopfs. Mit (0,3,0) (Klasse, Klasse, Attribut) gewinnt sie
gegen (0,2,0), gleich in welcher Reihenfolge die Dateien geladen werden. */
.wk-prospect-close.btn[type="button"] { width: 100%; min-height: 40px; justify-content: center; }
/* Bot-Leads: Blätterknöpfe wie die anderen Knöpfe der Seite mit 36 px, Zeile darf umbrechen */
.wk-bl-pager .btn { min-height: 36px; }
.wk-bl .table-foot { flex-wrap: wrap; gap: 8px 16px; }
}
/* ---------- Weniger Bewegung ---------- */
+328
View File
@@ -0,0 +1,328 @@
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'
// 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))
// 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 }) }) }
}
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
}
test('lädt beim Start (Raum alle, keine Suche, Seite 1), zeigt zuerst „Wird geladen“, dann die Zeilen', async () => {
const api = fakeApi()
const ui = await start(api)
try {
assert.deepEqual(api.calls.map((c) => c.params), [{ room: '', q: '', page: 1 }])
assert.ok(ui.text().includes('Reine Liste: keine Anrufe, kein Mailversand.'))
assert.ok(ui.text().includes('Wird geladen'))
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'])
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'])
assert.equal(rows[0].props['aria-current'], undefined)
} finally { ui.end() }
})
test('Quelle: Link nur über die geprüfte Adresse, 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')
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'])
assert.equal(nodes(ui.tree(), (n) => n.props && typeof n.props.href === 'string' && n.props.href.startsWith('tel:')).length, 0)
} finally { ui.end() }
})
test('gekürzte Einträge sind grau und tragen „gekürzt“', async () => {
const api = fakeApi()
const ui = await start(api)
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)
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 () => {
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']])
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter')
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')
assert.equal(api.calls.length, 3)
assert.deepEqual(api.calls[2].params, { room: 'handwerk', q: '', page: 1 })
assert.equal(ui.select().props.value, 'handwerk')
// noch einmal gewechselt, bevor die erste Antwort kommt; die späte Antwort der älteren Anfrage darf nichts überschreiben
await ui.pick('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'])
// derselbe Raum noch einmal gewählt: keine neue Anfrage
await ui.pick('laden')
assert.equal(api.calls.length, 4)
} 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.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)
assert.equal(ui.search().props.value, ' bäckerei ') // das Feld zeigt sofort, was getippt wurde
assert.equal(api.calls.length, 2) // noch keine Anfrage
assert.deepEqual([...ui.timers.values()].map((t) => t.ms), [300]) // nur der letzte Zeitgeber lebt
ui.fireTimers(); await settle()
assert.equal(api.calls.length, 3)
assert.deepEqual(api.calls[2].params, { room: '', q: 'bäckerei', page: 1 }) // getrimmt, Seite 1
// derselbe Suchbegriff noch einmal: keine neue Anfrage
await ui.type('bäckerei'); ui.fireTimers(); await settle()
assert.equal(api.calls.length, 3)
assert.equal(ui.search().props.maxLength, 100)
} finally { ui.end() }
})
test('Fehler: der Satz aus der API steht als Meldung, keine Liste; der nächste Erfolg räumt ihn weg', async () => {
const api = fakeApi()
const ui = await start(api)
try {
await ui.fail(0, Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 }))
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.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.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.fail(3, new Error('zu spät'))
assert.deepEqual(ui.errors(), [])
} finally { ui.end() }
})
test('Leerzustände: ohne Filter „Noch keine Bot-Leads.“, mit Filter oder Suche „Keine Treffer.“', async () => {
const api = fakeApi()
const ui = await start(api)
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.ok(!ui.text().includes('Keine Einträge')) // keine Seitenzeile
await ui.pick('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.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 () => {
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.equal(api.calls.length, 1)
await ui.click('Weiter')
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.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(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)
} finally { ui.end() }
})
test('Blättern: eine Seite genügt ⇒ nur die Zahl, kein Blättern; Einzahl bei einem Eintrag', async () => {
const api = fakeApi()
const ui = await start(api)
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)
} finally { ui.end() }
})
test('Blättern: ist die Seite nach einem Kürzen leer, springt die Liste auf die letzte Seite', 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.answer(1, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter')
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 })
assert.ok(ui.text().includes('Wird geladen'))
await ui.answer(3, { items: [ITEM], total: 60, pageSize: 50 })
assert.ok(ui.text().includes('Seite 2 von 2 · 60 Einträge'))
assert.equal(api.calls.length, 4)
} finally { ui.end() }
})
test('bot: ruft die API mit id ab, hebt die Zeile hervor und bietet „Alle anzeigen“ statt der Filter an', async () => {
const api = fakeApi()
let cleared = 0
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
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
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, '')
} finally { ui.end() }
})
test('bot: ein anderer Eintrag über die Adresse lädt neu; nur die neueste Anfrage zählt; nicht gefunden ⇒ Hinweis mit „Alle anzeigen“', async () => {
const api = fakeApi()
const ui = await start(api, { bot: 'p1', onClearBot: () => {} })
try {
ui.setProps({ bot: 'p2' }); await settle()
assert.deepEqual(api.calls.map((c) => c.params), [{ id: 'p1' }, { id: 'p2' }])
await ui.answer(0, { items: [ITEM], total: 1 }) // späte Antwort für p1
assert.equal(ui.rows().length, 0)
await ui.answer(1, { items: [], total: 0 })
assert.ok(ui.text().includes('Dieser Eintrag ist nicht (mehr) in der Liste.'))
assert.ok(!ui.text().includes('Noch keine Bot-Leads.'))
assert.equal(buttons(ui.tree(), 'Alle anzeigen').length, 1)
// ein Fehler (ungültige Kennung) lässt den Weg zurück offen
ui.setProps({ bot: 'x y' }); await settle()
await ui.fail(2, Object.assign(new Error('Ungültige Kennung.'), { status: 400 }))
assert.deepEqual(ui.errors().map((e) => e.text), ['Ungültige Kennung.'])
assert.equal(buttons(ui.tree(), 'Alle anzeigen').length, 1)
} finally { ui.end() }
})
test('nach dem Abbau setzt eine späte Antwort nichts mehr, ein offener Zeitgeber der Suche wird gelöscht', async () => {
const api = fakeApi()
const ui = await start(api)
await ui.type('abc')
assert.equal(ui.timers.size, 1)
ui.end()
assert.equal(ui.timers.size, 0)
api.calls[0].resolve({ items: [ITEM], total: 1 })
await settle()
assert.equal(ui.lateUpdates(), 0)
})
+98
View File
@@ -0,0 +1,98 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { loadModule } from './load.mjs'
const { safeHref, phoneLabel, botleadRow, ROOM_FILTERS, pageInfo } = await loadModule('src/lib/botleadsView.js')
// Die React-Teile sind unter Node nicht ladbar (JSX): der Quelltext wird geprüft (Muster workersComponents.test.mjs); das Verhalten prüft botleadList.test.mjs
const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
test('safeHref: nur http und https, keine Zugangsdaten in der Adresse', () => {
assert.equal(safeHref('https://beispiel-elektro.de/impressum'), 'https://beispiel-elektro.de/impressum')
assert.equal(safeHref(' http://beispiel-elektro.de/ '), 'http://beispiel-elektro.de/')
for (const bose of ['javascript:alert(1)', '//evil.example', 'data:text/html,x', '', ' ', 'ftp://x.example/', 'mailto:a@b.de', '/pfad', 'https://', null, undefined, 42, {}]) assert.equal(safeHref(bose), '', String(bose))
assert.equal(safeHref('https://user:pw@beispiel-elektro.de/'), '') // Zugangsdaten
assert.equal(safeHref('https://beispiel-elektro.de@evil.example/'), '') // sähe nach dem Betrieb aus, führt aber woandershin
})
test('phoneLabel: Nummer nur mit Quelle, Heilberuf nie', () => {
assert.equal(phoneLabel({ phone: '0631 111', phoneSource: 'https://x.de/i', isHealth: false }), '0631 111')
assert.equal(phoneLabel({ phone: '0631 111', phoneSource: '', isHealth: false }), 'nicht verfügbar')
assert.equal(phoneLabel({ phone: '', phoneSource: '', isHealth: true }), 'bewusst nicht gespeichert')
assert.equal(phoneLabel({ phone: '0631 111', phoneSource: 'https://x.de/i', isHealth: true }), 'bewusst nicht gespeichert')
assert.equal(phoneLabel({ phone: '', phoneSource: 'https://x.de/i', isHealth: false }), 'nicht verfügbar')
assert.equal(phoneLabel({}), 'nicht verfügbar')
assert.equal(phoneLabel(null), 'nicht verfügbar')
})
test('botleadRow: alle zehn Felder', () => {
const item = { id: 'a', room: 'handwerk', roomLabel: 'Handwerk', name: 'Elektro Beispiel GmbH', postalCode: '67655', city: 'Kaiserslautern', score: 82, reason: 'keine veröffentlichte Mail', sourceUrl: 'javascript:x', phone: '', phoneSource: '', isHealth: false, foundAt: '2026-10-19T20:00:00Z', gekuerzt: false }
const r = botleadRow(item)
assert.deepEqual(Object.keys(r), ['id', 'title', 'place', 'roomLabel', 'scoreLabel', 'reason', 'phone', 'sourceHref', 'found', 'gekuerzt'])
assert.deepEqual(r, { id: 'a', title: 'Elektro Beispiel GmbH', place: '67655 Kaiserslautern', roomLabel: 'Handwerk', scoreLabel: '82 Punkte', reason: 'keine veröffentlichte Mail', phone: 'nicht verfügbar', sourceHref: '', found: '19.10.2026', gekuerzt: false })
// sichere Quelle, Nummer mit Quelle, gekürzt
const b = botleadRow({ ...item, sourceUrl: 'https://beispiel-elektro.de/impressum', phone: '0631 111', phoneSource: 'https://beispiel-elektro.de/impressum', gekuerzt: true })
assert.deepEqual([b.sourceHref, b.phone, b.gekuerzt], ['https://beispiel-elektro.de/impressum', '0631 111', true])
// Heilberuf: nie die Nummer, auch wenn der Dienst eine mitschickt
assert.equal(botleadRow({ ...item, isHealth: true, phone: '0631 111', phoneSource: 'https://x.de/i' }).phone, 'bewusst nicht gespeichert')
})
test('botleadRow: fehlende Werte ergeben Platzhalter, nie undefined oder NaN', () => {
const r = botleadRow({ id: 'b' })
assert.deepEqual(r, { id: 'b', title: 'nicht verfügbar', place: '', roomLabel: '–', scoreLabel: '–', reason: '–', phone: 'nicht verfügbar', sourceHref: '', found: '–', gekuerzt: false })
assert.equal(botleadRow({ id: 'c', name: ' ', postalCode: '', city: 'Mainz' }).title, 'nicht verfügbar')
assert.equal(botleadRow({ id: 'c', city: 'Mainz' }).place, 'Mainz')
assert.equal(botleadRow({ id: 'c', postalCode: '55116' }).place, '55116')
assert.equal(botleadRow({ id: 'c', score: 0 }).scoreLabel, '0 Punkte')
assert.equal(botleadRow({ id: 'c', score: 'x' }).scoreLabel, '–')
assert.equal(botleadRow({ id: 'c', foundAt: 'kein Datum' }).found, '–')
assert.equal(botleadRow(null).id, '')
})
test('botleadRow: das Funddatum zählt in Berlin (Mitternacht und Zeitumstellung)', () => {
assert.equal(botleadRow({ id: 'a', foundAt: '2026-10-19T22:30:00Z' }).found, '20.10.2026') // 00:30 Uhr am nächsten Tag
assert.equal(botleadRow({ id: 'a', foundAt: '2026-12-31T23:30:00Z' }).found, '01.01.2027')
assert.equal(botleadRow({ id: 'a', foundAt: '2026-10-25T00:30:00Z' }).found, '25.10.2026') // Nacht der Zeitumstellung
})
test('ROOM_FILTERS: Reihenfolge, Werte und Beschriftungen', () => {
assert.deepEqual(ROOM_FILTERS, [
{ value: '', label: 'Alle Räume' },
{ value: 'aerzte', label: 'Ärzte & Praxen' },
{ value: 'handwerk', label: 'Handwerk' },
{ value: 'selbst', label: 'Selbstständige' },
{ value: 'laden', label: 'Läden & Gastro' },
])
})
test('pageInfo: Seiten, Blättern und Beschriftung', () => {
assert.deepEqual(pageInfo({ total: 120, page: 2, pageSize: 50 }), { pages: 3, hasPrev: true, hasNext: true, label: 'Seite 2 von 3 · 120 Einträge' })
assert.deepEqual(pageInfo({ total: 120, page: 1, pageSize: 50 }), { pages: 3, hasPrev: false, hasNext: true, label: 'Seite 1 von 3 · 120 Einträge' })
assert.deepEqual(pageInfo({ total: 120, page: 3, pageSize: 50 }), { pages: 3, hasPrev: true, hasNext: false, label: 'Seite 3 von 3 · 120 Einträge' })
assert.equal(pageInfo({ total: 0, page: 1, pageSize: 50 }).label, 'Keine Einträge')
assert.deepEqual(pageInfo({ total: 0, page: 1, pageSize: 50 }), { pages: 1, hasPrev: false, hasNext: false, label: 'Keine Einträge' })
assert.equal(pageInfo({ total: 1, page: 1, pageSize: 50 }).label, 'Seite 1 von 1 · 1 Eintrag') // Einzahl
assert.equal(pageInfo({ total: 50, page: 1, pageSize: 50 }).pages, 1)
assert.equal(pageInfo({ total: 51, page: 1, pageSize: 50 }).pages, 2)
assert.equal(pageInfo({ total: 1234, page: 1, pageSize: 50 }).label, 'Seite 1 von 25 · 1.234 Einträge') // deutsche Tausenderpunkte
// der Dienst schickt pageSize mit; fehlt es oder ist es unbrauchbar, gilt 50
assert.equal(pageInfo({ total: 120, page: 1 }).pages, 3)
for (const bad of [0, -5, NaN, 'x', null]) assert.equal(pageInfo({ total: 120, page: 1, pageSize: bad }).pages, 3, String(bad))
assert.equal(pageInfo({ total: 120, page: 1, pageSize: 25 }).pages, 5)
// unbrauchbare Zahlen ergeben nie NaN
assert.deepEqual(pageInfo({ total: 'x', page: 'y', pageSize: 50 }), { pages: 1, hasPrev: false, hasNext: false, label: 'Keine Einträge' })
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/)
// kein Anruf-Link, kein Mailversand
assert.doesNotMatch(src, /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)
// kein Polling
assert.doesNotMatch(src, /setInterval/)
})
+122
View File
@@ -0,0 +1,122 @@
// Mini-Läufer anstelle von React für Tests der Komponenten unter Node (Hooks, Zustand, Effekte; gerendert wird in einen Baum aus { tag, props, children }).
// Dieselbe Fassung wie in workersPultUi.test.mjs (FAKE_REACT), hier als Baustein für weitere Komponententests. Die Bauteile laufen wirklich, Klicks und
// Eingaben ruft der Test von Hand auf. `esbuild` bündelt die Komponente mit dem Plug-in `fakeReact` (react und react/jsx-runtime kommen von hier).
export const FAKE_REACT = `
export const Fragment = Symbol('fragment')
export function jsx(type, props, key) { return { type, props: props ?? {}, key } }
export const jsxs = jsx
let current = null // Instanz des Bauteils, dessen Render gerade läuft
let hookIndex = 0
const flat = (children, out = []) => {
if (Array.isArray(children)) children.forEach((c) => flat(c, out))
else if (children !== null && children !== undefined && children !== false && children !== true) out.push(children)
return out
}
// Hängt ein Element (Bauteile mit Hooks, darunter Tags) ein und rendert es; jedes Bauteil behält seinen Zustand unter seinem Pfad im Baum.
export function __mount(element) {
const owner = { render, effects: [], late: 0 } // late: Zustandsänderungen, die nach dem Abbau noch ankamen
const instances = new Map()
let tree = null
let rendering = false
let dirty = false
const dispose = (inst) => {
inst.alive = false
for (const h of inst.hooks) if (h && typeof h.cleanup === 'function') h.cleanup()
}
const expand = (node, path, visited) => {
if (typeof node === 'string' || typeof node === 'number') return String(node)
const { type, props } = node
if (typeof type === 'function') {
visited.add(path)
let inst = instances.get(path)
if (!inst) { inst = { hooks: [], alive: true, owner }; instances.set(path, inst) }
current = inst
hookIndex = 0
const out = type(props)
current = null
return out === null || out === undefined || out === false ? null : expand(out, path + '/c', visited)
}
const children = flat(props.children).map((c, i) => expand(c, path + '/' + (c && c.key != null ? 'k:' + c.key : i), visited))
// ref-Objekt an einem Tag: wie React vor den Effekten gesetzt; das Element liefert der Test (globalThis.__fakeRef), sonst null
if (typeof type === 'string' && props.ref && typeof props.ref === 'object') props.ref.current = globalThis.__fakeRef ? globalThis.__fakeRef(type, props) : null
return { tag: type === Fragment ? 'fragment' : type, props, children }
}
function render() {
if (rendering) { dirty = true; return }
rendering = true
do {
dirty = false
const visited = new Set()
tree = expand(element, 'r', visited)
for (const [path, inst] of [...instances]) {
if (visited.has(path)) continue
dispose(inst)
instances.delete(path)
}
for (const run of owner.effects.splice(0)) run()
} while (dirty)
rendering = false
}
render()
return {
tree: () => tree,
rerender: render,
lateUpdates: () => owner.late,
unmount: () => { for (const inst of instances.values()) dispose(inst); instances.clear() },
}
}
export function useState(initial) {
const inst = current
const i = hookIndex++
if (!inst.hooks[i]) inst.hooks[i] = { value: typeof initial === 'function' ? initial() : initial }
const slot = inst.hooks[i]
return [slot.value, (next) => {
if (!inst.alive) { inst.owner.late += 1; return }
const value = typeof next === 'function' ? next(slot.value) : next
if (Object.is(value, slot.value)) return
slot.value = value
inst.owner.render()
}]
}
export function useRef(initial) {
const inst = current
const i = hookIndex++
if (!inst.hooks[i]) inst.hooks[i] = { current: initial }
return inst.hooks[i]
}
export function useCallback(fn, deps) {
const inst = current
const i = hookIndex++
const slot = inst.hooks[i]
if (slot && deps.every((d, k) => Object.is(d, slot.deps[k]))) return slot.fn
inst.hooks[i] = { fn, deps }
return fn
}
export function useEffect(fn, deps) {
const inst = current
const i = hookIndex++
if (!inst.hooks[i]) inst.hooks[i] = { deps: undefined, cleanup: undefined, ran: false }
const slot = inst.hooks[i]
if (slot.ran && deps && slot.deps && deps.every((d, k) => Object.is(d, slot.deps[k]))) return
inst.owner.effects.push(() => {
if (typeof slot.cleanup === 'function') slot.cleanup()
slot.deps = deps
slot.ran = true
slot.cleanup = fn()
})
}
`
export const fakeReact = {
name: 'fake-react',
setup(build) {
build.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: 'react', namespace: 'fake-react' }))
build.onLoad({ filter: /.*/, namespace: 'fake-react' }, () => ({ contents: FAKE_REACT, loader: 'js' }))
},
}
+8
View File
@@ -17,6 +17,14 @@ test('WorkersPage: Titel Hustler (H1 und Tab-Titel), Reiter hochhaus und freigab
assert.match(s, /id: 'hochhaus'/); assert.match(s, /id: 'freigabe'/); assert.match(s, /WORKERS_TABS\.length > 1/)
assert.match(s, /const api = workersApi/); assert.match(s, /pickTab\(/); assert.match(s, /isAdmin=\{isAdmin\}/)
})
test('WorkersPage: Reiter botleads hinter freigabe zeigt die BotLeadList mit api, bot aus der Adresse und onClearBot, der den Reiter behält; ein Reiterwechsel lässt bot weg', () => {
const s = readFileSync(new URL('../../src/pages/WorkersPage.jsx', import.meta.url), 'utf8')
assert.match(s, /id: 'botleads', label: 'Bot-Leads'/); assert.ok(s.indexOf("id: 'freigabe'") < s.indexOf("id: 'botleads'"))
assert.match(s, /import BotLeadList from '\.\.\/components\/workers\/BotLeadList'/); assert.match(s, /active\.id === 'botleads' && <BotLeadList[^>]*api=\{api\}/)
assert.match(s, /params\.get\('bot'\)/); assert.match(s, /<BotLeadList[^>]*\bbot=\{bot\}/); assert.match(s, /<BotLeadList[^>]*onClearBot=\{\(\) => setParams\(\{ tab: 'botleads' \}\)\}/)
assert.match(s, /onChange=\{\(id\) => setParams\(\{ tab: id \}\)\}/) // setParams ersetzt alle Parameter: bot fällt beim Wechsel weg
assert.doesNotMatch(s, /Später kommt botleads/)
})
test('App: RouteErrorBoundary um die Routen im Inhaltsbereich (fehlender lazy-Chunk in altem Tab lässt nicht die ganze Oberfläche verschwinden), Pfad als resetKey', () => {
const s = readFileSync(new URL('../../src/App.jsx', import.meta.url), 'utf8')
assert.match(s, /import RouteErrorBoundary from '\.\/components\/RouteErrorBoundary'/)