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:
1 parent
ec63e5a43d
commit
1b3fdf5d97
14 files changed
+2244
-309
No files matched your search
@@ -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>
|
</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)}>
|
||||||
<div className="toolbar">
|
<span className="hk-ic hk-ic-akte" aria-hidden="true" /><span className="hk-kl-label">{zu ? 'Register aufklappen' : 'Register zuklappen'}</span>
|
||||||
<select className="select" aria-label="Raum" value={view.room} onChange={onRoom} ref={selectRef}>
|
</button>
|
||||||
{ROOM_FILTERS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
|
|
||||||
</select>
|
|
||||||
<input className="input search" type="search" aria-label="Betriebe suchen" placeholder="Name, Ort oder Grund" maxLength={SEARCH_MAX} value={draft} onChange={onSearch} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{zu ? null : (
|
||||||
{body}
|
<RegisterBuch
|
||||||
{!single && !loading && !result.error && result.total > 0 && (
|
einzeln={single}
|
||||||
<div className="table-foot">
|
botId={botId}
|
||||||
<span>{info.label}</span>
|
room={view.room}
|
||||||
{info.pages > 1 && (
|
draft={draft}
|
||||||
<span className="wk-bl-pager">
|
filtered={filtered}
|
||||||
<button type="button" className="btn" ref={prevRef} disabled={!info.hasPrev} onClick={() => onPage('prev')}>Zurück</button>
|
laden={loading}
|
||||||
<button type="button" className="btn" ref={nextRef} disabled={!info.hasNext} onClick={() => onPage('next')}>Weiter</button>
|
fehler={result.error}
|
||||||
</span>
|
zeilen={zeilen}
|
||||||
|
total={result.total}
|
||||||
|
info={info}
|
||||||
|
onRoom={onRoom}
|
||||||
|
onSearch={onSearch}
|
||||||
|
onClear={onClear}
|
||||||
|
onPage={onPage}
|
||||||
|
onRetry={onRetry}
|
||||||
|
onOpen={onOpen}
|
||||||
|
reiterRef={reiterRef}
|
||||||
|
zurueckRef={prevRef}
|
||||||
|
weiterRef={nextRef}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{zeigeAkte ? <Akte eintrag={zeilen[akte]} index={akte} gesamt={zeilen.length} onClose={() => setAkte(null)} onBlaettern={onBlaettern} /> : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
Vendored
+20
@@ -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
@@ -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() }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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\}/)
|
||||||
})
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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/)
|
||||||
|
})
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)['"]/)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user