workers: Reiter Bot-Leads mit Filter, Grund, Telefon und Quelle
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d1b7eddd10
commit
3d830ced38
12 files changed
+806
-5
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import EmptyState from '../ui/EmptyState'
|
||||
import { ROOM_FILTERS, botleadRow, pageInfo } from '../../lib/botleadsView'
|
||||
|
||||
const SEARCH_DELAY_MS = 300 // so lange wartet die Suche nach dem letzten Tastendruck, bevor sie den Dienst fragt
|
||||
const SEARCH_MAX = 100 // der Dienst kürzt die Suche auf 100 Zeichen
|
||||
const UNKNOWN_ERROR = 'Unbekannter Fehler'
|
||||
const COLUMNS = ['Betrieb', 'Raum', 'Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden']
|
||||
const START_VIEW = Object.freeze({ room: '', q: '', page: 1 }) // Raumfilter, geltende Suche, Seite: ein Wert, damit eine Änderung genau eine Anfrage auslöst
|
||||
|
||||
// Reiter „Bot-Leads“: Betriebe, die nicht angeschrieben werden (z. B. keine veröffentlichte Mail), mit Grund. Reine Liste, nichts wird gesendet oder angerufen.
|
||||
// Die Liste holt sich ihre Daten bei jeder Änderung von Raum, Suche, Seite oder bot selbst; es gibt kein Polling. Nur die jüngste Anfrage zählt: eine späte
|
||||
// Antwort (auch ein später Fehler) einer älteren Anfrage oder nach dem Abbau wird verworfen.
|
||||
// bot (aus ?bot= der Adresse, Link aus dem Ereignisstrom): zeigt genau diesen Eintrag, hervorgehoben, mit „Alle anzeigen“ (onClearBot nimmt bot aus der Adresse).
|
||||
// Alle Texte der Zeilen sind Textknoten; Links entstehen nur aus row.sourceHref (botleadRow prüft die Adresse mit safeHref: nur http und https, keine Zugangsdaten).
|
||||
export default function BotLeadList({ api, bot, onClearBot }) {
|
||||
const botId = typeof bot === 'string' ? bot : ''
|
||||
const single = botId !== ''
|
||||
const [view, setView] = useState(START_VIEW)
|
||||
const [draft, setDraft] = useState('') // Inhalt des Suchfelds; in view.q steht erst die Suche, die nach der Verzögerung gilt
|
||||
const [result, setResult] = useState({ key: null, items: [], total: 0, pageSize: undefined, error: null })
|
||||
const timerRef = useRef(null)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
const key = JSON.stringify(single ? [botId] : [view.room, view.q, view.page])
|
||||
|
||||
useEffect(() => {
|
||||
seqRef.current += 1
|
||||
const mine = seqRef.current
|
||||
// Ein früheres Ergebnis zu genau diesem Schlüssel (hin und zurück gewechselt, bevor der andere Schlüssel antwortete) ist veraltet: erst die neue Antwort zählt
|
||||
setResult((prev) => (prev.key === key ? { ...prev, key: null } : prev))
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await api.botleads(single ? { id: botId } : { room: view.room, q: view.q, page: view.page })
|
||||
if (mine !== seqRef.current) return
|
||||
const items = Array.isArray(data?.items) ? data.items : []
|
||||
const total = Number.isFinite(data?.total) ? data.total : items.length
|
||||
// Die Liste ist inzwischen kürzer (Kürzen über Nacht): statt einer leeren Seite auf die letzte Seite springen
|
||||
const { pages } = pageInfo({ total, page: view.page, pageSize: data?.pageSize })
|
||||
if (!single && items.length === 0 && total > 0 && view.page > pages) {
|
||||
setView((v) => ({ ...v, page: pages }))
|
||||
return
|
||||
}
|
||||
setResult({ key, items, total, pageSize: data?.pageSize, error: null })
|
||||
} catch (err) {
|
||||
if (mine !== seqRef.current) return
|
||||
setResult({ key, items: [], total: 0, pageSize: undefined, error: err?.message || UNKNOWN_ERROR })
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => { seqRef.current += 1 } // eine noch offene Anfrage zählt nicht mehr
|
||||
}, [api, key])
|
||||
|
||||
useEffect(() => () => window.clearTimeout(timerRef.current), [])
|
||||
|
||||
const onRoom = (e) => {
|
||||
const room = e.target.value
|
||||
setView((v) => ({ ...v, room, page: 1 }))
|
||||
}
|
||||
const onSearch = (e) => {
|
||||
const value = e.target.value
|
||||
setDraft(value)
|
||||
window.clearTimeout(timerRef.current)
|
||||
timerRef.current = window.setTimeout(() => setView((v) => ({ ...v, q: value.trim(), page: 1 })), SEARCH_DELAY_MS)
|
||||
}
|
||||
|
||||
const loading = result.key !== key
|
||||
const rows = result.items.map((item) => botleadRow(item))
|
||||
const info = pageInfo({ total: result.total, page: view.page, pageSize: result.pageSize })
|
||||
const filtered = view.room !== '' || view.q !== ''
|
||||
|
||||
let body
|
||||
if (loading) {
|
||||
body = <div className="loading" role="status">Wird geladen …</div>
|
||||
} else if (result.error) {
|
||||
body = <div className="msg err" role="alert">{result.error}</div>
|
||||
} else if (rows.length === 0) {
|
||||
if (single) body = <EmptyState title="Dieser Eintrag ist nicht (mehr) in der Liste." hint="Er wurde gekürzt oder ist kein Bot-Lead." />
|
||||
else if (filtered) body = <EmptyState title="Keine Treffer." hint="Für diesen Raum und diese Suche gibt es keine Bot-Leads." />
|
||||
else body = <EmptyState title="Noch keine Bot-Leads." hint="Betriebe, die nicht angeschrieben werden, erscheinen hier mit ihrem Grund." />
|
||||
} else {
|
||||
body = (
|
||||
<div className="wk-bl-scroll" role="region" aria-label="Bot-Leads" tabIndex={0}>
|
||||
<table className="table wk-bl-table">
|
||||
<thead>
|
||||
<tr>{COLUMNS.map((name) => <th key={name} scope="col">{name}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className={row.gekuerzt ? 'wk-bl-cut' : undefined} aria-current={row.id === botId ? 'true' : undefined}>
|
||||
<td>
|
||||
<div className="cell-title">{row.title}{row.gekuerzt && <span className="wk-bl-tag">gekürzt</span>}</div>
|
||||
{row.place && <div className="cell-sub">{row.place}</div>}
|
||||
</td>
|
||||
<td>{row.roomLabel}</td>
|
||||
<td className="wk-bl-nowrap">{row.scoreLabel}</td>
|
||||
<td>{row.reason}</td>
|
||||
<td>{row.phone}</td>
|
||||
<td className="wk-bl-nowrap">
|
||||
{row.sourceHref
|
||||
? <a href={row.sourceHref} target="_blank" rel="noopener noreferrer" aria-label={`Quelle öffnen: ${row.title}`}>Quelle öffnen</a>
|
||||
: '–'}
|
||||
</td>
|
||||
<td className="wk-bl-nowrap">{row.found}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wk-bl">
|
||||
<p className="wk-hint">Reine Liste: keine Anrufe, kein Mailversand.</p>
|
||||
{single ? (
|
||||
<div className="toolbar wk-bl-single">
|
||||
<span className="wk-hint">Einzelner Eintrag aus dem Ereignisstrom</span>
|
||||
<button type="button" className="btn" onClick={onClearBot}>Alle anzeigen</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="toolbar">
|
||||
<select className="select" aria-label="Raum" value={view.room} onChange={onRoom}>
|
||||
{ROOM_FILTERS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
|
||||
</select>
|
||||
<input className="input search" type="search" aria-label="Betriebe suchen" placeholder="Name, Ort oder Grund" maxLength={SEARCH_MAX} value={draft} onChange={onSearch} />
|
||||
</div>
|
||||
)}
|
||||
{body}
|
||||
{!single && !loading && !result.error && result.total > 0 && (
|
||||
<div className="table-foot">
|
||||
<span>{info.label}</span>
|
||||
{info.pages > 1 && (
|
||||
<span className="wk-bl-pager">
|
||||
<button type="button" className="btn" disabled={!info.hasPrev} onClick={() => setView((v) => ({ ...v, page: v.page - 1 }))}>Zurück</button>
|
||||
<button type="button" className="btn" disabled={!info.hasNext} onClick={() => setView((v) => ({ ...v, page: v.page + 1 }))}>Weiter</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { PageHeader, Tabs } from '../components/ui'
|
||||
import BotLeadList from '../components/workers/BotLeadList'
|
||||
import PultList from '../components/workers/PultList'
|
||||
import WorkersBoard from '../components/workers/WorkersBoard'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
@@ -8,20 +9,23 @@ import { workersApi } from '../lib/workersClient'
|
||||
import { pickTab } from '../lib/workersView'
|
||||
import '../styles/workers.css'
|
||||
|
||||
// Reiter der Seite. Später kommt botleads (P6) dazu; die Reiterleiste erscheint erst ab zwei Reitern.
|
||||
// Reiter der Seite (Hochhaus, Freigabe von P3, Bot-Leads von P6); die Reiterleiste erscheint erst ab zwei Reitern.
|
||||
const WORKERS_TABS = [
|
||||
{ id: 'hochhaus', label: 'Hochhaus' },
|
||||
{ id: 'freigabe', label: 'Freigabe' },
|
||||
{ id: 'botleads', label: 'Bot-Leads' },
|
||||
]
|
||||
|
||||
const api = workersApi
|
||||
|
||||
// Seite „Hustler“ (Dateiname bleibt WorkersPage): Titel, Reiterwahl über ?tab= und der Inhalt des gewählten Reiters.
|
||||
// ?tab=botleads&bot=<Id> (Link aus dem Ereignisstrom) zeigt einen einzelnen Bot-Lead; ein Reiterwechsel lässt bot weg.
|
||||
// Auth und Dienst-API kommen von hier; das Board selbst kennt weder Anmeldung noch Appwrite.
|
||||
export default function WorkersPage() {
|
||||
const { isAdmin, user } = useAuth()
|
||||
const [params, setParams] = useSearchParams()
|
||||
const active = pickTab(WORKERS_TABS, params.get('tab'))
|
||||
const bot = params.get('bot') ?? ''
|
||||
|
||||
// Tab-Titel nur solange die Seite offen ist; beim Verlassen kommt der vorige Titel zurück
|
||||
useEffect(() => {
|
||||
@@ -38,6 +42,7 @@ export default function WorkersPage() {
|
||||
)}
|
||||
{active.id === 'hochhaus' && <WorkersBoard api={api} isAdmin={isAdmin} />}
|
||||
{active.id === 'freigabe' && <PultList api={api} me={{ id: user?.$id, name: user?.name }} />}
|
||||
{active.id === 'botleads' && <BotLeadList api={api} bot={bot} onClearBot={() => setParams({ tab: 'botleads' })} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 ---------- */
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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' }))
|
||||
},
|
||||
}
|
||||
@@ -17,6 +17,14 @@ test('WorkersPage: Titel Hustler (H1 und Tab-Titel), Reiter hochhaus und freigab
|
||||
assert.match(s, /id: 'hochhaus'/); assert.match(s, /id: 'freigabe'/); assert.match(s, /WORKERS_TABS\.length > 1/)
|
||||
assert.match(s, /const api = workersApi/); assert.match(s, /pickTab\(/); assert.match(s, /isAdmin=\{isAdmin\}/)
|
||||
})
|
||||
test('WorkersPage: Reiter botleads hinter freigabe zeigt die BotLeadList mit api, bot aus der Adresse und onClearBot, der den Reiter behält; ein Reiterwechsel lässt bot weg', () => {
|
||||
const s = readFileSync(new URL('../../src/pages/WorkersPage.jsx', import.meta.url), 'utf8')
|
||||
assert.match(s, /id: 'botleads', label: 'Bot-Leads'/); assert.ok(s.indexOf("id: 'freigabe'") < s.indexOf("id: 'botleads'"))
|
||||
assert.match(s, /import BotLeadList from '\.\.\/components\/workers\/BotLeadList'/); assert.match(s, /active\.id === 'botleads' && <BotLeadList[^>]*api=\{api\}/)
|
||||
assert.match(s, /params\.get\('bot'\)/); assert.match(s, /<BotLeadList[^>]*\bbot=\{bot\}/); assert.match(s, /<BotLeadList[^>]*onClearBot=\{\(\) => setParams\(\{ tab: 'botleads' \}\)\}/)
|
||||
assert.match(s, /onChange=\{\(id\) => setParams\(\{ tab: id \}\)\}/) // setParams ersetzt alle Parameter: bot fällt beim Wechsel weg
|
||||
assert.doesNotMatch(s, /Später kommt botleads/)
|
||||
})
|
||||
test('App: RouteErrorBoundary um die Routen im Inhaltsbereich (fehlender lazy-Chunk in altem Tab lässt nicht die ganze Oberfläche verschwinden), Pfad als resetKey', () => {
|
||||
const s = readFileSync(new URL('../../src/App.jsx', import.meta.url), 'utf8')
|
||||
assert.match(s, /import RouteErrorBoundary from '\.\/components\/RouteErrorBoundary'/)
|
||||
|
||||
Reference in new issue
Block a user