import test from 'node:test' import assert from 'node:assert/strict' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import esbuild from 'esbuild' import { fakeReact } from './helpers/fakeReact.mjs' import { loadModule } from './load.mjs' // Dein Pult (Hustler-Pixelwelt T9, Spec E11/E13/E14): die reine Logik (src/lib/hustlerPult.js) und die Bauteile PultList, PultSzene, Klemmbrett, Brief im Mini-Läufer // anstelle von React (helpers/fakeReact.mjs). Die Aktionen des Briefs (Antworten, Freigeben, Bauen, Einfügen, Fokus nach Aktionen) prüft workersPultUi.test.mjs; // hier stehen Umschlag-Inhalt je Art und Status, Zahlen an den Reitern, Brief öffnen/schließen/Fokus, Boten nur bei wartenden Einträgen, „Pult zuklappen“, „◂ Hochhaus“, // der Weg zum Interessenten-Panel, die Klassen gegen das CSS und die Regeln der Architektur (kein CSS-/Bild-Import, kein innerHTML, sichere Links). const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8') const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`) const settle = () => new Promise((resolve) => setImmediate(resolve)) const L = await loadModule('src/lib/hustlerPult.js') const P = await loadModule('src/lib/workersPult.js') const { PULT_KIND_LABELS, PULT_FILTERS, rowModel } = P const NOW = Date.now() // die Bauteile rechnen mit der Uhr des Rechners: Alter und Fristen hängen deshalb an dieser Zeit const iso = (ms) => new Date(ms).toISOString() const ME = { id: 'u1', name: 'Justin' } const EMPLOYEES = [{ userId: 'u2', displayName: 'Kenso' }] const rowOf = (item) => rowModel(item, { me: ME, nowMs: NOW, employees: EMPLOYEES }) const entry = (extra = {}) => ({ $id: 'a', kind: 'plattform_antwort', status: 'offen', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(NOW - 3 * 3600e3), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein', city: 'Namborn', postalCode: '66640', score: 72, email: 'x@verein.example' }, ...extra }) // --------------------------------------------------------------------------------------------------------------- // Reine Logik test('szenePassung: „cover“ wie bindeRoot/passe der Vorlage, unbrauchbare Flächen ergeben null', () => { const at = (w, h) => { const p = L.szenePassung(w, h); return p && [p.s, p.x, p.y, p.transform] } assert.deepEqual(at(1280, 720), [2, 0, 0, 'translate(0.0px, 0.0px) scale(2.0000)']) assert.deepEqual(at(1920, 1080), [3, 0, 0, 'translate(0.0px, 0.0px) scale(3.0000)']) // breiter als hoch: die Szene ist höher als die Fläche, unten bleibt der Raum sichtbar (y = Überstand + 36 s, hier der kleinere von Überstand / 2 und Überstand + 36 s) assert.deepEqual(at(2560, 1080), [4, 0, -216, 'translate(0.0px, -216.0px) scale(4.0000)']) // höher als breit: die Szene ist breiter als die Fläche und rutscht um 15 % des Überstands nach links assert.deepEqual(at(800, 900), [2.5, -120, 0, 'translate(-120.0px, 0.0px) scale(2.5000)']) for (const [w, h] of [[0, 100], [100, 0], [-1, 5], [NaN, 5], [5, Infinity], ['5', 5], [undefined, undefined]]) assert.equal(L.szenePassung(w, h), null, `${w} x ${h}`) assert.deepEqual({ ...L.SZENE }, { w: 640, h: 360 }) assert.equal(L.SZENE_START.transform, 'translate(0.0px, 0.0px) scale(2.0000)') }) test('botenLaufen: nur bei kpis.pultOpen > 0 (Zahl), sonst ruht der Raum', () => { assert.equal(L.botenLaufen({ pultOpen: 1 }), true) assert.equal(L.botenLaufen({ pultOpen: 12 }), true) for (const kpis of [undefined, null, {}, { pultOpen: 0 }, { pultOpen: -1 }, { pultOpen: '3' }, { pultOpen: NaN }, { pultOpen: null }, [], 'x']) assert.equal(L.botenLaufen(kpis), false, JSON.stringify(kpis)) }) test('Zahlen an den Reitern: „Wartet“ aus kpis.pultOpen, am offenen Reiter die Länge der Liste, die übrigen Reiter ohne Zahl', () => { const z = (id, o) => L.reiterZahl(id, o) // Wartet zeigt kpis.pultOpen (auch am offenen Reiter); ohne Zahl vom Dienst am offenen Reiter die Länge der Liste assert.equal(z('offen', { aktivId: 'offen', kpis: { pultOpen: 5 }, geladen: 2 }), 5) assert.equal(z('offen', { aktivId: 'offen', kpis: { pultOpen: 5 }, geladen: null }), 5) assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: 5 }, geladen: 9 }), 5) assert.equal(z('offen', { aktivId: 'veraltet', kpis: {}, geladen: 9 }), null) assert.equal(z('offen', { aktivId: 'offen', kpis: undefined, geladen: 4 }), 4) assert.equal(z('offen', { aktivId: 'offen', kpis: undefined, geladen: null }), null) // Veraltet, Erledigt: nur wenn offen assert.equal(z('veraltet', { aktivId: 'veraltet', kpis: { pultOpen: 5 }, geladen: 3 }), 3) assert.equal(z('erledigt', { aktivId: 'veraltet', kpis: { pultOpen: 5 }, geladen: 3 }), null) assert.equal(z('veraltet', { aktivId: 'offen', kpis: { pultOpen: 5 }, geladen: 3 }), null) assert.equal(z('veraltet', { aktivId: 'veraltet', kpis: {}, geladen: null }), null) // kaputte Zahlen sind keine Zahlen assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: '5' }, geladen: 3 }), null) assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: -2 }, geladen: 3 }), null) assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: 0 }, geladen: 3 }), 0) assert.equal(L.wartetZahl({ pultOpen: 3 }, 'erledigt', null), 3) assert.equal(L.wartetZahl({}, 'offen', 7), 7) assert.equal(L.wartetZahl({}, 'veraltet', 7), null) assert.equal(L.reiterName('Wartet', 1), 'Wartet, 1 Eintrag') assert.equal(L.reiterName('Wartet', 0), 'Wartet, 0 Einträge') assert.equal(L.reiterName('Erledigt', 12), 'Erledigt, 12 Einträge') assert.equal(L.reiterName('Veraltet', null), 'Veraltet') assert.deepEqual(Object.keys(L.REITER_KLASSE), PULT_FILTERS.map((f) => f.id)) assert.deepEqual(Object.keys(L.UNTERTITEL), PULT_FILTERS.map((f) => f.id)); assert.deepEqual(Object.keys(L.LEER), PULT_FILTERS.map((f) => f.id)) }) test('artView: jede Pult-Art des Dienstes hat Name und Icon; Namen wie PULT_KIND_LABELS, unbekannte Art bekommt das Fragezeichen', () => { assert.deepEqual(Object.keys(L.ART_ICONS).sort(), Object.keys(PULT_KIND_LABELS).sort()) for (const [kind, label] of Object.entries(PULT_KIND_LABELS)) assert.deepEqual({ ...L.artView(kind) }, { label, icon: L.ART_ICONS[kind] }, kind) assert.deepEqual({ ...L.artView('neu_vom_dienst') }, { label: 'neu_vom_dienst', icon: 'art-unsicher' }) // jedes Icon gibt es im erzeugten CSS const css = read('src/styles/hustler/pult.css') for (const icon of Object.values(L.ART_ICONS)) assert.ok(css.includes(`.hk-ic-${icon}{`), icon) }) test('umschlagModell: Inhalt je Art und Status – Titel, Alter, Ort · Score, Frist, veraltet, Halter, Ergebnis-Stempel, zugänglicher Name', () => { const m = (extra) => L.umschlagModell(rowOf(entry(extra))) // offene Plattform-Antwort: Art, Alter, Ort · Score, nichts sonst let u = m() assert.deepEqual({ ...u, fertig: u.fertig }, { id: 'a', artLabel: 'Antwort auf Plattform-Anfrage', icon: 'art-plattform', titel: 'Suche Webdesigner', alter: 'vor 3 Std.', info: '66640 Namborn · Score 72', frist: '', veraltet: false, gehalten: '', fertig: null, label: 'Antwort auf Plattform-Anfrage: Suche Webdesigner, vor 3 Std.', }) // jede Art zeigt ihr Icon for (const kind of Object.keys(PULT_KIND_LABELS)) assert.equal(m({ kind }).icon, L.ART_ICONS[kind], kind) // läuft bald ab (< 24 h), veraltet (Status), veraltet durch staleAt u = m({ staleAt: iso(NOW + 5 * 3600e3) }) assert.equal(u.frist, 'läuft in 5 Std. ab'); assert.equal(u.veraltet, false); assert.ok(u.label.endsWith('vor 3 Std., läuft in 5 Std. ab')) u = m({ status: 'veraltet' }) assert.equal(u.veraltet, true); assert.equal(u.frist, ''); assert.ok(u.label.endsWith(', veraltet')) assert.equal(m({ staleAt: iso(NOW - 1000) }).veraltet, true) assert.equal(m({ staleAt: iso(NOW + 3 * 24 * 3600e3) }).frist, '') // übernommen: von mir „Du kümmerst dich darum.“, von einem Kollegen mit Namen, von Max automatisch, unbekannt „Kollege“ u = m({ status: 'in_arbeit', claimedBy: 'mensch:u1' }) assert.equal(u.gehalten, 'Du kümmerst dich darum.'); assert.ok(u.label.endsWith(', du kümmerst dich darum')) u = m({ status: 'in_arbeit', claimedBy: 'mensch:u2' }) assert.equal(u.gehalten, 'übernommen von Kenso'); assert.ok(u.label.endsWith(', übernommen von Kenso')) assert.equal(m({ status: 'in_arbeit', claimedBy: 'max' }).gehalten, 'übernommen von Max (automatisch)') assert.equal(m({ status: 'in_arbeit', claimedBy: 'mensch:u9' }).gehalten, 'übernommen von Kollege') // bei veraltet hält niemand mehr, auch wenn der frühere Bearbeiter noch drinsteht assert.equal(m({ status: 'veraltet', claimedBy: 'mensch:u2' }).gehalten, '') // abgeschlossen: Ergebnis-Stempel statt Alter ab Anlage (gesendet grün, die übrigen grau) const done = { createdAt: iso(NOW - 4 * 24 * 3600e3), doneBy: 'mensch:u2', doneAt: iso(NOW - 2 * 3600e3) } u = m({ ...done, status: 'gesendet' }) assert.deepEqual({ ...u.fertig }, { text: 'Gesendet von Kenso · vor 2 Std.', klasse: 'hk-abdruck hk-stempel-gruen' }); assert.equal(u.alter, '') assert.equal(u.label, 'Antwort auf Plattform-Anfrage: Suche Webdesigner, Gesendet von Kenso · vor 2 Std.') assert.deepEqual({ ...m({ ...done, status: 'verworfen' }).fertig }, { text: 'Verworfen von Kenso · vor 2 Std.', klasse: 'hk-abdruck hk-stempel-grau' }) assert.deepEqual({ ...m({ ...done, status: 'erledigt', doneBy: 'max' }).fertig }, { text: 'Erledigt von Max (automatisch) · vor 2 Std.', klasse: 'hk-abdruck hk-stempel-grau' }) assert.equal(m({ ...done, status: 'erledigt', doneBy: undefined }).fertig.text, 'Erledigt · vor 2 Std.') // ohne Interessent keine Kurzinfo, ohne Titel nur die Art im Namen, ohne Alter (kaputte Zeit) kein Alter assert.equal(m({ prospect: null }).info, '') assert.equal(m({ title: ' ' }).label, 'Antwort auf Plattform-Anfrage, vor 3 Std.') assert.equal(m({ createdAt: 'kaputt' }).alter, '') assert.equal(L.prospectInfo({ city: 'Worms' }), 'Worms'); assert.equal(L.prospectInfo({ score: 0 }), 'Score 0'); assert.equal(L.prospectInfo(null), '') }) // --------------------------------------------------------------------------------------------------------------- // Bauteile im Mini-Läufer // Was das Pult von außen holt: Feed (je Filter eine Antwort), Mitarbeiterliste, Zwischenablage, das Interessenten-Panel (hält seine Eigenschaften fest) const STUBS = { usePultFeed: 'export function usePultFeed({ statuses } = {}) { return globalThis.__pultFeedFor(statuses) }', useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }', CopyableCredential: 'export async function copyTextChecked() {}', ProspectPanel: "import { jsx } from 'react'\nexport default function ProspectPanel(props) { globalThis.__panel = props; return jsx('div', { 'data-panel': props.prospectId }) }", } const stubs = { name: 'pult-stubs', setup(build) { build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$|\/ProspectPanel$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-stub' })) build.onLoad({ filter: /.*/, namespace: 'pult-stub' }, (a) => ({ contents: STUBS[a.path], loader: 'js' })) }, } const loadUi = () => esbuild.build({ stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as PultList } from './src/components/workers/PultList.jsx'\nexport { default as PultSzene } from './src/components/workers/pult/PultSzene.jsx'", resolveDir: ROOT, loader: 'js', }, bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact, stubs], }).then(importBundle) const nodes = (node, test, out = []) => { if (node === null || typeof node === 'string') return out if (test(node)) out.push(node) for (const child of node.children) nodes(child, test, out) return out } const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join('')) const hasClass = (n, name) => n.tag !== undefined && String(n.props.className ?? '').split(' ').includes(name) const byClass = (tree, name) => nodes(tree, (n) => typeof n.tag === 'string' && hasClass(n, name)) const btn = (tree, label) => nodes(tree, (n) => (n.tag === 'button' || n.tag === 'a') && textOf(n).trim() === label) const classesIn = (tree, into = new Set()) => { for (const n of nodes(tree, (x) => typeof x.tag === 'string')) for (const c of String(n.props.className ?? '').split(' ')) if (c) into.add(c) return into } function fakeFocus() { const body = { name: 'body' } const doc = { body, documentElement: { name: 'html' }, activeElement: body } const f = { doc, body, found: new Map(), made: new Map(), all: [] } f.el = (name, extra = {}) => ({ name, isConnected: true, disabled: false, focusCalls: 0, focus() { this.focusCalls += 1; doc.activeElement = this }, querySelector: (selector) => f.found.get(selector) ?? null, querySelectorAll: () => f.all, ...extra, }) f.ref = (key) => f.made.get(key) const had = Object.hasOwn(globalThis, 'document') const saved = globalThis.document globalThis.document = doc globalThis.__fakeRef = (tag, props) => { const key = String(props.className ?? tag).split(' ')[0] if (!f.made.has(key)) f.made.set(key, f.el(key)) return f.made.get(key) } f.restore = () => { if (had) globalThis.document = saved else delete globalThis.document delete globalThis.__fakeRef } return f } // Ein Lauf des Pults: items gelten für jeden Filter (der Test steuert über die Liste, was sichtbar ist); opts: kpis, onBack, api, 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 })) 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 “ 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, / 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(/]*\btype="button"/g) ?? []).length, `${f}: Knopf ohne type`) // jeder externe Link öffnet ohne Rückbezug for (const m of s.matchAll(/]*\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)')) })