Workers P3: Interessenten-Panel im Pult

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 09:59:11 +00:00
1 parent f8f8df2cbd
commit 686db61e1c
5 files changed
+701 -1

No files matched your search

+432
View File
@@ -0,0 +1,432 @@
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])
})