diff --git a/scripts/workers-harness/build.mjs b/scripts/workers-harness/build.mjs
index 676609e..b62c792 100644
--- a/scripts/workers-harness/build.mjs
+++ b/scripts/workers-harness/build.mjs
@@ -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'
diff --git a/scripts/workers-harness/entry.jsx b/scripts/workers-harness/entry.jsx
index 6a56472..c74cfd2 100644
--- a/scripts/workers-harness/entry.jsx
+++ b/scripts/workers-harness/entry.jsx
@@ -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.
diff --git a/scripts/workers-harness/scripted-api.js b/scripts/workers-harness/scripted-api.js
index ac60dad..c12a77f 100644
--- a/scripts/workers-harness/scripted-api.js
+++ b/scripts/workers-harness/scripted-api.js
@@ -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 }
},
diff --git a/scripts/workers-harness/shot.cjs b/scripts/workers-harness/shot.cjs
index 516c377..89f14c7 100644
--- a/scripts/workers-harness/shot.cjs
+++ b/scripts/workers-harness/shot.cjs
@@ -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)
diff --git a/src/components/workers/BotLeadList.jsx b/src/components/workers/BotLeadList.jsx
new file mode 100644
index 0000000..b88cf98
--- /dev/null
+++ b/src/components/workers/BotLeadList.jsx
@@ -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 =
Wird geladen …
+ } else if (result.error) {
+ body = {result.error}
+ } else if (rows.length === 0) {
+ if (single) body =
+ else if (filtered) body =
+ else body =
+ } else {
+ body = (
+
+
+
+ {COLUMNS.map((name) => {name} )}
+
+
+ {rows.map((row) => (
+
+
+ {row.title}{row.gekuerzt && gekürzt }
+ {row.place && {row.place}
}
+
+ {row.roomLabel}
+ {row.scoreLabel}
+ {row.reason}
+ {row.phone}
+
+ {row.sourceHref
+ ? Quelle öffnen
+ : '–'}
+
+ {row.found}
+
+ ))}
+
+
+
+ )
+ }
+
+ return (
+
+
Reine Liste: keine Anrufe, kein Mailversand.
+ {single ? (
+
+ Einzelner Eintrag aus dem Ereignisstrom
+ Alle anzeigen
+
+ ) : (
+
+
+ {ROOM_FILTERS.map((f) => {f.label} )}
+
+
+
+ )}
+ {body}
+ {!single && !loading && !result.error && result.total > 0 && (
+
+ {info.label}
+ {info.pages > 1 && (
+
+ setView((v) => ({ ...v, page: v.page - 1 }))}>Zurück
+ setView((v) => ({ ...v, page: v.page + 1 }))}>Weiter
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/src/lib/botleadsView.js b/src/lib/botleadsView.js
new file mode 100644
index 0000000..593a63b
--- /dev/null
+++ b/src/lib/botleadsView.js
@@ -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 }
+}
diff --git a/src/pages/WorkersPage.jsx b/src/pages/WorkersPage.jsx
index 23f5f03..234f152 100644
--- a/src/pages/WorkersPage.jsx
+++ b/src/pages/WorkersPage.jsx
@@ -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= (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' && }
{active.id === 'freigabe' && }
+ {active.id === 'botleads' && setParams({ tab: 'botleads' })} />}
)
}
diff --git a/src/styles/workers.css b/src/styles/workers.css
index ea31938..23a5d06 100644
--- a/src/styles/workers.css
+++ b/src/styles/workers.css
@@ -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 ---------- */
diff --git a/tests/unit/botleadList.test.mjs b/tests/unit/botleadList.test.mjs
new file mode 100644
index 0000000..cd5baac
--- /dev/null
+++ b/tests/unit/botleadList.test.mjs
@@ -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)
+})
diff --git a/tests/unit/botleadsView.test.mjs b/tests/unit/botleadsView.test.mjs
new file mode 100644
index 0000000..715889e
--- /dev/null
+++ b/tests/unit/botleadsView.test.mjs
@@ -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/)
+})
diff --git a/tests/unit/helpers/fakeReact.mjs b/tests/unit/helpers/fakeReact.mjs
new file mode 100644
index 0000000..40b133d
--- /dev/null
+++ b/tests/unit/helpers/fakeReact.mjs
@@ -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' }))
+ },
+}
diff --git a/tests/unit/workersRoute.test.mjs b/tests/unit/workersRoute.test.mjs
index cdc0077..8562c32 100644
--- a/tests/unit/workersRoute.test.mjs
+++ b/tests/unit/workersRoute.test.mjs
@@ -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' && ]*api=\{api\}/)
+ assert.match(s, /params\.get\('bot'\)/); assert.match(s, /]*\bbot=\{bot\}/); assert.match(s, /]*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'/)