From 6f4350495f8fae4b3108f665bd69e02fdd731c78 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 08:20:19 +0000 Subject: [PATCH] =?UTF-8?q?Hustler-Pixelwelt=20T6:=20Kopfzeile=20mit=20M?= =?UTF-8?q?=C3=BCnzen=20und=20Icons,=20Meldungen,=20Pause=20mit=20Pixel-R?= =?UTF-8?q?=C3=BCckfrage,=20Einstellungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../workers/hochhaus/HochhausKopf.jsx | 220 +++++ src/styles/hustler/hustler-kopf.css | 108 +++ tests/unit/hustlerKopf.test.mjs | 782 ++++++++++++++++++ 3 files changed, 1110 insertions(+) create mode 100644 src/components/workers/hochhaus/HochhausKopf.jsx create mode 100644 src/styles/hustler/hustler-kopf.css create mode 100644 tests/unit/hustlerKopf.test.mjs diff --git a/src/components/workers/hochhaus/HochhausKopf.jsx b/src/components/workers/hochhaus/HochhausKopf.jsx new file mode 100644 index 0000000..1185a9f --- /dev/null +++ b/src/components/workers/hochhaus/HochhausKopf.jsx @@ -0,0 +1,220 @@ +import { useEffect, useRef, useState } from 'react' +import { shouldCloseOnKey } from '../../../lib/dialogKeys' +import { hudWerte } from '../../../lib/hustlerSicht' +import { pauseLabel } from '../../../lib/workersView' +import WorkersSettingsDialog from '../WorkersSettingsDialog' + +const PAUSED_TEXT = 'Angehalten: Es wird nichts gesendet und nichts gebaut.' +const PAUSE_FAILED_TEXT = 'Umschalten fehlgeschlagen.' +const NO_REASON_TEXT = 'Grund nicht angegeben' + +// Grund der Notbremse für den Satz „Versand gestoppt: . …“: getrimmt, ohne abschließende Punkte (sonst entstünde „..“) +function haltReasonOf(reason) { + const text = typeof reason === 'string' ? reason.trim().replace(/[.\s]+$/, '') : '' + return text || NO_REASON_TEXT +} + +// Rückfrage „Alles anhalten?“ als Pixel-Dialog (Panel-Stil der Zeichenfläche). Fokus beim Öffnen auf „Abbrechen“ (die harmlose Wahl), +// Tab bleibt im Dialog, Esc schließt, beim Schließen geht der Fokus zurück: auf das Element, das ihn vorher hatte, sonst auf den +// Pause-Knopf (fallbackRef; Safari fokussiert Knöpfe beim Klicken nicht). Ein Klick auf den Hintergrund tut nichts und nimmt dem Dialog +// den Fokus nicht. +function Rueckfrage({ onCancel, onConfirm, fallbackRef }) { + const boxRef = useRef(null) + const cancelRef = useRef(null) + const confirmRef = useRef(null) + const openerRef = useRef(undefined) + if (openerRef.current === undefined) openerRef.current = document.activeElement + + useEffect(() => { + cancelRef.current?.focus() + return () => { + const opener = openerRef.current + const usable = opener && opener !== document.body && opener.isConnected !== false && typeof opener.focus === 'function' + const target = usable ? opener : fallbackRef.current + if (target && target.isConnected !== false && typeof target.focus === 'function') target.focus() + } + }, [fallbackRef]) + + const onKeyDown = (e) => { + if (shouldCloseOnKey(e, false)) { + e.preventDefault() + onCancel() + return + } + if (e.key !== 'Tab') return + const items = [cancelRef.current, confirmRef.current].filter(Boolean) + if (items.length === 0) return + const first = items[0] + const last = items[items.length - 1] + const active = document.activeElement + const inside = items.includes(active) + if (e.shiftKey && (active === first || active === boxRef.current || !inside)) { + e.preventDefault() + last.focus() + } else if (!e.shiftKey && (active === last || !inside)) { + e.preventDefault() + first.focus() + } + } + + return ( +
{ if (e.target === e.currentTarget) e.preventDefault() }} + > +
+

Alles anhalten?

+

Die Worker hören sofort auf, bis jemand fortsetzt.

