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

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

1382 lines
95 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'
import { fakeReact } from './helpers/fakeReact.mjs'
// Die React-Teile sind unter Node nicht ladbar (JSX, Router): geprüft wird der Quelltext (Muster adminRoute.test.mjs); die Logik
// steckt in P3-T18. usePultFeed und PultList laufen wirklich: im Mini-Läufer anstelle von React (helpers/fakeReact.mjs), PultList und Brief zusätzlich als Text gerendert.
// Hustler-Pixelwelt T9: aus der Zeile (PultRow) wurde der Brief (pult/Brief.jsx), die Liste zeigt Umschläge (pult/Klemmbrett.jsx). Die fachlichen Zusicherungen gelten
// unverändert; wo früher in der Zeile geklickt wurde, wird jetzt erst der Umschlag geöffnet. Der Quelltext des Pults liegt in mehreren Dateien: pultSource() fügt sie zusammen.
const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
const PULT_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',
]
const pultSource = () => PULT_DATEIEN.map(read).join('\n')
test('WorkersPage hängt den Reiter freigabe an hochhaus an und zeigt dort die PultList', () => {
const s = read('src/pages/WorkersPage.jsx')
assert.match(s, /id: 'hochhaus'/); assert.match(s, /id: 'freigabe'/); assert.match(s, /import PultList from/); assert.match(s, /<PultList[^>]*api=\{api\}/)
})
test('PultList: sichere Links, kein innerHTML, Entwurf bearbeitbar, Filter aus PULT_FILTERS', () => {
const s = pultSource()
assert.doesNotMatch(s, /dangerouslySetInnerHTML/)
assert.match(s, /rel="noopener noreferrer"/); assert.match(s, /PULT_FILTERS/); assert.match(s, /copyAndOpen/)
assert.doesNotMatch(s, /readOnly[^>]*hk-brief-entwurf|hk-brief-entwurf[^>]*readOnly/)
})
test('usePultFeed: 30-s-Takt nur bei sichtbarem Tab', () => {
const s = read('src/hooks/usePultFeed.js')
assert.match(s, /30000/); assert.match(s, /document\.visibilityState/); assert.match(s, /visibilitychange/)
})
test('CSS: hk-pult-Klassen und Handy-Umbruch; jede im JSX benutzte hk-Klasse ist definiert (ProspectPanel bleibt bei den wk-Klassen von workers.css)', () => {
const css = ['pult.css', 'hustler-ui.css', 'hustler-pult-ui.css'].map((f) => read('src/styles/hustler/' + f)).join('\n')
const own = read('src/styles/hustler/hustler-pult-ui.css')
assert.match(own, /\.hk-pult \.hk-brief\b/); assert.match(own, /@media \(max-width: 719px\)[\s\S]*\.hk-brief\b/)
for (const f of PULT_DATEIEN) for (const m of read(f).matchAll(/(?<![\w-])hk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), `${f}: ${m[0]}`)
const wk = read('src/styles/workers.css')
for (const m of read('src/components/workers/ProspectPanel.jsx').matchAll(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(wk.includes('.' + m[0]), `ProspectPanel: ${m[0]}`)
// das Pult selbst benutzt keine wk-Klassen mehr
for (const f of PULT_DATEIEN) assert.doesNotMatch(read(f), /(?<![\w-])wk-[a-z0-9-]+/, f)
})
test('PultList: Aktionen über pultAction, "Als gesendet" sendet den bearbeiteten Text, Verwerfen fragt zuerst nach, Meldungen ok|err', () => {
const s = pultSource()
assert.match(s, /api\.pultAction\(row\.id, 'claim'\)/); assert.match(s, /api\.pultAction\(row\.id, 'sent', \{ text \}\)/)
assert.match(s, /api\.pultAction\(row\.id, 'discard', \{\}\)/); assert.match(s, /api\.pultAction\(row\.id, 'done'\)/)
// Kopieren & öffnen: Text ist der Inhalt des Textfelds (nicht row.copyText), das Öffnen geschieht ohne Referrer und ohne Rückbezug
assert.match(s, /copyAndOpen\(\{ text, url: row\.sourceHref, copy: copyTextChecked, open: openTab \}\)/)
assert.match(s, /window\.open\(url, '_blank', 'noopener,noreferrer'\)/)
assert.match(s, /Wirklich verwerfen\?/); assert.match(s, /CONFIRM_MS = 4000/)
// „Details“ heißt im Brief „Interessent“ (Spec E11)
for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Erledigt', 'Originalanfrage', 'Nichts wartet auf dich', 'Interessent']) assert.ok(s.includes(t), t)
assert.match(s, /hk-hinweis-err/); assert.match(s, /hk-hinweis-ok/); assert.match(s, /'ok'/); assert.match(s, /'err'/)
// der Mail-Link trägt den bearbeiteten Text, nicht den gespeicherten Entwurf
assert.match(s, /mailtoHref\(\{[\s\S]*?body: text \}\)/)
// der Text erscheint nur als Textknoten
assert.doesNotMatch(s, /innerHTML/)
})
// ---- gemeinsame Bausteine der Läufer: EIN Mini-Läufer anstelle von React (Hooks, Zustand, Effekte, Baum aus { tag, props, children }), er liegt in helpers/fakeReact.mjs ----
// ---- und EINE Tabelle mit Attrappen für alles, was PultList von außen holt. usePultFeed und PultList laufen im selben Läufer. ----
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
// Was PultList von außen holt: der Feed (je Filter eine Antwort aus globalThis.__pultFeedFor), die Mitarbeiterliste, die Zwischenablage, das Panel (Dialog)
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: 'export default function ProspectPanel() { return null }', // das Panel gehört nicht zu diesen Tests (workersProspect.test.mjs)
}
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' }))
},
}
// ---- usePultFeed läuft wirklich: im Mini-Läufer, falsche document/window mit von Hand weitergestelltem Takt ----
async function loadFeed() {
const { usePultFeed, __mount, jsx } = await importBundle(await esbuild.build({
stdin: { contents: "export { usePultFeed } from './src/hooks/usePultFeed.js'\nexport { __mount, jsx } from 'react'", resolveDir: ROOT, loader: 'js' },
bundle: true, format: 'esm', write: false, platform: 'neutral', plugins: [fakeReact],
}))
// Der Hook läuft in einem Bauteil, das nichts zeichnet und sein Ergebnis festhält
const mountHook = (hook) => {
let result
const mounted = __mount(jsx(() => { result = hook(); return null }, {}))
return { result: () => result, rerender: mounted.rerender, lateUpdates: mounted.lateUpdates, unmount: mounted.unmount }
}
return { usePultFeed, mountHook }
}
// document und window nur für die Dauer eines Tests: Takt (setInterval) und visibilitychange werden von Hand ausgelöst
function fakeBrowser() {
const listeners = new Map()
const timers = new Map()
let nextTimer = 1
const doc = {
visibilityState: 'visible',
addEventListener: (type, fn) => listeners.set(type, [...(listeners.get(type) ?? []), fn]),
removeEventListener: (type, fn) => listeners.set(type, (listeners.get(type) ?? []).filter((f) => f !== fn)),
}
const win = {
setInterval: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id },
clearInterval: (id) => { timers.delete(id) },
}
const saved = { document: globalThis.document, window: globalThis.window }
globalThis.document = doc
globalThis.window = win
return {
doc,
timers,
listenerCount: (type) => (listeners.get(type) ?? []).length,
fire: (type) => { for (const fn of listeners.get(type) ?? []) fn() },
tick: () => { for (const t of [...timers.values()]) t.fn() },
restore: () => { globalThis.document = saved.document; globalThis.window = saved.window },
}
}
// api.pult-Attrappe: jeder Aufruf ist ein offenes Versprechen, das der Test selbst auflöst
function fakeApi() {
const calls = []
return {
calls,
pult: (status) => new Promise((resolve, reject) => { calls.push({ status, resolve, reject }) }),
}
}
const settle = () => new Promise((resolve) => setImmediate(resolve))
const itemsOf = (...ids) => ({ items: ids.map((id) => ({ $id: id })) })
test('usePultFeed: lädt beim Start, zeigt die Einträge, deutscher Fehler bleibt neben den letzten Einträgen stehen', async () => {
const { usePultFeed, mountHook } = await loadFeed()
const b = fakeBrowser()
try {
const api = fakeApi()
const hook = mountHook(() => usePultFeed({ api, statuses: 'offen,in_arbeit' }))
assert.deepEqual(api.calls.map((c) => c.status), ['offen,in_arbeit'])
assert.deepEqual([hook.result().loading, hook.result().items, hook.result().error], [true, [], null])
api.calls[0].resolve(itemsOf('a', 'b')); await settle()
assert.deepEqual([hook.result().loading, hook.result().items.map((i) => i.$id), hook.result().error], [false, ['a', 'b'], null])
// Takt: ein Abruf schlägt fehl, die Einträge bleiben, der Satz des Dienstes erscheint
assert.equal([...b.timers.values()][0].ms, 30000)
b.tick()
api.calls[1].reject(Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })); await settle()
assert.deepEqual([hook.result().items.map((i) => i.$id), hook.result().error], [['a', 'b'], 'Der Hustler-Dienst ist gerade nicht erreichbar.'])
// der nächste Erfolg räumt den Fehler weg
b.tick(); api.calls[2].resolve(itemsOf('a')); await settle()
assert.deepEqual([hook.result().items.map((i) => i.$id), hook.result().error], [['a'], null])
hook.unmount()
} finally { b.restore() }
})
test('usePultFeed: Takt und Neuladen nur bei sichtbarem Tab; zurück im Vordergrund wird sofort geladen', async () => {
const { usePultFeed, mountHook } = await loadFeed()
const b = fakeBrowser()
try {
const api = fakeApi()
const hook = mountHook(() => usePultFeed({ api, statuses: 'offen' }))
api.calls[0].resolve(itemsOf('a')); await settle()
b.doc.visibilityState = 'hidden'
b.tick(); b.fire('visibilitychange') // versteckt: weder Takt noch Wechsel-Ereignis rufen den Dienst
assert.equal(api.calls.length, 1)
b.doc.visibilityState = 'visible'; b.fire('visibilitychange')
assert.equal(api.calls.length, 2) // zurück im Vordergrund: sofort
b.tick()
assert.equal(api.calls.length, 3)
hook.unmount()
assert.equal(b.timers.size, 0); assert.equal(b.listenerCount('visibilitychange'), 0) // aufgeräumt
} finally { b.restore() }
})
test('usePultFeed: nur die letzte Anfrage zählt (späte alte Antwort wird verworfen); refresh() holt sofort neu', async () => {
const { usePultFeed, mountHook } = await loadFeed()
const b = fakeBrowser()
try {
const api = fakeApi()
const hook = mountHook(() => usePultFeed({ api, statuses: 'offen' }))
const first = api.calls[0]
const refreshed = hook.result().refresh() // zweite Anfrage, während die erste noch läuft
assert.equal(api.calls.length, 2)
api.calls[1].resolve(itemsOf('neu')); await refreshed; await settle()
first.resolve(itemsOf('alt')); await settle() // die ältere Antwort kommt zuletzt und darf nichts überschreiben
assert.deepEqual(hook.result().items.map((i) => i.$id), ['neu'])
// ein später Fehler der alten Anfrage zählt ebenfalls nicht
b.tick(); const late = api.calls[2]
b.tick(); api.calls[3].resolve(itemsOf('neuer')); await settle()
late.reject(new Error('zu spät')); await settle()
assert.deepEqual([hook.result().items.map((i) => i.$id), hook.result().error], [['neuer'], null])
hook.unmount()
} finally { b.restore() }
})
test('usePultFeed: Wechsel des Filters zeigt nie die Einträge des alten Filters und verwirft dessen späte Antwort', async () => {
const { usePultFeed, mountHook } = await loadFeed()
const b = fakeBrowser()
try {
const api = fakeApi()
let statuses = 'offen,in_arbeit'
const hook = mountHook(() => usePultFeed({ api, statuses }))
api.calls[0].resolve(itemsOf('a')); await settle()
statuses = 'veraltet'; hook.rerender()
assert.equal(api.calls.at(-1).status, 'veraltet')
assert.deepEqual([hook.result().loading, hook.result().items], [true, []])
statuses = 'gesendet,verworfen,erledigt'; hook.rerender() // noch einmal gewechselt, bevor 'veraltet' antwortet
api.calls.find((c) => c.status === 'veraltet').resolve(itemsOf('v')); await settle()
assert.deepEqual([hook.result().loading, hook.result().items], [true, []])
api.calls.at(-1).resolve(itemsOf('e')); await settle()
assert.deepEqual([hook.result().loading, hook.result().items.map((i) => i.$id)], [false, ['e']])
hook.unmount()
} finally { b.restore() }
})
test('usePultFeed: nach dem Abbau setzt eine späte Antwort nichts mehr', async () => {
const { usePultFeed, mountHook } = await loadFeed()
const b = fakeBrowser()
try {
const api = fakeApi()
const hook = mountHook(() => usePultFeed({ api, statuses: 'offen' }))
hook.unmount()
api.calls[0].resolve(itemsOf('a')); await settle()
assert.equal(hook.lateUpdates(), 0)
// auch ein später Fehler setzt nichts
const second = fakeApi()
const again = mountHook(() => usePultFeed({ api: second, statuses: 'offen' }))
again.unmount()
second.calls[0].reject(new Error('zu spät')); await settle()
assert.equal(again.lateUpdates(), 0)
} finally { b.restore() }
})
// ---- PultList und Brief werden mit echtem React auf einen Text gerendert (renderToStaticMarkup); Attrappen aus STUBS ----
// Die Liste zeigt Umschläge; die Aktionen stehen im Brief. Der Brief wird für sich gerendert, mit derselben rowModel wie in der Seite.
async function loadList() {
return importBundle(await esbuild.build({
stdin: {
contents: [
"import { createElement } from 'react'",
"import { renderToStaticMarkup } from 'react-dom/server'",
"import PultList from './src/components/workers/PultList.jsx'",
"import Brief from './src/components/workers/pult/Brief.jsx'",
"import { rowModel } from './src/lib/workersPult.js'",
// Stufen-Sperre (hustlerStufe.js): die Zusicherungen dieser Datei gelten für das volle Pult (Stufe 6, P4-Aktionen offen, Verhalten wie vor der Sperre);
// die Tests „Stufe 3“ am Ende der Datei reichen stufe: 3 durch
'export const render = (props) => renderToStaticMarkup(createElement(PultList, { stufe: 6, ...props }))',
'export const renderBrief = (item, { me, nowMs, employees, text, fields, stufe = 6 } = {}) => {',
' const row = rowModel(item, { me, nowMs, employees, stufe })',
' return renderToStaticMarkup(createElement(Brief, { api: {}, item, row, text: text ?? row.copyText, fields: fields ?? {}, onText() {}, onField() {}, refresh: async () => {}, onFinished() {}, onClose() {}, onDetails() {}, onSpaet() {} }))',
'}',
].join('\n'),
resolveDir: ROOT, loader: 'js',
},
bundle: true, format: 'esm', platform: 'node', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [stubs],
// react-dom/server ist CommonJS und holt sich Node-Module über require: im Data-URL-Modul gibt es keins
banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(process.cwd() + '/');" },
}))
}
// Stempel-Knopf (Brief) mit diesem Text: <button type="button" class="hk-stempel …">[Icon]Text</button>
const stempel = (html, label) => new RegExp(`<button type="button" class="hk-stempel[^"]*"[^>]*>(?:<span[^>]*></span>)?${label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}</button>`).test(html)
test('Pult (gerendert): Aktionen je Art und Status im Brief, Fremdtext nur als Text, sichere Links, Mail-Link mit genau einer Adresse', async () => {
const { render, renderBrief } = await loadList()
const now = Date.now()
const iso = (ms) => new Date(ms).toISOString()
const me = { id: 'u1', name: 'Justin' }
const employees = [{ userId: 'u2', displayName: 'Kenso' }]
const brief = (item) => renderBrief(item, { me, nowMs: now, employees })
const show = (items, extra = {}) => {
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {}, ...extra })
globalThis.__pultEmployees = employees
return render({ api: {}, me })
}
const base = { $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 } }
try {
// offene Plattform-Antwort: alle vier Aktionen, Link mit rel, Kurzinfo, Interessent; das Textfeld trägt den Entwurf
let html = brief({ ...base, originalText: 'Original <img src=x onerror=alert(1)>', draft: 'Hallo <script>alert(1)</script>', title: 'T <b>x</b>' })
for (const t of ['Kopieren &amp; öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Interessent', '66640 Namborn · Score 72', 'vor 3 Std.']) assert.ok(html.includes(t), t)
assert.ok(!stempel(html, 'Erledigt'))
assert.match(html, /<a class="hk-link" href="https:\/\/www\.kleinanzeigen\.de\/s-anzeige\/x\/1-2-3" target="_blank" rel="noopener noreferrer" aria-label="Anzeige öffnen: T &lt;b&gt;x&lt;\/b&gt;">/)
assert.match(html, /<textarea class="hk-feld hk-brief-entwurf"[^>]*>Hallo &lt;script&gt;alert\(1\)&lt;\/script&gt;<\/textarea>/)
// Fremdtext erscheint nie als Markup
assert.ok(!html.includes('<script>') && !html.includes('<img') && !html.includes('<b>x</b>')); assert.ok(html.includes('Original &lt;img src=x onerror=alert(1)&gt;'))
// Kollege hat übernommen: Name statt „Ich kümmere mich“, der Rest bleibt bedienbar (der Dienst entscheidet mit 409); javascript:-Adresse gibt keinen Link, nur „Kopieren“
html = brief({ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2', sourceUrl: 'javascript:alert(1)' })
assert.ok(html.includes('übernommen von Kenso')); assert.ok(!html.includes('Ich kümmere mich')); assert.ok(html.includes('Als gesendet markieren'))
assert.ok(!html.includes('href="javascript:')); assert.ok(!html.includes('Anzeige öffnen')); assert.ok(stempel(html, 'Kopieren'))
// veraltet und „läuft bald ab“ stehen als Marken am Umschlag
html = show([{ ...base, status: 'veraltet' }, { ...base, $id: 'b', staleAt: iso(now + 5 * 3600e3) }])
assert.ok(html.includes('<span class="hk-marke hk-marke-veraltet"><span class="hk-ic hk-ic-klein hk-ic-veraltet" aria-hidden="true"></span>veraltet</span>'))
assert.ok(html.includes('<span class="hk-marke hk-marke-frist"><span class="hk-ic hk-ic-klein hk-ic-sanduhr-k" aria-hidden="true"></span>läuft in 5 Std. ab</span>'))
// nicht sendbare Art: Erledigt statt Senden, aber Verwerfen bleibt; abgeschlossene Einträge haben keine Aktion
html = brief({ ...base, kind: 'unsicher', draft: '' })
assert.ok(stempel(html, 'Erledigt')); assert.ok(html.includes('Verwerfen')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('<textarea'))
// gesendet: nichts mehr zu senden oder zu verwerfen; ohne Interessent bleibt der Brief ohne Aktionen, mit Interessent nur „Antwort des Kunden einfügen“ (P4-T32, D13)
html = brief({ ...base, status: 'gesendet', prospect: null })
assert.ok(!html.includes('hk-aktionen')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen')); assert.ok(html.includes('<h3 class="hk-brief-h">Entwurf</h3>'))
assert.ok(html.includes('Dieser Eintrag ist abgeschlossen.'))
html = brief({ ...base, status: 'gesendet' })
assert.ok(html.includes('hk-aktionen')); assert.ok(html.includes('Antwort des Kunden einfügen')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen'))
assert.ok(!html.includes('Kopieren')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('Ich kümmere mich'))
// Mail-Antwort: Link mit genau einer Adresse und kodiertem Text; eine eingeschleuste zweite Adresse ergibt keinen Link
const mail = { ...base, kind: 'mail_antwort', draft: 'Guten Tag\nBis bald', sourceUrl: '' }
html = brief({ ...mail, prospect: { $id: 'p2', name: 'Firma', email: 'x@firma.example' } })
assert.match(html, /<a class="hk-stempel hk-stempel-haupt" href="mailto:x@firma\.example\?subject=Re%3A%20Firma&amp;body=Guten%20Tag%0D%0ABis%20bald"><span class="hk-ic hk-ic-art-mail" aria-hidden="true"><\/span>E-Mail schreiben<\/a>/)
assert.ok(!html.includes('Kopieren'))
html = brief({ ...mail, prospect: { $id: 'p2', name: 'Firma', email: 'x@firma.example?bcc=z@w.de' } })
assert.ok(!html.includes('mailto:')); assert.ok(html.includes('Keine gültige E-Mail-Adresse hinterlegt.'))
// Zustände der Liste: laden, leer je Filter, Fehler ohne und mit Einträgen
assert.ok(show([], { loading: true }).includes('Wird geladen'))
assert.ok(show([]).includes('Nichts wartet auf dich'))
html = show([], { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' })
assert.ok(html.includes('Das Pult ist gerade nicht erreichbar')); assert.ok(html.includes('Der Hustler-Dienst ist gerade nicht erreichbar.')); assert.ok(html.includes('Neu laden'))
html = show([base], { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' })
assert.ok(html.includes('hk-hinweis hk-hinweis-warn')); assert.ok(html.includes('Verbindung gestört: Der Hustler-Dienst ist gerade nicht erreichbar. Es wird weiter versucht.')); assert.ok(html.includes('Suche Webdesigner'))
// die Reiter kommen aus PULT_FILTERS, „Wartet“ ist vorgewählt (am offenen Reiter steht die Länge der geladenen Liste, an den anderen keine Zahl)
html = show([])
assert.match(html, /<button type="button" class="hk-reiter hk-reiter-wartet hk-reiter-an" aria-pressed="true" aria-label="Wartet, 0 Einträge">Wartet<span class="hk-zahl">0<\/span><\/button><button type="button" class="hk-reiter hk-reiter-veraltet" aria-pressed="false">Veraltet<\/button><button type="button" class="hk-reiter hk-reiter-erledigt" aria-pressed="false">Erledigt<\/button>/)
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
})
// Endprüfung P3 (W5): Reiter „Erledigt“ – was wurde aus dem Eintrag, wer, wann
test('Pult (gerendert): abgeschlossene Einträge zeigen Ergebnis, Person und Zeit der Erledigung statt des Alters ab Anlage (W5)', async () => {
const { render, renderBrief } = await loadList()
const now = Date.now()
const iso = (ms) => new Date(ms).toISOString()
const employees = [{ userId: 'u2', displayName: 'Anna' }]
const show = (items) => {
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {} })
globalThis.__pultEmployees = employees
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
}
const base = { kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 4 * 24 * 3600e3), doneBy: 'mensch:u2', doneAt: iso(now - 2 * 3600e3) }
try {
const items = [
{ ...base, $id: 'a', status: 'gesendet' },
{ ...base, $id: 'b', status: 'verworfen', title: 'Zweite' },
{ ...base, $id: 'c', status: 'erledigt', kind: 'unsicher', title: 'Dritte', doneBy: 'max' },
]
for (const html of [show(items), ...items.map((item) => renderBrief(item, { me: { id: 'u1', name: 'Justin' }, nowMs: now, employees }))]) {
assert.ok(!html.includes('vor 4 Tagen')) // das Alter ab Anlage steht bei Abgeschlossenem nicht mehr da
assert.ok(!html.includes('Kollege'))
}
const list = show(items)
assert.ok(list.includes('<span class="hk-abdruck hk-stempel-gruen">Gesendet von Anna · vor 2 Std.</span>'))
assert.ok(list.includes('<span class="hk-abdruck hk-stempel-grau">Verworfen von Anna · vor 2 Std.</span>'))
assert.ok(list.includes('<span class="hk-abdruck hk-stempel-grau">Erledigt von Max (automatisch) · vor 2 Std.</span>'))
assert.ok(renderBrief(items[0], { me: { id: 'u1', name: 'Justin' }, nowMs: now, employees }).includes('<span class="hk-abdruck hk-stempel-gruen">Gesendet von Anna · vor 2 Std.</span>'))
// offene Zeile: wie bisher Alter ab Anlage, kein Ergebnis-Stempel
const open = show([{ ...base, $id: 'd', status: 'offen', doneBy: undefined, doneAt: undefined }])
assert.ok(open.includes('Antwort auf Plattform-Anfrage · vor 4 Tagen')); assert.ok(!open.includes('Gesendet')); assert.ok(!open.includes('hk-abdruck'))
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
})
// Endprüfung P3 (W8): „Interessent“ (früher „Details“), „Anzeige öffnen“, Entwurfsfeld und Aktionsgruppe tragen den Titel des Eintrags im zugänglichen Namen
test('Pult (gerendert): wiederkehrende Bedienelemente haben je Eintrag einen eigenen zugänglichen Namen, sichtbares Wort vorn (W8)', async () => {
const { render, renderBrief } = await loadList()
const me = { id: 'u1', name: 'Justin' }
const base = { kind: 'plattform_antwort', status: 'offen', draft: 'Hallo', createdAt: new Date().toISOString(), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
const brief = (item) => renderBrief(item, { me, nowMs: Date.now(), employees: [] })
try {
const html = brief({ ...base, $id: 'a', title: 'Suche Webdesigner' })
for (const name of ['Interessent zu Suche Webdesigner', 'Anzeige öffnen: Suche Webdesigner', 'Entwurf der Antwort: Suche Webdesigner', 'Aktionen: Suche Webdesigner']) {
assert.ok(html.includes(`aria-label="${name}"`), name)
}
// Fremdtext im Namen ist als Attribut kodiert; zwei Einträge ⇒ zwei verschiedene Namen (Umschläge in der Liste, Brief je Eintrag)
const titles = ['Suche Webdesigner', 'Homepage für Handwerker <b>x</b> & "Co"']
const second = brief({ ...base, $id: 'b', title: titles[1] })
assert.ok(second.includes('aria-label="Interessent zu Homepage für Handwerker &lt;b&gt;x&lt;/b&gt; &amp; &quot;Co&quot;"'))
assert.ok(second.includes('aria-label="Aktionen: Homepage für Handwerker &lt;b&gt;x&lt;/b&gt; &amp; &quot;Co&quot;"'))
assert.ok(!second.includes('<b>x</b>'))
globalThis.__pultFeedFor = () => ({ items: [{ ...base, $id: 'a', title: titles[0] }, { ...base, $id: 'b', title: titles[1] }], loading: false, error: null, refresh: async () => {} })
globalThis.__pultEmployees = []
const list = render({ api: {}, me })
const names = [...list.matchAll(/class="hk-umschlag" aria-haspopup="dialog" aria-label="([^"]*)"/g)].map((m) => m[1])
assert.equal(names.length, 2); assert.equal(new Set(names).size, 2)
assert.ok(names[1].includes('Homepage für Handwerker &lt;b&gt;x&lt;/b&gt; &amp; &quot;Co&quot;')); assert.ok(!list.includes('<b>x</b>'))
// das sichtbare Wort bleibt (WCAG 2.5.3) und steht im Namen vorn
assert.match(html, /aria-label="Interessent zu Suche Webdesigner">Interessent<\/button>/)
assert.match(html, /aria-label="Anzeige öffnen: Suche Webdesigner">Anzeige öffnen<\/a>/)
// leerer oder fehlender Titel: schlichter Name, nie „zu undefined“
for (const title of ['', ' ', undefined]) {
const bare = brief({ ...base, $id: 'c', title })
assert.ok(!bare.includes('undefined'), String(title))
assert.ok(!bare.includes('Interessent zu'), String(title)); assert.ok(!bare.includes('Anzeige öffnen:'), String(title))
assert.ok(bare.includes('aria-label="Entwurf der Antwort"'), String(title)); assert.ok(bare.includes('aria-label="Aktionen"'), String(title))
assert.match(bare, />Interessent<\/button>/)
}
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
})
// ---- P4-T32: als Mensch antworten, Vorschau freigeben, Vorschau bauen, Antwort des Kunden einfügen ----
test('PultList (P4-T32): Quelltext – Aktionen über pultAction, Prüfung mit validateReplyText, Freigabe mit Rückfrage, sichere Vorschau-Adresse', () => {
const s = pultSource()
for (const t of [
"'reply'", "'approve-preview'", "'paste'", "'start-build'", 'validateReplyText', 'canReply', 'canApprovePreview', 'canStartBuild', 'canPaste', 'previewHref',
'actionErrorText', 'pasteDoneText', 'rel="noopener noreferrer"', 'target="_blank"',
'Als Mensch antworten', 'Jetzt senden', 'Vorschau ansehen', 'Freigeben & senden', 'Wirklich freigeben?', 'Vorschau bauen', 'Wirklich bauen?',
'Antwort des Kunden einfügen', 'Einfügen', 'hk-feld-antwort', 'hk-feld-einfuegen', 'Abbrechen', 'data-opens', 'Escape', 'restoreFocus', 'activeControl', 'tabIndex={-1}',
'Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.',
]) assert.ok(s.includes(t), t)
assert.doesNotMatch(s, /dangerouslySetInnerHTML/); assert.doesNotMatch(s, /innerHTML/)
// jede der vier neuen Aktionen geht über den Client: Antwort mit dem geprüften Text, Freigabe und Bau ohne Body, Einfügen mit dem geprüften Text
assert.match(s, /api\.pultAction\(row\.id, 'reply', \{ text: [\w.]+ \}\)/)
assert.match(s, /api\.pultAction\(row\.id, 'approve-preview'\)/)
assert.match(s, /api\.pultAction\(row\.id, 'start-build'\)/)
assert.match(s, /api\.pultAction\(row\.id, 'paste', \{ text: [\w.]+ \}\)/)
// beide Texte werden vor dem Senden mit validateReplyText geprüft, ein Fehler erscheint als Meldung im Brief
assert.equal((s.match(/validateReplyText\(/g) ?? []).length, 2)
// die Vorschau-Adresse kommt aus rowModel (previewHref) und öffnet ohne Rückbezug in einem neuen Tab
assert.match(s, /href=\{row\.previewHref\}[^>]*target="_blank"[^>]*rel="noopener noreferrer"/)
// die Rückfrage vor dem Freigeben nutzt dieselbe Frist wie das Verwerfen (4 s)
assert.match(s, /CONFIRM_MS = 4000/)
// „Als Mensch antworten“ ist mit dem Entwurf vorbelegt, „Einfügen“ nie: die Felder sind getrennt und das Einfüge-Feld beginnt leer
assert.match(s, /fields\.reply \?\? text/); assert.match(s, /fields\.paste \?\? ''/)
assert.doesNotMatch(s, /fields\.paste \?\? (text|row\.copyText)/); assert.doesNotMatch(s, /fields\.reply \?\? (fields\.paste|'')/)
// das Fehlerschema des Briefs bleibt: Meldung ok|err, Fehlertext über actionErrorText (D14: Zeitüberschreitung ⇒ „Kann gesendet sein“)
assert.match(s, /actionErrorText\(err, action\)/)
})
test('PultList (P4-T32): Aktionsfeld erscheint auch bei gesendet, wenn eine der vier neuen Aktionen möglich ist (D13)', () => {
const s = pultSource()
assert.match(s, /row\.canSend \|\| row\.canClaim \|\| row\.canDone \|\| row\.canDiscard \|\| row\.canReply \|\| row\.canApprovePreview \|\| row\.canStartBuild \|\| row\.canPaste/)
})
test('CSS (P4-T32): Antwort- und Einfüge-Feld mit Zähler und zwei Knöpfen, auf dem Handy untereinander (Abbrechen unten, wie bisher)', () => {
const css = read('src/styles/hustler/hustler-pult-ui.css').replace(/\/\*[\s\S]*?\*\//g, '')
assert.match(css, /\.hk-pult \.hk-feld-panel\s*\{/); assert.match(css, /\.hk-pult \.hk-zaehler-ueber\s*\{/)
assert.match(css, /\.hk-pult \.hk-feld-knoepfe\s*\{[^}]*display:\s*flex/)
// schmale Bildschirme (unter 720 px, Spec E13): die Fußzeile des Feldes (Zähler, Knöpfe) steht untereinander, „Abbrechen“ als letzter
const phone = css.slice(css.indexOf('@media (max-width: 719px)'))
assert.match(phone, /\.hk-feld-fuss\s*\{[^}]*flex-direction:\s*column/); assert.match(phone, /\.hk-feld-knoepfe\s*\{[^}]*flex-direction:\s*column-reverse/)
})
test('Pult (gerendert, P4-T32): Aktionen je Art im Brief – Antworten, Freigeben, Bauen, Einfügen; Felder bleiben zu, Fremdtext nur als Text', async () => {
const { renderBrief } = await loadList()
const now = Date.now()
const iso = (ms) => new Date(ms).toISOString()
const employees = [{ userId: 'u2', displayName: 'Kenso' }]
const show = (item) => renderBrief(item, { me: { id: 'u1', name: 'Justin' }, nowMs: now, employees })
const base = { $id: 'a', kind: 'unsicher', status: 'offen', title: 'Unklare Antwort', draft: '', createdAt: iso(now - 3600e3), prospectId: 'p1', prospect: { $id: 'p1', name: 'Verein', email: 'x@verein.example' } }
const buttonOf = stempel
// unsicher mit Interessent und Adresse: antworten, bauen, erledigen, verwerfen; die Felder sind zu
let html = show(base)
for (const t of ['Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich']) assert.ok(buttonOf(html, t), t)
for (const t of ['Antwort des Kunden einfügen', 'Freigeben', 'Jetzt senden', 'hk-feld-antwort', 'hk-feld-einfuegen', '<textarea']) assert.ok(!html.includes(t), t)
assert.match(html, /<button[^>]*aria-expanded="false"[^>]*>(?:<span[^>]*><\/span>)?Als Mensch antworten<\/button>/)
// „Mail ohne Faden“: weder Interessent noch Adresse ⇒ weder Antworten noch Bauen
html = show({ ...base, prospectId: undefined, prospect: null })
assert.ok(!html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen')); assert.ok(buttonOf(html, 'Erledigt'))
// Interessent ohne Adresse (Plattform): kein Mail-Kanal ⇒ nicht antworten, aber bauen
html = show({ ...base, prospect: { $id: 'p1', name: 'Verein' } })
assert.ok(!html.includes('Als Mensch antworten')); assert.ok(html.includes('Vorschau bauen'))
// Endprüfung P4 (W2): ein toter Interessent (gesperrt, verloren, Deal) bekommt keinen Knopf zum Bauen, ein lebender behält ihn
for (const status of ['gesperrt', 'verloren', 'deal']) {
html = show({ ...base, prospect: { ...base.prospect, status } })
assert.ok(!html.includes('Vorschau bauen'), status); assert.ok(buttonOf(html, 'Als Mensch antworten'), status); assert.ok(buttonOf(html, 'Erledigt'), status)
}
assert.ok(buttonOf(show({ ...base, prospect: { ...base.prospect, status: 'gespraech' } }), 'Vorschau bauen'))
// von einem Kollegen übernommen: weder antworten noch bauen, der Rest bleibt wie bisher
html = show({ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2' })
assert.ok(html.includes('übernommen von Kenso')); assert.ok(!html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen')); assert.ok(buttonOf(html, 'Erledigt'))
// von Max übernommen: „Max (automatisch)“, nie „Kollege“
html = show({ ...base, status: 'in_arbeit', claimedBy: 'max' })
assert.ok(html.includes('übernommen von Max (automatisch)')); assert.ok(!html.includes('Kollege'))
// mail_antwort: Mail-Link und Antwort-Funktion nebeneinander
html = show({ ...base, kind: 'mail_antwort', draft: 'Guten Tag' })
assert.ok(html.includes('E-Mail schreiben')); assert.ok(html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen'))
// preisfrage, mensch_gewuenscht: antworten
for (const kind of ['preisfrage', 'mensch_gewuenscht']) assert.ok(show({ ...base, kind }).includes('Als Mensch antworten'), kind)
assert.ok(!show({ ...base, kind: 'beschwerde' }).includes('Als Mensch antworten'))
// abgeschlossen: keine Antwort-Funktion mehr
for (const status of ['gesendet', 'erledigt', 'verworfen']) {
html = show({ ...base, status })
assert.ok(!html.includes('Als Mensch antworten'), status); assert.ok(!html.includes('Vorschau bauen'), status); assert.ok(!html.includes('hk-aktionen'), status)
}
// vorschau_freigabe: Vorschau ansehen (sicherer Link) + Freigeben & senden + Hinweis zu Verwerfen; kein Erledigt, keine Anzeige-Adresse, keine Antwort-Funktion
const freigabe = { ...base, kind: 'vorschau_freigabe', title: 'Vorschau <b>prüfen</b>', draft: 'Hallo, hier ist Ihre Vorschau.', sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }
html = show(freigabe)
// der Name trägt den Titel des Eintrags (W8), Fremdtext darin ist kodiert
assert.match(html, /<a class="hk-stempel hk-stempel-normal" href="https:\/\/entwurf\.project\.webklar\.com\/AAA\/" target="_blank" rel="noopener noreferrer" aria-label="Vorschau ansehen: Vorschau &lt;b&gt;prüfen&lt;\/b&gt;"><span class="hk-ic hk-ic-ansehen" aria-hidden="true"><\/span>Vorschau ansehen<\/a>/)
assert.ok(html.includes('Freigeben &amp; senden')); assert.ok(buttonOf(html, 'Verwerfen')); assert.ok(html.includes('sendet die Vorschau nicht'))
for (const t of ['Anzeige öffnen', 'Als Mensch antworten', 'Vorschau bauen', 'Wirklich freigeben?']) assert.ok(!html.includes(t), t)
assert.ok(!buttonOf(html, 'Erledigt')); assert.ok(!html.includes('<b>prüfen</b>'))
// gültige Adresse: der Knopf zum Freigeben ist bedienbar
assert.doesNotMatch(html, /<button[^>]*disabled[^>]*>(?:<span[^>]*><\/span>)?Freigeben &amp; senden<\/button>/)
// fremde, unsichere oder fehlende Adresse: kein Link, Hinweis statt Link, und der Knopf zum Freigeben ist gesperrt (Endprüfung W5: der Mensch soll keine
// Vorschau freigeben, die er nicht ansehen kann); der Hinweis nennt den Grund und den Ausweg
for (const sourceUrl of ['https://fremd.example/AAA/', 'javascript:alert(1)', 'http://entwurf.project.webklar.com/AAA/', '']) {
html = show({ ...freigabe, sourceUrl })
assert.ok(!html.includes('Vorschau ansehen</a>'), sourceUrl); assert.ok(!html.includes('href="javascript:'), sourceUrl); assert.ok(!html.includes('fremd.example'), sourceUrl)
assert.ok(html.includes('Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich – bitte den Entwurf prüfen oder verwerfen.'), sourceUrl)
assert.match(html, /<button[^>]*disabled[^>]*>(?:<span[^>]*><\/span>)?Freigeben &amp; senden<\/button>/, sourceUrl)
assert.ok(buttonOf(html, 'Verwerfen')); assert.doesNotMatch(html, /<button[^>]*disabled[^>]*>(?:<span[^>]*><\/span>)?Verwerfen<\/button>/, sourceUrl) // Verwerfen bleibt frei
}
// freigegeben (erledigt) oder von einem Kollegen übernommen: nichts mehr freizugeben
assert.ok(!show({ ...freigabe, status: 'erledigt' }).includes('Freigeben'))
html = show({ ...freigabe, status: 'in_arbeit', claimedBy: 'mensch:u2' })
assert.ok(!html.includes('Freigeben')); assert.ok(buttonOf(html, 'Verwerfen'))
// plattform_antwort: „Antwort des Kunden einfügen“ in jedem Status außer verworfen, auch bei einem Kollegen; das Feld ist zu
const plattform = { ...base, kind: 'plattform_antwort', draft: 'Hallo', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
for (const status of ['offen', 'veraltet', 'gesendet', 'erledigt']) {
html = show({ ...plattform, status })
assert.ok(buttonOf(html, 'Antwort des Kunden einfügen'), status); assert.ok(!html.includes('hk-feld-einfuegen'), status); assert.ok(!html.includes('Als Mensch antworten'), status)
}
html = show({ ...plattform, status: 'in_arbeit', claimedBy: 'mensch:u2' })
assert.ok(buttonOf(html, 'Antwort des Kunden einfügen')); assert.ok(html.includes('übernommen von Kenso'))
assert.ok(!show({ ...plattform, status: 'verworfen' }).includes('Antwort des Kunden einfügen'))
assert.ok(!show({ ...plattform, prospect: null }).includes('Antwort des Kunden einfügen'))
assert.match(show({ ...plattform, status: 'in_arbeit', claimedBy: 'mensch:u2' }), /aria-expanded="false"[^>]*>(?:<span[^>]*><\/span>)?Antwort des Kunden einfügen<\/button>/)
// in den anderen Arten gibt es kein Einfügen
for (const kind of ['mail_antwort', 'unsicher', 'vorschau_freigabe']) assert.ok(!show({ ...base, kind }).includes('Antwort des Kunden einfügen'), kind)
})
// ---- Das Pult wird bedient (P4-T32): derselbe Mini-Läufer (helpers/fakeReact.mjs) statt React und DOM. Die Komponenten laufen wirklich (Zustand, Effekte, Ereignisse), ----
// ---- gerendert wird in einen Baum aus { tag, props, children }; Klicks und Eingaben ruft der Test von Hand auf. Der Dienst ist eine Attrappe. ----
// ---- Seit T9 sitzen die Aktionen im Brief: ui.click(label) öffnet erst den Umschlag des Eintrags (ui.open), wenn dessen Brief noch nicht offen ist. ----
async function loadUi() {
return importBundle(await esbuild.build({
stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as PultList } from './src/components/workers/PultList.jsx'", resolveDir: ROOT, loader: 'js' },
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact, stubs],
}))
}
// Baum durchsuchen und bedienen
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 clickable = (tree, label) => nodes(tree, (n) => (n.tag === 'button' || n.tag === 'a') && textOf(n).trim() === label)
const EMPTY_TREE = { tag: 'fragment', props: {}, children: [] }
// Der offene Brief (null, wenn keiner offen ist), die Umschläge der Liste und die Reiter
const briefOf = (tree) => nodes(tree, (n) => n.tag === 'section' && hasClass(n, 'hk-brief'))[0] ?? null
const envelopes = (tree) => nodes(tree, (n) => n.tag === 'button' && hasClass(n, 'hk-umschlag'))
const inFilters = (tree) => nodes(tree, (n) => n.tag === 'div' && hasClass(n, 'hk-reiter-leiste'))[0]
const fieldOf = (tree, labelStart) => nodes(tree, (n) => n.tag === 'textarea' && String(n.props['aria-label'] ?? '').startsWith(labelStart))
const messages = (tree) => nodes(tree, (n) => n.tag === 'div' && hasClass(n, 'hk-hinweis')).map((n) => ({ kind: String(n.props.className).split(' ').find((c) => c.startsWith('hk-hinweis-')).slice('hk-hinweis-'.length), text: textOf(n), role: n.props.role }))
// Ein Lauf: Liste mit den Einträgen des Reiters „Wartet“ (alle anderen Reiter sind leer), fester Dienst, Takt von Hand
async function startList(items, api, props = {}) {
const { __mount, jsx, PultList } = await loadUi()
const refreshes = []
const timers = new Map()
const opened = []
let nextTimer = 1
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: (...args) => { opened.push(args) },
}
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
globalThis.__pultFeedFor = (statuses) => ({ items: statuses === 'offen,in_arbeit' ? items : [], loading: false, error: null, refresh: async () => { refreshes.push(statuses) } })
const mounted = __mount(jsx(PultList, { api, me: { id: 'u1', name: 'Justin' }, stufe: 6, ...props })) // Stufe 6 = volles Pult; Stufe 3 siehe die Tests am Ende der Datei
let current = null // Nummer des Umschlags, dessen Brief offen ist
const view = {
refreshes, timers, opened,
tree: () => mounted.tree(),
brief: () => briefOf(view.tree()),
// Brief öffnen: Klick auf den Umschlag (Nummer in der Liste)
async open(index = 0) {
const found = envelopes(view.tree())[index]
assert.ok(found, `Umschlag ${index}`)
found.props.onClick({ preventDefault() {} })
current = index
await settle()
assert.ok(view.brief(), 'der Brief ist offen')
},
// Brief schließen: der Knopf „Schließen“
async close() {
const found = nodes(view.brief(), (n) => n.tag === 'button' && n.props['aria-label'] === 'Schließen')
assert.equal(found.length, 1)
found[0].props.onClick()
current = null
await settle()
},
// sorgt dafür, dass der Brief des Umschlags (Standard: der offene, sonst der erste) offen ist
async ensureOpen(row) {
if (view.brief() === null) current = null
if (row === undefined) {
if (view.brief() === null) await view.open(0)
return
}
if (view.brief() !== null && current === row) return
if (view.brief() !== null) await view.close()
await view.open(row)
},
// Klick auf den (einzigen) Knopf/Link mit dieser Beschriftung im Brief; ein gesperrter Knopf löst nichts aus (wie im Browser)
// row: Nummer des Umschlags, wenn mehrere Einträge denselben Knopf haben
async click(label, row) {
await view.ensureOpen(row)
const found = clickable(view.brief(), label)
assert.equal(found.length, 1, `Knopf „${label}“ (gefunden: ${found.length})`)
if (!found[0].props.disabled) found[0].props.onClick?.({ preventDefault() {} })
await settle()
},
// Taste im Feld mit dieser Klasse (hk-feld-antwort, hk-feld-einfuegen); Ergebnis: das Ereignis, damit der Test preventDefault sehen kann
async key(panelClass, key = 'Escape') {
const panel = nodes(view.tree(), (n) => n.tag === 'div' && hasClass(n, panelClass))[0]
assert.ok(panel, 'Feld ' + panelClass)
const event = { key, defaultPrevented: false, stopped: false, preventDefault() { this.defaultPrevented = true }, stopPropagation() { this.stopped = true } }
panel.props.onKeyDown?.(event)
await settle()
return event
},
// Taste am Brief selbst (Esc, Tab)
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
},
async tab(label) {
const found = nodes(inFilters(view.tree()), (n) => n.tag === 'button' && textOf(n).startsWith(label))
assert.equal(found.length, 1, `Reiter „${label}“`)
found[0].props.onClick()
await settle()
},
link: (label) => clickable(view.brief() ?? EMPTY_TREE, label)[0],
has: (label) => clickable(view.brief() ?? EMPTY_TREE, label).length > 0,
disabled: (label) => clickable(view.brief() ?? EMPTY_TREE, label).map((n) => n.props.disabled === true),
async type(labelStart, value) {
const found = fieldOf(view.tree(), labelStart)
assert.equal(found.length, 1, `Textfeld „${labelStart}“ (gefunden: ${found.length})`)
found[0].props.onChange({ target: { value } })
await settle()
},
value: (labelStart) => fieldOf(view.tree(), labelStart).map((n) => n.props.value),
messages: () => messages(view.tree()),
text: () => textOf(view.tree()),
fireTimers() { for (const [id, t] of [...timers]) { timers.delete(id); t.fn() } },
end() { mounted.unmount(); globalThis.window = saved.window; delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees },
}
await settle()
return view
}
// Dienst-Attrappe: jeder Aufruf wird festgehalten, die Antworten kommen der Reihe nach (Wert, Fehler oder offenes Versprechen)
function scriptedApi(...outcomes) {
const calls = []
return {
calls,
pultAction: (...args) => {
calls.push(args)
const next = outcomes.shift()
if (next instanceof Error) return Promise.reject(next)
return next ?? Promise.resolve({})
},
}
}
const failure = (message, status) => Object.assign(new Error(message), { status })
// document-Attrappe für den Fokus (nur für die Dauer eines Tests): activeElement stellt der Test, focus() eines Elements setzt es. Elemente, die der Bauteil über
// ref anfasst (Brief, Aktionsgruppe, Meldung, erster Knopf, Textfeld), liefert __fakeRef je Klasse; querySelector antwortet aus f.found (Selektor -> Element), sonst null.
function fakeFocus() {
const body = { name: 'body' }
const doc = { body, documentElement: { name: 'html' }, activeElement: body }
const f = { doc, body, found: new Map(), made: new Map() }
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, ...extra })
f.ref = (key) => f.made.get(key) // das Element hinter ref an Tag/Klasse 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
}
const unsicher = { $id: 'e1', kind: 'unsicher', status: 'offen', title: 'Unklare Antwort', draft: 'Entwurf von Clara', createdAt: new Date().toISOString(), prospectId: 'p1', prospect: { $id: 'p1', name: 'Verein', email: 'x@verein.example' } }
test('Pult (bedient, P4-T32): „Als Mensch antworten“ klappt auf, ist mit dem Entwurf vorbelegt, prüft den Text und zeigt die Sätze des Dienstes', async () => {
const api = scriptedApi(failure('Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.', 409), failure('Der Hustler-Dienst ist gerade nicht erreichbar.', 0), Promise.resolve({ item: { $id: 'e1' } }))
const ui = await startList([unsicher], api)
try {
assert.deepEqual(ui.value('Text der Antwort'), []) // zu: kein Textfeld
await ui.click('Als Mensch antworten')
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara']) // vorbelegt mit dem Entwurf
assert.ok(ui.text().includes('17 / 8000'))
assert.equal(nodes(ui.tree(), (n) => n.tag === 'button' && textOf(n) === 'Als Mensch antworten')[0].props['aria-expanded'], true)
// leer ⇒ Hinweis im Brief, nichts geht hinaus
await ui.type('Text der Antwort', ' ')
await ui.click('Jetzt senden')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Bitte einen Text eingeben.', role: 'alert' }])
assert.equal(api.calls.length, 0)
// zu lang ⇒ Zähler rot, Hinweis, nichts geht hinaus
await ui.type('Text der Antwort', 'a'.repeat(8001))
assert.match(textOf(nodes(ui.tree(), (n) => n.tag === 'span' && String(n.props.className).includes('hk-zaehler'))[0]), /8001 \/ 8000/)
assert.ok(String(nodes(ui.tree(), (n) => n.tag === 'span' && String(n.props.className).includes('hk-zaehler'))[0].props.className).includes('ueber'))
await ui.click('Jetzt senden')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Der Text ist zu lang (höchstens 8000 Zeichen).', role: 'alert' }])
assert.equal(api.calls.length, 0)
// Dienst lehnt ab (409): der Satz des Dienstes erscheint als Fehler, der Text bleibt zum Wiederholen stehen, die Liste wird neu geholt
await ui.type('Text der Antwort', ' Guten Tag, gern. ')
await ui.click('Jetzt senden')
assert.deepEqual(api.calls, [['e1', 'reply', { text: 'Guten Tag, gern.' }]]) // getrimmt, an die Id des Eintrags
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.', role: 'alert' }])
assert.deepEqual(ui.value('Text der Antwort'), [' Guten Tag, gern. ']); assert.equal(ui.refreshes.length, 1)
// keine Antwort (Zeitüberschreitung, status 0): die Mail kann hinausgegangen sein
await ui.click('Jetzt senden')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Kann gesendet sein – Pult neu laden.', role: 'alert' }])
assert.equal(ui.refreshes.length, 2)
// Erfolg: der Eintrag verlässt die Liste, der Brief schließt, die Meldung steht bei der Liste, die Liste wird neu geholt
await ui.click('Jetzt senden')
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ gesendet.', role: 'status' }])
assert.equal(ui.refreshes.length, 3); assert.equal(api.calls.length, 3)
assert.equal(ui.brief(), null)
// das Feld ist leer geräumt (beim erneuten Öffnen wieder beim Entwurf)
await ui.click('Als Mensch antworten')
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara'])
} finally { ui.end() }
})
test('Pult (bedient, P4-T32): während einer Anfrage sind alle Knöpfe des Briefs gesperrt, ein zweiter Klick sendet nichts', async () => {
let reject
const pending = new Promise((_, no) => { reject = no })
const api = scriptedApi(pending)
const ui = await startList([unsicher], api)
try {
await ui.click('Als Mensch antworten')
await ui.click('Jetzt senden')
assert.equal(api.calls.length, 1)
for (const label of ['Jetzt senden', 'Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich']) assert.deepEqual(ui.disabled(label), [true], label)
await ui.click('Jetzt senden'); await ui.click('Vorschau bauen')
assert.equal(api.calls.length, 1)
// der Dienst antwortet mit einem Fehler: der Brief bleibt offen, alles ist wieder frei (bei einem Erfolg schließt der Brief)
reject(failure('Wird schon von einem Kollegen bearbeitet.', 409)); await settle()
assert.deepEqual(ui.disabled('Jetzt senden'), [false]); assert.deepEqual(ui.disabled('Vorschau bauen'), [false])
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Wird schon von einem Kollegen bearbeitet.', role: 'alert' }])
} finally { ui.end() }
})
// T13 P1: die Sperre gehört dem Eintrag, nicht dem Brief. Wer den Brief während einer Anfrage schließt und denselben Eintrag wieder öffnet, darf nichts zweimal auslösen
// (sonst fügte „Antwort einfügen“ den Text doppelt ein). Aufruf der Knopf-Funktionen von Hand: ein gesperrter Knopf löst im Browser nichts aus, aber die Funktion dahinter
// darf auch auf anderem Weg (alter Stand, Hilfstechnik) nichts senden.
test('Pult (bedient, T13 P1): Brief schließen, während eine Anfrage läuft → denselben Eintrag öffnen → Knöpfe gesperrt, kein zweiter Aufruf → Anfrage endet → frei bzw. Brief/Meldung wie vorgesehen', async () => {
const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
const zweite = { ...unsicher, $id: 'e2', title: 'Zweite Rückfrage' }
const faelle = [
{ name: 'start-build', item: unsicher, aktion: 'start-build', schliesst: true, ok: 'wird gebaut.',
start: async (ui) => { await ui.click('Vorschau bauen'); await ui.click('Wirklich bauen?') },
gesperrt: ['Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich'] },
{ name: 'reply', item: unsicher, aktion: 'reply', schliesst: true, ok: 'gesendet.',
start: async (ui) => { await ui.click('Als Mensch antworten'); await ui.click('Jetzt senden') },
gesperrt: ['Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich'] },
{ name: 'paste', item: plattform, aktion: 'paste', schliesst: false, ok: 'Eingefügt. Nora sortiert die Antwort ein.',
start: async (ui) => { await ui.click('Antwort des Kunden einfügen'); await ui.type('Antwort des Kunden', 'Ja, gern'); await ui.click('Einfügen') },
gesperrt: ['Antwort des Kunden einfügen', 'Kopieren & öffnen', 'Als gesendet markieren', 'Verwerfen', 'Ich kümmere mich'] },
]
for (const fall of faelle) {
for (const ende of ['fehler', 'erfolg']) {
const wo = `${fall.name} / ${ende}`
let finish
let reject
const api = scriptedApi(new Promise((resolve, no) => { finish = resolve; reject = no }))
const ui = await startList([fall.item, zweite], api)
const stale = (label) => clickable(ui.brief(), label)[0].props.onClick?.({ preventDefault() {} }) // wie ein Klick auf einen Knopf, den der Browser nicht auslöst
try {
// 1. die Anfrage läuft, der Brief wird geschlossen
await ui.open(0)
await fall.start(ui)
assert.equal(api.calls.length, 1, wo); assert.equal(api.calls[0][1], fall.aktion, wo)
await ui.close()
assert.equal(ui.brief(), null, wo)
// 2. derselbe Eintrag, neuer Brief: alles gesperrt, ein Hinweis nennt den Grund
await ui.open(0)
for (const label of fall.gesperrt) assert.deepEqual(ui.disabled(label), [true], `${wo}: ${label}`)
assert.ok(ui.text().includes('Für diesen Eintrag läuft noch eine Anfrage.'), wo)
// 3. auch die Funktionen hinter den Knöpfen senden nichts (kein zweites reply, paste oder start-build, keine Rückfrage, kein Feld, kein Öffnen der Anzeige)
for (const label of fall.gesperrt) { stale(label); stale(label); await settle() }
assert.equal(api.calls.length, 1, `${wo}: zweiter Aufruf`)
assert.ok(!ui.has('Wirklich bauen?') && !ui.has('Wirklich verwerfen?') && !ui.has('Wirklich freigeben?'), `${wo}: Rückfrage`)
assert.deepEqual([ui.value('Text der Antwort'), ui.value('Antwort des Kunden')], [[], []], `${wo}: Feld`)
assert.equal(ui.opened.length, 0, `${wo}: Anzeige geöffnet`)
// 4. andere Einträge sind nicht betroffen
await ui.close(); await ui.open(1)
assert.deepEqual(ui.disabled('Erledigt'), [false], wo); assert.ok(!ui.text().includes('Für diesen Eintrag läuft noch eine Anfrage.'), wo)
await ui.close(); await ui.open(0)
assert.deepEqual(ui.disabled(fall.gesperrt[0]), [true], wo)
// 5. die Anfrage endet
if (ende === 'fehler') {
reject(failure('Wird schon von einem Kollegen bearbeitet.', 409)); await settle()
// der Brief bleibt offen, alles ist frei, der Hinweis ist weg; der späte Fehler steht bei der Liste und nennt den Eintrag
assert.ok(ui.brief(), wo)
for (const label of fall.gesperrt) assert.deepEqual(ui.disabled(label), [false], `${wo}: ${label}`)
assert.ok(!ui.text().includes('Für diesen Eintrag läuft noch eine Anfrage.'), wo)
assert.deepEqual(ui.messages(), [{ kind: 'err', text: `„${fall.item.title}“: Wird schon von einem Kollegen bearbeitet.`, role: 'alert' }], wo)
assert.equal(api.calls.length, 1, wo)
// wieder frei: die nächste Aktion geht hinaus
await ui.click('Ich kümmere mich')
assert.deepEqual(api.calls[1], [fall.item.$id, 'claim'], wo)
} else {
finish({ status: 'neu', item: { $id: fall.item.$id } }); await settle()
assert.equal(api.calls.length, 1, wo)
if (fall.schliesst) {
// der Eintrag hat die Liste verlassen: der wieder geöffnete Brief schließt, die Meldung nennt den Titel
assert.equal(ui.brief(), null, wo)
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: `„${fall.item.title}“ ${fall.ok}`, role: 'status' }], wo)
} else {
// der Eintrag bleibt: der Brief ist offen und frei, das späte Ergebnis steht mit dem Titel in der Liste
assert.ok(ui.brief(), wo)
for (const label of fall.gesperrt) assert.deepEqual(ui.disabled(label), [false], `${wo}: ${label}`)
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: `„${fall.item.title}“: ${fall.ok}`, role: 'status' }], wo)
}
}
} finally { ui.end() }
}
}
})
test('Pult (bedient, T13 P1): ohne Anfrage ist nichts gesperrt; die Sperre hält nur während der Anfrage und gilt je Eintrag (Brief zu, Brief auf, andere Einträge frei)', async () => {
const zweite = { ...unsicher, $id: 'e2', title: 'Zweite Rückfrage' }
let finish
const api = scriptedApi(new Promise((resolve) => { finish = resolve }))
const ui = await startList([unsicher, zweite], api)
try {
await ui.open(0)
for (const label of ['Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich']) assert.deepEqual(ui.disabled(label), [false], label)
assert.ok(!ui.text().includes('Für diesen Eintrag läuft noch eine Anfrage.'))
await ui.click('Ich kümmere mich') // Anfrage zu e1 läuft
assert.equal(api.calls.length, 1)
// der erste Brief sperrt sich selbst (wie bisher), der Hinweis erscheint dort nicht: die Sperre ist die eigene
assert.deepEqual(ui.disabled('Erledigt'), [true]); assert.ok(!ui.text().includes('Für diesen Eintrag läuft noch eine Anfrage.'))
// ein zweiter Eintrag kann währenddessen bearbeitet werden (eigene Id)
await ui.close(); await ui.open(1)
await ui.click('Ich kümmere mich')
assert.deepEqual(api.calls.map((c) => c[0]), ['e1', 'e2'])
finish({ item: { $id: 'e1' } }); await settle()
await ui.close(); await ui.open(0)
assert.deepEqual(ui.disabled('Erledigt'), [false]) // Anfrage zu e1 ist zu Ende
} finally { ui.end() }
})
test('Pult (bedient, P4-T32): ein „unsicher“-Eintrag ohne Entwurf öffnet „Als Mensch antworten“ mit leerem Feld', async () => {
const api = scriptedApi()
const ui = await startList([{ ...unsicher, draft: undefined }], api)
try {
await ui.click('Als Mensch antworten')
assert.deepEqual(ui.value('Text der Antwort'), ['']); assert.ok(ui.text().includes('0 / 8000'))
assert.equal(api.calls.length, 0)
} finally { ui.end() }
})
// Endprüfung P4 (W4): die aufgeklappten Felder schließen mit Esc und mit „Abbrechen“; der Fokus geht an den Knopf zurück, der sie öffnete, der Text bleibt.
// Während einer Anfrage bleiben beide gesperrt (das Feld soll nicht mitten im Senden verschwinden).
test('Pult (bedient, W4): Esc und „Abbrechen“ schließen „Als Mensch antworten“, geben den Fokus an den Knopf zurück und behalten den Text', async () => {
const f = fakeFocus()
let finish
const api = scriptedApi(new Promise((resolve) => { finish = resolve }))
const ui = await startList([unsicher], api)
try {
const opener = f.el('opener')
f.found.set('[data-opens="reply"]', opener)
await ui.open()
assert.equal(clickable(ui.brief(), 'Als Mensch antworten')[0].props['data-opens'], 'reply') // der Auslöser trägt seine Kennung
assert.ok(!ui.has('Abbrechen'))
await ui.click('Als Mensch antworten')
assert.ok(ui.has('Abbrechen'))
await ui.type('Text der Antwort', 'Mein Text')
// eine andere Taste ändert nichts
let event = await ui.key('hk-feld-antwort', 'a')
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text']); assert.equal(event.defaultPrevented, false)
// Esc: Feld zu, Fokus auf dem Auslöser, das Ereignis ist behandelt (die Handy-Leiste schließt sonst mit)
event = await ui.key('hk-feld-antwort')
assert.deepEqual(ui.value('Text der Antwort'), []); assert.equal(opener.focusCalls, 1); assert.equal(f.doc.activeElement, opener)
assert.ok(event.defaultPrevented); assert.ok(event.stopped)
assert.equal(nodes(ui.tree(), (n) => n.tag === 'button' && textOf(n) === 'Als Mensch antworten')[0].props['aria-expanded'], false)
// der Text bleibt erhalten
await ui.click('Als Mensch antworten')
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text'])
// Abbrechen: wie Esc
await ui.click('Abbrechen')
assert.deepEqual(ui.value('Text der Antwort'), []); assert.equal(opener.focusCalls, 2); assert.equal(api.calls.length, 0)
// ein zweiter Klick auf den Auslöser schließt wie bisher, ohne den Fokus zu bewegen (er liegt schon dort)
await ui.click('Als Mensch antworten'); await ui.click('Als Mensch antworten')
assert.deepEqual(ui.value('Text der Antwort'), []); assert.equal(opener.focusCalls, 2)
// während der Anfrage sind Esc und Abbrechen gesperrt
await ui.click('Als Mensch antworten')
await ui.click('Jetzt senden')
assert.equal(api.calls.length, 1); assert.deepEqual(ui.disabled('Abbrechen'), [true])
event = await ui.key('hk-feld-antwort')
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text']); assert.equal(event.defaultPrevented, false); assert.equal(opener.focusCalls, 2)
await ui.click('Abbrechen')
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text'])
finish({ item: { $id: 'e1' } }); await settle()
} finally { ui.end(); f.restore() }
})
test('Pult (bedient, W4): Esc und „Abbrechen“ schließen auch „Antwort des Kunden einfügen“ und kehren an dessen Knopf zurück', async () => {
const f = fakeFocus()
const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
const api = scriptedApi()
const ui = await startList([plattform], api)
try {
const opener = f.el('opener')
f.found.set('[data-opens="paste"]', opener)
await ui.open()
assert.equal(clickable(ui.brief(), 'Antwort des Kunden einfügen')[0].props['data-opens'], 'paste')
await ui.click('Antwort des Kunden einfügen')
await ui.type('Antwort des Kunden', 'Noch nicht gesendet')
await ui.key('hk-feld-einfuegen')
assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(opener.focusCalls, 1)
await ui.click('Antwort des Kunden einfügen')
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch nicht gesendet']) // der Text bleibt
await ui.click('Abbrechen')
assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(opener.focusCalls, 2); assert.equal(api.calls.length, 0)
// „Abbrechen“ liegt vor dem Absenden (Einfügen bleibt der Hauptknopf)
await ui.click('Antwort des Kunden einfügen')
const names = nodes(ui.tree(), (n) => n.tag === 'div' && hasClass(n, 'hk-feld-knoepfe'))[0].children.map(textOf)
assert.deepEqual(names, ['Abbrechen', 'Einfügen'])
} finally { ui.end(); f.restore() }
})
// Endprüfung P4 (W3): ein gesperrter Knopf verliert den Fokus an <body>. Nach der Anfrage gibt der Brief ihn zurück (an den Knopf, sonst ans Feld, an den
// Knopf, der das Feld öffnete, oder an den Brief); verlässt ein Eintrag die Liste, geht er an die Meldung oben. Wer den Fokus inzwischen selbst
// woanders hat, wird nicht gestört. Der Test spielt den Browser nach: activeElement stellt der Test (Klick: Knopf; Sperre: body).
// Der Brief nimmt beim Öffnen selbst den Fokus (erster Knopf): die Tests setzen activeElement deshalb erst nach ui.open().
const PANEL_FIELD = '.hk-feld-panel textarea'
test('Pult (bedient, W3): nach einem Fehler beim Senden steht der Fokus wieder auf „Jetzt senden“; hat die Person inzwischen selbst gewählt, bleibt er dort', async () => {
for (const moved of [false, true]) {
const f = fakeFocus()
let reject
const api = scriptedApi(new Promise((_, no) => { reject = no }))
const ui = await startList([unsicher], api)
try {
await ui.click('Als Mensch antworten')
const submit = f.el('submit')
f.doc.activeElement = submit // der Klick hat den Knopf fokussiert
await ui.click('Jetzt senden')
const other = f.el('other')
f.doc.activeElement = moved ? other : f.body // der gesperrte Knopf verliert den Fokus (Chromium) – oder die Person ist schon woandershin gegangen
reject(failure('Wird schon von einem Kollegen bearbeitet.', 409)); await settle()
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Wird schon von einem Kollegen bearbeitet.', role: 'alert' }]) // der Brief bleibt
if (moved) assert.deepEqual([f.doc.activeElement, submit.focusCalls], [other, 0])
else assert.deepEqual([f.doc.activeElement, submit.focusCalls], [submit, 1])
} finally { ui.end(); f.restore() }
}
})
test('Pult (bedient, W3): ist der Knopf nach der Aktion weg („Ich kümmere mich“), geht der Fokus an den Brief; er ist mit tabIndex -1 fokussierbar', async () => {
const f = fakeFocus()
let finish
const api = scriptedApi(new Promise((resolve) => { finish = resolve }))
const ui = await startList([unsicher], api)
try {
await ui.open()
assert.equal(ui.brief().props.tabIndex, -1)
const claim = f.el('claim')
f.doc.activeElement = claim
await ui.click('Ich kümmere mich')
f.doc.activeElement = f.body
claim.isConnected = false // nach „übernommen“ gibt es den Knopf nicht mehr
finish({ item: { $id: 'e1' } }); await settle()
const brief = f.ref('hk-brief')
assert.deepEqual([brief.focusCalls, f.doc.activeElement], [1, brief])
assert.equal(claim.focusCalls, 0)
} finally { ui.end(); f.restore() }
})
test('Pult (bedient, W3): nach dem Einfügen (Feld klappt zu) geht der Fokus an „Antwort des Kunden einfügen“; nach einem Fehler bleibt er im Feld', async () => {
const f = fakeFocus()
const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
let reject
let finish
const api = scriptedApi(new Promise((_, no) => { reject = no }), new Promise((resolve) => { finish = resolve }))
const ui = await startList([plattform], api)
try {
const opener = f.el('opener'); f.found.set('[data-opens="paste"]', opener)
const field = f.el('textarea'); f.found.set(PANEL_FIELD, field)
await ui.click('Antwort des Kunden einfügen')
await ui.type('Antwort des Kunden', 'Danke, passt.')
// Fehler: das Feld bleibt offen, der Knopf „Einfügen“ ist noch da und bekommt den Fokus zurück
const insert = f.el('insert'); f.doc.activeElement = insert
await ui.click('Einfügen')
f.doc.activeElement = f.body
reject(failure('Dieser Kontakt ist gesperrt.', 409)); await settle()
assert.deepEqual([f.doc.activeElement, insert.focusCalls], [insert, 1])
// Erfolg: das Feld klappt zu (Textfeld und Knopf „Einfügen“ sind weg), der Fokus geht an „Antwort des Kunden einfügen“ (vor dem Brief)
f.found.delete(PANEL_FIELD)
f.doc.activeElement = insert
await ui.click('Einfügen')
f.doc.activeElement = f.body
insert.isConnected = false
finish({ status: 'neu' }); await settle()
assert.deepEqual([opener.focusCalls, f.doc.activeElement], [1, opener])
assert.equal(field.focusCalls, 0); assert.equal(f.ref('hk-brief').focusCalls, 0)
// und bleibt das Feld offen, aber der Knopf ist weg, nimmt das Textfeld den Fokus (vor dem Auslöser)
} finally { ui.end(); f.restore() }
})
test('Pult (bedient, W3): verlässt ein Eintrag die Liste, geht der Fokus an die Meldung oben (auch bei gleichem Text); sie ist mit tabIndex -1 fokussierbar', async () => {
const f = fakeFocus()
const zweite = { ...unsicher, $id: 'e2' } // gleicher Titel: dieselbe Meldung zweimal
let finish
const api = scriptedApi(undefined, undefined, new Promise((resolve) => { finish = resolve }))
const ui = await startList([unsicher, zweite], api)
try {
// der Knopf, den die Anfrage gerade sperrt (disabled): der Browser lässt ihn erst beim nächsten Takt los, dann liegt der Fokus auf <body>
await ui.open(0)
const done0 = f.el('done0', { disabled: true }); f.doc.activeElement = done0
await ui.click('Erledigt')
const status = f.ref('hk-hinweis')
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ erledigt.', role: 'status' }])
assert.equal(ui.brief(), null) // der Brief schließt, der Eintrag verlässt die Liste
assert.equal(nodes(ui.tree(), (n) => n.tag === 'div' && hasClass(n, 'hk-hinweis'))[0].props.tabIndex, -1)
assert.deepEqual([status.focusCalls, f.doc.activeElement], [1, status])
assert.equal(done0.focusCalls, 0)
// zweiter Eintrag, gleiche Meldung: wieder auf die Meldung
await ui.open(1)
f.doc.activeElement = f.el('done1', { disabled: true })
await ui.click('Erledigt')
assert.equal(status.focusCalls, 2); assert.equal(f.doc.activeElement, status)
// hat die Person den Fokus während der Anfrage selbst woanders hingesetzt, bleibt er dort
await ui.open(0)
f.doc.activeElement = f.el('done2', { disabled: true })
await ui.click('Erledigt')
const other = f.el('other'); f.doc.activeElement = other
finish({}); await settle()
assert.deepEqual([status.focusCalls, f.doc.activeElement], [2, other])
} finally { ui.end(); f.restore() }
})
test('Pult (bedient, W3): ohne document (Server, Node) und ohne Fokus vor dem Klick passiert nichts', async () => {
const api = scriptedApi()
const ui = await startList([unsicher], api) // kein document, kein fakeRef: refs bleiben null
try {
await ui.click('Ich kümmere mich')
await ui.click('Erledigt')
assert.equal(api.calls.length, 2)
} finally { ui.end() }
// das document ist da, aber der Klick hatte keinen Fokus (Safari fokussiert Knöpfe nicht): nichts zurückzugeben, nichts wird bewegt – auch nicht zur Meldung oben
const f = fakeFocus()
const ui2 = await startList([unsicher], scriptedApi())
try {
await ui2.open()
f.doc.activeElement = f.body // der Brief hat beim Öffnen den Fokus genommen; ein Klick in Safari ließe ihn bei <body>
await ui2.click('Ich kümmere mich')
assert.deepEqual([f.ref('hk-brief').focusCalls, f.doc.activeElement], [0, f.body])
await ui2.click('Erledigt')
assert.ok(ui2.text().includes('erledigt.'))
assert.deepEqual([f.ref('hk-hinweis').focusCalls, f.doc.activeElement], [0, f.body])
} finally { ui2.end(); f.restore() }
})
// Endprüfung P4 (W2): „Vorschau bauen“ startet einen Opus-Lauf und lässt sich in der Oberfläche nicht zurücknehmen: wie Freigeben und Verwerfen
// fragt der Knopf zuerst nach (4 s). Ein Fehlklick (Handy: der Knopf liegt in voller Breite zwischen anderen) startet keinen Bau mehr.
test('Pult (bedient, W2): „Vorschau bauen“ – erster Klick fragt nach (4 s, Hinweis), zweiter startet den Bau; Fehler und Erfolg wie bisher', async () => {
const api = scriptedApi(failure('Für diesen Interessenten gibt es schon eine Vorschau.', 409), Promise.resolve({ item: { $id: 'e1' } }))
const ui = await startList([{ ...unsicher, draft: undefined }], api)
try {
// erster Klick: Rückfrage und Hinweis zu Kosten und Wirkung, nichts geht hinaus; die Rückfrage verfällt nach 4 s
await ui.open()
assert.ok(!ui.text().includes('Opus'))
await ui.click('Vorschau bauen')
assert.equal(api.calls.length, 0); assert.ok(ui.has('Wirklich bauen?')); assert.ok(!ui.has('Vorschau bauen'))
assert.ok(ui.text().includes('Startet einen Opus-Lauf')); assert.ok(ui.text().includes('als hätte der Interessent Ja gesagt'))
assert.ok(!ui.text().includes('nur wenn')) // kein „nur wenn der Interessent Ja gesagt hat“: der Knopf gilt gerade für unklare Antworten
assert.deepEqual([...ui.timers.values()].map((t) => t.ms), [4000])
ui.fireTimers(); await settle()
assert.ok(ui.has('Vorschau bauen')); assert.ok(!ui.has('Wirklich bauen?')); assert.ok(!ui.text().includes('Opus')); assert.equal(api.calls.length, 0)
// Rückfrage und zweiter Klick ⇒ Aktion ohne Body; der Dienst lehnt ab: sein Satz erscheint, die Rückfrage beginnt von vorn
await ui.click('Vorschau bauen'); await ui.click('Wirklich bauen?')
assert.deepEqual(api.calls, [['e1', 'start-build']])
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Für diesen Interessenten gibt es schon eine Vorschau.', role: 'alert' }])
assert.ok(ui.has('Vorschau bauen')); assert.ok(!ui.text().includes('Opus'))
// Erfolg oben bei der Liste
await ui.click('Vorschau bauen'); await ui.click('Wirklich bauen?')
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ wird gebaut.', role: 'status' }])
assert.equal(api.calls.length, 2)
} finally { ui.end() }
})
test('Pult (bedient, W2): die Rückfragen vor Bauen, Verwerfen und Freigeben lösen einander ab; Verwerfen sendet nichts ohne zweiten Klick', async () => {
const api = scriptedApi()
const ui = await startList([unsicher], api)
try {
await ui.click('Vorschau bauen'); assert.ok(ui.has('Wirklich bauen?'))
await ui.click('Verwerfen'); assert.ok(ui.has('Wirklich verwerfen?')); assert.ok(ui.has('Vorschau bauen')); assert.ok(!ui.text().includes('Opus'))
await ui.click('Vorschau bauen'); assert.ok(ui.has('Wirklich bauen?')); assert.ok(ui.has('Verwerfen'))
assert.equal(api.calls.length, 0)
await ui.click('Wirklich bauen?')
assert.deepEqual(api.calls, [['e1', 'start-build']])
} finally { ui.end() }
})
test('Pult (bedient, P4-T32): Vorschau freigeben – erster Klick fragt nach (4 s), zweiter sendet; keine Antwort heißt „Kann gesendet sein“', async () => {
const freigabe = { ...unsicher, $id: 'f1', kind: 'vorschau_freigabe', title: 'Vorschau prüfen', draft: 'Hier ist Ihre Vorschau.', sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }
const api = scriptedApi(failure('Die Vorschau ist nicht freigabebereit.', 409), failure('Der Hustler-Dienst ist gerade nicht erreichbar.', 0), Promise.resolve({ item: { $id: 'f1' }, previewUrl: freigabe.sourceUrl }))
const ui = await startList([freigabe], api)
try {
// der Link zur Vorschau öffnet in einem neuen Tab ohne Rückbezug
await ui.open()
const link = ui.link('Vorschau ansehen')
assert.deepEqual([link.tag, link.props.href, link.props.target, link.props.rel], ['a', 'https://entwurf.project.webklar.com/AAA/', '_blank', 'noopener noreferrer'])
assert.ok(!ui.has('Anzeige öffnen')); assert.ok(!ui.has('Erledigt'))
// erster Klick: Rückfrage, nichts geht hinaus; sie verfällt nach 4 s
await ui.click('Freigeben & senden')
assert.equal(api.calls.length, 0); assert.ok(ui.has('Wirklich freigeben?')); assert.ok(!ui.has('Freigeben & senden'))
assert.deepEqual([...ui.timers.values()].map((t) => t.ms), [4000])
ui.fireTimers(); await settle()
assert.ok(ui.has('Freigeben & senden')); assert.ok(!ui.has('Wirklich freigeben?'))
// Rückfrage und zweiter Klick ⇒ Aktion ohne Body; der Dienst lehnt ab: sein Satz erscheint
await ui.click('Freigeben & senden'); await ui.click('Wirklich freigeben?')
assert.deepEqual(api.calls, [['f1', 'approve-preview']])
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Die Vorschau ist nicht freigabebereit.', role: 'alert' }])
assert.ok(ui.has('Freigeben & senden')) // nach der Aktion beginnt die Rückfrage von vorn
// Zeitüberschreitung: Hinweis, die Liste wird neu geholt
await ui.click('Freigeben & senden'); await ui.click('Wirklich freigeben?')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Kann gesendet sein – Pult neu laden.', role: 'alert' }]); assert.equal(ui.refreshes.length, 2)
// Erfolg
await ui.click('Freigeben & senden'); await ui.click('Wirklich freigeben?')
assert.equal(api.calls.length, 3)
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Vorschau prüfen“ freigegeben. Max sendet den Link im nächsten Sendefenster.', role: 'status' }])
// Verwerfen fragt weiterhin für sich nach, und die Rückfragen stören sich nicht
await ui.click('Verwerfen'); assert.ok(ui.has('Wirklich verwerfen?')); assert.ok(ui.has('Freigeben & senden'))
await ui.click('Freigeben & senden'); assert.ok(ui.has('Wirklich freigeben?')); assert.ok(ui.has('Verwerfen'))
} finally { ui.end() }
})
// Endprüfung P4 (W5): ohne gültige Vorschau-Adresse lässt sich nicht freigeben (auch nicht über die Rückfrage), mit gültiger Adresse wie bisher
test('Pult (bedient, W5): „Freigeben & senden“ ist ohne gültige Vorschau-Adresse gesperrt – kein Klick, keine Rückfrage, keine Aktion', async () => {
const freigabe = { ...unsicher, $id: 'f1', kind: 'vorschau_freigabe', title: 'Vorschau prüfen', draft: 'Hier ist Ihre Vorschau.' }
for (const sourceUrl of ['http://entwurf.project.webklar.com/AAA/', 'https://fremd.example/AAA/', '']) {
const api = scriptedApi()
const ui = await startList([{ ...freigabe, sourceUrl }], api)
try {
await ui.open()
assert.deepEqual(ui.disabled('Freigeben & senden'), [true], sourceUrl)
assert.ok(ui.text().includes('Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich')); assert.ok(!ui.has('Vorschau ansehen'))
await ui.click('Freigeben & senden')
assert.ok(ui.has('Freigeben & senden')); assert.ok(!ui.has('Wirklich freigeben?')); assert.equal(api.calls.length, 0)
assert.deepEqual([...ui.timers.values()], [])
// Verwerfen bleibt möglich und sendet nichts: der Mensch kann den Eintrag schließen
assert.deepEqual(ui.disabled('Verwerfen'), [false])
} finally { ui.end() }
}
// gültige Adresse: Freigeben ist frei und fragt wie bisher nach
const api = scriptedApi()
const ui = await startList([{ ...freigabe, sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }], api)
try {
await ui.open()
assert.deepEqual(ui.disabled('Freigeben & senden'), [false]); assert.ok(!ui.text().includes('Keine gültige Vorschau-Adresse'))
await ui.click('Freigeben & senden'); assert.ok(ui.has('Wirklich freigeben?'))
await ui.click('Wirklich freigeben?'); assert.deepEqual(api.calls, [['f1', 'approve-preview']])
} finally { ui.end() }
})
test('Pult (bedient, P4-T32): „Antwort des Kunden einfügen“ – leeres Feld mit Hinweis, Prüfung, Einfügen, doppelt, Fehler des Dienstes, Text bleibt beim Filterwechsel', async () => {
const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', status: 'in_arbeit', claimedBy: 'mensch:u2', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
const api = scriptedApi(Promise.resolve({ status: 'neu', messageId: 'm1', threadId: 't1' }), Promise.resolve({ status: 'doppelt', messageId: 'm1', threadId: 't1' }), failure('Dieser Kontakt ist gesperrt.', 409))
const ui = await startList([plattform], api)
try {
assert.deepEqual(ui.value('Antwort des Kunden'), [])
await ui.click('Antwort des Kunden einfügen') // auch bei einem Kollegen, der den Eintrag hält
assert.deepEqual(ui.value('Antwort des Kunden'), ['']) // leer, nie mit Claras Entwurf vorbelegt
assert.ok(ui.text().includes('Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.'))
// leer ⇒ Hinweis; nichts geht hinaus
await ui.click('Einfügen')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Bitte einen Text eingeben.', role: 'alert' }]); assert.equal(api.calls.length, 0)
// einfügen: getrimmter Text an die Id des Eintrags; danach ist das Feld geleert und zu, der Brief meldet das Einsortieren
await ui.type('Antwort des Kunden', ' Danke, das passt. ')
await ui.click('Einfügen')
assert.deepEqual(api.calls, [['k1', 'paste', { text: 'Danke, das passt.' }]])
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: 'Eingefügt. Nora sortiert die Antwort ein.', role: 'status' }])
assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(ui.refreshes.length, 1)
await ui.click('Antwort des Kunden einfügen')
assert.deepEqual(ui.value('Antwort des Kunden'), ['']) // nach dem Einfügen wieder leer
// dieselbe Antwort noch einmal: der Dienst kennt sie schon
await ui.type('Antwort des Kunden', 'Danke, das passt.')
await ui.click('Einfügen')
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: 'Diese Antwort war schon eingefügt.', role: 'status' }])
assert.deepEqual(ui.value('Antwort des Kunden'), [])
// Dienstfehler: Satz als Fehler, der Text bleibt stehen (auch wenn der Brief schließt, der Reiter gewechselt wird und der Eintrag verschwindet)
await ui.click('Antwort des Kunden einfügen')
await ui.type('Antwort des Kunden', 'Noch eine Antwort')
await ui.click('Einfügen')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Dieser Kontakt ist gesperrt.', role: 'alert' }])
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort'])
await ui.close()
await ui.tab('Veraltet'); assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(envelopes(ui.tree()).length, 0)
await ui.tab('Wartet')
await ui.click('Antwort des Kunden einfügen')
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort'])
// Einfügen und Antworten sind getrennt: an einer Plattform-Antwort gibt es kein „Als Mensch antworten“
assert.ok(!ui.has('Als Mensch antworten'))
} finally { ui.end() }
})
test('Pult (bedient, P4-T32): ein Text von „Als Mensch antworten“ überlebt Schließen und den Wechsel des Reiters; nach dem Absenden ist er weg', async () => {
const api = scriptedApi(Promise.resolve({ item: { $id: 'e1' } }))
const ui = await startList([unsicher], api)
try {
await ui.click('Als Mensch antworten')
await ui.type('Text der Antwort', 'Mein eigener Text')
await ui.close()
await ui.tab('Veraltet'); assert.deepEqual(ui.value('Text der Antwort'), []) // der Eintrag ist weg
await ui.tab('Wartet')
await ui.click('Als Mensch antworten')
assert.deepEqual(ui.value('Text der Antwort'), ['Mein eigener Text'])
await ui.click('Jetzt senden')
assert.deepEqual(api.calls, [['e1', 'reply', { text: 'Mein eigener Text' }]])
await ui.click('Als Mensch antworten')
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara'])
} finally { ui.end() }
})
test('Pult (bedient, P4-T32): mehrere Einträge desselben Interessenten – Felder, Texte und Aktionen hängen an der Eintrags-Id', async () => {
const zweite = { ...unsicher, $id: 'e3', title: 'Zweite Rückfrage', draft: 'Entwurf drei' } // gleicher Interessent p1 wie e1
const nachfolge = { ...unsicher, $id: 'k9', kind: 'plattform_antwort', title: 'Folge-Antwort', draft: 'Claras Folge', prospect: { $id: 'p1', name: 'Verein' } }
const api = scriptedApi(Promise.resolve({ item: { $id: 'e3' } }), Promise.resolve({ status: 'neu' }))
const ui = await startList([{ ...unsicher, draft: 'Entwurf eins' }, zweite, nachfolge], api)
try {
assert.equal(envelopes(ui.tree()).length, 3)
await ui.click('Als Mensch antworten', 0)
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins']) // nur der Brief des ersten Eintrags ist offen
await ui.type('Text der Antwort', 'Text eins')
await ui.click('Als Mensch antworten', 1)
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf drei']) // der Brief zeigt nur seinen Eintrag
await ui.type('Text der Antwort: Zweite Rückfrage', 'Text drei')
// die Texte stören sich nicht: nach dem Wechsel zurück steht in jedem Eintrag sein eigener Text
await ui.click('Als Mensch antworten', 0)
assert.deepEqual(ui.value('Text der Antwort'), ['Text eins'])
await ui.click('Als Mensch antworten', 1)
assert.deepEqual(ui.value('Text der Antwort'), ['Text drei'])
// gesendet wird der Text des Eintrags, dessen Brief offen ist, an die Id dieses Eintrags
await ui.click('Jetzt senden')
assert.deepEqual(api.calls, [['e3', 'reply', { text: 'Text drei' }]])
assert.equal(ui.brief(), null)
await ui.click('Als Mensch antworten', 1)
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf drei']) // e3 ist abgeschlossen (Text zurückgesetzt)
await ui.click('Als Mensch antworten', 0)
assert.deepEqual(ui.value('Text der Antwort'), ['Text eins']) // e1 unberührt
// Einfügen am dritten Eintrag: nur dort, mit der Id des Eintrags k9
await ui.click('Antwort des Kunden einfügen', 2)
await ui.type('Antwort des Kunden: Folge-Antwort', 'Ja, gern')
await ui.click('Einfügen')
assert.deepEqual(api.calls[1], ['k9', 'paste', { text: 'Ja, gern' }])
} finally { ui.end() }
})
// ---- Stufen-Sperre (hustlerStufe.js): Stufe 3 = Stand der Produktion, der Dienst kann P3, aber noch nicht P4. Das Pult bietet dann keine der vier P4-Aktionen an ----
// ---- (Antwort des Kunden einfügen, Als Mensch antworten / Jetzt senden, Vorschau freigeben, Vorschau bauen); die P3-Aktionen bleiben, ohne Loch und ohne Fokusfalle. ----
const P4_TEXTE = ['Antwort des Kunden einfügen', 'Als Mensch antworten', 'Jetzt senden', 'Freigeben &amp; senden', 'Wirklich freigeben?', 'Vorschau bauen', 'Wirklich bauen?', 'Vorschau ansehen', 'Einfügen', 'Text der Antwort', 'Antwort des Kunden']
const P4_MERKMALE = ['data-opens', 'aria-expanded', 'hk-feld-panel', 'hk-feld-antwort', 'hk-feld-einfuegen', 'hk-zaehler', 'hk-brief-hinweis-bau']
test('Stufe 3 (gerendert): kein Brief, keine Art und kein Status zeigt eine P4-Aktion; Stufe 6 zeigt sie weiter', async () => {
const { renderBrief } = await loadList()
const now = Date.now()
const me = { id: 'u1', name: 'Justin' }
const prospect = { $id: 'p1', name: 'Verein', city: 'Namborn', postalCode: '66640', score: 72, email: 'x@verein.example' }
const base = { $id: 'a', status: 'offen', title: 'Eintrag', draft: 'Hallo', createdAt: new Date(now).toISOString(), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect, prospectId: 'p1' }
const vorschau = { ...base, kind: 'vorschau_freigabe', sourceUrl: 'https://entwurf.project.webklar.com/verein' }
const alle = [
...['offen', 'in_arbeit', 'veraltet', 'gesendet', 'erledigt', 'verworfen'].flatMap((status) => [
{ ...base, kind: 'plattform_antwort', status }, { ...base, kind: 'mail_antwort', status }, { ...vorschau, status },
{ ...base, kind: 'unsicher', status }, { ...base, kind: 'preisfrage', status }, { ...base, kind: 'mensch_gewuenscht', status },
{ ...base, kind: 'beschwerde', status }, { ...base, kind: 'bau_fehlgeschlagen', status },
]),
{ ...base, kind: 'plattform_antwort', status: 'in_arbeit', claimedBy: 'mensch:u2' }, // von einem Kollegen übernommen
{ ...base, kind: 'plattform_antwort', status: 'gesendet', prospect: null },
]
for (const item of alle) {
const name = `${item.kind}/${item.status}${item.claimedBy ? '/Kollege' : ''}`
const html = renderBrief(item, { me, nowMs: now, employees: [], stufe: 3 })
for (const t of P4_TEXTE) assert.ok(!html.includes(t), `${name}: „${t}“`)
for (const m of P4_MERKMALE) assert.ok(!html.includes(m), `${name}: ${m}`)
assert.ok(html.includes('hk-brief'), name) // der Brief selbst steht
}
// Gegenprobe Stufe 6: dieselben Einträge zeigen die P4-Aktionen (sonst prüft die Schleife oben nichts)
const gezeigt = (item) => P4_TEXTE.filter((t) => renderBrief(item, { me, nowMs: now, employees: [], stufe: 6 }).includes(t))
assert.ok(gezeigt({ ...base, kind: 'plattform_antwort' }).includes('Antwort des Kunden einfügen'))
assert.ok(gezeigt({ ...base, kind: 'mail_antwort' }).includes('Als Mensch antworten'))
assert.ok(gezeigt(vorschau).includes('Freigeben &amp; senden'))
assert.ok(gezeigt({ ...base, kind: 'unsicher' }).includes('Vorschau bauen'))
})
test('Stufe 3 (gerendert): die P3-Aktionen je Art bleiben unverändert, Mail-Link, Anzeige öffnen und Interessent auch; abgeschlossene Einträge sagen es', async () => {
const { renderBrief } = await loadList()
const now = Date.now()
const me = { id: 'u1', name: 'Justin' }
const prospect = { $id: 'p1', name: 'Verein', email: 'x@verein.example' }
const base = { $id: 'a', status: 'offen', title: 'Eintrag', draft: 'Hallo', createdAt: new Date(now).toISOString(), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect, prospectId: 'p1' }
const brief = (item) => renderBrief(item, { me, nowMs: now, employees: [], stufe: 3 })
// Plattform-Antwort: übernehmen, Kopieren & öffnen, als gesendet markieren, verwerfen
let html = brief({ ...base, kind: 'plattform_antwort' })
for (const t of ['Kopieren &amp; öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Anzeige öffnen', 'Interessent']) assert.ok(html.includes(t), t)
assert.ok(!stempel(html, 'Erledigt'))
// Mail-Antwort: E-Mail schreiben (mailto) bleibt
html = brief({ ...base, kind: 'mail_antwort', sourceUrl: '' })
assert.match(html, /<a class="hk-stempel hk-stempel-haupt" href="mailto:x@verein\.example\?[^"]*"><span class="hk-ic hk-ic-art-mail" aria-hidden="true"><\/span>E-Mail schreiben<\/a>/)
assert.ok(html.includes('Ich kümmere mich')); assert.ok(html.includes('Als gesendet markieren')); assert.ok(html.includes('Verwerfen'))
// nicht sendbare Arten: Erledigt und Verwerfen; „unsicher“ bietet kein Bauen mehr
for (const kind of ['unsicher', 'preisfrage', 'mensch_gewuenscht', 'beschwerde', 'bau_fehlgeschlagen']) {
html = brief({ ...base, kind })
assert.ok(stempel(html, 'Erledigt'), kind); assert.ok(html.includes('Verwerfen'), kind); assert.ok(html.includes('Ich kümmere mich'), kind); assert.ok(!html.includes('Als gesendet markieren'), kind)
}
// Vorschau-Freigabe ohne P4: übernehmen und verwerfen (Erledigt gibt es dort nie), mit dem Hinweis des Verwerfens
html = brief({ ...base, kind: 'vorschau_freigabe', sourceUrl: 'https://entwurf.project.webklar.com/verein' })
assert.ok(html.includes('Ich kümmere mich')); assert.ok(html.includes('Verwerfen')); assert.ok(!stempel(html, 'Erledigt')); assert.ok(html.includes('„Verwerfen“ sendet die Vorschau nicht.'))
// abgeschlossen: keine Aktionsgruppe, auch nicht für „gesendet mit Interessent“ (dort stand bisher nur „Antwort des Kunden einfügen“)
html = brief({ ...base, kind: 'plattform_antwort', status: 'gesendet' })
assert.ok(!html.includes('hk-aktionen')); assert.ok(html.includes('Dieser Eintrag ist abgeschlossen.')); assert.ok(html.includes('<h3 class="hk-brief-h">Entwurf</h3>'))
})
test('Stufe 3 (bedient): das Pult arbeitet mit den P3-Aktionen, ohne P4-Knöpfe, ohne Loch in der Aktionsgruppe und ohne Fokusfalle', async () => {
const api = scriptedApi(Promise.resolve({}), Promise.resolve({}), Promise.resolve({}))
const ui = await startList([{ ...unsicher, $id: 'k1', kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }], api, { stufe: 3 })
try {
await ui.open(0)
const aktionen = nodes(ui.brief(), (n) => n.tag === 'div' && n.props.role === 'group' && hasClass(n, 'hk-aktionen'))[0]
assert.ok(aktionen)
// die Gruppe enthält genau die P3-Knöpfe, in dieser Reihenfolge, ohne leere Plätze
const beschriftung = aktionen.children.filter((c) => c !== null && c !== false).map((c) => textOf(c).trim())
assert.deepEqual(beschriftung, ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen'])
assert.equal(aktionen.children.length, aktionen.children.filter((c) => c !== null && c !== false).length)
// nichts im Brief öffnet ein Feld; kein Textfeld außer dem Entwurf
assert.equal(nodes(ui.brief(), (n) => n.props && (n.props['data-opens'] !== undefined || n.props['aria-expanded'] !== undefined)).length, 0)
assert.equal(nodes(ui.brief(), (n) => n.tag === 'textarea').length, 1)
for (const label of ['Antwort des Kunden einfügen', 'Als Mensch antworten', 'Jetzt senden', 'Freigeben & senden', 'Vorschau bauen']) assert.equal(ui.has(label), false, label)
// Esc schließt den Brief (es gibt kein Feld, das zuerst schließen müsste), Umschlag bleibt bedienbar
const esc = await ui.briefKey('Escape'); assert.equal(esc.defaultPrevented, true); assert.equal(ui.brief(), null)
// P3 läuft: erster Klick auf Verwerfen fragt, der zweite verwirft; es geht nur discard hinaus
await ui.click('Verwerfen')
assert.deepEqual(ui.disabled('Wirklich verwerfen?'), [false]); assert.equal(api.calls.length, 0)
await ui.click('Wirklich verwerfen?')
assert.deepEqual(api.calls, [['k1', 'discard', {}]])
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Suche Webdesigner“ verworfen.', role: 'status' }])
} finally { ui.end() }
})
test('Stufe 3 (bedient): „unsicher“ und „Vorschau freigeben“ zeigen kein Bauen und kein Freigeben; Erledigt und Verwerfen bleiben; die Zahl der Umschläge bleibt gleich', async () => {
const api = scriptedApi()
const vorschau = { ...unsicher, $id: 'v1', kind: 'vorschau_freigabe', title: 'Vorschau Verein', sourceUrl: 'https://entwurf.project.webklar.com/verein' }
const ui = await startList([unsicher, vorschau], api, { stufe: 3 })
try {
assert.equal(envelopes(ui.tree()).length, 2)
await ui.open(0)
assert.deepEqual(['Erledigt', 'Verwerfen', 'Ich kümmere mich'].map((l) => ui.has(l)), [true, true, true])
assert.deepEqual(['Vorschau bauen', 'Als Mensch antworten', 'Antwort des Kunden einfügen'].map((l) => ui.has(l)), [false, false, false])
await ui.close()
await ui.open(1)
assert.deepEqual(['Verwerfen', 'Ich kümmere mich'].map((l) => ui.has(l)), [true, true])
assert.deepEqual(['Freigeben & senden', 'Vorschau ansehen', 'Erledigt'].map((l) => ui.has(l)), [false, false, false])
assert.equal(api.calls.length, 0)
} finally { ui.end() }
})