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

783 lines
41 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 { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'
import { fakeReact } from './helpers/fakeReact.mjs'
import { makeState } from './helpers/workersFixtures.mjs'
// Die Kopfzeile des Hochhauses (T6) läuft wirklich: im Mini-Läufer anstelle von React (Muster hustlerSeite.test.mjs, Baustein helpers/fakeReact.mjs).
// WorkersSettingsDialog ist per esbuild-Plug-in durch eine Attrappe ersetzt (sie legt ihre Props unter globalThis.__kopf ab). Elemente mit ref
// bekommen kleine Attrappen (focus() setzt document.activeElement, gesperrte Knöpfe nehmen den Fokus nicht), document ist eine Attrappe.
// Geprüft werden die fachlichen Zusicherungen des früheren WorkersBoard (Pause mit Zielwert, Rückfrage nur beim Anhalten, Notbremse, Verbindungshinweis,
// Einstellungen nur für Admins) und die Ausgabe der Kopfzeile (Werte, Striche, Budget-Stufen).
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
const settle = () => new Promise((resolve) => setImmediate(resolve))
const NBSP = String.fromCharCode(160)
const nodes = (node, match, out = []) => {
if (node === null || typeof node === 'string') return out
if (match(node)) out.push(node)
for (const child of node.children) nodes(child, match, out)
return out
}
const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join(''))
const classes = (n) => String(n.props.className ?? '').split(' ').filter(Boolean)
const hasClass = (n, name) => classes(n).includes(name)
const byClass = (tree, name) => nodes(tree, (n) => typeof n.tag === 'string' && hasClass(n, name))
const byRole = (tree, role) => nodes(tree, (n) => typeof n.tag === 'string' && n.props.role === role)
const buttons = (tree) => nodes(tree, (n) => n.tag === 'button')
const buttonByText = (tree, text) => buttons(tree).find((b) => textOf(b) === text)
const pauseButton = (tree) => buttons(tree).find((b) => b.props['data-hk-pause'] !== undefined)
const settingsButton = (tree) => buttons(tree).find((b) => b.props['aria-label'] === 'Einstellungen')
const hausButton = (tree) => buttons(tree).find((b) => textOf(b) === 'Ganzes Haus zeigen')
const event = (extra = {}) => ({ defaultPrevented: false, preventDefault() { this.defaultPrevented = true }, ...extra })
// Klick wie im Browser: gesperrte Knöpfe lösen nichts aus
const press = (node) => {
if (node.props.disabled) return false
node.props.onClick(event())
return true
}
const deferred = () => {
let resolve, reject
const promise = new Promise((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
// ---------- Bündel mit Attrappe für den Einstellungen-Dialog ----------
const ATTRAPPE = "import { jsx } from 'react'\nexport default function WorkersSettingsDialog(props) { globalThis.__kopf.settings.push(props); return jsx('div', { 'data-attrappe': 'einstellungen' }) }"
const attrappe = {
name: 'einstellungen-attrappe',
setup(build) {
build.onResolve({ filter: /^\.\.\/WorkersSettingsDialog$/ }, () => ({ path: 'WorkersSettingsDialog', namespace: 'kopf-attrappe' }))
build.onLoad({ filter: /.*/, namespace: 'kopf-attrappe' }, () => ({ contents: ATTRAPPE, loader: 'js' }))
},
}
let bundle
const loadKopf = () => (bundle ??= esbuild.build({
stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as HochhausKopf } from './src/components/workers/hochhaus/HochhausKopf.jsx'", resolveDir: ROOT, loader: 'js' },
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact, attrappe],
}).then(importBundle))
// Ein Lauf der Kopfzeile. props werden zwischendurch geändert (set) und neu gerendert. log hält die Aufrufe in ihrer Reihenfolge.
async function kopf(overrides = {}) {
const { __mount, jsx, HochhausKopf } = await loadKopf()
const log = []
const kb = { settings: [] }
const body = { tag: 'body', isConnected: true, focus() { globalThis.document.activeElement = body } }
const saved = { document: globalThis.document, hadDocument: Object.hasOwn(globalThis, 'document'), kopf: globalThis.__kopf, fakeRef: globalThis.__fakeRef }
globalThis.document = { activeElement: body, body }
globalThis.__kopf = kb
// pro ref-Objekt bleibt dasselbe Element erhalten (wie im Browser), disabled folgt den Props des letzten Renders
const elements = new WeakMap()
globalThis.__fakeRef = (type, props) => {
let el = elements.get(props.ref)
if (!el) {
el = { tag: type, isConnected: true, disabled: false, focus() { if (!this.disabled) globalThis.document.activeElement = this } }
elements.set(props.ref, el)
}
el.disabled = Boolean(props.disabled)
return el
}
const holder = {
props: {
api: { pause: async (target) => { log.push(`pause:${target}`) } },
isAdmin: false,
state: makeState(),
error: null,
refresh: () => { log.push('refresh') },
onGanz: () => { log.push('ganz') },
...overrides,
},
}
const mounted = __mount(jsx(() => jsx(HochhausKopf, holder.props), {}))
await settle()
return {
log, kb, holder,
document: () => globalThis.document,
tree: () => mounted.tree(),
set(next) { holder.props = { ...holder.props, ...next }; mounted.rerender() },
late: () => mounted.lateUpdates(),
unmount: () => mounted.unmount(),
end() {
mounted.unmount()
if (saved.hadDocument) globalThis.document = saved.document; else delete globalThis.document
if (saved.kopf === undefined) delete globalThis.__kopf; else globalThis.__kopf = saved.kopf
if (saved.fakeRef === undefined) delete globalThis.__fakeRef; else globalThis.__fakeRef = saved.fakeRef
},
}
}
const VOLLE_KPIS = { mrrNewEur: 60, apiTodayUsd: 5.1, dailyBudgetUsd: 6, newAbosMonth: 3, dealsMonth: 3, openThreads: 7, pultOpen: 5, botLeads: 112 }
// Wert-Knoten eines Kopfzeilen-Eintrags: [Screenreader-Text, sichtbarer Text]
const wert = (knoten) => {
const sr = byClass(knoten, 'hk-kopf-sr')[0]
return { sr: textOf(sr), text: knoten.children.filter((c) => typeof c === 'string').join('') }
}
// ---------- Werte ----------
test('Kopfzeile: Panel „Geld“ mit grüner und roter Münze, Beträgen, Screenreader-Texten und Titeln', async () => {
const ui = await kopf({ state: makeState({ kpis: VOLLE_KPIS }) })
try {
const geld = nodes(ui.tree(), (n) => n.tag === 'section' && n.props['aria-label'] === 'Geld')
assert.equal(geld.length, 1); assert.ok(hasClass(geld[0], 'hk-panel'))
const werte = byClass(geld[0], 'hk-kopf-wert')
assert.equal(werte.length, 2)
assert.equal(werte[0].props.title, 'Eingenommen pro Monat')
assert.equal(werte[1].props.title, `API-Kosten heute: 5,10${NBSP}$ von 6,00${NBSP}$`)
// Münzen
assert.deepEqual(classes(byClass(werte[0], 'hk-ic')[0]).slice(0, 2), ['hk-ic', 'hk-muenze-gruen'])
assert.deepEqual(classes(byClass(werte[1], 'hk-ic')[0]).slice(0, 2), ['hk-ic', 'hk-muenze-rot'])
for (const ic of byClass(geld[0], 'hk-ic')) assert.equal(ic.props['aria-hidden'], 'true')
// Beträge: Pixelschrift-Betrag mit ASCII-Minus, davor der Text nur für Screenreader
const gruen = wert(byClass(werte[0], 'hk-kopf-betrag')[0])
const rot = wert(byClass(werte[1], 'hk-kopf-betrag')[0])
assert.deepEqual(gruen, { sr: 'Eingenommen:', text: `+60${NBSP}€/m` })
assert.deepEqual(rot, { sr: 'API-Kosten heute:', text: `-5,10${NBSP}$` })
assert.ok(hasClass(byClass(werte[0], 'hk-kopf-betrag')[0], 'hk-kopf-gruen')); assert.ok(hasClass(byClass(werte[1], 'hk-kopf-betrag')[0], 'hk-kopf-rot'))
} finally { ui.end() }
})
test('Kopfzeile: Panel „Kennzahlen“ mit fünf Icons in Farbe, Titel, Screenreader-Text „Titel:“ und Zahl', async () => {
const ui = await kopf({ state: makeState({ kpis: VOLLE_KPIS }) })
try {
const panel = nodes(ui.tree(), (n) => n.tag === 'section' && n.props['aria-label'] === 'Kennzahlen')
assert.equal(panel.length, 1); assert.ok(hasClass(panel[0], 'hk-panel'))
const zahlen = byClass(panel[0], 'hk-kopf-zahl')
const erwartet = [
['hk-stern', 'Neue Abos (Monat)', '3', '#ffd25a'],
['hk-pokal', 'Deals (Monat)', '3', '#ff9f43'],
['hk-blase', 'Gespräche offen', '7', '#4fd1e8'],
['hk-glocke', 'Wartet auf dich', '5', '#ff6fae'],
['hk-robo', 'Bot-Leads gesamt', '112', '#a78bfa'],
]
assert.equal(zahlen.length, 5)
zahlen.forEach((z, i) => {
const [icon, titel, text, farbe] = erwartet[i]
assert.equal(z.props.title, titel); assert.equal(z.props.style.color, farbe)
const ic = nodes(z, (n) => n.tag === 'span' && hasClass(n, 'hk-ic'))[0]
assert.deepEqual(classes(ic).slice(0, 2), ['hk-ic', icon]); assert.equal(ic.props['aria-hidden'], 'true')
assert.equal(textOf(byClass(z, 'hk-kopf-sr')[0]), `${titel}:`)
assert.equal(textOf(z), `${titel}:${text}`)
})
} finally { ui.end() }
})
test('Kopfzeile: fehlende, leere und kaputte Werte ergeben Striche, nie NaN oder undefined', async () => {
const faelle = [
{ kpis: {} },
{ kpis: undefined },
{ kpis: null },
{ kpis: { mrrNewEur: 'viel', apiTodayUsd: NaN, dailyBudgetUsd: 'x', newAbosMonth: null, dealsMonth: Infinity, openThreads: undefined, pultOpen: '5', botLeads: {} } },
{ kpis: { mrrNewEur: -1, apiTodayUsd: -2 } },
]
for (const { kpis } of faelle) {
const state = { ...makeState(), kpis }
const ui = await kopf({ state })
try {
const text = textOf(ui.tree())
assert.ok(!/NaN|undefined|null|Infinity/.test(text), JSON.stringify(kpis))
const betraege = byClass(ui.tree(), 'hk-kopf-betrag').map((b) => wert(b).text)
assert.deepEqual(betraege, ['–', '–'], JSON.stringify(kpis))
const zahlen = byClass(ui.tree(), 'hk-kopf-zahl').map((z) => textOf(z.children[2]))
assert.deepEqual(zahlen, ['–', '–', '–', '–', '–'], JSON.stringify(kpis))
// ohne Betrag steht auch im Titel der Strich, nie ein erfundener Wert
assert.ok(byClass(ui.tree(), 'hk-kopf-wert')[1].props.title.includes('–'))
} finally { ui.end() }
}
})
test('Kopfzeile: Null ist ein Wert (0 Deals, 0,00 $), kein Strich; große Zahlen mit Tausenderpunkt', async () => {
const ui = await kopf({ state: makeState({ kpis: { mrrNewEur: 0, apiTodayUsd: 0, dailyBudgetUsd: 6, newAbosMonth: 0, dealsMonth: 0, openThreads: 1234, pultOpen: 0, botLeads: 20000 } }) })
try {
const betraege = byClass(ui.tree(), 'hk-kopf-betrag').map((b) => wert(b).text)
assert.deepEqual(betraege, [`+0${NBSP}€/m`, `0,00${NBSP}$`])
assert.deepEqual(byClass(ui.tree(), 'hk-kopf-zahl').map((z) => textOf(z.children[2])), ['0', '0', '1.234', '0', '20.000'])
} finally { ui.end() }
})
test('Kopfzeile: Budget-Stufen am roten Betrag – unter 80 % nichts, ab 80 % hk-api-warn, ab 100 % hk-api-neg; ohne Budget nichts', async () => {
const stufe = async (kpis) => {
const ui = await kopf({ state: makeState({ kpis }) })
try {
const rot = byClass(ui.tree(), 'hk-kopf-betrag')[1]
return { warn: hasClass(rot, 'hk-api-warn'), neg: hasClass(rot, 'hk-api-neg'), gruenHat: classes(byClass(ui.tree(), 'hk-kopf-betrag')[0]).some((k) => k.startsWith('hk-api-')) }
} finally { ui.end() }
}
assert.deepEqual(await stufe({ apiTodayUsd: 2, dailyBudgetUsd: 6 }), { warn: false, neg: false, gruenHat: false })
assert.deepEqual(await stufe({ apiTodayUsd: 4.79, dailyBudgetUsd: 6 }), { warn: false, neg: false, gruenHat: false })
assert.deepEqual(await stufe({ apiTodayUsd: 4.8, dailyBudgetUsd: 6 }), { warn: true, neg: false, gruenHat: false })
assert.deepEqual(await stufe({ apiTodayUsd: 5.1, dailyBudgetUsd: 6 }), { warn: true, neg: false, gruenHat: false })
assert.deepEqual(await stufe({ apiTodayUsd: 6, dailyBudgetUsd: 6 }), { warn: false, neg: true, gruenHat: false })
assert.deepEqual(await stufe({ apiTodayUsd: 9, dailyBudgetUsd: 6 }), { warn: false, neg: true, gruenHat: false })
assert.deepEqual(await stufe({ apiTodayUsd: 9, dailyBudgetUsd: undefined }), { warn: false, neg: false, gruenHat: false })
assert.deepEqual(await stufe({ apiTodayUsd: 9, dailyBudgetUsd: 0 }), { warn: false, neg: false, gruenHat: false })
})
// ---------- Pause ----------
test('Pause-Knopf: läuft -> „Alles anhalten“ mit Pause-Icon; angehalten -> „Fortsetzen“ mit Pfeil; type=button, data-hk-pause', async () => {
const ui = await kopf()
try {
let b = pauseButton(ui.tree())
assert.equal(b.props.type, 'button'); assert.equal(b.props['aria-label'], 'Alles anhalten'); assert.equal(b.props.title, 'Alles anhalten')
assert.ok(hasClass(b, 'hk-hud-knopf')); assert.equal(b.props.disabled, false)
assert.deepEqual(classes(b.children[0]), ['hk-ic', 'hk-pause']); assert.equal(b.children[0].props['aria-hidden'], 'true')
ui.set({ state: makeState({ paused: true }) })
b = pauseButton(ui.tree())
assert.equal(b.props['aria-label'], 'Fortsetzen'); assert.equal(b.props.title, 'Fortsetzen')
assert.deepEqual(classes(b.children[0]), ['hk-ic', 'hk-pfeil-rechts'])
} finally { ui.end() }
})
test('Pause: läuft -> „Alles anhalten“ fragt erst nach; „Anhalten“ ruft api.pause(true) und danach refresh, die Rückfrage verschwindet', async () => {
const ui = await kopf()
try {
assert.equal(byRole(ui.tree(), 'dialog').length, 0)
press(pauseButton(ui.tree()))
assert.equal(byRole(ui.tree(), 'dialog').length, 1)
assert.deepEqual(ui.log, []) // noch nichts passiert
press(buttonByText(ui.tree(), 'Anhalten'))
await settle()
assert.deepEqual(ui.log, ['pause:true', 'refresh'])
assert.equal(byRole(ui.tree(), 'dialog').length, 0)
assert.equal(pauseButton(ui.tree()).props.disabled, false) // wieder bedienbar
} finally { ui.end() }
})
test('Pause: der Dienst bekommt genau einen Zielwert und sonst nichts', async () => {
const aufrufe = []
const ui = await kopf({ api: { pause: async (...args) => { aufrufe.push(args) } } })
try {
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle()
assert.deepEqual(aufrufe, [[true]])
} finally { ui.end() }
})
test('Pause: „Abbrechen“ ruft den Dienst nicht, schließt die Rückfrage und gibt den Fokus an den Pause-Knopf zurück', async () => {
const ui = await kopf()
try {
const pauseEl = pauseButton(ui.tree()).props.ref.current
pauseEl.focus()
press(pauseButton(ui.tree()))
assert.notEqual(ui.document().activeElement, pauseEl) // der Fokus liegt im Dialog
press(buttonByText(ui.tree(), 'Abbrechen'))
await settle()
assert.deepEqual(ui.log, [])
assert.equal(byRole(ui.tree(), 'dialog').length, 0)
assert.equal(ui.document().activeElement, pauseEl)
} finally { ui.end() }
})
test('Pause: hatte der Pause-Knopf keinen Fokus (Safari fokussiert Knöpfe beim Klick nicht), geht er beim Schließen trotzdem an ihn', async () => {
const ui = await kopf()
try {
const pauseEl = pauseButton(ui.tree()).props.ref.current
assert.equal(ui.document().activeElement, ui.document().body)
press(pauseButton(ui.tree()))
assert.equal(byRole(ui.tree(), 'dialog').length, 1)
press(buttonByText(ui.tree(), 'Abbrechen'))
assert.equal(ui.document().activeElement, pauseEl)
} finally { ui.end() }
})
test('Pause: angehalten -> „Fortsetzen“ geht ohne Rückfrage sofort mit api.pause(false), danach refresh', async () => {
const ui = await kopf({ state: makeState({ paused: true }) })
try {
press(pauseButton(ui.tree()))
assert.equal(byRole(ui.tree(), 'dialog').length, 0) // keine Rückfrage
await settle()
assert.deepEqual(ui.log, ['pause:false', 'refresh'])
} finally { ui.end() }
})
test('Pause: die Rückfrage verschwindet, wenn inzwischen jemand anderes angehalten hat, und taucht nach dem Fortsetzen nicht von selbst wieder auf', async () => {
const ui = await kopf()
try {
press(pauseButton(ui.tree()))
assert.equal(byRole(ui.tree(), 'dialog').length, 1)
ui.set({ state: makeState({ paused: true }) })
assert.equal(byRole(ui.tree(), 'dialog').length, 0)
ui.set({ state: makeState({ paused: false }) })
assert.equal(byRole(ui.tree(), 'dialog').length, 0)
assert.deepEqual(ui.log, [])
} finally { ui.end() }
})
test('Pause: ein veralteter Dialog setzt nie fort – der Zielwert steht fest (true), egal was der Zustand inzwischen sagt', async () => {
const ui = await kopf()
try {
press(pauseButton(ui.tree()))
const anhalten = buttonByText(ui.tree(), 'Anhalten')
// der Dienst meldet erst „angehalten“, dann wieder „läuft“, während die Rückfrage noch als Knoten vorliegt
ui.set({ state: makeState({ paused: true }) })
ui.set({ state: makeState({ paused: false }) })
anhalten.props.onClick(event())
await settle()
assert.deepEqual(ui.log.filter((l) => l.startsWith('pause')), ['pause:true'])
} finally { ui.end() }
})
test('Pause: Fehler beim Umschalten zeigt die Fehlermeldung des Dienstes bzw. „Umschalten fehlgeschlagen.“, ohne refresh; der nächste Versuch löscht ihn', async () => {
// Antworten der Reihe nach: Error mit Text, Error ohne Text, ein geworfener Text; ist die Liste leer, gelingt der Aufruf
const antworten = [new Error('Dienst nicht erreichbar.'), new Error(''), 'text']
const ui = await kopf({ api: { pause: async (t) => { ui.log.push(`pause:${t}`); if (antworten.length > 0) throw antworten.shift() } } })
try {
// 1. Fehler mit Meldung
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle()
let alarm = byRole(ui.tree(), 'alert')
assert.equal(alarm.length, 1); assert.equal(textOf(alarm[0]), 'Dienst nicht erreichbar.'); assert.ok(hasClass(alarm[0], 'hk-meldung'))
assert.ok(!ui.log.includes('refresh'))
assert.equal(pauseButton(ui.tree()).props.disabled, false) // nach dem Fehler wieder bedienbar
// 2. Fehler ohne Meldung -> Standardtext (der alte verschwindet beim Start des neuen Versuchs)
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle()
alarm = byRole(ui.tree(), 'alert')
assert.equal(alarm.length, 1); assert.equal(textOf(alarm[0]), 'Umschalten fehlgeschlagen.')
// 3. Fehler, der kein Error ist (Text)
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle()
assert.equal(textOf(byRole(ui.tree(), 'alert')[0]), 'Umschalten fehlgeschlagen.')
// 4. Erfolg löscht die Meldung
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle()
assert.equal(byRole(ui.tree(), 'alert').length, 0)
assert.deepEqual(ui.log.filter((l) => l === 'refresh').length, 1)
} finally { ui.end() }
})
test('Pause: Fehlertext des Dienstes bleibt ein Textknoten (kein HTML)', async () => {
const ui = await kopf({ api: { pause: async () => { throw new Error('<img src=x onerror=alert(1)>') } } })
try {
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle()
const alarm = byRole(ui.tree(), 'alert')[0]
assert.deepEqual(alarm.children, ['<img src=x onerror=alert(1)>'])
} finally { ui.end() }
})
test('Pause: der Knopf ist während des Umschaltens gesperrt, ein zweiter Klick tut nichts; danach wieder frei', async () => {
const warten = deferred()
const ui = await kopf({ api: { pause: (t) => { ui.log.push(`pause:${t}`); return warten.promise } } })
try {
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten'))
assert.equal(pauseButton(ui.tree()).props.disabled, true)
assert.equal(press(pauseButton(ui.tree())), false)
// auch ein Klick, der trotzdem ankäme, startet nichts Zweites
pauseButton(ui.tree()).props.onClick(event())
assert.equal(byRole(ui.tree(), 'dialog').length, 1) // der Versuch öffnet höchstens die Frage …
press(buttonByText(ui.tree(), 'Anhalten')) // … deren Bestätigung aber nicht zum zweiten Aufruf führt
assert.deepEqual(ui.log, ['pause:true'])
warten.resolve()
await settle()
assert.equal(pauseButton(ui.tree()).props.disabled, false)
assert.deepEqual(ui.log, ['pause:true', 'refresh'])
} finally { ui.end() }
})
test('Pause: nach dem Umschalten bekommt der Pause-Knopf den Fokus zurück, wenn er ihn verlor (Fokus auf body); wer woanders ist, wird nicht gestört', async () => {
for (const woanders of [false, true]) {
const warten = deferred()
const ui = await kopf({ api: { pause: (t) => { ui.log.push(`pause:${t}`); return warten.promise } } })
try {
const pauseEl = pauseButton(ui.tree()).props.ref.current
pauseEl.focus()
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten'))
assert.equal(pauseEl.disabled, true)
// der gesperrte Knopf verliert den Fokus (Browser), die Rückfrage ist zu
const andere = { tag: 'input', isConnected: true, focus() { ui.document().activeElement = this } }
if (woanders) andere.focus(); else ui.document().body.focus()
warten.resolve(); await settle()
assert.equal(pauseEl.disabled, false)
assert.equal(ui.document().activeElement, woanders ? andere : pauseEl)
} finally { ui.end() }
}
})
test('Pause: nach einem Fehler bekommt der Pause-Knopf den Fokus ebenfalls zurück', async () => {
const warten = deferred()
const ui = await kopf({ state: makeState({ paused: true }), api: { pause: () => warten.promise } })
try {
const pauseEl = pauseButton(ui.tree()).props.ref.current
pauseEl.focus()
press(pauseButton(ui.tree())) // Fortsetzen
ui.document().body.focus()
warten.reject(new Error('weg')); await settle()
assert.equal(ui.document().activeElement, pauseEl)
assert.equal(textOf(byRole(ui.tree(), 'alert')[0]), 'weg')
} finally { ui.end() }
})
test('Pause: kommt die Antwort erst nach dem Abbau der Kopfzeile, ändert sie keinen Zustand mehr', async () => {
const warten = deferred()
const ui = await kopf({ state: makeState({ paused: true }), api: { pause: () => warten.promise } })
try {
press(pauseButton(ui.tree()))
ui.unmount()
warten.reject(new Error('zu spät')); await settle()
assert.equal(ui.late(), 0)
} finally { ui.end() }
})
// ---------- Rückfrage als Pixel-Dialog ----------
test('Rückfrage: Pixel-Dialog (hk-panel) mit role=dialog, aria-modal, beschrifteter Überschrift, Text und den Knöpfen „Abbrechen“ und „Anhalten“', async () => {
const ui = await kopf()
try {
press(pauseButton(ui.tree()))
const dialog = byRole(ui.tree(), 'dialog')
assert.equal(dialog.length, 1)
const d = dialog[0]
assert.ok(hasClass(d, 'hk-panel')); assert.equal(d.props['aria-modal'], 'true'); assert.equal(d.props.tabIndex, -1)
const titel = byClass(d, 'hk-rueckfrage-titel')[0]
assert.equal(textOf(titel), 'Alles anhalten?'); assert.equal(d.props['aria-labelledby'], titel.props.id)
assert.equal(textOf(byClass(d, 'hk-rueckfrage-text')[0]), 'Die Worker hören sofort auf, bis jemand fortsetzt.')
const knoepfe = buttons(d)
assert.deepEqual(knoepfe.map(textOf), ['Abbrechen', 'Anhalten'])
for (const k of knoepfe) { assert.equal(k.props.type, 'button'); assert.ok(hasClass(k, 'hk-knopf')) }
// der Hintergrund liegt um den Dialog
assert.equal(byClass(ui.tree(), 'hk-rueckfrage-hintergrund').length, 1)
assert.ok(nodes(byClass(ui.tree(), 'hk-rueckfrage-hintergrund')[0], (n) => n === d).length === 1)
} finally { ui.end() }
})
test('Rückfrage: der Fokus springt beim Öffnen auf „Abbrechen“ (die harmlose Wahl)', async () => {
const ui = await kopf()
try {
press(pauseButton(ui.tree()))
const abbrechen = buttonByText(ui.tree(), 'Abbrechen').props.ref.current
assert.ok(abbrechen)
assert.equal(ui.document().activeElement, abbrechen)
} finally { ui.end() }
})
test('Rückfrage: Esc schließt (und verhindert den Standard, damit nichts darunter mitschließt); ein schon behandeltes Esc tut nichts; andere Tasten auch nicht', async () => {
const ui = await kopf()
try {
press(pauseButton(ui.tree()))
const hintergrund = () => byClass(ui.tree(), 'hk-rueckfrage-hintergrund')[0]
hintergrund().props.onKeyDown(event({ key: 'Enter' }))
hintergrund().props.onKeyDown(event({ key: 'a' }))
assert.equal(byRole(ui.tree(), 'dialog').length, 1)
hintergrund().props.onKeyDown(event({ key: 'Escape', defaultPrevented: true }))
assert.equal(byRole(ui.tree(), 'dialog').length, 1)
const e = event({ key: 'Escape' })
hintergrund().props.onKeyDown(e)
assert.equal(e.defaultPrevented, true)
assert.equal(byRole(ui.tree(), 'dialog').length, 0)
assert.deepEqual(ui.log, [])
} finally { ui.end() }
})
test('Rückfrage: Tab bleibt im Dialog – vom letzten Knopf nach vorn zum ersten, mit Umschalt vom ersten zum letzten, von draußen hinein', async () => {
const ui = await kopf()
try {
press(pauseButton(ui.tree()))
const abbrechen = buttonByText(ui.tree(), 'Abbrechen').props.ref.current
const anhalten = buttonByText(ui.tree(), 'Anhalten').props.ref.current
const hintergrund = () => byClass(ui.tree(), 'hk-rueckfrage-hintergrund')[0]
const tab = (shift) => { const e = event({ key: 'Tab', shiftKey: shift }); hintergrund().props.onKeyDown(e); return e }
// Abbrechen -> Tab: normaler Weg zum nächsten Knopf (nicht abgefangen)
abbrechen.focus()
assert.equal(tab(false).defaultPrevented, false)
// Anhalten ist der letzte: Tab springt zurück zu Abbrechen
anhalten.focus()
assert.equal(tab(false).defaultPrevented, true); assert.equal(ui.document().activeElement, abbrechen)
// Abbrechen ist der erste: Umschalt+Tab springt zu Anhalten
assert.equal(tab(true).defaultPrevented, true); assert.equal(ui.document().activeElement, anhalten)
// Umschalt+Tab von Anhalten: normaler Weg
assert.equal(tab(true).defaultPrevented, false)
// Fokus außerhalb (body): Tab holt ihn in den Dialog, Umschalt+Tab auch
ui.document().body.focus()
assert.equal(tab(false).defaultPrevented, true); assert.equal(ui.document().activeElement, abbrechen)
ui.document().body.focus()
assert.equal(tab(true).defaultPrevented, true); assert.equal(ui.document().activeElement, anhalten)
// Fokus auf dem Dialog selbst (tabIndex -1): Umschalt+Tab geht zum letzten Knopf
byRole(ui.tree(), 'dialog')[0].props.ref.current.focus()
assert.equal(tab(true).defaultPrevented, true); assert.equal(ui.document().activeElement, anhalten)
} finally { ui.end() }
})
test('Rückfrage: ein Klick auf den Hintergrund nimmt dem Dialog den Fokus nicht und schließt nichts; ein Klick im Dialog wird nicht abgefangen', async () => {
const ui = await kopf()
try {
press(pauseButton(ui.tree()))
const hintergrund = byClass(ui.tree(), 'hk-rueckfrage-hintergrund')[0]
const aufHintergrund = event({ target: 'h', currentTarget: 'h' })
hintergrund.props.onMouseDown(aufHintergrund)
assert.equal(aufHintergrund.defaultPrevented, true)
const imDialog = event({ target: 'd', currentTarget: 'h' })
hintergrund.props.onMouseDown(imDialog)
assert.equal(imDialog.defaultPrevented, false)
assert.equal(byRole(ui.tree(), 'dialog').length, 1)
} finally { ui.end() }
})
// ---------- Einstellungen ----------
test('Zahnrad: nur für Admins; mit aria-label „Einstellungen“, Zahnrad-Icon und type=button', async () => {
const kein = await kopf({ isAdmin: false })
try {
assert.equal(settingsButton(kein.tree()), undefined)
assert.equal(byClass(kein.tree(), 'hk-zahnrad').length, 0)
} finally { kein.end() }
const admin = await kopf({ isAdmin: true })
try {
const b = settingsButton(admin.tree())
assert.equal(b.props.type, 'button'); assert.equal(b.props.title, 'Einstellungen'); assert.ok(hasClass(b, 'hk-hud-knopf'))
assert.deepEqual(classes(b.children[0]).slice(0, 2), ['hk-ic', 'hk-zahnrad'])
} finally { admin.end() }
})
test('Einstellungen: das Zahnrad öffnet den bestehenden Dialog in einer hk-woms-Hülle mit api, onClose und onSaved (-> refresh)', async () => {
const api = { pause: async () => {}, name: 'api-attrappe' }
const ui = await kopf({ isAdmin: true, api })
try {
assert.equal(ui.kb.settings.length, 0)
press(settingsButton(ui.tree()))
const huelle = byClass(ui.tree(), 'hk-woms')
assert.equal(huelle.length, 1)
assert.equal(nodes(huelle[0], (n) => n.props && n.props['data-attrappe'] === 'einstellungen').length, 1)
const props = ui.kb.settings.at(-1)
assert.equal(props.api, api)
// Speichern holt den Zustand neu
assert.equal(ui.log.filter((l) => l === 'refresh').length, 0)
props.onSaved()
assert.equal(ui.log.filter((l) => l === 'refresh').length, 1)
// Schließen
props.onClose()
assert.equal(byClass(ui.tree(), 'hk-woms').length, 0)
// und wieder öffnen
press(settingsButton(ui.tree()))
assert.equal(byClass(ui.tree(), 'hk-woms').length, 1)
} finally { ui.end() }
})
test('Einstellungen: verliert der Mensch die Admin-Rechte, verschwindet der offene Dialog', async () => {
const ui = await kopf({ isAdmin: true })
try {
press(settingsButton(ui.tree()))
assert.equal(byClass(ui.tree(), 'hk-woms').length, 1)
ui.set({ isAdmin: false })
assert.equal(byClass(ui.tree(), 'hk-woms').length, 0)
assert.equal(settingsButton(ui.tree()), undefined)
} finally { ui.end() }
})
// ---------- Meldungen ----------
test('Meldung „Angehalten“: nur wenn der Dienst angehalten ist, role=status, mit dem Satz aus dem Board', async () => {
const ui = await kopf()
try {
assert.equal(byClass(ui.tree(), 'hk-meldung').length, 0)
ui.set({ state: makeState({ paused: true }) })
const m = byClass(ui.tree(), 'hk-meldung')
assert.equal(m.length, 1); assert.equal(m[0].props.role, 'status'); assert.ok(hasClass(m[0], 'hk-meldung-angehalten'))
assert.equal(textOf(m[0]), 'Angehalten: Es wird nichts gesendet und nichts gebaut.')
} finally { ui.end() }
})
test('Meldung Notbremse: für alle sichtbar (auch ohne Admin), role=alert, Satz mit Grund, Grund als Textknoten', async () => {
const ui = await kopf({ isAdmin: false, state: makeState({ sendingHalted: true, sendingHaltedReason: 'Tageslimit erreicht' }) })
try {
const m = byRole(ui.tree(), 'alert')
assert.equal(m.length, 1); assert.ok(hasClass(m[0], 'hk-meldung-notbremse'))
assert.equal(textOf(m[0]), 'Versand gestoppt: Tageslimit erreicht. Ein Admin kann ihn in den Einstellungen freigeben.')
// der Grund steht als eigener Textknoten, kein Element darin
assert.ok(m[0].children.every((c) => typeof c === 'string'))
assert.ok(m[0].children.includes('Tageslimit erreicht'))
} finally { ui.end() }
})
test('Meldung Notbremse: Grund getrimmt, ohne abschließende Punkte (kein „..“); ohne Grund „Grund nicht angegeben“', async () => {
const faelle = [
['Limit erreicht.', 'Limit erreicht'],
['Limit erreicht...', 'Limit erreicht'],
[' Limit erreicht. . ', 'Limit erreicht'],
['Zu viele Fehler (3).', 'Zu viele Fehler (3)'],
['', 'Grund nicht angegeben'],
[' ', 'Grund nicht angegeben'],
['...', 'Grund nicht angegeben'],
[undefined, 'Grund nicht angegeben'],
[null, 'Grund nicht angegeben'],
[42, 'Grund nicht angegeben'],
[{ text: 'x' }, 'Grund nicht angegeben'],
]
for (const [grund, anzeige] of faelle) {
const state = makeState({ sendingHalted: true })
state.sendingHaltedReason = grund
const ui = await kopf({ state })
try {
const satz = textOf(byRole(ui.tree(), 'alert')[0])
assert.equal(satz, `Versand gestoppt: ${anzeige}. Ein Admin kann ihn in den Einstellungen freigeben.`, JSON.stringify(grund))
assert.ok(!satz.includes('..'), JSON.stringify(grund))
} finally { ui.end() }
}
// fehlendes Feld ganz
const state = makeState({ sendingHalted: true })
delete state.sendingHaltedReason
const ui = await kopf({ state })
try { assert.match(textOf(byRole(ui.tree(), 'alert')[0]), /^Versand gestoppt: Grund nicht angegeben\. Ein Admin/) } finally { ui.end() }
})
test('Meldung Notbremse: ein HTML-Grund bleibt Text; nur sendingHalted === true löst sie aus', async () => {
const ui = await kopf({ state: makeState({ sendingHalted: true, sendingHaltedReason: '<b>fett</b>' }) })
try {
assert.ok(byRole(ui.tree(), 'alert')[0].children.includes('<b>fett</b>'))
assert.equal(nodes(byRole(ui.tree(), 'alert')[0], (n) => typeof n.tag === 'string' && n.tag !== 'div').length, 0)
} finally { ui.end() }
for (const wert of [false, 'true', 1, null, undefined]) {
const state = makeState({ sendingHaltedReason: 'egal' })
state.sendingHalted = wert
const u = await kopf({ state })
try { assert.equal(byRole(u.tree(), 'alert').length, 0, String(wert)) } finally { u.end() }
}
})
test('Meldung „Verbindung gestört“: role=status mit dem Fehlertext als Textknoten, nur solange ein Fehler anliegt', async () => {
const ui = await kopf({ error: null })
try {
assert.equal(byClass(ui.tree(), 'hk-meldung').length, 0)
ui.set({ error: 'Zeitüberschreitung.' })
const m = byClass(ui.tree(), 'hk-meldung-verbindung')
assert.equal(m.length, 1); assert.equal(m[0].props.role, 'status')
assert.equal(textOf(m[0]), 'Verbindung gestört: Zeitüberschreitung. Es wird weiter versucht.')
ui.set({ error: '<i>x</i>' })
assert.ok(byClass(ui.tree(), 'hk-meldung-verbindung')[0].children.includes('<i>x</i>'))
ui.set({ error: '' })
assert.equal(byClass(ui.tree(), 'hk-meldung').length, 0)
} finally { ui.end() }
})
test('Meldungen: mehrere stehen untereinander im selben Behälter unter der Kopfzeile, in fester Reihenfolge', async () => {
const ui = await kopf({ state: makeState({ paused: true, sendingHalted: true, sendingHaltedReason: 'Limit' }), error: 'weg' })
try {
const behaelter = byClass(ui.tree(), 'hk-meldungen')
assert.equal(behaelter.length, 1)
const meldungen = behaelter[0].children.filter((c) => typeof c !== 'string')
assert.equal(meldungen.length, behaelter[0].children.length) // keine Leertexte dazwischen
assert.deepEqual(meldungen.map((m) => classes(m)[1]), ['hk-meldung-angehalten', 'hk-meldung-notbremse', 'hk-meldung-verbindung'])
assert.deepEqual(meldungen.map((m) => m.props.role), ['status', 'alert', 'status'])
// Behälter steht nach der Kopfzeile (darunter), beide in der Hülle hk-kopf
const huelle = byClass(ui.tree(), 'hk-kopf')[0]
assert.deepEqual(huelle.children.map((c) => classes(c)[0]), ['hk-kopf-zeile', 'hk-meldungen'])
} finally { ui.end() }
})
// ---------- Rechts: Ganzes Haus ----------
test('„Ganzes Haus zeigen“: Knopf nur für die Tastatur (hk-nur-tastatur), ruft onGanz; ohne onGanz kein Absturz', async () => {
const ui = await kopf()
try {
const b = hausButton(ui.tree())
assert.equal(b.props.type, 'button'); assert.ok(hasClass(b, 'hk-nur-tastatur'))
press(b)
assert.deepEqual(ui.log, ['ganz'])
press(b)
assert.deepEqual(ui.log, ['ganz', 'ganz'])
ui.set({ onGanz: undefined })
assert.doesNotThrow(() => press(hausButton(ui.tree())))
} finally { ui.end() }
})
test('Aufbau: Geld, Kennzahlen, Abstandhalter, Panel rechts in dieser Reihenfolge; Pause steht nach „Ganzes Haus zeigen“, das Zahnrad zuletzt', async () => {
const ui = await kopf({ isAdmin: true })
try {
const zeile = byClass(ui.tree(), 'hk-kopf-zeile')[0]
assert.deepEqual(zeile.children.map((c) => classes(c)[1] ?? classes(c)[0]), ['hk-kopf-geld', 'hk-kopf-zahlen', 'hk-kopf-abstand', 'hk-kopf-rechts'])
const rechts = byClass(zeile, 'hk-kopf-rechts')[0]
assert.ok(hasClass(rechts, 'hk-panel'))
assert.deepEqual(buttons(rechts).map((b) => b.props['aria-label'] ?? textOf(b)), ['Ganzes Haus zeigen', 'Alles anhalten', 'Einstellungen'])
} finally { ui.end() }
})
// ---------- Allgemeine Zusicherungen ----------
test('Alle Knöpfe sind type="button" – in jedem Zustand (Rückfrage offen, Einstellungen offen, Admin)', async () => {
const ui = await kopf({ isAdmin: true, state: makeState({ kpis: VOLLE_KPIS, sendingHalted: true, sendingHaltedReason: 'x' }), error: 'weg' })
try {
const pruefe = () => { for (const b of buttons(ui.tree())) assert.equal(b.props.type, 'button', textOf(b) || b.props['aria-label']) }
pruefe()
press(pauseButton(ui.tree())); pruefe()
assert.equal(buttons(ui.tree()).length, 5) // Ganzes Haus, Pause, Zahnrad, Abbrechen, Anhalten
press(buttonByText(ui.tree(), 'Abbrechen')); pruefe()
press(settingsButton(ui.tree())); pruefe()
ui.set({ state: makeState({ paused: true }) }); pruefe()
} finally { ui.end() }
})
test('Quelltext: kein innerHTML, kein CSS-/Bildimport, kein window.confirm, keine Links aus Dienst-Daten, Einstellungen unverändert eingebunden', () => {
const s = readFileSync(path.join(ROOT, 'src/components/workers/hochhaus/HochhausKopf.jsx'), 'utf8')
assert.doesNotMatch(s, /dangerouslySetInnerHTML|innerHTML/)
assert.doesNotMatch(s, /^\s*import\s+(?:[^'"\n]*\sfrom\s+)?['"][^'"]+\.(css|png|svg|jpe?g|webp|gif)['"]/m)
assert.doesNotMatch(s, /window\.confirm|\bconfirm\(/)
assert.doesNotMatch(s, /href=|<a[\s>]|<Link[\s>]/)
assert.doesNotMatch(s, /useAuth|appwrite/)
assert.match(s, /<div className="hk-woms">\s*<WorkersSettingsDialog api=\{api\} onClose=\{[^}]*\} onSaved=\{\(\) => refresh\(\)\} \/>\s*<\/div>/)
assert.match(s, /api\.pause\(target\)/); assert.doesNotMatch(s, /api\.pause\(!paused\)/)
// jedes <button> im Quelltext hat type="button"
assert.equal((s.match(/<button\b/g) || []).length, (s.match(/<button\b[^>]*\btype="button"/g) || []).length)
})
// ---------- CSS ----------
const cssText = (name) => readFileSync(path.join(ROOT, 'src/styles/hustler', name), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
const klassenIn = (css) => new Set((css.replace(/url\([^)]*\)/g, '').match(/\.hk-[A-Za-z0-9_-]+/g) ?? []).map((k) => k.slice(1)))
test('CSS: jede Klasse, die die Kopfzeile in allen Zuständen ausgibt, steht in hochhaus.css, hustler-kopf.css oder hustler-ui.css', async () => {
const bekannt = new Set([...klassenIn(cssText('hochhaus.css')), ...klassenIn(cssText('hustler-kopf.css')), ...klassenIn(cssText('hustler-ui.css'))])
const ui = await kopf({ isAdmin: true, state: makeState({ kpis: { ...VOLLE_KPIS, apiTodayUsd: 5.1 }, paused: false, sendingHalted: true, sendingHaltedReason: 'x' }), error: 'weg' })
try {
const gesehen = new Set()
const sammle = () => nodes(ui.tree(), (n) => typeof n.tag === 'string').forEach((n) => classes(n).forEach((k) => gesehen.add(k)))
sammle()
press(pauseButton(ui.tree())); sammle()
press(buttonByText(ui.tree(), 'Abbrechen'))
press(settingsButton(ui.tree())); sammle()
ui.set({ state: makeState({ kpis: { ...VOLLE_KPIS, apiTodayUsd: 7 }, paused: true }) }); sammle()
ui.set({ state: makeState({ kpis: {}, paused: true }) })
// Fehlermeldung des Umschaltens
ui.set({ api: { pause: async () => { throw new Error('x') } } })
press(pauseButton(ui.tree())); await settle(); sammle()
for (const k of gesehen) assert.ok(k.startsWith('hk-'), `fremde Klasse ${k}`)
for (const k of gesehen) assert.ok(bekannt.has(k), `Klasse ${k} fehlt im CSS`)
for (const k of ['hk-api-warn', 'hk-api-neg', 'hk-meldung-angehalten', 'hk-meldung-umschalten', 'hk-meldung-notbremse', 'hk-meldung-verbindung', 'hk-rueckfrage', 'hk-pfeil-rechts', 'hk-pause', 'hk-zahnrad']) assert.ok(gesehen.has(k), k)
} finally { ui.end() }
})
test('CSS hustler-kopf.css: nur hk-Klassen unter .hk-hochhaus, Keyframes hk-kopf-…, keine Bilder/Importe, Handy-Regeln unter 720 px, weniger Bewegung', () => {
const css = cssText('hustler-kopf.css')
assert.ok(klassenIn(css).size > 20)
assert.ok(!/\.(?!hk-)[A-Za-z_-][\w-]*/.test(css.replace(/url\([^)]*\)/g, '').replace(/\d+\.\d+/g, '').replace(/['"][^'"]*['"]/g, '')), 'Klasse ohne hk-')
assert.doesNotMatch(css, /url\(|@import|@font-face|\/_blob\//)
// Selektoren: jede Regel (außer in @keyframes) beginnt mit .hk-hochhaus
const vorspann = []
let tiefe = 0
const stapel = []
let aktuell = ''
for (const c of css) {
if (c === '{') {
const p = aktuell.trim(); aktuell = ''
stapel.push(p)
if (!stapel.slice(0, -1).some((x) => x.startsWith('@keyframes'))) vorspann.push(p)
tiefe += 1
} else if (c === '}') { stapel.pop(); tiefe -= 1; aktuell = '' } else if (c === ';') aktuell = ''
else aktuell += c
}
assert.equal(tiefe, 0)
const regeln = vorspann.filter((p) => !p.startsWith('@'))
assert.ok(regeln.length > 30)
for (const p of regeln) for (const teil of p.split(',')) assert.match(teil.trim(), /^\.hk-hochhaus\s/, `Selektor: ${teil.trim()}`)
const keyframes = [...css.matchAll(/@keyframes\s+([\w-]+)/g)].map((m) => m[1])
assert.ok(keyframes.length >= 1)
for (const k of keyframes) assert.match(k, /^hk-kopf-/)
for (const m of css.matchAll(/animation:\s*([\w-]+)/g)) assert.ok(keyframes.includes(m[1]) || m[1] === 'none', m[1])
assert.match(css, /@media \(max-width: 719px\)/)
assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.hk-api-warn\s*\{\s*animation:\s*none/)
// Meldungen sind nur Text (Dekor per ::before), die Hülle fängt keine Zeigerereignisse ab
assert.match(css, /\.hk-hochhaus \.hk-kopf \{[^}]*pointer-events: none/)
assert.match(css, /\.hk-hochhaus \.hk-kopf-geld \{[^}]*pointer-events: auto/)
})