433 lines
26 KiB
JavaScript
433 lines
26 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'
|
||
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 <script>alert(1)</script>'))
|
||
// Anfragetext und Nachrichten: Zeilenumbruch bleibt im Text (pre-wrap), Markup nie
|
||
assert.match(html, /<p class="wk-pult-text">Suche Webdesigner <img src=x onerror=alert\(1\)>\nZeile 2<\/p>/)
|
||
assert.ok(html.includes('Hallo <b>du</b>')); 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])
|
||
})
|