Files
tickte-system/tests/unit/workersPultUi.test.mjs
T
ClaudeandClaude Sonnet 5.5 f6099938d8 Workers P3-Endprüfung W2: Kopieren & öffnen meldet ein gescheitertes Kopieren ehrlich
copyTextChecked erfüllt nur, wenn der Text wirklich in der Zwischenablage liegt (Clipboard-API,
sonst Notweg mit document.execCommand, dessen Rückgabewert jetzt geprüft wird; das Textfeld wird
immer entfernt). copyText bleibt für die Kundenansicht die verschluckende Hülle. Das Pult nimmt
die strenge Fassung, sodass die Zeile statt "Kopiert und geöffnet." den Hinweis zum Kopieren
von Hand zeigt. Die Reihenfolge Kopieren vor Öffnen bleibt (Nutzer-Geste).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 10:41:24 +00:00

360 lines
20 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'
// 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. Nur usePultFeed läuft wirklich, mit einem kleinen Hook-Läufer anstelle von React (siehe unten).
const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
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 = read('src/components/workers/PultList.jsx')
assert.doesNotMatch(s, /dangerouslySetInnerHTML/)
assert.match(s, /rel="noopener noreferrer"/); assert.match(s, /PULT_FILTERS/); assert.match(s, /copyAndOpen/)
assert.doesNotMatch(s, /readOnly[^>]*wk-pult-draft|wk-pult-draft[^>]*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: wk-pult-Klassen und Handy-Umbruch; jede im JSX benutzte wk-Klasse ist definiert', () => {
const css = read('src/styles/workers.css')
assert.match(css, /\.wk-pult-row/); assert.match(css, /@media \(max-width: 860px\)[\s\S]*\.wk-pult-row/)
for (const f of ['PultList', 'ProspectPanel']) { const p = 'src/components/workers/' + f + '.jsx'; if (fs.existsSync(new URL('../../' + p, import.meta.url))) for (const m of read(p).matchAll(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), `${f}: ${m[0]}`) }
})
test('PultList: Aktionen über pultAction, "Als gesendet" sendet den bearbeiteten Text, Verwerfen fragt zuerst nach, Meldungen ok|err', () => {
const s = read('src/components/workers/PultList.jsx')
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/)
for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Erledigt', 'Originalanfrage', 'Nichts wartet auf dich', 'Details']) assert.ok(s.includes(t), t)
assert.match(s, /className=\{`msg \$\{/); 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/)
})
// ---- usePultFeed läuft wirklich: Mini-Läufer für Hooks statt React, falsche document/window mit von Hand weitergestelltem Takt ----
const FAKE_REACT = `
let active = null
export function __mount(hook, getProps) {
const run = { hooks: [], index: 0, effects: [], dirty: false, rendering: false, unmounted: false, late: 0, result: undefined }
run.render = () => {
if (run.rendering) { run.dirty = true; return }
run.rendering = true
do {
run.dirty = false
run.index = 0
active = run
run.result = hook(getProps())
active = null
for (const effect of run.effects.splice(0)) effect()
} while (run.dirty)
run.rendering = false
}
run.render()
return {
result: () => run.result,
rerender: () => run.render(),
lateUpdates: () => run.late,
unmount: () => {
run.unmounted = true
for (const h of run.hooks) if (h && typeof h.cleanup === 'function') h.cleanup()
},
}
}
export function useState(initial) {
const run = active
const i = run.index++
if (!run.hooks[i]) run.hooks[i] = { value: typeof initial === 'function' ? initial() : initial }
const slot = run.hooks[i]
return [slot.value, (next) => {
if (run.unmounted) { run.late += 1; return }
const value = typeof next === 'function' ? next(slot.value) : next
if (Object.is(value, slot.value)) return
slot.value = value
run.render()
}]
}
export function useRef(initial) {
const run = active
const i = run.index++
if (!run.hooks[i]) run.hooks[i] = { current: initial }
return run.hooks[i]
}
export function useCallback(fn, deps) {
const run = active
const i = run.index++
const slot = run.hooks[i]
if (slot && deps.every((d, k) => Object.is(d, slot.deps[k]))) return slot.fn
run.hooks[i] = { fn, deps }
return fn
}
export function useEffect(fn, deps) {
const run = active
const i = run.index++
if (!run.hooks[i]) run.hooks[i] = { deps: undefined, cleanup: undefined }
const slot = run.hooks[i]
if (slot.deps && deps && deps.every((d, k) => Object.is(d, slot.deps[k]))) return
run.effects.push(() => {
if (typeof slot.cleanup === 'function') slot.cleanup()
slot.deps = deps
slot.cleanup = fn()
})
}
`
async function loadFeed() {
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const fakeReact = {
name: 'fake-react',
setup(build) {
build.onResolve({ filter: /^react$/ }, () => ({ path: 'react', namespace: 'fake-react' }))
build.onLoad({ filter: /.*/, namespace: 'fake-react' }, () => ({ contents: FAKE_REACT, loader: 'js' }))
},
}
const out = await esbuild.build({
stdin: { contents: "export { usePultFeed } from './src/hooks/usePultFeed.js'\nexport { __mount } from 'react'", resolveDir: root, loader: 'js' },
bundle: true, format: 'esm', write: false, platform: 'neutral', plugins: [fakeReact],
})
return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
}
// 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, __mount } = await loadFeed()
const b = fakeBrowser()
try {
const api = fakeApi()
const hook = __mount(() => 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, __mount } = await loadFeed()
const b = fakeBrowser()
try {
const api = fakeApi()
const hook = __mount(() => 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, __mount } = await loadFeed()
const b = fakeBrowser()
try {
const api = fakeApi()
const hook = __mount(() => 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, __mount } = await loadFeed()
const b = fakeBrowser()
try {
const api = fakeApi()
let statuses = 'offen,in_arbeit'
const hook = __mount(() => 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, __mount } = await loadFeed()
const b = fakeBrowser()
try {
const api = fakeApi()
const hook = __mount(() => 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 = __mount(() => 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 wird mit echtem React auf einen Text gerendert (renderToStaticMarkup); der Feed, die Mitarbeiterliste und die Zwischenablage sind Attrappen ----
const STUBS = {
usePultFeed: 'export function usePultFeed() { return globalThis.__pultFeed }',
useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }',
CopyableCredential: 'export async function copyTextChecked() {}',
}
async function loadList() {
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const stubs = {
name: 'pult-stubs',
setup(build) {
build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-stub' }))
build.onLoad({ filter: /.*/, namespace: 'pult-stub' }, (a) => ({ contents: STUBS[a.path], loader: 'js' }))
},
}
const out = await esbuild.build({
stdin: {
contents: "import { createElement } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport PultList from './src/components/workers/PultList.jsx'\nexport const render = (props) => renderToStaticMarkup(createElement(PultList, props))",
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() + '/');" },
})
return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
}
test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text, sichere Links, Mail-Link mit genau einer Adresse', async () => {
const { render } = await loadList()
const now = Date.now()
const iso = (ms) => new Date(ms).toISOString()
const me = { id: 'u1', name: 'Justin' }
const show = (items, extra = {}) => {
globalThis.__pultFeed = { items, loading: false, error: null, refresh: async () => {}, ...extra }
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
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, Details; das Textfeld trägt den Entwurf
let html = show([{ ...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', 'Details', '66640 Namborn · Score 72', 'vor 3 Std.']) assert.ok(html.includes(t), t)
const erledigt = '<button class="btn" type="button">Erledigt</button>' // der Filter „Erledigt“ steht ebenfalls auf der Seite: nur den Knopf der Zeile meinen
assert.ok(!html.includes(erledigt)); assert.match(html, /<a class="link" href="https:\/\/www\.kleinanzeigen\.de\/s-anzeige\/x\/1-2-3" target="_blank" rel="noopener noreferrer">/)
assert.match(html, /<textarea class="textarea wk-pult-draft"[^>]*>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 = show([{ ...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.match(html, />Kopieren<\/button>/)
// veraltet und „läuft bald ab“
html = show([{ ...base, status: 'veraltet' }, { ...base, $id: 'b', staleAt: iso(now + 5 * 3600e3) }])
assert.ok(html.includes('<span class="badge orange">veraltet</span>')); assert.ok(html.includes('läuft in 5 Std. ab'))
// nicht sendbare Art: Erledigt statt Senden, aber Verwerfen bleibt; abgeschlossene Einträge haben keine Aktion
html = show([{ ...base, kind: 'unsicher', draft: '' }])
assert.ok(html.includes(erledigt)); assert.ok(html.includes('Verwerfen')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('<textarea'))
html = show([{ ...base, status: 'gesendet' }])
assert.ok(!html.includes('row-actions')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen')); assert.ok(html.includes('<summary>Entwurf</summary>'))
// 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 = show([{ ...mail, prospect: { $id: 'p2', name: 'Firma', email: 'x@firma.example' } }])
assert.match(html, /<a class="btn btn-primary" href="mailto:x@firma\.example\?subject=Re%3A%20Firma&amp;body=Guten%20Tag%0D%0ABis%20bald">E-Mail schreiben<\/a>/)
assert.ok(!html.includes('Kopieren'))
html = show([{ ...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: 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('class="msg warn"')); assert.ok(html.includes('Suche Webdesigner'))
// die Filter kommen aus PULT_FILTERS, „Wartet“ ist vorgewählt
html = show([])
assert.match(html, /<button type="button" class="on" aria-pressed="true">Wartet<\/button><button type="button" aria-pressed="false">Veraltet<\/button><button type="button" aria-pressed="false">Erledigt<\/button>/)
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
})