Hustler-Pixelwelt T10: Keller (Szene mit Arne nach Status, Register-Buch, Akte, Gründe)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-22 08:53:56 +00:00
1 parent ec63e5a43d
commit 1b3fdf5d97
14 files changed
+2247 -312

No files matched your search

+70 -90
View File
@@ -1,34 +1,41 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import EmptyState from '../ui/EmptyState' import Akte from './keller/Akte'
import { ROOM_FILTERS, botleadRow, pageInfo } from '../../lib/botleadsView' import KellerSzene from './keller/KellerSzene'
import RegisterBuch from './keller/RegisterBuch'
import { pageInfo } from '../../lib/botleadsView'
import { eintragModell } from '../../lib/hustlerKeller'
import { formatCount } from '../../lib/workersView'
import { restoreFocus } from '../../lib/workersFocus' import { restoreFocus } from '../../lib/workersFocus'
const SEARCH_DELAY_MS = 300 // so lange wartet die Suche nach dem letzten Tastendruck, bevor sie den Dienst fragt 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 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 const START_VIEW = Object.freeze({ room: '', q: '', page: 1 }) // Raumfilter, geltende Suche, Seite: ein Wert, damit eine Änderung genau eine Anfrage auslöst
const browserDocument = () => (typeof document === 'undefined' ? null : document) // unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet const browserDocument = () => (typeof document === 'undefined' ? null : document) // unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet
// Reiter „Bot-Leads“: Betriebe, die nicht angeschrieben werden (z. B. keine veröffentlichte Mail), mit Grund. Reine Liste, nichts wird gesendet oder angerufen. // Bildschirm „Keller“ (Hustler-Pixelwelt T10, Spec E12/E13; Dateiname bleibt BotLeadList): die Archiv-Szene mit Arne (keller/KellerSzene), davor Arnes Register-Buch
// (keller/RegisterBuch) mit den Betrieben, die nicht angeschrieben werden, samt Grund, und die Akte zu einem Eintrag (keller/Akte). Reine Liste, nichts wird gesendet
// oder angerufen. Props: api, bot, onClearBot, kpis (botLeads für die Zahl am Schild), workers (Hustler-Tabelle der Seite: Arnes Status), onBack (◂ Hochhaus).
// 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 // 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. Scheitert eine Anfrage, steht der Satz des Dienstes mit „Erneut versuchen“, // Antwort (auch ein später Fehler) einer älteren Anfrage oder nach dem Abbau wird verworfen. Scheitert eine Anfrage, steht der Satz des Dienstes mit „Erneut versuchen“,
// das genau diese Anfrage (Raum, Suche, Seite oder Eintrag) noch einmal stellt; Raum und Suche gehen weiterhin. // das genau diese Anfrage (Raum, Suche, Seite oder Eintrag) noch einmal stellt; Raum und Suche gehen weiterhin.
// bot (aus ?bot= der Adresse, Link aus dem Ereignisstrom): zeigt genau diesen Eintrag, hervorgehoben, mit „Alle anzeigen“ (onClearBot nimmt bot aus der Adresse). // 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). // 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), und zwar
export default function BotLeadList({ api, bot, onClearBot }) { // nur in der Akte. Kein CSS-Import (WorkersPage hängt die Stile ein), keine Bilder, kein HTML-Einschub.
export default function BotLeadList({ api, bot, onClearBot, kpis, workers, onBack }) {
const botId = typeof bot === 'string' ? bot : '' const botId = typeof bot === 'string' ? bot : ''
const single = botId !== '' const single = botId !== ''
const [view, setView] = useState(START_VIEW) 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 [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 [result, setResult] = useState({ key: null, items: [], total: 0, pageSize: undefined, error: null })
const [attempt, setAttempt] = useState(0) // zählt „Erneut versuchen“: gehört zum Schlüssel, damit dieselbe Anfrage noch einmal läuft und „Wird geladen“ zeigt const [attempt, setAttempt] = useState(0) // zählt „Erneut versuchen“: gehört zum Schlüssel, damit dieselbe Anfrage noch einmal läuft und „Wird geladen“ zeigt
const [zu, setZu] = useState(false) // „Register zuklappen“: nur die Szene bleibt
const [akte, setAkte] = useState(null) // Nr. des Eintrags in der gezeigten Seite, dessen Akte offen ist
const timerRef = useRef(null) const timerRef = useRef(null)
const seqRef = useRef(0) const seqRef = useRef(0)
// Tastaturfokus: Wer „Alle anzeigen“ oder „Zurück“/„Weiter“ drückt, verliert den Knopf mit dem Fokus (Einzelansicht und Seitenzeile werden ausgetauscht oder // Tastaturfokus: Wer „Alle anzeigen“ oder ◂/▸ drückt, verliert den Knopf mit dem Fokus (Einzelansicht und Seitenzeile werden ausgetauscht oder
// hängen beim Laden aus); ohne Zutun fiele der Fokus auf <body>, an den Seitenanfang. Gemerkt wird nur, was der Nutzer als Letztes bedient hat. // hängen beim Laden aus); ohne Zutun fiele der Fokus auf <body>, an den Seitenanfang. Gemerkt wird nur, was der Nutzer als Letztes bedient hat.
const selectRef = useRef(null) // Raumfilter: Ziel nach „Alle anzeigen“ const reiterRef = useRef(null) // aktiver Raum-Reiter: Ziel nach „Alle anzeigen“
const clearedRef = useRef(false) // „Alle anzeigen“ gedrückt: erst dann (nie schon beim Einhängen, das öffnete am Handy die Auswahl) bekommt der Raumfilter den Fokus const clearedRef = useRef(false) // „Alle anzeigen“ gedrückt: erst dann (nie schon beim Einhängen) bekommt der Reiter den Fokus
const prevRef = useRef(null) const prevRef = useRef(null)
const nextRef = useRef(null) const nextRef = useRef(null)
const pagedRef = useRef('') // 'prev' oder 'next': welcher Blätterknopf zuletzt gedrückt wurde; nach dem Laden bekommt die Seitenzeile den Fokus zurück (auch nach „Erneut versuchen“) const pagedRef = useRef('') // 'prev' oder 'next': welcher Blätterknopf zuletzt gedrückt wurde; nach dem Laden bekommt die Seitenzeile den Fokus zurück (auch nach „Erneut versuchen“)
@@ -40,6 +47,7 @@ export default function BotLeadList({ api, bot, onClearBot }) {
const mine = seqRef.current 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 // 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)) setResult((prev) => (prev.key === key ? { ...prev, key: null } : prev))
setAkte(null) // eine neue Anfrage tauscht die Seite aus: die Akte zu einem Eintrag der alten Seite ist zu
const load = async () => { const load = async () => {
try { try {
const data = await api.botleads(single ? { id: botId } : { room: view.room, q: view.q, page: view.page }) const data = await api.botleads(single ? { id: botId } : { room: view.room, q: view.q, page: view.page })
@@ -73,8 +81,7 @@ export default function BotLeadList({ api, bot, onClearBot }) {
pagedRef.current = dir pagedRef.current = dir
setView((v) => ({ ...v, page: v.page + (dir === 'next' ? 1 : -1) })) setView((v) => ({ ...v, page: v.page + (dir === 'next' ? 1 : -1) }))
} }
const onRoom = (e) => { const onRoom = (room) => {
const room = e.target.value
pagedRef.current = '' // wer den Raum wählt, hat den Blätterknopf verlassen: der Fokus springt nicht zurück pagedRef.current = '' // wer den Raum wählt, hat den Blätterknopf verlassen: der Fokus springt nicht zurück
setView((v) => ({ ...v, room, page: 1 })) setView((v) => ({ ...v, room, page: 1 }))
} }
@@ -88,17 +95,17 @@ export default function BotLeadList({ api, bot, onClearBot }) {
const loading = result.key !== key const loading = result.key !== key
// Die Einzelansicht ist weg (die Seite hat bot aus der Adresse genommen): der Raumfilter ersetzt den gedrückten Knopf und bekommt den Fokus. // Die Einzelansicht ist weg (die Seite hat bot aus der Adresse genommen): der Raum-Reiter ersetzt den gedrückten Knopf und bekommt den Fokus.
// Ohne den Knopf (Zurück-Taste des Browsers) bleibt der Fokus, wo er ist. // Ohne den Knopf (Zurück-Taste des Browsers) bleibt der Fokus, wo er ist.
useEffect(() => { useEffect(() => {
const wanted = clearedRef.current const wanted = clearedRef.current
clearedRef.current = false clearedRef.current = false
if (wanted && !single) selectRef.current?.focus() if (wanted && !single) reiterRef.current?.focus()
}, [single]) }, [single])
// Die neue Seite ist da und die Seitenzeile wieder eingehängt: der Fokus geht an den gedrückten Knopf, ist er gesperrt (erste oder letzte Seite) an den anderen. // Die neue Seite ist da und die Seitenzeile wieder eingehängt: der Fokus geht an den gedrückten Knopf, ist er gesperrt (erste oder letzte Seite) an den anderen.
// Nur wenn er wirklich verloren ging (body, nirgends, der alte Knopf ist ausgehängt): wer inzwischen selbst woandershin gegangen ist (Suchfeld, Raumfilter), wird // Nur wenn er wirklich verloren ging (body, nirgends, der alte Knopf ist ausgehängt): wer inzwischen selbst woandershin gegangen ist (Suchfeld, Reiter), wird
// nicht gestört. Ohne preventScroll spränge die Seite ans Ende der neuen Seite, denn die Seitenzeile steht unter der Tabelle (restoreFocus in workersFocus.js). // nicht gestört. Ohne preventScroll spränge die Seite ans Ende der neuen Seite (restoreFocus in workersFocus.js).
// Scheiterte die Anfrage, gibt es keine Seitenzeile; die Merkung bleibt dann für „Erneut versuchen“ stehen (Raum, Suche und neues Blättern setzen sie neu oder zurück). // Scheiterte die Anfrage, gibt es keine Seitenzeile; die Merkung bleibt dann für „Erneut versuchen“ stehen (Raum, Suche und neues Blättern setzen sie neu oder zurück).
useEffect(() => { useEffect(() => {
if (loading || result.error) return if (loading || result.error) return
@@ -108,86 +115,59 @@ export default function BotLeadList({ api, bot, onClearBot }) {
restoreFocus(browserDocument(), dir === 'next' ? [nextRef.current, prevRef.current] : [prevRef.current, nextRef.current]) restoreFocus(browserDocument(), dir === 'next' ? [nextRef.current, prevRef.current] : [prevRef.current, nextRef.current])
}, [loading, result.error]) }, [loading, result.error])
const rows = result.items.map((item) => botleadRow(item)) const zeilen = result.items.map((item) => eintragModell(item))
const info = pageInfo({ total: result.total, page: view.page, pageSize: result.pageSize }) const info = pageInfo({ total: result.total, page: view.page, pageSize: result.pageSize })
const filtered = view.room !== '' || view.q !== '' const filtered = view.room !== '' || view.q !== ''
const zeigeAkte = akte !== null && !loading && !result.error && akte < zeilen.length
const plakette = kpis && Number.isFinite(kpis.botLeads) ? formatCount(kpis.botLeads) : ''
let body const onOpen = (nr) => setAkte(nr)
if (loading) { const onBlaettern = (schritt) => setAkte((nr) => (nr === null || zeilen.length === 0 ? nr : (nr + schritt + zeilen.length) % zeilen.length))
body = <div className="loading" role="status">Wird geladen …</div>
} else if (result.error) {
body = (
<>
<div className="msg err" role="alert">{result.error}</div>
<div className="toolbar wk-bl-retry">
<button type="button" className="btn" onClick={onRetry}>Erneut versuchen</button>
</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, index) => (
<tr key={row.id || `ohne-id-${index}`} className={row.gekuerzt ? 'wk-bl-cut' : undefined} aria-current={single && 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 ( return (
<div className="wk-bl"> <div className="hk-keller">
<p className="wk-hint">Reine Liste: keine Anrufe, kein Mailversand.</p> <KellerSzene workers={workers} />
{single ? ( <div className="hk-kl-vignette" aria-hidden="true" />
<div className="toolbar wk-bl-single"> <div className="hk-kl-dunkel" aria-hidden="true" />
<span className="wk-hint">Einzelner Eintrag aus dem Ereignisstrom</span> <div className="hk-kl-ui">
<button type="button" className="btn" onClick={onClear}>Alle anzeigen</button> <div className="hk-kl-kopf">
<button type="button" className="hk-knopf hk-kl-knopf-klein" aria-label="Zurück zum Hochhaus" onClick={() => { if (typeof onBack === 'function') onBack() }}>
<span className="hk-ic hk-ic-zurueck" aria-hidden="true" /><span className="hk-kl-label">Hochhaus</span>
</button>
<div className="hk-messing hk-kl-schild">
<span className="hk-ic hk-ic-robo" aria-hidden="true" />
<h1 className="hk-kl-titel">Bot-Leads</h1>
{plakette ? <span className="hk-kl-zahl">{plakette}</span> : null}
</div>
<button type="button" className="hk-knopf hk-kl-knopf-klein hk-kl-zuklappen" aria-expanded={!zu} aria-label={zu ? 'Register aufklappen' : 'Register zuklappen'} onClick={() => setZu((z) => !z)}>
<span className="hk-ic hk-ic-akte" aria-hidden="true" /><span className="hk-kl-label">{zu ? 'Register aufklappen' : 'Register zuklappen'}</span>
</button>
</div> </div>
) : ( {zu ? null : (
<div className="toolbar"> <RegisterBuch
<select className="select" aria-label="Raum" value={view.room} onChange={onRoom} ref={selectRef}> einzeln={single}
{ROOM_FILTERS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)} botId={botId}
</select> room={view.room}
<input className="input search" type="search" aria-label="Betriebe suchen" placeholder="Name, Ort oder Grund" maxLength={SEARCH_MAX} value={draft} onChange={onSearch} /> draft={draft}
</div> filtered={filtered}
)} laden={loading}
{body} fehler={result.error}
{!single && !loading && !result.error && result.total > 0 && ( zeilen={zeilen}
<div className="table-foot"> total={result.total}
<span>{info.label}</span> info={info}
{info.pages > 1 && ( onRoom={onRoom}
<span className="wk-bl-pager"> onSearch={onSearch}
<button type="button" className="btn" ref={prevRef} disabled={!info.hasPrev} onClick={() => onPage('prev')}>Zurück</button> onClear={onClear}
<button type="button" className="btn" ref={nextRef} disabled={!info.hasNext} onClick={() => onPage('next')}>Weiter</button> onPage={onPage}
</span> onRetry={onRetry}
)} onOpen={onOpen}
</div> reiterRef={reiterRef}
)} zurueckRef={prevRef}
weiterRef={nextRef}
/>
)}
</div>
{zeigeAkte ? <Akte eintrag={zeilen[akte]} index={akte} gesamt={zeilen.length} onClose={() => setAkte(null)} onBlaettern={onBlaettern} /> : null}
</div> </div>
) )
} }
+124
View File
@@ -0,0 +1,124 @@
// Die Akte zu einem Bot-Lead (Hustler-Pixelwelt T10, Spec E12/E13/E14): Dialog auf der Manila-Karte (9-slice hk-akte der Vorlage), Aufbau und Klassen wie
// keller/entwurf/BotLeads.dc.html mit hk- davor, die Inline-Stile der Vorlage stehen als Klassen in styles/hustler/hustler-keller-ui.css.
// Inhalt: Raum und Scout, Name, Ort, Punkte (Balken und scoreLabel), Grund mit Symbol und Erklärung (grundInfo), Telefon (Regel aus botleadRow: Heilberufe nie, ohne Quelle
// „nicht verfügbar“), Quelle als Link nur über row.sourceHref (botleadRow prüft mit safeHref: nur http und https, keine Zugangsdaten; neuer Tab ohne Rückbezug),
// gefunden am und, bei einem gekürzten Eintrag, „gekürzt“. Blättern ◂ ▸ innerhalb der Seite (im Kreis), Esc schließt, ←/→ blättern, Tab bleibt im Dialog.
// Fokus: beim Öffnen auf „Schließen“; beim Schließen zurück auf die Zeile des zuletzt gezeigten Eintrags ([data-hk-zeile="<Nr>"], die setzt das Buch), sonst auf das
// Element, das beim Öffnen den Fokus hatte (wenn es noch im Dokument steht). Alle Texte sind Textknoten, kein HTML-Einschub, kein CSS-Import, keine Bilder.
import { useEffect, useRef } from 'react'
// Props: eintrag (Ergebnis von eintragModell), index (Nr. in der Seite, ab 0), gesamt (Einträge der Seite), onClose(), onBlaettern(schritt: -1 | 1)
export default function Akte({ eintrag: e, index, gesamt, onClose, onBlaettern }) {
const ersterRef = useRef(null) // Knopf „Schließen“ (Fokus beim Öffnen, Anfang der Fokusfalle)
const letzterRef = useRef(null) // Knopf „Nächste Akte“ (Ende der Fokusfalle)
const sectionRef = useRef(null)
const hintenRef = useRef(null)
const openerRef = useRef(null)
const indexRef = useRef(index)
indexRef.current = index
// Öffnen: merken, wer den Fokus hatte, und den Fokus auf „Schließen“ setzen; Schließen (oder Abbau): Fokus zurückgeben
useEffect(() => {
const aktiv = typeof document !== 'undefined' ? document.activeElement : null
openerRef.current = aktiv && aktiv !== document.body ? aktiv : null
const erster = ersterRef.current
if (erster && typeof erster.focus === 'function') erster.focus({ preventScroll: true })
return () => {
const opener = openerRef.current
openerRef.current = null
let ziel = null
if (typeof document !== 'undefined' && typeof document.querySelector === 'function' && Number.isInteger(indexRef.current)) {
ziel = document.querySelector(`[data-hk-zeile="${indexRef.current}"]`)
}
if (!ziel && opener && opener.isConnected !== false) ziel = opener
if (ziel && typeof ziel.focus === 'function') ziel.focus()
}
}, [])
// Tasten (am äußeren Rahmen, damit sie auch gelten, wenn der Fokus auf dem Hintergrund oder der Karte selbst liegt):
// Esc schließt, ←/→ blättern, Tab wandert im Kreis zwischen „Schließen“ und „Nächste Akte“
const taste = (ev) => {
if (ev.key === 'Escape') {
ev.preventDefault()
ev.stopPropagation()
onClose()
return
}
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
if (ev.altKey || ev.ctrlKey || ev.metaKey) return // Alt+← ist „Zurück“ im Browser
ev.preventDefault()
ev.stopPropagation()
onBlaettern(ev.key === 'ArrowRight' ? 1 : -1)
return
}
if (ev.key !== 'Tab') return
const aktiv = typeof document !== 'undefined' ? document.activeElement : null
const erster = ersterRef.current
const letzter = letzterRef.current
if (!ev.shiftKey && aktiv === letzter && erster) {
ev.preventDefault()
erster.focus()
} else if (ev.shiftKey && letzter && (aktiv === erster || aktiv === sectionRef.current || aktiv === hintenRef.current || aktiv === ev.currentTarget)) {
ev.preventDefault()
letzter.focus()
}
}
const p = e.punkte
return (
<div className="hk-kl-akte-ueber" onKeyDown={taste}>
<button type="button" tabIndex={-1} className="hk-kl-akte-hinten" aria-label="Akte schließen" ref={hintenRef} onClick={() => onClose()} />
<section role="dialog" aria-modal="true" aria-label={`Akte ${e.title}`} tabIndex={-1} className="hk-akte hk-kl-akte" ref={sectionRef}>
<span className="hk-kl-sr" aria-live="polite">{`Akte ${index + 1} von ${gesamt}: ${e.title}`}</span>
<div className="hk-kl-akte-inhalt">
<div className="hk-kl-akte-kopf">
<div className="hk-kl-akte-titelbox">
<div className="hk-kl-akte-raum">
<span className={e.raumIcon} aria-hidden="true" />
{e.scout ? `${e.roomLabel} · gefunden von ${e.scout}` : e.roomLabel}
</div>
<h2 className="hk-kl-akte-name">{e.title}</h2>
{e.place ? <div className="hk-kl-akte-ort">{e.place}</div> : null}
</div>
<button type="button" className="hk-knopf hk-kl-knopf-icon" ref={ersterRef} aria-label="Schließen" onClick={() => onClose()}>
<span className="hk-ic hk-ic-schliessen" aria-hidden="true" />
</button>
</div>
<dl className="hk-kl-akte-dl">
<dt>Punkte</dt>
<dd className="hk-kl-akte-zeile">
<span className="hk-kl-balken hk-kl-balken-gross" aria-hidden="true"><span className={`hk-kl-balken-fuell hk-kl-farbe-${p.farbe}`} style={{ width: p.breiteGross }} /></span>
<span className="hk-kl-punkte-text">{e.scoreLabel}</span>
</dd>
<dt>Grund</dt>
<dd>
<span className="hk-kl-akte-zeile hk-kl-akte-grund"><span className={e.grundIcon} aria-hidden="true" /><span className="hk-kl-akte-grundtext">{e.reason}</span></span>
<span className="hk-kl-akte-erklaerung">{e.erklaerung}</span>
</dd>
<dt>Telefon</dt>
<dd className="hk-kl-akte-zeile"><span className={e.telefonIcon} aria-hidden="true" />{e.phone}</dd>
<dt>Quelle</dt>
<dd>
{e.sourceHref
? <a className="hk-akte-link" href={e.sourceHref} target="_blank" rel="noopener noreferrer" aria-label={`Quelle öffnen: ${e.title}`}><span className="hk-ic hk-ic-quelle" aria-hidden="true" />Quelle öffnen</a>
: '–'}
</dd>
<dt>Gefunden</dt>
<dd className="hk-kl-akte-zeile"><span className="hk-ic hk-ic-kalender" aria-hidden="true" />{e.found}</dd>
{e.gekuerzt ? <dt>Kürzung</dt> : null}
{e.gekuerzt ? <dd className="hk-kl-akte-zeile"><span className="hk-ic hk-ic-schere" aria-hidden="true" />gekürzt</dd> : null}
</dl>
<div className="hk-kl-akte-fuss">
<button type="button" className="hk-knopf hk-kl-knopf-icon" aria-label="Vorherige Akte" onClick={() => onBlaettern(-1)}>
<span className="hk-ic hk-ic-pfeil-links" aria-hidden="true" />
</button>
<span className="hk-kl-akte-hinweis">Nur zum Nachsehen, hier wird nichts verschickt.</span>
<button type="button" className="hk-knopf hk-kl-knopf-icon" ref={letzterRef} aria-label="Nächste Akte" onClick={() => onBlaettern(1)}>
<span className="hk-ic hk-ic-pfeil-rechts" aria-hidden="true" />
</button>
</div>
</div>
</section>
</div>
)
}
@@ -0,0 +1,74 @@
// Die Szene des Kellers (Hustler-Pixelwelt T10, Spec E12/E13): der Archiv-Raum aus keller/entwurf/BotLeads.dc.html, Klassen und Inline-Geometrie 1:1 mit hk- davor
// (Lage der Dinge: src/lib/hustlerKeller.js). Keller-Raum, Licht mit Flackern, Staub, Schnee im Fenster mit Passant, Maus, Blinkpunkte, Einpassen „cover“ links
// verankert (ResizeObserver, wie die Vorlage).
// Arne hängt am Status des Hustlers (arneAnsicht über figurZustand): bei „arbeitet“ läuft seine Ablauf-Schleife samt Rohrpost, Stempel und Schublade; sonst steht oder
// sitzt er als Haus-Figur der passenden Pose (warten, schlafen, nicht) an seinem Pult und die drei ruhen; bei „fehler“ trägt er den roten Marker hk-alarm.
// Die ganze Szene ist Zierde (aria-hidden). Kein CSS-Import (WorkersPage hängt keller.css und hustler-keller-ui.css ein), keine Bilder, kein HTML-Einschub.
import { useEffect, useRef, useState } from 'react'
import { ARNE_RUHE, BUCH_STEMPEL, DAMPF, FENSTER, LEDS, MAUS_STREIFEN, ROHRPOST, SCHUBLADE, arneAnsicht, kellerEinpassen } from '../../../lib/hustlerKeller'
// Props: workers = Hustler-Tabelle der Seite (Zustandseinträge nach workerId, aus indexWorkers/reduceEvents)
export default function KellerSzene({ workers }) {
const rahmenRef = useRef(null)
const [fit, setFit] = useState(() => kellerEinpassen(0, 0))
const arne = arneAnsicht(workers)
// Einpassen: bei jeder Größenänderung des Rahmens neu (ohne Messung bleibt die Vorgabe der Vorlage, Maßstab 2)
useEffect(() => {
const el = rahmenRef.current
if (!el || typeof el.getBoundingClientRect !== 'function') return undefined
const passe = () => {
const r = el.getBoundingClientRect()
if (!(r.width > 0 && r.height > 0)) return
const next = kellerEinpassen(r.width, r.height)
setFit((prev) => (prev.s === next.s && prev.x === next.x && prev.y === next.y ? prev : next))
}
passe()
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(passe)
ro.observe(el)
return () => ro.disconnect()
}
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('resize', passe)
return () => window.removeEventListener('resize', passe)
}
return undefined
}, [])
const ruht = arne.arbeitet ? '' : ' hk-kl-ruht'
return (
<div className="hk-kl-szene" aria-hidden="true" ref={rahmenRef} data-hk-arne={arne.status}>
<div className="hk-kl-buehne" style={{ transform: `translate(${fit.x.toFixed(1)}px, ${fit.y.toFixed(1)}px) scale(${fit.s.toFixed(4)})` }}>
<div className="hk-sz-ebene hk-sz-bild" />
<div style={FENSTER}>
<div className="hk-sz-fensterschnee" />
<div className="hk-sz-passant-weg"><span className="hk-spr hk-sz-passant" /></div>
<div className="hk-sz-kreuz" />
</div>
<span className="hk-spr hk-sz-dampf" style={DAMPF} />
<span className={`hk-spr hk-sz-buch-stempel${ruht}`} style={BUCH_STEMPEL} />
<span className={`hk-spr hk-sz-rohrpost${ruht}`} style={ROHRPOST} />
<span className={`hk-spr hk-sz-schublade${ruht}`} style={SCHUBLADE} />
{arne.arbeitet ? (
<div className="hk-arne-weg"><span className="hk-arne-bild" /></div>
) : (
<span className="hk-kl-arne" style={ARNE_RUHE}>
<span className={`hk-spr hk-kl-fig hk-kl-fig-${arne.pose}`} />
{arne.punkte ? <span className="hk-spr hk-kl-punkte" /> : null}
{arne.zzz ? <span className="hk-spr hk-kl-zzz" /> : null}
{arne.alarm ? <span className="hk-alarm">!</span> : null}
</span>
)}
<div style={MAUS_STREIFEN}><div className="hk-sz-maus-weg"><span className="hk-spr hk-sz-maus" /></div></div>
{LEDS.map((l) => <span key={l.name} className="hk-sz-led" style={l.stil} />)}
<div className="hk-sz-ebene hk-sz-licht-tisch" />
<div className="hk-sz-ebene hk-sz-licht-92" />
<div className="hk-sz-ebene hk-sz-licht-236" />
<div className="hk-sz-ebene hk-sz-licht-halle" />
<div className="hk-sz-ebene hk-sz-staub" />
</div>
</div>
)
}
@@ -0,0 +1,152 @@
// Das Register-Buch des Kellers (Hustler-Pixelwelt T10, Spec E12/E13): Raum-Reiter, das aufgeschlagene Buch mit Titel, Hinweis, Suchfeld (oder, bei einem einzelnen
// Eintrag, „Alle anzeigen“), Kopfzeile, den Einträgen der Dienst-Seite (im Buch scrollbar), Leer-, Lade- und Fehlerzustand und der Seitenzeile mit ◂ ▸.
// Aufbau und Klassen wie keller/entwurf/BotLeads.dc.html mit hk- davor; die Inline-Stile der Vorlage stehen als Klassen in styles/hustler/hustler-keller-ui.css.
// Rein darstellend: Abruf, Suche, Seiten und Fokus gehören BotLeadList. Alle Texte der Zeilen sind Textknoten; Links gibt es hier nicht (der Link zur Quelle steht in der
// Akte und kommt nur aus row.sourceHref). Kein CSS-Import, keine Bilder, kein HTML-Einschub.
import { REITER } from '../../../lib/hustlerKeller'
import { formatCount } from '../../../lib/workersView'
const SEARCH_MAX = 100 // der Dienst kürzt die Suche auf 100 Zeichen
// Eine Zeile des Buchs: ein Knopf, der die Akte öffnet. Spalten: Raum-Symbol, Name (+ „gekürzt“) mit Ort und Funddatum, Punkte, Grund mit Symbol, Telefon.
function Zeile({ z, nr, aktuell, onOpen }) {
return (
<li>
<button
type="button"
className={z.gekuerzt ? 'hk-zeile hk-zeile-alt' : 'hk-zeile'}
data-hk-zeile={nr}
aria-label={z.label}
aria-current={aktuell ? 'true' : undefined}
onClick={(ev) => onOpen(nr, ev)}
>
<span className={z.raumIcon} aria-hidden="true" />
<span className="hk-kl-namebox">
<span className="hk-kl-namezeile">
<span className="hk-zelle-text hk-kl-name" title={z.title}>{z.title}</span>
{z.gekuerzt ? <span className="hk-kl-tag"><span className="hk-ic hk-ic-schere hk-kl-tag-icon" aria-hidden="true" />gekürzt</span> : null}
</span>
<span className="hk-klein">{[z.place, z.found].filter((t) => t !== '').join(' · ')}</span>
</span>
<span className="hk-zelle hk-kl-spalte-punkte">
<span className="hk-kl-balken" aria-hidden="true"><span className={`hk-kl-balken-fuell hk-kl-farbe-${z.punkte.farbe}`} style={{ width: z.punkte.breite }} /></span>
<span className="hk-kl-punkte-text">{z.punkte.text}</span>
</span>
<span className="hk-zelle"><span className={z.grundIcon} aria-hidden="true" /><span className="hk-zwei-zeilen">{z.reason}</span></span>
<span className="hk-zelle hk-kl-spalte-telefon"><span className={z.telefonIcon} aria-hidden="true" /><span className="hk-zwei-zeilen">{z.phone}</span></span>
</button>
</li>
)
}
// Props:
// einzeln (Einzelansicht über ?bot=), botId, room (gewählter Raum-Schlüssel), draft (Inhalt des Suchfelds), filtered (Raum oder Suche gesetzt)
// laden, fehler (Satz des Dienstes oder null), zeilen (eintragModell je Eintrag der Seite), total, info (pageInfo)
// onRoom(value), onSearch(ev), onClear(), onPage('prev' | 'next'), onRetry(), onOpen(nr, ev)
// reiterRef (am aktiven Reiter), zurueckRef, weiterRef (Blätterknöpfe): Fokus-Ziele von BotLeadList
export default function RegisterBuch({ einzeln, botId, room, draft, filtered, laden, fehler, zeilen, total, info, onRoom, onSearch, onClear, onPage, onRetry, onOpen, reiterRef, zurueckRef, weiterRef }) {
const zahl = laden || fehler ? null : total // die Zahl steht nur am aktiven Reiter und erst, wenn die Antwort da ist
let koerper
if (laden) {
koerper = <p className="hk-kl-laden" role="status">Wird geladen …</p>
} else if (fehler) {
koerper = (
<div className="hk-kl-fehlerbox">
<p className="hk-kl-fehler" role="alert">{fehler}</p>
<button type="button" className="hk-knopf hk-kl-knopf-text" onClick={() => onRetry()}>Erneut versuchen</button>
</div>
)
} else if (zeilen.length === 0) {
const leer = einzeln
? ['Dieser Eintrag ist nicht (mehr) in der Liste.', 'Er wurde gekürzt oder ist kein Bot-Lead.']
: filtered
? ['Keine Treffer.', 'Für diesen Raum und diese Suche gibt es keine Bot-Leads.']
: ['Noch keine Bot-Leads.', 'Betriebe, die nicht angeschrieben werden, erscheinen hier mit ihrem Grund.']
koerper = (
<div className="hk-kl-leer">
<p className="hk-kl-leer-titel">{leer[0]}</p>
<p className="hk-kl-leer-text">{leer[1]}</p>
</div>
)
} else {
koerper = (
<ol className="hk-liste" aria-label="Bot-Leads">
{zeilen.map((z, i) => <Zeile key={z.id || `ohne-id-${i}`} z={z} nr={i} aktuell={einzeln && z.id === botId} onOpen={onOpen} />)}
</ol>
)
}
const mehrere = info.pages > 1
return (
<>
{einzeln ? null : (
<div className="hk-kl-reiter" role="group" aria-label="Räume filtern">
{REITER.map((r) => {
const aktiv = r.value === room
const mitZahl = aktiv && zahl !== null
return (
<button
key={r.value || 'alle'}
type="button"
className={aktiv ? `${r.klasse} hk-reiter-an` : r.klasse}
aria-pressed={aktiv}
aria-label={mitZahl ? `${r.label}, ${formatCount(zahl)} ${zahl === 1 ? 'Eintrag' : 'Einträge'}` : r.label}
ref={aktiv ? reiterRef : undefined}
onClick={() => onRoom(r.value)}
>
<span className={r.icon} aria-hidden="true" />
<span className="hk-zelle-text">{r.kurz}</span>
{mitZahl ? <span className="hk-kl-reiter-zahl">{formatCount(zahl)}</span> : null}
</button>
)
})}
</div>
)}
<section className="hk-buch hk-kl-buch" aria-label="Nie-doppelt-Register">
<div className="hk-kl-buchkopf">
<div className="hk-kl-buchtitelbox">
<h2 className="hk-kl-buchtitel">Nie-doppelt-Register</h2>
<p className="hk-kl-hinweis">Reine Liste: keine Anrufe, kein Mailversand.</p>
</div>
{einzeln ? (
<div className="hk-kl-einzeln">
<span className="hk-kl-einzeln-text">Einzelner Eintrag aus dem Ereignisstrom</span>
<button type="button" className="hk-knopf hk-kl-knopf-text" onClick={() => onClear()}>Alle anzeigen</button>
</div>
) : (
<div className="hk-kl-suchfeld">
<span className="hk-ic hk-ic-lupe hk-kl-lupe" aria-hidden="true" />
<input className="hk-suche" type="search" aria-label="Betriebe suchen" placeholder="Name, Ort oder Grund" maxLength={SEARCH_MAX} value={draft} onChange={onSearch} />
</div>
)}
</div>
{laden || fehler || zeilen.length === 0 ? null : (
<div className="hk-kopfzeile" aria-hidden="true">
<span />
<span>Betrieb · Ort · gefunden</span>
<span className="hk-kl-spalte-punkte">Punkte</span>
<span>Grund</span>
<span className="hk-kl-spalte-telefon">Telefon</span>
</div>
)}
{koerper}
{einzeln || laden || fehler || total <= 0 ? null : (
<div className="hk-kl-fuss">
{mehrere ? (
<button type="button" className="hk-knopf hk-kl-knopf-icon" ref={zurueckRef} aria-label="Vorherige Seite" disabled={!info.hasPrev} onClick={() => onPage('prev')}>
<span className="hk-ic hk-ic-pfeil-links" aria-hidden="true" />
</button>
) : null}
<span className="hk-kl-seiten" aria-live="polite">{info.label}</span>
{mehrere ? (
<button type="button" className="hk-knopf hk-kl-knopf-icon" ref={weiterRef} aria-label="Nächste Seite" disabled={!info.hasNext} onClick={() => onPage('next')}>
<span className="hk-ic hk-ic-pfeil-rechts" aria-hidden="true" />
</button>
) : null}
</div>
)}
</section>
</>
)
}
+53
View File
@@ -0,0 +1,53 @@
// Gründe der Bot-Lead-Liste (Spec E12): Grund-Text des Dienstes -> Symbol und Erklärung für die Akte des Kellers.
// Die Texte sind die festen Grundtexte GRUND des Dienstes (src/scoring/business.js, Stand d0d66f9); Fixture tests/fixtures/botlead-gruende.json.
// Symbole und Erklärungen stammen wörtlich aus der GRUENDE-Tabelle der Zeichenfläche (keller/entwurf/logik.js), die Erklärung für „Lauf unterbrochen“ ist neu im
// selben Ton. Unbekannter Text (der Dienst bekommt einen neuen Grund) ergibt ein neutrales Symbol und einen allgemeinen Satz, nie einen Fehler.
// Kein React, kein DOM: unter Node testbar (loadModule). Die Symbol-Klassen stehen in src/styles/hustler/keller.css (hk-ic-<symbol>).
const GRENZE_MUSTER = /^Punktzahl unter (\d{1,3})$/
// Grund-Text -> { symbol, erklaerung }; „Punktzahl unter N“ steht in grundInfo (die Grenze steht fest im Code des Dienstes, MIN_SCORE_MAIL)
const TABELLE = Object.freeze({
'keine veröffentlichte Mail': Object.freeze({ symbol: 'brief-x', erklaerung: 'Auf der Website steht keine E-Mail-Adresse. Ohne veröffentlichte Adresse schreibt der Hustler nie.' }),
'veraltet (nicht ausgewählt)': Object.freeze({ symbol: 'uhr', erklaerung: 'Die Website ist veraltet, kam aber nicht unter die Top 3 der Nacht.' }),
'Website in gutem Zustand': Object.freeze({ symbol: 'haken', erklaerung: 'Die Website ist aktuell genug. Es gibt keinen Anlass zu schreiben.' }),
'Kette oder Franchise': Object.freeze({ symbol: 'kette', erklaerung: 'Gehört zu einer Kette; die Website wird zentral gemacht.' }),
'schon bekannt (E-Mail oder Telefon)': Object.freeze({ symbol: 'doppelt', erklaerung: 'Steht schon im Nie-doppelt-Register.' }),
'schon bekannt (Name und PLZ)': Object.freeze({ symbol: 'doppelt', erklaerung: 'Name und Postleitzahl sind schon im Register.' }),
'Impressum passt nicht zum Betrieb': Object.freeze({ symbol: 'fremd', erklaerung: 'Das Impressum gehört zu jemand anderem, zum Beispiel einem Vereins- oder Stadtportal.' }),
'Name fehlt': Object.freeze({ symbol: 'etikett-leer', erklaerung: 'Auf der Website war kein brauchbarer Firmenname zu finden.' }),
'Adresse nicht zustellbar': Object.freeze({ symbol: 'brief-zurueck', erklaerung: 'Die Mail-Domain hat keinen Mailserver.' }),
'Lesen fehlgeschlagen': Object.freeze({ symbol: 'seite-kaputt', erklaerung: 'Die Website ließ sich nicht lesen.' }),
Sperrliste: Object.freeze({ symbol: 'stopp', erklaerung: 'Hat widersprochen oder steht auf der Sperrliste.' }),
'Bewertung ausgefallen': Object.freeze({ symbol: 'waage-kaputt', erklaerung: 'Bea konnte den Betrieb nicht bewerten.' }),
'Lauf unterbrochen': Object.freeze({ symbol: 'pause', erklaerung: 'Der Suchlauf wurde unterbrochen, bevor der Betrieb bewertet war. Er bekommt keine Mail.' }),
})
/** Symbol und Satz für einen Grund, den die Liste nicht kennt. */
export const ALLGEMEIN = Object.freeze({ symbol: 'akte', erklaerung: 'Der Hustler schreibt diesem Betrieb nicht. Für diesen Grund gibt es noch keine genauere Erklärung.' })
/** Alle Symbole, die grundInfo liefern kann (jedes hat eine Klasse hk-ic-<symbol> in keller.css). */
export const GRUND_SYMBOLE = Object.freeze([...new Set([...Object.values(TABELLE).map((g) => g.symbol), 'pfeil-runter', ALLGEMEIN.symbol])])
const schwelleErklaerung = (grenze) => `Zu wenige Punkte für eine Mail (Grenze ${grenze}).`
/** Ist der Grund-Text einer der Texte des Dienstes? (Die Punktzahl-Grenze zählt mit jeder Zahl.) */
export function grundBekannt(reasonText) {
const t = typeof reasonText === 'string' ? reasonText.trim() : ''
return Object.hasOwn(TABELLE, t) || GRENZE_MUSTER.test(t)
}
/**
* Anzeige eines Grundes: icon = Klassen des Symbols („hk-ic hk-ic-<symbol>“), erklaerung = ein Satz.
* Nimmt den Text so, wie der Dienst ihn liefert (Leerraum am Rand zählt nicht); alles andere ergibt das neutrale Symbol mit dem allgemeinen Satz.
*/
export function grundInfo(reasonText) {
const t = typeof reasonText === 'string' ? reasonText.trim() : ''
if (Object.hasOwn(TABELLE, t)) {
const g = TABELLE[t]
return { icon: `hk-ic hk-ic-${g.symbol}`, erklaerung: g.erklaerung }
}
const m = GRENZE_MUSTER.exec(t)
if (m) return { icon: 'hk-ic hk-ic-pfeil-runter', erklaerung: schwelleErklaerung(m[1]) }
return { icon: `hk-ic hk-ic-${ALLGEMEIN.symbol}`, erklaerung: ALLGEMEIN.erklaerung }
}
+147
View File
@@ -0,0 +1,147 @@
// Reine Daten und Ableitungen des Kellers (Hustler-Pixelwelt T10, Spec E12/E13): Lage der Dinge in der 640 x 360 großen Szene (aus dem Markup von
// keller/entwurf/BotLeads.dc.html), Einpassen der Szene („cover“), Arne nach Status, Raum-Reiter, Zeilenmodell der Bot-Leads, Scout je Raum.
// Kein React, kein DOM, kein Appwrite: unter Node testbar (loadModule). Beispieldaten gibt es hier nicht; alle Werte kommen aus dem Dienst.
import { ROOM_FILTERS, botleadRow } from './botleadsView.js'
import { grundInfo } from './botleadGruende.js'
import { figurZustand } from './hustlerSicht.js'
import { ROLES } from './workersRoster.js'
// ---------------------------------------------------------------------------------------------------------------
// Szene
/** Größe der Szene in Kunstpixeln (keller.png) */
export const SZENE = Object.freeze({ breite: 640, hoehe: 360 })
/**
* Szene in den Rahmen einpassen wie die Vorlage (object-fit „cover“, links verankert): s = Maßstab, x/y = Verschiebung in Bildschirmpixeln.
* Ohne brauchbare Größe (Rahmen noch nicht gemessen) gilt der Standard der Vorlage: Maßstab 2, keine Verschiebung.
*/
export function kellerEinpassen(breite, hoehe) {
if (!(Number.isFinite(breite) && Number.isFinite(hoehe) && breite > 0 && hoehe > 0)) return { s: 2, x: 0, y: 0 }
const s = Math.max(breite / SZENE.breite, hoehe / SZENE.hoehe)
const ueber = hoehe - SZENE.hoehe * s
return { s, x: Math.min(0, (breite - SZENE.breite * s) * 0.15), y: Math.min(ueber / 2, ueber + 36 * s) }
}
const px = (n) => `${n}px`
/** Fenster mit Schnee, Passant und Kreuz (Glas 46 x 29 bei 23/34) */
export const FENSTER = Object.freeze({ position: 'absolute', left: px(23), top: px(34), width: px(46), height: px(29), overflow: 'hidden' })
/** Dampf über der Tasse */
export const DAMPF = Object.freeze({ position: 'absolute', left: px(26), top: px(247) })
/** Stempelabdruck im Register */
export const BUCH_STEMPEL = Object.freeze({ position: 'absolute', left: px(60), top: px(252) })
/** Rohrpost (Mündung bei 150/232, Sprite 40 x 48 links oben bei 130/224) */
export const ROHRPOST = Object.freeze({ position: 'absolute', left: px(130), top: px(224) })
/** Schublade des Aktenschranks (dritte, unterste breite Schublade) */
export const SCHUBLADE = Object.freeze({ position: 'absolute', left: px(214), top: px(279) })
/** Bodenstreifen, in dem die Maus läuft (Ausschnitt 238 x 24) */
export const MAUS_STREIFEN = Object.freeze({ position: 'absolute', left: px(30), top: px(336), width: px(238), height: px(24), overflow: 'hidden' })
/** Arnes Platz am Pult, wenn er nicht arbeitet: Füße auf der Laufline (318) beim Halt am Pult (128), Sprite 24 x 32 */
export const ARNE_RUHE = Object.freeze({ position: 'absolute', left: px(128 - 12), top: px(318 - 32) })
// Blinkpunkte (Lage, Größe, Aus-Farbe, Animation): Keyframes hk-k-k-led-… stehen in keller.css
const led = (name, left, top, breite, aus, dauer) => Object.freeze({
name,
stil: Object.freeze({ left: px(left), top: px(top), width: px(breite), height: px(2), background: aus, animation: `hk-k-k-led-${name} ${dauer}ms steps(1,end) infinite` }),
})
export const LEDS = Object.freeze([
led('weiche-gruen', 146, 57, 2, '#1f5a3a', 1800),
led('weiche-bernstein', 153, 57, 2, '#6a4a1a', 2600),
led('sicherung-1', 161, 125, 1, '#1f5a3a', 3000),
led('sicherung-2', 163, 125, 1, '#6a4a1a', 2200),
led('sicherung-3', 165, 125, 1, '#1f5a3a', 3400),
led('verteiler', 203, 33, 2, '#6a2a1a', 2000),
])
// ---------------------------------------------------------------------------------------------------------------
// Arne
/**
* Was die Szene von Arne zeigt (Spec E12): bei „arbeitet“ die Ablauf-Schleife mit Rohrpost, Stempel und Schublade, sonst steht oder sitzt er als Haus-Figur der
* passenden Pose (warten, schlafen, nicht) am Pult und die drei ruhen; bei „fehler“ trägt er den roten Marker. workers = Hustler-Tabelle der Seite.
*/
export function arneAnsicht(workers) {
const f = figurZustand('arne', workers && typeof workers === 'object' ? workers.arne : undefined)
return {
status: f.status,
arbeitet: f.status === 'arbeitet',
pose: f.pose,
alarm: f.alarm,
punkte: f.punkte,
zzz: f.zzz,
label: f.label,
}
}
// ---------------------------------------------------------------------------------------------------------------
// Register-Buch
const RAUM_KURZ = Object.freeze({ '': 'Alle', aerzte: 'Ärzte', handwerk: 'Handwerk', selbst: 'Selbstständige', laden: 'Läden' })
const RAUM_SYMBOL = Object.freeze({ '': 'alle', aerzte: 'raum-aerzte', handwerk: 'raum-handwerk', selbst: 'raum-selbst', laden: 'raum-laden' })
/** Raum-Reiter in der Reihenfolge von ROOM_FILTERS: value, label (voll, für Screenreader), kurz (sichtbar), icon und klasse (Farbe des Reiters). */
export const REITER = Object.freeze(ROOM_FILTERS.map((f) => Object.freeze({
value: f.value,
label: f.label,
kurz: RAUM_KURZ[f.value] ?? f.label,
icon: `hk-ic hk-ic-${RAUM_SYMBOL[f.value] ?? 'alle'}`,
klasse: `hk-reiter hk-reiter-${f.value === '' ? 'alle' : f.value}`,
})))
/** Symbol-Klassen des Raums einer Zeile; ein unbekannter Raum zeigt den Aktenstapel. */
export function raumIcon(room) {
const symbol = typeof room === 'string' && Object.hasOwn(RAUM_SYMBOL, room) && room !== '' ? RAUM_SYMBOL[room] : 'alle'
return `hk-ic hk-ic-${symbol}`
}
/** Hustler, der den Raum scoutet (Roster: Etage „Ohne Website“, Raum aerzte → Ali, handwerk → Hanna, selbst → Sven, laden → Lea), sonst ''. */
export function scoutFuer(room) {
const rolle = ROLES.find((r) => r.floor === 'ohne_website' && r.room === room)
return rolle ? rolle.name : ''
}
// Punkte-Balken: Farbe nach Punktzahl (ab 75 grün, ab 55 gelb, darunter rot), ohne Punktzahl grau
const farbeVon = (punkte) => (punkte === null ? 'leer' : punkte >= 75 ? 'gut' : punkte >= 55 ? 'mittel' : 'schlecht')
/** Punkte-Anzeige: text (Zahl oder „–“), breite (Füllung in px bei 48 px Balken), breiteGross (96 px, Akte), farbe (gut | mittel | schlecht | leer). */
export function punkteInfo(score) {
const punkte = Number.isFinite(score) ? Math.round(score) : null
const balken = punkte === null ? 0 : Math.min(100, Math.max(0, punkte)) // die Füllung bleibt im Balken, der Text nennt die Zahl des Dienstes
return {
text: punkte === null ? '–' : String(punkte),
breite: Math.round(balken * 0.48),
breiteGross: Math.round(balken * 0.96),
farbe: farbeVon(punkte),
}
}
/** Symbol-Klassen der Telefon-Zeile: Schloss bei Heilberufen („bewusst nicht gespeichert“), Schere bei gekürzten Einträgen, sonst Telefon. */
function telefonIcon(item, gekuerzt) {
const heil = Boolean(item && typeof item === 'object' && item.isHealth)
return `hk-ic hk-ic-${heil ? 'schloss' : gekuerzt ? 'schere' : 'telefon'}`
}
/**
* Alles, was eine Zeile des Buchs und die Akte zu einem Eintrag des Dienstes brauchen: das Zeilenmodell aus botleadRow (Telefonregel, sichere Quelle, Datum)
* plus Raum-Symbol, Scout, Punkte-Anzeige, Grund mit Symbol und Erklärung und die Beschriftung für Screenreader.
*/
export function eintragModell(item) {
const it = item && typeof item === 'object' ? item : {}
const row = botleadRow(it)
const raum = typeof it.room === 'string' ? it.room : ''
const grund = grundInfo(row.reason)
const punkte = punkteInfo(it.score)
return {
...row,
raum,
raumIcon: raumIcon(raum),
scout: scoutFuer(raum),
punkte,
grundIcon: grund.icon,
erklaerung: grund.erklaerung,
telefonIcon: telefonIcon(it, row.gekuerzt),
label: [row.title + (row.gekuerzt ? ' (gekürzt)' : ''), row.place, row.scoreLabel, row.reason].filter((t) => t !== '').join(', ') + `, gefunden am ${row.found}`,
}
}
+142
View File
@@ -0,0 +1,142 @@
/* Keller (BotLeadList.jsx und keller/*, Hustler-Pixelwelt T10, Spec E12/E13/E14). Handgeschrieben, nicht erzeugt: die Inline-Stile des Artboards BotLeads.dc.html stehen
hier als Klassen. Szene, Reiter, Buch, Zeile, Knöpfe, Akte-Karte und Symbole (hk-sz-*, hk-reiter, hk-buch, hk-zeile, hk-knopf, hk-akte, hk-ic-*) kommen aus dem
erzeugten keller.css, hk-alarm aus hustler-ui.css. Alle Klassen beginnen mit hk-kl- (eigene) und stehen unter der Wurzel .hk-keller; Keyframes heißen hk-kl-….
Überschreibt eine Regel eine erzeugte, steht ein zweiter Klassenname davor, damit die Reihenfolge der Stylesheets keine Rolle spielt.
Die Stylesheets hängt WorkersPage ein (Komponenten importieren kein CSS): keller.css, dann diese Datei. */
/* ---------- Wurzel: füllt die Seite (neben der WOMS-Seitenleiste), kein Seiten-Scrollen ---------- */
.hk-keller { position: relative; width: 100%; height: 100vh; height: 100dvh; overflow: hidden; color: #2b2230; font-size: 15px; line-height: 20px; }
.hk-seite > .hk-keller { position: absolute; inset: 0; width: auto; height: auto; }
/* ---------- Szene: 640 x 360, per transform eingepasst (cover), dahinter nur Zierde ---------- */
.hk-keller .hk-kl-szene { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.hk-keller .hk-kl-buehne { position: absolute; left: 0; top: 0; width: 640px; height: 360px; transform-origin: 0 0; image-rendering: pixelated; pointer-events: none; }
.hk-keller .hk-kl-vignette {
position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(ellipse 80% 75% at 30% 52%, rgba(10, 12, 28, 0) 45%, rgba(10, 12, 28, 0.45) 78%, rgba(6, 7, 18, 0.82) 100%);
}
.hk-keller .hk-kl-dunkel { display: none; }
/* Arne, wenn er nicht arbeitet: Haus-Figur am Pult (24 x 32), Pose nach Status; Rohrpost, Stempel und Schublade stehen auf dem Ruhebild */
.hk-keller .hk-kl-arne { position: absolute; width: 24px; height: 32px; }
.hk-keller .hk-kl-fig { position: absolute; left: 0; top: 0; width: 24px; height: 32px; }
.hk-keller .hk-kl-fig-warten { background-image: url(../../assets/hustler/figuren/muetze-warten.png); animation: hk-kl-k24x6 2400ms steps(6) infinite; }
.hk-keller .hk-kl-fig-schlafen { background-image: url(../../assets/hustler/figuren/muetze-schlafen.png); animation: hk-kl-k24x4 2000ms steps(4) infinite; }
.hk-keller .hk-kl-fig-nicht { background-image: url(../../assets/hustler/figuren/muetze-nicht.png); animation: hk-kl-nicht 3000ms steps(1, end) infinite; }
.hk-keller .hk-kl-punkte { position: absolute; left: 14px; top: -6px; width: 12px; height: 8px; background-image: url(../../assets/hustler/figuren/punkte.png); animation: hk-kl-k12x3 1200ms steps(3) infinite; }
.hk-keller .hk-kl-zzz { position: absolute; left: 15px; top: -11px; width: 12px; height: 16px; background-image: url(../../assets/hustler/figuren/zzz.png); animation: hk-kl-k12x4 1600ms steps(4) infinite; }
.hk-keller .hk-sz-rohrpost.hk-kl-ruht { animation: none; background-position: -360px 0; }
.hk-keller .hk-sz-schublade.hk-kl-ruht { animation: none; background-position: 0 0; }
.hk-keller .hk-sz-buch-stempel.hk-kl-ruht { animation: none; background-position: 0 0; }
@keyframes hk-kl-k24x4 { to { background-position: -96px 0; } }
@keyframes hk-kl-k24x6 { to { background-position: -144px 0; } }
@keyframes hk-kl-nicht { 0% { background-position: 0 0; } 90% { background-position: -24px 0; } 100% { background-position: -24px 0; } }
@keyframes hk-kl-k12x3 { to { background-position: -36px 0; } }
@keyframes hk-kl-k12x4 { to { background-position: -48px 0; } }
/* ---------- Oberfläche: rechts, Buch und Kopfzeile ---------- */
.hk-keller .hk-kl-ui { position: absolute; top: 14px; right: 16px; bottom: 14px; width: min(716px, calc(100% - 32px)); display: flex; flex-direction: column; }
.hk-keller .hk-kl-kopf { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin-bottom: 6px; }
.hk-keller .hk-knopf.hk-kl-knopf-klein { padding: 0 10px 4px 4px; }
.hk-keller .hk-knopf.hk-kl-knopf-icon { padding-bottom: 4px; }
.hk-keller .hk-knopf.hk-kl-knopf-text { padding: 0 12px 4px; }
.hk-keller .hk-knopf:disabled { opacity: 0.45; cursor: default; }
.hk-keller .hk-knopf.hk-kl-zuklappen { margin-left: auto; }
.hk-keller .hk-kl-schild { display: flex; align-items: center; gap: 10px; padding: 0 6px; color: #3a2410; }
.hk-keller .hk-kl-titel, .hk-keller .hk-kl-zahl { margin: 0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 16px; font-weight: 400; color: #3a2410; }
/* Raum-Reiter: sitzen oberhalb des Buchs, die inaktiven ragen 8 px hinter das Buch */
.hk-keller .hk-kl-reiter { position: relative; z-index: 1; flex: none; display: flex; flex-wrap: nowrap; gap: 6px; padding: 0 0 0 22px; min-width: 0; }
.hk-keller .hk-kl-reiter-zahl { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 8px; line-height: 8px; color: #5a4a3a; }
/* Buch: Kopf mit Titel, Hinweis und Suche, Kopfzeile, Liste, Fuß mit Seitenzeile */
.hk-keller .hk-kl-buch { position: relative; z-index: 2; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: 6px; }
.hk-keller .hk-kl-buchkopf { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 6px 16px; padding: 0 4px 6px; border-bottom: 2px solid rgba(122, 46, 58, 0.35); }
.hk-keller .hk-kl-buchtitelbox { flex: 1 1 220px; min-width: 0; }
.hk-keller .hk-kl-buchtitel { margin: 0; font-size: 17px; line-height: 20px; font-weight: 700; color: #7a2e3a; }
.hk-keller .hk-kl-hinweis { margin: 2px 0 0; font-size: 13px; line-height: 16px; color: #5a4a3a; }
.hk-keller .hk-kl-suchfeld { position: relative; display: block; flex: 0 1 250px; min-width: 180px; }
.hk-keller .hk-kl-lupe { position: absolute; left: 4px; top: 2px; pointer-events: none; }
.hk-keller input.hk-suche { -webkit-appearance: none; appearance: none; border-radius: 0; }
.hk-keller .hk-kl-einzeln { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin-left: auto; }
.hk-keller .hk-kl-einzeln-text { font-size: 13px; line-height: 16px; color: #5a4a3a; }
/* Zeile: Name mit Ort und Funddatum, „gekürzt“, Punkte-Balken */
.hk-keller .hk-kl-namebox { min-width: 0; }
.hk-keller .hk-kl-namezeile { display: flex; align-items: center; gap: 4px; min-width: 0; height: 17px; }
.hk-keller .hk-kl-name { font-size: 15px; line-height: 17px; }
.hk-keller .hk-kl-tag { flex: none; display: inline-flex; align-items: center; gap: 2px; font-size: 11px; line-height: 14px; color: #6b5f58; }
.hk-keller .hk-ic.hk-kl-tag-icon { width: 16px; height: 16px; background-size: auto 16px; }
.hk-keller .hk-kl-balken { position: relative; flex: none; width: 48px; height: 10px; background: #e8dcc0; box-shadow: 0 0 0 2px #2b2230; }
.hk-keller .hk-kl-balken.hk-kl-balken-gross { width: 96px; height: 12px; background: #f3ecd8; }
.hk-keller .hk-kl-balken-fuell { position: absolute; left: 0; top: 0; bottom: 0; }
.hk-keller .hk-kl-farbe-gut { background: #3f9a57; }
.hk-keller .hk-kl-farbe-mittel { background: #c9962a; }
.hk-keller .hk-kl-farbe-schlecht { background: #b8452f; }
.hk-keller .hk-kl-farbe-leer { background: #9a8f84; }
.hk-keller .hk-kl-punkte-text { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 8px; line-height: 8px; }
/* Laden, Leer, Fehler */
.hk-keller .hk-kl-laden, .hk-keller .hk-kl-leer, .hk-keller .hk-kl-fehlerbox { margin: 8px; color: #5a4a3a; }
.hk-keller .hk-kl-laden { margin-top: 8px; margin-bottom: 0; }
.hk-keller .hk-kl-leer-titel { margin: 0; font-size: 16px; line-height: 20px; color: #2b2230; }
.hk-keller .hk-kl-leer-text { margin: 4px 0 0; font-size: 13px; line-height: 16px; }
.hk-keller .hk-kl-fehler { margin: 0 0 8px; font-weight: 700; color: #b8452f; }
/* Fuß: ◂ Seitenzeile ▸ */
.hk-keller .hk-kl-fuss { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 2px; }
.hk-keller .hk-kl-seiten { flex: 1 1 auto; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 8px; line-height: 12px; color: #5a4a3a; text-align: center; }
/* Nur für Screenreader */
.hk-keller .hk-kl-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* ---------- Akte: Dialog auf der Manila-Karte ---------- */
.hk-keller .hk-kl-akte-ueber { position: absolute; inset: 0; z-index: 20; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 16px; }
.hk-keller .hk-kl-akte-hinten {
position: absolute; inset: 0; margin: 0; padding: 0; border: 0; border-radius: 0;
background: rgba(6, 7, 18, 0.62); cursor: default;
-webkit-appearance: none; appearance: none; -webkit-tap-highlight-color: transparent;
}
.hk-keller .hk-akte.hk-kl-akte { position: relative; width: min(560px, 100%); max-height: 100%; display: flex; flex-direction: column; color: #2b2230; outline: none; }
.hk-keller .hk-kl-akte-inhalt { overflow: auto; min-height: 0; padding: 6px; }
.hk-keller .hk-kl-akte-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.hk-keller .hk-kl-akte-titelbox { min-width: 0; }
.hk-keller .hk-kl-akte-raum { display: flex; align-items: center; gap: 6px; font-size: 13px; line-height: 16px; color: #5a4a3a; }
.hk-keller .hk-kl-akte-name { margin: 8px 0 0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 22px; font-weight: 400; overflow-wrap: anywhere; }
.hk-keller .hk-kl-akte-ort { margin-top: 4px; }
.hk-keller .hk-kl-akte-dl { margin: 14px 0 0; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 14px; align-items: start; }
.hk-keller .hk-kl-akte-dl dt { color: #7a2e3a; }
.hk-keller .hk-kl-akte-dl dd { margin: 0; overflow-wrap: anywhere; }
.hk-keller .hk-kl-akte-zeile { display: flex; align-items: center; gap: 6px; }
.hk-keller .hk-kl-akte-zeile.hk-kl-akte-grund { align-items: flex-start; }
.hk-keller .hk-kl-akte-grundtext { padding-top: 6px; }
.hk-keller .hk-kl-akte-erklaerung { display: block; margin-top: 4px; font-size: 13px; line-height: 17px; color: #5a4a3a; }
.hk-keller .hk-kl-akte-fuss { margin-top: 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.hk-keller .hk-kl-akte-hinweis { font-size: 13px; line-height: 16px; color: #5a4a3a; text-align: center; }
/* ---------- Handy (unter 720 px, Spec E13): Buch in voller Breite über der abgedunkelten Szene, Akte als Vollbild ---------- */
@media (max-width: 719px) {
.hk-keller .hk-kl-dunkel { display: block; position: absolute; inset: 0; background: rgba(6, 7, 18, 0.62); pointer-events: none; }
.hk-keller .hk-kl-ui { top: 8px; right: 8px; bottom: 8px; left: 8px; width: auto; }
/* Kopfzeile in einer Reihe: ◂ und Register-Knopf nur als Symbol (die Beschriftung steht im aria-label), das Schild ohne Roboter und kleiner */
.hk-keller .hk-kl-kopf { flex-wrap: nowrap; gap: 8px; }
.hk-keller .hk-kl-label { display: none; }
.hk-keller .hk-knopf.hk-kl-knopf-klein { padding: 0 6px 4px; }
.hk-keller .hk-kl-schild { flex: 1 1 auto; min-width: 0; justify-content: center; gap: 8px; padding: 0; }
.hk-keller .hk-kl-schild .hk-ic-robo { display: none; }
.hk-keller .hk-kl-titel, .hk-keller .hk-kl-zahl { font-size: 12px; line-height: 12px; }
/* Die Reiter wischen seitlich; oben und unten Platz für Rand und die 8 px hinter dem Buch, damit nichts abgeschnitten wird */
.hk-keller .hk-kl-reiter { overflow-x: auto; padding: 2px 0 8px 8px; margin-bottom: -8px; scrollbar-width: none; }
.hk-keller .hk-kl-reiter::-webkit-scrollbar { display: none; }
.hk-keller .hk-kl-reiter .hk-reiter { flex: none; }
.hk-keller .hk-kl-buch .hk-zeile, .hk-keller .hk-kl-buch .hk-kopfzeile { grid-template-columns: 32px minmax(0, 1fr) minmax(0, 38%); }
.hk-keller .hk-kl-buch .hk-kl-spalte-punkte, .hk-keller .hk-kl-buch .hk-kl-spalte-telefon { display: none; }
.hk-keller .hk-kl-akte-name { font-size: 12px; line-height: 18px; }
.hk-keller .hk-kl-akte-ueber { padding: 0; }
.hk-keller .hk-akte.hk-kl-akte { width: 100%; height: 100%; max-height: none; }
}
/* ---------- weniger Bewegung: Arne am Pult steht still auf dem ersten Bild ---------- */
@media (prefers-reduced-motion: reduce) {
.hk-keller .hk-kl-fig-warten, .hk-keller .hk-kl-fig-schlafen, .hk-keller .hk-kl-fig-nicht, .hk-keller .hk-kl-punkte, .hk-keller .hk-kl-zzz { animation: none; }
}
+20
View File
@@ -0,0 +1,20 @@
{
"quelle": "workers-dev-p6/src/scoring/business.js: Konstante GRUND (Stand d0d66f9). SCHWELLE ist `Punktzahl unter ${MIN_SCORE_MAIL}` mit MIN_SCORE_MAIL = 55. Die Texte stehen als fester Grund in der Bot-Lead-Liste des Dienstes (botLeadReason).",
"stand": "d0d66f9",
"gruende": {
"KEINE_MAIL": "keine veröffentlichte Mail",
"MX": "Adresse nicht zustellbar",
"KETTE": "Kette oder Franchise",
"WEBSITE_GUT": "Website in gutem Zustand",
"SCHWELLE": "Punktzahl unter 55",
"VERALTET": "veraltet (nicht ausgewählt)",
"BEKANNT": "schon bekannt (E-Mail oder Telefon)",
"BEKANNT_NAME": "schon bekannt (Name und PLZ)",
"GESPERRT": "Sperrliste",
"LESEN": "Lesen fehlgeschlagen",
"IMPRESSUM_FREMD": "Impressum passt nicht zum Betrieb",
"ABGEBROCHEN": "Lauf unterbrochen",
"BEWERTUNG_AUSGEFALLEN": "Bewertung ausgefallen",
"NAME_FEHLT": "Name fehlt"
}
}
+143 -205
View File
@@ -1,134 +1,22 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import path from 'node:path' import { loadModule } from './load.mjs'
import { fileURLToPath } from 'node:url' import {
import esbuild from 'esbuild' ITEM, KUERZ, PRAXIS, buttons, byClass, fakeApi, fakeFocus, nodes, settle, start, textOf,
import { fakeReact } from './helpers/fakeReact.mjs' } from './helpers/kellerRunner.mjs'
// BotLeadList läuft wirklich: im Mini-Läufer anstelle von React und DOM (Muster workersPultUi.test.mjs, Baustein helpers/fakeReact.mjs). // BotLeadList (Hustler-Pixelwelt T10: der Keller) 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) // gemeinsamer Läufer helpers/kellerRunner.mjs). Gerendert wird in einen Baum aus { tag, props, children }; Eingaben und Klicks ruft der Test von Hand auf, die Zeit
// läuft von Hand, der Dienst ist eine Attrappe mit offenen Versprechen. // (window.setTimeout) läuft von Hand, der Dienst ist eine Attrappe mit offenen Versprechen.
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') // Die fachlichen Zusicherungen der alten Tabelle gelten unverändert (Abruf, Suche mit Verzögerung, Seiten, Fokus beim Blättern, „Erneut versuchen“, Einzelansicht, Telefonregel,
const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`) // sichere Links, Leer- und Ladezustände). Umgestellt ist nur, wo sie stehen: Zeilen sind Knöpfe des Buchs (hk-zeile) statt Tabellenzeilen, der Raumfilter sind Reiter statt einer
const settle = () => new Promise((resolve) => setImmediate(resolve)) // Auswahlliste, die Blätterknöpfe heißen „Vorherige Seite“ / „Nächste Seite“ (◂ ▸), Quelle und Raum-Beschriftung stehen in der Akte. Kommentare „UMGESTELLT“ nennen jede Änderung.
const { ROOM_FILTERS } = await loadModule('src/lib/botleadsView.js')
// Die Komponente wird einmal gebündelt; jeder Test hängt sie neu ein (eigener Zustand) const PREV = 'Vorherige Seite'
let bundle const NEXT = 'Nächste Seite'
const loadUi = () => (bundle ??= esbuild.build({ const ROOM_VALUES = ROOM_FILTERS.map((f) => f.value) // ['', 'aerzte', 'handwerk', 'selbst', 'laden']
stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as BotLeadList } from './src/components/workers/BotLeadList.jsx'", resolveDir: ROOT, loader: 'js' }, const idx = (room) => ROOM_VALUES.indexOf(room)
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 }) }) }
}
// DOM-Attrappe für den Fokus (Muster workersPultUi.test.mjs): document mit body und activeElement, ref an select und den Blätterknöpfen liefert __fakeRef.
// Der Schlüssel ist „select“ oder die Beschriftung des Knopfs. Ein Element gilt nur, solange es im Baum steht (isConnected, wie im Browser); steht es beim
// nächsten Render nicht im Baum der Runde davor (Seitenzeile während des Ladens), ist es danach ein neues Element, so wie React die Knöpfe neu einhängt.
// focus() zählt nur für ein eingehängtes Element, merkt sich Schlüssel und Argument und setzt activeElement (Fokus liegt dann auf dem Element).
function fakeFocus() {
const body = { name: 'body' }
const doc = { body, documentElement: { name: 'html' }, activeElement: body }
const f = { doc, body, focused: [], options: [], tree: () => null, view: null, made: new Map() }
const keyOf = (tag, props) => (tag === 'button' ? String(props.children) : tag)
const live = (tag, key) => nodes(f.tree(), (n) => n.tag === tag && keyOf(tag, n.props) === key).length > 0
const had = Object.hasOwn(globalThis, 'document')
const saved = globalThis.document
globalThis.document = doc
globalThis.__fakeRef = (tag, props) => {
const key = keyOf(tag, props)
const known = f.made.get(key)
if (!known || !live(tag, key)) { // während dieser Runde zeigt f.tree() noch die Runde davor
f.made.set(key, {
key, props,
get disabled() { return this.props.disabled === true },
get isConnected() { return f.made.get(key) === this && live(tag, key) },
focus(options) { if (this.isConnected) { f.focused.push(key); f.options.push(options); doc.activeElement = this } },
})
}
f.made.get(key).props = props
return f.made.get(key)
}
// ein Element, das der Nutzer von Hand ansteuert (Suchfeld, Link …): eingehängt, bedienbar
f.other = (name) => ({ name, isConnected: true, disabled: false, focus() { doc.activeElement = this } })
f.attach = (view) => { f.view = view; f.tree = () => view.tree() }
// Der Nutzer drückt einen Knopf: der Browser gibt ihm den Fokus, dann läuft der Klick
// (Knöpfe ohne ref, z. B. „Erneut versuchen“, sind nur so lange Element, wie ein Knopf dieser Beschriftung im Baum steht)
f.press = async (label) => {
doc.activeElement = f.made.get(label) ?? { name: label, disabled: false, get isConnected() { return buttons(f.tree(), label).length > 0 }, focus() { doc.activeElement = this } }
await f.view.click(label)
}
f.restore = () => {
if (had) globalThis.document = saved
else delete globalThis.document
delete globalThis.__fakeRef
}
return f
}
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 () => { test('lädt beim Start (Raum alle, keine Suche, Seite 1), zeigt zuerst „Wird geladen“, dann die Zeilen', async () => {
const api = fakeApi() const api = fakeApi()
@@ -140,24 +28,42 @@ test('lädt beim Start (Raum alle, keine Suche, Seite 1), zeigt zuerst „Wird g
assert.equal(ui.rows().length, 0) assert.equal(ui.rows().length, 0)
await ui.answer(0, { items: [ITEM, PRAXIS], total: 2, pageSize: 50 }) await ui.answer(0, { items: [ITEM, PRAXIS], total: 2, pageSize: 50 })
assert.ok(!ui.text().includes('Wird geladen')) 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']) // UMGESTELLT: statt der Tabellenköpfe (Betrieb, Raum, Punkte, Grund, Telefon, Quelle, Gefunden) die Kopfzeile des Buchs; Raum steht als Symbol in der Zeile, Quelle in der Akte
assert.deepEqual(byClass(ui.tree(), 'hk-kopfzeile')[0].children.map(textOf), ['', 'Betrieb · Ort · gefunden', 'Punkte', 'Grund', 'Telefon'])
const rows = ui.rows() const rows = ui.rows()
assert.equal(rows.length, 2) 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']) // UMGESTELLT: Ort und Funddatum in einer Zeile unter dem Namen, Punkte als Zahl neben dem Balken („82 Punkte“ steht in der Akte und im Label)
// Heilberuf: nie eine Nummer, unsichere Quelle ist kein Link assert.deepEqual(ui.parts(rows[0]), {
assert.deepEqual(ui.cells(rows[1]).slice(4), ['bewusst nicht gespeichert', '–', '18.10.2026']) name: 'Elektro Beispiel GmbH', sub: '67655 Kaiserslautern · 19.10.2026', score: '82', reason: 'keine veröffentlichte Mail', phone: '0631 111',
raumIcon: 'hk-ic hk-ic-raum-handwerk', label: 'Elektro Beispiel GmbH, 67655 Kaiserslautern, 82 Punkte, keine veröffentlichte Mail, gefunden am 19.10.2026',
})
// Heilberuf: nie eine Nummer
assert.equal(ui.parts(rows[1]).phone, 'bewusst nicht gespeichert')
assert.equal(ui.parts(rows[1]).raumIcon, 'hk-ic hk-ic-raum-aerzte')
assert.equal(ui.parts(rows[1]).sub, '67655 Kaiserslautern · 18.10.2026')
assert.equal(rows[0].props['aria-current'], undefined) assert.equal(rows[0].props['aria-current'], undefined)
// Zeilen sind Knöpfe (öffnen die Akte), keine Links
assert.ok(rows.every((r) => r.props.type === 'button'))
} finally { ui.end() } } finally { ui.end() }
}) })
test('Quelle: Link nur über die geprüfte Adresse, neuer Tab ohne Rückbezug; kein Anruf-Link', async () => { test('Quelle: Link nur über die geprüfte Adresse (jetzt in der Akte), neuer Tab ohne Rückbezug; kein Anruf-Link', async () => {
const api = fakeApi() const api = fakeApi()
const ui = await start(api) const ui = await start(api)
try { try {
await ui.answer(0, { items: [ITEM, PRAXIS, { ...ITEM, id: 'p4', sourceUrl: 'https://user:pw@evil.example/' }], total: 3 }) 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') // UMGESTELLT: die Liste selbst hat gar keine Links mehr (die Zeilen sind Knöpfe); die Quelle öffnet die Akte
assert.equal(nodes(ui.tree(), (n) => n.tag === 'a').length, 0)
await ui.openRow(0)
let links = nodes(ui.tree(), (n) => n.tag === 'a')
assert.equal(links.length, 1) 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.deepEqual([links[0].props.href, links[0].props.target, links[0].props.rel], ['https://beispiel-elektro.de/impressum', '_blank', 'noopener noreferrer'])
// zweiter Eintrag: javascript:-Adresse ist kein Link; dritter: Adresse mit Zugangsdaten ebenfalls nicht
await ui.click('Nächste Akte')
assert.equal(nodes(ui.tree(), (n) => n.tag === 'a').length, 0)
await ui.click('Nächste Akte')
links = nodes(ui.tree(), (n) => n.tag === 'a')
assert.equal(links.length, 0)
assert.equal(nodes(ui.tree(), (n) => n.props && typeof n.props.href === 'string' && n.props.href.startsWith('tel:')).length, 0) assert.equal(nodes(ui.tree(), (n) => n.props && typeof n.props.href === 'string' && n.props.href.startsWith('tel:')).length, 0)
} finally { ui.end() } } finally { ui.end() }
}) })
@@ -168,47 +74,74 @@ test('gekürzte Einträge sind grau und tragen „gekürzt“', async () => {
try { try {
await ui.answer(0, { items: [ITEM, KUERZ], total: 2 }) await ui.answer(0, { items: [ITEM, KUERZ], total: 2 })
const [normal, cut] = ui.rows() const [normal, cut] = ui.rows()
assert.equal(hasClass(normal, 'wk-bl-cut'), false) // UMGESTELLT: Klassen des Buchs (hk-zeile-alt statt wk-bl-cut)
assert.equal(hasClass(cut, 'wk-bl-cut'), true) assert.equal(normal.props.className.split(' ').includes('hk-zeile-alt'), false)
assert.equal(cut.props.className.split(' ').includes('hk-zeile-alt'), true)
assert.ok(textOf(cut).includes('gekürzt')) assert.ok(textOf(cut).includes('gekürzt'))
assert.ok(!textOf(normal).includes('gekürzt')) assert.ok(!textOf(normal).includes('gekürzt'))
} finally { ui.end() } } finally { ui.end() }
}) })
test('Raumfilter: aus ROOM_FILTERS, neue Anfrage mit Seite 1; nur die neueste Anfrage zählt', async () => { test('Raumfilter: Reiter aus ROOM_FILTERS, neue Anfrage mit Seite 1; nur die neueste Anfrage zählt', async () => {
const api = fakeApi() const api = fakeApi()
const ui = await start(api) const ui = await start(api)
try { 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']]) // UMGESTELLT: fünf Reiter mit aria-pressed (statt der Optionen einer Auswahlliste), Beschriftung = ROOM_FILTERS (aria-label), kurze sichtbare Namen, ein Symbol je Reiter
const reiter = ui.reiter()
assert.deepEqual(reiter.map((r) => r.props['aria-label']), ROOM_FILTERS.map((f) => f.label))
assert.deepEqual(reiter.map((r) => textOf(r)), ['Alle', 'Ärzte', 'Handwerk', 'Selbstständige', 'Läden'])
assert.deepEqual(reiter.map((r) => r.props['aria-pressed']), [true, false, false, false, false])
assert.ok(reiter.every((r) => r.props.type === 'button'))
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter') await ui.click(NEXT)
assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 }) assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 })
await ui.answer(1, { items: [PRAXIS], total: 120, pageSize: 50 }) await ui.answer(1, { items: [PRAXIS], total: 120, pageSize: 50 })
// Raum wechseln: zurück auf Seite 1, genau eine neue Anfrage // Raum wechseln: zurück auf Seite 1, genau eine neue Anfrage
await ui.pick('handwerk') await ui.pick(idx('handwerk'))
assert.equal(api.calls.length, 3) assert.equal(api.calls.length, 3)
assert.deepEqual(api.calls[2].params, { room: 'handwerk', q: '', page: 1 }) assert.deepEqual(api.calls[2].params, { room: 'handwerk', q: '', page: 1 })
assert.equal(ui.select().props.value, 'handwerk') assert.equal(ui.activeIndex(), idx('handwerk')) // UMGESTELLT: der gedrückte Reiter statt select.value
// noch einmal gewechselt, bevor die erste Antwort kommt; die späte Antwort der älteren Anfrage darf nichts überschreiben // noch einmal gewechselt, bevor die erste Antwort kommt; die späte Antwort der älteren Anfrage darf nichts überschreiben
await ui.pick('laden') await ui.pick(idx('laden'))
assert.deepEqual(api.calls[3].params, { room: 'laden', q: '', page: 1 }) assert.deepEqual(api.calls[3].params, { room: 'laden', q: '', page: 1 })
await ui.answer(2, { items: [ITEM], total: 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.equal(ui.rows().length, 0) // „laden“ lädt noch, der alte Raum zeigt nichts
assert.ok(ui.text().includes('Wird geladen')) assert.ok(ui.text().includes('Wird geladen'))
await ui.answer(3, { items: [PRAXIS], total: 1 }) await ui.answer(3, { items: [PRAXIS], total: 1 })
assert.deepEqual(ui.rows().map((r) => ui.cells(r)[0]), ['Praxis Dr. Muster67655 Kaiserslautern']) assert.deepEqual(ui.rows().map((r) => ui.parts(r).name), ['Praxis Dr. Muster'])
// derselbe Raum noch einmal gewählt: keine neue Anfrage // derselbe Raum noch einmal gewählt: keine neue Anfrage
await ui.pick('laden') await ui.pick(idx('laden'))
assert.equal(api.calls.length, 4) assert.equal(api.calls.length, 4)
} finally { ui.end() } } finally { ui.end() }
}) })
test('Raum-Reiter: die Zahl steht nur am aktiven Reiter (= total der Antwort), erst wenn die Antwort da ist', async () => {
const api = fakeApi()
const ui = await start(api)
try {
assert.deepEqual(ui.reiter().map((r) => r.props['aria-label']), ROOM_FILTERS.map((f) => f.label)) // beim Laden keine Zahl
await ui.answer(0, { items: [ITEM], total: 1234, pageSize: 50 })
const zahlen = ui.reiter().map((r) => byClass(r, 'hk-kl-reiter-zahl').map(textOf))
assert.deepEqual(zahlen, [['1.234'], [], [], [], []])
assert.equal(ui.reiter()[0].props['aria-label'], 'Alle Räume, 1.234 Einträge')
await ui.pick(idx('aerzte'))
assert.deepEqual(ui.reiter().map((r) => byClass(r, 'hk-kl-reiter-zahl').length), [0, 0, 0, 0, 0]) // die neue Anfrage läuft: keine Zahl
await ui.answer(1, { items: [PRAXIS], total: 1 })
assert.deepEqual(ui.reiter().map((r) => byClass(r, 'hk-kl-reiter-zahl').map(textOf)), [[], ['1'], [], [], []])
assert.equal(ui.reiter()[1].props['aria-label'], 'Ärzte & Praxen, 1 Eintrag')
// bei einem Fehler steht keine Zahl
await ui.pick(idx('selbst'))
await ui.fail(2, new Error('weg'))
assert.deepEqual(ui.reiter().map((r) => byClass(r, 'hk-kl-reiter-zahl').length), [0, 0, 0, 0, 0])
} finally { ui.end() }
})
test('Suche: 300 ms Verzögerung, nur die letzte Eingabe wird abgefragt, Seite springt auf 1', async () => { test('Suche: 300 ms Verzögerung, nur die letzte Eingabe wird abgefragt, Seite springt auf 1', async () => {
const api = fakeApi() const api = fakeApi()
const ui = await start(api) const ui = await start(api)
try { try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter') await ui.click(NEXT)
await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 })
assert.equal(api.calls.length, 2) assert.equal(api.calls.length, 2)
for (const value of ['b', 'bä', ' bäckerei ']) await ui.type(value) for (const value of ['b', 'bä', ' bäckerei ']) await ui.type(value)
@@ -222,6 +155,7 @@ test('Suche: 300 ms Verzögerung, nur die letzte Eingabe wird abgefragt, Seite s
await ui.type('bäckerei'); ui.fireTimers(); await settle() await ui.type('bäckerei'); ui.fireTimers(); await settle()
assert.equal(api.calls.length, 3) assert.equal(api.calls.length, 3)
assert.equal(ui.search().props.maxLength, 100) assert.equal(ui.search().props.maxLength, 100)
assert.equal(ui.search().props['aria-label'], 'Betriebe suchen')
} finally { ui.end() } } finally { ui.end() }
}) })
@@ -233,16 +167,16 @@ test('Fehler: der Satz aus der API steht als Meldung, keine Liste; der nächste
assert.deepEqual(ui.errors(), [{ text: 'Der Hustler-Dienst ist gerade nicht erreichbar.', role: 'alert' }]) assert.deepEqual(ui.errors(), [{ text: 'Der Hustler-Dienst ist gerade nicht erreichbar.', role: 'alert' }])
assert.equal(ui.rows().length, 0) assert.equal(ui.rows().length, 0)
assert.ok(!ui.text().includes('Noch keine Bot-Leads.')) assert.ok(!ui.text().includes('Noch keine Bot-Leads.'))
await ui.pick('laden') await ui.pick(idx('laden'))
await ui.answer(1, { items: [ITEM], total: 1 }) await ui.answer(1, { items: [ITEM], total: 1 })
assert.deepEqual(ui.errors(), []) assert.deepEqual(ui.errors(), [])
assert.equal(ui.rows().length, 1) assert.equal(ui.rows().length, 1)
// ein Fehler ohne Satz zeigt trotzdem einen deutschen Text // ein Fehler ohne Satz zeigt trotzdem einen deutschen Text
await ui.pick('selbst') await ui.pick(idx('selbst'))
await ui.fail(2, {}) await ui.fail(2, {})
assert.deepEqual(ui.errors().map((e) => e.text), ['Unbekannter Fehler']) assert.deepEqual(ui.errors().map((e) => e.text), ['Unbekannter Fehler'])
// ein später Fehler einer älteren Anfrage zählt nicht // ein später Fehler einer älteren Anfrage zählt nicht
await ui.pick('aerzte'); await ui.pick('handwerk') await ui.pick(idx('aerzte')); await ui.pick(idx('handwerk'))
await ui.fail(3, new Error('zu spät')) await ui.fail(3, new Error('zu spät'))
assert.deepEqual(ui.errors(), []) assert.deepEqual(ui.errors(), [])
} finally { ui.end() } } finally { ui.end() }
@@ -254,39 +188,42 @@ test('Leerzustände: ohne Filter „Noch keine Bot-Leads.“, mit Filter oder Su
try { try {
await ui.answer(0, { items: [], total: 0 }) await ui.answer(0, { items: [], total: 0 })
assert.ok(ui.text().includes('Noch keine Bot-Leads.')) assert.ok(ui.text().includes('Noch keine Bot-Leads.'))
assert.equal(nodes(ui.tree(), (n) => n.tag === 'table').length, 0) assert.equal(byClass(ui.tree(), 'hk-liste').length, 0) // UMGESTELLT: keine Liste statt keine Tabelle
assert.ok(!ui.text().includes('Keine Einträge')) // keine Seitenzeile assert.ok(!ui.text().includes('Keine Einträge')) // keine Seitenzeile
await ui.pick('laden') await ui.pick(idx('laden'))
await ui.answer(1, { items: [], total: 0 }) await ui.answer(1, { items: [], total: 0 })
assert.ok(ui.text().includes('Keine Treffer.')) assert.ok(ui.text().includes('Keine Treffer.'))
assert.ok(!ui.text().includes('Noch keine Bot-Leads.')) assert.ok(!ui.text().includes('Noch keine Bot-Leads.'))
await ui.pick('') await ui.pick(idx(''))
await ui.type('xyz'); ui.fireTimers(); await settle() await ui.type('xyz'); ui.fireTimers(); await settle()
await ui.answer(3, { items: [], total: 0 }) await ui.answer(3, { items: [], total: 0 })
assert.ok(ui.text().includes('Keine Treffer.')) assert.ok(ui.text().includes('Keine Treffer.'))
} finally { ui.end() } } finally { ui.end() }
}) })
test('Blättern: Seitenzeile, Zurück und Weiter, pageSize aus der Antwort', async () => { test('Blättern: Seitenzeile, Zurück und Weiter (◂ ▸), pageSize aus der Antwort', async () => {
const api = fakeApi() const api = fakeApi()
const ui = await start(api) const ui = await start(api)
try { try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
assert.ok(ui.text().includes('Seite 1 von 3 · 120 Einträge')) assert.ok(ui.text().includes('Seite 1 von 3 · 120 Einträge'))
assert.deepEqual([ui.disabled('Zurück'), ui.disabled('Weiter')], [[true], [false]]) assert.deepEqual([ui.disabled(PREV), ui.disabled(NEXT)], [[true], [false]])
await ui.click('Zurück') // gesperrt: nichts passiert await ui.click(PREV) // gesperrt: nichts passiert
assert.equal(api.calls.length, 1) assert.equal(api.calls.length, 1)
await ui.click('Weiter') await ui.click(NEXT)
await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 }) assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 })
assert.ok(ui.text().includes('Seite 2 von 3 · 120 Einträge')) assert.ok(ui.text().includes('Seite 2 von 3 · 120 Einträge'))
await ui.click('Weiter') await ui.click(NEXT)
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual([ui.disabled('Zurück'), ui.disabled('Weiter')], [[false], [true]]) assert.deepEqual([ui.disabled(PREV), ui.disabled(NEXT)], [[false], [true]])
await ui.click('Zurück') await ui.click(PREV)
assert.deepEqual(api.calls[3].params, { room: '', q: '', page: 2 }) 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 // während einer Anfrage ist die Seitenzeile weg: kein zweiter Klick auf eine veraltete Seitenzahl
assert.equal(buttons(ui.tree(), 'Weiter').length, 0) assert.equal(buttons(ui.tree(), NEXT).length, 0)
// die Knöpfe sind echte Knöpfe mit Beschriftung (nur das Symbol ist zu sehen)
await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 })
assert.ok(buttons(ui.tree(), NEXT).every((b) => b.props.type === 'button' && b.props['aria-label'] === NEXT))
} finally { ui.end() } } finally { ui.end() }
}) })
@@ -296,7 +233,7 @@ test('Blättern: eine Seite genügt ⇒ nur die Zahl, kein Blättern; Einzahl be
try { try {
await ui.answer(0, { items: [ITEM], total: 1, pageSize: 50 }) await ui.answer(0, { items: [ITEM], total: 1, pageSize: 50 })
assert.ok(ui.text().includes('Seite 1 von 1 · 1 Eintrag')) 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) assert.equal(buttons(ui.tree(), NEXT).length + buttons(ui.tree(), PREV).length, 0)
} finally { ui.end() } } finally { ui.end() }
}) })
@@ -305,8 +242,8 @@ test('Blättern: ist die Seite nach einem Kürzen leer, springt die Liste auf di
const ui = await start(api) const ui = await start(api)
try { try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) 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(NEXT); await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter') await ui.click(NEXT)
assert.deepEqual(api.calls[2].params, { room: '', q: '', page: 3 }) assert.deepEqual(api.calls[2].params, { room: '', q: '', page: 3 })
await ui.answer(2, { items: [], total: 60, pageSize: 50 }) // inzwischen nur noch zwei Seiten 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.deepEqual(api.calls[3].params, { room: '', q: '', page: 2 })
@@ -323,19 +260,20 @@ test('bot: ruft die API mit id ab, hebt die Zeile hervor und bietet „Alle anze
const ui = await start(api, { bot: 'p1', onClearBot: () => { cleared += 1 } }) const ui = await start(api, { bot: 'p1', onClearBot: () => { cleared += 1 } })
try { try {
assert.deepEqual(api.calls.map((c) => c.params), [{ id: 'p1' }]) 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 assert.equal(nodes(ui.tree(), (n) => n.tag === 'input' || n.props['aria-pressed'] !== undefined).length, 0) // Filter (Reiter) und Suche wirken auf einen Einzeleintrag nicht
assert.ok(ui.text().includes('Reine Liste: keine Anrufe, kein Mailversand.'))
await ui.answer(0, { items: [ITEM], total: 1, pageSize: 50 }) await ui.answer(0, { items: [ITEM], total: 1, pageSize: 50 })
const rows = ui.rows() const rows = ui.rows()
assert.equal(rows.length, 1) assert.equal(rows.length, 1)
assert.equal(rows[0].props['aria-current'], 'true') assert.equal(rows[0].props['aria-current'], 'true')
assert.equal(buttons(ui.tree(), 'Weiter').length, 0) // keine Seitenzeile bei einem Einzeleintrag assert.equal(buttons(ui.tree(), NEXT).length, 0) // keine Seitenzeile bei einem Einzeleintrag
await ui.click('Alle anzeigen') await ui.click('Alle anzeigen')
assert.equal(cleared, 1) assert.equal(cleared, 1)
// die Seite nimmt bot aus der Adresse: die Liste lädt wieder alle (Filter und Suche zurück) // die Seite nimmt bot aus der Adresse: die Liste lädt wieder alle (Filter und Suche zurück)
ui.setProps({ bot: '' }) ui.setProps({ bot: '' })
await settle() await settle()
assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 1 }) assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 1 })
assert.equal(ui.select().props.value, '') assert.equal(ui.activeIndex(), idx('')) // UMGESTELLT: „Alle Räume“ ist der gedrückte Reiter statt select.value ''
} finally { ui.end() } } finally { ui.end() }
}) })
@@ -379,21 +317,21 @@ test('Blättern (C5): nach dem Laden der neuen Seite bekommt die Seitenzeile den
try { try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(dom.focused, []) // nicht schon beim ersten Laden assert.deepEqual(dom.focused, []) // nicht schon beim ersten Laden
await dom.press('Weiter') await dom.press(NEXT)
assert.deepEqual(dom.focused, []) // während der Anfrage ist die Seitenzeile weg assert.deepEqual(dom.focused, []) // während der Anfrage ist die Seitenzeile weg
assert.equal(dom.doc.activeElement.isConnected, false) // und mit ihr der Knopf, der den Fokus hatte: der Fokus ist verloren assert.equal(dom.doc.activeElement.isConnected, false) // und mit ihr der Knopf, der den Fokus hatte: der Fokus ist verloren
await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 2 von 3 await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 2 von 3
assert.deepEqual(dom.focused, ['Weiter']) assert.deepEqual(dom.focused, [NEXT])
await dom.press('Weiter') await dom.press(NEXT)
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 3 von 3: „Weiter“ ist gesperrt ⇒ „Zurück“ await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 3 von 3: „Nächste Seite“ ist gesperrt ⇒ „Vorherige Seite“
assert.deepEqual(dom.focused, ['Weiter', 'Zurück']) assert.deepEqual(dom.focused, [NEXT, PREV])
await dom.press('Zurück') await dom.press(PREV)
await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 2 await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 2
assert.deepEqual(dom.focused, ['Weiter', 'Zurück', 'Zurück']) assert.deepEqual(dom.focused, [NEXT, PREV, PREV])
await dom.press('Zurück') await dom.press(PREV)
await ui.answer(4, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 1: „Zurück“ ist gesperrt ⇒ „Weiter“ await ui.answer(4, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 1: „Vorherige Seite“ ist gesperrt ⇒ „Nächste Seite“
assert.deepEqual(dom.focused, ['Weiter', 'Zurück', 'Zurück', 'Weiter']) assert.deepEqual(dom.focused, [NEXT, PREV, PREV, NEXT])
// N5: ohne { preventScroll: true } sprängen Chromium und Firefox ans Ende der neuen Seite (der Knopf steht unter der Tabelle) // N5: ohne { preventScroll: true } sprängen Chromium und Firefox ans Ende der neuen Seite
assert.deepEqual(dom.options, [{ preventScroll: true }, { preventScroll: true }, { preventScroll: true }, { preventScroll: true }]) assert.deepEqual(dom.options, [{ preventScroll: true }, { preventScroll: true }, { preventScroll: true }, { preventScroll: true }])
} finally { dom.restore(); ui.end() } } finally { dom.restore(); ui.end() }
}) })
@@ -406,36 +344,36 @@ test('Blättern (N5): hat der Nutzer den Fokus inzwischen selbst woandershin ges
try { try {
await ui.answer(0, { items: [ITEM], total: 400, pageSize: 50 }) await ui.answer(0, { items: [ITEM], total: 400, pageSize: 50 })
// 1. Der Nutzer klickt während des Ladens ins Suchfeld (Fokus dort, aber die Zeitgeber der Suche laufen noch nicht) // 1. Der Nutzer klickt während des Ladens ins Suchfeld (Fokus dort, aber die Zeitgeber der Suche laufen noch nicht)
await dom.press('Weiter') await dom.press(NEXT)
const search = dom.other('Suchfeld') const search = dom.other('Suchfeld')
search.focus() search.focus()
await ui.answer(1, { items: [ITEM], total: 400, pageSize: 50 }) await ui.answer(1, { items: [ITEM], total: 400, pageSize: 50 })
assert.deepEqual(dom.focused, []) assert.deepEqual(dom.focused, [])
assert.equal(dom.doc.activeElement, search) assert.equal(dom.doc.activeElement, search)
// 2. … oder er wählt den Raumfilter mit der Tastatur an, ohne etwas zu ändern // 2. … oder er wählt den Raum-Reiter mit der Tastatur an, ohne etwas zu ändern (UMGESTELLT: Reiter statt Auswahlliste)
await dom.press('Weiter') await dom.press(NEXT)
dom.made.get('select').focus() dom.made.get('reiter').focus()
assert.deepEqual(dom.focused, ['select']) assert.deepEqual(dom.focused, ['reiter'])
await ui.answer(2, { items: [ITEM], total: 400, pageSize: 50 }) await ui.answer(2, { items: [ITEM], total: 400, pageSize: 50 })
assert.deepEqual(dom.focused, ['select']) // kein weiterer Fokus auf die Seitenzeile assert.deepEqual(dom.focused, ['reiter']) // kein weiterer Fokus auf die Seitenzeile
assert.equal(dom.doc.activeElement.key, 'select') assert.equal(dom.doc.activeElement.key, 'reiter')
// 3. Fokus auf body: zurück an die Seitenzeile // 3. Fokus auf body: zurück an die Seitenzeile
dom.focused.length = 0; dom.options.length = 0 dom.focused.length = 0; dom.options.length = 0
await dom.press('Weiter') await dom.press(NEXT)
dom.doc.activeElement = dom.body dom.doc.activeElement = dom.body
await ui.answer(3, { items: [ITEM], total: 400, pageSize: 50 }) await ui.answer(3, { items: [ITEM], total: 400, pageSize: 50 })
assert.deepEqual(dom.focused, ['Weiter']) assert.deepEqual(dom.focused, [NEXT])
assert.deepEqual(dom.options, [{ preventScroll: true }]) assert.deepEqual(dom.options, [{ preventScroll: true }])
// 4. Fokus auf nichts (activeElement null, etwa ein Dokument ohne Fokus): ebenfalls zurück // 4. Fokus auf nichts (activeElement null, etwa ein Dokument ohne Fokus): ebenfalls zurück
await dom.press('Weiter') await dom.press(NEXT)
dom.doc.activeElement = null dom.doc.activeElement = null
await ui.answer(4, { items: [ITEM], total: 400, pageSize: 50 }) await ui.answer(4, { items: [ITEM], total: 400, pageSize: 50 })
assert.deepEqual(dom.focused, ['Weiter', 'Weiter']) assert.deepEqual(dom.focused, [NEXT, NEXT])
// 5. ohne document (Server, Node): nichts geschieht, nichts bricht // 5. ohne document (Server, Node): nichts geschieht, nichts bricht
delete globalThis.document delete globalThis.document
await ui.click('Weiter') await ui.click(NEXT)
await ui.answer(5, { items: [ITEM], total: 400, pageSize: 50 }) await ui.answer(5, { items: [ITEM], total: 400, pageSize: 50 })
assert.deepEqual(dom.focused, ['Weiter', 'Weiter']) assert.deepEqual(dom.focused, [NEXT, NEXT])
} finally { dom.restore(); ui.end() } } finally { dom.restore(); ui.end() }
}) })
@@ -446,12 +384,12 @@ test('Blättern (C5): der Fokus springt nicht, wenn der Nutzer inzwischen Raum o
dom.attach(ui) dom.attach(ui)
try { try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter') await ui.click(NEXT)
await ui.pick('handwerk') // während die Seite lädt, wechselt der Raum await ui.pick(idx('handwerk')) // während die Seite lädt, wechselt der Raum
await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) // späte Antwort der älteren Anfrage await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) // späte Antwort der älteren Anfrage
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(dom.focused, []) assert.deepEqual(dom.focused, [])
await ui.click('Weiter') await ui.click(NEXT)
await ui.type('bäckerei') // die Suche wird getippt, bevor die Seite da ist await ui.type('bäckerei') // die Suche wird getippt, bevor die Seite da ist
await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(dom.focused, []) assert.deepEqual(dom.focused, [])
@@ -460,10 +398,10 @@ test('Blättern (C5): der Fokus springt nicht, wenn der Nutzer inzwischen Raum o
await ui.answer(4, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(4, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(dom.focused, []) assert.deepEqual(dom.focused, [])
// scheitert die Anfrage, gibt es keine Seitenzeile zum Fokussieren; die Merkung bleibt nicht für später hängen // scheitert die Anfrage, gibt es keine Seitenzeile zum Fokussieren; die Merkung bleibt nicht für später hängen
await ui.click('Weiter') await ui.click(NEXT)
await ui.fail(5, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.')) await ui.fail(5, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'))
assert.deepEqual(dom.focused, []) assert.deepEqual(dom.focused, [])
await ui.pick('laden') await ui.pick(idx('laden'))
await ui.answer(6, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(6, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(dom.focused, []) assert.deepEqual(dom.focused, [])
} finally { dom.restore(); ui.end() } } finally { dom.restore(); ui.end() }
@@ -474,7 +412,7 @@ test('Fehler beim Blättern (N6): „Erneut versuchen“ wiederholt genau die Se
const ui = await start(api) const ui = await start(api)
try { try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter') await ui.click(NEXT)
assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 }) assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 })
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0) // beim Laden gibt es keinen Knopf assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0) // beim Laden gibt es keinen Knopf
await ui.fail(1, Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })) await ui.fail(1, Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 }))
@@ -493,9 +431,9 @@ test('Fehler beim Blättern (N6): „Erneut versuchen“ wiederholt genau die Se
await ui.answer(2, { items: [PRAXIS], total: 120, pageSize: 50 }) await ui.answer(2, { items: [PRAXIS], total: 120, pageSize: 50 })
assert.equal(api.calls.length, 3) assert.equal(api.calls.length, 3)
assert.deepEqual(ui.errors(), []) assert.deepEqual(ui.errors(), [])
assert.deepEqual(ui.rows().map((r) => ui.cells(r)[0]), ['Praxis Dr. Muster67655 Kaiserslautern']) assert.deepEqual(ui.rows().map((r) => ui.parts(r).name), ['Praxis Dr. Muster'])
assert.ok(ui.text().includes('Seite 2 von 3 · 120 Einträge')) assert.ok(ui.text().includes('Seite 2 von 3 · 120 Einträge'))
assert.deepEqual([ui.disabled('Zurück'), ui.disabled('Weiter')], [[false], [false]]) assert.deepEqual([ui.disabled(PREV), ui.disabled(NEXT)], [[false], [false]])
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0) assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0)
} finally { ui.end() } } finally { ui.end() }
}) })
@@ -505,7 +443,7 @@ test('Fehler beim Blättern (N6): scheitert auch der zweite Versuch, bleibt der
const ui = await start(api) const ui = await start(api)
try { try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter') await ui.click(NEXT)
await ui.fail(1, new Error('zu langsam')) await ui.fail(1, new Error('zu langsam'))
await ui.click('Erneut versuchen') await ui.click('Erneut versuchen')
await ui.fail(2, new Error('immer noch nicht')) await ui.fail(2, new Error('immer noch nicht'))
@@ -514,13 +452,13 @@ test('Fehler beim Blättern (N6): scheitert auch der zweite Versuch, bleibt der
await ui.click('Erneut versuchen') await ui.click('Erneut versuchen')
assert.deepEqual(api.calls[3].params, { room: '', q: '', page: 2 }) assert.deepEqual(api.calls[3].params, { room: '', q: '', page: 2 })
// statt eines weiteren Versuchs ändert der Nutzer den Raum: neue Anfrage mit Seite 1, die späte Antwort des Versuchs zählt nicht // statt eines weiteren Versuchs ändert der Nutzer den Raum: neue Anfrage mit Seite 1, die späte Antwort des Versuchs zählt nicht
await ui.pick('handwerk') await ui.pick(idx('handwerk'))
assert.deepEqual(api.calls[4].params, { room: 'handwerk', q: '', page: 1 }) assert.deepEqual(api.calls[4].params, { room: 'handwerk', q: '', page: 1 })
await ui.answer(3, { items: [PRAXIS], total: 120, pageSize: 50 }) await ui.answer(3, { items: [PRAXIS], total: 120, pageSize: 50 })
assert.equal(ui.rows().length, 0) assert.equal(ui.rows().length, 0)
await ui.answer(4, { items: [ITEM], total: 1, pageSize: 50 }) await ui.answer(4, { items: [ITEM], total: 1, pageSize: 50 })
assert.deepEqual(ui.errors(), []) assert.deepEqual(ui.errors(), [])
assert.deepEqual(ui.rows().map((r) => ui.cells(r)[0]), ['Elektro Beispiel GmbH67655 Kaiserslautern']) assert.deepEqual(ui.rows().map((r) => ui.parts(r).name), ['Elektro Beispiel GmbH'])
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0) assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0)
} finally { ui.end() } } finally { ui.end() }
}) })
@@ -555,18 +493,18 @@ test('Fehler beim Blättern (N6): nach einem gelungenen Wiederholen bekommt die
dom.attach(ui) dom.attach(ui)
try { try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await dom.press('Weiter') await dom.press(NEXT)
await ui.fail(1, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.')) await ui.fail(1, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'))
assert.deepEqual(dom.focused, []) // keine Seitenzeile, nichts zu fokussieren assert.deepEqual(dom.focused, []) // keine Seitenzeile, nichts zu fokussieren
await dom.press('Erneut versuchen') // der Knopf verschwindet beim Laden wie vorher die Seitenzeile await dom.press('Erneut versuchen') // der Knopf verschwindet beim Laden wie vorher die Seitenzeile
assert.deepEqual(dom.focused, []) assert.deepEqual(dom.focused, [])
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 }) await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(dom.focused, ['Weiter']) assert.deepEqual(dom.focused, [NEXT])
assert.deepEqual(dom.options, [{ preventScroll: true }]) assert.deepEqual(dom.options, [{ preventScroll: true }])
} finally { dom.restore(); ui.end() } } finally { dom.restore(); ui.end() }
}) })
test('Alle anzeigen (W1): nach dem Verlassen der Einzelansicht bekommt der Raumfilter den Fokus, nicht beim Einhängen und nicht bei einem anderen Weg zur Liste', async () => { test('Alle anzeigen (W1): nach dem Verlassen der Einzelansicht bekommt der aktive Raum-Reiter den Fokus, nicht beim Einhängen und nicht bei einem anderen Weg zur Liste', async () => {
const api = fakeApi() const api = fakeApi()
const dom = fakeFocus() const dom = fakeFocus()
let cleared = 0 let cleared = 0
@@ -579,12 +517,12 @@ test('Alle anzeigen (W1): nach dem Verlassen der Einzelansicht bekommt der Raumf
assert.equal(cleared, 1) assert.equal(cleared, 1)
assert.deepEqual(dom.focused, []) // der Knopf steht noch, bis die Seite bot aus der Adresse nimmt assert.deepEqual(dom.focused, []) // der Knopf steht noch, bis die Seite bot aus der Adresse nimmt
ui.setProps({ bot: '' }); await settle() ui.setProps({ bot: '' }); await settle()
assert.deepEqual(dom.focused, ['select']) assert.deepEqual(dom.focused, ['reiter']) // UMGESTELLT: der aktive Reiter („Alle Räume“) statt der Auswahlliste
assert.equal(ui.select().props['aria-label'], 'Raum') assert.equal(ui.reiter()[ui.activeIndex()].props['aria-label'].startsWith('Alle Räume'), true)
// ein weiterer Wechsel in die Einzelansicht und zurück ohne den Knopf (Zurück-Taste des Browsers): kein Fokussprung // ein weiterer Wechsel in die Einzelansicht und zurück ohne den Knopf (Zurück-Taste des Browsers): kein Fokussprung
ui.setProps({ bot: 'p2' }); await settle() ui.setProps({ bot: 'p2' }); await settle()
ui.setProps({ bot: '' }); await settle() ui.setProps({ bot: '' }); await settle()
assert.deepEqual(dom.focused, ['select']) assert.deepEqual(dom.focused, ['reiter'])
} finally { dom.restore(); ui.end() } } finally { dom.restore(); ui.end() }
}) })
+39 -17
View File
@@ -79,26 +79,48 @@ test('pageInfo: Seiten, Blättern und Beschriftung', () => {
assert.deepEqual(pageInfo({}), { 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"', () => { // Quelltext des Kellers (BotLeadList.jsx und keller/*, Hustler-Pixelwelt T10): die Zeilen-Ableitung steckt in src/lib/hustlerKeller.js (eintragModell → botleadRow),
const src = read('src/components/workers/BotLeadList.jsx') // Links gibt es nur in der Akte. UMGESTELLT gegenüber der alten Tabelle: die Prüfungen laufen über alle Dateien des Kellers statt über BotLeadList.jsx allein.
assert.match(src, /botleadRow\(/) const KELLER_DATEIEN = [
assert.match(src, /rel="noopener noreferrer"/); assert.match(src, /target="_blank"/) 'src/components/workers/BotLeadList.jsx',
// jeder href kommt aus row.sourceHref (botleadRow → safeHref), nie aus dem Eintrag selbst 'src/components/workers/keller/KellerSzene.jsx',
assert.match(src, /href=\{row\.sourceHref\}/) 'src/components/workers/keller/RegisterBuch.jsx',
assert.doesNotMatch(src, /\bhref=\{(?!row\.sourceHref\})/) 'src/components/workers/keller/Akte.jsx',
assert.doesNotMatch(src, /\bhref="/) ]
assert.doesNotMatch(src, /dangerouslySetInnerHTML|innerHTML/)
// Quelltext ohne Kommentare (die Kommentare nennen auch, was es nicht gibt, etwa „kein innerHTML“)
const ohneKommentare = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
test('Keller: Links nur aus der Zeilen-Ableitung, extern mit rel noopener, kein HTML-Einschub, Knöpfe mit type="button"', () => {
const quellen = KELLER_DATEIEN.map((p) => ({ p, src: ohneKommentare(read(p)) }))
const alle = quellen.map((q) => q.src).join('\n')
// die Ableitung läuft über botleadRow (safeHref für die Quelle, Telefonregel, Datum)
assert.match(ohneKommentare(read('src/components/workers/BotLeadList.jsx')), /eintragModell\(/)
assert.match(read('src/lib/hustlerKeller.js'), /botleadRow\(/)
// genau ein Link im ganzen Keller: die Quelle in der Akte, extern in neuem Tab ohne Rückbezug
const akte = ohneKommentare(read('src/components/workers/keller/Akte.jsx'))
assert.match(akte, /rel="noopener noreferrer"/); assert.match(akte, /target="_blank"/)
// jeder href kommt aus e.sourceHref (eintragModell → botleadRow → safeHref), nie aus dem Eintrag selbst
assert.match(akte, /href=\{e\.sourceHref\}/)
assert.equal((alle.match(/\bhref=/g) || []).length, 1)
assert.doesNotMatch(alle, /\bhref=\{(?!e\.sourceHref\})/)
assert.doesNotMatch(alle, /\bhref="/)
assert.equal((alle.match(/<a\b/g) || []).length, 1)
assert.doesNotMatch(alle, /dangerouslySetInnerHTML|innerHTML/)
// kein Anruf-Link, kein Mailversand // kein Anruf-Link, kein Mailversand
assert.doesNotMatch(src, /tel:|mailto:/) assert.doesNotMatch(alle, /tel:|mailto:/)
// WOMS-Button hat keinen Standard-type: jeder Knopf nennt ihn // WOMS-Button hat keinen Standard-type: jeder Knopf nennt ihn
assert.equal((src.match(/<(?:button|Button)\b/g) || []).length > 0, true) for (const { p, src } of quellen) {
assert.equal((src.match(/<(?:button|Button)\b/g) || []).length, (src.match(/<(?:button|Button)\b[^>]*type="button"/g) || []).length) assert.equal((src.match(/<(?:button|Button)\b/g) || []).length, (src.match(/<(?:button|Button)\b[^>]*type="button"/g) || []).length, p)
}
assert.ok((alle.match(/<button\b/g) || []).length > 0)
// kein Polling // kein Polling
assert.doesNotMatch(src, /setInterval/) assert.doesNotMatch(alle, /setInterval/)
}) })
test('BotLeadList.jsx: Zeilen ohne Kennung teilen sich keinen key (Ausweichwert aus der Position), aria-current nur in der Einzelansicht', () => { test('Keller: Zeilen ohne Kennung teilen sich keinen key (Ausweichwert aus der Position), aria-current nur in der Einzelansicht', () => {
const src = read('src/components/workers/BotLeadList.jsx') const src = ohneKommentare(read('src/components/workers/keller/RegisterBuch.jsx'))
assert.match(src, /key=\{row\.id \|\| /) assert.match(src, /key=\{z\.id \|\| /)
assert.match(src, /aria-current=\{single && row\.id === botId \?/) assert.match(src, /aktuell=\{einzeln && z\.id === botId\}/)
assert.match(src, /aria-current=\{aktuell \? 'true' : undefined\}/)
}) })
+197
View File
@@ -0,0 +1,197 @@
// Läufer für die Tests des Kellers (BotLeadList, keller/*): bündelt die Komponente mit esbuild und dem Mini-Läufer fakeReact (React und DOM gibt es unter Node nicht),
// rendert in einen Baum aus { tag, props, children }; Eingaben, Klicks und Tasten ruft der Test von Hand auf, die Zeit (window.setTimeout) läuft von Hand, der Dienst
// ist eine Attrappe mit offenen Versprechen. Gemeinsam für tests/unit/botleadList.test.mjs und tests/unit/hustlerKeller.test.mjs.
import assert from 'node:assert/strict'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'
import { fakeReact } from './fakeReact.mjs'
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')
const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
export const settle = () => new Promise((resolve) => setImmediate(resolve))
// Die Komponente wird einmal gebündelt; jeder Test hängt sie neu ein (eigener Zustand)
let bundle
export const loadUi = () => (bundle ??= esbuild.build({
stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as BotLeadList } from './src/components/workers/BotLeadList.jsx'", resolveDir: ROOT, loader: 'js' },
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact],
}).then(importBundle))
// ---------- Baum durchsuchen ----------
export const nodes = (node, match, out = []) => {
if (node === null || typeof node === 'string') return out
if (match(node)) out.push(node)
for (const child of node.children) nodes(child, match, out)
return out
}
export const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join(''))
export const classesOf = (n) => String(n.props.className ?? '').split(' ').filter(Boolean)
export const hasClass = (n, name) => classesOf(n).includes(name)
export const byClass = (tree, name) => nodes(tree, (n) => hasClass(n, name))
// Knopf nach Beschriftung: aria-label oder sichtbarer Text
export const buttons = (tree, label) => nodes(tree, (n) => n.tag === 'button' && (n.props['aria-label'] === label || textOf(n).trim() === label))
// alle hk-Klassen eines Baums
export const allClasses = (tree) => {
const set = new Set()
nodes(tree, (n) => { for (const c of classesOf(n)) if (c.startsWith('hk-')) set.add(c); return false })
return set
}
// Dienst-Attrappe: jeder Aufruf von botleads ist ein offenes Versprechen, das der Test selbst auflöst
export function fakeApi() {
const calls = []
return { calls, botleads: (params) => new Promise((resolve, reject) => { calls.push({ params, resolve, reject }) }) }
}
// DOM-Attrappe für den Fokus: document mit body, activeElement und querySelector; ref an dem, was die Komponente anfasst, liefert __fakeRef.
// Schlüssel eines Elements: „reiter“ (der aktive Raum-Reiter, aria-pressed), sonst die aria-label des Knopfs („Nächste Seite“, „Schließen“ …) oder sein Text, sonst der Tag.
// Ein Element gilt nur, solange es im Baum steht (isConnected, wie im Browser); steht es beim nächsten Render nicht im Baum der Runde davor (Seitenzeile während des
// Ladens), ist es danach ein neues Element, so wie React die Knöpfe neu einhängt. focus() zählt nur für ein eingehängtes Element, merkt sich Schlüssel und Argument
// und setzt activeElement. Zeilen des Buchs gibt es über querySelector('[data-hk-zeile="<Nr>"]') (Schlüssel „zeile <Nr>“), wie der Browser sie der Akte liefert.
export function fakeFocus() {
const body = { name: 'body' }
const doc = { body, documentElement: { name: 'html' }, activeElement: body }
const f = { doc, body, focused: [], options: [], tree: () => null, view: null, made: new Map() }
const keyOf = (tag, props) => {
if (tag !== 'button') return tag
if (props['aria-pressed'] !== undefined) return 'reiter'
return String(props['aria-label'] ?? props.children)
}
const live = (tag, key) => nodes(f.tree(), (n) => n.tag === tag && keyOf(tag, n.props) === key).length > 0
const had = Object.hasOwn(globalThis, 'document')
const saved = globalThis.document
globalThis.document = doc
globalThis.__fakeRef = (tag, props) => {
const key = keyOf(tag, props)
const known = f.made.get(key)
if (!known || !live(tag, key)) { // während dieser Runde zeigt f.tree() noch die Runde davor
f.made.set(key, {
key, props,
get disabled() { return this.props.disabled === true },
get isConnected() { return f.made.get(key) === this && live(tag, key) },
focus(options) { if (this.isConnected) { f.focused.push(key); f.options.push(options); doc.activeElement = this } },
})
}
f.made.get(key).props = props
return f.made.get(key)
}
doc.querySelector = (sel) => {
const m = /^\[data-hk-zeile="(\d+)"\]$/.exec(sel)
if (!m) return null
const nr = Number(m[1])
if (nodes(f.tree(), (n) => n.tag === 'button' && n.props['data-hk-zeile'] === nr).length === 0) return null
const key = `zeile ${nr}`
return { key, isConnected: true, disabled: false, focus(options) { f.focused.push(key); f.options.push(options); doc.activeElement = this } }
}
// ein Element, das der Nutzer von Hand ansteuert (Suchfeld, Link …): eingehängt, bedienbar
f.other = (name) => ({ name, isConnected: true, disabled: false, focus() { doc.activeElement = this } })
f.attach = (view) => { f.view = view; f.tree = () => view.tree() }
// Der Nutzer drückt einen Knopf: der Browser gibt ihm den Fokus, dann läuft der Klick
// (Knöpfe ohne ref, z. B. „Erneut versuchen“, sind nur so lange Element, wie ein Knopf dieser Beschriftung im Baum steht)
f.press = async (label) => {
doc.activeElement = f.made.get(label) ?? { name: label, disabled: false, get isConnected() { return buttons(f.tree(), label).length > 0 }, focus() { doc.activeElement = this } }
await f.view.click(label)
}
// Der Nutzer klickt eine Zeile des Buchs (Fokus auf der Zeile, dann der Klick)
f.pressRow = async (nr) => {
const key = `zeile ${nr}`
const el = { key, isConnected: true, disabled: false, focus(options) { f.focused.push(key); f.options.push(options); doc.activeElement = this } }
doc.activeElement = el
await f.view.openRow(nr)
return el
}
f.restore = () => {
if (had) globalThis.document = saved
else delete globalThis.document
delete globalThis.__fakeRef
}
return f
}
// ---------- Einträge des Dienstes (Form von GET /botleads) ----------
export const ITEM = {
id: 'p1', room: 'handwerk', roomLabel: 'Handwerk', name: 'Elektro Beispiel GmbH', postalCode: '67655', city: 'Kaiserslautern', score: 82,
reason: 'keine veröffentlichte Mail', sourceUrl: 'https://beispiel-elektro.de/impressum', phone: '0631 111', phoneSource: 'https://beispiel-elektro.de/impressum',
isHealth: false, foundAt: '2026-10-19T20:00:00Z', gekuerzt: false,
}
export const PRAXIS = { ...ITEM, id: 'p2', room: 'aerzte', roomLabel: 'Ärzte & Praxen', name: 'Praxis Dr. Muster', isHealth: true, phone: '', phoneSource: '', sourceUrl: 'javascript:alert(1)', foundAt: '2026-10-18T20:00:00Z' }
export const KUERZ = { ...ITEM, id: 'p3', name: '[gekürzt]', gekuerzt: true, phone: '', phoneSource: '', sourceUrl: '' }
// ---------- Ein Lauf: Komponente einhängen, window.setTimeout von Hand, Props zwischendurch änderbar (rerender) ----------
export async function start(api, initial = {}) {
const { __mount, jsx, BotLeadList } = await loadUi()
const timers = new Map()
let nextTimer = 1
const saved = globalThis.window
globalThis.window = {
setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id },
clearTimeout: (id) => { timers.delete(id) },
}
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() },
// Zeilen des Buchs (Knöpfe) und ihre Teile
rows: () => nodes(view.tree(), (n) => n.tag === 'button' && hasClass(n, 'hk-zeile')),
parts: (row) => {
const zwei = byClass(row, 'hk-zwei-zeilen')
return {
name: textOf(byClass(row, 'hk-kl-name')[0]),
sub: textOf(byClass(row, 'hk-klein')[0]),
score: textOf(byClass(row, 'hk-kl-punkte-text')[0]),
reason: textOf(zwei[0]),
phone: textOf(zwei[1]),
raumIcon: row.children[0].props.className,
label: row.props['aria-label'],
}
},
// Raum-Reiter (Knöpfe mit aria-pressed) in der Reihenfolge der Leiste
reiter: () => nodes(view.tree(), (n) => n.tag === 'button' && n.props['aria-pressed'] !== undefined),
activeIndex: () => view.reiter().findIndex((r) => r.props['aria-pressed'] === true),
search: () => nodes(view.tree(), (n) => n.tag === 'input')[0],
errors: () => byClass(view.tree(), 'hk-kl-fehler').map((n) => ({ text: textOf(n), role: n.props.role })),
async pick(index) { view.reiter()[index].props.onClick({}); await settle() },
async type(value) { view.search().props.onChange({ target: { value } }); await settle() },
async click(label) {
const found = buttons(view.tree(), label)
assert.equal(found.length, 1, `Knopf „${label}“ (gefunden: ${found.length})`)
if (!found[0].props.disabled) found[0].props.onClick({ preventDefault() {} })
await settle()
},
async openRow(nr) { view.rows()[nr].props.onClick({ preventDefault() {} }); await settle() },
disabled: (label) => buttons(view.tree(), label).map((n) => n.props.disabled === true),
fireTimers() { for (const [id, t] of [...timers]) { timers.delete(id); t.fn() } },
// Akte: der Dialog, seine Teile, Tasten am äußeren Rahmen
akte: () => nodes(view.tree(), (n) => n.props.role === 'dialog')[0] ?? null,
akteDl: () => {
const dl = byClass(view.tree(), 'hk-kl-akte-dl')[0]
const out = {}
let name = null
for (const c of dl ? dl.children : []) {
if (c === null || typeof c === 'string') continue
if (c.tag === 'dt') name = textOf(c)
else if (c.tag === 'dd') out[name] = c
}
return out
},
async key(key, extra = {}) {
const ueber = byClass(view.tree(), 'hk-kl-akte-ueber')[0]
assert.ok(ueber, 'keine Akte offen')
const ev = { key, shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, currentTarget: ueber, prevented: false, preventDefault() { ev.prevented = true }, stopPropagation() {}, ...extra }
ueber.props.onKeyDown(ev)
await settle()
return ev
},
// der Dienst antwortet auf den n-ten Aufruf
async answer(n, data) { api.calls[n].resolve(data); await settle() },
async fail(n, err) { api.calls[n].reject(err); await settle() },
lateUpdates: () => mounted.lateUpdates(),
end() { mounted.unmount(); globalThis.window = saved },
}
await settle()
return view
}
+101
View File
@@ -0,0 +1,101 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { loadModule } from './load.mjs'
// Gründe der Bot-Lead-Liste (src/lib/botleadGruende.js, Hustler-Pixelwelt T10, Spec E12): Grund-Text des Dienstes -> Symbol und Erklärung für die Akte.
// Die Fixture tests/fixtures/botlead-gruende.json trägt alle 14 Texte des Dienstes (workers-dev-p6/src/scoring/business.js, GRUND, Stand d0d66f9); sie ist unabhängig vom Code unter Test.
const { grundInfo, grundBekannt, ALLGEMEIN, GRUND_SYMBOLE } = await loadModule('src/lib/botleadGruende.js')
const fixture = JSON.parse(readFileSync(new URL('../fixtures/botlead-gruende.json', import.meta.url), 'utf8'))
const read = (p) => readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
// Symbol je Grund (Tabelle GRUENDE der Zeichenfläche, keller/entwurf/logik.js; „Lauf unterbrochen“ bekommt das Pausen-Symbol)
const SYMBOL = {
KEINE_MAIL: 'brief-x', MX: 'brief-zurueck', KETTE: 'kette', WEBSITE_GUT: 'haken', SCHWELLE: 'pfeil-runter', VERALTET: 'uhr', BEKANNT: 'doppelt',
BEKANNT_NAME: 'doppelt', GESPERRT: 'stopp', LESEN: 'seite-kaputt', IMPRESSUM_FREMD: 'fremd', ABGEBROCHEN: 'pause', BEWERTUNG_AUSGEFALLEN: 'waage-kaputt', NAME_FEHLT: 'etikett-leer',
}
// Klassen aus keller.css und hustler-keller-ui.css (ohne Kommentare und url(...))
const cssKlassen = (datei) => new Set((read(datei).replace(/\/\*[\s\S]*?\*\//g, '').replace(/url\([^)]*\)/g, '').match(/\.hk-[A-Za-z0-9_-]+/g) ?? []).map((k) => k.slice(1)))
const CSS = new Set([...cssKlassen('src/styles/hustler/keller.css'), ...cssKlassen('src/styles/hustler/hustler-keller-ui.css')])
test('Fixture: 14 Texte des Dienstes mit Quelle und Stand', () => {
assert.match(fixture.quelle, /scoring\/business\.js/)
assert.match(fixture.quelle, /d0d66f9/)
assert.equal(fixture.stand, 'd0d66f9')
const texte = Object.values(fixture.gruende)
assert.equal(texte.length, 14)
assert.equal(new Set(texte).size, 14)
assert.ok(texte.every((t) => typeof t === 'string' && t.trim() === t && t !== ''))
assert.deepEqual(Object.keys(fixture.gruende).sort(), Object.keys(SYMBOL).sort())
assert.equal(fixture.gruende.SCHWELLE, 'Punktzahl unter 55') // so, wie der Dienst den Text mit der Grenze bildet
})
test('grundInfo: jeder Grund der Fixture hat sein Symbol und eine eigene Erklärung (nie der allgemeine Satz)', () => {
const saetze = new Set()
for (const [name, text] of Object.entries(fixture.gruende)) {
assert.equal(grundBekannt(text), true, name)
const info = grundInfo(text)
assert.equal(info.icon, `hk-ic hk-ic-${SYMBOL[name]}`, name)
assert.equal(typeof info.erklaerung, 'string')
assert.ok(info.erklaerung.length > 15 && info.erklaerung.endsWith('.'), `${name}: ${info.erklaerung}`)
assert.notEqual(info.erklaerung, ALLGEMEIN.erklaerung, name)
assert.deepEqual(Object.keys(info).sort(), ['erklaerung', 'icon'])
saetze.add(info.erklaerung)
}
assert.equal(saetze.size, 14) // auch die beiden „schon bekannt“ haben je einen eigenen Satz
})
test('grundInfo: Wortlaut der Erklärungen aus der Zeichenfläche, „Lauf unterbrochen“ im selben Ton', () => {
assert.equal(grundInfo('keine veröffentlichte Mail').erklaerung, 'Auf der Website steht keine E-Mail-Adresse. Ohne veröffentlichte Adresse schreibt der Hustler nie.')
assert.equal(grundInfo('Punktzahl unter 55').erklaerung, 'Zu wenige Punkte für eine Mail (Grenze 55).')
assert.equal(grundInfo('Sperrliste').erklaerung, 'Hat widersprochen oder steht auf der Sperrliste.')
assert.equal(grundInfo('Bewertung ausgefallen').erklaerung, 'Bea konnte den Betrieb nicht bewerten.')
const abbruch = grundInfo('Lauf unterbrochen')
assert.equal(abbruch.icon, 'hk-ic hk-ic-pause')
assert.match(abbruch.erklaerung, /unterbrochen/)
assert.match(abbruch.erklaerung, /keine Mail/)
})
test('grundInfo: die Grenze in „Punktzahl unter N“ kommt aus dem Text des Dienstes (ändert er sie, stimmt die Erklärung weiter)', () => {
assert.equal(grundInfo('Punktzahl unter 60').erklaerung, 'Zu wenige Punkte für eine Mail (Grenze 60).')
assert.equal(grundInfo('Punktzahl unter 60').icon, 'hk-ic hk-ic-pfeil-runter')
assert.equal(grundBekannt('Punktzahl unter 60'), true)
for (const nein of ['Punktzahl unter', 'Punktzahl unter x', 'Punktzahl unter 5 5', 'Punktzahl unter 0055', 'Punktzahl unter 55 Punkte']) assert.equal(grundBekannt(nein), false, nein)
})
test('grundInfo: Leerraum am Rand zählt nicht, Groß- und Kleinschreibung schon', () => {
assert.deepEqual(grundInfo(' Sperrliste \n'), grundInfo('Sperrliste'))
assert.equal(grundBekannt('sperrliste'), false)
})
test('grundInfo: unbekannter Text ergibt das neutrale Symbol und einen allgemeinen Satz, nie einen Fehler', () => {
for (const unbekannt of ['irgendein neuer Grund', '', ' ', null, undefined, 42, {}, [], 'constructor', '__proto__', 'toString', 'hasOwnProperty']) {
const info = grundInfo(unbekannt)
assert.deepEqual(info, { icon: 'hk-ic hk-ic-akte', erklaerung: ALLGEMEIN.erklaerung }, String(unbekannt))
assert.equal(grundBekannt(unbekannt), false)
}
assert.match(ALLGEMEIN.erklaerung, /^Der Hustler schreibt diesem Betrieb nicht\./)
})
test('Symbole: jede Klasse, die grundInfo liefern kann, steht in keller.css oder hustler-keller-ui.css', () => {
const klassen = new Set()
for (const text of [...Object.values(fixture.gruende), 'Punktzahl unter 70', 'unbekannt']) {
const { icon } = grundInfo(text)
assert.match(icon, /^hk-ic hk-ic-[a-z-]+$/)
for (const k of icon.split(' ')) klassen.add(k)
}
assert.ok(klassen.size >= 14)
for (const k of klassen) assert.ok(CSS.has(k), `${k} fehlt in keller.css / hustler-keller-ui.css`)
// GRUND_SYMBOLE nennt genau diese Symbole
assert.deepEqual([...GRUND_SYMBOLE].sort(), [...new Set([...Object.values(SYMBOL), 'akte'])].sort())
for (const s of GRUND_SYMBOLE) assert.ok(CSS.has(`hk-ic-${s}`), s)
})
test('botleadGruende.js: rein (kein React, kein DOM, keine Importe), keine Beispieldaten', () => {
const src = read('src/lib/botleadGruende.js').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
assert.doesNotMatch(src, /^\s*import\b/m)
assert.doesNotMatch(src, /\b(?:window|document|fetch)\b/)
assert.doesNotMatch(src, /innerHTML/)
})
+742
View File
@@ -0,0 +1,742 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync, statSync } from 'node:fs'
import path from 'node:path'
import { loadModule } from './load.mjs'
import {
ITEM, KUERZ, PRAXIS, ROOT, allClasses, buttons, byClass, classesOf, fakeApi, fakeFocus, nodes, settle, start, textOf,
} from './helpers/kellerRunner.mjs'
// Der Keller (Hustler-Pixelwelt T10, Spec E12/E13/E14): Szene mit Arne nach Status (keller/KellerSzene.jsx), Register-Buch (keller/RegisterBuch.jsx), Akte
// (keller/Akte.jsx) und die Hülle BotLeadList.jsx (◂ Hochhaus, Schild, „Register zuklappen“). Abruf, Suche, Seiten, Fokus beim Blättern und Einzelansicht prüft
// botleadList.test.mjs; hier steht, was neu dazukommt, und die Ordnung von Klassen, Stilen und Quelltext.
const { ROOM_FILTERS } = await loadModule('src/lib/botleadsView.js')
const { grundInfo } = await loadModule('src/lib/botleadGruende.js')
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8')
const ohneKommentare = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
const SELBST = { ...ITEM, id: 'p5', room: 'selbst', roomLabel: 'Selbstständige', name: 'Fotostudio Muster', reason: 'Kette oder Franchise', score: 74, foundAt: '2026-10-17T20:00:00Z' }
const LADEN = { ...ITEM, id: 'p6', room: 'laden', roomLabel: 'Läden & Gastro', name: 'Bäckerei Probe', reason: 'Punktzahl unter 55', score: 40, phoneSource: '', foundAt: '2026-10-16T20:00:00Z' }
const SEITE = [ITEM, PRAXIS, KUERZ, SELBST, LADEN]
const eintrag = (status) => (status === 'grau' ? { status: 'grau', task: '', lastTs: null, connected: false, counters: {} } : { status, task: '', lastTs: null, connected: true, counters: {} })
const idx = (room) => ROOM_FILTERS.findIndex((f) => f.value === room)
// ---------------------------------------------------------------------------------------------------------------
// Szene: Arne nach Status
async function szene(workers) {
const api = fakeApi()
const ui = await start(api, workers === undefined ? {} : { workers })
return { api, ui }
}
const sz = (ui) => byClass(ui.tree(), 'hk-kl-szene')[0]
const has = (ui, name) => byClass(ui.tree(), name).length > 0
const RUHENDE = ['hk-sz-rohrpost', 'hk-sz-schublade', 'hk-sz-buch-stempel']
test('Szene: Wurzel .hk-keller, die Szene ist Zierde (aria-hidden) und hat alle Ebenen der Vorlage in der Reihenfolge der Vorlage', async () => {
const { ui } = await szene({ arne: eintrag('arbeitet') })
try {
const tree = ui.tree()
assert.equal(tree.tag, 'div')
assert.deepEqual(classesOf(tree), ['hk-keller'])
assert.equal(sz(ui).props['aria-hidden'], 'true')
// Ebenen: Raum, Fenster mit Schnee/Passant/Kreuz, Dampf, Stempel, Rohrpost, Schublade, Maus, Blinkpunkte, Licht (Tisch, zwei Lampen, Halle), Staub
const ebenen = byClass(sz(ui), 'hk-sz-ebene').map((n) => classesOf(n).find((c) => c !== 'hk-sz-ebene'))
assert.deepEqual(ebenen, ['hk-sz-bild', 'hk-sz-licht-tisch', 'hk-sz-licht-92', 'hk-sz-licht-236', 'hk-sz-licht-halle', 'hk-sz-staub'])
for (const c of ['hk-sz-fensterschnee', 'hk-sz-passant-weg', 'hk-sz-passant', 'hk-sz-kreuz', 'hk-sz-dampf', 'hk-sz-maus-weg', 'hk-sz-maus']) assert.equal(byClass(sz(ui), c).length, 1, c)
assert.equal(byClass(sz(ui), 'hk-sz-led').length, 6)
// die Reihenfolge der Dinge in der Bühne: Raum zuerst, Licht zuletzt (Licht liegt über Arne und Maus)
const kinder = byClass(sz(ui), 'hk-kl-buehne')[0].children.map((n) => classesOf(n).join(' '))
assert.ok(kinder.indexOf('hk-arne-weg') < kinder.indexOf('hk-sz-ebene hk-sz-licht-tisch'))
assert.equal(kinder[0], 'hk-sz-ebene hk-sz-bild')
assert.equal(kinder.at(-1), 'hk-sz-ebene hk-sz-staub')
// Blinkpunkte: Animationen mit den umbenannten Keyframes (hk-k-k-led-…)
const led = byClass(sz(ui), 'hk-sz-led').map((n) => n.props.style.animation.split(' ')[0])
assert.deepEqual(led, ['hk-k-k-led-weiche-gruen', 'hk-k-k-led-weiche-bernstein', 'hk-k-k-led-sicherung-1', 'hk-k-k-led-sicherung-2', 'hk-k-k-led-sicherung-3', 'hk-k-k-led-verteiler'])
} finally { ui.end() }
})
test('Arne arbeitet: Ablauf-Schleife samt Rohrpost, Stempel und Schublade, kein Haus-Figur, kein Marker', async () => {
const { ui } = await szene({ arne: eintrag('arbeitet') })
try {
assert.equal(sz(ui).props['data-hk-arne'], 'arbeitet')
assert.equal(byClass(sz(ui), 'hk-arne-weg').length, 1)
assert.equal(byClass(byClass(sz(ui), 'hk-arne-weg')[0], 'hk-arne-bild').length, 1)
assert.equal(has(ui, 'hk-kl-arne'), false)
assert.equal(has(ui, 'hk-alarm'), false)
for (const c of RUHENDE) {
const n = byClass(sz(ui), c)
assert.equal(n.length, 1, c)
assert.equal(classesOf(n[0]).includes('hk-kl-ruht'), false, `${c} läuft`)
}
} finally { ui.end() }
})
test('Arne arbeitet nicht: Haus-Figur in der Pose des Status am Pult, Rohrpost, Stempel und Schublade ruhen', async () => {
const faelle = [
{ status: 'wartet', pose: 'warten', punkte: true, zzz: false, alarm: false },
{ status: 'leerlauf', pose: 'schlafen', punkte: false, zzz: true, alarm: false },
{ status: 'grau', pose: 'nicht', punkte: false, zzz: false, alarm: false },
{ status: 'fehler', pose: 'nicht', punkte: false, zzz: false, alarm: true },
]
for (const f of faelle) {
const { ui } = await szene({ arne: eintrag(f.status) })
try {
assert.equal(sz(ui).props['data-hk-arne'], f.status)
assert.equal(has(ui, 'hk-arne-weg'), false, f.status)
assert.equal(has(ui, 'hk-arne-bild'), false, f.status)
const figur = byClass(sz(ui), 'hk-kl-fig')
assert.equal(figur.length, 1, f.status)
assert.deepEqual(classesOf(figur[0]), ['hk-spr', 'hk-kl-fig', `hk-kl-fig-${f.pose}`], f.status)
assert.equal(has(ui, 'hk-kl-punkte'), f.punkte, `${f.status} punkte`)
assert.equal(has(ui, 'hk-kl-zzz'), f.zzz, `${f.status} zzz`)
assert.equal(has(ui, 'hk-alarm'), f.alarm, `${f.status} alarm`)
if (f.alarm) assert.equal(textOf(byClass(sz(ui), 'hk-alarm')[0]), '!')
for (const c of RUHENDE) assert.ok(classesOf(byClass(sz(ui), c)[0]).includes('hk-kl-ruht'), `${f.status}: ${c} ruht nicht`)
// Arne steht an seinem Pult: Lage aus ARNE_RUHE
const platz = byClass(sz(ui), 'hk-kl-arne')[0].props.style
assert.deepEqual([platz.position, platz.left, platz.top], ['absolute', '116px', '286px'])
} finally { ui.end() }
}
})
test('Arne ohne Zustand (Tabelle noch leer, kein Eintrag): er schläft; der Status wechselt mit den Daten der Seite', async () => {
const { ui } = await szene(undefined)
try {
assert.equal(sz(ui).props['data-hk-arne'], 'leerlauf')
assert.ok(has(ui, 'hk-kl-fig-schlafen') && has(ui, 'hk-kl-zzz'))
ui.setProps({ workers: { arne: eintrag('arbeitet') } })
assert.ok(has(ui, 'hk-arne-weg') && !has(ui, 'hk-kl-fig'))
ui.setProps({ workers: { arne: eintrag('fehler') } })
assert.ok(!has(ui, 'hk-arne-weg') && has(ui, 'hk-kl-fig-nicht') && has(ui, 'hk-alarm'))
ui.setProps({ workers: { arne: eintrag('wartet') } })
assert.ok(has(ui, 'hk-kl-fig-warten') && has(ui, 'hk-kl-punkte') && !has(ui, 'hk-alarm'))
ui.setProps({ workers: {} })
assert.ok(has(ui, 'hk-kl-fig-schlafen'))
} finally { ui.end() }
})
test('Szene: Einpassen „cover“ aus der Größe des Rahmens, folgt dem Beobachter und löst ihn beim Abbau', async () => {
const rect = { width: 1280, height: 720 }
const beobachter = []
let getrennt = 0
const hatte = Object.hasOwn(globalThis, 'ResizeObserver')
const alt = globalThis.ResizeObserver
globalThis.ResizeObserver = class { constructor(cb) { this.cb = cb; this.ziele = []; beobachter.push(this) } observe(el) { this.ziele.push(el) } disconnect() { getrennt += 1 } }
globalThis.__fakeRef = (tag, props) => (props.className === 'hk-kl-szene' ? { getBoundingClientRect: () => ({ ...rect }) } : null)
const api = fakeApi()
const ui = await start(api, {})
try {
const transform = () => byClass(ui.tree(), 'hk-kl-buehne')[0].props.style.transform
assert.equal(beobachter.length, 1)
assert.equal(beobachter[0].ziele.length, 1)
assert.equal(transform(), 'translate(0.0px, 0.0px) scale(2.0000)')
rect.width = 1920; rect.height = 1080
beobachter[0].cb(); await settle()
assert.equal(transform(), 'translate(0.0px, 0.0px) scale(3.0000)')
rect.width = 2560; rect.height = 720
beobachter[0].cb(); await settle()
assert.equal(transform(), 'translate(0.0px, -576.0px) scale(4.0000)')
// Handy hochkant: links verankert
rect.width = 390; rect.height = 844
beobachter[0].cb(); await settle()
assert.match(transform(), /^translate\(-166\.\d+px, 0\.0px\) scale\(2\.3444\)$/)
// ein Rahmen ohne Größe (noch nicht gelegt) lässt alles, wie es ist
const davor = transform()
rect.width = 0; rect.height = 0
beobachter[0].cb(); await settle()
assert.equal(transform(), davor)
assert.equal(getrennt, 0)
} finally { ui.end(); delete globalThis.__fakeRef; if (hatte) globalThis.ResizeObserver = alt; else delete globalThis.ResizeObserver }
assert.equal(getrennt, 1)
})
// ---------------------------------------------------------------------------------------------------------------
// Hülle: ◂ Hochhaus, Schild, Register zuklappen
test('◂ Hochhaus ruft onBack; ohne onBack bricht nichts', async () => {
let zurueck = 0
const mit = await start(fakeApi(), { onBack: () => { zurueck += 1 } })
try {
const knopf = buttons(mit.tree(), 'Zurück zum Hochhaus')
assert.equal(knopf.length, 1)
assert.equal(knopf[0].props.type, 'button')
assert.ok(textOf(knopf[0]).includes('Hochhaus'))
assert.deepEqual(byClass(knopf[0], 'hk-ic').map((n) => classesOf(n).join(' ')), ['hk-ic hk-ic-zurueck'])
await mit.click('Zurück zum Hochhaus')
assert.equal(zurueck, 1)
} finally { mit.end() }
const ohne = await start(fakeApi(), {})
try {
await ohne.click('Zurück zum Hochhaus') // keine Prop onBack: nichts geschieht, nichts bricht
} finally { ohne.end() }
// auch in der Einzelansicht und bei einem Fehler bleibt der Weg zurück
const einzeln = await start(fakeApi(), { bot: 'p1', onClearBot: () => {}, onBack: () => { zurueck += 1 } })
try {
await einzeln.fail(0, new Error('weg'))
await einzeln.click('Zurück zum Hochhaus')
assert.equal(zurueck, 2)
} finally { einzeln.end() }
})
test('Schild: Überschrift „Bot-Leads“ und die Zahl aus kpis.botLeads (1.234 mit Punkt), ohne brauchbare Zahl keine Zahl', async () => {
const faelle = [[{ botLeads: 112 }, '112'], [{ botLeads: 1234 }, '1.234'], [{ botLeads: 0 }, '0'], [undefined, null], [{}, null], [{ botLeads: NaN }, null], [{ botLeads: '5' }, null], [null, null]]
for (const [kpis, erwartet] of faelle) {
const api = fakeApi()
const ui = await start(api, { kpis })
try {
assert.equal(textOf(nodes(ui.tree(), (n) => n.tag === 'h1')[0]), 'Bot-Leads')
const zahl = byClass(ui.tree(), 'hk-kl-zahl')
assert.deepEqual(zahl.map(textOf), erwartet === null ? [] : [erwartet], JSON.stringify(kpis))
} finally { ui.end() }
}
})
test('Register zuklappen: nur die Szene bleibt, nichts wird neu abgerufen, Raum und Suche bleiben beim Aufklappen', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: [ITEM, PRAXIS], total: 2, pageSize: 50 })
await ui.pick(idx('handwerk'))
await ui.answer(1, { items: [ITEM], total: 1, pageSize: 50 })
await ui.type('elektro')
assert.equal(api.calls.length, 2)
const toggle = () => buttons(ui.tree(), 'Register zuklappen')
assert.equal(toggle().length, 1)
assert.equal(toggle()[0].props['aria-expanded'], true)
assert.equal(toggle()[0].props.type, 'button')
assert.equal(byClass(ui.tree(), 'hk-buch').length, 1)
await ui.click('Register zuklappen')
assert.equal(byClass(ui.tree(), 'hk-buch').length, 0)
assert.equal(ui.rows().length, 0)
assert.equal(ui.reiter().length, 0)
assert.equal(ui.search(), undefined)
assert.equal(byClass(ui.tree(), 'hk-kl-szene').length, 1) // die Szene steht ganz
const auf = buttons(ui.tree(), 'Register aufklappen')
assert.equal(auf.length, 1)
assert.equal(auf[0].props['aria-expanded'], false)
assert.ok(textOf(auf[0]).includes('Register aufklappen'))
assert.equal(buttons(ui.tree(), 'Register zuklappen').length, 0)
assert.equal(byClass(ui.tree(), 'hk-kl-schild').length, 1) // Kopfzeile bleibt
assert.equal(api.calls.length, 2) // nichts abgerufen
await ui.click('Register aufklappen')
assert.equal(api.calls.length, 2)
assert.equal(ui.rows().length, 1)
assert.equal(ui.activeIndex(), idx('handwerk'))
assert.equal(ui.search().props.value, 'elektro')
assert.equal(buttons(ui.tree(), 'Register zuklappen').length, 1)
} finally { ui.end() }
})
test('Register zuklappen: auch beim Laden, im Fehler und in der Einzelansicht; Antworten kommen trotzdem an', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.click('Register zuklappen') // die erste Antwort steht noch aus
await ui.answer(0, { items: [ITEM], total: 1 })
assert.equal(ui.rows().length, 0)
await ui.click('Register aufklappen')
assert.equal(ui.rows().length, 1)
assert.equal(api.calls.length, 1)
} finally { ui.end() }
})
// ---------------------------------------------------------------------------------------------------------------
// Akte
test('Akte: Dialog zur gewählten Zeile mit Raum, Scout, Name, Ort, Punkten, Grund samt Erklärung, Telefon, Quelle und Funddatum', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: SEITE, total: 5, pageSize: 50 })
assert.equal(ui.akte(), null)
await ui.openRow(0)
const dialog = ui.akte()
assert.equal(dialog.tag, 'section')
assert.equal(dialog.props.role, 'dialog')
assert.equal(dialog.props['aria-modal'], 'true')
assert.equal(dialog.props['aria-label'], 'Akte Elektro Beispiel GmbH')
assert.equal(dialog.props.tabIndex, -1)
assert.equal(textOf(byClass(dialog, 'hk-kl-akte-raum')[0]), 'Handwerk · gefunden von Hanna')
assert.equal(textOf(nodes(dialog, (n) => n.tag === 'h2')[0]), 'Elektro Beispiel GmbH')
assert.equal(textOf(byClass(dialog, 'hk-kl-akte-ort')[0]), '67655 Kaiserslautern')
const dl = ui.akteDl()
assert.deepEqual(Object.keys(dl), ['Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden'])
assert.equal(textOf(byClass(dl.Punkte, 'hk-kl-punkte-text')[0]), '82 Punkte')
assert.deepEqual(byClass(dl.Punkte, 'hk-kl-balken-fuell').map((n) => [classesOf(n).join(' '), n.props.style.width]), [['hk-kl-balken-fuell hk-kl-farbe-gut', 79]])
assert.equal(textOf(byClass(dl.Grund, 'hk-kl-akte-grundtext')[0]), 'keine veröffentlichte Mail')
assert.equal(textOf(byClass(dl.Grund, 'hk-kl-akte-erklaerung')[0]), grundInfo('keine veröffentlichte Mail').erklaerung)
assert.deepEqual(byClass(dl.Grund, 'hk-ic').map((n) => classesOf(n).join(' ')), ['hk-ic hk-ic-brief-x'])
assert.equal(textOf(dl.Telefon), '0631 111')
assert.deepEqual(byClass(dl.Telefon, 'hk-ic').map((n) => classesOf(n).join(' ')), ['hk-ic hk-ic-telefon'])
const link = nodes(dl.Quelle, (n) => n.tag === 'a')
assert.equal(link.length, 1)
assert.deepEqual([link[0].props.href, link[0].props.target, link[0].props.rel, link[0].props['aria-label']], ['https://beispiel-elektro.de/impressum', '_blank', 'noopener noreferrer', 'Quelle öffnen: Elektro Beispiel GmbH'])
assert.equal(textOf(link[0]), 'Quelle öffnen')
assert.equal(textOf(dl.Gefunden), '19.10.2026')
assert.ok(textOf(dialog).includes('Nur zum Nachsehen, hier wird nichts verschickt.'))
// Zeile und Akte gehören zusammen: die Akte kennt Nr. und Seite
assert.ok(textOf(byClass(dialog, 'hk-kl-sr')[0]).startsWith('Akte 1 von 5: Elektro Beispiel GmbH'))
} finally { ui.end() }
})
test('Akte: Heilberuf (Ärzte & Praxen, Ali): nie eine Nummer, Schloss; unsichere Quelle ist kein Link', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: SEITE, total: 5 })
await ui.openRow(1)
assert.equal(textOf(byClass(ui.akte(), 'hk-kl-akte-raum')[0]), 'Ärzte & Praxen · gefunden von Ali')
const dl = ui.akteDl()
assert.equal(textOf(dl.Telefon), 'bewusst nicht gespeichert')
assert.deepEqual(byClass(dl.Telefon, 'hk-ic').map((n) => classesOf(n).join(' ')), ['hk-ic hk-ic-schloss'])
assert.equal(nodes(dl.Quelle, (n) => n.tag === 'a').length, 0)
assert.equal(textOf(dl.Quelle), '–')
assert.equal(nodes(ui.akte(), (n) => n.tag === 'a').length, 0)
} finally { ui.end() }
})
test('Akte: auch ein Heilberuf mit Altbestand (Nummer und Quelle im Datensatz) zeigt die Nummer nie', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: [{ ...PRAXIS, phone: '0631 999', phoneSource: 'https://alt.example' }], total: 1 })
await ui.openRow(0)
assert.equal(textOf(ui.akteDl().Telefon), 'bewusst nicht gespeichert')
assert.ok(!textOf(ui.akte()).includes('0631 999'))
} finally { ui.end() }
})
test('Akte: Scout je Raum (Sven, Lea), Telefon ohne Quelle „nicht verfügbar“, unbekannter Raum ohne Scout, gekürzter Eintrag mit „gekürzt“', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: [...SEITE, { ...ITEM, id: 'p7', room: 'xyz', roomLabel: 'Sonstige' }], total: 6 })
await ui.openRow(3) // selbst
assert.equal(textOf(byClass(ui.akte(), 'hk-kl-akte-raum')[0]), 'Selbstständige · gefunden von Sven')
assert.equal(textOf(ui.akteDl().Telefon), '0631 111')
await ui.click('Nächste Akte') // laden: Nummer ohne Quelle gilt als nicht belegt
assert.equal(textOf(byClass(ui.akte(), 'hk-kl-akte-raum')[0]), 'Läden & Gastro · gefunden von Lea')
assert.equal(textOf(ui.akteDl().Telefon), 'nicht verfügbar')
assert.equal(textOf(byClass(ui.akteDl().Grund, 'hk-kl-akte-erklaerung')[0]), 'Zu wenige Punkte für eine Mail (Grenze 55).')
assert.deepEqual(byClass(ui.akteDl().Punkte, 'hk-kl-balken-fuell').map((n) => classesOf(n).join(' ')), ['hk-kl-balken-fuell hk-kl-farbe-schlecht'])
await ui.click('Nächste Akte') // Raum ohne Scout
assert.equal(textOf(byClass(ui.akte(), 'hk-kl-akte-raum')[0]), 'Sonstige')
await ui.openRow(2) // gekürzt
assert.deepEqual(Object.keys(ui.akteDl()), ['Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden', 'Kürzung'])
assert.equal(textOf(ui.akteDl().Kürzung), 'gekürzt')
assert.equal(textOf(ui.akteDl().Quelle), '–')
assert.equal(ui.akte().props['aria-label'], 'Akte [gekürzt]')
} finally { ui.end() }
})
test('Akte: unbekannter Grund zeigt das neutrale Symbol und den allgemeinen Satz', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: [{ ...ITEM, reason: 'ein ganz neuer Grund' }], total: 1 })
await ui.openRow(0)
const g = ui.akteDl().Grund
assert.equal(textOf(byClass(g, 'hk-kl-akte-grundtext')[0]), 'ein ganz neuer Grund')
assert.deepEqual(byClass(g, 'hk-ic').map((n) => classesOf(n).join(' ')), ['hk-ic hk-ic-akte'])
assert.match(textOf(byClass(g, 'hk-kl-akte-erklaerung')[0]), /^Der Hustler schreibt diesem Betrieb nicht\./)
} finally { ui.end() }
})
test('Akte: Blättern ◂ ▸ innerhalb der Seite (im Kreis), mit den Pfeiltasten, Alt+Pfeil bleibt dem Browser', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: [ITEM, PRAXIS, KUERZ], total: 120, pageSize: 50 })
const name = () => textOf(nodes(ui.akte(), (n) => n.tag === 'h2')[0])
const live = () => textOf(byClass(ui.akte(), 'hk-kl-sr')[0])
await ui.openRow(1)
assert.equal(name(), 'Praxis Dr. Muster')
assert.equal(live(), 'Akte 2 von 3: Praxis Dr. Muster')
await ui.click('Nächste Akte')
assert.equal(name(), '[gekürzt]')
await ui.click('Nächste Akte') // am Ende der Seite wieder von vorn
assert.equal(name(), 'Elektro Beispiel GmbH')
assert.equal(live(), 'Akte 1 von 3: Elektro Beispiel GmbH')
await ui.click('Vorherige Akte') // vom Anfang ans Ende
assert.equal(name(), '[gekürzt]')
const rechts = await ui.key('ArrowRight')
assert.equal(rechts.prevented, true)
assert.equal(name(), 'Elektro Beispiel GmbH')
const links = await ui.key('ArrowLeft')
assert.equal(links.prevented, true)
assert.equal(name(), '[gekürzt]')
for (const mod of [{ altKey: true }, { ctrlKey: true }, { metaKey: true }]) {
const ev = await ui.key('ArrowLeft', mod)
assert.equal(ev.prevented, false)
assert.equal(name(), '[gekürzt]')
}
// die Akte blättert nur in der Seite: kein neuer Abruf
assert.equal(api.calls.length, 1)
// beide Pfeile sind Knöpfe mit Beschriftung (Symbol ohne Text)
for (const label of ['Vorherige Akte', 'Nächste Akte']) {
const b = buttons(ui.akte(), label)
assert.equal(b.length, 1)
assert.equal(b[0].props.type, 'button')
}
// ein einzelner Eintrag: Blättern bleibt, wo es ist
} finally { ui.end() }
const api2 = fakeApi()
const eins = await start(api2, {})
try {
await eins.answer(0, { items: [ITEM], total: 1 })
await eins.openRow(0)
await eins.click('Nächste Akte')
assert.equal(textOf(nodes(eins.akte(), (n) => n.tag === 'h2')[0]), 'Elektro Beispiel GmbH')
await eins.click('Vorherige Akte')
assert.equal(textOf(nodes(eins.akte(), (n) => n.tag === 'h2')[0]), 'Elektro Beispiel GmbH')
} finally { eins.end() }
})
test('Akte schließen: Esc, Schließen-Knopf und Hintergrund; Esc wird verbraucht', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: SEITE, total: 5 })
await ui.openRow(0)
const esc = await ui.key('Escape')
assert.equal(esc.prevented, true)
assert.equal(ui.akte(), null)
await ui.openRow(0)
await ui.click('Schließen')
assert.equal(ui.akte(), null)
await ui.openRow(0)
const hinten = buttons(ui.tree(), 'Akte schließen')
assert.equal(hinten.length, 1)
assert.equal(hinten[0].props.tabIndex, -1) // der Hintergrund-Knopf kommt nicht in die Tab-Reihenfolge
await ui.click('Akte schließen')
assert.equal(ui.akte(), null)
// andere Tasten schließen nichts
await ui.openRow(0)
for (const k of ['Enter', ' ', 'a', 'ArrowUp']) await ui.key(k)
assert.notEqual(ui.akte(), null)
} finally { ui.end() }
})
test('Akte: Fokus auf „Schließen“ beim Öffnen, Tab bleibt im Dialog, beim Schließen zurück auf die Zeile (die zuletzt gezeigte)', async () => {
const api = fakeApi()
const dom = fakeFocus()
const ui = await start(api, {})
dom.attach(ui)
try {
await ui.answer(0, { items: [ITEM, PRAXIS, KUERZ], total: 3 })
await dom.pressRow(1)
assert.deepEqual(dom.focused, ['Schließen'])
assert.deepEqual(dom.options, [{ preventScroll: true }])
assert.equal(dom.doc.activeElement.key, 'Schließen')
// Fokusfalle: Tab am letzten Knopf (Nächste Akte) → Schließen; Shift+Tab am ersten → Nächste Akte; dazwischen darf der Browser selbst weiter
dom.doc.activeElement = dom.made.get('Nächste Akte')
let ev = await ui.key('Tab')
assert.equal(ev.prevented, true)
assert.equal(dom.doc.activeElement.key, 'Schließen')
ev = await ui.key('Tab', { shiftKey: true })
assert.equal(ev.prevented, true)
assert.equal(dom.doc.activeElement.key, 'Nächste Akte')
dom.doc.activeElement = dom.made.get('Schließen')
ev = await ui.key('Tab')
assert.equal(ev.prevented, false) // Schließen → Vorherige Akte: der Browser geht weiter
dom.doc.activeElement = dom.other('Vorherige Akte')
ev = await ui.key('Tab', { shiftKey: true })
assert.equal(ev.prevented, false)
// auch wenn der Fokus auf der Karte selbst liegt, führt Shift+Tab ans Ende
const karte = ui.akte()
dom.doc.activeElement = { name: 'karte' }
ev = await ui.key('Tab', { shiftKey: true, currentTarget: { name: 'karte' } })
assert.equal(ev.prevented, false)
dom.doc.activeElement = dom.other('x')
// in der Akte weiterblättern, dann Esc: der Fokus geht an die Zeile des zuletzt gezeigten Eintrags
await ui.click('Nächste Akte')
assert.equal(karte.props.role, 'dialog')
dom.focused.length = 0; dom.options.length = 0
await ui.key('Escape')
assert.equal(ui.akte(), null)
assert.deepEqual(dom.focused, ['zeile 2'])
assert.equal(dom.doc.activeElement.key, 'zeile 2')
} finally { dom.restore(); ui.end() }
})
test('Akte: findet der Browser die Zeile nicht mehr, geht der Fokus an das Element, das beim Öffnen den Fokus hatte; ohne beides bleibt er unberührt', async () => {
const api = fakeApi()
const dom = fakeFocus()
const ui = await start(api, {})
dom.attach(ui)
try {
await ui.answer(0, { items: [ITEM, PRAXIS], total: 2 })
dom.doc.querySelector = () => null
await dom.pressRow(0)
dom.focused.length = 0
await ui.key('Escape')
assert.deepEqual(dom.focused, ['zeile 0']) // der Opener (Zeilenknopf von pressRow)
// beim Öffnen hatte body den Fokus (Safari fokussiert Knöpfe nicht): ohne Zeile und Opener bleibt der Fokus, wo er ist
dom.doc.activeElement = dom.body
await ui.openRow(1)
dom.focused.length = 0
dom.doc.activeElement = dom.other('Irgendwo')
await ui.key('Escape')
assert.deepEqual(dom.focused, [])
// ohne document (Node, Server) bricht nichts
delete globalThis.document
await ui.openRow(0)
await ui.key('Escape')
assert.equal(ui.akte(), null)
} finally { dom.restore(); ui.end() }
})
test('Akte: eine neue Anfrage (Raum, Suche, Seite, Fehler) schließt sie; sie steht nie über „Wird geladen“ oder einer Fehlermeldung', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: SEITE, total: 120, pageSize: 50 })
await ui.openRow(0)
assert.notEqual(ui.akte(), null)
await ui.pick(idx('laden'))
assert.equal(ui.akte(), null)
assert.ok(ui.text().includes('Wird geladen'))
await ui.answer(1, { items: SEITE, total: 5 })
assert.equal(ui.akte(), null) // sie kommt nicht von selbst wieder
await ui.openRow(4)
await ui.type('x'); ui.fireTimers(); await settle()
assert.equal(ui.akte(), null)
await ui.fail(2, new Error('weg'))
assert.equal(ui.akte(), null)
assert.deepEqual(ui.errors().map((e) => e.text), ['weg'])
await ui.click('Erneut versuchen')
await ui.answer(3, { items: SEITE, total: 120, pageSize: 50 })
assert.equal(ui.akte(), null)
await ui.openRow(0)
await ui.click('Nächste Seite')
assert.equal(ui.akte(), null)
} finally { ui.end() }
})
test('Zeile: ein Knopf (Beschriftung für Screenreader, Nr. für die Fokus-Rückgabe), die Akte öffnet nur durch einen Klick', async () => {
const api = fakeApi()
const ui = await start(api, {})
try {
await ui.answer(0, { items: SEITE, total: 5 })
const rows = ui.rows()
assert.deepEqual(rows.map((r) => r.props['data-hk-zeile']), [0, 1, 2, 3, 4])
assert.equal(rows[1].props['aria-label'], 'Praxis Dr. Muster, 67655 Kaiserslautern, 82 Punkte, keine veröffentlichte Mail, gefunden am 18.10.2026')
assert.equal(rows[2].props['aria-label'], '[gekürzt] (gekürzt), 67655 Kaiserslautern, 82 Punkte, keine veröffentlichte Mail, gefunden am 19.10.2026')
assert.equal(ui.akte(), null)
// die Raum-Symbole je Zeile
assert.deepEqual(rows.map((r) => ui.parts(r).raumIcon), ['hk-ic hk-ic-raum-handwerk', 'hk-ic hk-ic-raum-aerzte', 'hk-ic hk-ic-raum-handwerk', 'hk-ic hk-ic-raum-selbst', 'hk-ic hk-ic-raum-laden'])
// Grund-Symbol je Zeile
const symbole = rows.map((r) => classesOf(byClass(r, 'hk-zelle')[1].children[0]).join(' '))
assert.deepEqual(symbole, ['hk-ic hk-ic-brief-x', 'hk-ic hk-ic-brief-x', 'hk-ic hk-ic-brief-x', 'hk-ic hk-ic-kette', 'hk-ic hk-ic-pfeil-runter'])
// Punkte-Balken: Farbe und Breite aus der Punktzahl
assert.deepEqual(rows.map((r) => byClass(r, 'hk-kl-balken-fuell').map((n) => `${classesOf(n).at(-1)} ${n.props.style.width}`)[0]), ['hk-kl-farbe-gut 39', 'hk-kl-farbe-gut 39', 'hk-kl-farbe-gut 39', 'hk-kl-farbe-mittel 36', 'hk-kl-farbe-schlecht 19'])
} finally { ui.end() }
})
// ---------------------------------------------------------------------------------------------------------------
// Klassen, Stile, Quelltext
const CSS_DATEIEN = ['src/styles/hustler/keller.css', 'src/styles/hustler/hustler-keller-ui.css', 'src/styles/hustler/hustler-ui.css']
const cssOhne = (p) => read(p).replace(/\/\*[\s\S]*?\*\//g, '')
const cssKlassen = (p) => new Set((cssOhne(p).replace(/url\([^)]*\)/g, '').match(/\.hk-[A-Za-z0-9_-]+/g) ?? []).map((k) => k.slice(1)))
const CSS_ALLE = new Set(CSS_DATEIEN.flatMap((p) => [...cssKlassen(p)]))
test('Klassen: jede gerenderte Klasse beginnt mit hk- und steht in keller.css, hustler-keller-ui.css oder hustler-ui.css (Alarm-Marker)', async () => {
const gesehen = new Set()
const sammle = (ui) => { for (const c of allClasses(ui.tree())) gesehen.add(c) }
const alleKlassen = (ui) => {
nodes(ui.tree(), (n) => { for (const c of classesOf(n)) assert.match(c, /^hk-[a-z0-9-]+$/, `Klasse ohne hk-: ${c}`); return false })
}
// alle Arne-Zustände
for (const s of ['arbeitet', 'wartet', 'leerlauf', 'grau', 'fehler']) {
const { ui } = await szene({ arne: eintrag(s) })
try { sammle(ui); alleKlassen(ui) } finally { ui.end() }
}
// Liste mit allen Räumen, gekürzt, Heilberuf, Akte (offen, mit Link und ohne)
const api = fakeApi()
const ui = await start(api, { kpis: { botLeads: 5 }, workers: { arne: eintrag('arbeitet') } })
try {
sammle(ui); alleKlassen(ui) // Laden
await ui.answer(0, { items: [...SEITE, { ...ITEM, id: 'p9', score: 20 }, { ...ITEM, id: 'p10', score: undefined }], total: 120, pageSize: 50 })
sammle(ui); alleKlassen(ui)
for (const nr of [0, 1, 2, 3, 4, 5, 6]) {
await ui.openRow(nr)
sammle(ui); alleKlassen(ui)
}
await ui.key('Escape')
await ui.click('Register zuklappen')
sammle(ui); alleKlassen(ui)
await ui.click('Register aufklappen')
await ui.pick(idx('laden'))
await ui.fail(1, new Error('weg'))
sammle(ui); alleKlassen(ui) // Fehler
await ui.pick(idx('selbst'))
await ui.answer(2, { items: [], total: 0 })
sammle(ui); alleKlassen(ui) // leer
} finally { ui.end() }
const einzeln = await start(fakeApi(), { bot: 'p1', onClearBot: () => {} })
try {
sammle(einzeln)
await einzeln.answer(0, { items: [ITEM], total: 1 })
sammle(einzeln); alleKlassen(einzeln) // Einzelansicht
} finally { einzeln.end() }
assert.ok(gesehen.size > 60, `nur ${gesehen.size} Klassen gesehen`)
const fehlen = [...gesehen].filter((c) => !CSS_ALLE.has(c))
assert.deepEqual(fehlen, [], `Klassen ohne Regel: ${fehlen.join(', ')}`)
// Zustandsklassen der Pose und der Punkte-Farbe kommen vor
for (const c of ['hk-kl-fig-warten', 'hk-kl-fig-schlafen', 'hk-kl-fig-nicht', 'hk-kl-farbe-gut', 'hk-kl-farbe-mittel', 'hk-kl-farbe-schlecht', 'hk-kl-farbe-leer', 'hk-alarm', 'hk-arne-weg', 'hk-zeile-alt', 'hk-reiter-an', 'hk-kl-tag']) {
assert.ok(gesehen.has(c), `${c} nie gerendert`)
}
})
test('Quelltext: keine CSS- und Bild-Importe in den Komponenten, kein innerHTML, keine Inline-Bilder', () => {
const dateien = ['src/components/workers/BotLeadList.jsx', 'src/components/workers/keller/KellerSzene.jsx', 'src/components/workers/keller/RegisterBuch.jsx', 'src/components/workers/keller/Akte.jsx', 'src/lib/hustlerKeller.js', 'src/lib/botleadGruende.js']
for (const p of dateien) {
const src = ohneKommentare(read(p))
assert.doesNotMatch(src, /^\s*import\s[^;]*?['"][^'"]*\.(?:css|scss|png|jpe?g|svg|gif|webp)['"]/m, `${p}: Import von CSS oder Bild`)
assert.doesNotMatch(src, /^\s*import\s+['"][^'"]*\.(?:css|png|svg)['"]/m, `${p}: Import von CSS oder Bild`)
assert.doesNotMatch(src, /\brequire\(/, p)
assert.doesNotMatch(src, /dangerouslySetInnerHTML|innerHTML|outerHTML|insertAdjacentHTML/, p)
assert.doesNotMatch(src, /url\(|data:image|\.png|\.svg/, `${p}: Bildadresse im Quelltext`)
assert.doesNotMatch(src, /\bnew Image\b|<img\b/, p)
}
// BotLeadList und die Teile importieren nur React, die eigenen Teile und Dateien aus src/lib
const erlaubt = /^(?:react|\.\/keller\/(?:Akte|KellerSzene|RegisterBuch)|\.\.\/\.\.\/lib\/[A-Za-z]+|\.\.\/\.\.\/\.\.\/lib\/[A-Za-z]+)$/
for (const p of dateien.slice(0, 4)) {
for (const m of ohneKommentare(read(p)).matchAll(/^\s*import\b[^'"]*['"]([^'"]+)['"]/gm)) assert.match(m[1], erlaubt, `${p}: ${m[1]}`)
}
})
// Zerlegt eine CSS-Datei in Regeln { selektor, koerper, kontext } und Keyframe-Namen
function zerlege(css) {
const regeln = []
const keyframes = []
let i = 0
function liste(kontext) {
for (;;) {
while (i < css.length && /\s/.test(css[i])) i += 1
if (i >= css.length) return
if (css[i] === '}') { i += 1; return }
const start = i
while (i < css.length && css[i] !== '{' && css[i] !== '}') i += 1
const kopf = css.slice(start, i).trim()
assert.equal(css[i], '{', `Regel ohne Block: ${kopf.slice(0, 60)}`)
i += 1
if (kopf.startsWith('@keyframes')) {
keyframes.push(kopf.slice('@keyframes'.length).trim())
let tiefe = 1
while (i < css.length && tiefe > 0) { if (css[i] === '{') tiefe += 1; if (css[i] === '}') tiefe -= 1; i += 1 }
} else if (kopf.startsWith('@media')) {
liste(kopf)
} else {
const b = i
while (i < css.length && css[i] !== '}') i += 1
regeln.push({ selektor: kopf, koerper: css.slice(b, i), kontext })
i += 1
}
}
}
liste('')
return { regeln, keyframes }
}
const UI_CSS = cssOhne('src/styles/hustler/hustler-keller-ui.css')
const ui = zerlege(UI_CSS)
test('hustler-keller-ui.css: jede Regel steht unter .hk-keller, jede Klasse beginnt mit hk-, eigene Klassen heißen hk-kl-…, Keyframes hk-kl-…', () => {
assert.ok(ui.regeln.length > 60, `nur ${ui.regeln.length} Regeln`)
for (const r of ui.regeln) {
for (const teil of r.selektor.split(',').map((s) => s.trim())) {
assert.ok(teil.startsWith('.hk-keller') || teil.startsWith('.hk-seite > .hk-keller'), `nicht unter .hk-keller: ${teil}`)
assert.ok(!/(^|[\s>+~(,])(?:html|body|:root)(?![\w-])/i.test(teil), `html/body/:root in ${teil}`)
for (const m of teil.matchAll(/\.(-?[A-Za-z_][\w-]*)/g)) assert.match(m[1], /^hk-/, `${teil}: ${m[1]}`)
}
}
for (const k of ui.keyframes) assert.match(k, /^hk-kl-/, k)
assert.equal(new Set(ui.keyframes).size, ui.keyframes.length)
assert.match(UI_CSS.split('\n')[0] ?? '', /^/)
assert.match(read('src/styles/hustler/hustler-keller-ui.css'), /^\/\* Keller \(BotLeadList\.jsx und keller\/\*/)
})
test('hustler-keller-ui.css: jede Bilddatei aus url(...) gibt es unter src/assets/hustler, jede genannte Animation ist ein Keyframe aus dieser Datei oder aus keller.css', () => {
const urls = [...UI_CSS.matchAll(/url\(\s*([^)]*?)\s*\)/g)].map((m) => m[1].replace(/^["']|["']$/g, ''))
assert.ok(urls.length >= 5)
const assets = path.join(ROOT, 'src/assets/hustler')
for (const u of urls) {
assert.match(u, /^\.\.\/\.\.\/assets\/hustler\/[\w./-]+\.png$/, u)
const ziel = path.resolve(path.join(ROOT, 'src/styles/hustler'), u)
assert.ok(ziel.startsWith(assets + path.sep) && existsSync(ziel) && statSync(ziel).isFile(), `${u} fehlt`)
}
assert.deepEqual([...new Set(urls)].map((u) => u.replace('../../assets/hustler/', '')).sort(), [
'figuren/muetze-nicht.png', 'figuren/muetze-schlafen.png', 'figuren/muetze-warten.png', 'figuren/punkte.png', 'figuren/zzz.png',
])
const kf = new Set([...ui.keyframes, ...zerlege(cssOhne('src/styles/hustler/keller.css')).keyframes])
const woerter = new Set(['none', 'infinite', 'linear', 'steps', 'end', 'ease', 'ease-in-out'])
let geprueft = 0
for (const r of ui.regeln) {
for (const d of r.koerper.split(';')) {
const k = d.indexOf(':')
if (k < 0 || !/^\s*(?:-webkit-)?animation(?:-name)?\s*$/.test(d.slice(0, k))) continue
for (const w of d.slice(k + 1).split(/[\s,]+/).filter(Boolean)) {
if (/^[+-]?[\d.]+(?:ms|s)?$/.test(w) || w.includes('(') || w.endsWith(')') || woerter.has(w)) continue
assert.ok(kf.has(w), `Animation ${w} ohne Keyframes`)
geprueft += 1
}
}
}
assert.ok(geprueft >= 5, `nur ${geprueft} Animationen`)
})
test('hustler-keller-ui.css: jede eigene Klasse (hk-kl-…) wird gebraucht, jede gerenderte eigene Klasse hat eine Regel; weniger Bewegung stellt Arnes Animationen ab', () => {
const quellen = ['src/components/workers/BotLeadList.jsx', 'src/components/workers/keller/KellerSzene.jsx', 'src/components/workers/keller/RegisterBuch.jsx', 'src/components/workers/keller/Akte.jsx', 'src/lib/hustlerKeller.js']
.map((p) => ohneKommentare(read(p))).join('\n')
const eigene = [...cssKlassen('src/styles/hustler/hustler-keller-ui.css')].filter((k) => k.startsWith('hk-kl-'))
assert.ok(eigene.length > 40)
// dynamisch gebildete Klassen (hk-kl-fig-<Pose>, hk-kl-farbe-<Farbe>) stehen mit ihrem Vorsatz im Quelltext, alle Fälle als Regel (unten)
for (const k of eigene) assert.ok(quellen.includes(k) || /^hk-kl-(?:fig|farbe)-/.test(k), `${k} steht im CSS, aber in keiner Komponente`)
assert.ok(quellen.includes('hk-kl-fig-${') && quellen.includes('hk-kl-farbe-${'))
for (const pose of ['warten', 'schlafen', 'nicht']) assert.ok(eigene.includes(`hk-kl-fig-${pose}`))
for (const farbe of ['gut', 'mittel', 'schlecht', 'leer']) assert.ok(eigene.includes(`hk-kl-farbe-${farbe}`))
// jede Regel mit einer Animation (außer „none“) steht im Block „weniger Bewegung“
const reduce = ui.regeln.filter((r) => r.kontext.includes('prefers-reduced-motion'))
assert.ok(reduce.length >= 1)
const reduziert = reduce.map((r) => r.selektor).join(',')
for (const r of ui.regeln.filter((x) => !x.kontext.includes('prefers-reduced-motion'))) {
const anim = /(?:^|;)\s*animation\s*:\s*([^;]+)/.exec(r.koerper)
if (!anim || /^\s*none\b/.test(anim[1])) continue
for (const teil of r.selektor.split(',').map((s) => s.trim())) assert.ok(reduziert.includes(teil), `${teil} animiert, aber nicht in „weniger Bewegung“`)
}
for (const r of reduce) assert.match(r.koerper, /animation\s*:\s*none/)
})
test('hustler-keller-ui.css: Handy unter 720 px (E13): Buch in voller Breite über abgedunkelter Szene, Akte als Vollbild', () => {
const handy = ui.regeln.filter((r) => /max-width:\s*719px/.test(r.kontext))
assert.ok(handy.length >= 8)
const finde = (sel) => handy.find((r) => r.selektor.split(',').map((s) => s.trim()).includes(sel))
assert.match(finde('.hk-keller .hk-kl-dunkel').koerper, /display:\s*block[\s\S]*rgba\(6, 7, 18, 0\.62\)/)
assert.match(finde('.hk-keller .hk-kl-ui').koerper, /left:\s*8px[\s\S]*width:\s*auto/)
const akte = finde('.hk-keller .hk-akte.hk-kl-akte').koerper
assert.match(akte, /width:\s*100%/); assert.match(akte, /height:\s*100%/); assert.match(akte, /max-height:\s*none/)
assert.match(finde('.hk-keller .hk-kl-akte-ueber').koerper, /padding:\s*0/)
// die Abdunklung ist am Desktop aus
assert.match(ui.regeln.find((r) => r.selektor === '.hk-keller .hk-kl-dunkel' && r.kontext === '').koerper, /display:\s*none/)
// Reiter wischen seitlich, Spalten Punkte und Telefon entfallen
assert.match(finde('.hk-keller .hk-kl-reiter').koerper, /overflow-x:\s*auto/)
assert.ok(finde('.hk-keller .hk-kl-buch .hk-kl-spalte-punkte'))
})
test('hustler-keller-ui.css: Überschreibungen erzeugter Regeln haben höhere Spezifität (Reihenfolge der Stylesheets egal)', () => {
// Wo eine eigene Regel eine erzeugte aus keller.css überstimmt (Knopf, Zeile, Reiter, Akte, Symbol, Suche), steht eine zweite Klasse davor
const erzeugt = ['hk-knopf', 'hk-zeile', 'hk-kopfzeile', 'hk-reiter', 'hk-akte', 'hk-ic', 'hk-suche', 'hk-zelle', 'hk-sz-rohrpost', 'hk-sz-schublade', 'hk-sz-buch-stempel']
for (const r of ui.regeln) {
for (const teil of r.selektor.split(',').map((s) => s.trim())) {
const klassen = [...teil.matchAll(/\.(hk-[\w-]+)/g)].map((m) => m[1]).filter((k) => k !== 'hk-keller')
const trifft = klassen.filter((k) => erzeugt.includes(k))
if (trifft.length === 0) continue
// eine erzeugte Klasse im Selektor: mindestens zwei Klassen nach .hk-keller oder ein Elementname
assert.ok(klassen.length >= 2 || /\.hk-[\w-]+\s*:|input\./.test(teil) || /\sinput\.hk/.test(teil), `${teil}: nur eine erzeugte Klasse, Reihenfolge der Stylesheets entscheidet`)
}
}
})
+243
View File
@@ -0,0 +1,243 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { loadModule } from './load.mjs'
// Reine Daten und Ableitungen des Kellers (src/lib/hustlerKeller.js, Hustler-Pixelwelt T10, Spec E12/E13): Einpassen der Szene, Lage der Dinge, Arne nach Status,
// Raum-Reiter, Scout, Punkte-Anzeige, Zeilenmodell eines Bot-Leads.
const K = await loadModule('src/lib/hustlerKeller.js')
const V = await loadModule('src/lib/botleadsView.js')
const { SZENE, kellerEinpassen, FENSTER, DAMPF, BUCH_STEMPEL, ROHRPOST, SCHUBLADE, MAUS_STREIFEN, ARNE_RUHE, LEDS, REITER, raumIcon, scoutFuer, punkteInfo, eintragModell, arneAnsicht } = K
const { ROOM_FILTERS } = V
const read = (p) => readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
const ohneKommentare = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '')
const kellerCss = ohneKommentare(read('src/styles/hustler/keller.css'))
const uiCss = ohneKommentare(read('src/styles/hustler/hustler-keller-ui.css'))
const klassen = (css) => new Set((css.replace(/url\([^)]*\)/g, '').match(/\.hk-[A-Za-z0-9_-]+/g) ?? []).map((k) => k.slice(1)))
const CSS = new Set([...klassen(kellerCss), ...klassen(uiCss)])
const KEYFRAMES = new Set([...kellerCss.matchAll(/@keyframes\s+([\w-]+)/g)].map((m) => m[1]))
const px = (s) => Number.parseFloat(s)
function tiefEingefroren(wert, pfad) {
if (wert === null || typeof wert !== 'object') return
assert.ok(Object.isFrozen(wert), `${pfad} ist nicht eingefroren`)
for (const [k, v] of Object.entries(wert)) tiefEingefroren(v, `${pfad}.${k}`)
}
// ---------------------------------------------------------------------------------------------------------------
// Szene
test('kellerEinpassen: „cover“ links verankert, Maßstab = größeres Seitenverhältnis, Vorgabe ohne Messung', () => {
assert.deepEqual(SZENE, { breite: 640, hoehe: 360 })
assert.deepEqual(kellerEinpassen(1280, 720), { s: 2, x: 0, y: 0 })
assert.deepEqual(kellerEinpassen(1920, 1080), { s: 3, x: 0, y: 0 })
// breiter als hoch: der Maßstab folgt der Höhe, links bleibt verankert (x nie positiv, nur ein Teil des Überstands wandert)
const schmal = kellerEinpassen(1280, 900)
assert.equal(schmal.s, 2.5)
assert.equal(schmal.x, -48)
assert.equal(schmal.y, 0)
// sehr breit: der Maßstab folgt der Breite, die Szene steht tiefer (der untere Rand bleibt, höchstens 36 Szenen-Pixel gehen unten verloren)
const breit = kellerEinpassen(2560, 720)
assert.equal(breit.s, 4)
assert.equal(breit.x, 0)
assert.equal(breit.y, -576)
// Handy hochkant
const handy = kellerEinpassen(390, 844)
assert.ok(Math.abs(handy.s - 844 / 360) < 1e-9)
assert.ok(handy.x < 0 && handy.x > -(640 * handy.s - 390))
assert.equal(handy.y, 0)
// ohne brauchbare Größe: Vorgabe der Vorlage
for (const [b, h] of [[0, 0], [NaN, 720], [1280, NaN], [-1, 700], [1280, 0], [undefined, undefined], ['1280', '720']]) {
assert.deepEqual(kellerEinpassen(b, h), { s: 2, x: 0, y: 0 }, `${b} x ${h}`)
}
})
test('kellerEinpassen: die Szene deckt den Rahmen immer ganz (cover) und liegt nie rechts vom linken Rand', () => {
for (const [b, h] of [[1280, 720], [1920, 1080], [1048, 720], [390, 844], [768, 1024], [2560, 1440], [3440, 1440], [320, 568], [1000, 300]]) {
const { s, x, y } = kellerEinpassen(b, h)
assert.ok(640 * s >= b - 1e-9 && 360 * s >= h - 1e-9, `${b} x ${h}: Szene kleiner als der Rahmen`)
assert.ok(x <= 0 && x + 640 * s >= b - 1e-9, `${b} x ${h}: Lücke links oder rechts (x ${x})`)
assert.ok(y <= 0 && y + 360 * s >= h - 1e-9, `${b} x ${h}: Lücke oben oder unten (y ${y})`)
}
})
test('Lage der Dinge: alles liegt in der 640 x 360 großen Szene, Arne am Pult steht auf der Laufline, Daten sind eingefroren', () => {
for (const [name, o] of Object.entries({ FENSTER, DAMPF, BUCH_STEMPEL, ROHRPOST, SCHUBLADE, MAUS_STREIFEN, ARNE_RUHE })) {
assert.equal(o.position, 'absolute', name)
assert.ok(px(o.left) >= 0 && px(o.top) >= 0 && px(o.left) < 640 && px(o.top) < 360, name)
assert.match(o.left, /^\d+px$/); assert.match(o.top, /^\d+px$/)
}
// Maße der Vorlage (BotLeads.dc.html)
assert.deepEqual([FENSTER.left, FENSTER.top, FENSTER.width, FENSTER.height, FENSTER.overflow], ['23px', '34px', '46px', '29px', 'hidden'])
assert.deepEqual([ROHRPOST.left, ROHRPOST.top], ['130px', '224px'])
assert.deepEqual([SCHUBLADE.left, SCHUBLADE.top], ['214px', '279px'])
assert.deepEqual([BUCH_STEMPEL.left, BUCH_STEMPEL.top], ['60px', '252px'])
assert.deepEqual([DAMPF.left, DAMPF.top], ['26px', '247px'])
assert.deepEqual([MAUS_STREIFEN.left, MAUS_STREIFEN.top, MAUS_STREIFEN.width, MAUS_STREIFEN.height], ['30px', '336px', '238px', '24px'])
assert.equal(px(MAUS_STREIFEN.top) + px(MAUS_STREIFEN.height), 360)
// Haus-Figur 24 x 32: die Füße stehen auf der Laufline (318), Mitte beim Halt am Pult (128)
assert.equal(px(ARNE_RUHE.top) + 32, 318)
assert.equal(px(ARNE_RUHE.left) + 12, 128)
for (const [name, o] of Object.entries({ SZENE, FENSTER, DAMPF, BUCH_STEMPEL, ROHRPOST, SCHUBLADE, MAUS_STREIFEN, ARNE_RUHE, LEDS, REITER })) tiefEingefroren(o, name)
})
test('Blinkpunkte: sechs, jeder mit Keyframes aus keller.css (hk-k-k-led-…), Maße und Takte der Vorlage', () => {
assert.deepEqual(LEDS.map((l) => l.name), ['weiche-gruen', 'weiche-bernstein', 'sicherung-1', 'sicherung-2', 'sicherung-3', 'verteiler'])
for (const l of LEDS) {
const m = /^(hk-k-k-led-[a-z0-9-]+) (\d+)ms steps\(1,end\) infinite$/.exec(l.stil.animation)
assert.ok(m, l.stil.animation)
assert.equal(m[1], `hk-k-k-led-${l.name}`)
assert.ok(KEYFRAMES.has(m[1]), `${m[1]} fehlt in keller.css`)
assert.equal(l.stil.height, '2px')
assert.match(l.stil.background, /^#[0-9a-f]{6}$/)
assert.ok(px(l.stil.left) < 640 && px(l.stil.top) < 360)
}
assert.deepEqual(LEDS.map((l) => px(l.stil.animation.split(' ')[1])), [1800, 2600, 3000, 2200, 3400, 2000])
})
// ---------------------------------------------------------------------------------------------------------------
// Arne
const eintrag = (status) => (status === 'grau' ? { status: 'grau', task: '', lastTs: null, connected: false, counters: {} } : { status, task: '', lastTs: null, connected: true, counters: {} })
test('arneAnsicht: arbeitet ⇒ Ablauf-Schleife; sonst Haus-Figur nach Status (warten, schlafen, nicht), Fehler mit Marker', () => {
const arbeit = arneAnsicht({ arne: eintrag('arbeitet') })
assert.deepEqual(arbeit, { status: 'arbeitet', arbeitet: true, pose: 'arbeiten', alarm: false, punkte: false, zzz: false, label: 'Arne, Haiku, arbeitet' })
assert.deepEqual(arneAnsicht({ arne: eintrag('wartet') }), { status: 'wartet', arbeitet: false, pose: 'warten', alarm: false, punkte: true, zzz: false, label: 'Arne, Haiku, wartet auf Auftrag' })
assert.deepEqual(arneAnsicht({ arne: eintrag('leerlauf') }), { status: 'leerlauf', arbeitet: false, pose: 'schlafen', alarm: false, punkte: false, zzz: true, label: 'Arne, Haiku, Leerlauf' })
assert.deepEqual(arneAnsicht({ arne: eintrag('grau') }), { status: 'grau', arbeitet: false, pose: 'nicht', alarm: false, punkte: false, zzz: false, label: 'Arne, Haiku, noch nicht angeschlossen' })
assert.deepEqual(arneAnsicht({ arne: eintrag('fehler') }), { status: 'fehler', arbeitet: false, pose: 'nicht', alarm: true, punkte: false, zzz: false, label: 'Arne, Haiku, Fehler' })
})
test('arneAnsicht: nur „arbeitet“ arbeitet; ohne Tabelle, ohne Eintrag oder mit Unsinn schläft Arne (Leerlauf)', () => {
for (const workers of [undefined, null, {}, [], 'x', 5, { otto: eintrag('arbeitet') }, { arne: null }, { arne: 'arbeitet' }, { arne: { status: 'irgendwas' } }, { arne: {} }]) {
const a = arneAnsicht(workers)
assert.equal(a.arbeitet, false, JSON.stringify(workers))
assert.equal(a.status, 'leerlauf')
assert.equal(a.pose, 'schlafen')
assert.equal(a.alarm, false)
}
// ein Eintrag anderer Hustler ändert nichts: nur arne zählt
assert.equal(arneAnsicht({ arne: eintrag('wartet'), otto: eintrag('arbeitet'), ali: eintrag('fehler') }).status, 'wartet')
// arbeitet und gleichzeitig nicht angeschlossen: grau gewinnt (figurZustand)
assert.equal(arneAnsicht({ arne: { ...eintrag('arbeitet'), connected: false } }).status, 'grau')
})
// ---------------------------------------------------------------------------------------------------------------
// Register-Buch
test('REITER: Reihenfolge, Werte und Beschriftungen von ROOM_FILTERS, kurze sichtbare Namen, Symbol und Farbe je Reiter aus keller.css', () => {
assert.deepEqual(REITER.map((r) => r.value), ROOM_FILTERS.map((f) => f.value))
assert.deepEqual(REITER.map((r) => r.label), ROOM_FILTERS.map((f) => f.label))
assert.deepEqual(REITER.map((r) => r.kurz), ['Alle', 'Ärzte', 'Handwerk', 'Selbstständige', 'Läden'])
assert.deepEqual(REITER.map((r) => r.icon), ['hk-ic hk-ic-alle', 'hk-ic hk-ic-raum-aerzte', 'hk-ic hk-ic-raum-handwerk', 'hk-ic hk-ic-raum-selbst', 'hk-ic hk-ic-raum-laden'])
assert.deepEqual(REITER.map((r) => r.klasse), ['hk-reiter hk-reiter-alle', 'hk-reiter hk-reiter-aerzte', 'hk-reiter hk-reiter-handwerk', 'hk-reiter hk-reiter-selbst', 'hk-reiter hk-reiter-laden'])
for (const r of REITER) for (const k of `${r.icon} ${r.klasse}`.split(' ')) assert.ok(CSS.has(k), `${k} fehlt im CSS`)
})
test('raumIcon: Symbol je Raum, ein unbekannter Raum zeigt den Aktenstapel', () => {
assert.equal(raumIcon('aerzte'), 'hk-ic hk-ic-raum-aerzte')
assert.equal(raumIcon('handwerk'), 'hk-ic hk-ic-raum-handwerk')
assert.equal(raumIcon('selbst'), 'hk-ic hk-ic-raum-selbst')
assert.equal(raumIcon('laden'), 'hk-ic hk-ic-raum-laden')
for (const unbekannt of ['', 'netz', 'xyz', 'constructor', '__proto__', undefined, null, 3, {}]) assert.equal(raumIcon(unbekannt), 'hk-ic hk-ic-alle', String(unbekannt))
for (const room of ['aerzte', 'handwerk', 'selbst', 'laden', '']) for (const k of raumIcon(room).split(' ')) assert.ok(CSS.has(k), k)
})
test('scoutFuer: Raum → Hustler der Etage „Ohne Website“ (aerzte → Ali, handwerk → Hanna, selbst → Sven, laden → Lea), sonst leer', () => {
assert.equal(scoutFuer('aerzte'), 'Ali')
assert.equal(scoutFuer('handwerk'), 'Hanna')
assert.equal(scoutFuer('selbst'), 'Sven')
assert.equal(scoutFuer('laden'), 'Lea')
// Räume anderer Etagen (Rita im Netz, Arne im Archiv) und alles Unbekannte haben keinen Scout
for (const room of ['netz', 'archiv', 'bruecke', 'waage', '', 'xyz', 'constructor', undefined, null, 7]) assert.equal(scoutFuer(room), '', String(room))
})
test('punkteInfo: Zahl, Balkenbreite (48 und 96 px) und Farbe (ab 75 gut, ab 55 mittel, darunter schlecht, ohne Zahl leer)', () => {
assert.deepEqual(punkteInfo(82), { text: '82', breite: 39, breiteGross: 79, farbe: 'gut' })
assert.deepEqual(punkteInfo(75), { text: '75', breite: 36, breiteGross: 72, farbe: 'gut' })
assert.equal(punkteInfo(74).farbe, 'mittel')
assert.equal(punkteInfo(55).farbe, 'mittel')
assert.equal(punkteInfo(54).farbe, 'schlecht')
assert.deepEqual(punkteInfo(0), { text: '0', breite: 0, breiteGross: 0, farbe: 'schlecht' })
assert.deepEqual(punkteInfo(100), { text: '100', breite: 48, breiteGross: 96, farbe: 'gut' })
assert.equal(punkteInfo(81.6).text, '82') // gerundet wie scoreLabel in botleadRow
// der Balken bleibt im Rahmen, der Text nennt die Zahl des Dienstes
assert.deepEqual(punkteInfo(130), { text: '130', breite: 48, breiteGross: 96, farbe: 'gut' })
assert.deepEqual(punkteInfo(-5), { text: '-5', breite: 0, breiteGross: 0, farbe: 'schlecht' })
for (const nichts of [undefined, null, NaN, Infinity, 'x', '82', {}]) assert.deepEqual(punkteInfo(nichts), { text: '–', breite: 0, breiteGross: 0, farbe: 'leer' }, String(nichts))
})
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,
}
test('eintragModell: Zeilenmodell von botleadRow plus Raum-Symbol, Scout, Punkte, Grund mit Erklärung, Telefon-Symbol und Label', () => {
const m = eintragModell(ITEM)
assert.deepEqual({ ...m, punkte: undefined }, {
...V.botleadRow(ITEM),
raum: 'handwerk', raumIcon: 'hk-ic hk-ic-raum-handwerk', scout: 'Hanna', punkte: undefined,
grundIcon: 'hk-ic hk-ic-brief-x', erklaerung: 'Auf der Website steht keine E-Mail-Adresse. Ohne veröffentlichte Adresse schreibt der Hustler nie.',
telefonIcon: 'hk-ic hk-ic-telefon',
label: 'Elektro Beispiel GmbH, 67655 Kaiserslautern, 82 Punkte, keine veröffentlichte Mail, gefunden am 19.10.2026',
})
assert.deepEqual(m.punkte, punkteInfo(82))
assert.equal(m.sourceHref, 'https://beispiel-elektro.de/impressum')
assert.equal(m.phone, '0631 111')
})
test('eintragModell: Heilberuf nie mit Nummer (Schloss), unsichere Quelle ohne Link, ohne Quelle der Nummer „nicht verfügbar“, gekürzt mit Schere', () => {
const praxis = eintragModell({ ...ITEM, id: 'p2', room: 'aerzte', roomLabel: 'Ärzte & Praxen', isHealth: true, sourceUrl: 'javascript:alert(1)' })
assert.equal(praxis.phone, 'bewusst nicht gespeichert')
assert.equal(praxis.telefonIcon, 'hk-ic hk-ic-schloss')
assert.equal(praxis.sourceHref, '')
assert.equal(praxis.scout, 'Ali')
// ein Altbestand mit Nummer und Quelle verrät sie bei einem Heilberuf trotzdem nicht
assert.equal(eintragModell({ ...ITEM, isHealth: true }).phone, 'bewusst nicht gespeichert')
assert.equal(eintragModell({ ...ITEM, phoneSource: '' }).phone, 'nicht verfügbar')
assert.equal(eintragModell({ ...ITEM, phoneSource: '' }).telefonIcon, 'hk-ic hk-ic-telefon')
assert.equal(eintragModell({ ...ITEM, sourceUrl: 'https://user:pw@evil.example/' }).sourceHref, '')
assert.equal(eintragModell({ ...ITEM, sourceUrl: 'http://alt.example/kontakt' }).sourceHref, 'http://alt.example/kontakt')
const kuerz = eintragModell({ ...ITEM, name: '[gekürzt]', gekuerzt: true, phone: '', phoneSource: '', sourceUrl: '' })
assert.equal(kuerz.gekuerzt, true)
assert.equal(kuerz.telefonIcon, 'hk-ic hk-ic-schere')
assert.ok(kuerz.label.startsWith('[gekürzt] (gekürzt), '))
// jedes Symbol des Modells gibt es im CSS
for (const m of [eintragModell(ITEM), praxis, kuerz]) for (const k of `${m.raumIcon} ${m.grundIcon} ${m.telefonIcon}`.split(' ')) assert.ok(CSS.has(k), k)
})
test('eintragModell: fehlende oder kaputte Werte ergeben Platzhalter, nie undefined, NaN oder einen Fehler', () => {
for (const kaputt of [null, undefined, 42, 'x', [], {}, { room: 5, score: 'x', reason: null, name: 7 }]) {
const m = eintragModell(kaputt)
assert.equal(m.title, 'nicht verfügbar')
assert.equal(m.scoreLabel, '–')
assert.equal(m.reason, '–')
assert.equal(m.raum, '')
assert.equal(m.raumIcon, 'hk-ic hk-ic-alle')
assert.equal(m.scout, '')
assert.deepEqual(m.punkte, { text: '–', breite: 0, breiteGross: 0, farbe: 'leer' })
assert.equal(m.grundIcon, 'hk-ic hk-ic-akte') // Grund „–“ ist nicht bekannt: neutrales Symbol
assert.equal(m.sourceHref, '')
assert.ok(!/undefined|NaN/.test(JSON.stringify(m)), JSON.stringify(m))
assert.equal(typeof m.label, 'string')
}
// unbekannter Raum und unbekannter Grund
const m = eintragModell({ ...ITEM, room: 'xyz', reason: 'irgendein neuer Grund' })
assert.equal(m.scout, '')
assert.equal(m.raumIcon, 'hk-ic hk-ic-alle')
assert.equal(m.grundIcon, 'hk-ic hk-ic-akte')
assert.match(m.erklaerung, /Der Hustler schreibt diesem Betrieb nicht/)
})
test('hustlerKeller.js: rein (kein React, kein DOM, kein CSS und keine Bilder), nur Importe aus src/lib', () => {
const src = read('src/lib/hustlerKeller.js').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
const importe = [...src.matchAll(/^\s*import\b[^'"]*['"]([^'"]+)['"]/gm)].map((m) => m[1])
assert.deepEqual(importe.sort(), ['./botleadGruende.js', './botleadsView.js', './hustlerSicht.js', './workersRoster.js'])
assert.doesNotMatch(src, /\b(?:window|document|fetch|localStorage)\b/)
assert.doesNotMatch(src, /innerHTML/)
assert.doesNotMatch(src, /\.(?:css|png|svg|jpg)['"]/)
})