Files
tickte-system/tests/unit/hustlerSeite.test.mjs
T
ClaudeandClaude Sonnet 5.5 32f4f1d941 Hustler-Stufe: Keller und Bot-Leads erst ab Stufe 6 (Seite, Keller-Etage, Spur-Links, Abruf von /botleads)
WorkersPage: ?tab=botleads (auch mit &bot=) und unbekannte Werte fallen unterhalb von Stufe 6 auf das Hochhaus zurück, BotLeadList wird nie eingehängt; die Weiterleitungen /leads und /workers bleiben.
HochhausWelt: die Keller-Etage ist dann ein Knopf wie jede andere (zoomt heran) statt eines Links; Arne bleibt klickbar, die Zahl Bot-Leads in der Kopfzeile bleibt Text.
HustlerDialog: Spur-Links in den Keller bleiben Text. workersApi.botleads ruft unterhalb von Stufe 6 nichts ab (kein JWT, keine Anfrage), sondern liefert 404 mit deutschem Satz.
Bestehende Keller-Tests laufen ausdrücklich auf Stufe 6; neue Tests prüfen Stufe 1, 3 und 5 (gesperrt) und 6 bis 9 (offen).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 10:36:52 +00:00

716 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '<b>Der Dienst antwortet nicht.</b>', 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, ['<b>Der Dienst antwortet nicht.</b>'])
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() }
})