Files
tickte-system/tests/unit/workersProspect.test.mjs
T
ClaudeandClaude Sonnet 5.5 d29cc29d47 Workers P4 Endprüfung W6: „Schließen“ im Interessenten-Panel ist am Handy 40 px hoch
Die gemeinsame Regel `.dialog-foot .btn` (32 px) gilt für rund 22 Dialoge der Seite und bleibt unberührt.
Das Panel gibt seinem Knopf die Klasse wk-prospect-close; im Handy-Block von workers.css hebt
`.wk-prospect-close.btn[type="button"]` (Spezifität 0,3,0 gegen 0,2,0, beginnt mit einer wk-Klasse wie
workersCss.test.mjs es verlangt) ihn auf volle Breite und 40 px. Der Desktop behält 32 px.

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

450 lines
27 KiB
JavaScript
Raw 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 { loadModule } from './load.mjs'
// Reine Ansicht des Interessenten-Panels (Wirkung von GET /prospect/:id auf den Bildschirm): Whitelist der Felder, Score-Band,
// Anzeigenamen, Nachrichtenverlauf, Vorschau-Zeile. Das Panel selbst wird weiter unten mit echtem React gerendert (Text, nie Markup).
const { prospectView } = await loadModule('src/lib/workersProspect.js')
const NOW = Date.parse('2026-10-19T09:00:00Z')
const MIN = 60 * 1000
const HOUR = 60 * MIN
const iso = (offsetMs) => new Date(NOW + offsetMs).toISOString()
const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
const PROSPECT = {
$id: 'p1', name: 'Verein X', source: 'kleinanzeigen-alarm', foundAt: '2026-10-19T06:00:00Z', city: 'Namborn', postalCode: '66640',
status: 'bewertet', score: 72, scoreReason: 'Passt.', requestText: 'Text', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3',
}
const detailOf = (prospect = {}, rest = {}) => ({ prospect: { ...PROSPECT, ...prospect }, thread: null, messages: [], preview: null, reply: null, ...rest })
const view = (detail, employees = []) => prospectView(detail, { nowMs: NOW, employees })
test('Fakten: Quelle, Fundzeit, Ort, Status; keine Telefon-Zeile, auch wenn das Feld mitkäme', () => {
const v = prospectView({ prospect: { name: 'Verein X', source: 'kleinanzeigen-alarm', foundAt: '2026-10-19T06:00:00Z', city: 'Namborn', postalCode: '66640', status: 'bewertet', score: 72, scoreReason: 'Passt.', requestText: 'Text', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', phone: '0631 123' }, thread: null, messages: [], preview: null, reply: null }, { nowMs: NOW, employees: [] })
assert.deepEqual(v.facts.map((f) => f.label), ['Quelle', 'Gefunden', 'Ort', 'Status'])
assert.equal(JSON.stringify(v).includes('0631'), false)
assert.deepEqual([v.title, v.score.value, v.score.band, v.sourceHref], ['Verein X', 72, 'mittel', 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3'])
})
test('Fakten: Werte (Anzeigename der Plattform, Alter, PLZ vor Ort, deutscher Status); Felder ohne Wert entfallen', () => {
const v = view(detailOf())
assert.deepEqual(v.facts, [
{ label: 'Quelle', value: 'Kleinanzeigen' },
{ label: 'Gefunden', value: 'vor 3 Std.' },
{ label: 'Ort', value: '66640 Namborn' },
{ label: 'Status', value: 'Bewertet' },
])
// nur die Stadt oder nur die PLZ; ohne beides gibt es keine Zeile „Ort“; ohne gültige Fundzeit keine Zeile „Gefunden“
assert.deepEqual(view(detailOf({ postalCode: undefined })).facts[2], { label: 'Ort', value: 'Namborn' })
assert.deepEqual(view(detailOf({ city: undefined })).facts[2], { label: 'Ort', value: '66640' })
assert.deepEqual(view(detailOf({ city: '', postalCode: ' ' })).facts.map((f) => f.label), ['Quelle', 'Gefunden', 'Status'])
assert.deepEqual(view(detailOf({ foundAt: 'gestern' })).facts.map((f) => f.label), ['Quelle', 'Ort', 'Status'])
})
test('Quelle: Anzeigenamen der drei Alarm-Plattformen, sonst der Rohwert (auch bei Namen wie "constructor")', () => {
const quelle = (source) => view(detailOf({ source })).facts[0].value
assert.equal(quelle('kleinanzeigen-alarm'), 'Kleinanzeigen')
assert.equal(quelle('freelancermap-alarm'), 'freelancermap')
assert.equal(quelle('freelance-de-alarm'), 'freelance.de')
assert.equal(quelle('gutefrage.net'), 'gutefrage.net')
assert.equal(quelle('constructor'), 'constructor')
})
test('Status: deutsche Bezeichnung für alle zwölf Status, unbekannte Werte bleiben stehen', () => {
const status = (s) => view(detailOf({ status: s })).facts.find((f) => f.label === 'Status').value
assert.deepEqual(
['gefunden', 'bewertet', 'botlead', 'kontaktiert', 'gespraech', 'ja', 'vorschau_bau', 'vorschau_gesendet', 'angebot', 'deal', 'verloren', 'gesperrt'].map(status),
['Gefunden', 'Bewertet', 'Bot-Lead', 'Kontaktiert', 'Im Gespräch', 'Zugesagt', 'Vorschau im Bau', 'Vorschau gesendet', 'Angebot', 'Abschluss', 'Verloren', 'Gesperrt'],
)
assert.equal(status('neu_erfunden'), 'neu_erfunden')
assert.equal(status('toString'), 'toString')
})
test('Score-Band: ≥ 80 hoch, 60–79 mittel, < 60 niedrig, ohne Score leer', () => {
const band = (score) => view(detailOf({ score })).score
assert.deepEqual([100, 80].map((s) => band(s).band), ['hoch', 'hoch'])
assert.deepEqual([79, 72, 60].map((s) => band(s).band), ['mittel', 'mittel', 'mittel'])
assert.deepEqual([59, 1, 0].map((s) => band(s).band), ['niedrig', 'niedrig', 'niedrig'])
// ohne brauchbare Zahl: kein Band, kein Wert (ein Text "72" ist keine Zahl des Dienstes)
for (const kaputt of [undefined, null, '72', NaN, Infinity, {}]) assert.deepEqual([band(kaputt).band, band(kaputt).value], ['', null], String(kaputt))
assert.deepEqual(band(72), { value: 72, band: 'mittel', reason: 'Passt.' })
assert.equal(view(detailOf({ scoreReason: undefined })).score.reason, '')
})
test('Anfragetext und Quelle: Wortlaut unverändert, Adresse nur als https ohne Zugangsdaten', () => {
assert.equal(view(detailOf({ requestText: 'Zeile 1\n Zeile 2 <b>fett</b>' })).requestText, 'Zeile 1\n Zeile 2 <b>fett</b>')
assert.equal(view(detailOf({ requestText: undefined })).requestText, '')
assert.equal(view(detailOf({ requestText: ' \n ' })).requestText, '')
for (const sourceUrl of ['javascript:alert(1)', 'http://www.kleinanzeigen.de/x', 'https://www.kleinanzeigen.de@evil.example/x', 'data:text/html,hi', '', undefined, 42]) {
assert.equal(view(detailOf({ sourceUrl })).sourceHref, null, String(sourceUrl))
}
})
test('Titel: Name des Interessenten, ohne Namen "Interessent"', () => {
assert.equal(view(detailOf({ name: ' Verein X ' })).title, 'Verein X')
assert.equal(view(detailOf({ name: undefined })).title, 'Interessent')
assert.equal(view(detailOf({ name: ' ' })).title, 'Interessent')
})
test('Whitelist: unbekannte Felder (Telefon, E-Mail-Nachweise, interne Hinweise) tauchen in der Ansicht nicht auf', () => {
const v = view(detailOf({ phone: '0631 123', phoneSource: 'https://x.example/tel', email: 'geheim@x.example', emailSource: 'https://x.example/mail', internal: 'INTERN-7', contactName: 'Frau Y' },
{ thread: { $id: 't1', toEmail: 'thread@x.example' }, reply: { $id: 'r1', draft: 'ENTWURF-9' } }))
const text = JSON.stringify(v)
for (const leak of ['0631', 'geheim@x.example', 'x.example/tel', 'x.example/mail', 'INTERN-7', 'thread@x.example', 'ENTWURF-9']) assert.equal(text.includes(leak), false, leak)
assert.deepEqual(Object.keys(v), ['title', 'facts', 'score', 'requestText', 'sourceHref', 'messages', 'previewLine'])
})
test('Nachrichten: Autor "mensch:<id>" → Name aus employees, "clara" → Clara; ohne Vorschau leere Zeile', () => {
const messages = [
{ $id: 'm1', direction: 'out', author: 'clara', body: 'Guten Tag', sentAt: iso(-2 * HOUR), createdAt: iso(-2 * HOUR) },
{ $id: 'm2', direction: 'out', author: 'mensch:u2', body: 'Gesendet', sentAt: iso(-90 * MIN), createdAt: iso(-90 * MIN) },
{ $id: 'm3', direction: 'in', author: 'kunde', body: 'Ja gern', createdAt: iso(-10 * MIN) },
{ $id: 'm4', direction: 'out', author: 'mensch:u9', body: 'Unbekannt', sentAt: iso(-5 * MIN), createdAt: iso(-5 * MIN) },
{ $id: 'm5', direction: 'out', author: 'max', body: 'Mail', createdAt: iso(-1000) },
]
const v = view(detailOf({}, { messages }), [{ userId: 'u2', displayName: 'Kenso' }])
assert.deepEqual(v.messages, [
{ id: 'm1', who: 'Clara', when: 'vor 2 Std.', body: 'Guten Tag' },
{ id: 'm2', who: 'Kenso', when: 'vor 1 Std.', body: 'Gesendet' },
{ id: 'm3', who: 'Interessent', when: 'vor 10 Min.', body: 'Ja gern' },
{ id: 'm4', who: 'Kollege', when: 'vor 5 Min.', body: 'Unbekannt' },
{ id: 'm5', who: 'Max', when: 'gerade eben', body: 'Mail' },
])
assert.equal(v.previewLine, '')
})
test('Nachrichten: Reihenfolge bleibt, fremde Autoren erscheinen als Text, Kaputtes wird übersprungen oder leer', () => {
const v = view(detailOf({}, { messages: [
{ $id: 'a', author: 'info@firma.example', body: 'B1', createdAt: iso(-60 * MIN) },
null,
'text',
{ author: 'toString', body: 'B2' },
{ $id: 'c', body: 42, createdAt: 'kaputt' },
] }))
assert.deepEqual(v.messages, [
{ id: 'a', who: 'info@firma.example', when: 'vor 1 Std.', body: 'B1' },
{ id: '', who: 'toString', when: '', body: 'B2' },
{ id: 'c', who: 'Unbekannt', when: '', body: '' },
])
assert.deepEqual(view(detailOf({}, { messages: 'kaputt' })).messages, [])
assert.deepEqual(view(detailOf({}, { messages: undefined })).messages, [])
})
test('Mitarbeiterliste fehlt oder ist kaputt: Namen fallen auf "Kollege" zurück, nichts wirft', () => {
const messages = [{ $id: 'm', author: 'mensch:u2', body: 'x', createdAt: iso(-1000) }]
for (const employees of [undefined, null, 'kaputt', [null], [{ userId: 'u2' }]]) assert.equal(prospectView(detailOf({}, { messages }), { nowMs: NOW, employees }).messages[0].who, 'Kollege')
})
test('Vorschau-Zeile: ohne Vorschau leer, sonst Status, Runde und Alter; kein Token, keine Adresse', () => {
assert.equal(view(detailOf()).previewLine, '')
assert.equal(view(detailOf({}, { preview: undefined })).previewLine, '')
assert.equal(view(detailOf({}, { preview: 'kaputt' })).previewLine, '')
const preview = { $id: 'v1', prospectId: 'p1', builder: 'bob', status: 'freigabe', round: 2, createdAt: iso(-3 * HOUR), deleteAfter: iso(60 * HOUR), token: 'GEHEIM-TOKEN' }
assert.equal(view(detailOf({}, { preview })).previewLine, 'Vorschau: wartet auf Freigabe · Runde 2 · erstellt vor 3 Std.')
assert.equal(view(detailOf({}, { preview: { ...preview, status: 'gesendet', round: 1, convertedProjectId: 'proj1' } })).previewLine, 'Vorschau: gesendet · Runde 1 · erstellt vor 3 Std. · als Projekt übernommen')
assert.equal(view(detailOf({}, { preview: { status: 'sonstwas' } })).previewLine, 'Vorschau: sonstwas')
assert.equal(view(detailOf({}, { preview: {} })).previewLine, 'Vorschau vorhanden')
assert.equal(JSON.stringify(view(detailOf({}, { preview }))).includes('GEHEIM-TOKEN'), false)
})
test('Vorschau-Status: alle acht Status haben einen deutschen Text', () => {
const line = (status) => view(detailOf({}, { preview: { status } })).previewLine
assert.deepEqual(
['bau', 'pruefung', 'zurueck', 'fertig', 'freigabe', 'gesendet', 'verworfen', 'gescheitert'].map(line),
['Vorschau: wird gebaut', 'Vorschau: wird geprüft', 'Vorschau: geht zurück an den Bau', 'Vorschau: fertig', 'Vorschau: wartet auf Freigabe', 'Vorschau: gesendet', 'Vorschau: verworfen', 'Vorschau: gescheitert'],
)
})
test('Kaputte Antwort: ohne Interessent kommt eine leere, aber vollständige Ansicht', () => {
for (const detail of [{}, { prospect: null }, { prospect: 'x' }, null, undefined]) {
const v = view(detail)
assert.deepEqual([v.title, v.facts, v.score, v.requestText, v.sourceHref, v.messages, v.previewLine], ['Interessent', [], { value: null, band: '', reason: '' }, '', null, [], ''])
}
})
// ---- Das Panel wird mit echtem React gerendert (renderToStaticMarkup): Fremdtext erscheint nur als Text, Links tragen rel ----
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
async function loadBody() {
const out = await esbuild.build({
stdin: {
contents: "import { createElement } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport { ProspectBody } from './src/components/workers/ProspectPanel.jsx'\nexport const render = (view) => renderToStaticMarkup(createElement(ProspectBody, { view }))",
resolveDir: ROOT, loader: 'js',
},
bundle: true, format: 'esm', platform: 'node', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' },
// die Mitarbeiterliste käme aus Appwrite: für den Aufbau der Datei nur ein Stellvertreter
plugins: [{
name: 'prospect-stubs',
setup(build) {
build.onResolve({ filter: /hooks\/useEmployees$/ }, () => ({ path: 'useEmployees', namespace: 'prospect-stub' }))
build.onLoad({ filter: /.*/, namespace: 'prospect-stub' }, () => ({ contents: 'export function useEmployees() { return { employees: [], loading: false } }', loader: 'js' }))
},
}],
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('ProspectBody (gerendert): Fakten als .kv, Score und Begründung, Anfragetext und Nachrichten nur als Text, Link mit rel', async () => {
const { render } = await loadBody()
const v = view(
detailOf({ name: 'Verein <i>X</i>', scoreReason: 'Passt <script>alert(1)</script>', requestText: 'Suche Webdesigner <img src=x onerror=alert(1)>\nZeile 2', phone: '0631 123' },
{ messages: [{ $id: 'm1', direction: 'in', author: 'kunde', body: 'Hallo <b>du</b>', createdAt: iso(-10 * MIN) }, { $id: 'm2', direction: 'out', author: 'clara', body: 'Antwort', createdAt: iso(-5 * MIN) }], preview: { status: 'bau', round: 1, createdAt: iso(-HOUR) } }),
)
const html = render(v)
assert.match(html, /<dl class="kv">/)
for (const t of ['<dt>Quelle</dt><dd>Kleinanzeigen</dd>', '<dt>Gefunden</dt><dd>vor 3 Std.</dd>', '<dt>Ort</dt><dd>66640 Namborn</dd>', '<dt>Status</dt><dd>Bewertet</dd>']) assert.ok(html.includes(t), t)
assert.ok(html.includes('Score 72')); assert.ok(html.includes('mittel')); assert.ok(html.includes('Passt &lt;script&gt;alert(1)&lt;/script&gt;'))
// Anfragetext und Nachrichten: Zeilenumbruch bleibt im Text (pre-wrap), Markup nie
assert.match(html, /<p class="wk-pult-text">Suche Webdesigner &lt;img src=x onerror=alert\(1\)&gt;\nZeile 2<\/p>/)
assert.ok(html.includes('Hallo &lt;b&gt;du&lt;/b&gt;')); assert.ok(html.includes('Clara')); assert.ok(html.includes('Interessent')); assert.ok(html.includes('vor 10 Min.'))
assert.ok(!html.includes('<script>') && !html.includes('<img') && !html.includes('<b>du</b>') && !html.includes('<i>X</i>'))
assert.match(html, /<a class="link" href="https:\/\/www\.kleinanzeigen\.de\/s-anzeige\/x\/1-2-3" target="_blank" rel="noopener noreferrer">Anfrage öffnen<\/a>/)
assert.ok(html.includes('Vorschau: wird gebaut · Runde 1 · erstellt vor 1 Std.')); assert.ok(!html.includes('0631'))
})
test('ProspectBody (gerendert): Leerzustände ohne Score, Anfragetext, Nachrichten, Vorschau und Adresse', async () => {
const { render } = await loadBody()
const html = render(view(detailOf({ score: undefined, scoreReason: undefined, requestText: undefined, sourceUrl: 'javascript:alert(1)' })))
assert.ok(html.includes('Noch nicht bewertet')); assert.ok(html.includes('Kein Anfragetext gespeichert.')); assert.ok(html.includes('Noch keine Nachrichten.'))
assert.ok(!html.includes('Anfrage öffnen')); assert.ok(!html.includes('href=')); assert.ok(!html.includes('Vorschau'))
})
// ---- ProspectPanel läuft wirklich: Mini-Läufer für Hooks statt React (useState, useEffect), JSX liefert einfache { type, props }-Bäume ----
const FAKE_REACT = `
let active = null
let late = 0
export const Fragment = Symbol.for('Fragment')
export const jsx = (type, props) => ({ type, props })
export const jsxs = jsx
export const lateUpdates = () => late
export function __mount(component, getProps) {
const run = { hooks: [], index: 0, effects: [], dirty: false, rendering: false, unmounted: false, tree: null }
run.render = () => {
if (run.rendering) { run.dirty = true; return }
run.rendering = true
do {
run.dirty = false
run.index = 0
active = run
run.tree = component(getProps())
active = null
for (const effect of run.effects.splice(0)) effect()
} while (run.dirty)
run.rendering = false
}
run.render()
return {
tree: () => run.tree,
rerender: () => run.render(),
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) { 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 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()
})
}
`
// Dialog, Button, Badge und die Mitarbeiterliste sind Stellvertreter: der Baum zeigt, was das Panel ihnen übergibt
const PANEL_STUBS = {
Dialog: 'export default function Dialog() {}',
Button: 'export default function Button() {}',
Badge: 'export default function Badge() {}',
useEmployees: 'export function useEmployees() { return { employees: globalThis.__panelEmployees ?? [], loading: globalThis.__panelEmployeesLoading === true } }',
}
async function loadPanel() {
const out = await esbuild.build({
stdin: {
contents: "export { default as ProspectPanel, ProspectBody } from './src/components/workers/ProspectPanel.jsx'\nexport { __mount, lateUpdates } from 'react'",
resolveDir: ROOT, loader: 'js',
},
bundle: true, format: 'esm', write: false, platform: 'neutral', jsx: 'automatic', loader: { '.jsx': 'jsx' },
plugins: [{
name: 'panel-fakes',
setup(build) {
build.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: 'react', namespace: 'panel-fake' }))
build.onResolve({ filter: /ui\/(Dialog|Button|Badge)$|hooks\/useEmployees$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'panel-stub' }))
build.onLoad({ filter: /.*/, namespace: 'panel-fake' }, () => ({ contents: FAKE_REACT, loader: 'js' }))
build.onLoad({ filter: /.*/, namespace: 'panel-stub' }, (a) => ({ contents: PANEL_STUBS[a.path], loader: 'js' }))
},
}],
})
return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
}
const settle = () => new Promise((resolve) => setImmediate(resolve))
// Aufrufe von api.prospect, die der Test von Hand beantwortet
function fakeApi() {
const calls = []
return { calls, prospect: (id) => new Promise((resolve, reject) => calls.push({ id, resolve, reject })) }
}
test('ProspectPanel (läuft): Ladezustand, dann Interessent mit Namen aus der Mitarbeiterliste; Titel ist sein Name', async () => {
const { ProspectPanel, ProspectBody, __mount } = await loadPanel()
try {
const api = fakeApi()
const closed = []
const panel = __mount(ProspectPanel, () => ({ api, prospectId: 'p1', onClose: () => closed.push('zu') }))
assert.deepEqual(api.calls.map((c) => c.id), ['p1'])
let root = panel.tree()
assert.equal(root.props.title, 'Interessent'); assert.equal(root.props.wide, true); assert.equal(root.props.children.props.className, 'loading')
// der Schließen-Knopf im Fuß und das × des Dialogs rufen dasselbe onClose
assert.equal(root.props.onClose, root.props.footer.props.onClick); root.props.footer.props.onClick(); assert.deepEqual(closed, ['zu'])
globalThis.__panelEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
api.calls[0].resolve(detailOf({ name: 'Verein X' }, { messages: [{ $id: 'm1', author: 'mensch:u2', body: 'Gesendet', createdAt: iso(-5 * MIN) }] }))
await settle(); panel.rerender()
root = panel.tree()
assert.equal(root.props.title, 'Verein X'); assert.equal(root.props.children.type, ProspectBody)
const shown = root.props.children.props.view
assert.deepEqual([shown.title, shown.score.band, shown.messages[0].who], ['Verein X', 'mittel', 'Kenso'])
panel.unmount()
} finally { delete globalThis.__panelEmployees }
})
test('ProspectPanel (läuft): solange die Mitarbeiterliste lädt, bleibt der Ladezustand (kein "Kollege" für alle)', async () => {
const { ProspectPanel, ProspectBody, __mount } = await loadPanel()
try {
globalThis.__panelEmployeesLoading = true
const api = fakeApi()
const panel = __mount(ProspectPanel, () => ({ api, prospectId: 'p1', onClose() {} }))
api.calls[0].resolve(detailOf()); await settle(); panel.rerender()
assert.equal(panel.tree().props.children.props.className, 'loading')
globalThis.__panelEmployeesLoading = false; panel.rerender()
assert.equal(panel.tree().props.children.type, ProspectBody)
panel.unmount()
} finally { delete globalThis.__panelEmployeesLoading }
})
test('ProspectPanel (läuft): Fehler des Dienstes als Satz mit "Neu laden"; kaputte Antwort ergibt die Standardmeldung; Wiederholung lädt neu', async () => {
const { ProspectPanel, ProspectBody, __mount } = await loadPanel()
const api = fakeApi()
const panel = __mount(ProspectPanel, () => ({ api, prospectId: 'p1', onClose() {} }))
const errorOf = () => {
const [message, action] = panel.tree().props.children.props.children
return { message, retry: action.props.children }
}
api.calls[0].reject(Object.assign(new Error('Interessent nicht gefunden.'), { status: 404 })); await settle()
let { message, retry } = errorOf()
assert.deepEqual([message.props.className, message.props.role, message.props.children], ['msg err', 'alert', 'Interessent nicht gefunden.'])
assert.deepEqual([retry.props.type, retry.props.children], ['button', 'Neu laden'])
retry.props.onClick() // Wiederholung: neue Abfrage, dazwischen wieder der Ladezustand
assert.equal(api.calls.length, 2); assert.equal(panel.tree().props.children.props.className, 'loading')
api.calls[1].resolve({ prospect: null }); await settle() // Antwort ohne Interessent
;({ message, retry } = errorOf())
assert.equal(message.props.children, 'Der Hustler-Dienst antwortet nicht richtig.')
retry.props.onClick(); api.calls[2].reject(new Error('')); await settle() // Fehler ohne Satz
assert.equal(errorOf().message.props.children, 'Der Hustler-Dienst antwortet nicht richtig.')
errorOf().retry.props.onClick(); api.calls[3].resolve(detailOf({ name: 'Jetzt da' })); await settle(); panel.rerender()
assert.equal(panel.tree().props.children.type, ProspectBody); assert.equal(panel.tree().props.title, 'Jetzt da')
panel.unmount()
})
test('ProspectPanel (läuft): späte Antworten nach dem Schließen oder nach einem Wechsel des Interessenten zählen nicht', async () => {
const { ProspectPanel, __mount, lateUpdates } = await loadPanel()
const api = fakeApi()
let prospectId = 'p1'
const panel = __mount(ProspectPanel, () => ({ api, prospectId, onClose() {} }))
prospectId = 'p2'; panel.rerender()
assert.deepEqual(api.calls.map((c) => c.id), ['p1', 'p2'])
api.calls[1].resolve(detailOf({ name: 'Zweiter' })); await settle(); panel.rerender()
api.calls[0].resolve(detailOf({ name: 'Erster' })); await settle(); panel.rerender() // die ältere Antwort kommt zuletzt
assert.equal(panel.tree().props.title, 'Zweiter')
panel.unmount()
const second = fakeApi()
const closed = __mount(ProspectPanel, () => ({ api: second, prospectId: 'p1', onClose() {} }))
closed.unmount()
second.calls[0].resolve(detailOf()); await settle()
const third = fakeApi()
const failing = __mount(ProspectPanel, () => ({ api: third, prospectId: 'p1', onClose() {} }))
failing.unmount()
third.calls[0].reject(new Error('zu spät')); await settle()
assert.equal(lateUpdates(), 0)
})
// ---- Quelltext: Panel, Einbau in die Liste, Stil ----
test('ProspectPanel: Dialog mit Ladezustand und Fehler, lädt über api.prospect, kein innerHTML, jeder Knopf mit type', () => {
const s = read('src/components/workers/ProspectPanel.jsx')
assert.match(s, /import Dialog from '\.\.\/ui\/Dialog'/); assert.match(s, /<Dialog\b/); assert.match(s, /api\.prospect\(prospectId\)/)
assert.match(s, /prospectView\(/); assert.match(s, /useEmployees\(/); assert.match(s, /export default function ProspectPanel\(\{ api, prospectId, onClose \}\)/)
assert.doesNotMatch(s, /innerHTML/)
for (const t of ['Wird geladen', 'Neu laden', 'Anfrage öffnen', 'Nachrichtenverlauf', 'Schließen']) assert.ok(s.includes(t), t)
assert.match(s, /className="wk-pult-text"/); assert.match(s, /rel="noopener noreferrer"/); assert.match(s, /target="_blank"/)
// die Adresse kommt aus der Ansicht (sicher geprüft), nie roh aus der Antwort
assert.match(s, /href=\{view\.sourceHref\}/); assert.doesNotMatch(s, /sourceUrl/)
// eine späte Antwort nach dem Schließen oder nach einem Wechsel des Interessenten zählt nicht
assert.match(s, /let alive = true/); assert.match(s, /alive = false/)
assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length)
})
test('PultList: "Details" öffnet das Interessenten-Panel, Schließen räumt die Auswahl weg', () => {
const s = read('src/components/workers/PultList.jsx')
assert.match(s, /import ProspectPanel from '\.\/ProspectPanel'/)
assert.match(s, /const \[detailId, setDetailId\] = useState\(null\)/)
assert.match(s, /onClick=\{\(\) => onDetails\(prospect\.\$id\)\}/)
assert.match(s, /\{detailId && <ProspectPanel api=\{api\} prospectId=\{detailId\} onClose=\{\(\) => setDetailId\(null\)\} \/>\}/)
})
test('CSS: wk-pult-text bricht Zeilen wie im Text (pre-wrap) und lange Wörter um; Panel-Klassen sind definiert', () => {
const css = read('src/styles/workers.css').replace(/\/\*[\s\S]*?\*\//g, '')
const rule = (selector) => css.match(new RegExp(`${selector.replace(/[.~]/g, '\\$&')}\\s*\\{([^}]*)\\}`))?.[1] ?? ''
assert.match(rule('.wk-pult-text'), /white-space:\s*pre-wrap/); assert.match(rule('.wk-pult-text'), /overflow-wrap:\s*anywhere/)
const jsx = read('src/components/workers/ProspectPanel.jsx')
for (const m of jsx.matchAll(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), m[0])
})
// Endprüfung P4 (W6): „Schließen“ im Fuß des Panels ist am Handy mindestens 40 px hoch. Die gemeinsame Regel `.dialog-foot .btn` (32 px) bleibt für die
// übrigen Dialoge unverändert; die Hustler-Seite hebt nur ihren eigenen Knopf an (Klasse wk-prospect-close, Spezifität (0,3,0) gegen (0,2,0); die Regel
// beginnt mit einer wk-Klasse, wie workersCss.test.mjs es für jede Regel dieser Datei verlangt).
test('ProspectPanel (W6): der Schließen-Knopf trägt wk-prospect-close; die Handy-Regel macht ihn 40 px hoch, die Dialog-Regel bleibt unverändert', () => {
const jsx = read('src/components/workers/ProspectPanel.jsx')
assert.match(jsx, /<Button type="button" className="wk-prospect-close" onClick=\{onClose\}>Schließen<\/Button>/)
const css = read('src/styles/workers.css').replace(/\/\*[\s\S]*?\*\//g, '')
const phone = css.slice(css.indexOf('@media (max-width: 860px)'), css.indexOf('@media (prefers-reduced-motion'))
const rule = phone.match(/\.wk-prospect-close\.btn\[type="button"\]\s*\{([^}]*)\}/)?.[1] ?? ''
assert.match(rule, /min-height:\s*40px/); assert.match(rule, /width:\s*100%/)
// nirgends außerhalb des Handy-Blocks (der Desktop behält 32 px) und keine Änderung an der gemeinsamen Regel
assert.equal((css.match(/wk-prospect-close/g) ?? []).length, 1)
assert.doesNotMatch(css, /(^|\})\s*\.dialog-foot \.btn\s*\{/)
const global = read('src/styles/global.css')
assert.match(global, /\.dialog-foot \.btn\s*\{[^}]*min-height:\s*32px/)
})