+
+ + +
+
+
+ ) +} + +// Kopfzeile des Hochhauses (Spec E7): Münzen (Einnahmen, API-Kosten), fünf Kennzahlen mit Icon, rechts „Ganzes Haus zeigen“ (nur Tastatur), +// Pause/Fortsetzen und das Zahnrad (nur Admins), darunter die Meldungen als Pixel-Banner. Die Pause-Logik ist die des früheren WorkersBoard: +// der Dienst bekommt den Zielwert (true = anhalten, nie aus dem Zustand abgeleitet), danach wird der Zustand sofort neu geholt; nur das +// Anhalten fragt vorher nach. Ohne Auth-Bezug: isAdmin kommt von der Seite. state ist der Dienst-Zustand (bereits geladen), error der +// Verbindungsfehler des Feeds (leer, solange alles gut geht), refresh holt den Zustand neu, onGanz zeigt das ganze Haus. +export default function HochhausKopf({ api, isAdmin, state, error, refresh, onGanz }) { + const aliveRef = useRef(true) + const pauseRef = useRef(null) + const refocusPauseRef = useRef(false) // nach dem Umschalten bekommt der Pause-Knopf den Fokus zurück, falls er ihn verlor + const [settingsOpen, setSettingsOpen] = useState(false) + const [pausing, setPausing] = useState(false) + const [pauseError, setPauseError] = useState('') + const [confirmOpen, setConfirmOpen] = useState(false) + + useEffect(() => { + aliveRef.current = true + return () => { aliveRef.current = false } + }, []) + + // Fokus nach dem Umschalten: Während pausing gilt, ist der Knopf gesperrt. Schließt die Rückfrage im selben Takt, in dem pausing beginnt, + // kann sie den Fokus nicht auf den gesperrten Knopf zurückgeben, und er bliebe für immer auf . Deshalb wird er erst hier gesetzt, + // wenn pausing wieder endet (auch nach einem Fehler) und der Knopf wieder bedienbar ist. Hat der Mensch inzwischen woanders hin gewechselt + // (Fokus nicht auf ), bleibt es dabei. + useEffect(() => { + if (pausing || !refocusPauseRef.current) return + refocusPauseRef.current = false + const active = document.activeElement + if (active && active !== document.body) return + pauseRef.current?.focus() + }, [pausing]) + + // Hat inzwischen jemand anderes angehalten, entfällt die Frage; sie darf nach einem späteren Fortsetzen nicht wieder auftauchen + const haltedByState = Boolean(state?.paused) + useEffect(() => { + if (haltedByState) setConfirmOpen(false) + }, [haltedByState]) + + const paused = haltedByState + const hud = hudWerte(state?.kpis) + + // Pause für alle Mitarbeiter: Zielwert übergeben, danach den Zustand sofort neu holen. Ein offener Dialog darf nie fortsetzen, falls + // inzwischen jemand anderes angehalten hat (darum der feste Zielwert). + const setPause = async (target) => { + if (pausing) return + refocusPauseRef.current = true + setPausing(true) + setPauseError('') + try { + await api.pause(target) + refresh() + } catch (err) { + if (aliveRef.current) setPauseError(err?.message || PAUSE_FAILED_TEXT) + } + if (aliveRef.current) setPausing(false) + } + + // Anhalten betrifft alle Mitarbeiter sofort und fragt deshalb vorher nach; Fortsetzen ist harmlos und geht ohne Rückfrage + const requestPause = () => { + if (paused) setPause(false) + else setConfirmOpen(true) + } + const confirmHalt = () => { + setConfirmOpen(false) + setPause(true) + } + + return ( + <> +
+
+
+
+
+
+
+
+ +
+ {hud.zahlen.map((z) => ( +
+
+ ))} +
+ + + +
+ + + {isAdmin && ( + + )} +
+
+ +
+ {paused &&
{PAUSED_TEXT}
} + {pauseError !== '' &&
{pauseError}
} + {state?.sendingHalted === true && ( +
+ Versand gestoppt: {haltReasonOf(state.sendingHaltedReason)}. Ein Admin kann ihn in den Einstellungen freigeben. +
+ )} + {Boolean(error) &&
Verbindung gestört: {error} Es wird weiter versucht.
} +
+
+ + {isAdmin && settingsOpen && ( +
+ setSettingsOpen(false)} onSaved={() => refresh()} /> +
+ )} + + {confirmOpen && !paused && ( + setConfirmOpen(false)} onConfirm={confirmHalt} fallbackRef={pauseRef} /> + )} + + ) +} diff --git a/src/styles/hustler/hustler-kopf.css b/src/styles/hustler/hustler-kopf.css new file mode 100644 index 0000000..55b5557 --- /dev/null +++ b/src/styles/hustler/hustler-kopf.css @@ -0,0 +1,108 @@ +/* Kopfzeile des Hochhauses (HochhausKopf.jsx): Lage, Münzen und Kennzahlen, Pause/Zahnrad, Pixel-Banner für Meldungen, Rückfrage „Alles anhalten?“. + Handgeschrieben (nicht erzeugt). Die Inline-Stile der Zeichenfläche (Main.dc.html, erster Block) stehen hier als Klassen; Panel, Knopf, Icons und + hk-nur-tastatur kommen aus hochhaus.css. Alle Klassen beginnen mit hk- und stehen unter .hk-hochhaus; Keyframes heißen hk-kopf-… . + Wird wie hustler-ui.css in WorkersPage.jsx eingehängt (Komponenten importieren kein CSS). */ + +/* ---------- Lage: oben über der Welt, Abstand 12/10 px; nur die Panels fangen Zeigerereignisse ab (die Welt darunter bleibt bedienbar) ---------- */ +.hk-hochhaus .hk-kopf { + position: absolute; left: 12px; top: 10px; right: 12px; z-index: 5; + display: flex; flex-direction: column; align-items: flex-start; gap: 10px; + pointer-events: none; + font-family: 'Pixelify Sans', ui-monospace, monospace; font-size: 15px; line-height: 20px; color: #e6ebff; +} +.hk-hochhaus .hk-kopf-zeile { align-self: stretch; display: flex; align-items: flex-start; flex-wrap: wrap; gap: 10px; pointer-events: none; } +.hk-hochhaus .hk-kopf-abstand { flex: 1; min-width: 0; } + +/* ---------- Panel „Geld“ ---------- */ +.hk-hochhaus .hk-kopf-geld { display: flex; align-items: center; gap: 20px; padding: 0 6px 0 2px; pointer-events: auto; } +.hk-hochhaus .hk-kopf-wert { display: flex; align-items: center; gap: 8px; } +.hk-hochhaus .hk-kopf-betrag { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 15px; line-height: 20px; } +.hk-hochhaus .hk-kopf-gruen { color: #4ade5a; } +.hk-hochhaus .hk-kopf-rot { color: #ff6b6b; } + +/* Budget fast (80 %) oder ganz verbraucht: der rote Betrag blinkt bzw. leuchtet kräftiger */ +.hk-hochhaus .hk-api-warn { animation: hk-kopf-blinken 1s steps(1, end) infinite; } +.hk-hochhaus .hk-api-neg { color: #ff2d2d; text-shadow: 0 0 0 #ff2d2d, 1px 0 0 #ff2d2d; } +@keyframes hk-kopf-blinken { 0% { opacity: 1; } 50% { opacity: 0.3; } } + +/* ---------- Panel „Kennzahlen“ ---------- */ +.hk-hochhaus .hk-kopf-zahlen { + display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; max-width: 100%; padding: 0 6px 0 2px; pointer-events: auto; + font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 13px; line-height: 18px; +} +.hk-hochhaus .hk-kopf-zahl { display: flex; align-items: center; gap: 6px; } + +/* Die Icons laufen gegeneinander versetzt (wie in der Zeichenfläche), sonst blinzeln alle im selben Takt */ +.hk-hochhaus .hk-kopf-v-rot { animation-delay: -1.3s; } +.hk-hochhaus .hk-kopf-v-abos { animation-delay: -0.4s; } +.hk-hochhaus .hk-kopf-v-deals { animation-delay: -1.1s; } +.hk-hochhaus .hk-kopf-v-threads { animation-delay: -1.8s; } +.hk-hochhaus .hk-kopf-v-pult { animation-delay: -2.5s; } +.hk-hochhaus .hk-kopf-v-botleads { animation-delay: -3.2s; } +.hk-hochhaus .hk-kopf-v-zahnrad { animation-delay: -0.9s; } + +/* Nur für Screenreader (Beschriftung vor der Zahl) */ +.hk-hochhaus .hk-kopf-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } + +/* ---------- Panel rechts: Ganzes Haus (nur Tastatur), Pause/Fortsetzen, Zahnrad ---------- */ +.hk-hochhaus .hk-kopf-rechts { display: flex; align-items: center; gap: 2px; padding: 0; pointer-events: auto; } +.hk-hochhaus .hk-kopf .hk-nur-tastatur { font: inherit; } +.hk-hochhaus .hk-hud-knopf { + display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; margin: -6px 0; padding: 0; + border: 0; background: none; cursor: pointer; +} +.hk-hochhaus .hk-hud-knopf:disabled { opacity: 0.5; cursor: progress; } + +/* ---------- Meldungen: Pixel-Banner unter der Kopfzeile, links, in der Breite begrenzt, mehrere untereinander ---------- */ +.hk-hochhaus .hk-meldungen { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; width: 100%; max-width: min(560px, 100%); pointer-events: none; } +.hk-hochhaus .hk-meldungen:empty { display: none; } +.hk-hochhaus .hk-meldung { + --hk-ton: #5b6bc0; + position: relative; box-sizing: border-box; margin: 0; padding: 8px 12px 8px 44px; + border: 4px solid var(--hk-ton); background: #0e1430; color: #e6ebff; overflow-wrap: anywhere; + /* abgetreppte Ecken statt runder: Pixel-Optik ohne Bild */ + clip-path: polygon(0 4px, 4px 4px, 4px 0, calc(100% - 4px) 0, calc(100% - 4px) 4px, 100% 4px, 100% calc(100% - 4px), calc(100% - 4px) calc(100% - 4px), calc(100% - 4px) 100%, 4px 100%, 4px calc(100% - 4px), 0 calc(100% - 4px)); +} +.hk-hochhaus .hk-meldung::before { + content: '!'; position: absolute; left: 10px; top: 50%; width: 22px; height: 22px; margin-top: -11px; + background: var(--hk-ton); color: #1a1630; text-align: center; + font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 12px; line-height: 22px; +} +.hk-hochhaus .hk-meldung-angehalten { --hk-ton: #ffd25a; } +.hk-hochhaus .hk-meldung-angehalten::before { content: 'II'; font-size: 10px; } +.hk-hochhaus .hk-meldung-umschalten, +.hk-hochhaus .hk-meldung-notbremse { --hk-ton: #e2483d; } +.hk-hochhaus .hk-meldung-umschalten::before, +.hk-hochhaus .hk-meldung-notbremse::before { color: #ffffff; } +.hk-hochhaus .hk-meldung-verbindung { --hk-ton: #ff9f43; } + +/* ---------- Rückfrage „Alles anhalten?“: Pixel-Dialog über der ganzen Welt ---------- */ +.hk-hochhaus .hk-rueckfrage-hintergrund { + position: absolute; inset: 0; z-index: 30; box-sizing: border-box; + display: flex; align-items: center; justify-content: center; padding: 16px; + background: rgba(5, 8, 26, 0.72); color: #e6ebff; + font-family: 'Pixelify Sans', ui-monospace, monospace; font-size: 15px; line-height: 20px; +} +.hk-hochhaus .hk-rueckfrage { width: 100%; max-width: 420px; padding: 4px 8px 8px; outline: none; } +.hk-hochhaus .hk-rueckfrage-titel { margin: 0 0 12px; color: #ffe9a0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 15px; line-height: 22px; font-weight: 400; } +.hk-hochhaus .hk-rueckfrage-text { margin: 0; color: #b8c2f0; } +.hk-hochhaus .hk-rueckfrage-knoepfe { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; margin-top: 16px; } + +/* ---------- Handy: die Kopfzeile bricht um, Schrift etwas kleiner ---------- */ +@media (max-width: 719px) { + .hk-hochhaus .hk-kopf { left: 8px; right: 8px; top: 8px; gap: 8px; font-size: 14px; line-height: 18px; } + .hk-hochhaus .hk-kopf-zeile { gap: 6px 8px; } + .hk-hochhaus .hk-kopf-abstand { display: none; } + .hk-hochhaus .hk-kopf-geld { order: 1; gap: 12px; } + .hk-hochhaus .hk-kopf-betrag { font-size: 12px; line-height: 16px; } + .hk-hochhaus .hk-kopf-rechts { order: 2; margin-left: auto; } + .hk-hochhaus .hk-kopf-zahlen { order: 3; flex: 1 1 100%; gap: 6px 10px; font-size: 11px; line-height: 16px; } + .hk-hochhaus .hk-meldung { padding: 6px 10px 6px 40px; } + .hk-hochhaus .hk-rueckfrage-hintergrund { padding: 12px; } + .hk-hochhaus .hk-rueckfrage-titel { font-size: 13px; } +} + +/* ---------- Weniger Bewegung: kein Blinken (der Betrag bleibt auch ohne Blinken erkennbar) ---------- */ +@media (prefers-reduced-motion: reduce) { + .hk-hochhaus .hk-api-warn { animation: none; color: #ffb86b; } +} diff --git a/tests/unit/hustlerKopf.test.mjs b/tests/unit/hustlerKopf.test.mjs new file mode 100644 index 0000000..320421d --- /dev/null +++ b/tests/unit/hustlerKopf.test.mjs @@ -0,0 +1,782 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import esbuild from 'esbuild' +import { fakeReact } from './helpers/fakeReact.mjs' +import { makeState } from './helpers/workersFixtures.mjs' + +// Die Kopfzeile des Hochhauses (T6) läuft wirklich: im Mini-Läufer anstelle von React (Muster hustlerSeite.test.mjs, Baustein helpers/fakeReact.mjs). +// WorkersSettingsDialog ist per esbuild-Plug-in durch eine Attrappe ersetzt (sie legt ihre Props unter globalThis.__kopf ab). Elemente mit ref +// bekommen kleine Attrappen (focus() setzt document.activeElement, gesperrte Knöpfe nehmen den Fokus nicht), document ist eine Attrappe. +// Geprüft werden die fachlichen Zusicherungen des früheren WorkersBoard (Pause mit Zielwert, Rückfrage nur beim Anhalten, Notbremse, Verbindungshinweis, +// Einstellungen nur für Admins) und die Ausgabe der Kopfzeile (Werte, Striche, Budget-Stufen). +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`) +const settle = () => new Promise((resolve) => setImmediate(resolve)) +const NBSP = String.fromCharCode(160) + +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 classes = (n) => String(n.props.className ?? '').split(' ').filter(Boolean) +const hasClass = (n, name) => classes(n).includes(name) +const byClass = (tree, name) => nodes(tree, (n) => typeof n.tag === 'string' && hasClass(n, name)) +const byRole = (tree, role) => nodes(tree, (n) => typeof n.tag === 'string' && n.props.role === role) +const buttons = (tree) => nodes(tree, (n) => n.tag === 'button') +const buttonByText = (tree, text) => buttons(tree).find((b) => textOf(b) === text) +const pauseButton = (tree) => buttons(tree).find((b) => b.props['data-hk-pause'] !== undefined) +const settingsButton = (tree) => buttons(tree).find((b) => b.props['aria-label'] === 'Einstellungen') +const hausButton = (tree) => buttons(tree).find((b) => textOf(b) === 'Ganzes Haus zeigen') +const event = (extra = {}) => ({ defaultPrevented: false, preventDefault() { this.defaultPrevented = true }, ...extra }) +// Klick wie im Browser: gesperrte Knöpfe lösen nichts aus +const press = (node) => { + if (node.props.disabled) return false + node.props.onClick(event()) + return true +} +const deferred = () => { + let resolve, reject + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +// ---------- Bündel mit Attrappe für den Einstellungen-Dialog ---------- + +const ATTRAPPE = "import { jsx } from 'react'\nexport default function WorkersSettingsDialog(props) { globalThis.__kopf.settings.push(props); return jsx('div', { 'data-attrappe': 'einstellungen' }) }" +const attrappe = { + name: 'einstellungen-attrappe', + setup(build) { + build.onResolve({ filter: /^\.\.\/WorkersSettingsDialog$/ }, () => ({ path: 'WorkersSettingsDialog', namespace: 'kopf-attrappe' })) + build.onLoad({ filter: /.*/, namespace: 'kopf-attrappe' }, () => ({ contents: ATTRAPPE, loader: 'js' })) + }, +} + +let bundle +const loadKopf = () => (bundle ??= esbuild.build({ + stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as HochhausKopf } from './src/components/workers/hochhaus/HochhausKopf.jsx'", resolveDir: ROOT, loader: 'js' }, + bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact, attrappe], +}).then(importBundle)) + +// Ein Lauf der Kopfzeile. props werden zwischendurch geändert (set) und neu gerendert. log hält die Aufrufe in ihrer Reihenfolge. +async function kopf(overrides = {}) { + const { __mount, jsx, HochhausKopf } = await loadKopf() + const log = [] + const kb = { settings: [] } + const body = { tag: 'body', isConnected: true, focus() { globalThis.document.activeElement = body } } + const saved = { document: globalThis.document, hadDocument: Object.hasOwn(globalThis, 'document'), kopf: globalThis.__kopf, fakeRef: globalThis.__fakeRef } + globalThis.document = { activeElement: body, body } + globalThis.__kopf = kb + // pro ref-Objekt bleibt dasselbe Element erhalten (wie im Browser), disabled folgt den Props des letzten Renders + const elements = new WeakMap() + globalThis.__fakeRef = (type, props) => { + let el = elements.get(props.ref) + if (!el) { + el = { tag: type, isConnected: true, disabled: false, focus() { if (!this.disabled) globalThis.document.activeElement = this } } + elements.set(props.ref, el) + } + el.disabled = Boolean(props.disabled) + return el + } + const holder = { + props: { + api: { pause: async (target) => { log.push(`pause:${target}`) } }, + isAdmin: false, + state: makeState(), + error: null, + refresh: () => { log.push('refresh') }, + onGanz: () => { log.push('ganz') }, + ...overrides, + }, + } + const mounted = __mount(jsx(() => jsx(HochhausKopf, holder.props), {})) + await settle() + return { + log, kb, holder, + document: () => globalThis.document, + tree: () => mounted.tree(), + set(next) { holder.props = { ...holder.props, ...next }; mounted.rerender() }, + late: () => mounted.lateUpdates(), + unmount: () => mounted.unmount(), + end() { + mounted.unmount() + if (saved.hadDocument) globalThis.document = saved.document; else delete globalThis.document + if (saved.kopf === undefined) delete globalThis.__kopf; else globalThis.__kopf = saved.kopf + if (saved.fakeRef === undefined) delete globalThis.__fakeRef; else globalThis.__fakeRef = saved.fakeRef + }, + } +} + +const VOLLE_KPIS = { mrrNewEur: 60, apiTodayUsd: 5.1, dailyBudgetUsd: 6, newAbosMonth: 3, dealsMonth: 3, openThreads: 7, pultOpen: 5, botLeads: 112 } + +// Wert-Knoten eines Kopfzeilen-Eintrags: [Screenreader-Text, sichtbarer Text] +const wert = (knoten) => { + const sr = byClass(knoten, 'hk-kopf-sr')[0] + return { sr: textOf(sr), text: knoten.children.filter((c) => typeof c === 'string').join('') } +} + +// ---------- Werte ---------- + +test('Kopfzeile: Panel „Geld“ mit grüner und roter Münze, Beträgen, Screenreader-Texten und Titeln', async () => { + const ui = await kopf({ state: makeState({ kpis: VOLLE_KPIS }) }) + try { + const geld = nodes(ui.tree(), (n) => n.tag === 'section' && n.props['aria-label'] === 'Geld') + assert.equal(geld.length, 1); assert.ok(hasClass(geld[0], 'hk-panel')) + const werte = byClass(geld[0], 'hk-kopf-wert') + assert.equal(werte.length, 2) + assert.equal(werte[0].props.title, 'Eingenommen pro Monat') + assert.equal(werte[1].props.title, `API-Kosten heute: 5,10${NBSP}$ von 6,00${NBSP}$`) + // Münzen + assert.deepEqual(classes(byClass(werte[0], 'hk-ic')[0]).slice(0, 2), ['hk-ic', 'hk-muenze-gruen']) + assert.deepEqual(classes(byClass(werte[1], 'hk-ic')[0]).slice(0, 2), ['hk-ic', 'hk-muenze-rot']) + for (const ic of byClass(geld[0], 'hk-ic')) assert.equal(ic.props['aria-hidden'], 'true') + // Beträge: Pixelschrift-Betrag mit ASCII-Minus, davor der Text nur für Screenreader + const gruen = wert(byClass(werte[0], 'hk-kopf-betrag')[0]) + const rot = wert(byClass(werte[1], 'hk-kopf-betrag')[0]) + assert.deepEqual(gruen, { sr: 'Eingenommen:', text: `+60${NBSP}€/m` }) + assert.deepEqual(rot, { sr: 'API-Kosten heute:', text: `-5,10${NBSP}$` }) + assert.ok(hasClass(byClass(werte[0], 'hk-kopf-betrag')[0], 'hk-kopf-gruen')); assert.ok(hasClass(byClass(werte[1], 'hk-kopf-betrag')[0], 'hk-kopf-rot')) + } finally { ui.end() } +}) + +test('Kopfzeile: Panel „Kennzahlen“ mit fünf Icons in Farbe, Titel, Screenreader-Text „Titel:“ und Zahl', async () => { + const ui = await kopf({ state: makeState({ kpis: VOLLE_KPIS }) }) + try { + const panel = nodes(ui.tree(), (n) => n.tag === 'section' && n.props['aria-label'] === 'Kennzahlen') + assert.equal(panel.length, 1); assert.ok(hasClass(panel[0], 'hk-panel')) + const zahlen = byClass(panel[0], 'hk-kopf-zahl') + const erwartet = [ + ['hk-stern', 'Neue Abos (Monat)', '3', '#ffd25a'], + ['hk-pokal', 'Deals (Monat)', '3', '#ff9f43'], + ['hk-blase', 'Gespräche offen', '7', '#4fd1e8'], + ['hk-glocke', 'Wartet auf dich', '5', '#ff6fae'], + ['hk-robo', 'Bot-Leads gesamt', '112', '#a78bfa'], + ] + assert.equal(zahlen.length, 5) + zahlen.forEach((z, i) => { + const [icon, titel, text, farbe] = erwartet[i] + assert.equal(z.props.title, titel); assert.equal(z.props.style.color, farbe) + const ic = nodes(z, (n) => n.tag === 'span' && hasClass(n, 'hk-ic'))[0] + assert.deepEqual(classes(ic).slice(0, 2), ['hk-ic', icon]); assert.equal(ic.props['aria-hidden'], 'true') + assert.equal(textOf(byClass(z, 'hk-kopf-sr')[0]), `${titel}:`) + assert.equal(textOf(z), `${titel}:${text}`) + }) + } finally { ui.end() } +}) + +test('Kopfzeile: fehlende, leere und kaputte Werte ergeben Striche, nie NaN oder undefined', async () => { + const faelle = [ + { kpis: {} }, + { kpis: undefined }, + { kpis: null }, + { kpis: { mrrNewEur: 'viel', apiTodayUsd: NaN, dailyBudgetUsd: 'x', newAbosMonth: null, dealsMonth: Infinity, openThreads: undefined, pultOpen: '5', botLeads: {} } }, + { kpis: { mrrNewEur: -1, apiTodayUsd: -2 } }, + ] + for (const { kpis } of faelle) { + const state = { ...makeState(), kpis } + const ui = await kopf({ state }) + try { + const text = textOf(ui.tree()) + assert.ok(!/NaN|undefined|null|Infinity/.test(text), JSON.stringify(kpis)) + const betraege = byClass(ui.tree(), 'hk-kopf-betrag').map((b) => wert(b).text) + assert.deepEqual(betraege, ['–', '–'], JSON.stringify(kpis)) + const zahlen = byClass(ui.tree(), 'hk-kopf-zahl').map((z) => textOf(z.children[2])) + assert.deepEqual(zahlen, ['–', '–', '–', '–', '–'], JSON.stringify(kpis)) + // ohne Betrag steht auch im Titel der Strich, nie ein erfundener Wert + assert.ok(byClass(ui.tree(), 'hk-kopf-wert')[1].props.title.includes('–')) + } finally { ui.end() } + } +}) + +test('Kopfzeile: Null ist ein Wert (0 Deals, 0,00 $), kein Strich; große Zahlen mit Tausenderpunkt', async () => { + const ui = await kopf({ state: makeState({ kpis: { mrrNewEur: 0, apiTodayUsd: 0, dailyBudgetUsd: 6, newAbosMonth: 0, dealsMonth: 0, openThreads: 1234, pultOpen: 0, botLeads: 20000 } }) }) + try { + const betraege = byClass(ui.tree(), 'hk-kopf-betrag').map((b) => wert(b).text) + assert.deepEqual(betraege, [`+0${NBSP}€/m`, `0,00${NBSP}$`]) + assert.deepEqual(byClass(ui.tree(), 'hk-kopf-zahl').map((z) => textOf(z.children[2])), ['0', '0', '1.234', '0', '20.000']) + } finally { ui.end() } +}) + +test('Kopfzeile: Budget-Stufen am roten Betrag – unter 80 % nichts, ab 80 % hk-api-warn, ab 100 % hk-api-neg; ohne Budget nichts', async () => { + const stufe = async (kpis) => { + const ui = await kopf({ state: makeState({ kpis }) }) + try { + const rot = byClass(ui.tree(), 'hk-kopf-betrag')[1] + return { warn: hasClass(rot, 'hk-api-warn'), neg: hasClass(rot, 'hk-api-neg'), gruenHat: classes(byClass(ui.tree(), 'hk-kopf-betrag')[0]).some((k) => k.startsWith('hk-api-')) } + } finally { ui.end() } + } + assert.deepEqual(await stufe({ apiTodayUsd: 2, dailyBudgetUsd: 6 }), { warn: false, neg: false, gruenHat: false }) + assert.deepEqual(await stufe({ apiTodayUsd: 4.79, dailyBudgetUsd: 6 }), { warn: false, neg: false, gruenHat: false }) + assert.deepEqual(await stufe({ apiTodayUsd: 4.8, dailyBudgetUsd: 6 }), { warn: true, neg: false, gruenHat: false }) + assert.deepEqual(await stufe({ apiTodayUsd: 5.1, dailyBudgetUsd: 6 }), { warn: true, neg: false, gruenHat: false }) + assert.deepEqual(await stufe({ apiTodayUsd: 6, dailyBudgetUsd: 6 }), { warn: false, neg: true, gruenHat: false }) + assert.deepEqual(await stufe({ apiTodayUsd: 9, dailyBudgetUsd: 6 }), { warn: false, neg: true, gruenHat: false }) + assert.deepEqual(await stufe({ apiTodayUsd: 9, dailyBudgetUsd: undefined }), { warn: false, neg: false, gruenHat: false }) + assert.deepEqual(await stufe({ apiTodayUsd: 9, dailyBudgetUsd: 0 }), { warn: false, neg: false, gruenHat: false }) +}) + +// ---------- Pause ---------- + +test('Pause-Knopf: läuft -> „Alles anhalten“ mit Pause-Icon; angehalten -> „Fortsetzen“ mit Pfeil; type=button, data-hk-pause', async () => { + const ui = await kopf() + try { + let b = pauseButton(ui.tree()) + assert.equal(b.props.type, 'button'); assert.equal(b.props['aria-label'], 'Alles anhalten'); assert.equal(b.props.title, 'Alles anhalten') + assert.ok(hasClass(b, 'hk-hud-knopf')); assert.equal(b.props.disabled, false) + assert.deepEqual(classes(b.children[0]), ['hk-ic', 'hk-pause']); assert.equal(b.children[0].props['aria-hidden'], 'true') + ui.set({ state: makeState({ paused: true }) }) + b = pauseButton(ui.tree()) + assert.equal(b.props['aria-label'], 'Fortsetzen'); assert.equal(b.props.title, 'Fortsetzen') + assert.deepEqual(classes(b.children[0]), ['hk-ic', 'hk-pfeil-rechts']) + } finally { ui.end() } +}) + +test('Pause: läuft -> „Alles anhalten“ fragt erst nach; „Anhalten“ ruft api.pause(true) und danach refresh, die Rückfrage verschwindet', async () => { + const ui = await kopf() + try { + assert.equal(byRole(ui.tree(), 'dialog').length, 0) + press(pauseButton(ui.tree())) + assert.equal(byRole(ui.tree(), 'dialog').length, 1) + assert.deepEqual(ui.log, []) // noch nichts passiert + press(buttonByText(ui.tree(), 'Anhalten')) + await settle() + assert.deepEqual(ui.log, ['pause:true', 'refresh']) + assert.equal(byRole(ui.tree(), 'dialog').length, 0) + assert.equal(pauseButton(ui.tree()).props.disabled, false) // wieder bedienbar + } finally { ui.end() } +}) + +test('Pause: der Dienst bekommt genau einen Zielwert und sonst nichts', async () => { + const aufrufe = [] + const ui = await kopf({ api: { pause: async (...args) => { aufrufe.push(args) } } }) + try { + press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle() + assert.deepEqual(aufrufe, [[true]]) + } finally { ui.end() } +}) + +test('Pause: „Abbrechen“ ruft den Dienst nicht, schließt die Rückfrage und gibt den Fokus an den Pause-Knopf zurück', async () => { + const ui = await kopf() + try { + const pauseEl = pauseButton(ui.tree()).props.ref.current + pauseEl.focus() + press(pauseButton(ui.tree())) + assert.notEqual(ui.document().activeElement, pauseEl) // der Fokus liegt im Dialog + press(buttonByText(ui.tree(), 'Abbrechen')) + await settle() + assert.deepEqual(ui.log, []) + assert.equal(byRole(ui.tree(), 'dialog').length, 0) + assert.equal(ui.document().activeElement, pauseEl) + } finally { ui.end() } +}) + +test('Pause: hatte der Pause-Knopf keinen Fokus (Safari fokussiert Knöpfe beim Klick nicht), geht er beim Schließen trotzdem an ihn', async () => { + const ui = await kopf() + try { + const pauseEl = pauseButton(ui.tree()).props.ref.current + assert.equal(ui.document().activeElement, ui.document().body) + press(pauseButton(ui.tree())) + assert.equal(byRole(ui.tree(), 'dialog').length, 1) + press(buttonByText(ui.tree(), 'Abbrechen')) + assert.equal(ui.document().activeElement, pauseEl) + } finally { ui.end() } +}) + +test('Pause: angehalten -> „Fortsetzen“ geht ohne Rückfrage sofort mit api.pause(false), danach refresh', async () => { + const ui = await kopf({ state: makeState({ paused: true }) }) + try { + press(pauseButton(ui.tree())) + assert.equal(byRole(ui.tree(), 'dialog').length, 0) // keine Rückfrage + await settle() + assert.deepEqual(ui.log, ['pause:false', 'refresh']) + } finally { ui.end() } +}) + +test('Pause: die Rückfrage verschwindet, wenn inzwischen jemand anderes angehalten hat, und taucht nach dem Fortsetzen nicht von selbst wieder auf', async () => { + const ui = await kopf() + try { + press(pauseButton(ui.tree())) + assert.equal(byRole(ui.tree(), 'dialog').length, 1) + ui.set({ state: makeState({ paused: true }) }) + assert.equal(byRole(ui.tree(), 'dialog').length, 0) + ui.set({ state: makeState({ paused: false }) }) + assert.equal(byRole(ui.tree(), 'dialog').length, 0) + assert.deepEqual(ui.log, []) + } finally { ui.end() } +}) + +test('Pause: ein veralteter Dialog setzt nie fort – der Zielwert steht fest (true), egal was der Zustand inzwischen sagt', async () => { + const ui = await kopf() + try { + press(pauseButton(ui.tree())) + const anhalten = buttonByText(ui.tree(), 'Anhalten') + // der Dienst meldet erst „angehalten“, dann wieder „läuft“, während die Rückfrage noch als Knoten vorliegt + ui.set({ state: makeState({ paused: true }) }) + ui.set({ state: makeState({ paused: false }) }) + anhalten.props.onClick(event()) + await settle() + assert.deepEqual(ui.log.filter((l) => l.startsWith('pause')), ['pause:true']) + } finally { ui.end() } +}) + +test('Pause: Fehler beim Umschalten zeigt die Fehlermeldung des Dienstes bzw. „Umschalten fehlgeschlagen.“, ohne refresh; der nächste Versuch löscht ihn', async () => { + // Antworten der Reihe nach: Error mit Text, Error ohne Text, ein geworfener Text; ist die Liste leer, gelingt der Aufruf + const antworten = [new Error('Dienst nicht erreichbar.'), new Error(''), 'text'] + const ui = await kopf({ api: { pause: async (t) => { ui.log.push(`pause:${t}`); if (antworten.length > 0) throw antworten.shift() } } }) + try { + // 1. Fehler mit Meldung + press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle() + let alarm = byRole(ui.tree(), 'alert') + assert.equal(alarm.length, 1); assert.equal(textOf(alarm[0]), 'Dienst nicht erreichbar.'); assert.ok(hasClass(alarm[0], 'hk-meldung')) + assert.ok(!ui.log.includes('refresh')) + assert.equal(pauseButton(ui.tree()).props.disabled, false) // nach dem Fehler wieder bedienbar + // 2. Fehler ohne Meldung -> Standardtext (der alte verschwindet beim Start des neuen Versuchs) + press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle() + alarm = byRole(ui.tree(), 'alert') + assert.equal(alarm.length, 1); assert.equal(textOf(alarm[0]), 'Umschalten fehlgeschlagen.') + // 3. Fehler, der kein Error ist (Text) + press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle() + assert.equal(textOf(byRole(ui.tree(), 'alert')[0]), 'Umschalten fehlgeschlagen.') + // 4. Erfolg löscht die Meldung + press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle() + assert.equal(byRole(ui.tree(), 'alert').length, 0) + assert.deepEqual(ui.log.filter((l) => l === 'refresh').length, 1) + } finally { ui.end() } +}) + +test('Pause: Fehlertext des Dienstes bleibt ein Textknoten (kein HTML)', async () => { + const ui = await kopf({ api: { pause: async () => { throw new Error('') } } }) + try { + press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle() + const alarm = byRole(ui.tree(), 'alert')[0] + assert.deepEqual(alarm.children, ['']) + } finally { ui.end() } +}) + +test('Pause: der Knopf ist während des Umschaltens gesperrt, ein zweiter Klick tut nichts; danach wieder frei', async () => { + const warten = deferred() + const ui = await kopf({ api: { pause: (t) => { ui.log.push(`pause:${t}`); return warten.promise } } }) + try { + press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')) + assert.equal(pauseButton(ui.tree()).props.disabled, true) + assert.equal(press(pauseButton(ui.tree())), false) + // auch ein Klick, der trotzdem ankäme, startet nichts Zweites + pauseButton(ui.tree()).props.onClick(event()) + assert.equal(byRole(ui.tree(), 'dialog').length, 1) // der Versuch öffnet höchstens die Frage … + press(buttonByText(ui.tree(), 'Anhalten')) // … deren Bestätigung aber nicht zum zweiten Aufruf führt + assert.deepEqual(ui.log, ['pause:true']) + warten.resolve() + await settle() + assert.equal(pauseButton(ui.tree()).props.disabled, false) + assert.deepEqual(ui.log, ['pause:true', 'refresh']) + } finally { ui.end() } +}) + +test('Pause: nach dem Umschalten bekommt der Pause-Knopf den Fokus zurück, wenn er ihn verlor (Fokus auf body); wer woanders ist, wird nicht gestört', async () => { + for (const woanders of [false, true]) { + const warten = deferred() + const ui = await kopf({ api: { pause: (t) => { ui.log.push(`pause:${t}`); return warten.promise } } }) + try { + const pauseEl = pauseButton(ui.tree()).props.ref.current + pauseEl.focus() + press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')) + assert.equal(pauseEl.disabled, true) + // der gesperrte Knopf verliert den Fokus (Browser), die Rückfrage ist zu + const andere = { tag: 'input', isConnected: true, focus() { ui.document().activeElement = this } } + if (woanders) andere.focus(); else ui.document().body.focus() + warten.resolve(); await settle() + assert.equal(pauseEl.disabled, false) + assert.equal(ui.document().activeElement, woanders ? andere : pauseEl) + } finally { ui.end() } + } +}) + +test('Pause: nach einem Fehler bekommt der Pause-Knopf den Fokus ebenfalls zurück', async () => { + const warten = deferred() + const ui = await kopf({ state: makeState({ paused: true }), api: { pause: () => warten.promise } }) + try { + const pauseEl = pauseButton(ui.tree()).props.ref.current + pauseEl.focus() + press(pauseButton(ui.tree())) // Fortsetzen + ui.document().body.focus() + warten.reject(new Error('weg')); await settle() + assert.equal(ui.document().activeElement, pauseEl) + assert.equal(textOf(byRole(ui.tree(), 'alert')[0]), 'weg') + } finally { ui.end() } +}) + +test('Pause: kommt die Antwort erst nach dem Abbau der Kopfzeile, ändert sie keinen Zustand mehr', async () => { + const warten = deferred() + const ui = await kopf({ state: makeState({ paused: true }), api: { pause: () => warten.promise } }) + try { + press(pauseButton(ui.tree())) + ui.unmount() + warten.reject(new Error('zu spät')); await settle() + assert.equal(ui.late(), 0) + } finally { ui.end() } +}) + +// ---------- Rückfrage als Pixel-Dialog ---------- + +test('Rückfrage: Pixel-Dialog (hk-panel) mit role=dialog, aria-modal, beschrifteter Überschrift, Text und den Knöpfen „Abbrechen“ und „Anhalten“', async () => { + const ui = await kopf() + try { + press(pauseButton(ui.tree())) + const dialog = byRole(ui.tree(), 'dialog') + assert.equal(dialog.length, 1) + const d = dialog[0] + assert.ok(hasClass(d, 'hk-panel')); assert.equal(d.props['aria-modal'], 'true'); assert.equal(d.props.tabIndex, -1) + const titel = byClass(d, 'hk-rueckfrage-titel')[0] + assert.equal(textOf(titel), 'Alles anhalten?'); assert.equal(d.props['aria-labelledby'], titel.props.id) + assert.equal(textOf(byClass(d, 'hk-rueckfrage-text')[0]), 'Die Worker hören sofort auf, bis jemand fortsetzt.') + const knoepfe = buttons(d) + assert.deepEqual(knoepfe.map(textOf), ['Abbrechen', 'Anhalten']) + for (const k of knoepfe) { assert.equal(k.props.type, 'button'); assert.ok(hasClass(k, 'hk-knopf')) } + // der Hintergrund liegt um den Dialog + assert.equal(byClass(ui.tree(), 'hk-rueckfrage-hintergrund').length, 1) + assert.ok(nodes(byClass(ui.tree(), 'hk-rueckfrage-hintergrund')[0], (n) => n === d).length === 1) + } finally { ui.end() } +}) + +test('Rückfrage: der Fokus springt beim Öffnen auf „Abbrechen“ (die harmlose Wahl)', async () => { + const ui = await kopf() + try { + press(pauseButton(ui.tree())) + const abbrechen = buttonByText(ui.tree(), 'Abbrechen').props.ref.current + assert.ok(abbrechen) + assert.equal(ui.document().activeElement, abbrechen) + } finally { ui.end() } +}) + +test('Rückfrage: Esc schließt (und verhindert den Standard, damit nichts darunter mitschließt); ein schon behandeltes Esc tut nichts; andere Tasten auch nicht', async () => { + const ui = await kopf() + try { + press(pauseButton(ui.tree())) + const hintergrund = () => byClass(ui.tree(), 'hk-rueckfrage-hintergrund')[0] + hintergrund().props.onKeyDown(event({ key: 'Enter' })) + hintergrund().props.onKeyDown(event({ key: 'a' })) + assert.equal(byRole(ui.tree(), 'dialog').length, 1) + hintergrund().props.onKeyDown(event({ key: 'Escape', defaultPrevented: true })) + assert.equal(byRole(ui.tree(), 'dialog').length, 1) + const e = event({ key: 'Escape' }) + hintergrund().props.onKeyDown(e) + assert.equal(e.defaultPrevented, true) + assert.equal(byRole(ui.tree(), 'dialog').length, 0) + assert.deepEqual(ui.log, []) + } finally { ui.end() } +}) + +test('Rückfrage: Tab bleibt im Dialog – vom letzten Knopf nach vorn zum ersten, mit Umschalt vom ersten zum letzten, von draußen hinein', async () => { + const ui = await kopf() + try { + press(pauseButton(ui.tree())) + const abbrechen = buttonByText(ui.tree(), 'Abbrechen').props.ref.current + const anhalten = buttonByText(ui.tree(), 'Anhalten').props.ref.current + const hintergrund = () => byClass(ui.tree(), 'hk-rueckfrage-hintergrund')[0] + const tab = (shift) => { const e = event({ key: 'Tab', shiftKey: shift }); hintergrund().props.onKeyDown(e); return e } + // Abbrechen -> Tab: normaler Weg zum nächsten Knopf (nicht abgefangen) + abbrechen.focus() + assert.equal(tab(false).defaultPrevented, false) + // Anhalten ist der letzte: Tab springt zurück zu Abbrechen + anhalten.focus() + assert.equal(tab(false).defaultPrevented, true); assert.equal(ui.document().activeElement, abbrechen) + // Abbrechen ist der erste: Umschalt+Tab springt zu Anhalten + assert.equal(tab(true).defaultPrevented, true); assert.equal(ui.document().activeElement, anhalten) + // Umschalt+Tab von Anhalten: normaler Weg + assert.equal(tab(true).defaultPrevented, false) + // Fokus außerhalb (body): Tab holt ihn in den Dialog, Umschalt+Tab auch + ui.document().body.focus() + assert.equal(tab(false).defaultPrevented, true); assert.equal(ui.document().activeElement, abbrechen) + ui.document().body.focus() + assert.equal(tab(true).defaultPrevented, true); assert.equal(ui.document().activeElement, anhalten) + // Fokus auf dem Dialog selbst (tabIndex -1): Umschalt+Tab geht zum letzten Knopf + byRole(ui.tree(), 'dialog')[0].props.ref.current.focus() + assert.equal(tab(true).defaultPrevented, true); assert.equal(ui.document().activeElement, anhalten) + } finally { ui.end() } +}) + +test('Rückfrage: ein Klick auf den Hintergrund nimmt dem Dialog den Fokus nicht und schließt nichts; ein Klick im Dialog wird nicht abgefangen', async () => { + const ui = await kopf() + try { + press(pauseButton(ui.tree())) + const hintergrund = byClass(ui.tree(), 'hk-rueckfrage-hintergrund')[0] + const aufHintergrund = event({ target: 'h', currentTarget: 'h' }) + hintergrund.props.onMouseDown(aufHintergrund) + assert.equal(aufHintergrund.defaultPrevented, true) + const imDialog = event({ target: 'd', currentTarget: 'h' }) + hintergrund.props.onMouseDown(imDialog) + assert.equal(imDialog.defaultPrevented, false) + assert.equal(byRole(ui.tree(), 'dialog').length, 1) + } finally { ui.end() } +}) + +// ---------- Einstellungen ---------- + +test('Zahnrad: nur für Admins; mit aria-label „Einstellungen“, Zahnrad-Icon und type=button', async () => { + const kein = await kopf({ isAdmin: false }) + try { + assert.equal(settingsButton(kein.tree()), undefined) + assert.equal(byClass(kein.tree(), 'hk-zahnrad').length, 0) + } finally { kein.end() } + const admin = await kopf({ isAdmin: true }) + try { + const b = settingsButton(admin.tree()) + assert.equal(b.props.type, 'button'); assert.equal(b.props.title, 'Einstellungen'); assert.ok(hasClass(b, 'hk-hud-knopf')) + assert.deepEqual(classes(b.children[0]).slice(0, 2), ['hk-ic', 'hk-zahnrad']) + } finally { admin.end() } +}) + +test('Einstellungen: das Zahnrad öffnet den bestehenden Dialog in einer hk-woms-Hülle mit api, onClose und onSaved (-> refresh)', async () => { + const api = { pause: async () => {}, name: 'api-attrappe' } + const ui = await kopf({ isAdmin: true, api }) + try { + assert.equal(ui.kb.settings.length, 0) + press(settingsButton(ui.tree())) + const huelle = byClass(ui.tree(), 'hk-woms') + assert.equal(huelle.length, 1) + assert.equal(nodes(huelle[0], (n) => n.props && n.props['data-attrappe'] === 'einstellungen').length, 1) + const props = ui.kb.settings.at(-1) + assert.equal(props.api, api) + // Speichern holt den Zustand neu + assert.equal(ui.log.filter((l) => l === 'refresh').length, 0) + props.onSaved() + assert.equal(ui.log.filter((l) => l === 'refresh').length, 1) + // Schließen + props.onClose() + assert.equal(byClass(ui.tree(), 'hk-woms').length, 0) + // und wieder öffnen + press(settingsButton(ui.tree())) + assert.equal(byClass(ui.tree(), 'hk-woms').length, 1) + } finally { ui.end() } +}) + +test('Einstellungen: verliert der Mensch die Admin-Rechte, verschwindet der offene Dialog', async () => { + const ui = await kopf({ isAdmin: true }) + try { + press(settingsButton(ui.tree())) + assert.equal(byClass(ui.tree(), 'hk-woms').length, 1) + ui.set({ isAdmin: false }) + assert.equal(byClass(ui.tree(), 'hk-woms').length, 0) + assert.equal(settingsButton(ui.tree()), undefined) + } finally { ui.end() } +}) + +// ---------- Meldungen ---------- + +test('Meldung „Angehalten“: nur wenn der Dienst angehalten ist, role=status, mit dem Satz aus dem Board', async () => { + const ui = await kopf() + try { + assert.equal(byClass(ui.tree(), 'hk-meldung').length, 0) + ui.set({ state: makeState({ paused: true }) }) + const m = byClass(ui.tree(), 'hk-meldung') + assert.equal(m.length, 1); assert.equal(m[0].props.role, 'status'); assert.ok(hasClass(m[0], 'hk-meldung-angehalten')) + assert.equal(textOf(m[0]), 'Angehalten: Es wird nichts gesendet und nichts gebaut.') + } finally { ui.end() } +}) + +test('Meldung Notbremse: für alle sichtbar (auch ohne Admin), role=alert, Satz mit Grund, Grund als Textknoten', async () => { + const ui = await kopf({ isAdmin: false, state: makeState({ sendingHalted: true, sendingHaltedReason: 'Tageslimit erreicht' }) }) + try { + const m = byRole(ui.tree(), 'alert') + assert.equal(m.length, 1); assert.ok(hasClass(m[0], 'hk-meldung-notbremse')) + assert.equal(textOf(m[0]), 'Versand gestoppt: Tageslimit erreicht. Ein Admin kann ihn in den Einstellungen freigeben.') + // der Grund steht als eigener Textknoten, kein Element darin + assert.ok(m[0].children.every((c) => typeof c === 'string')) + assert.ok(m[0].children.includes('Tageslimit erreicht')) + } finally { ui.end() } +}) + +test('Meldung Notbremse: Grund getrimmt, ohne abschließende Punkte (kein „..“); ohne Grund „Grund nicht angegeben“', async () => { + const faelle = [ + ['Limit erreicht.', 'Limit erreicht'], + ['Limit erreicht...', 'Limit erreicht'], + [' Limit erreicht. . ', 'Limit erreicht'], + ['Zu viele Fehler (3).', 'Zu viele Fehler (3)'], + ['', 'Grund nicht angegeben'], + [' ', 'Grund nicht angegeben'], + ['...', 'Grund nicht angegeben'], + [undefined, 'Grund nicht angegeben'], + [null, 'Grund nicht angegeben'], + [42, 'Grund nicht angegeben'], + [{ text: 'x' }, 'Grund nicht angegeben'], + ] + for (const [grund, anzeige] of faelle) { + const state = makeState({ sendingHalted: true }) + state.sendingHaltedReason = grund + const ui = await kopf({ state }) + try { + const satz = textOf(byRole(ui.tree(), 'alert')[0]) + assert.equal(satz, `Versand gestoppt: ${anzeige}. Ein Admin kann ihn in den Einstellungen freigeben.`, JSON.stringify(grund)) + assert.ok(!satz.includes('..'), JSON.stringify(grund)) + } finally { ui.end() } + } + // fehlendes Feld ganz + const state = makeState({ sendingHalted: true }) + delete state.sendingHaltedReason + const ui = await kopf({ state }) + try { assert.match(textOf(byRole(ui.tree(), 'alert')[0]), /^Versand gestoppt: Grund nicht angegeben\. Ein Admin/) } finally { ui.end() } +}) + +test('Meldung Notbremse: ein HTML-Grund bleibt Text; nur sendingHalted === true löst sie aus', async () => { + const ui = await kopf({ state: makeState({ sendingHalted: true, sendingHaltedReason: 'fett' }) }) + try { + assert.ok(byRole(ui.tree(), 'alert')[0].children.includes('fett')) + assert.equal(nodes(byRole(ui.tree(), 'alert')[0], (n) => typeof n.tag === 'string' && n.tag !== 'div').length, 0) + } finally { ui.end() } + for (const wert of [false, 'true', 1, null, undefined]) { + const state = makeState({ sendingHaltedReason: 'egal' }) + state.sendingHalted = wert + const u = await kopf({ state }) + try { assert.equal(byRole(u.tree(), 'alert').length, 0, String(wert)) } finally { u.end() } + } +}) + +test('Meldung „Verbindung gestört“: role=status mit dem Fehlertext als Textknoten, nur solange ein Fehler anliegt', async () => { + const ui = await kopf({ error: null }) + try { + assert.equal(byClass(ui.tree(), 'hk-meldung').length, 0) + ui.set({ error: 'Zeitüberschreitung.' }) + const m = byClass(ui.tree(), 'hk-meldung-verbindung') + assert.equal(m.length, 1); assert.equal(m[0].props.role, 'status') + assert.equal(textOf(m[0]), 'Verbindung gestört: Zeitüberschreitung. Es wird weiter versucht.') + ui.set({ error: 'x' }) + assert.ok(byClass(ui.tree(), 'hk-meldung-verbindung')[0].children.includes('x')) + ui.set({ error: '' }) + assert.equal(byClass(ui.tree(), 'hk-meldung').length, 0) + } finally { ui.end() } +}) + +test('Meldungen: mehrere stehen untereinander im selben Behälter unter der Kopfzeile, in fester Reihenfolge', async () => { + const ui = await kopf({ state: makeState({ paused: true, sendingHalted: true, sendingHaltedReason: 'Limit' }), error: 'weg' }) + try { + const behaelter = byClass(ui.tree(), 'hk-meldungen') + assert.equal(behaelter.length, 1) + const meldungen = behaelter[0].children.filter((c) => typeof c !== 'string') + assert.equal(meldungen.length, behaelter[0].children.length) // keine Leertexte dazwischen + assert.deepEqual(meldungen.map((m) => classes(m)[1]), ['hk-meldung-angehalten', 'hk-meldung-notbremse', 'hk-meldung-verbindung']) + assert.deepEqual(meldungen.map((m) => m.props.role), ['status', 'alert', 'status']) + // Behälter steht nach der Kopfzeile (darunter), beide in der Hülle hk-kopf + const huelle = byClass(ui.tree(), 'hk-kopf')[0] + assert.deepEqual(huelle.children.map((c) => classes(c)[0]), ['hk-kopf-zeile', 'hk-meldungen']) + } finally { ui.end() } +}) + +// ---------- Rechts: Ganzes Haus ---------- + +test('„Ganzes Haus zeigen“: Knopf nur für die Tastatur (hk-nur-tastatur), ruft onGanz; ohne onGanz kein Absturz', async () => { + const ui = await kopf() + try { + const b = hausButton(ui.tree()) + assert.equal(b.props.type, 'button'); assert.ok(hasClass(b, 'hk-nur-tastatur')) + press(b) + assert.deepEqual(ui.log, ['ganz']) + press(b) + assert.deepEqual(ui.log, ['ganz', 'ganz']) + ui.set({ onGanz: undefined }) + assert.doesNotThrow(() => press(hausButton(ui.tree()))) + } finally { ui.end() } +}) + +test('Aufbau: Geld, Kennzahlen, Abstandhalter, Panel rechts in dieser Reihenfolge; Pause steht nach „Ganzes Haus zeigen“, das Zahnrad zuletzt', async () => { + const ui = await kopf({ isAdmin: true }) + try { + const zeile = byClass(ui.tree(), 'hk-kopf-zeile')[0] + assert.deepEqual(zeile.children.map((c) => classes(c)[1] ?? classes(c)[0]), ['hk-kopf-geld', 'hk-kopf-zahlen', 'hk-kopf-abstand', 'hk-kopf-rechts']) + const rechts = byClass(zeile, 'hk-kopf-rechts')[0] + assert.ok(hasClass(rechts, 'hk-panel')) + assert.deepEqual(buttons(rechts).map((b) => b.props['aria-label'] ?? textOf(b)), ['Ganzes Haus zeigen', 'Alles anhalten', 'Einstellungen']) + } finally { ui.end() } +}) + +// ---------- Allgemeine Zusicherungen ---------- + +test('Alle Knöpfe sind type="button" – in jedem Zustand (Rückfrage offen, Einstellungen offen, Admin)', async () => { + const ui = await kopf({ isAdmin: true, state: makeState({ kpis: VOLLE_KPIS, sendingHalted: true, sendingHaltedReason: 'x' }), error: 'weg' }) + try { + const pruefe = () => { for (const b of buttons(ui.tree())) assert.equal(b.props.type, 'button', textOf(b) || b.props['aria-label']) } + pruefe() + press(pauseButton(ui.tree())); pruefe() + assert.equal(buttons(ui.tree()).length, 5) // Ganzes Haus, Pause, Zahnrad, Abbrechen, Anhalten + press(buttonByText(ui.tree(), 'Abbrechen')); pruefe() + press(settingsButton(ui.tree())); pruefe() + ui.set({ state: makeState({ paused: true }) }); pruefe() + } finally { ui.end() } +}) + +test('Quelltext: kein innerHTML, kein CSS-/Bildimport, kein window.confirm, keine Links aus Dienst-Daten, Einstellungen unverändert eingebunden', () => { + const s = readFileSync(path.join(ROOT, 'src/components/workers/hochhaus/HochhausKopf.jsx'), 'utf8') + assert.doesNotMatch(s, /dangerouslySetInnerHTML|innerHTML/) + assert.doesNotMatch(s, /^\s*import\s+(?:[^'"\n]*\sfrom\s+)?['"][^'"]+\.(css|png|svg|jpe?g|webp|gif)['"]/m) + assert.doesNotMatch(s, /window\.confirm|\bconfirm\(/) + assert.doesNotMatch(s, /href=|]|]/) + assert.doesNotMatch(s, /useAuth|appwrite/) + assert.match(s, /
\s* refresh\(\)\} \/>\s*<\/div>/) + assert.match(s, /api\.pause\(target\)/); assert.doesNotMatch(s, /api\.pause\(!paused\)/) + // jedes