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

689 lines
45 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 fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'
import { fakeReact } from './helpers/fakeReact.mjs'
import { loadModule } from './load.mjs'
// Dein Pult (Hustler-Pixelwelt T9, Spec E11/E13/E14): die reine Logik (src/lib/hustlerPult.js) und die Bauteile PultList, PultSzene, Klemmbrett, Brief im Mini-Läufer
// anstelle von React (helpers/fakeReact.mjs). Die Aktionen des Briefs (Antworten, Freigeben, Bauen, Einfügen, Fokus nach Aktionen) prüft workersPultUi.test.mjs;
// hier stehen Umschlag-Inhalt je Art und Status, Zahlen an den Reitern, Brief öffnen/schließen/Fokus, Boten nur bei wartenden Einträgen, „Pult zuklappen“, „◂ Hochhaus“,
// der Weg zum Interessenten-Panel, die Klassen gegen das CSS und die Regeln der Architektur (kein CSS-/Bild-Import, kein innerHTML, sichere Links).
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8')
const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
const settle = () => new Promise((resolve) => setImmediate(resolve))
const L = await loadModule('src/lib/hustlerPult.js')
const P = await loadModule('src/lib/workersPult.js')
const { PULT_KIND_LABELS, PULT_FILTERS, rowModel } = P
const NOW = Date.now() // die Bauteile rechnen mit der Uhr des Rechners: Alter und Fristen hängen deshalb an dieser Zeit
const iso = (ms) => new Date(ms).toISOString()
const ME = { id: 'u1', name: 'Justin' }
const EMPLOYEES = [{ userId: 'u2', displayName: 'Kenso' }]
const rowOf = (item) => rowModel(item, { me: ME, nowMs: NOW, employees: EMPLOYEES })
const entry = (extra = {}) => ({ $id: 'a', kind: 'plattform_antwort', status: 'offen', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(NOW - 3 * 3600e3), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein', city: 'Namborn', postalCode: '66640', score: 72, email: 'x@verein.example' }, ...extra })
// ---------------------------------------------------------------------------------------------------------------
// Reine Logik
test('szenePassung: „cover“ wie bindeRoot/passe der Vorlage, unbrauchbare Flächen ergeben null', () => {
const at = (w, h) => { const p = L.szenePassung(w, h); return p && [p.s, p.x, p.y, p.transform] }
assert.deepEqual(at(1280, 720), [2, 0, 0, 'translate(0.0px, 0.0px) scale(2.0000)'])
assert.deepEqual(at(1920, 1080), [3, 0, 0, 'translate(0.0px, 0.0px) scale(3.0000)'])
// breiter als hoch: die Szene ist höher als die Fläche, unten bleibt der Raum sichtbar (y = Überstand + 36 s, hier der kleinere von Überstand / 2 und Überstand + 36 s)
assert.deepEqual(at(2560, 1080), [4, 0, -216, 'translate(0.0px, -216.0px) scale(4.0000)'])
// höher als breit: die Szene ist breiter als die Fläche und rutscht um 15 % des Überstands nach links
assert.deepEqual(at(800, 900), [2.5, -120, 0, 'translate(-120.0px, 0.0px) scale(2.5000)'])
for (const [w, h] of [[0, 100], [100, 0], [-1, 5], [NaN, 5], [5, Infinity], ['5', 5], [undefined, undefined]]) assert.equal(L.szenePassung(w, h), null, `${w} x ${h}`)
assert.deepEqual({ ...L.SZENE }, { w: 640, h: 360 })
assert.equal(L.SZENE_START.transform, 'translate(0.0px, 0.0px) scale(2.0000)')
})
test('botenLaufen: nur bei kpis.pultOpen > 0 (Zahl), sonst ruht der Raum', () => {
assert.equal(L.botenLaufen({ pultOpen: 1 }), true)
assert.equal(L.botenLaufen({ pultOpen: 12 }), true)
for (const kpis of [undefined, null, {}, { pultOpen: 0 }, { pultOpen: -1 }, { pultOpen: '3' }, { pultOpen: NaN }, { pultOpen: null }, [], 'x']) assert.equal(L.botenLaufen(kpis), false, JSON.stringify(kpis))
})
test('Zahlen an den Reitern: „Wartet“ aus kpis.pultOpen, am offenen Reiter die Länge der Liste, die übrigen Reiter ohne Zahl', () => {
const z = (id, o) => L.reiterZahl(id, o)
// Wartet zeigt kpis.pultOpen (auch am offenen Reiter); ohne Zahl vom Dienst am offenen Reiter die Länge der Liste
assert.equal(z('offen', { aktivId: 'offen', kpis: { pultOpen: 5 }, geladen: 2 }), 5)
assert.equal(z('offen', { aktivId: 'offen', kpis: { pultOpen: 5 }, geladen: null }), 5)
assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: 5 }, geladen: 9 }), 5)
assert.equal(z('offen', { aktivId: 'veraltet', kpis: {}, geladen: 9 }), null)
assert.equal(z('offen', { aktivId: 'offen', kpis: undefined, geladen: 4 }), 4)
assert.equal(z('offen', { aktivId: 'offen', kpis: undefined, geladen: null }), null)
// Veraltet, Erledigt: nur wenn offen
assert.equal(z('veraltet', { aktivId: 'veraltet', kpis: { pultOpen: 5 }, geladen: 3 }), 3)
assert.equal(z('erledigt', { aktivId: 'veraltet', kpis: { pultOpen: 5 }, geladen: 3 }), null)
assert.equal(z('veraltet', { aktivId: 'offen', kpis: { pultOpen: 5 }, geladen: 3 }), null)
assert.equal(z('veraltet', { aktivId: 'veraltet', kpis: {}, geladen: null }), null)
// kaputte Zahlen sind keine Zahlen
assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: '5' }, geladen: 3 }), null)
assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: -2 }, geladen: 3 }), null)
assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: 0 }, geladen: 3 }), 0)
assert.equal(L.wartetZahl({ pultOpen: 3 }, 'erledigt', null), 3)
assert.equal(L.wartetZahl({}, 'offen', 7), 7)
assert.equal(L.wartetZahl({}, 'veraltet', 7), null)
assert.equal(L.reiterName('Wartet', 1), 'Wartet, 1 Eintrag')
assert.equal(L.reiterName('Wartet', 0), 'Wartet, 0 Einträge')
assert.equal(L.reiterName('Erledigt', 12), 'Erledigt, 12 Einträge')
assert.equal(L.reiterName('Veraltet', null), 'Veraltet')
assert.deepEqual(Object.keys(L.REITER_KLASSE), PULT_FILTERS.map((f) => f.id))
assert.deepEqual(Object.keys(L.UNTERTITEL), PULT_FILTERS.map((f) => f.id)); assert.deepEqual(Object.keys(L.LEER), PULT_FILTERS.map((f) => f.id))
})
test('artView: jede Pult-Art des Dienstes hat Name und Icon; Namen wie PULT_KIND_LABELS, unbekannte Art bekommt das Fragezeichen', () => {
assert.deepEqual(Object.keys(L.ART_ICONS).sort(), Object.keys(PULT_KIND_LABELS).sort())
for (const [kind, label] of Object.entries(PULT_KIND_LABELS)) assert.deepEqual({ ...L.artView(kind) }, { label, icon: L.ART_ICONS[kind] }, kind)
assert.deepEqual({ ...L.artView('neu_vom_dienst') }, { label: 'neu_vom_dienst', icon: 'art-unsicher' })
// jedes Icon gibt es im erzeugten CSS
const css = read('src/styles/hustler/pult.css')
for (const icon of Object.values(L.ART_ICONS)) assert.ok(css.includes(`.hk-ic-${icon}{`), icon)
})
test('umschlagModell: Inhalt je Art und Status – Titel, Alter, Ort · Score, Frist, veraltet, Halter, Ergebnis-Stempel, zugänglicher Name', () => {
const m = (extra) => L.umschlagModell(rowOf(entry(extra)))
// offene Plattform-Antwort: Art, Alter, Ort · Score, nichts sonst
let u = m()
assert.deepEqual({ ...u, fertig: u.fertig }, {
id: 'a', artLabel: 'Antwort auf Plattform-Anfrage', icon: 'art-plattform', titel: 'Suche Webdesigner', alter: 'vor 3 Std.', info: '66640 Namborn · Score 72',
frist: '', veraltet: false, gehalten: '', fertig: null, label: 'Antwort auf Plattform-Anfrage: Suche Webdesigner, vor 3 Std.',
})
// jede Art zeigt ihr Icon
for (const kind of Object.keys(PULT_KIND_LABELS)) assert.equal(m({ kind }).icon, L.ART_ICONS[kind], kind)
// läuft bald ab (< 24 h), veraltet (Status), veraltet durch staleAt
u = m({ staleAt: iso(NOW + 5 * 3600e3) })
assert.equal(u.frist, 'läuft in 5 Std. ab'); assert.equal(u.veraltet, false); assert.ok(u.label.endsWith('vor 3 Std., läuft in 5 Std. ab'))
u = m({ status: 'veraltet' })
assert.equal(u.veraltet, true); assert.equal(u.frist, ''); assert.ok(u.label.endsWith(', veraltet'))
assert.equal(m({ staleAt: iso(NOW - 1000) }).veraltet, true)
assert.equal(m({ staleAt: iso(NOW + 3 * 24 * 3600e3) }).frist, '')
// übernommen: von mir „Du kümmerst dich darum.“, von einem Kollegen mit Namen, von Max automatisch, unbekannt „Kollege“
u = m({ status: 'in_arbeit', claimedBy: 'mensch:u1' })
assert.equal(u.gehalten, 'Du kümmerst dich darum.'); assert.ok(u.label.endsWith(', du kümmerst dich darum'))
u = m({ status: 'in_arbeit', claimedBy: 'mensch:u2' })
assert.equal(u.gehalten, 'übernommen von Kenso'); assert.ok(u.label.endsWith(', übernommen von Kenso'))
assert.equal(m({ status: 'in_arbeit', claimedBy: 'max' }).gehalten, 'übernommen von Max (automatisch)')
assert.equal(m({ status: 'in_arbeit', claimedBy: 'mensch:u9' }).gehalten, 'übernommen von Kollege')
// bei veraltet hält niemand mehr, auch wenn der frühere Bearbeiter noch drinsteht
assert.equal(m({ status: 'veraltet', claimedBy: 'mensch:u2' }).gehalten, '')
// abgeschlossen: Ergebnis-Stempel statt Alter ab Anlage (gesendet grün, die übrigen grau)
const done = { createdAt: iso(NOW - 4 * 24 * 3600e3), doneBy: 'mensch:u2', doneAt: iso(NOW - 2 * 3600e3) }
u = m({ ...done, status: 'gesendet' })
assert.deepEqual({ ...u.fertig }, { text: 'Gesendet von Kenso · vor 2 Std.', klasse: 'hk-abdruck hk-stempel-gruen' }); assert.equal(u.alter, '')
assert.equal(u.label, 'Antwort auf Plattform-Anfrage: Suche Webdesigner, Gesendet von Kenso · vor 2 Std.')
assert.deepEqual({ ...m({ ...done, status: 'verworfen' }).fertig }, { text: 'Verworfen von Kenso · vor 2 Std.', klasse: 'hk-abdruck hk-stempel-grau' })
assert.deepEqual({ ...m({ ...done, status: 'erledigt', doneBy: 'max' }).fertig }, { text: 'Erledigt von Max (automatisch) · vor 2 Std.', klasse: 'hk-abdruck hk-stempel-grau' })
assert.equal(m({ ...done, status: 'erledigt', doneBy: undefined }).fertig.text, 'Erledigt · vor 2 Std.')
// ohne Interessent keine Kurzinfo, ohne Titel nur die Art im Namen, ohne Alter (kaputte Zeit) kein Alter
assert.equal(m({ prospect: null }).info, '')
assert.equal(m({ title: ' ' }).label, 'Antwort auf Plattform-Anfrage, vor 3 Std.')
assert.equal(m({ createdAt: 'kaputt' }).alter, '')
assert.equal(L.prospectInfo({ city: 'Worms' }), 'Worms'); assert.equal(L.prospectInfo({ score: 0 }), 'Score 0'); assert.equal(L.prospectInfo(null), '')
})
// ---------------------------------------------------------------------------------------------------------------
// Bauteile im Mini-Läufer
// Was das Pult von außen holt: Feed (je Filter eine Antwort), Mitarbeiterliste, Zwischenablage, das Interessenten-Panel (hält seine Eigenschaften fest)
const STUBS = {
usePultFeed: 'export function usePultFeed({ statuses } = {}) { return globalThis.__pultFeedFor(statuses) }',
useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }',
CopyableCredential: 'export async function copyTextChecked() {}',
ProspectPanel: "import { jsx } from 'react'\nexport default function ProspectPanel(props) { globalThis.__panel = props; return jsx('div', { 'data-panel': props.prospectId }) }",
}
const stubs = {
name: 'pult-stubs',
setup(build) {
build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$|\/ProspectPanel$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-stub' }))
build.onLoad({ filter: /.*/, namespace: 'pult-stub' }, (a) => ({ contents: STUBS[a.path], loader: 'js' }))
},
}
const loadUi = () => esbuild.build({
stdin: {
contents: "export { __mount, jsx } from 'react'\nexport { default as PultList } from './src/components/workers/PultList.jsx'\nexport { default as PultSzene } from './src/components/workers/pult/PultSzene.jsx'",
resolveDir: ROOT, loader: 'js',
},
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact, stubs],
}).then(importBundle)
const nodes = (node, test, out = []) => {
if (node === null || typeof node === 'string') return out
if (test(node)) out.push(node)
for (const child of node.children) nodes(child, test, out)
return out
}
const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join(''))
const hasClass = (n, name) => n.tag !== undefined && String(n.props.className ?? '').split(' ').includes(name)
const byClass = (tree, name) => nodes(tree, (n) => typeof n.tag === 'string' && hasClass(n, name))
const btn = (tree, label) => nodes(tree, (n) => (n.tag === 'button' || n.tag === 'a') && textOf(n).trim() === label)
const classesIn = (tree, into = new Set()) => {
for (const n of nodes(tree, (x) => typeof x.tag === 'string')) for (const c of String(n.props.className ?? '').split(' ')) if (c) into.add(c)
return into
}
function fakeFocus() {
const body = { name: 'body' }
const doc = { body, documentElement: { name: 'html' }, activeElement: body }
const f = { doc, body, found: new Map(), made: new Map(), all: [] }
f.el = (name, extra = {}) => ({
name, isConnected: true, disabled: false, focusCalls: 0, focus() { this.focusCalls += 1; doc.activeElement = this },
querySelector: (selector) => f.found.get(selector) ?? null, querySelectorAll: () => f.all, ...extra,
})
f.ref = (key) => f.made.get(key)
const had = Object.hasOwn(globalThis, 'document')
const saved = globalThis.document
globalThis.document = doc
globalThis.__fakeRef = (tag, props) => {
const key = String(props.className ?? tag).split(' ')[0]
if (!f.made.has(key)) f.made.set(key, f.el(key))
return f.made.get(key)
}
f.restore = () => {
if (had) globalThis.document = saved
else delete globalThis.document
delete globalThis.__fakeRef
}
return f
}
// Ein Lauf des Pults: items gelten für jeden Filter (der Test steuert über die Liste, was sichtbar ist); opts: kpis, onBack, api
async function startPult(items, { api = { pultAction: async () => ({}) }, kpis, onBack, feed = {} } = {}) {
const { __mount, jsx, PultList } = await loadUi()
const timers = new Map()
let nextTimer = 1
const refreshes = []
const saved = { window: globalThis.window }
globalThis.window = {
setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id },
clearTimeout: (id) => { timers.delete(id) },
open: () => {},
}
globalThis.__pultEmployees = EMPLOYEES
globalThis.__pultFeedFor = (statuses) => ({ items: feed.byStatus ? (feed.byStatus[statuses] ?? []) : items, loading: false, error: null, refresh: async () => { refreshes.push(statuses) }, ...feed.extra })
delete globalThis.__panel
const mounted = __mount(jsx(PultList, { api, me: ME, kpis, onBack }))
const view = {
timers, refreshes,
tree: () => mounted.tree(),
brief: () => nodes(view.tree(), (n) => n.tag === 'section' && hasClass(n, 'hk-brief'))[0] ?? null,
envelopes: () => byClass(view.tree(), 'hk-umschlag'),
async open(index = 0, currentTarget) {
const e = view.envelopes()[index]
assert.ok(e, `Umschlag ${index}`)
e.props.onClick({ preventDefault() {}, currentTarget })
await settle()
},
async press(node) { node.props.onClick?.({ preventDefault() {} }); await settle() },
async briefKey(key, extra = {}) {
const event = { key, defaultPrevented: false, stopped: false, preventDefault() { this.defaultPrevented = true }, stopPropagation() { this.stopped = true }, ...extra }
view.brief().props.onKeyDown(event)
await settle()
return event
},
close: () => view.press(nodes(view.brief(), (n) => n.props['aria-label'] === 'Schließen')[0]),
tab: async (label) => { await view.press(nodes(byClass(view.tree(), 'hk-reiter-leiste')[0], (n) => n.tag === 'button' && textOf(n).startsWith(label))[0]) },
end() { mounted.unmount(); globalThis.window = saved.window; delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees; delete globalThis.__panel },
}
await settle()
return view
}
const ALLE_ARTEN = Object.keys(PULT_KIND_LABELS).map((kind, i) => entry({ $id: `k${i}`, kind, title: `Titel ${kind}`, draft: 'Entwurf', originalText: 'Anfrage des Kunden', sourceUrl: kind === 'vorschau_freigabe' ? 'https://entwurf.project.webklar.com/AAA/' : 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospectId: 'p1' }))
test('Pult: die Liste zeigt je Eintrag einen Umschlag mit Art-Icon, Titel, Art · Alter, Ort · Score und den Marken des Zustands', async () => {
const items = [
entry({ $id: 'a' }),
entry({ $id: 'b', kind: 'mail_antwort', title: 'Zweite', status: 'veraltet' }),
entry({ $id: 'c', kind: 'unsicher', title: 'Dritte', status: 'in_arbeit', claimedBy: 'mensch:u2', staleAt: iso(Date.now() + 5 * 3600e3) }),
entry({ $id: 'd', kind: 'preisfrage', title: 'Vierte', status: 'in_arbeit', claimedBy: 'mensch:u1' }),
entry({ $id: 'e', kind: 'beschwerde', title: 'Fünfte', status: 'gesendet', doneBy: 'mensch:u2', doneAt: iso(Date.now() - 2 * 3600e3) }),
]
const ui = await startPult(items)
try {
const rows = ui.envelopes()
assert.equal(rows.length, 5)
const sig = (n) => ({ icon: byClass(n, 'hk-ic')[0].props.className, titel: textOf(byClass(n, 'hk-umschlag-titel')[0]), artAlter: textOf(byClass(n, 'hk-umschlag-art-alter')[0]), marken: byClass(n, 'hk-marke').map(textOf), stempel: byClass(n, 'hk-abdruck').map(textOf), info: byClass(n, 'hk-klein-inline').map(textOf) })
assert.deepEqual(sig(rows[0]), { icon: 'hk-ic hk-ic-art-plattform hk-umschlag-art', titel: 'Suche Webdesigner', artAlter: 'Antwort auf Plattform-Anfrage · vor 3 Std.', marken: [], stempel: [], info: ['66640 Namborn · Score 72'] })
assert.deepEqual([sig(rows[1]).icon, sig(rows[1]).marken], ['hk-ic hk-ic-art-mail hk-umschlag-art', ['veraltet']])
assert.deepEqual([sig(rows[2]).icon, sig(rows[2]).marken], ['hk-ic hk-ic-art-unsicher hk-umschlag-art', ['läuft in 5 Std. ab', 'übernommen von Kenso']])
assert.deepEqual([sig(rows[3]).icon, sig(rows[3]).marken], ['hk-ic hk-ic-art-preisfrage hk-umschlag-art', ['Du kümmerst dich darum.']])
assert.deepEqual([sig(rows[4]).icon, sig(rows[4]).marken, sig(rows[4]).stempel, sig(rows[4]).artAlter], ['hk-ic hk-ic-art-beschwerde hk-umschlag-art', [], ['Gesendet von Kenso · vor 2 Std.'], 'Beschwerde'])
// Knopf mit Namen, Fenster-Hinweis und ohne eigenen Fokus-Trick: ein Klick öffnet, sonst nichts
assert.ok(rows.every((r) => r.tag === 'button' && r.props.type === 'button' && r.props['aria-haspopup'] === 'dialog'))
assert.equal(rows[2].props['aria-label'], 'Unsicher – bitte prüfen: Dritte, vor 3 Std., läuft in 5 Std. ab, übernommen von Kenso')
assert.equal(ui.brief(), null)
// „von <Hustler>“ gibt es nicht: ein Pult-Eintrag nennt keinen Hustler
assert.ok(!ui.tree() || !textOf(ui.tree()).includes('Gebracht von'))
} finally { ui.end() }
})
test('Pult: Zahlen an den Reitern und im Kopf – Wartet aus kpis.pultOpen, am offenen Reiter die Länge der Liste, die anderen ohne Zahl', async () => {
const wartet = [entry({ $id: 'a' }), entry({ $id: 'b' })]
const byStatus = { 'offen,in_arbeit': wartet, veraltet: [entry({ $id: 'v', status: 'veraltet' })], 'gesendet,verworfen,erledigt': [entry({ $id: 'x', status: 'gesendet' }), entry({ $id: 'y', status: 'gesendet' }), entry({ $id: 'z', status: 'gesendet' })] }
const ui = await startPult([], { kpis: { pultOpen: 5 }, feed: { byStatus } })
try {
const tabs = () => byClass(ui.tree(), 'hk-reiter').map((t) => ({ text: textOf(t), name: t.props['aria-label'], an: t.props['aria-pressed'] }))
const kopf = () => textOf(byClass(ui.tree(), 'hk-pult-zahl')[0] ?? { children: [] })
// Wartet ist offen: die Liste hat 2 Einträge, Reiter und Kopf zeigen die Zahl des Dienstes (wie der Briefkasten im Haus)
assert.deepEqual(tabs(), [
{ text: 'Wartet5', name: 'Wartet, 5 Einträge', an: true }, { text: 'Veraltet', name: undefined, an: false }, { text: 'Erledigt', name: undefined, an: false },
])
assert.equal(kopf(), 'Wartet auf dich:5')
await ui.tab('Veraltet')
assert.deepEqual(tabs(), [
{ text: 'Wartet5', name: 'Wartet, 5 Einträge', an: false }, { text: 'Veraltet1', name: 'Veraltet, 1 Eintrag', an: true }, { text: 'Erledigt', name: undefined, an: false },
])
await ui.tab('Erledigt')
assert.deepEqual(tabs().map((t) => t.text), ['Wartet5', 'Veraltet', 'Erledigt3'])
assert.equal(kopf(), 'Wartet auf dich:5')
} finally { ui.end() }
// ohne Zahl vom Dienst: nur der offene Reiter „Wartet“ kennt seine Zahl (die Länge der Liste), im Kopf ebenso; in anderen Reitern fehlt sie überall
const ohne = await startPult([], { kpis: undefined, feed: { byStatus } })
try {
assert.deepEqual(byClass(ohne.tree(), 'hk-reiter').map(textOf), ['Wartet2', 'Veraltet', 'Erledigt'])
assert.equal(textOf(byClass(ohne.tree(), 'hk-pult-zahl')[0]), 'Wartet auf dich:2')
await ohne.tab('Veraltet')
assert.deepEqual(byClass(ohne.tree(), 'hk-reiter').map(textOf), ['Wartet', 'Veraltet1', 'Erledigt'])
assert.equal(byClass(ohne.tree(), 'hk-pult-zahl').length, 0)
} finally { ohne.end() }
// während des Ladens und bei einem Fehler ohne Einträge steht keine erfundene „0“
const laden = await startPult([], { kpis: {}, feed: { extra: { loading: true } } })
try {
assert.deepEqual(byClass(laden.tree(), 'hk-reiter').map(textOf), ['Wartet', 'Veraltet', 'Erledigt']); assert.equal(byClass(laden.tree(), 'hk-pult-zahl').length, 0)
assert.ok(textOf(laden.tree()).includes('Wird geladen …'))
} finally { laden.end() }
const kaputt = await startPult([], { kpis: {}, feed: { extra: { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' } } })
try {
assert.deepEqual(byClass(kaputt.tree(), 'hk-reiter').map(textOf), ['Wartet', 'Veraltet', 'Erledigt'])
assert.ok(textOf(kaputt.tree()).includes('Das Pult ist gerade nicht erreichbar'))
assert.equal(btn(kaputt.tree(), 'Neu laden').length, 1)
await kaputt.press(btn(kaputt.tree(), 'Neu laden')[0]); assert.equal(kaputt.refreshes.length, 1)
} finally { kaputt.end() }
})
test('Pult: Untertitel und Leerzustand je Reiter wie in der Vorlage', async () => {
const ui = await startPult([], { kpis: { pultOpen: 0 } })
try {
const sub = () => textOf(byClass(ui.tree(), 'hk-brett-unter')[0])
const leer = () => byClass(ui.tree(), 'hk-leer').map(textOf)
assert.equal(sub(), 'Hier wartet alles, was ein Mensch freigeben muss.'); assert.deepEqual(leer(), ['Nichts wartet auf dichNeue Antworten erscheinen hier, sobald ein Entwurf bereit ist.'])
await ui.tab('Veraltet')
assert.equal(sub(), 'Diese Einträge liegen schon zu lange. Du kannst sie weiter bearbeiten.'); assert.deepEqual(leer(), ['Nichts ist veraltetEinträge, die zu lange liegen, landen hier und lassen sich weiter bearbeiten.'])
await ui.tab('Erledigt')
assert.equal(sub(), 'Was du oder das Team schon abgeschlossen habt.'); assert.deepEqual(leer(), ['Noch nichts erledigtGesendete, verworfene und erledigte Einträge stehen hier.'])
// der Kopf zeigt bei pultOpen 0 die „0“ (eine Zahl vom Dienst), die Reiter-Leiste ist eine Gruppe mit Namen
assert.equal(textOf(byClass(ui.tree(), 'hk-pult-zahl')[0]), 'Wartet auf dich:0')
assert.equal(byClass(ui.tree(), 'hk-reiter-leiste')[0].props.role, 'group')
} finally { ui.end() }
})
test('Pult: die Boten-Schleife (Clara, Nora, Aufzug, Fähnchen) läuft nur bei kpis.pultOpen > 0, sonst steht die Ruhe-Klasse der Vorlage am Rahmen', async () => {
const klasse = async (kpis) => {
const ui = await startPult([], { kpis })
try {
const szene = byClass(ui.tree(), 'hk-pult-szene')[0]
assert.equal(szene.props['aria-hidden'], 'true')
return hasClass(szene, 'hk-pult-ruhe')
} finally { ui.end() }
}
assert.equal(await klasse({ pultOpen: 3 }), false)
assert.equal(await klasse({ pultOpen: 1 }), false)
for (const kpis of [undefined, {}, { pultOpen: 0 }, { pultOpen: '2' }]) assert.equal(await klasse(kpis), true, JSON.stringify(kpis))
// die Ruhe-Regeln der Vorlage hängen an .hk-pult-ruhe unterhalb der Wurzel .hk-pult, die Boten und der Aufzug darunter
const css = read('src/styles/hustler/pult.css')
assert.ok(css.includes('.hk-pult .hk-pult-ruhe .hk-boten-weg')); assert.ok(css.includes('.hk-pult .hk-pult-ruhe .hk-sz-briefkasten'))
// die Szene steckt innerhalb der Wurzel (PultList) und enthält Boten und Fähnchen
const ui = await startPult([], { kpis: { pultOpen: 1 } })
try {
assert.ok(hasClass(ui.tree(), 'hk-pult'))
const szene = byClass(ui.tree(), 'hk-pult-szene')[0]
for (const c of ['hk-boten-weg', 'hk-boten-bild', 'hk-sz-tuer', 'hk-sz-briefkasten', 'hk-sz-sekunde', 'hk-sz-schnee', 'hk-sz-kette0', 'hk-sz-lmond', 'hk-sz-monitor']) assert.ok(byClass(szene, c).length >= 1, c)
assert.match(byClass(szene, 'hk-pult-buehne')[0].props.style.transform, /^translate\(.*\) scale\(.*\)$/)
} finally { ui.end() }
})
test('PultSzene: passt sich der Fläche an (ResizeObserver, „cover“) und räumt den Beobachter auf', async () => {
const { __mount, jsx, PultSzene } = await loadUi()
const size = { width: 1920, height: 1080 }
const observed = []
const disconnected = []
const callbacks = []
globalThis.ResizeObserver = class { constructor(cb) { callbacks.push(cb) } observe(el) { observed.push(el) } disconnect() { disconnected.push(true) } }
globalThis.__fakeRef = () => ({ getBoundingClientRect: () => ({ ...size }) })
try {
const mounted = __mount(jsx(PultSzene, { laeuft: true }))
await settle()
const stage = () => nodes(mounted.tree(), (n) => hasClass(n, 'hk-pult-buehne'))[0]
assert.equal(stage().props.style.transform, 'translate(0.0px, 0.0px) scale(3.0000)')
assert.equal(observed.length, 1)
size.width = 2560; size.height = 1080
callbacks[0](); await settle()
assert.equal(stage().props.style.transform, 'translate(0.0px, -216.0px) scale(4.0000)')
size.width = 0; size.height = 0
callbacks[0](); await settle()
assert.equal(stage().props.style.transform, 'translate(0.0px, -216.0px) scale(4.0000)') // keine Fläche: das letzte Maß bleibt
mounted.unmount()
assert.equal(disconnected.length, 1)
} finally { delete globalThis.ResizeObserver; delete globalThis.__fakeRef }
// ohne ResizeObserver wird einmal gemessen; ohne Rahmen (Server, Node) passiert nichts
const einmal = __mount(jsx(PultSzene, {}))
await settle()
assert.equal(nodes(einmal.tree(), (n) => hasClass(n, 'hk-pult-buehne'))[0].props.style.transform, 'translate(0.0px, 0.0px) scale(2.0000)')
einmal.unmount()
})
test('Pult: „Pult zuklappen“ blendet Reiter und Klemmbrett aus (die Szene bleibt), „Pult aufklappen“ bringt sie zurück', async () => {
const ui = await startPult([entry()], { kpis: { pultOpen: 1 } })
try {
assert.equal(byClass(ui.tree(), 'hk-klemmbrett').length, 1); assert.equal(byClass(ui.tree(), 'hk-reiter-leiste').length, 1)
assert.equal(btn(ui.tree(), 'Pult zuklappen').length, 1)
await ui.press(btn(ui.tree(), 'Pult zuklappen')[0])
assert.equal(byClass(ui.tree(), 'hk-klemmbrett').length, 0); assert.equal(byClass(ui.tree(), 'hk-reiter-leiste').length, 0); assert.equal(byClass(ui.tree(), 'hk-umschlag').length, 0)
assert.equal(byClass(ui.tree(), 'hk-pult-szene').length, 1) // die Szene bleibt ganz zu sehen
assert.equal(btn(ui.tree(), 'Pult zuklappen').length, 0); assert.equal(btn(ui.tree(), 'Pult aufklappen').length, 1)
assert.equal(byClass(ui.tree(), 'hk-pult-zahl').length, 1) // Kopf mit Zahl bleibt
await ui.press(btn(ui.tree(), 'Pult aufklappen')[0])
assert.equal(byClass(ui.tree(), 'hk-klemmbrett').length, 1); assert.equal(byClass(ui.tree(), 'hk-umschlag').length, 1)
assert.equal(btn(ui.tree(), 'Pult zuklappen').length, 1)
} finally { ui.end() }
})
test('Pult: „◂ Hochhaus“ ruft onBack; ohne onBack passiert beim Klick nichts', async () => {
const calls = []
const ui = await startPult([], { onBack: () => calls.push('zurück') })
try {
const back = btn(ui.tree(), 'Hochhaus')
assert.equal(back.length, 1); assert.equal(back[0].tag, 'button'); assert.equal(back[0].props.type, 'button')
assert.ok(byClass(back[0], 'hk-ic-zurueck').length === 1 && byClass(back[0], 'hk-ic-zurueck')[0].props['aria-hidden'] === 'true')
await ui.press(back[0]); await ui.press(back[0])
assert.deepEqual(calls, ['zurück', 'zurück'])
} finally { ui.end() }
const stumm = await startPult([])
try { await stumm.press(btn(stumm.tree(), 'Hochhaus')[0]) } finally { stumm.end() }
})
// ---- Brief: öffnen, schließen, Fokus ----
test('Pult: ein Klick auf den Umschlag öffnet den Brief als Dialog (role, aria-modal, Name), der Fokus geht auf den ersten Knopf „Schließen“', async () => {
const f = fakeFocus()
const ui = await startPult([entry({ title: 'Suche Webdesigner' })])
try {
const umschlag = f.el('umschlag')
f.doc.activeElement = umschlag
await ui.open(0, umschlag)
const brief = ui.brief()
assert.ok(brief)
assert.deepEqual([brief.props.role, brief.props['aria-modal'], brief.props['aria-label'], brief.props.tabIndex], ['dialog', 'true', 'Antwort auf Plattform-Anfrage: Suche Webdesigner', -1])
// der erste Knopf im Brief ist „Schließen“ und hat den Fokus
const buttons = nodes(brief, (n) => n.tag === 'button' || n.tag === 'a')
assert.equal(buttons[0].props['aria-label'], 'Schließen')
assert.deepEqual([f.ref('hk-knopf').focusCalls, f.doc.activeElement], [1, f.ref('hk-knopf')])
// Hintergrund-Knopf (nicht in der Tab-Reihenfolge) schließt
const hinten = byClass(ui.tree(), 'hk-brief-hinten')[0]
assert.deepEqual([hinten.tag, hinten.props.tabIndex, hinten.props['aria-label']], ['button', -1, 'Brief schließen'])
// Überschrift, Art, Original und Entwurf stehen im Brief
assert.equal(textOf(byClass(brief, 'hk-brief-titel')[0]), 'Suche Webdesigner')
assert.equal(byClass(brief, 'hk-brief-titel')[0].tag, 'h2')
} finally { ui.end(); f.restore() }
})
test('Pult: der Brief schließt mit „Schließen“, Hintergrund und Esc; der Fokus geht an den Umschlag zurück', async () => {
for (const weg of ['knopf', 'hintergrund', 'esc']) {
const f = fakeFocus()
const ui = await startPult([entry()])
try {
const umschlag = f.el('umschlag')
await ui.open(0, umschlag)
assert.ok(ui.brief(), weg)
if (weg === 'knopf') await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
if (weg === 'hintergrund') await ui.press(byClass(ui.tree(), 'hk-brief-hinten')[0])
if (weg === 'esc') { const event = await ui.briefKey('Escape'); assert.ok(event.defaultPrevented && event.stopped) }
assert.equal(ui.brief(), null, weg)
assert.deepEqual([umschlag.focusCalls, f.doc.activeElement], [1, umschlag], weg)
// eine andere Taste schließt nicht
} finally { ui.end(); f.restore() }
}
const ui = await startPult([entry()])
try {
await ui.open(0)
await ui.briefKey('a'); await ui.briefKey('Enter')
assert.ok(ui.brief())
} finally { ui.end() }
})
test('Pult: Esc schließt erst ein offenes Feld, dann den Brief; Esc bei laufender Anfrage lässt das Feld stehen', async () => {
const f = fakeFocus()
let finish
const api = { pultAction: () => new Promise((resolve) => { finish = resolve }) }
const ui = await startPult([entry({ kind: 'unsicher', draft: 'Entwurf' })], { api })
try {
const umschlag = f.el('umschlag')
const opener = f.el('opener')
f.found.set('[data-opens="reply"]', opener)
await ui.open(0, umschlag)
await ui.press(btn(ui.brief(), 'Als Mensch antworten')[0])
assert.equal(nodes(ui.brief(), (n) => n.tag === 'textarea').length, 1)
let event = await ui.briefKey('Escape') // der Fokus liegt außerhalb des Textfelds: der Brief schließt das Feld
assert.ok(event.defaultPrevented); assert.equal(nodes(ui.brief(), (n) => n.tag === 'textarea').length, 0); assert.ok(ui.brief()); assert.equal(opener.focusCalls, 1)
event = await ui.briefKey('Escape')
assert.equal(ui.brief(), null); assert.equal(umschlag.focusCalls, 1)
// während einer Anfrage: Feld bleibt (nur der Brief hat Esc behandelt)
await ui.open(0, umschlag)
await ui.press(btn(ui.brief(), 'Als Mensch antworten')[0])
await ui.press(btn(ui.brief(), 'Jetzt senden')[0])
event = await ui.briefKey('Escape')
assert.ok(event.defaultPrevented); assert.equal(nodes(ui.brief(), (n) => n.tag === 'textarea').length, 1); assert.ok(ui.brief())
finish({}); await settle()
} finally { ui.end(); f.restore() }
})
test('Pult: Tab bleibt im Brief (vom letzten Knopf zum ersten, Umschalt+Tab vom ersten oder vom Brief selbst zum letzten)', async () => {
const f = fakeFocus()
const ui = await startPult([entry()])
try {
const erster = f.el('erster'); const mitte = f.el('mitte'); const letzter = f.el('letzter'); const gesperrt = f.el('gesperrt', { disabled: true })
f.all = [erster, mitte, gesperrt, letzter]
await ui.open(0)
// Mitte: Tab geht seinen normalen Weg
f.doc.activeElement = mitte
let event = await ui.briefKey('Tab'); assert.equal(event.defaultPrevented, false)
event = await ui.briefKey('Tab', { shiftKey: true }); assert.equal(event.defaultPrevented, false)
// Ende -> Anfang
f.doc.activeElement = letzter
event = await ui.briefKey('Tab'); assert.ok(event.defaultPrevented); assert.equal(f.doc.activeElement, erster)
// Anfang (und der Brief selbst) mit Umschalt -> Ende; gesperrte Knöpfe zählen nicht
event = await ui.briefKey('Tab', { shiftKey: true }); assert.ok(event.defaultPrevented); assert.equal(f.doc.activeElement, letzter)
f.doc.activeElement = f.ref('hk-brief')
event = await ui.briefKey('Tab', { shiftKey: true }); assert.ok(event.defaultPrevented); assert.equal(f.doc.activeElement, letzter)
// Umschalt+Tab vom Ende: normal
event = await ui.briefKey('Tab', { shiftKey: true }); assert.equal(event.defaultPrevented, false)
} finally { ui.end(); f.restore() }
})
test('Pult: nach einem Abschluss schließt der Brief, die Meldung erscheint (role status) und nimmt den Fokus; der Umschlag bekommt ihn nicht', async () => {
const f = fakeFocus()
const ui = await startPult([entry({ kind: 'unsicher', title: 'Unklare Antwort', draft: '' })])
try {
const umschlag = f.el('umschlag')
await ui.open(0, umschlag)
const erledigt = f.el('erledigt-knopf', { disabled: true })
f.doc.activeElement = erledigt
await ui.press(btn(ui.brief(), 'Erledigt')[0])
assert.equal(ui.brief(), null)
const meldung = byClass(ui.tree(), 'hk-hinweis')[0]
assert.deepEqual([meldung.props.role, meldung.props.tabIndex, textOf(meldung)], ['status', -1, '„Unklare Antwort“ erledigt.'])
assert.deepEqual([f.ref('hk-hinweis').focusCalls, f.doc.activeElement, umschlag.focusCalls], [1, f.ref('hk-hinweis'), 0])
// ein Filterwechsel räumt die Meldung weg
await ui.tab('Veraltet')
assert.equal(byClass(ui.tree(), 'hk-hinweis').length, 0)
} finally { ui.end(); f.restore() }
})
test('Pult: verschwindet der Eintrag aus der Liste (ein Kollege war schneller), schließt sein offener Brief', async () => {
const f = fakeFocus()
const items = [entry({ $id: 'a', title: 'Eins' }), entry({ $id: 'b', title: 'Zwei' })]
const ui = await startPult(items)
try {
const umschlag = f.el('umschlag')
await ui.open(1, umschlag)
assert.equal(textOf(byClass(ui.brief(), 'hk-brief-titel')[0]), 'Zwei')
items.splice(1, 1) // der nächste Abruf des Feeds liefert die kürzere Liste (hier: beim nächsten Zeichnen, ausgelöst durch einen Reiterwechsel)
await ui.tab('Veraltet')
assert.equal(ui.brief(), null)
assert.equal(ui.envelopes().length, 1)
} finally { ui.end(); f.restore() }
})
test('Pult: ein Ergebnis, das nach dem Schließen des Briefs eintrifft, erscheint in der Liste (Fehler als Alarm); ein Abschluss danach schließt keinen anderen Brief', async () => {
const f = fakeFocus()
let reject
let finish
const api = { pultAction: (id, action) => (action === 'discard' ? new Promise((resolve) => { finish = resolve }) : new Promise((_, no) => { reject = no })) }
const items = [entry({ $id: 'a', title: 'Eins', kind: 'unsicher', prospectId: 'p1' }), entry({ $id: 'b', title: 'Zwei', kind: 'unsicher', prospectId: 'p1' })]
const ui = await startPult(items, { api })
try {
// 1. Fehler nach dem Schließen: „Kann gesendet sein“ geht nicht verloren
await ui.open(0, f.el('u0'))
await ui.press(btn(ui.brief(), 'Vorschau bauen')[0]); await ui.press(btn(ui.brief(), 'Wirklich bauen?')[0])
await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
assert.equal(ui.brief(), null)
reject(Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })); await settle()
const alarm = byClass(ui.tree(), 'hk-hinweis-err')
assert.deepEqual(alarm.map((n) => [n.props.role, textOf(n)]), [['alert', 'Kann gesendet sein – Pult neu laden.']])
// ein neuer Brief räumt die späte Meldung weg
await ui.open(1)
assert.equal(byClass(ui.tree(), 'hk-hinweis-err').length, 0)
await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
// 2. Abschluss, nachdem ein anderer Brief offen ist: die Meldung kommt, der andere Brief bleibt, kein Fokus-Raub
await ui.open(0, f.el('u0b'))
await ui.press(btn(ui.brief(), 'Verwerfen')[0]); await ui.press(btn(ui.brief(), 'Wirklich verwerfen?')[0])
await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
await ui.open(1, f.el('u1'))
const vorher = f.doc.activeElement
finish({}); await settle()
assert.ok(ui.brief()); assert.equal(textOf(byClass(ui.brief(), 'hk-brief-titel')[0]), 'Zwei')
assert.deepEqual(byClass(ui.tree(), 'hk-hinweis-ok').map(textOf), ['„Eins“ verworfen.'])
assert.equal(f.doc.activeElement, vorher)
} finally { ui.end(); f.restore() }
})
test('Pult: „Interessent“ im Brief öffnet das Interessenten-Panel in der WOMS-Hülle, Schließen räumt es weg und gibt den Brief frei', async () => {
const ui = await startPult([entry({ prospect: { $id: 'p42', name: 'Verein' } })])
try {
await ui.open()
assert.equal(byClass(ui.tree(), 'hk-woms').length, 0)
const knopf = btn(ui.brief(), 'Interessent')
assert.equal(knopf.length, 1); assert.equal(knopf[0].props['aria-label'], 'Interessent zu Suche Webdesigner')
await ui.press(knopf[0])
const huelle = byClass(ui.tree(), 'hk-woms')
assert.equal(huelle.length, 1)
assert.deepEqual(nodes(huelle[0], (n) => n.props['data-panel'] !== undefined).map((n) => n.props['data-panel']), ['p42'])
assert.equal(globalThis.__panel.prospectId, 'p42'); assert.ok(ui.brief()) // der Brief bleibt darunter offen
globalThis.__panel.onClose(); await settle()
assert.equal(byClass(ui.tree(), 'hk-woms').length, 0); assert.ok(ui.brief())
} finally { ui.end() }
// ohne Interessent gibt es den Knopf nicht
const ohne = await startPult([entry({ prospect: null })])
try { await ohne.open(); assert.equal(btn(ohne.brief(), 'Interessent').length, 0) } finally { ohne.end() }
})
// ---- Klassen gegen das CSS, Architektur-Regeln ----
const CSS_DATEIEN = ['pult.css', 'hustler-ui.css', 'hustler-pult-ui.css']
const definiert = () => {
const out = new Set()
for (const f of CSS_DATEIEN) for (const m of read('src/styles/hustler/' + f).matchAll(/\.(hk-[a-z0-9-]+)/g)) out.add(m[1])
return out
}
test('Pult: jede gerenderte hk-Klasse steht in pult.css, hustler-ui.css oder hustler-pult-ui.css (Liste, Szene, Brief in allen Arten, Felder, Rückfragen, Meldungen)', async () => {
const css = definiert()
const seen = new Set()
const jeder = [...ALLE_ARTEN,
entry({ $id: 'v', status: 'veraltet', staleAt: iso(NOW - 1000) }), entry({ $id: 'h', status: 'in_arbeit', claimedBy: 'mensch:u1' }), entry({ $id: 's', status: 'in_arbeit', claimedBy: 'mensch:u2', staleAt: iso(Date.now() + 3600e3) }),
entry({ $id: 'g', status: 'gesendet', doneBy: 'mensch:u2', doneAt: iso(Date.now() - 3600e3) }), entry({ $id: 'x', status: 'verworfen', doneBy: 'max', doneAt: iso(Date.now() - 3600e3) }),
]
const api = { pultAction: async () => { throw Object.assign(new Error('Fehler des Dienstes.'), { status: 409 }) } }
const ui = await startPult(jeder, { api, kpis: { pultOpen: 3 }, onBack() {} })
try {
classesIn(ui.tree(), seen)
for (let i = 0; i < jeder.length; i += 1) {
await ui.open(i)
classesIn(ui.tree(), seen)
// jedes Feld und jede Rückfrage einmal aufklappen, dann Fehler und Erfolg zeigen
for (const label of ['Als Mensch antworten', 'Antwort des Kunden einfügen']) {
const k = btn(ui.brief(), label)[0]
if (k) { await ui.press(k); classesIn(ui.tree(), seen); await ui.press(btn(ui.brief(), 'Abbrechen')[0]) }
}
for (const label of ['Verwerfen', 'Vorschau bauen', 'Freigeben & senden']) {
const k = btn(ui.brief(), label)[0]
if (k && !k.props.disabled) { await ui.press(k); classesIn(ui.tree(), seen); await ui.press(btn(ui.brief(), 'Wirklich verwerfen?')[0] ?? btn(ui.brief(), 'Wirklich bauen?')[0] ?? btn(ui.brief(), 'Wirklich freigeben?')[0]); classesIn(ui.tree(), seen) }
}
await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
}
// Meldung nach Abschluss, später Ergebnis, Panel
classesIn(ui.tree(), seen)
} finally { ui.end() }
for (const [name, extra] of [['laden', { loading: true }], ['fehler', { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' }]]) {
const z = await startPult([], { feed: { extra } })
try { classesIn(z.tree(), seen) } finally { z.end() }
assert.ok(seen.size > 0, name)
}
const mitFehler = await startPult(jeder.slice(0, 2), { feed: { extra: { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' } } })
try { classesIn(mitFehler.tree(), seen) } finally { mitFehler.end() }
const ohneBoten = await startPult([], { kpis: { pultOpen: 0 } })
try { classesIn(ohneBoten.tree(), seen) } finally { ohneBoten.end() }
const zu = await startPult([entry({ prospect: { $id: 'p42' } })])
try {
await zu.open(); await zu.press(btn(zu.brief(), 'Interessent')[0]); classesIn(zu.tree(), seen)
await zu.press(btn(zu.tree(), 'Pult zuklappen')[0]); classesIn(zu.tree(), seen)
} finally { zu.end() }
const fehlt = [...seen].filter((c) => c.startsWith('hk-') && !css.has(c))
assert.deepEqual(fehlt, [])
// gesehen sind die wichtigen Klassen wirklich (der Test prüft nicht ins Leere)
for (const c of ['hk-pult', 'hk-pult-szene', 'hk-umschlag', 'hk-brief', 'hk-brief-ueber', 'hk-stempel', 'hk-stempel-frage', 'hk-stempel-gruen', 'hk-stempel-rot', 'hk-stempel-haupt', 'hk-feld-panel', 'hk-feld-antwort', 'hk-feld-einfuegen',
'hk-hinweis-err', 'hk-hinweis-warn', 'hk-leer', 'hk-laden', 'hk-reiter-an', 'hk-abdruck', 'hk-marke-frist', 'hk-marke-halter', 'hk-marke-veraltet', 'hk-woms', 'hk-brief-zitat', 'hk-brief-lesen', 'hk-link', 'hk-zaehler',
'hk-ic-art-plattform', 'hk-ic-art-mail', 'hk-ic-art-vorschau', 'hk-ic-art-unsicher', 'hk-ic-art-beschwerde', 'hk-ic-art-preisfrage', 'hk-ic-art-mensch', 'hk-ic-art-bau-kaputt', 'hk-ic-bauen', 'hk-ic-verwerfen']) {
assert.ok(seen.has(c), `nie gerendert: ${c}`)
}
})
test('Pult: Quelltext – kein CSS- oder Bild-Import, kein innerHTML, Links nur mit rel, jeder Knopf mit type, Bilder und Dekoration aria-hidden', () => {
const dateien = ['src/components/workers/PultList.jsx', 'src/components/workers/pult/Brief.jsx', 'src/components/workers/pult/Klemmbrett.jsx', 'src/components/workers/pult/Marken.jsx', 'src/components/workers/pult/PultSzene.jsx', 'src/lib/hustlerPult.js']
for (const f of dateien) {
const s = read(f)
assert.doesNotMatch(s, /import\s[^\n]*\.(css|png|svg|jpe?g|gif|webp|woff2?)['"]/, `${f}: Import von CSS oder Bild`)
assert.doesNotMatch(s, /import\s*['"][^'"]*\.(css|png|svg)['"]/, `${f}: Import von CSS oder Bild`)
assert.doesNotMatch(s, /innerHTML|dangerouslySetInnerHTML/, f)
assert.doesNotMatch(s, /\burl\(/, `${f}: Bild im Quelltext`)
assert.equal((s.match(/<button\b/g) ?? []).length, (s.match(/<button\b[^>]*\btype="button"/g) ?? []).length, `${f}: Knopf ohne type`)
// jeder externe Link öffnet ohne Rückbezug
for (const m of s.matchAll(/<a\b[^>]*\btarget="_blank"[^>]*>/g)) assert.match(m[0], /rel="noopener noreferrer"/, f)
}
// Links aus Dienst-Daten laufen nur über rowModel (sourceHref/previewHref/mailtoHref), nie über den rohen Eintrag
const brief = read('src/components/workers/pult/Brief.jsx')
assert.doesNotMatch(brief, /href=\{item\./); assert.doesNotMatch(brief, /sourceUrl/)
})
test('Pult: Handy unter 720 px – Klemmbrett in voller Breite über abgedunkelter Szene, Brief als Vollbild (CSS)', () => {
const css = read('src/styles/hustler/hustler-pult-ui.css')
const phone = css.slice(css.indexOf('@media (max-width: 719px)'))
assert.ok(phone.length > 0 && css.includes('@media (max-width: 719px)'))
assert.match(phone, /\.hk-pult-ui\s*\{[^}]*width:\s*auto/); assert.match(phone, /\.hk-pult-ui\s*\{[^}]*left:\s*8px/)
assert.match(phone, /\.hk-pult-vignette\s*\{[^}]*rgba\(6, 7, 18, 0\.55\)/)
assert.match(phone, /\.hk-brief-ueber\s*\{[^}]*padding:\s*0/); assert.match(phone, /\.hk-brief\s*\{[^}]*width:\s*100%[^}]*height:\s*100%/)
// am Desktop: Klemmbrett rechts, höchstens 716 px breit; Brief höchstens 640 px
assert.match(css, /\.hk-pult \.hk-pult-ui\s*\{[^}]*width:\s*min\(716px, calc\(100% - 32px\)\)/); assert.match(css, /\.hk-pult \.hk-brief\s*\{[^}]*width:\s*min\(640px, 100%\)/)
// „weniger Bewegung“ steht im erzeugten CSS der Vorlage
assert.ok(read('src/styles/hustler/pult.css').includes('@media (prefers-reduced-motion: reduce)'))
})