From 3027f03dbe230081cd3e0094fc12cdd1202b4fda Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 08:19:02 +0000 Subject: [PATCH] =?UTF-8?q?Hustler-Pixelwelt=20T7:=20Detailansicht=20eines?= =?UTF-8?q?=20Hustlers=20(Szene=20nach=20Status,=20Daten,=20Spur,=20Bl?= =?UTF-8?q?=C3=A4ttern,=20Fokus)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../workers/hochhaus/HustlerDialog.jsx | 193 ++++++ src/styles/hustler/hustler-detail.css | 97 +++ tests/unit/hustlerDialog.test.mjs | 638 ++++++++++++++++++ 3 files changed, 928 insertions(+) create mode 100644 src/components/workers/hochhaus/HustlerDialog.jsx create mode 100644 src/styles/hustler/hustler-detail.css create mode 100644 tests/unit/hustlerDialog.test.mjs diff --git a/src/components/workers/hochhaus/HustlerDialog.jsx b/src/components/workers/hochhaus/HustlerDialog.jsx new file mode 100644 index 0000000..377bf9b --- /dev/null +++ b/src/components/workers/hochhaus/HustlerDialog.jsx @@ -0,0 +1,193 @@ +// Detailansicht eines Hustlers im Hochhaus (Spec E10, E14): Szene mit großer Figur nach Status (Büro, Stecker oder Brücke), rechts Name, +// Status, Modell, Etage · Raum, Aufgabe, Gerade, Heute, Kosten, die Spur und unten ◂ Nummer ▸. Aufbau und Klassen wie Entwurf/markup.html der +// Zeichenfläche (Block „offen“), alle Klassen mit hk- davor; die Inline-Stile der Vorlage stehen als Klassen in styles/hustler/hustler-detail.css. +// Die Felder liefert hustlerSicht.detailModell, die Positionen kommen aus DETAIL in hustlerWelt.js, die Spur aus useWorkerTrace (alle 15 s). +// Kein CSS-Import (die Stile hängt WorkersPage ein), keine Bilder, kein innerHTML. +// +// Dialog: role="dialog" aria-modal, Fokus beim Öffnen auf „Schließen“, Tab bleibt im Dialog (erster Knopf = Schließen, letzter = ▸), +// Esc schließt, ←/→ blättern in Roster-Reihenfolge, ein Klick auf den Hintergrund schließt erst 400 ms nach dem Öffnen (Geister-Klick nach +// dem Antippen der Figur). Beim Schließen geht der Fokus zurück auf das Element, das beim Öffnen den Fokus hatte (wenn es noch im Dokument +// steht), sonst auf die Figur des zuletzt gezeigten Hustlers ([data-hk-hustler=""], die setzt die Welt). +import { useEffect, useRef } from 'react' +import { Link } from 'react-router-dom' +import { useWorkerTrace } from '../../../hooks/useWorkersFeed' +import { blaettern, detailModell } from '../../../lib/hustlerSicht' +import { DETAIL } from '../../../lib/hustlerWelt' +import { safeLink } from '../../../lib/workersView' + +// So lange nach dem Öffnen schließt der Hintergrund nicht (der Antipp-Klick, der den Dialog öffnet, kommt sonst als Geister-Klick darauf an) +const HINTERGRUND_SPERRE_MS = 400 + +// Ids der Hustler sind einfache Kennungen; nur solche gehen in den Attribut-Selektor +const EINFACHE_ID = /^[A-Za-z0-9_-]+$/ + +const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) + +// Einziger Ort des Dialogs, an dem ein Ziel für einen Link entsteht (wie TraceText in WorkerCard): nur was safeLink durchlässt, wird ein Link +// (extern in neuem Tab mit noopener, intern über den Router), alles andere bleibt Text. +function SpurText({ link, children }) { + const safe = isRecord(link) ? safeLink(link.href) : null + if (!safe) return <>{children} + if (safe.external) return {children} + return {children} +} + +// Stelle der Figur in der Szene: Brücke (Overseer), Stecker-Szene (nicht angeschlossen) oder Büro +function figurStelle(szene) { + if (szene.includes('hk-sz-bruecke')) return DETAIL.figur.bruecke + if (szene.includes('hk-sz-nicht')) return DETAIL.figur.nicht + return DETAIL.figur.buero +} + +// Props: api (Dienst-API für die Spur), workerId (null = nichts anzeigen), workers (Hustler-Tabelle der Seite, Zustandseinträge nach workerId), +// onClose(), onWechsel(nextId) (Blättern; die Seite setzt die neue Id) +export default function HustlerDialog({ api, workerId, workers, onClose, onWechsel }) { + const { trace, loading, error } = useWorkerTrace(api, workerId) + const ersterRef = useRef(null) // Knopf „Schließen“ (Fokus beim Öffnen, Anfang der Fokusfalle) + const letzterRef = useRef(null) // Knopf „Nächster Hustler“ (Ende der Fokusfalle) + const sectionRef = useRef(null) + const hintenRef = useRef(null) + const geoeffnetRef = useRef(0) + const openerRef = useRef(null) + const idRef = useRef(workerId) + const offen = Boolean(workerId) + if (workerId) idRef.current = workerId + + // Öffnen: merken, wer den Fokus hatte, und den Fokus auf „Schließen“ setzen; Schließen (oder Abbau): Fokus zurückgeben + useEffect(() => { + if (!offen) return undefined + const aktiv = typeof document !== 'undefined' ? document.activeElement : null + openerRef.current = aktiv && aktiv !== document.body ? aktiv : null + geoeffnetRef.current = Date.now() + const erster = ersterRef.current + if (erster && typeof erster.focus === 'function') erster.focus({ preventScroll: true }) + return () => { + const opener = openerRef.current + openerRef.current = null + let ziel = opener && opener.isConnected ? opener : null + if (!ziel && typeof document !== 'undefined' && typeof document.querySelector === 'function' && EINFACHE_ID.test(String(idRef.current ?? ''))) { + ziel = document.querySelector(`[data-hk-hustler="${idRef.current}"]`) + } + if (ziel && typeof ziel.focus === 'function') ziel.focus({ preventScroll: true }) + } + }, [offen]) + + if (!workerId) return null + + const eintrag = isRecord(workers) && Object.hasOwn(workers, workerId) ? workers[workerId] : null + const m = detailModell(workerId, eintrag, trace, Date.now()) + const stelle = figurStelle(m.szene) + const o = m.overlays + const wechsel = (schritt) => onWechsel(blaettern(workerId, schritt)) + + // Hintergrund: schließt erst nach der Sperrzeit + const hintergrund = () => { + if (Date.now() - geoeffnetRef.current > HINTERGRUND_SPERRE_MS) onClose() + } + + // Tasten (am äußeren Rahmen, damit sie auch gelten, wenn der Fokus auf dem Hintergrund oder dem Dialog selbst liegt): + // Esc schließt, ←/→ blättern, Tab wandert im Kreis zwischen „Schließen“ und „Nächster Hustler“ + const taste = (e) => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + onClose() + return + } + if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { + if (e.altKey || e.ctrlKey || e.metaKey) return // Alt+← ist „Zurück“ im Browser + e.preventDefault() + e.stopPropagation() + wechsel(e.key === 'ArrowRight' ? 1 : -1) + return + } + if (e.key !== 'Tab') return + const aktiv = typeof document !== 'undefined' ? document.activeElement : null + const erster = ersterRef.current + const letzter = letzterRef.current + if (!e.shiftKey && aktiv === letzter && erster) { + e.preventDefault() + erster.focus() + } else if (e.shiftKey && letzter && (aktiv === erster || aktiv === sectionRef.current || aktiv === hintenRef.current || aktiv === e.currentTarget)) { + e.preventDefault() + letzter.focus() + } + } + + return ( +
+ +
+
+
Modell
+
{m.modell}
+
Etage
+
{m.etageRaum}
+
Aufgabe
+
{m.aufgabe}
+
Gerade
+
{m.gerade}
+
Heute
+
{m.heute}
+
Kosten
+
{`${m.kosten} · ${m.tokens} Tokens`}
+
+
+

