diff --git a/src/components/workers/hochhaus/Hochhaus.jsx b/src/components/workers/hochhaus/Hochhaus.jsx
new file mode 100644
index 0000000..5dfad7e
--- /dev/null
+++ b/src/components/workers/hochhaus/Hochhaus.jsx
@@ -0,0 +1,33 @@
+// Bildschirm „Hochhaus“ (Spielwelt, Kopfzeile, Detailansicht). Gerüst von T3: Zustände wie bisher im WorkersBoard; T5–T8 füllen die Welt.
+// Reine Darstellung ohne Auth-Bezug: isAdmin, der Feed (feed = { state, error, loaded, refresh }), die Hustler-Tabelle (workers), die Regie-Anbindung
+// (regieRef, die Seite ruft regieRef.current.push(events) bei neuen Ereignissen) und der Bildschirmwechsel (onOpen(tab)) kommen von der Seite.
+// Kein CSS-Import und keine Bilder: die Stile hängt WorkersPage ein (Regeln dieser Datei in styles/hustler/hustler-ui.css).
+export default function Hochhaus({ api, isAdmin, feed, workers, regieRef, onOpen }) {
+ const ready = feed.loaded && Boolean(feed.state)
+ // Dienst noch nie erreicht: Pixel-Leerzustand mit Fehlertext und „Neu laden“
+ if (!ready && feed.error) {
+ return (
+
+
+
Der Hustler-Dienst ist noch nicht erreichbar
+
{feed.error}
+
feed.refresh()}>Neu laden
+
+
+ )
+ }
+ if (!ready) {
+ return (
+
+ )
+ }
+
+ // T5 füllt diesen Rahmen mit der Welt (Kopfzeile T6, Detailansicht T7, Regie T8)
+ return (
+
+ )
+}
diff --git a/src/pages/WorkersPage.jsx b/src/pages/WorkersPage.jsx
index 234f152..99a88e3 100644
--- a/src/pages/WorkersPage.jsx
+++ b/src/pages/WorkersPage.jsx
@@ -1,15 +1,22 @@
-import { useEffect } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
-import { PageHeader, Tabs } from '../components/ui'
import BotLeadList from '../components/workers/BotLeadList'
+import Hochhaus from '../components/workers/hochhaus/Hochhaus'
import PultList from '../components/workers/PultList'
-import WorkersBoard from '../components/workers/WorkersBoard'
import { useAuth } from '../context/AuthContext'
+import { useWorkersFeed } from '../hooks/useWorkersFeed'
+import { indexWorkers, reduceEvents } from '../lib/workersEvents'
import { workersApi } from '../lib/workersClient'
import { pickTab } from '../lib/workersView'
import '../styles/workers.css'
+import '../styles/hustler/schrift.css'
+import '../styles/hustler/hochhaus.css'
+import '../styles/hustler/keller.css'
+import '../styles/hustler/pult.css'
+import '../styles/hustler/hustler-ui.css'
-// Reiter der Seite (Hochhaus, Freigabe von P3, Bot-Leads von P6); die Reiterleiste erscheint erst ab zwei Reitern.
+// Bildschirme der Seite: das Hochhaus, das Pult (Freigabe von P3) und der Keller (Bot-Leads von P6). Eine Reiterleiste gibt es nicht mehr;
+// gewechselt wird im Haus (Etage Freigabe, Etage Keller) und über „◂ Hochhaus“ in Pult und Keller.
const WORKERS_TABS = [
{ id: 'hochhaus', label: 'Hochhaus' },
{ id: 'freigabe', label: 'Freigabe' },
@@ -18,14 +25,18 @@ const WORKERS_TABS = [
const api = workersApi
-// Seite „Hustler“ (Dateiname bleibt WorkersPage): Titel, Reiterwahl über ?tab= und der Inhalt des gewählten Reiters.
-// ?tab=botleads&bot= (Link aus dem Ereignisstrom) zeigt einen einzelnen Bot-Lead; ein Reiterwechsel lässt bot weg.
-// Auth und Dienst-API kommen von hier; das Board selbst kennt weder Anmeldung noch Appwrite.
+// Seite „Hustler“ (Dateiname bleibt WorkersPage): Tab-Titel, Bildschirmwahl über ?tab= und der gewählte Bildschirm in voller Höhe.
+// ?tab=botleads&bot= (Link aus dem Ereignisstrom) zeigt einen einzelnen Bot-Lead; ein Wechsel per setParams lässt bot weg.
+// Auth und Dienst-API kommen von hier. Der Feed (Zustand alle 15 s, Ereignisse alle 5 s) läuft genau einmal pro Seite, egal welcher
+// Bildschirm offen ist; die Hustler-Tabelle (indexWorkers beim Schnappschuss, reduceEvents bei Ereignissen) gehört ebenfalls hierher.
+// Ereignisse gehen zusätzlich an die Regie, die nur das Hochhaus über regieRef einhängt.
export default function WorkersPage() {
const { isAdmin, user } = useAuth()
const [params, setParams] = useSearchParams()
const active = pickTab(WORKERS_TABS, params.get('tab'))
const bot = params.get('bot') ?? ''
+ const [workers, setWorkers] = useState({})
+ const regieRef = useRef(null)
// Tab-Titel nur solange die Seite offen ist; beim Verlassen kommt der vorige Titel zurück
useEffect(() => {
@@ -34,15 +45,53 @@ export default function WorkersPage() {
return () => { document.title = previous }
}, [])
+ // Neue Ereignisse: Tabelle fortschreiben und der Regie vorspielen (die Regie kennt den Rückstau selbst)
+ const onEvents = useCallback((events) => {
+ setWorkers((w) => reduceEvents(w, events))
+ if (regieRef.current) regieRef.current.push(events)
+ }, [])
+
+ const { state, error, loaded, refresh } = useWorkersFeed(api, { onEvents })
+ useEffect(() => {
+ if (state) setWorkers(indexWorkers(state))
+ }, [state])
+
return (
-
-
- {WORKERS_TABS.length > 1 && (
-
setParams({ tab: id })} />
+
+ {active.id === 'hochhaus' && (
+
setParams({ tab })}
+ />
+ )}
+ {/* Pult und Keller stecken bis T9/T10 noch in der alten hellen Hülle (.page.wk-page); hustler-ui.css gibt ihnen dafür normale Höhe */}
+ {active.id === 'freigabe' && (
+
+
setParams({ tab: 'hochhaus' })}
+ />
+
+ )}
+ {active.id === 'botleads' && (
+
+ setParams({ tab: 'botleads' })}
+ kpis={state?.kpis}
+ workers={workers}
+ onBack={() => setParams({ tab: 'hochhaus' })}
+ />
+
)}
- {active.id === 'hochhaus' && }
- {active.id === 'freigabe' && }
- {active.id === 'botleads' && setParams({ tab: 'botleads' })} />}
)
}
diff --git a/src/styles/hustler/hustler-ui.css b/src/styles/hustler/hustler-ui.css
new file mode 100644
index 0000000..eade344
--- /dev/null
+++ b/src/styles/hustler/hustler-ui.css
@@ -0,0 +1,27 @@
+/* Handgeschriebene Regeln der Hustler-Pixelwelt (WorkersPage, Hochhaus, später Pult und Keller). Nicht erzeugt: die drei Bildschirm-Dateien
+ hochhaus.css, keller.css und pult.css kommen von scripts/hustler-import.mjs und werden nie von Hand geändert. Alle Klassen beginnen mit hk-
+ und stehen unter einer der Wurzeln .hk-seite, .hk-hochhaus, .hk-keller oder .hk-pult. */
+
+/* ---------- Seite: füllt den Inhaltsbereich (main) neben der WOMS-Seitenleiste in voller Höhe, kein Seiten-Scrollen ---------- */
+.hk-seite { position: relative; height: 100vh; height: 100dvh; overflow: hidden; background: #0b1030; }
+@media (max-width: 860px) {
+ /* Mobilleiste aus global.css (.mobile-bar, 52 px) abziehen */
+ .hk-seite { height: calc(100vh - 52px); height: calc(100dvh - 52px); }
+}
+.hk-seite > .hk-hochhaus { position: absolute; inset: 0; }
+
+/* Übergang bis T9/T10: Pult und Keller stehen noch in der alten hellen Hülle (.page.wk-page), brauchen normale Höhe, Scrollen und hellen Grund.
+ Entfällt, sobald PultList und BotLeadList in ihre Pixelszenen umgebaut sind (.hk-pult / .hk-keller füllen dann selbst die Seite). */
+.hk-seite[data-tab="freigabe"], .hk-seite[data-tab="botleads"] { height: auto; overflow: visible; background: none; }
+
+/* ---------- Hochhaus: Laden und Leerzustand im Pixel-Stil ---------- */
+.hk-hochhaus .hk-laden,
+.hk-hochhaus .hk-leer {
+ position: absolute; inset: 0;
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px;
+ padding: 24px; box-sizing: border-box; text-align: center;
+ background: #0b1030; color: #e6ebff;
+ font-family: 'Pixelify Sans', ui-monospace, monospace; font-size: 18px; line-height: 1.4;
+}
+.hk-hochhaus .hk-leer-titel { display: block; margin: 0; color: #ffe9a0; font-size: 22px; font-weight: 400; }
+.hk-hochhaus .hk-leer-text { margin: 0; max-width: 46ch; color: #b8c2f0; overflow-wrap: anywhere; }
diff --git a/tests/unit/hustlerSeite.test.mjs b/tests/unit/hustlerSeite.test.mjs
new file mode 100644
index 0000000..bfba3c9
--- /dev/null
+++ b/tests/unit/hustlerSeite.test.mjs
@@ -0,0 +1,363 @@
+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'
+
+// 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 -> Rahmen der Welt.
+// 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 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 ----------
+
+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],
+}).then(importBundle))
+
+async function hochhaus(feed, extra = {}) {
+ const { __mount, jsx, Hochhaus } = await loadHochhaus()
+ const props = { api: {}, isAdmin: false, workers: {}, regieRef: { current: null }, onOpen() {}, ...extra, feed }
+ const mounted = __mount(jsx(() => jsx(Hochhaus, props), {}))
+ await settle()
+ return { tree: () => mounted.tree(), end: () => mounted.unmount() }
+}
+const counting = (n = { refresh: 0 }) => ({ n, refresh: () => { n.refresh += 1 } })
+
+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(byClass(root, 'hk-welt-rahmen').length, 0); assert.equal(byClass(root, 'hk-leer').length, 0)
+ assert.equal(c.n.refresh, 0)
+ } finally { view.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(byClass(root, 'hk-welt-rahmen').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 -> leerer Rahmen hk-welt-rahmen; ein späterer Verbindungsfehler ersetzt die Welt nicht', async () => {
+ const c = counting()
+ const state = makeState()
+ const view = await hochhaus({ state, error: null, loaded: true, refresh: c.refresh })
+ try {
+ const root = view.tree()
+ assert.equal(root.props.className, 'hk-hochhaus')
+ assert.equal(byClass(root, 'hk-welt-rahmen').length, 1)
+ assert.equal(byClass(root, 'hk-laden').length, 0); assert.equal(byClass(root, 'hk-leer').length, 0)
+ } finally { view.end() }
+ const gestoert = await hochhaus({ state, error: 'Verbindung weg', loaded: true, refresh: c.refresh })
+ try {
+ assert.equal(byClass(gestoert.tree(), 'hk-welt-rahmen').length, 1)
+ assert.equal(byClass(gestoert.tree(), 'hk-leer').length, 0)
+ } finally { gestoert.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(byClass(view.tree(), 'hk-welt-rahmen').length, 0)
+ assert.equal(byClass(view.tree(), 'hk-leer').length, 1)
+ } 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
+ }
+ `,
+ hochhaus: "import { jsx } from 'react'\nexport default function Hochhaus(props) { globalThis.__hk.props.hochhaus = props; return jsx('div', { 'data-screen': 'hochhaus' }) }",
+ pult: "import { jsx } from 'react'\nexport default function PultList(props) { globalThis.__hk.props.pult = props; return jsx('div', { 'data-screen': 'pult' }) }",
+ keller: "import { jsx } from 'react'\nexport default function BotLeadList(props) { globalThis.__hk.props.keller = 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.
+async function seite(query, { admin = false, feed } = {}) {
+ 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,
+ }
+ 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, {}))
+ 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 in der alten Hülle, ?tab=botleads den Keller; 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, 1); assert.ok(hasClass(byClass(pult.root(), 'page')[0], 'wk-page'))
+ } 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, 1)
+ } finally { keller.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() }
+})
diff --git a/tests/unit/workersRoute.test.mjs b/tests/unit/workersRoute.test.mjs
index 8562c32..3833766 100644
--- a/tests/unit/workersRoute.test.mjs
+++ b/tests/unit/workersRoute.test.mjs
@@ -1,6 +1,8 @@
import test from 'node:test'
import assert from 'node:assert/strict'
-import { readFileSync } from 'node:fs'
+import { readFileSync, readdirSync } from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
// Die Seite und die Routen sind unter Node nicht ladbar (JSX, Router): geprüft wird der Quelltext (Muster workersComponents.test.mjs)
test('App: /hustler lazy hinter ProtectedRoute, /leads und /workers leiten mit erhaltener Query auf /hustler um, kein statischer Import', () => {
@@ -11,20 +13,50 @@ test('App: /hustler lazy hinter ProtectedRoute, /leads und /workers leiten mit e
assert.match(s, /function HustlerRedirect\(\)[\s\S]*?useLocation\(\)[\s\S]*? /) // /workers?tab=botleads&bot=x → /hustler?tab=botleads&bot=x
assert.ok(s.indexOf('path="/hustler"') < s.indexOf('path="*"')); assert.ok(s.indexOf('path="/workers"') < s.indexOf('path="*"'))
})
-test('WorkersPage: Titel Hustler (H1 und Tab-Titel), Reiter hochhaus und freigabe, Reiterleiste erst ab zwei Reitern, api-Instanz, Admin-Weitergabe', () => {
+test('WorkersPage: Tab-Titel Hustler, drei Bildschirme in fester Reihenfolge, keine Seitenüberschrift und keine Reiterleiste mehr, api-Instanz, Admin-Weitergabe ans Hochhaus', () => {
const s = readFileSync(new URL('../../src/pages/WorkersPage.jsx', import.meta.url), 'utf8')
- assert.match(s, /title="Hustler"/); assert.match(s, /document\.title = 'Hustler'/)
- assert.match(s, /id: 'hochhaus'/); assert.match(s, /id: 'freigabe'/); assert.match(s, /WORKERS_TABS\.length > 1/)
- assert.match(s, /const api = workersApi/); assert.match(s, /pickTab\(/); assert.match(s, /isAdmin=\{isAdmin\}/)
+ assert.match(s, /document\.title = 'Hustler'/); assert.match(s, /return \(\) => \{ document\.title = previous \}/) // der alte Titel kommt beim Verlassen zurück
+ assert.match(s, /id: 'hochhaus', label: 'Hochhaus'/); assert.match(s, /id: 'freigabe', label: 'Freigabe'/); assert.match(s, /id: 'botleads', label: 'Bot-Leads'/)
+ assert.ok(s.indexOf("id: 'hochhaus'") < s.indexOf("id: 'freigabe'") && s.indexOf("id: 'freigabe'") < s.indexOf("id: 'botleads'"))
+ assert.doesNotMatch(s, /title="Hustler"/); assert.doesNotMatch(s, /PageHeader/); assert.doesNotMatch(s, / 1/)
+ assert.match(s, /const api = workersApi/); assert.match(s, /pickTab\(WORKERS_TABS, params\.get\('tab'\)\)/); assert.match(s, /useAuth\(\)/)
+ assert.match(s, /]*\bapi=\{api\}/); assert.match(s, /]*isAdmin=\{isAdmin\}/)
+ assert.match(s, //) // Vollhöhen-Rahmen, data-tab schaltet den Übergang für Pult und Keller
+ assert.match(s, /import Hochhaus from '\.\.\/components\/workers\/hochhaus\/Hochhaus'/)
+ assert.doesNotMatch(s, /WorkersBoard/) // die Kanvas-Oberfläche wird nicht mehr benutzt
})
-test('WorkersPage: Reiter botleads hinter freigabe zeigt die BotLeadList mit api, bot aus der Adresse und onClearBot, der den Reiter behält; ein Reiterwechsel lässt bot weg', () => {
+test('WorkersPage: der Feed läuft einmal pro Seite und füllt die Hustler-Tabelle; Ereignisse gehen zusätzlich an die Regie', () => {
const s = readFileSync(new URL('../../src/pages/WorkersPage.jsx', import.meta.url), 'utf8')
- assert.match(s, /id: 'botleads', label: 'Bot-Leads'/); assert.ok(s.indexOf("id: 'freigabe'") < s.indexOf("id: 'botleads'"))
- assert.match(s, /import BotLeadList from '\.\.\/components\/workers\/BotLeadList'/); assert.match(s, /active\.id === 'botleads' &&
]*api=\{api\}/)
+ assert.equal(s.match(/useWorkersFeed\(/g).length, 1)
+ assert.match(s, /const \{ state, error, loaded, refresh \} = useWorkersFeed\(api, \{ onEvents \}\)/)
+ assert.match(s, /indexWorkers\(state\)/); assert.match(s, /reduceEvents\(w, events\)/)
+ assert.match(s, /const regieRef = useRef\(null\)/); assert.match(s, /regieRef\.current\.push\(events\)/); assert.match(s, /]*regieRef=\{regieRef\}/)
+ assert.match(s, /feed=\{\{ state, error, loaded, refresh \}\}/); assert.match(s, /]*workers=\{workers\}/)
+ for (const f of ['Hochhaus', 'PultList', 'BotLeadList']) assert.equal(readFileSync(new URL(`../../src/components/workers/${f === 'Hochhaus' ? 'hochhaus/' : ''}${f}.jsx`, import.meta.url), 'utf8').includes('useWorkersFeed('), false, `${f} startet keinen eigenen Feed`)
+})
+test('WorkersPage: freigabe zeigt die PultList, botleads die BotLeadList (api, bot aus der Adresse, onClearBot behält den Reiter); Wechsel per setParams lässt bot weg; Zurück ins Hochhaus', () => {
+ const s = readFileSync(new URL('../../src/pages/WorkersPage.jsx', import.meta.url), 'utf8')
+ assert.match(s, /import PultList from '\.\.\/components\/workers\/PultList'/); assert.match(s, /import BotLeadList from '\.\.\/components\/workers\/BotLeadList'/)
+ assert.match(s, /active\.id === 'freigabe' && \([\s\S]*?]*\bapi=\{api\}/); assert.match(s, /]*me=\{\{ id: user\?\.\$id, name: user\?\.name \}\}/)
+ assert.match(s, /active\.id === 'botleads' && \([\s\S]*?]*\bapi=\{api\}/)
assert.match(s, /params\.get\('bot'\)/); assert.match(s, /]*\bbot=\{bot\}/); assert.match(s, /]*onClearBot=\{\(\) => setParams\(\{ tab: 'botleads' \}\)\}/)
- assert.match(s, /onChange=\{\(id\) => setParams\(\{ tab: id \}\)\}/) // setParams ersetzt alle Parameter: bot fällt beim Wechsel weg
+ assert.match(s, /onOpen=\{\(tab\) => setParams\(\{ tab \}\)\}/) // setParams ersetzt alle Parameter: bot fällt beim Wechsel weg
+ assert.equal(s.match(/onBack=\{\(\) => setParams\(\{ tab: 'hochhaus' \}\)\}/g).length, 2) // „◂ Hochhaus“ in Pult und Keller
+ for (const name of ['PultList', 'BotLeadList']) assert.match(s, new RegExp(`<${name}[\\s\\S]*?kpis=\\{state\\?\\.kpis\\}[\\s\\S]*?workers=\\{workers\\}[\\s\\S]*?/>`))
assert.doesNotMatch(s, /Später kommt botleads/)
})
+test('CSS-Importe der Hustler-Oberfläche stehen nur in WorkersPage, nicht in den Komponenten (sonst nicht unter Node mit fakeReact ladbar)', () => {
+ const s = readFileSync(new URL('../../src/pages/WorkersPage.jsx', import.meta.url), 'utf8')
+ for (const css of ['workers', 'hustler/schrift', 'hustler/hochhaus', 'hustler/keller', 'hustler/pult', 'hustler/hustler-ui']) assert.ok(s.includes(`import '../styles/${css}.css'`), css)
+ const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]))
+ const root = fileURLToPath(new URL('../../src/components/workers/', import.meta.url))
+ const files = walk(root).filter((f) => /\.(jsx?|mjs)$/.test(f))
+ assert.ok(files.length > 5)
+ for (const f of files) {
+ const text = readFileSync(f, 'utf8')
+ assert.doesNotMatch(text, /^\s*import\s+(?:[^'"\n]*\sfrom\s+)?['"][^'"]+\.(css|png|svg|jpe?g|webp|gif)['"]/m, `${path.relative(root, f)} importiert CSS oder Bilder`)
+ }
+})
test('App: RouteErrorBoundary um die Routen im Inhaltsbereich (fehlender lazy-Chunk in altem Tab lässt nicht die ganze Oberfläche verschwinden), Pfad als resetKey', () => {
const s = readFileSync(new URL('../../src/App.jsx', import.meta.url), 'utf8')
assert.match(s, /import RouteErrorBoundary from '\.\/components\/RouteErrorBoundary'/)