import test from 'node:test' import assert from 'node:assert/strict' import path from 'node:path' import { fileURLToPath } from 'node:url' import esbuild from 'esbuild' import { fakeReact } from './helpers/fakeReact.mjs' import { ev, makeState } from './helpers/workersFixtures.mjs' import { loadModule } from './load.mjs' // Das Gerüst der Hustler-Pixelwelt (T3) läuft wirklich: im Mini-Läufer anstelle von React (Muster botleadList.test.mjs, Baustein helpers/fakeReact.mjs). // 1. Hochhaus.jsx: Laden, Dienst nicht erreichbar („Neu laden“), geladen -> Welt, Kopfzeile, Detailansicht auf Klick. // 2. WorkersPage.jsx: mit esbuild-Attrappen für Router, Anmeldung, Dienst-Client, Feed-Hook, CSS und die drei Bildschirme. Geprüft wird, // welcher Bildschirm zu ?tab gehört, welche Props er bekommt, dass der Feed einmal pro Seite läuft (kein Neustart beim Bildschirmwechsel), // dass Ereignisse die Hustler-Tabelle fortschreiben und an die Regie gehen, und dass ein Schnappschuss die Tabelle neu setzt. 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 { HUSTLER_STUFE } = await loadModule('src/lib/hustlerStufe.js') 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 byClass = (tree, name) => nodes(tree, (n) => typeof n.tag === 'string' && hasClass(n, name)) // ---------- Hochhaus ---------- // Die vier Teile des Hochhauses (Welt T5, Kopfzeile T6, Detailansicht T7, Regie T8) haben eigene Tests; hier sind sie Attrappen, // geprüft wird das Zusammenstecken (Props, Auswahl, Sperre der Kamera, Regie-Handle, „Ganzes Haus zeigen“). const TEILE = { welt: `import { jsx, forwardRef, useImperativeHandle } from 'react' export default forwardRef(function HochhausWelt(props, ref) { useImperativeHandle(ref, () => ({ ganz: () => { globalThis.__hh.ganz += 1 } })) globalThis.__hh.welt = props return jsx('div', { 'data-teil': 'welt', children: props.children }) })`, kopf: "import { jsx } from 'react'\nexport default function HochhausKopf(props) { globalThis.__hh.kopf = props; return jsx('div', { 'data-teil': 'kopf' }) }", dialog: "import { jsx } from 'react'\nexport default function HustlerDialog(props) { globalThis.__hh.dialog = props; return jsx('div', { 'data-teil': 'dialog' }) }", regie: `import { jsx, forwardRef, useImperativeHandle } from 'react' export default forwardRef(function RegieEbene(props, ref) { useImperativeHandle(ref, () => ({ push: (events) => { globalThis.__hh.pushed.push(events) }, flush() {} })) return jsx('div', { 'data-teil': 'regie' }) })`, } const teilePlugin = { name: 'hochhaus-teile', setup(build) { for (const [filter, key] of [[/^\.\/HochhausWelt$/, 'welt'], [/^\.\/HochhausKopf$/, 'kopf'], [/^\.\/HustlerDialog$/, 'dialog'], [/^\.\/RegieEbene$/, 'regie']]) { build.onResolve({ filter }, () => ({ path: key, namespace: 'hochhaus-teil' })) } build.onLoad({ filter: /.*/, namespace: 'hochhaus-teil' }, (args) => ({ contents: TEILE[args.path], loader: 'js' })) }, } let hochhausBundle const loadHochhaus = () => (hochhausBundle ??= esbuild.build({ stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as Hochhaus } from './src/components/workers/hochhaus/Hochhaus.jsx'", resolveDir: ROOT, loader: 'js' }, bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact, teilePlugin], }).then(importBundle)) async function hochhaus(feed, extra = {}) { const { __mount, jsx, Hochhaus } = await loadHochhaus() globalThis.__hh = { ganz: 0, pushed: [], welt: null, kopf: null, dialog: null } const props = { api: { name: 'api' }, isAdmin: false, workers: {}, regieRef: { current: null }, onOpen() {}, ...extra, feed } const mounted = __mount(jsx(() => jsx(Hochhaus, props), {})) await settle() // setFeed: der Feed ändert sich zwischendurch (wie der Hook der Seite), das Hochhaus bleibt am selben Einhängepunkt return { props, hh: globalThis.__hh, tree: () => mounted.tree(), setFeed(next) { props.feed = { ...props.feed, ...next }; mounted.rerender() }, end: () => mounted.unmount() } } const counting = (n = { refresh: 0 }) => ({ n, refresh: () => { n.refresh += 1 } }) const teil = (tree, name) => nodes(tree, (n) => n.props && n.props['data-teil'] === name) test('Hochhaus: solange nichts geladen ist, steht „Wird geladen …“ mit role=status in der Wurzel hk-hochhaus', async () => { const c = counting() const view = await hochhaus({ state: null, error: null, loaded: false, refresh: c.refresh }) try { const root = view.tree() assert.equal(root.tag, 'div'); assert.equal(root.props.className, 'hk-hochhaus') const laden = byClass(root, 'hk-laden') assert.equal(laden.length, 1); assert.equal(laden[0].props.role, 'status'); assert.equal(textOf(laden[0]), 'Wird geladen …') assert.equal(teil(root, 'welt').length, 0); assert.equal(byClass(root, 'hk-leer').length, 0) assert.equal(c.n.refresh, 0) } finally { view.end() } }) test('Hochhaus: die Stufe (Stufen-Sperre) geht an die Welt und an die Detailansicht; ohne Angabe gilt HUSTLER_STUFE', async () => { const feed = { state: makeState(), error: null, loaded: true, refresh() {} } for (const stufe of [3, 6]) { const view = await hochhaus(feed, { stufe }) try { assert.equal(view.hh.welt.stufe, stufe) view.hh.welt.onZeige('arne') await settle() assert.equal(view.hh.dialog.stufe, stufe); assert.equal(view.hh.dialog.workerId, 'arne') } finally { view.end() } } const ohne = await hochhaus(feed) try { assert.equal(ohne.hh.welt.stufe, HUSTLER_STUFE) ohne.hh.welt.onZeige('arne') await settle() assert.equal(ohne.hh.dialog.stufe, HUSTLER_STUFE) } finally { ohne.end() } }) test('Hochhaus: Dienst nie erreicht -> Pixel-Leerzustand mit Titel, Fehlertext als Text und „Neu laden“, das refresh ruft', async () => { const c = counting() const view = await hochhaus({ state: null, error: 'Der Dienst antwortet nicht.', loaded: false, refresh: c.refresh }) try { const root = view.tree() assert.equal(root.props.className, 'hk-hochhaus') const leer = byClass(root, 'hk-leer') assert.equal(leer.length, 1) assert.equal(textOf(byClass(leer[0], 'hk-leer-titel')[0]), 'Der Hustler-Dienst ist noch nicht erreichbar') // der Fehlertext ist ein Textknoten (kein HTML) const text = byClass(leer[0], 'hk-leer-text')[0] assert.deepEqual(text.children, ['Der Dienst antwortet nicht.']) const knoepfe = nodes(root, (n) => n.tag === 'button') assert.equal(knoepfe.length, 1) assert.equal(textOf(knoepfe[0]), 'Neu laden'); assert.equal(knoepfe[0].props.type, 'button'); assert.ok(hasClass(knoepfe[0], 'hk-knopf')) assert.equal(byClass(root, 'hk-laden').length, 0); assert.equal(teil(root, 'welt').length, 0) knoepfe[0].props.onClick({ preventDefault() {} }) assert.equal(c.n.refresh, 1) knoepfe[0].props.onClick({ preventDefault() {} }) assert.equal(c.n.refresh, 2) } finally { view.end() } }) test('Hochhaus: geladen -> Welt mit Regie-Ebene darin, Kopfzeile, noch keine Detailansicht; ein späterer Verbindungsfehler ersetzt die Welt nicht', async () => { const c = counting() const state = makeState() const workers = { lea: { status: 'arbeitet', task: 'x', lastTs: null, connected: true, counters: {} } } const onOpen = () => {} const view = await hochhaus({ state, error: null, loaded: true, refresh: c.refresh }, { isAdmin: true, workers, onOpen }) try { const root = view.tree() assert.equal(root.props.className, 'hk-hochhaus') const welt = teil(root, 'welt') assert.equal(welt.length, 1); assert.equal(teil(welt[0], 'regie').length, 1) // Regie liegt in der Bühne der Welt assert.equal(teil(root, 'kopf').length, 1); assert.equal(teil(root, 'dialog').length, 0) assert.equal(byClass(root, 'hk-laden').length, 0); assert.equal(byClass(root, 'hk-leer').length, 0) const w = view.hh.welt assert.equal(w.state, state); assert.equal(w.workers, workers); assert.equal(w.onOpen, onOpen); assert.equal(w.gesperrt, false) const k = view.hh.kopf assert.equal(k.api, view.props.api); assert.equal(k.isAdmin, true); assert.equal(k.state, state); assert.equal(k.error, null); assert.equal(k.refresh, c.refresh) } finally { view.end() } const gestoert = await hochhaus({ state, error: 'Verbindung weg', loaded: true, refresh: c.refresh }) try { assert.equal(teil(gestoert.tree(), 'welt').length, 1) assert.equal(byClass(gestoert.tree(), 'hk-leer').length, 0) assert.equal(gestoert.hh.kopf.error, 'Verbindung weg') } finally { gestoert.end() } }) test('Hochhaus: das Regie-Handle landet in regieRef der Seite, Ereignisse kommen bei der Ebene an; nach dem Abbau ist es weg', async () => { const regieRef = { current: null } const view = await hochhaus({ state: makeState(), error: null, loaded: true, refresh() {} }, { regieRef }) assert.equal(typeof regieRef.current?.push, 'function') const events = [ev('fund', 'lea')] regieRef.current.push(events) assert.deepEqual(view.hh.pushed, [events]) view.end() await settle() assert.equal(regieRef.current, null) }) test('Hochhaus: Figur-Klick öffnet die Detailansicht und sperrt die Kamera, Blättern wechselt, Schließen gibt frei; „Ganzes Haus zeigen“ ruft die Welt', async () => { const workers = { lea: { status: 'arbeitet', task: 'x', lastTs: null, connected: true, counters: {} } } const view = await hochhaus({ state: makeState(), error: null, loaded: true, refresh() {} }, { workers }) try { view.hh.welt.onZeige('lea') await settle() assert.equal(teil(view.tree(), 'dialog').length, 1) assert.equal(view.hh.dialog.workerId, 'lea'); assert.equal(view.hh.dialog.workers, workers); assert.equal(view.hh.dialog.api, view.props.api) assert.equal(view.hh.welt.gesperrt, true) view.hh.dialog.onWechsel('bea') await settle() assert.equal(view.hh.dialog.workerId, 'bea') view.hh.dialog.onClose() await settle() assert.equal(teil(view.tree(), 'dialog').length, 0); assert.equal(view.hh.welt.gesperrt, false) view.hh.kopf.onGanz() assert.equal(view.hh.ganz, 1) } finally { view.end() } }) test('Hochhaus: ohne Zustand gilt auch ein geladen-Flag nicht als bereit (wie bisher im Board)', async () => { const c = counting() const view = await hochhaus({ state: null, error: 'weg', loaded: true, refresh: c.refresh }) try { assert.equal(teil(view.tree(), 'welt').length, 0) assert.equal(byClass(view.tree(), 'hk-leer').length, 1) } finally { view.end() } }) // Frühere Prüfung des Boards (Hooks vor der frühen Rückgabe): Laden, Leerzustand und Welt wechseln am selben Einhängepunkt, die Auswahl überlebt Verbindungsfehler. test('Hochhaus: Laden -> Dienst nicht erreicht -> bereit -> Verbindungsfehler -> wieder gut, ohne Neueinhängen; die offene Detailansicht überlebt den Fehler', async () => { const c = counting() const view = await hochhaus({ state: null, error: null, loaded: false, refresh: c.refresh }) try { assert.equal(byClass(view.tree(), 'hk-laden').length, 1) view.setFeed({ error: 'weg' }) assert.equal(byClass(view.tree(), 'hk-leer').length, 1); assert.equal(byClass(view.tree(), 'hk-laden').length, 0) view.setFeed({ state: makeState(), error: null, loaded: true }) assert.equal(teil(view.tree(), 'welt').length, 1); assert.equal(byClass(view.tree(), 'hk-leer').length, 0) view.hh.welt.onZeige('lea') await settle() assert.equal(teil(view.tree(), 'dialog').length, 1) view.setFeed({ error: 'Verbindung weg' }) assert.equal(teil(view.tree(), 'welt').length, 1); assert.equal(teil(view.tree(), 'dialog').length, 1) assert.equal(view.hh.dialog.workerId, 'lea'); assert.equal(view.hh.welt.gesperrt, true) view.setFeed({ error: null }) assert.equal(teil(view.tree(), 'welt').length, 1); assert.equal(view.hh.dialog.workerId, 'lea') assert.equal(c.n.refresh, 0) } finally { view.end() } }) // ---------- WorkersPage ---------- // Attrappen: alle Zustände und Aufrufe laufen über globalThis.__hk, das der Test je Lauf anlegt const STUBS = { 'react-router-dom': "export function useSearchParams() { return [globalThis.__hk.params, globalThis.__hk.setParams] }", auth: "export function useAuth() { return globalThis.__hk.auth }", // die api-Attrappe entsteht beim Laden des Moduls (WorkersPage liest sie auf oberster Ebene) und wird für den Test unter __hkApi abgelegt client: "const api = { name: 'workers-api-attrappe' }\nglobalThis.__hkApi = api\nexport const workersApi = api", feed: ` import { useEffect } from 'react' export function useWorkersFeed(api, options) { const hk = globalThis.__hk hk.feedCalls.push({ api, options }) hk.onEvents = options.onEvents useEffect(() => { hk.feedMounts += 1; return () => { hk.feedUnmounts += 1 } }, []) return hk.feed } `, // renders: jede Darstellung eines Bildschirms (nicht nur die letzte), damit der Test auch Zwischenstände sieht (R5) hochhaus: "import { jsx } from 'react'\nexport default function Hochhaus(props) { globalThis.__hk.props.hochhaus = props; globalThis.__hk.renders?.hochhaus.push(props); return jsx('div', { 'data-screen': 'hochhaus' }) }", pult: "import { jsx } from 'react'\nexport default function PultList(props) { globalThis.__hk.props.pult = props; globalThis.__hk.renders?.pult.push(props); return jsx('div', { 'data-screen': 'pult' }) }", keller: "import { jsx } from 'react'\nexport default function BotLeadList(props) { globalThis.__hk.props.keller = props; globalThis.__hk.renders?.keller.push(props); return jsx('div', { 'data-screen': 'keller' }) }", } const stubPlugin = { name: 'seite-attrappen', setup(build) { const table = [ [/^react-router-dom$/, 'react-router-dom'], [/^\.\.\/context\/AuthContext$/, 'auth'], [/^\.\.\/lib\/workersClient$/, 'client'], [/^\.\.\/hooks\/useWorkersFeed$/, 'feed'], [/^\.\.\/components\/workers\/hochhaus\/Hochhaus$/, 'hochhaus'], [/^\.\.\/components\/workers\/PultList$/, 'pult'], [/^\.\.\/components\/workers\/BotLeadList$/, 'keller'], ] for (const [filter, key] of table) { build.onResolve({ filter }, () => ({ path: key, namespace: 'seite-attrappe' })) } build.onLoad({ filter: /.*/, namespace: 'seite-attrappe' }, (args) => ({ contents: STUBS[args.path], loader: 'js' })) }, } let seiteBundle const loadSeite = () => (seiteBundle ??= esbuild.build({ stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as WorkersPage } from './src/pages/WorkersPage.jsx'", resolveDir: ROOT, loader: 'js' }, bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx', '.css': 'empty' }, plugins: [fakeReact, stubPlugin], }).then(importBundle)) // Ein Lauf der Seite. query ist der Teil nach dem ?, feed wird vom Test zwischendurch geändert (setFeed) und die Seite neu gerendert. // stufe (Stufen-Sperre, hustlerStufe.js): die Zusicherungen dieser Datei gelten für die volle Seite (Stufe 6, Keller offen, Verhalten wie vor der Sperre); // die Tests „Stufe 3“ weiter unten bauen die Seite mit stufe: 3 (Stand der Produktion). async function seite(query, { admin = false, feed, stufe = 6 } = {}) { const { __mount, jsx, WorkersPage } = await loadSeite() const setParamsCalls = [] const hk = { params: new URLSearchParams(query), setParams: (next) => { setParamsCalls.push(next) }, auth: { isAdmin: admin, user: { $id: 'u-1', name: 'Kenso' } }, api: globalThis.__hkApi, feed: feed ?? { state: null, error: null, loaded: false, refresh: () => {} }, feedCalls: [], feedMounts: 0, feedUnmounts: 0, props: {}, onEvents: null, renders: { hochhaus: [], pult: [], keller: [] }, } const savedHk = globalThis.__hk const hadDocument = Object.hasOwn(globalThis, 'document') const savedDocument = globalThis.document globalThis.__hk = hk globalThis.document = { title: 'Vorher' } const mounted = __mount(jsx(WorkersPage, { stufe })) await settle() const view = { hk, setParamsCalls, tree: () => mounted.tree(), root: () => mounted.tree(), screen: () => nodes(mounted.tree(), (n) => n.props && n.props['data-screen']).map((n) => n.props['data-screen']), // Adresse wechseln, wie es der Router nach setParams täte, und neu rendern go(next) { hk.params = new URLSearchParams(next); mounted.rerender() }, setFeed(next) { hk.feed = { ...hk.feed, ...next }; mounted.rerender() }, end() { mounted.unmount() if (savedHk === undefined) delete globalThis.__hk; else globalThis.__hk = savedHk if (hadDocument) globalThis.document = savedDocument; else delete globalThis.document }, } return view } test('Seite: ohne ?tab, mit ?tab=hochhaus und mit unbekanntem Tab erscheint das Hochhaus; Wurzel hk-seite trägt data-tab', async () => { for (const query of ['', 'tab=hochhaus', 'tab=gibtsnicht', 'tab=', 'bot=x']) { const ui = await seite(query) try { const root = ui.root() assert.equal(root.tag, 'div'); assert.equal(root.props.className, 'hk-seite'); assert.equal(root.props['data-tab'], 'hochhaus', query) assert.deepEqual(ui.screen(), ['hochhaus'], query) } finally { ui.end() } } }) test('Seite: ?tab=freigabe zeigt das Pult, ?tab=botleads den Keller, beide direkt in .hk-seite (keine alte helle Hülle mehr); data-tab folgt', async () => { const pult = await seite('tab=freigabe') try { assert.equal(pult.root().props['data-tab'], 'freigabe'); assert.deepEqual(pult.screen(), ['pult']) assert.equal(byClass(pult.root(), 'page').length, 0); assert.equal(byClass(pult.root(), 'wk-page').length, 0) } finally { pult.end() } const keller = await seite('tab=botleads') try { assert.equal(keller.root().props['data-tab'], 'botleads'); assert.deepEqual(keller.screen(), ['keller']) assert.equal(byClass(keller.root(), 'page').length, 0); assert.equal(byClass(keller.root(), 'wk-page').length, 0) } finally { keller.end() } }) // ---------- Stufen-Sperre (Stufe 3 = Stand der Produktion: Dienst P3, ohne Keller) ---------- // Adressen, die auf Stufe 3 in den Keller führen würden: der Reiter, mit Einzelfall, mit Ballast, andere Schreibweisen und erfundene Parameter const KELLER_ADRESSEN = ['tab=botleads', 'tab=botleads&bot=abc123', 'bot=abc123&tab=botleads', 'tab=botleads&bot=', 'tab=BotLeads', 'ansicht=keller', 'tab=keller', 'tab=botleads&ansicht=keller'] test('Stufe 3: jede Adresse in den Keller fällt auf das Hochhaus zurück; der Keller wird nie eingehängt, Hochhaus und Seite bleiben ganz', async () => { for (const stufe of [1, 3, 5]) { for (const query of KELLER_ADRESSEN) { const ui = await seite(query, { stufe, feed: { state: makeState(), error: null, loaded: true, refresh() {} } }) try { assert.equal(ui.root().props.className, 'hk-seite', `${stufe} ${query}`) assert.equal(ui.root().props['data-tab'], 'hochhaus', `${stufe} ${query}`) assert.deepEqual(ui.screen(), ['hochhaus'], `${stufe} ${query}`) assert.equal(ui.hk.renders.keller.length, 0, `${stufe} ${query}: BotLeadList wurde nie gezeichnet`) assert.equal(ui.hk.props.keller, undefined, `${stufe} ${query}`) assert.equal(ui.hk.props.hochhaus.stufe, stufe) } finally { ui.end() } } } }) test('Stufe 3: der Wechsel der Adresse zu ?tab=botleads zeigt das Hochhaus, Freigabe bleibt erreichbar; der Rückweg aus dem Pult funktioniert wie sonst', async () => { const ui = await seite('tab=freigabe', { stufe: 3, feed: { state: makeState(), error: null, loaded: true, refresh() {} } }) try { assert.deepEqual(ui.screen(), ['pult']) ui.go('tab=botleads&bot=x'); assert.deepEqual(ui.screen(), ['hochhaus']); assert.equal(ui.hk.props.hochhaus.vonTab, 'freigabe') ui.go('tab=freigabe'); assert.deepEqual(ui.screen(), ['pult']) ui.go('tab=hochhaus'); assert.deepEqual(ui.screen(), ['hochhaus']) assert.equal(ui.hk.renders.keller.length, 0); assert.equal(ui.hk.props.keller, undefined) } finally { ui.end() } }) test('Stufe 3: das Hochhaus öffnet nur noch die Freigabe (onOpen(\'botleads\') tut nichts); Hochhaus und Pult bekommen die Stufe', async () => { const ui = await seite('tab=hochhaus', { stufe: 3, feed: { state: makeState(), error: null, loaded: true, refresh() {} } }) try { const p = ui.hk.props.hochhaus p.onOpen('botleads'); p.onOpen('freigabe') assert.deepEqual(ui.setParamsCalls, [{ tab: 'freigabe' }]) ui.go('tab=freigabe') assert.equal(ui.hk.props.pult.stufe, 3) ui.hk.props.pult.onBack() assert.deepEqual(ui.setParamsCalls, [{ tab: 'freigabe' }, { tab: 'hochhaus' }]) } finally { ui.end() } }) test('Stufe 6 und höher: ?tab=botleads zeigt den Keller samt Einzelfall (bot), onOpen(\'botleads\') wechselt; Stufe 4 und 5 haben den Keller noch nicht', async () => { for (const stufe of [6, 8, 9]) { const ui = await seite('tab=botleads&bot=abc123', { stufe, feed: { state: makeState(), error: null, loaded: true, refresh() {} } }) try { assert.deepEqual(ui.screen(), ['keller'], `Stufe ${stufe}`); assert.equal(ui.hk.props.keller.bot, 'abc123') ui.go('tab=hochhaus'); ui.hk.props.hochhaus.onOpen('botleads') assert.deepEqual(ui.setParamsCalls, [{ tab: 'botleads' }], `Stufe ${stufe}`) } finally { ui.end() } } for (const stufe of [4, 5]) { const ui = await seite('tab=botleads', { stufe }) try { assert.deepEqual(ui.screen(), ['hochhaus'], `Stufe ${stufe}`) } finally { ui.end() } } }) test('Seite: keine Seitenüberschrift, keine Reiterleiste', async () => { for (const query of ['', 'tab=freigabe', 'tab=botleads']) { const ui = await seite(query) try { assert.equal(nodes(ui.root(), (n) => n.tag === 'h1').length, 0) assert.equal(nodes(ui.root(), (n) => n.props && (n.props.role === 'tablist' || n.props.role === 'tab')).length, 0) assert.ok(!textOf(ui.root()).includes('Nachtschicht')) } finally { ui.end() } } }) test('Seite: Hochhaus bekommt api, isAdmin, Feed-Werte, Hustler-Tabelle, regieRef; onOpen wechselt per setParams({ tab })', async () => { const state = makeState({ statuses: { lea: 'wartet' } }) const refresh = () => {} const feed = { state, error: null, loaded: true, refresh } for (const admin of [false, true]) { const ui = await seite('tab=hochhaus&bot=zurueckgeblieben', { admin, feed }) try { const p = ui.hk.props.hochhaus assert.equal(p.api, ui.hk.api); assert.equal(p.isAdmin, admin) assert.deepEqual(Object.keys(p.feed).sort(), ['error', 'loaded', 'refresh', 'state']) assert.equal(p.feed.state, state); assert.equal(p.feed.loaded, true); assert.equal(p.feed.error, null); assert.equal(p.feed.refresh, refresh) assert.equal(Object.keys(p.workers).length, 19); assert.equal(p.workers.lea.status, 'wartet') assert.deepEqual(Object.keys(p.regieRef), ['current']); assert.equal(p.regieRef.current, null) p.onOpen('freigabe'); p.onOpen('botleads') // setParams ersetzt alle Parameter: ein Wechsel nimmt bot nicht mit assert.deepEqual(ui.setParamsCalls, [{ tab: 'freigabe' }, { tab: 'botleads' }]) } finally { ui.end() } } }) test('Seite: Pult bekommt api, me (Id und Name), Kennzahlen, Hustler-Tabelle und onBack -> Hochhaus', async () => { const state = makeState({ kpis: { pultOpen: 4 } }) const ui = await seite('tab=freigabe', { feed: { state, error: null, loaded: true, refresh() {} } }) try { const p = ui.hk.props.pult assert.equal(p.api, ui.hk.api); assert.deepEqual(p.me, { id: 'u-1', name: 'Kenso' }) assert.equal(p.kpis, state.kpis); assert.equal(p.kpis.pultOpen, 4) assert.equal(Object.keys(p.workers).length, 19) p.onBack() assert.deepEqual(ui.setParamsCalls, [{ tab: 'hochhaus' }]) } finally { ui.end() } }) test('Seite: Keller bekommt api, bot aus der Adresse, Kennzahlen, Hustler-Tabelle; onClearBot behält den Reiter, onBack geht ins Hochhaus', async () => { const state = makeState() const ui = await seite('tab=botleads&bot=abc123', { feed: { state, error: null, loaded: true, refresh() {} } }) try { const p = ui.hk.props.keller assert.equal(p.api, ui.hk.api); assert.equal(p.bot, 'abc123'); assert.equal(p.kpis, state.kpis); assert.equal(Object.keys(p.workers).length, 19) p.onClearBot(); p.onBack() assert.deepEqual(ui.setParamsCalls, [{ tab: 'botleads' }, { tab: 'hochhaus' }]) } finally { ui.end() } const ohne = await seite('tab=botleads') try { assert.equal(ohne.hk.props.keller.bot, '') } finally { ohne.end() } }) test('Seite: vor dem ersten Schnappschuss gibt es keine Kennzahlen und eine leere Hustler-Tabelle', async () => { const ui = await seite('tab=freigabe') try { assert.equal(ui.hk.props.pult.kpis, undefined); assert.deepEqual(ui.hk.props.pult.workers, {}) } finally { ui.end() } }) test('Seite: der Feed läuft genau einmal pro Seite, mit der api der Seite, und startet beim Bildschirmwechsel nicht neu', async () => { const state = makeState() const ui = await seite('tab=hochhaus', { feed: { state, error: null, loaded: true, refresh() {} } }) try { assert.equal(ui.hk.feedMounts, 1) assert.equal(ui.hk.feedCalls[0].api, ui.hk.api); assert.equal(typeof ui.hk.feedCalls[0].options.onEvents, 'function') ui.go('tab=freigabe'); assert.deepEqual(ui.screen(), ['pult']) ui.go('tab=botleads&bot=x'); assert.deepEqual(ui.screen(), ['keller']) ui.go('tab=hochhaus'); assert.deepEqual(ui.screen(), ['hochhaus']) assert.equal(ui.hk.feedMounts, 1); assert.equal(ui.hk.feedUnmounts, 0) // Die Hustler-Tabelle bleibt über Bildschirmwechsel erhalten assert.equal(Object.keys(ui.hk.props.hochhaus.workers).length, 19) // onEvents bleibt zwischen den Rendern dieselbe Funktion (der Feed-Hook darf sich nicht an einer neuen Funktion verschlucken) assert.ok(ui.hk.feedCalls.every((c) => c.options.onEvents === ui.hk.feedCalls[0].options.onEvents)) } finally { ui.end() } assert.equal(ui.hk.feedUnmounts, 1) }) test('Seite: Ereignisse schreiben die Hustler-Tabelle fort und gehen als dieselbe Liste an regieRef.current.push; ohne Regie kein Fehler', async () => { const state = makeState({ statuses: { lea: 'wartet' } }) const ui = await seite('tab=hochhaus', { feed: { state, error: null, loaded: true, refresh() {} } }) try { assert.equal(ui.hk.props.hochhaus.workers.lea.status, 'wartet') // noch keine Regie eingehängt: nur die Tabelle ändert sich const e1 = [ev('auftrag', 'lea', { text: 'Suche Betriebe in Kaiserslautern' })] ui.hk.onEvents(e1) assert.equal(ui.hk.props.hochhaus.workers.lea.status, 'arbeitet'); assert.equal(ui.hk.props.hochhaus.workers.lea.task, 'Suche Betriebe in Kaiserslautern') // Das Hochhaus hängt eine Regie ein const pushed = [] ui.hk.props.hochhaus.regieRef.current = { push: (events) => pushed.push(events) } const e2 = [ev('leerlauf', 'lea', { text: 'Nichts zu tun' }), ev('fehler', 'kim', { text: 'Portal gesperrt' })] ui.hk.onEvents(e2) assert.equal(pushed.length, 1); assert.equal(pushed[0], e2) assert.equal(ui.hk.props.hochhaus.workers.lea.status, 'leerlauf'); assert.equal(ui.hk.props.hochhaus.workers.kim.status, 'fehler') assert.equal(ui.hk.props.hochhaus.workers.rita.status, 'arbeitet') // Ereignisse erreichen die Tabelle auch, wenn ein anderer Bildschirm offen ist; die Regie bekommt sie weiter (nur das Hochhaus hängt eine ein) ui.go('tab=freigabe') ui.hk.onEvents([ev('fehler', 'lea', { text: 'Stopp' })]) assert.equal(ui.hk.props.pult.workers.lea.status, 'fehler') } finally { ui.end() } }) test('Seite: ein neuer Schnappschuss setzt die Hustler-Tabelle neu (Ereignisse davor wirken nicht mehr)', async () => { const feed = { state: null, error: null, loaded: false, refresh() {} } const ui = await seite('tab=hochhaus', { feed }) try { assert.deepEqual(ui.hk.props.hochhaus.workers, {}) ui.setFeed({ state: makeState(), loaded: true }) assert.equal(ui.hk.props.hochhaus.workers.lea.status, 'arbeitet') ui.hk.onEvents([ev('leerlauf', 'lea', { text: 'Pause' })]) assert.equal(ui.hk.props.hochhaus.workers.lea.status, 'leerlauf') ui.setFeed({ state: makeState({ statuses: { lea: 'wartet', kim: 'leerlauf' }, connected: { pia: false } }) }) const w = ui.hk.props.hochhaus.workers assert.equal(w.lea.status, 'wartet'); assert.equal(w.kim.status, 'leerlauf'); assert.equal(w.pia.status, 'grau'); assert.equal(w.pia.connected, false) } finally { ui.end() } }) test('Seite: Tab-Titel „Hustler“ solange die Seite offen ist, beim Verlassen kommt der vorige zurück', async () => { const { __mount, jsx, WorkersPage } = await loadSeite() const hk = { params: new URLSearchParams(''), setParams() {}, auth: { isAdmin: false, user: null }, api: {}, feed: { state: null, error: null, loaded: false, refresh() {} }, feedCalls: [], feedMounts: 0, feedUnmounts: 0, props: {}, onEvents: null, } const savedHk = globalThis.__hk globalThis.__hk = hk globalThis.document = { title: 'WOMS – Projekte' } const mounted = __mount(jsx(WorkersPage, {})) try { await settle() assert.equal(globalThis.document.title, 'Hustler') mounted.unmount() assert.equal(globalThis.document.title, 'WOMS – Projekte') } finally { if (savedHk === undefined) delete globalThis.__hk; else globalThis.__hk = savedHk delete globalThis.document } }) test('Seite: ohne angemeldeten Nutzer bleibt me ohne Id und Name (kein Absturz)', async () => { const ui = await seite('tab=freigabe') try { ui.hk.auth = { isAdmin: false, user: null } ui.setFeed({}) assert.deepEqual(ui.hk.props.pult.me, { id: undefined, name: undefined }) } finally { ui.end() } }) // ---------- Korrekturen der Endprüfung (T13) ---------- test('R5 Seite: die Hustler-Tabelle liegt nie einen Render hinter dem Schnappschuss (kein „Leerlauf“-Blitz der Figuren, in keinem Bildschirm)', async () => { const state = makeState({ statuses: { lea: 'wartet', kim: 'fehler' }, connected: { pia: false } }) const ui = await seite('tab=hochhaus', { feed: { state, error: null, loaded: true, refresh() {} } }) try { // schon die allererste Darstellung mit Zustand hat die Tabelle daraus const renders = ui.hk.renders.hochhaus assert.ok(renders.length >= 1) for (const p of renders) { assert.equal(Object.keys(p.workers).length, 19) assert.equal(p.workers.lea.status, 'wartet'); assert.equal(p.workers.kim.status, 'fehler'); assert.equal(p.workers.pia.status, 'grau') } // neuer Schnappschuss mitten im Lauf: jede weitere Darstellung zeigt schon die neue Lage (nie die alte Tabelle zum neuen Zustand) renders.length = 0 const zweiter = makeState({ statuses: { lea: 'leerlauf' } }) ui.setFeed({ state: zweiter }) assert.ok(renders.length >= 1) for (const p of renders) { assert.equal(p.feed.state, zweiter); assert.equal(p.workers.lea.status, 'leerlauf'); assert.equal(p.workers.kim.status, 'arbeitet') } // dasselbe für Pult und Keller (sie bekommen dieselbe Tabelle) ui.go('tab=freigabe') ui.hk.renders.pult.length = 0 const dritter = makeState({ statuses: { lea: 'wartet' } }) ui.setFeed({ state: dritter }) for (const p of ui.hk.renders.pult) assert.equal(p.workers.lea.status, 'wartet') ui.go('tab=botleads') ui.hk.renders.keller.length = 0 ui.setFeed({ state: makeState({ statuses: { lea: 'fehler' } }) }) for (const p of ui.hk.renders.keller) assert.equal(p.workers.lea.status, 'fehler') assert.ok(ui.hk.renders.keller.length >= 1) // Ereignisse wirken weiter auf die Tabelle und überleben das Neu-Rendern desselben Schnappschusses ui.hk.onEvents([ev('leerlauf', 'lea', { text: 'Pause' })]) assert.equal(ui.hk.props.keller.workers.lea.status, 'leerlauf') ui.setFeed({}) assert.equal(ui.hk.props.keller.workers.lea.status, 'leerlauf') } finally { ui.end() } // Erster Schnappschuss kommt erst später: vorher leer, danach nie eine Darstellung mit Zustand und leerer Tabelle const spaet = await seite('tab=hochhaus') try { assert.deepEqual(spaet.hk.props.hochhaus.workers, {}) spaet.hk.renders.hochhaus.length = 0 spaet.setFeed({ state: makeState({ statuses: { lea: 'wartet' } }), loaded: true }) for (const p of spaet.hk.renders.hochhaus) { assert.ok(p.feed.state); assert.equal(Object.keys(p.workers).length, 19) } assert.ok(spaet.hk.renders.hochhaus.length >= 1) } finally { spaet.end() } }) test('R3 Seite: Pult und Keller bekommen paused aus state.paused (nur ein echtes true), Pause schaltet mit dem Zustand um', async () => { const ui = await seite('tab=freigabe', { feed: { state: makeState({ paused: false }), error: null, loaded: true, refresh() {} } }) try { assert.equal(ui.hk.props.pult.paused, false) ui.setFeed({ state: makeState({ paused: true }) }) assert.equal(ui.hk.props.pult.paused, true) ui.go('tab=botleads') assert.equal(ui.hk.props.keller.paused, true) ui.setFeed({ state: makeState({ paused: false }) }) assert.equal(ui.hk.props.keller.paused, false) ui.setFeed({ state: { ...makeState(), paused: 'ja' } }) assert.equal(ui.hk.props.keller.paused, false) // vor dem ersten Schnappschuss: nicht angehalten ui.setFeed({ state: null, loaded: false }) assert.equal(ui.hk.props.keller.paused, false) } finally { ui.end() } }) test('M2 Seite: das Hochhaus bekommt vonTab (der Bildschirm, von dem man zurückkommt); beim ersten Besuch null', async () => { const ui = await seite('tab=hochhaus', { feed: { state: makeState(), error: null, loaded: true, refresh() {} } }) try { assert.equal(ui.hk.props.hochhaus.vonTab, null) ui.go('tab=freigabe'); assert.deepEqual(ui.screen(), ['pult']) ui.go('tab=hochhaus'); assert.equal(ui.hk.props.hochhaus.vonTab, 'freigabe') // schon der erste Render nach dem Wechsel kennt vonTab (das Hochhaus fokussiert beim Einhängen) assert.ok(ui.hk.renders.hochhaus.every((p, i) => i === 0 || p.vonTab === null || p.vonTab === 'freigabe')) ui.go('tab=botleads'); assert.deepEqual(ui.screen(), ['keller']) ui.go('tab=hochhaus'); assert.equal(ui.hk.props.hochhaus.vonTab, 'botleads') // Neu-Rendern im Hochhaus ändert nichts ui.setFeed({}); assert.equal(ui.hk.props.hochhaus.vonTab, 'botleads') } finally { ui.end() } // direkt im Pult gestartet, dann zurück const direkt = await seite('tab=freigabe', { feed: { state: makeState(), error: null, loaded: true, refresh() {} } }) try { direkt.go('tab=hochhaus') assert.equal(direkt.hk.props.hochhaus.vonTab, 'freigabe') } finally { direkt.end() } }) // Hochhaus mit Wurzel-Attrappe (querySelector, focus) für den Rückweg und die Überschrift function wurzelAttrappe() { const gefragt = [] const etagen = new Map() const wurzel = { querySelector(sel) { gefragt.push(sel) const m = /^\[data-hk-etage="([\w-]+)"\]$/.exec(sel) if (!m) return null if (!etagen.has(m[1])) etagen.set(m[1], { id: m[1], focusCalls: [], focus(o) { this.focusCalls.push(o) } }) return etagen.get(m[1]) }, } const gehabt = Object.hasOwn(globalThis, '__fakeRef') const alt = globalThis.__fakeRef globalThis.__fakeRef = (tag, props) => (tag === 'div' && props.className === 'hk-hochhaus' ? wurzel : null) return { gefragt, etagen, restore() { if (gehabt) globalThis.__fakeRef = alt; else delete globalThis.__fakeRef } } } test('M2 Hochhaus: nach dem Rückweg aus dem Pult fokussiert die Etage „freigabe“, aus dem Keller die Etage „keller“ (ohne Scrollen), erst wenn die Welt steht; sonst nichts', async () => { const ready = { state: makeState(), error: null, loaded: true, refresh() {} } for (const [von, etage] of [['freigabe', 'freigabe'], ['botleads', 'keller']]) { const w = wurzelAttrappe() const view = await hochhaus(ready, { vonTab: von }) try { assert.deepEqual(w.gefragt, [`[data-hk-etage="${etage}"]`]) assert.deepEqual(w.etagen.get(etage).focusCalls, [{ preventScroll: true }]) // Neu-Rendern (Feed-Aktualisierung) holt den Fokus nicht noch einmal view.setFeed({ state: makeState() }) assert.equal(w.etagen.get(etage).focusCalls.length, 1) } finally { view.end(); w.restore() } } // kein vonTab / unbekannter Wert: kein Fokus for (const von of [null, undefined, 'hochhaus', 'toString', '__proto__']) { const w = wurzelAttrappe() const view = await hochhaus(ready, { vonTab: von }) try { assert.deepEqual(w.gefragt, [], String(von)) } finally { view.end(); w.restore() } } // noch nicht geladen: erst wenn der Zustand da ist const w = wurzelAttrappe() const view = await hochhaus({ state: null, error: null, loaded: false, refresh() {} }, { vonTab: 'freigabe' }) try { assert.deepEqual(w.gefragt, []) view.setFeed({ state: makeState(), loaded: true }) assert.deepEqual(w.etagen.get('freigabe').focusCalls, [{ preventScroll: true }]) } finally { view.end(); w.restore() } }) test('M2/M7 Hochhaus: Überschrift „Hustler – Hochhaus“ (h1, nur für Screenreader) in jedem Zustand; die Kopfzeile steht im DOM vor der Welt', async () => { const ready = { state: makeState(), error: null, loaded: true, refresh() {} } const faelle = [ ['geladen', ready], ['lädt', { state: null, error: null, loaded: false, refresh() {} }], ['Dienst nicht erreichbar', { state: null, error: 'weg', loaded: false, refresh() {} }], ] for (const [wo, feed] of faelle) { const view = await hochhaus(feed) try { const h1 = nodes(view.tree(), (n) => n.tag === 'h1') assert.equal(h1.length, 1, wo) assert.equal(textOf(h1[0]), 'Hustler – Hochhaus', wo); assert.equal(h1[0].props.className, 'hk-nur-sr', wo) assert.equal(view.tree().children[0], h1[0], `${wo}: die Überschrift steht zuerst`) } finally { view.end() } } const view = await hochhaus(ready) try { const reihenfolge = view.tree().children.filter((k) => k && k.props && k.props['data-teil']).map((k) => k.props['data-teil']) assert.deepEqual(reihenfolge, ['kopf', 'welt'], 'Tab-Reihenfolge: Kopfzeile vor der Welt') view.hh.welt.onZeige('lea') await settle() assert.deepEqual(view.tree().children.filter((k) => k && k.props && k.props['data-teil']).map((k) => k.props['data-teil']), ['kopf', 'welt', 'dialog']) } finally { view.end() } }) test('R3 Hochhaus: paused des Dienstes geht an die Detailansicht (nur ein echtes true)', async () => { const view = await hochhaus({ state: makeState({ paused: false }), error: null, loaded: true, refresh() {} }) try { view.hh.welt.onZeige('lea') await settle() assert.equal(view.hh.dialog.paused, false) view.setFeed({ state: makeState({ paused: true }) }) assert.equal(view.hh.dialog.paused, true) view.setFeed({ state: { ...makeState(), paused: 'ja' } }) assert.equal(view.hh.dialog.paused, false) } finally { view.end() } })