Spur

+ {error &&

{error}

} + {loading &&

Spur wird geladen …

} + {!loading && m.spur.length === 0 && !error &&

Noch keine Einträge.

} + {m.spur.length > 0 && ( +
    + {m.spur.map((row, i) => ( +
  1. + {row.time} + + {row.text} + +
  2. + ))} +
+ )} +
+
+ + {m.nummer} + +
+ + + + ) +} diff --git a/src/styles/hustler/hustler-detail.css b/src/styles/hustler/hustler-detail.css new file mode 100644 index 0000000..b3eb0dd --- /dev/null +++ b/src/styles/hustler/hustler-detail.css @@ -0,0 +1,97 @@ +/* Detailansicht eines Hustlers (HustlerDialog.jsx, Spec E10/E13/E14). Handgeschrieben, nicht erzeugt: die Inline-Stile des Aufbaus „offen“ in + pixelwelt-v2/entwurf/markup.html stehen hier als Klassen. Szene, Figur, Zusätze (hk-sz, hk-gr-*, hk-monitor-gross, hk-dampf, hk-zzz-gross, + hk-wirbel), Panel (hk-panel), Knöpfe (hk-knopf) und Symbole (hk-ic, hk-schliessen, hk-pfeil-*) kommen aus dem erzeugten hochhaus.css; + hk-gedimmt und hk-alarm aus hustler-ui.css. Alle Klassen beginnen mit hk- und stehen unter der Wurzel .hk-hochhaus. + Die Stylesheets hängt WorkersPage ein (Komponenten importieren kein CSS). */ + +/* ---------- Überlagerung: dunkler Hintergrund-Knopf und das Panel in der Mitte ---------- */ +.hk-hochhaus .hk-dialog-ueber { + position: absolute; inset: 0; z-index: 20; + display: flex; align-items: center; justify-content: center; + box-sizing: border-box; padding: 16px; + font-family: 'Pixelify Sans', ui-monospace, monospace; font-size: 15px; line-height: 20px; color: #e6ebff; +} +.hk-hochhaus .hk-dialog-hinten { + position: absolute; inset: 0; margin: 0; padding: 0; border: 0; border-radius: 0; + background: rgba(5, 8, 26, 0.74); cursor: default; + -webkit-appearance: none; appearance: none; -webkit-tap-highlight-color: transparent; +} + +/* Panel: Szene links, Daten rechts; wird es zu schmal, rutschen die Daten unter die Szene und das Panel scrollt */ +.hk-hochhaus .hk-dialog { + position: relative; display: flex; flex-wrap: wrap; gap: 20px; + max-width: 100%; max-height: 100%; min-height: min(560px, 100%); + overflow: auto; padding: 4px; outline: none; +} +.hk-hochhaus .hk-dialog a:focus-visible { outline: 2px solid #ffd25a; outline-offset: 2px; } + +/* ---------- Szene: Hülle und Schild kommen aus hochhaus.css (hk-sz) bzw. hier (Schild) ---------- */ +.hk-hochhaus .hk-dialog-schild { + position: absolute; display: flex; align-items: center; justify-content: center; + color: #3a2f3d; font-size: 10px; line-height: 13px; text-align: center; +} + +/* ---------- Rechte Spalte: Kopf, Fakten, Spur, Fußzeile ---------- */ +.hk-hochhaus .hk-dialog-daten { + flex: 1 1 300px; min-width: 280px; max-width: 380px; + display: flex; flex-direction: column; gap: 14px; padding: 2px 2px 2px 0; +} +.hk-hochhaus .hk-dialog-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.hk-hochhaus .hk-dialog-name { + margin: 0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 20px; line-height: 28px; font-weight: 400; color: #ffffff; + overflow-wrap: anywhere; +} +/* Farbe des Status kommt als --hk-status vom Dienst-Status (statusFarbe) */ +.hk-hochhaus .hk-dialog-status { margin-top: 6px; display: inline-flex; align-items: center; gap: 8px; color: var(--hk-status, #9aa6d6); } +.hk-hochhaus .hk-dialog-punkt { flex: none; width: 10px; height: 10px; background: var(--hk-status, #9aa6d6); box-shadow: 0 0 0 2px #05081a; } + +.hk-hochhaus .hk-dialog-dl { margin: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 14px; } +.hk-hochhaus .hk-dialog-dl dt { color: #9aa6d6; } +.hk-hochhaus .hk-dialog-dl dd { margin: 0; overflow-wrap: anywhere; } + +.hk-hochhaus .hk-dialog-spurtitel { + margin: 0 0 8px; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 11px; line-height: 14px; font-weight: 400; color: #9aa6d6; +} +.hk-hochhaus .hk-dialog-spurliste { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.hk-hochhaus .hk-dialog-spurzeile { display: flex; gap: 10px; } +/* Breite für „TT.MM. HH:MM“ (Ereignisse von früheren Tagen), nicht nur „HH:MM“ */ +.hk-hochhaus .hk-dialog-zeit { flex: none; width: 96px; color: #ffd25a; white-space: nowrap; } +.hk-hochhaus .hk-dialog-spurtext { min-width: 0; overflow-wrap: anywhere; } +.hk-hochhaus .hk-dialog-hinweis { margin: 0; color: #9aa6d6; } +.hk-hochhaus .hk-dialog-fehler { margin: 0 0 8px; color: #ff6b6b; overflow-wrap: anywhere; } + +.hk-hochhaus .hk-dialog-fuss { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.hk-hochhaus .hk-dialog-nummer { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 11px; line-height: 14px; color: #9aa6d6; } + +/* ---------- Handy (unter 720 px, Spec E13): Vollbild, Szene oben (auf die Breite verkleinert), Daten darunter scrollbar ---------- */ +@media (max-width: 719px) { + .hk-hochhaus .hk-dialog-ueber { padding: 0; } + .hk-hochhaus .hk-dialog { + /* Container für die Szenen-Skalierung (cqw = Innenbreite des Panels) */ + container-type: inline-size; + display: flex; flex-direction: column; flex-wrap: nowrap; gap: 12px; + width: 100%; max-width: 100%; height: 100%; max-height: 100%; min-height: 0; + overflow: hidden; + } + /* Szene: 640 x 400 wird per zoom auf die Breite des Panels gebracht (Layoutgröße schrumpft mit, kein waagrechtes Scrollen) */ + .hk-hochhaus .hk-dialog .hk-sz { flex: none; zoom: 0.5; } + @supports (zoom: tan(atan2(1px, 1px))) { + .hk-hochhaus .hk-dialog .hk-sz { zoom: tan(atan2(100cqw, 640px)); } + } + /* Daten: füllen den Rest und scrollen für sich; die Fußzeile ◂ n / 19 ▸ bleibt unten stehen */ + .hk-hochhaus .hk-dialog-daten { + flex: 1 1 auto; min-width: 0; max-width: none; min-height: 0; overflow-x: hidden; overflow-y: auto; padding: 0 2px 0 0; + -webkit-overflow-scrolling: touch; + } + .hk-hochhaus .hk-dialog-fuss { position: sticky; bottom: 0; margin-top: 4px; padding: 6px 0 2px; background: #0e1430; } +} + +/* Handy quer (niedrig): Szene links (45 % der Breite), Daten rechts daneben und scrollbar */ +@media (max-width: 719px) and (max-height: 520px) { + .hk-hochhaus .hk-dialog { flex-direction: row; align-items: flex-start; } + .hk-hochhaus .hk-dialog .hk-sz { zoom: 0.4; } + @supports (zoom: tan(atan2(1px, 1px))) { + .hk-hochhaus .hk-dialog .hk-sz { zoom: tan(atan2(45cqw, 640px)); } + } + .hk-hochhaus .hk-dialog-daten { align-self: stretch; } +} diff --git a/tests/unit/hustlerDialog.test.mjs b/tests/unit/hustlerDialog.test.mjs new file mode 100644 index 0000000..e260640 --- /dev/null +++ b/tests/unit/hustlerDialog.test.mjs @@ -0,0 +1,638 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import esbuild from 'esbuild' +import { fakeReact } from './helpers/fakeReact.mjs' +import { loadModule } from './load.mjs' + +// HustlerDialog (Detailansicht, Spec E10/E14) läuft wirklich: im Mini-Läufer anstelle von React (helpers/fakeReact.mjs), gerendert wird in einen Baum +// aus { tag, props, children }. react-router-dom (Link) und der Spur-Abruf (hooks/useWorkersFeed) sind Attrappen, damit der Test die Spur selbst +// liefert; document, Fokus und die Uhr (Date.now) sind ebenfalls Attrappen. Klicks und Tasten ruft der Test von Hand auf. +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`) +const NBSP = String.fromCharCode(160) + +const ROUTER = "import { jsx } from 'react'\nexport function Link({ to, children }) { return jsx('router-link', { to, children }) }\n" +const TRACE = ` +export function useWorkerTrace(api, workerId) { + globalThis.__traceCalls.push({ api, workerId }) + return globalThis.__traceState +} +` +const attrappen = { + name: 'hustler-dialog-attrappen', + setup(build) { + build.onResolve({ filter: /^react-router-dom$/ }, () => ({ path: 'router', namespace: 'attrappe' })) + build.onResolve({ filter: /hooks\/useWorkersFeed$/ }, () => ({ path: 'trace', namespace: 'attrappe' })) + build.onLoad({ filter: /^router$/, namespace: 'attrappe' }, () => ({ contents: ROUTER, loader: 'js' })) + build.onLoad({ filter: /^trace$/, namespace: 'attrappe' }, () => ({ contents: TRACE, loader: 'js' })) + }, +} + +let bundle +const loadUi = () => (bundle ??= esbuild.build({ + stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as HustlerDialog } from './src/components/workers/hochhaus/HustlerDialog.jsx'", resolveDir: ROOT, loader: 'js' }, + bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact, attrappen], +}).then(importBundle)) + +const { ROLES } = await loadModule('src/lib/workersRoster.js') +const { DETAIL } = await loadModule('src/lib/hustlerWelt.js') + +// 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 classes = (n) => String(n.props.className ?? '').split(' ').filter((c) => c !== '') + +// DOM-Attrappe für den Fokus: document mit body, activeElement und querySelector; ref an Rahmen, Hintergrund, Dialog und die zwei Knöpfe liefert __fakeRef. +// Jedes Element merkt sich die Argumente seiner focus()-Aufrufe und setzt beim Fokussieren activeElement. +function fakeDom() { + const body = { name: 'body', isConnected: true, focusCalls: [] } + const doc = { body, activeElement: body, asked: [], found: new Map(), querySelector(selector) { doc.asked.push(selector); return doc.found.get(selector) ?? null } } + const element = (name, extra = {}) => ({ + name, isConnected: true, focusCalls: [], + focus(options) { this.focusCalls.push(options); doc.activeElement = this }, + ...extra, + }) + const made = new Map() + const had = Object.hasOwn(globalThis, 'document') + const saved = globalThis.document + globalThis.document = doc + globalThis.__fakeRef = (tag, props) => { + const key = props.role ?? props['aria-label'] ?? tag + if (!made.has(key)) made.set(key, element(key)) + return made.get(key) + } + return { + doc, element, + ref: (key) => made.get(key), + restore() { + if (had) globalThis.document = saved + else delete globalThis.document + delete globalThis.__fakeRef + }, + } +} + +// Ein Lauf: Dialog einhängen. props = { workerId, workers, ... }, trace = Ergebnis des Spur-Abrufs, Uhr von Hand (clock), Props und Spur zwischendurch änderbar. +const NOW = Date.parse('2026-10-22T12:00:00Z') +async function start({ props = {}, trace = { trace: null, loading: false, error: null }, dom = null } = {}) { + const { __mount, jsx, HustlerDialog } = await loadUi() + const realNow = Date.now + const view = { clock: NOW, closed: 0, wechsel: [], dom } + Date.now = () => view.clock + globalThis.__traceCalls = [] + globalThis.__traceState = trace + const api = { name: 'api' } + let current = { api, workerId: 'lea', workers: {}, onClose: () => { view.closed += 1 }, onWechsel: (id) => { view.wechsel.push(id) }, ...props } + const mounted = __mount(jsx(() => jsx(HustlerDialog, current), {})) + Object.assign(view, { + api, + traceCalls: () => globalThis.__traceCalls, + tree: () => mounted.tree(), + text: () => textOf(mounted.tree()), + setProps(next) { current = { ...current, ...next }; mounted.rerender() }, + setTrace(next) { globalThis.__traceState = next; mounted.rerender() }, + find: (match) => nodes(mounted.tree(), match), + one(match, what) { + const found = nodes(mounted.tree(), match) + assert.equal(found.length, 1, `${what} (gefunden: ${found.length})`) + return found[0] + }, + byClass: (name) => nodes(mounted.tree(), (n) => hasClass(n, name)), + dialog: () => view.one((n) => n.tag === 'section' && n.props.role === 'dialog', 'Dialog'), + rahmen: () => view.one((n) => hasClass(n, 'hk-dialog-ueber'), 'Rahmen'), + hinten: () => view.one((n) => hasClass(n, 'hk-dialog-hinten'), 'Hintergrund'), + knopf: (label) => view.one((n) => n.tag === 'button' && n.props['aria-label'] === label, `Knopf ${label}`), + figur: () => view.one((n) => classes(n).includes('hk-spr') && classes(n).some((c) => c.startsWith('hk-gr-')), 'Figur'), + // Fakten aus der Liste: { Modell: 'Claude …', Etage: '…', … } + fakten() { + const dl = view.one((n) => n.tag === 'dl', 'Faktenliste') + const out = {} + for (let i = 0; i + 1 < dl.children.length; i += 2) out[textOf(dl.children[i])] = textOf(dl.children[i + 1]) + return out + }, + spurZeilen: () => view.byClass('hk-dialog-spurzeile').map((li) => ({ zeit: textOf(view.sub(li, 'hk-dialog-zeit')), text: textOf(view.sub(li, 'hk-dialog-spurtext')), li })), + sub: (node, name) => nodes(node, (n) => hasClass(n, name))[0], + taste(key, extra = {}) { + const e = { key, shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, prevented: false, stopped: false, preventDefault() { this.prevented = true }, stopPropagation() { this.stopped = true }, ...extra } + view.rahmen().props.onKeyDown(e) + return e + }, + end() { mounted.unmount(); Date.now = realNow; delete globalThis.__traceCalls; delete globalThis.__traceState; if (dom) dom.restore() }, + }) + return view +} + +const lauf = async (opts, fn) => { + const ui = await start(opts) + try { await fn(ui) } finally { ui.end() } +} + +const EINTRAG = (status, extra = {}) => ({ status, task: '', lastTs: null, connected: status !== 'grau', counters: {}, ...extra }) + +// --------------------------------------------------------------------------------------------------------------------------------------------- +// Felder und Szene je Status + +test('arbeitender Haiku (Lea): Dialog-Rolle, Name, Status, Fakten, Büro-Szene mit Figur und Monitor, Nummer', async () => { + const workers = { lea: EINTRAG('arbeitet', { task: 'Liest das Impressum von „Café Morgenrot“', counters: { gelesen: 3 } }) } + const trace = { trace: { worker: { modelId: 'claude-haiku-5-5' }, today: { costUsd: 0.21, tokens: { in: 20000, out: 4800 }, counters: { gelesen: 7, passend: 2 } }, events: [] }, loading: false, error: null } + await lauf({ props: { workerId: 'lea', workers }, trace }, (ui) => { + const d = ui.dialog() + assert.equal(d.props['aria-modal'], 'true') + assert.equal(d.props['aria-label'], 'Hustler Lea') + assert.equal(textOf(ui.one((n) => n.tag === 'h2', 'Überschrift')), 'Lea') + const status = ui.one((n) => hasClass(n, 'hk-dialog-status'), 'Status') + assert.equal(textOf(status).trim(), 'Arbeitet') + assert.equal(status.props.style['--hk-status'], '#4ade5a') + assert.deepEqual(ui.fakten(), { + Modell: 'Claude Haiku 5.5', + Etage: 'Ohne Website · Läden & Gastro', + Aufgabe: 'Branchen-Scout für Läden und Gastro ohne oder mit veralteter Website.', + Gerade: 'Liest das Impressum von „Café Morgenrot“', + Heute: '7 gelesen · 2 passend', + Kosten: `0,21${NBSP}$ · 24.800 Tokens`, + }) + assert.equal(textOf(ui.one((n) => hasClass(n, 'hk-dialog-nummer'), 'Nummer')), '9 / 19') + // Szene und Figur + const szene = ui.one((n) => hasClass(n, 'hk-sz'), 'Szene') + assert.equal(szene.props.className, 'hk-sz hk-sz-buero') + const figur = ui.figur() + assert.equal(figur.props.className, 'hk-spr hk-gr-muetze-arbeiten') + assert.deepEqual(figur.props.style, { left: DETAIL.figur.buero.left, top: DETAIL.figur.buero.top }) + assert.equal(figur.props['aria-hidden'], 'true') + const monitor = ui.one((n) => hasClass(n, 'hk-monitor-gross'), 'Monitor') + assert.deepEqual(monitor.props.style, { left: DETAIL.monitor.left, top: DETAIL.monitor.top }) + for (const weg of ['hk-dampf', 'hk-zzz-gross', 'hk-wirbel', 'hk-dialog-schild', 'hk-alarm', 'hk-gedimmt']) assert.equal(ui.byClass(weg).length, 0, `${weg} nicht da`) + assert.ok(ui.text().includes('Spur')) + }) +}) + +test('Fakten ohne Spur-Daten: Modell aus dem Roster, Gerade und Heute aus dem Zustandseintrag, sonst Strich', async () => { + const workers = { bea: EINTRAG('wartet', { task: 'Wartet auf Leas Kandidaten.', counters: { bewertet: 2 } }) } + await lauf({ props: { workerId: 'bea', workers } }, (ui) => { + assert.deepEqual(ui.fakten(), { + Modell: 'Claude Sonnet 5.5', + Etage: 'Bewertung · Waage', + Aufgabe: 'Bewertet Anfragen und Kandidaten und verteilt Top 3 und Rest.', + Gerade: 'Wartet auf Leas Kandidaten.', + Heute: '2 bewertet', + Kosten: '– · – Tokens', + }) + // wartet: Kaffeedampf, keine anderen Zusätze + assert.equal(ui.byClass('hk-dampf').length, 1) + assert.deepEqual(ui.byClass('hk-dampf')[0].props.style, { left: DETAIL.dampf.left, top: DETAIL.dampf.top }) + assert.equal(ui.byClass('hk-monitor-gross').length + ui.byClass('hk-zzz-gross').length, 0) + assert.equal(ui.figur().props.className, 'hk-spr hk-gr-kappe-warten') + }) +}) + +test('Leerlauf: Zzz neben der schlafenden Figur', async () => { + await lauf({ props: { workerId: 'hanna', workers: { hanna: EINTRAG('leerlauf') } } }, (ui) => { + assert.equal(ui.figur().props.className, 'hk-spr hk-gr-muetze-schlafen') + const zzz = ui.one((n) => hasClass(n, 'hk-zzz-gross'), 'Zzz') + assert.deepEqual(zzz.props.style, { left: DETAIL.zzz.left, top: DETAIL.zzz.top }) + assert.equal(ui.byClass('hk-monitor-gross').length + ui.byClass('hk-dampf').length, 0) + const status = ui.one((n) => hasClass(n, 'hk-dialog-status'), 'Status') + assert.equal(textOf(status).trim(), 'Leerlauf') + assert.equal(status.props.style['--hk-status'], '#9aa6d6') + }) +}) + +test('grauer Such-Scout (Kim, nicht angeschlossen): Stecker-Szene, stehende Figur, Schild „noch nicht angeschlossen“, kein Monitor', async () => { + await lauf({ props: { workerId: 'kim', workers: { kim: EINTRAG('grau', { connected: false }) } } }, (ui) => { + assert.equal(ui.one((n) => hasClass(n, 'hk-sz'), 'Szene').props.className, 'hk-sz hk-sz-nicht') + const figur = ui.figur() + assert.equal(figur.props.className, 'hk-spr hk-gr-muetze-nicht') + assert.deepEqual(figur.props.style, { left: DETAIL.figur.nicht.left, top: DETAIL.figur.nicht.top }) + const schild = ui.one((n) => hasClass(n, 'hk-dialog-schild'), 'Schild') + assert.equal(textOf(schild), 'noch nicht angeschlossen') + assert.deepEqual(schild.props.style, { left: DETAIL.schild.left, top: DETAIL.schild.top, width: DETAIL.schild.w, height: DETAIL.schild.h }) + for (const weg of ['hk-monitor-gross', 'hk-dampf', 'hk-zzz-gross', 'hk-wirbel', 'hk-alarm']) assert.equal(ui.byClass(weg).length, 0, `${weg} nicht da`) + const status = ui.one((n) => hasClass(n, 'hk-dialog-status'), 'Status') + assert.equal(textOf(status).trim(), 'Noch nicht angeschlossen') + assert.equal(status.props.style['--hk-status'], '#a4a2ad') + assert.equal(ui.fakten().Etage, 'Anfragen · Kleinanzeigen') + }) +}) + +test('Fehler: stehende Figur im Büro mit rotem „!“-Marker (hk-alarm hk-alarm-gross), Status „Fehler“ in Rot', async () => { + await lauf({ props: { workerId: 'rita', workers: { rita: EINTRAG('fehler', { task: 'Suche fehlgeschlagen' }) } } }, (ui) => { + assert.equal(ui.one((n) => hasClass(n, 'hk-sz'), 'Szene').props.className, 'hk-sz hk-sz-buero') + const figur = ui.figur() + assert.equal(figur.props.className, 'hk-spr hk-gr-muetze-nicht') + assert.deepEqual(figur.props.style, { left: DETAIL.figur.buero.left, top: DETAIL.figur.buero.top }) + const alarm = ui.one((n) => hasClass(n, 'hk-alarm'), 'Alarm') + assert.equal(alarm.props.className, 'hk-alarm hk-alarm-gross') + assert.equal(alarm.props['aria-hidden'], 'true') + assert.equal(textOf(alarm), '!') + assert.ok(figur.children.includes(alarm), 'der Marker sitzt an der Figur (über dem Kopf)') + const status = ui.one((n) => hasClass(n, 'hk-dialog-status'), 'Status') + assert.equal(textOf(status).trim(), 'Fehler') + assert.equal(status.props.style['--hk-status'], '#ff5a5a') + assert.equal(ui.fakten().Gerade, 'Suche fehlgeschlagen') + assert.equal(ui.byClass('hk-schild').length + ui.byClass('hk-dialog-schild').length + ui.byClass('hk-monitor-gross').length, 0) + }) +}) + +test('Overseer: heißt „Overseer“, Brücken-Szene mit Wirbel; bei Leerlauf gedimmt, bei „arbeitet“ voll', async () => { + await lauf({ props: { workerId: 'otto', workers: { otto: EINTRAG('leerlauf') } } }, (ui) => { + assert.equal(ui.dialog().props['aria-label'], 'Hustler Overseer') + assert.equal(textOf(ui.one((n) => n.tag === 'h2', 'Überschrift')), 'Overseer') + assert.equal(ui.one((n) => hasClass(n, 'hk-sz'), 'Szene').props.className, 'hk-sz hk-sz-bruecke') + const figur = ui.figur() + assert.equal(figur.props.className, 'hk-spr hk-gr-overseer hk-gedimmt') + assert.deepEqual(figur.props.style, { left: DETAIL.figur.bruecke.left, top: DETAIL.figur.bruecke.top }) + assert.deepEqual(ui.one((n) => hasClass(n, 'hk-wirbel'), 'Wirbel').props.style, { left: DETAIL.wirbel.left, top: DETAIL.wirbel.top }) + // kein Zzz, kein Dampf, kein Monitor, kein Schild (Overseer ist ein Kristall) + for (const weg of ['hk-zzz-gross', 'hk-dampf', 'hk-monitor-gross', 'hk-dialog-schild']) assert.equal(ui.byClass(weg).length, 0, `${weg} nicht da`) + assert.equal(ui.fakten().Etage, 'Kommandobrücke · Brücke') + assert.equal(textOf(ui.one((n) => hasClass(n, 'hk-dialog-nummer'), 'Nummer')), '1 / 19') + ui.setProps({ workers: { otto: EINTRAG('arbeitet') } }) + assert.equal(ui.figur().props.className, 'hk-spr hk-gr-overseer') + assert.equal(ui.byClass('hk-wirbel').length, 1) + }) +}) + +test('unbekannter Hustler oder fehlender Eintrag: kein Absturz, Name = Kennung, Leerlauf-Figur im Büro', async () => { + await lauf({ props: { workerId: 'constructor', workers: {} } }, (ui) => { + assert.equal(ui.dialog().props['aria-label'], 'Hustler constructor') + assert.equal(ui.fakten().Etage, '–') + assert.equal(ui.fakten().Gerade, '–') + assert.equal(ui.one((n) => hasClass(n, 'hk-sz'), 'Szene').props.className, 'hk-sz hk-sz-buero') + }) + await lauf({ props: { workerId: 'lea', workers: undefined } }, (ui) => { + assert.equal(ui.figur().props.className, 'hk-spr hk-gr-muetze-schlafen') + }) +}) + +test('workerId null: nichts wird angezeigt, der Abruf bekommt null', async () => { + await lauf({ props: { workerId: null } }, (ui) => { + assert.equal(ui.tree(), null) + assert.deepEqual(ui.traceCalls().map((c) => c.workerId), [null]) + }) +}) + +test('alle 19 Hustler in allen 5 Status: Klassen der Szene, Figur und Zusätze stehen im CSS, jeder bekommt Name und Nummer', async () => { + const css = ['hochhaus.css', 'hustler-ui.css', 'hustler-detail.css'].map((f) => fs.readFileSync(path.join(ROOT, 'src/styles/hustler', f), 'utf8')).join('\n') + const bekannt = new Set([...css.matchAll(/\.(hk-[a-z0-9-]+)/g)].map((m) => m[1])) + const stati = [['arbeitet', {}], ['wartet', {}], ['leerlauf', {}], ['grau', { connected: false }], ['fehler', {}]] + const ui = await start({ props: { workerId: 'otto', workers: {} } }) + try { + for (const rolle of ROLES) { + for (const [status, extra] of stati) { + ui.setProps({ workerId: rolle.workerId, workers: { [rolle.workerId]: EINTRAG(status, extra) } }) + const alle = ui.find(() => true) + for (const n of alle) for (const c of classes(n)) if (c.startsWith('hk-')) assert.ok(bekannt.has(c), `${rolle.workerId}/${status}: Klasse ${c} fehlt im CSS`) + assert.ok(textOf(ui.one((n) => n.tag === 'h2', 'Überschrift')).length > 0) + assert.equal(textOf(ui.one((n) => hasClass(n, 'hk-dialog-nummer'), 'Nummer')), `${ROLES.indexOf(rolle) + 1} / 19`) + assert.equal(ui.figur().props.style.left !== undefined, true) + } + } + } finally { ui.end() } +}) + +// --------------------------------------------------------------------------------------------------------------------------------------------- +// Spur: Laden, Fehler, leer, Links + +test('Spur: während des ersten Ladens „Spur wird geladen …“, nicht „Noch keine Einträge.“', async () => { + await lauf({ props: { workerId: 'lea' }, trace: { trace: null, loading: true, error: null } }, (ui) => { + assert.ok(ui.text().includes('Spur wird geladen …')) + assert.ok(!ui.text().includes('Noch keine Einträge.')) + assert.equal(ui.spurZeilen().length, 0) + assert.equal(ui.find((n) => n.props.role === 'alert').length, 0) + // Fakten stehen schon (Roster + Zustandseintrag), auch ohne Spur + assert.equal(ui.fakten().Modell, 'Claude Haiku 5.5') + ui.setTrace({ trace: { events: [] }, loading: false, error: null }) + assert.ok(!ui.text().includes('Spur wird geladen')) + }) +}) + +test('Spur: keine Ereignisse -> „Noch keine Einträge.“', async () => { + await lauf({ props: { workerId: 'lea' }, trace: { trace: { events: [] }, loading: false, error: null } }, (ui) => { + assert.ok(ui.text().includes('Noch keine Einträge.')) + assert.equal(ui.byClass('hk-dialog-spurliste').length, 0) + }) +}) + +test('Spur: Fehler des Abrufs als role=alert; ohne Daten kein „Noch keine Einträge.“, mit alter Spur bleibt sie stehen', async () => { + await lauf({ props: { workerId: 'lea' }, trace: { trace: null, loading: false, error: 'Der Hustler-Dienst ist nicht erreichbar.' } }, (ui) => { + const alerts = ui.find((n) => n.props.role === 'alert') + assert.equal(alerts.length, 1) + assert.equal(textOf(alerts[0]), 'Der Hustler-Dienst ist nicht erreichbar.') + assert.ok(!ui.text().includes('Noch keine Einträge.')) + assert.ok(!ui.text().includes('Spur wird geladen')) + ui.setTrace({ trace: { events: [{ id: 'e1', ts: '2026-10-22T01:02:00Z', text: 'Lauf gestartet' }] }, loading: false, error: 'Zeitüberschreitung' }) + assert.equal(ui.find((n) => n.props.role === 'alert').length, 1) + assert.deepEqual(ui.spurZeilen().map((z) => [z.zeit, z.text]), [['03:02', 'Lauf gestartet']]) + ui.setTrace({ trace: { events: [{ id: 'e1', ts: '2026-10-22T01:02:00Z', text: 'Lauf gestartet' }] }, loading: false, error: null }) + assert.equal(ui.find((n) => n.props.role === 'alert').length, 0) + }) +}) + +test('Spur: neueste zuerst mit Zeit und Text; der Abruf läuft mit api und Hustler-Kennung, beim Blättern für den neuen Hustler', async () => { + const events = [ + { id: 'a', ts: '2026-10-21T10:00:00Z', text: 'Gestern' }, + { id: 'b', ts: '2026-10-22T01:02:00Z', text: 'Heute früh' }, + { id: 'c', ts: '2026-10-22T09:30:00Z', text: 'Heute Vormittag' }, + ] + await lauf({ props: { workerId: 'lea' }, trace: { trace: { events }, loading: false, error: null } }, (ui) => { + assert.deepEqual(ui.spurZeilen().map((z) => [z.zeit, z.text]), [['11:30', 'Heute Vormittag'], ['03:02', 'Heute früh'], ['21.10. 12:00', 'Gestern']]) + assert.equal(ui.byClass('hk-dialog-spurliste')[0].tag, 'ol') + assert.deepEqual(ui.traceCalls().map((c) => [c.api, c.workerId]), [[ui.api, 'lea']]) + ui.setProps({ workerId: 'bea' }) + assert.equal(ui.traceCalls().at(-1).workerId, 'bea') + }) +}) + +test('Spur-Links: unsichere (javascript:, //host, /\\host, data:) bleiben Text; extern öffnet in neuem Tab mit noopener; intern über den Router-Link', async () => { + const events = [ + { id: '1', ts: '2026-10-22T09:00:00Z', text: 'Skript', link: 'javascript:alert(1)' }, + { id: '2', ts: '2026-10-22T08:59:00Z', text: 'Extern', link: 'https://example.org/pfad?x=1' }, + { id: '3', ts: '2026-10-22T08:58:00Z', text: 'Intern', link: '/leads/abc?tab=1' }, + { id: '4', ts: '2026-10-22T08:57:00Z', text: 'Fremder Host', link: '//evil.example/x' }, + { id: '5', ts: '2026-10-22T08:56:00Z', text: 'Rückstrich', link: '/\\evil.example' }, + { id: '6', ts: '2026-10-22T08:55:00Z', text: 'Daten', link: 'data:text/html,x' }, + { id: '7', ts: '2026-10-22T08:54:00Z', text: 'Ohne Link' }, + { id: '8', ts: '2026-10-22T08:53:00Z', text: 'Steuerzeichen', link: '/ok\n.evil' }, + ] + await lauf({ props: { workerId: 'lea' }, trace: { trace: { events }, loading: false, error: null } }, (ui) => { + const zeilen = ui.spurZeilen() + assert.equal(zeilen.length, 8) + const ziele = (li) => nodes(li, (n) => n.tag === 'a' || n.tag === 'router-link') + const byText = (t) => zeilen.find((z) => z.text === t) + for (const t of ['Skript', 'Fremder Host', 'Rückstrich', 'Daten', 'Ohne Link', 'Steuerzeichen']) assert.equal(ziele(byText(t).li).length, 0, `${t} bleibt Text`) + const extern = ziele(byText('Extern').li) + assert.equal(extern.length, 1) + assert.equal(extern[0].tag, 'a') + assert.equal(extern[0].props.href, 'https://example.org/pfad?x=1') + assert.equal(extern[0].props.target, '_blank') + assert.equal(extern[0].props.rel, 'noopener noreferrer') + const intern = ziele(byText('Intern').li) + assert.equal(intern.length, 1) + assert.equal(intern[0].tag, 'router-link') + assert.equal(intern[0].props.to, '/leads/abc?tab=1') + assert.equal(intern[0].props.href, undefined) + // nirgends ein gefährliches Ziel im Baum + for (const n of ui.find((x) => x.tag === 'a' || x.tag === 'router-link')) assert.ok(!/^javascript:|^data:|^\/\//i.test(String(n.props.href ?? n.props.to))) + }) +}) + +// --------------------------------------------------------------------------------------------------------------------------------------------- +// Tasten, Blättern, Hintergrund + +test('Esc schließt (onClose, Taste behandelt und nicht weitergereicht); „Schließen“ schließt ebenfalls', async () => { + await lauf({ props: { workerId: 'lea' } }, (ui) => { + const e = ui.taste('Escape') + assert.equal(ui.closed, 1) + assert.equal(e.prevented, true) + assert.equal(e.stopped, true) + ui.knopf('Schließen').props.onClick({}) + assert.equal(ui.closed, 2) + // andere Tasten tun nichts + for (const k of ['Enter', 'a', ' ', 'ArrowUp', 'ArrowDown']) { + const x = ui.taste(k) + assert.equal(x.prevented, false, k) + } + assert.equal(ui.closed, 2) + assert.deepEqual(ui.wechsel, []) + }) +}) + +test('←/→ blättern in Roster-Reihenfolge mit Umlauf (otto ↔ arne); ◂ ▸ tun dasselbe; Strg/Alt/Meta + Pfeil bleibt dem Browser', async () => { + await lauf({ props: { workerId: 'otto' } }, (ui) => { + const links = ui.taste('ArrowLeft') + assert.deepEqual(ui.wechsel, ['arne']) + assert.equal(links.prevented, true) + assert.equal(links.stopped, true) + ui.taste('ArrowRight') + assert.deepEqual(ui.wechsel, ['arne', 'rita']) + ui.setProps({ workerId: 'arne' }) + ui.taste('ArrowRight') + assert.deepEqual(ui.wechsel.at(-1), 'otto') + ui.setProps({ workerId: 'lea' }) + ui.taste('ArrowRight') + ui.taste('ArrowLeft') + assert.deepEqual(ui.wechsel.slice(-2), ['bea', 'sven']) + // ◂ ▸ + ui.knopf('Nächster Hustler').props.onClick({}) + ui.knopf('Vorheriger Hustler').props.onClick({}) + assert.deepEqual(ui.wechsel.slice(-2), ['bea', 'sven']) + ui.setProps({ workerId: 'arne' }) + ui.knopf('Nächster Hustler').props.onClick({}) + assert.equal(ui.wechsel.at(-1), 'otto') + ui.setProps({ workerId: 'otto' }) + ui.knopf('Vorheriger Hustler').props.onClick({}) + assert.equal(ui.wechsel.at(-1), 'arne') + // mit Modifikator nicht abfangen + const n = ui.wechsel.length + for (const mod of ['altKey', 'ctrlKey', 'metaKey']) { + const e = ui.taste('ArrowLeft', { [mod]: true }) + assert.equal(e.prevented, false, mod) + } + assert.equal(ui.wechsel.length, n) + // Blättern schließt nicht + assert.equal(ui.closed, 0) + }) +}) + +test('Hintergrund-Klick schließt erst 400 ms nach dem Öffnen (Uhr über Date.now)', async () => { + await lauf({ props: { workerId: 'lea' } }, (ui) => { + const hinten = ui.hinten() + assert.equal(hinten.props['aria-label'], 'Detailansicht schließen') + assert.equal(hinten.props.tabIndex, -1) + hinten.props.onClick({}) + assert.equal(ui.closed, 0, 'sofort nach dem Öffnen: nichts') + ui.clock += 399 + ui.hinten().props.onClick({}) + assert.equal(ui.closed, 0, '399 ms: nichts') + ui.clock += 1 + ui.hinten().props.onClick({}) + assert.equal(ui.closed, 0, 'genau 400 ms: noch nichts') + ui.clock += 1 + ui.hinten().props.onClick({}) + assert.equal(ui.closed, 1, '401 ms: schließt') + // „Schließen“ und Esc sind nicht gesperrt + ui.knopf('Schließen').props.onClick({}) + assert.equal(ui.closed, 2) + }) +}) + +test('Hintergrund-Sperre gilt ab dem Öffnen: wird der Dialog später (neu) geöffnet, beginnt die Sperre von vorn', async () => { + await lauf({ props: { workerId: null } }, (ui) => { + ui.clock += 10000 + ui.setProps({ workerId: 'lea' }) + ui.hinten().props.onClick({}) + assert.equal(ui.closed, 0, 'frisch geöffnet') + ui.clock += 500 + ui.hinten().props.onClick({}) + assert.equal(ui.closed, 1) + }) +}) + +// --------------------------------------------------------------------------------------------------------------------------------------------- +// Fokus: auf „Schließen“ beim Öffnen, Falle, Rückgabe + +test('Fokus beim Öffnen: auf „Schließen“ (ohne Scrollen); die Falle hält Tab und Umschalt+Tab im Dialog', async () => { + const dom = fakeDom() + const opener = dom.element('figur') + dom.doc.activeElement = opener + await lauf({ props: { workerId: 'lea' }, dom }, (ui) => { + const erster = dom.ref('Schließen') + const letzter = dom.ref('Nächster Hustler') + assert.ok(erster && letzter, 'refs an Schließen und ▸') + assert.equal(dom.doc.activeElement, erster) + assert.deepEqual(erster.focusCalls, [{ preventScroll: true }]) + // Tab auf dem letzten Knopf (▸) -> zurück zu „Schließen“ + dom.doc.activeElement = letzter + let e = ui.taste('Tab') + assert.equal(e.prevented, true) + assert.equal(dom.doc.activeElement, erster) + // Umschalt+Tab auf „Schließen“, auf dem Dialog und auf dem Hintergrund -> ▸ + for (const start of [erster, dom.ref('dialog'), dom.ref('Detailansicht schließen')]) { + dom.doc.activeElement = start + e = ui.taste('Tab', { shiftKey: true }) + assert.equal(e.prevented, true, start.name) + assert.equal(dom.doc.activeElement, letzter, start.name) + } + // dazwischen läuft Tab von selbst (Links der Spur, ◂): nichts abfangen + const mitte = dom.element('◂') + dom.doc.activeElement = mitte + for (const shiftKey of [false, true]) { + e = ui.taste('Tab', { shiftKey }) + assert.equal(e.prevented, false) + assert.equal(dom.doc.activeElement, mitte) + } + // Tab auf „Schließen“ und Umschalt+Tab auf ▸ laufen ebenfalls von selbst + dom.doc.activeElement = erster + assert.equal(ui.taste('Tab').prevented, false) + dom.doc.activeElement = letzter + assert.equal(ui.taste('Tab', { shiftKey: true }).prevented, false) + }) +}) + +test('Blättern lässt den Fokus, wo er ist (die Knöpfe bleiben dieselben Elemente)', async () => { + const dom = fakeDom() + await lauf({ props: { workerId: 'lea' }, dom }, (ui) => { + const letzter = dom.ref('Nächster Hustler') + letzter.focus() + const aufrufe = letzter.focusCalls.length + ui.setProps({ workerId: 'bea' }) + assert.equal(dom.doc.activeElement, letzter) + assert.equal(letzter.focusCalls.length, aufrufe) + assert.equal(dom.ref('Schließen').focusCalls.length, 1, 'nur das Öffnen fokussiert „Schließen“') + }) +}) + +test('Fokus-Rückgabe: beim Schließen geht der Fokus an das Element zurück, das beim Öffnen aktiv war', async () => { + const dom = fakeDom() + const opener = dom.element('figur lea') + dom.doc.activeElement = opener + const ui = await start({ props: { workerId: 'lea' }, dom }) + try { + assert.notEqual(dom.doc.activeElement, opener) + ui.setProps({ workerId: 'bea' }) // Blättern ändert den Rückgabeort nicht + assert.equal(opener.focusCalls.length, 0) + ui.setProps({ workerId: null }) // die Seite schließt + assert.equal(ui.tree(), null) + assert.equal(dom.doc.activeElement, opener) + assert.deepEqual(opener.focusCalls, [{ preventScroll: true }]) + assert.deepEqual(dom.doc.asked, [], 'kein Suchen, solange das Element noch da ist') + } finally { ui.end() } +}) + +test('Fokus-Rückgabe beim Abbau der Komponente (Seite wechselt den Bildschirm) und beim erneuten Öffnen mit neuem Opener', async () => { + const dom = fakeDom() + const erster = dom.element('figur 1') + const zweiter = dom.element('figur 2') + dom.doc.activeElement = erster + const ui = await start({ props: { workerId: 'lea' }, dom }) + ui.setProps({ workerId: null }) + assert.equal(dom.doc.activeElement, erster) + dom.doc.activeElement = zweiter + ui.setProps({ workerId: 'kim' }) + assert.notEqual(dom.doc.activeElement, zweiter) + assert.equal(dom.ref('Schließen').focusCalls.length, 2, 'beim zweiten Öffnen wieder auf „Schließen“') + ui.end() // Abbau + assert.equal(dom.doc.activeElement, zweiter) + assert.deepEqual(zweiter.focusCalls, [{ preventScroll: true }]) +}) + +test('Fokus-Rückgabe: ist das Element nicht mehr im Dokument, geht der Fokus an die Figur des zuletzt gezeigten Hustlers', async () => { + const dom = fakeDom() + const opener = dom.element('figur lea') + const figurArne = dom.element('figur arne') + dom.doc.found.set('[data-hk-hustler="arne"]', figurArne) + dom.doc.activeElement = opener + const ui = await start({ props: { workerId: 'lea' }, dom }) + try { + ui.setProps({ workerId: 'arne' }) + opener.isConnected = false + ui.setProps({ workerId: null }) + assert.deepEqual(dom.doc.asked, ['[data-hk-hustler="arne"]']) + assert.equal(dom.doc.activeElement, figurArne) + assert.deepEqual(figurArne.focusCalls, [{ preventScroll: true }]) + assert.equal(opener.focusCalls.length, 0) + } finally { ui.end() } +}) + +test('Fokus-Rückgabe: ohne Opener (Fokus lag auf body) ebenfalls die Figur; findet sich keine, passiert nichts', async () => { + const dom = fakeDom() + const figurLea = dom.element('figur lea') + dom.doc.found.set('[data-hk-hustler="lea"]', figurLea) + const ui = await start({ props: { workerId: 'lea' }, dom }) // activeElement = body + ui.setProps({ workerId: null }) + assert.equal(dom.doc.activeElement, figurLea) + assert.deepEqual(figurLea.focusCalls, [{ preventScroll: true }]) + dom.doc.activeElement = dom.doc.body + ui.setProps({ workerId: 'bea' }) + assert.equal(dom.doc.activeElement, dom.ref('Schließen')) + ui.end() // [data-hk-hustler="bea"] gibt es nicht + assert.deepEqual(dom.doc.asked.at(-1), '[data-hk-hustler="bea"]') + assert.equal(dom.doc.activeElement, dom.ref('Schließen'), 'nichts gefunden: der Fokus bleibt, kein Absturz') +}) + +test('Fokus-Rückgabe: Kennungen mit Sonderzeichen gehen nie in einen Selektor', async () => { + const dom = fakeDom() + const ui = await start({ props: { workerId: 'x"] , [data-x="y' }, dom }) + ui.end() + assert.deepEqual(dom.doc.asked, []) +}) + +// --------------------------------------------------------------------------------------------------------------------------------------------- +// Quelltext und Stil + +test('Komponente: kein innerHTML, kein CSS- oder Bild-Import, Links nur über safeLink', () => { + const quelle = fs.readFileSync(path.join(ROOT, 'src/components/workers/hochhaus/HustlerDialog.jsx'), 'utf8') + const src = quelle.replace(/(^|\s)\/\/.*$/gm, '$1') // Zeilenkommentare dürfen das Wort erwähnen + assert.ok(!/dangerouslySetInnerHTML|innerHTML|outerHTML|insertAdjacentHTML|document\.write/.test(src)) + assert.ok(!/import[^\n]*\.(css|png|svg|gif|jpg)['"]/.test(src)) + assert.ok(/safeLink/.test(src)) + // jede Klasse im JSX trägt den Vorsatz hk- + for (const m of src.matchAll(/className=(?:"([^"]*)"|\{`([^`]*)`\})/g)) { + for (const c of (m[1] ?? m[2]).split(/\s+/)) if (c !== '' && !c.startsWith('${')) assert.ok(c.startsWith('hk-'), `Klasse ${c} ohne hk-`) + } +}) + +test('hustler-detail.css: Kopfkommentar, alle Regeln unter .hk-hochhaus, alle Klassen mit hk-, Handy-Regeln unter 720 px', () => { + const css = fs.readFileSync(path.join(ROOT, 'src/styles/hustler/hustler-detail.css'), 'utf8') + assert.ok(css.trimStart().startsWith('/*'), 'Kopfkommentar') + const ohneKommentar = css.replace(/\/\*[\s\S]*?\*\//g, '') + const vorspanne = [...ohneKommentar.matchAll(/([^{}]+)\{/g)].map((m) => m[1].trim()) + assert.ok(vorspanne.length > 20) + for (const v of vorspanne) { + if (v.startsWith('@')) continue + for (const selektor of v.split(',')) assert.ok(selektor.trim().startsWith('.hk-hochhaus '), `Selektor ohne Wurzel: ${selektor.trim()}`) + } + for (const m of ohneKommentar.matchAll(/\.([A-Za-z][\w-]*)/g)) assert.ok(m[1].startsWith('hk-'), `Klasse ${m[1]} ohne hk-`) + assert.ok(/@media \(max-width: 719px\)/.test(css), 'Handy unter 720 px') + // keine Bilder und keine Schriften hier: die kommen aus den erzeugten Dateien + assert.ok(!/url\(|@font-face|@import/.test(ohneKommentar)) +})