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('') } } }) try { press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle() const alarm = byRole(ui.tree(), 'alert')[0] assert.deepEqual(alarm.children, ['']) } 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: 'fett' }) }) try { assert.ok(byRole(ui.tree(), 'alert')[0].children.includes('fett')) 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: 'x' }) assert.ok(byClass(ui.tree(), 'hk-meldung-verbindung')[0].children.includes('x')) 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=|]|]/) assert.doesNotMatch(s, /useAuth|appwrite/) assert.match(s, /\s* refresh\(\)\} \/>\s*<\/div>/) assert.match(s, /api\.pause\(target\)/); assert.doesNotMatch(s, /api\.pause\(!paused\)/) // jedes im Quelltext hat type="button" assert.equal((s.match(/]*\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\)/) // Handy (T12): hochkant genau zwei Reihen – Geld und rechtes Panel in Reihe 1 (order 1/2), die fünf Kennzahlen als eigene Reihe 2 (order 3, volle Breite); // kompakte Panels (schmaler Rand) und Knöpfe von 40 px auch im Querformat unter 500 px Höhe assert.match(css, /@media \(max-width: 719px\) \{[^@]*\.hk-hochhaus \.hk-kopf-geld \{[^}]*order: 1;/) assert.match(css, /@media \(max-width: 719px\) \{[^@]*\.hk-hochhaus \.hk-kopf-rechts \{[^}]*order: 2;/) assert.match(css, /@media \(max-width: 719px\) \{[^@]*\.hk-hochhaus \.hk-kopf-zahlen \{[^}]*order: 3;[^}]*flex: 1 1 100%;[^}]*flex-wrap: nowrap;/) assert.match(css, /@media \(max-width: 719px\), \(max-height: 500px\) \{[^@]*\.hk-hochhaus \.hk-kopf \.hk-panel \{[^}]*border-width: 8px;/) assert.match(css, /@media \(max-width: 719px\), \(max-height: 500px\) \{[^@]*\.hk-hochhaus \.hk-hud-knopf \{[^}]*width: 40px;[^}]*height: 40px;/) 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/) }) test('Rückfrage liegt am Handy unter der WOMS-Schublade: z-index ≤ 9 (Seitenleiste 20, Scrim 15 in global.css; Endprüfung R4)', () => { const css = cssText('hustler-kopf.css') const regel = css.match(/\.hk-rueckfrage-hintergrund\s*\{([^}]*)\}/) assert.ok(regel, 'Regel .hk-rueckfrage-hintergrund fehlt') const z = Number((regel[1].match(/z-index:\s*(\d+)/) || [])[1]) assert.ok(Number.isInteger(z) && z <= 9, `z-index ${z}`) }) test('Angehalten: die Kopfzeile trägt hk-kopf-steht und ihre Symbole ruhen; Warnrot der Meldungen ist #c8372d (Weiß darauf ≥ 4,5:1)', () => { const s = readFileSync(path.join(ROOT, 'src/components/workers/hochhaus/HochhausKopf.jsx'), 'utf8') assert.match(s, /className=\{paused \? 'hk-kopf hk-kopf-steht' : 'hk-kopf'\}/) const css = cssText('hustler-kopf.css') assert.match(css, /\.hk-hochhaus \.hk-kopf-steht \.hk-ic \{ animation-play-state: paused; \}/) assert.match(css, /\.hk-meldung-notbremse \{ --hk-ton: #c8372d; \}/) assert.doesNotMatch(css, /#e2483d/) })