807 lines
43 KiB
JavaScript
807 lines
43 KiB
JavaScript
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\)/)
|
||
// 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/)
|
||
})
|