Files
ClaudeandClaude Sonnet 5.5 5e48fbf264 Hustler-Stufe: P4-Aktionen im Pult erst ab Stufe 4 (rowModel-Parameter stufe, PultList reicht ihn durch)
Unterhalb von Stufe 4 sind canReply, canPaste, canApprovePreview und canStartBuild immer false: Antwort des Kunden einfügen, Als Mensch antworten / Jetzt senden, Freigeben & senden
und Vorschau bauen verschwinden samt Feldern und Rückfragen, die Aktionsgruppe schließt sich ohne Lücke. P3-Aktionen (übernehmen, Kopieren & öffnen, als gesendet markieren, verwerfen,
erledigt, E-Mail schreiben) bleiben. Bestehende P4-Tests laufen ausdrücklich auf Stufe 6; neue Tests prüfen Stufe 3 gerendert und bedient (Reihenfolge der Knöpfe, kein Feld, Esc, Verwerfen mit Rückfrage).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 10:36:44 +00:00

805 lines
53 KiB
JavaScript
Raw Permalink 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' }]
// Stufen-Sperre (hustlerStufe.js): diese Datei prüft das volle Pult (Stufe 6, P4-Aktionen offen, Verhalten wie vor der Sperre); Stufe 3 prüfen workersPultUi.test.mjs und hustlerStufe.test.mjs
const rowOf = (item) => rowModel(item, { me: ME, nowMs: NOW, employees: EMPLOYEES, stufe: 6 })
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, paused
async function startPult(items, { api = { pultAction: async () => ({}) }, kpis, onBack, feed = {}, paused } = {}) {
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, paused, stufe: 6 }))
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 (T13 P1b): ein spätes Ergebnis nennt den Eintrag („Titel“: …), auch ein später Erfolg; ohne Titel bleibt der Satz wie er ist', async () => {
const f = fakeFocus()
const wartend = []
const api = { pultAction: () => new Promise((resolve, reject) => { wartend.push({ resolve, reject }) }) }
const items = [entry({ $id: 'a', title: 'Suche Webdesigner', kind: 'unsicher', prospectId: 'p1' }), entry({ $id: 'b', title: ' ', kind: 'unsicher', prospectId: 'p1' })]
const ui = await startPult(items, { api })
try {
const zu = () => ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
// Fehler: Titel vorn
await ui.open(0, f.el('u0'))
await ui.press(btn(ui.brief(), 'Ich kümmere mich')[0]); await zu()
wartend[0].reject(Object.assign(new Error('Wird schon von einem Kollegen bearbeitet.'), { status: 409 })); await settle()
assert.deepEqual(byClass(ui.tree(), 'hk-hinweis-err').map(textOf), ['„Suche Webdesigner“: Wird schon von einem Kollegen bearbeitet.'])
// Erfolg (Antwort kommt nach dem Schließen): „Du kümmerst dich darum.“ gehört zu diesem Eintrag
await ui.open(0, f.el('u0b'))
await ui.press(btn(ui.brief(), 'Ich kümmere mich')[0]); await zu()
wartend[1].resolve({ item: { $id: 'a' } }); await settle()
assert.deepEqual(byClass(ui.tree(), 'hk-hinweis-ok').map(textOf), ['„Suche Webdesigner“: Du kümmerst dich darum.'])
// leerer Titel: kein „„“: …“ vorn
await ui.open(1, f.el('u1'))
await ui.press(btn(ui.brief(), 'Ich kümmere mich')[0]); await zu()
wartend[2].reject(Object.assign(new Error('Wird schon von einem Kollegen bearbeitet.'), { status: 409 })); await settle()
assert.deepEqual(byClass(ui.tree(), 'hk-hinweis-err').map(textOf), ['Wird schon von einem Kollegen bearbeitet.'])
} finally { ui.end(); f.restore() }
})
// ---- T13 M2: Bildschirmwechsel – die Überschrift nimmt beim Einhängen den Fokus ----
test('Pult (T13 M2): beim Einhängen nimmt die Überschrift „Freigabe“ den Fokus (tabIndex -1, preventScroll); ohne document oder Element passiert nichts', async () => {
const aufrufe = []
const h1 = { focus: (options) => aufrufe.push(options) }
globalThis.__fakeRef = (tag) => (tag === 'h1' ? h1 : null)
const ui = await startPult([entry()])
try {
const titel = byClass(ui.tree(), 'hk-pult-h1')
assert.equal(titel.length, 1); assert.equal(titel[0].tag, 'h1'); assert.equal(titel[0].props.tabIndex, -1); assert.equal(textOf(titel[0]), 'Freigabe')
assert.deepEqual(aufrufe, [{ preventScroll: true }]) // genau einmal, ohne dass die Seite springt
// ein Filterwechsel oder ein Brief holt den Fokus nicht noch einmal an die Überschrift
await ui.tab('Veraltet'); await ui.tab('Wartet')
await ui.open(0)
assert.equal(aufrufe.length, 1)
} finally { ui.end(); delete globalThis.__fakeRef }
// ohne Element (Server, Node: kein ref gesetzt) bricht nichts
const ohne = await startPult([entry()])
try { assert.equal(byClass(ohne.tree(), 'hk-pult-h1').length, 1) } finally { ohne.end() }
// die Überschrift zeigt den Fokus (sichtbarer Ring) wie die anderen Bedienelemente
assert.match(read('src/styles/hustler/hustler-pult-ui.css'), /\.hk-pult \.hk-pult-h1:focus-visible\s*\{[^}]*outline:\s*2px solid/)
})
// ---- T13 K4: „Pult zuklappen“ sagt, ob das Pult offen ist ----
test('Pult (T13 K4): „Pult zuklappen“ trägt aria-expanded (offen = true, zugeklappt = false)', async () => {
const ui = await startPult([entry()], { kpis: { pultOpen: 1 } })
try {
assert.equal(btn(ui.tree(), 'Pult zuklappen')[0].props['aria-expanded'], true)
await ui.press(btn(ui.tree(), 'Pult zuklappen')[0])
assert.equal(btn(ui.tree(), 'Pult aufklappen')[0].props['aria-expanded'], false)
await ui.press(btn(ui.tree(), 'Pult aufklappen')[0])
assert.equal(btn(ui.tree(), 'Pult zuklappen')[0].props['aria-expanded'], true)
} finally { ui.end() }
})
// ---- T13 R3: paused erreicht die Szene (Boten, Aufzug, Fähnchen, Uhr stehen still) ----
test('Pult (T13 R3): paused setzt hk-steht an der Szene (mit und ohne Boten-Schleife); sonst nicht; das CSS der Seite hält alles darunter an', async () => {
const klassen = async (opts) => {
const ui = await startPult([entry()], opts)
try { return String(byClass(ui.tree(), 'hk-pult-szene')[0].props.className) } finally { ui.end() }
}
assert.equal(await klassen({ kpis: { pultOpen: 3 }, paused: true }), 'hk-pult-szene hk-steht')
assert.equal(await klassen({ kpis: { pultOpen: 0 }, paused: true }), 'hk-pult-szene hk-pult-ruhe hk-steht')
assert.equal(await klassen({ kpis: { pultOpen: 3 }, paused: false }), 'hk-pult-szene')
assert.equal(await klassen({ kpis: { pultOpen: 3 } }), 'hk-pult-szene') // ohne die Prop gilt „nicht angehalten“
for (const unsinn of ['ja', 1, null, {}]) assert.equal(await klassen({ kpis: { pultOpen: 3 }, paused: unsinn }), 'hk-pult-szene', String(unsinn))
// die Regel, die Animationen unter hk-steht anhält, steht in hustler-ui.css unter der Seite (das Pult hängt darin)
assert.match(read('src/styles/hustler/hustler-ui.css'), /\.hk-seite \.hk-steht \*[^{]*\{[^}]*animation-play-state:\s*paused\s*!important/)
// PultSzene allein (ohne Seite) kennt die Prop ebenfalls
const { __mount, jsx, PultSzene } = await loadUi()
const szene = __mount(jsx(PultSzene, { laeuft: true, steht: true }))
assert.ok(hasClass(szene.tree(), 'hk-steht')); szene.unmount()
})
// ---- T13 P3, R4, K7: CSS des Pults ----
test('hustler-pult-ui.css (T13 P3): Originalanfrage und Entwurf zum Lesen behalten ihre Zeilenumbrüche und Leerzeichen (pre-wrap, nicht pre-line)', () => {
const css = read('src/styles/hustler/hustler-pult-ui.css').replace(/\/\*[\s\S]*?\*\//g, '')
for (const klasse of ['hk-brief-zitat', 'hk-brief-lesen']) {
const regel = new RegExp(`\\.hk-pult \\.${klasse}\\s*\\{([^}]*)\\}`).exec(css)
assert.ok(regel, klasse)
assert.match(regel[1], /white-space:\s*pre-wrap\s*;/, klasse); assert.doesNotMatch(regel[1], /pre-line/, klasse)
}
assert.doesNotMatch(css, /white-space:\s*pre-line/)
})
test('hustler-pult-ui.css (T13 R4): der Brief liegt unter der Handy-Seitenleiste (z-index 20) und ihrem Hintergrund (15), aber über Szene und Klemmbrett', () => {
const css = read('src/styles/hustler/hustler-pult-ui.css').replace(/\/\*[\s\S]*?\*\//g, '')
const z = (klasse) => Number(new RegExp(`\\.hk-pult \\.${klasse}\\s*\\{[^}]*z-index:\\s*(\\d+)`).exec(css)?.[1])
assert.ok(z('hk-brief-ueber') <= 9, `Brief z-index ${z('hk-brief-ueber')}`)
assert.ok(z('hk-brief-ueber') > z('hk-brett'), 'über dem Klemmbrett'); assert.ok(z('hk-brief-ueber') > z('hk-reiter-leiste'), 'über den Reitern')
// keine Regel dieser Datei legt etwas über die Seitenleiste
for (const m of css.matchAll(/z-index:\s*(\d+)/g)) assert.ok(Number(m[1]) <= 9, `z-index ${m[1]}`)
})
test('hustler-pult-ui.css (T13 K7): am Handy sind die Textlinks im Brief („Anzeige öffnen“, „Interessent“) mindestens 40 px hoch', () => {
const css = read('src/styles/hustler/hustler-pult-ui.css').replace(/\/\*[\s\S]*?\*\//g, '')
const handy = css.slice(css.indexOf('@media (max-width: 719px)'))
assert.match(handy, /\.hk-pult \.hk-link\s*\{[^}]*min-height:\s*40px/)
// die Link-Regel selbst zentriert den Text in dieser Höhe (inline-flex), die Fläche ist also wirklich antippbar
assert.match(css, /\.hk-pult \.hk-link\s*\{[^}]*display:\s*inline-flex[^}]*align-items:\s*center/)
// „Anzeige öffnen“ und „Interessent“ tragen genau diese Klasse
const brief = read('src/components/workers/pult/Brief.jsx')
assert.match(brief, /<a className="hk-link" href=\{row\.sourceHref\}/); assert.match(brief, /<button type="button" className="hk-link" onClick=\{\(\) => onDetails/)
})
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')
// T13 P1b: der Satz nennt den Eintrag (sein Brief ist ja zu), sonst weiß der Leser nicht, wozu er gehört
assert.deepEqual(alarm.map((n) => [n.props.role, textOf(n)]), [['alert', '„Eins“: 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)'))
})