Files
tickte-system/tests/unit/hustlerSeite.test.mjs
T

364 lines
19 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'
// 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: '<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(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() }
})