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>
805 lines
53 KiB
JavaScript
805 lines
53 KiB
JavaScript
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)'))
|
||
})
|