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) => )} + + + {rows.map((row) => ( + + + + + + + + + + ))} + +
{name}
+
{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 + +
+ ) : ( +
+ + +
+ )} + {body} + {!single && !loading && !result.error && result.total > 0 && ( +
+ {info.label} + {info.pages > 1 && ( + + + + + )} +
+ )} +
+ ) +} 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'/)