Workers P3: Interessenten-Panel im Pult
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f8f8df2cbd
commit
686db61e1c
5 files changed
+701
-1
No files matched your search
@@ -0,0 +1,131 @@
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import Badge from '../ui/Badge'
|
||||
import Button from '../ui/Button'
|
||||
import Dialog from '../ui/Dialog'
|
||||
import { useEmployees } from '../../hooks/useEmployees'
|
||||
import { BAD_REPLY_TEXT } from '../../lib/workersApi'
|
||||
import { prospectView } from '../../lib/workersProspect'
|
||||
|
||||
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
// Ton des Score-Bands: ab 80 grün, 60 bis 79 orange (reicht für eine Antwort), darunter rot
|
||||
const BAND_TONES = { hoch: 'green', mittel: 'orange', niedrig: 'red' }
|
||||
|
||||
// Inhalt des Panels aus der fertigen Ansicht (prospectView). Alles Fremde (Name, Anfragetext, Nachrichten, Begründung) steht nur als
|
||||
// Textknoten da; Zeilenumbrüche bleiben über .wk-pult-text (pre-wrap) erhalten. Die Adresse der Anfrage ist schon als sicher geprüft.
|
||||
export function ProspectBody({ view }) {
|
||||
const { score } = view
|
||||
return (
|
||||
<div className="wk-prospect">
|
||||
<div className="wk-prospect-score">
|
||||
{score.band
|
||||
? <Badge tone={BAND_TONES[score.band]}>{`Score ${score.value} · ${score.band}`}</Badge>
|
||||
: <span className="muted">Noch nicht bewertet</span>}
|
||||
{score.reason && <span>{score.reason}</span>}
|
||||
</div>
|
||||
|
||||
{view.facts.length > 0 && (
|
||||
<dl className="kv">
|
||||
{view.facts.map((fact) => (
|
||||
<Fragment key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<section className="wk-prospect-section">
|
||||
<h4 className="wk-prospect-title">Anfrage</h4>
|
||||
{view.requestText
|
||||
? <p className="wk-pult-text">{view.requestText}</p>
|
||||
: <p className="muted">Kein Anfragetext gespeichert.</p>}
|
||||
{view.sourceHref && (
|
||||
<p>
|
||||
<a className="link" href={view.sourceHref} target="_blank" rel="noopener noreferrer">Anfrage öffnen</a>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="wk-prospect-section">
|
||||
<h4 className="wk-prospect-title">Nachrichtenverlauf</h4>
|
||||
{view.messages.length > 0 ? (
|
||||
<ul className="wk-prospect-msgs">
|
||||
{view.messages.map((message, index) => (
|
||||
<li key={message.id !== '' ? message.id : `m${index}`} className="wk-prospect-msg">
|
||||
<div className="wk-prospect-msg-head">
|
||||
<strong>{message.who}</strong>
|
||||
{message.when && <span>{message.when}</span>}
|
||||
</div>
|
||||
<p className="wk-pult-text">{message.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="muted">Noch keine Nachrichten.</p>
|
||||
)}
|
||||
{view.previewLine && <p className="muted">{view.previewLine}</p>}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Interessenten-Panel (Dialog): lädt den Interessenten mit Faden, Nachrichten und Vorschau vom Dienst (api.prospect) und zeigt ihn nur
|
||||
// zum Lesen. Ein Schließen oder Wechsel während des Ladens verwirft die Antwort. Die Namen der Absender kommen aus der Mitarbeiterliste;
|
||||
// bis sie da ist, bleibt der Ladezustand stehen (sonst stünde kurz überall „Kollege“).
|
||||
export default function ProspectPanel({ api, prospectId, onClose }) {
|
||||
const [state, setState] = useState({ phase: 'loading', detail: null, error: '' })
|
||||
const [attempt, setAttempt] = useState(0) // „Neu laden“ zählt hoch und startet die Abfrage noch einmal
|
||||
const { employees, loading: employeesLoading } = useEmployees()
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setState((s) => (s.phase === 'loading' ? s : { phase: 'loading', detail: null, error: '' }))
|
||||
const load = async () => {
|
||||
try {
|
||||
const detail = await api.prospect(prospectId)
|
||||
if (!alive) return
|
||||
if (!isRecord(detail) || !isRecord(detail.prospect)) {
|
||||
setState({ phase: 'error', detail: null, error: BAD_REPLY_TEXT })
|
||||
return
|
||||
}
|
||||
setState({ phase: 'ready', detail, error: '' })
|
||||
} catch (err) {
|
||||
if (alive) setState({ phase: 'error', detail: null, error: err?.message || BAD_REPLY_TEXT })
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [api, prospectId, attempt])
|
||||
|
||||
const view = state.phase === 'ready' && !employeesLoading ? prospectView(state.detail, { nowMs: Date.now(), employees }) : null
|
||||
|
||||
let body
|
||||
if (state.phase === 'error') {
|
||||
body = (
|
||||
<>
|
||||
<div className="msg err" role="alert">{state.error}</div>
|
||||
<div>
|
||||
<Button type="button" onClick={() => setAttempt((n) => n + 1)}>Neu laden</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
} else if (view === null) {
|
||||
body = <div className="loading">Wird geladen …</div>
|
||||
} else {
|
||||
body = <ProspectBody view={view} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={view ? view.title : 'Interessent'}
|
||||
onClose={onClose}
|
||||
wide
|
||||
footer={<Button type="button" onClick={onClose}>Schließen</Button>}
|
||||
>
|
||||
{body}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { copyText } from '../CopyableCredential'
|
||||
import { useEmployees } from '../../hooks/useEmployees'
|
||||
import { usePultFeed } from '../../hooks/usePultFeed'
|
||||
import { PULT_FILTERS, copyAndOpen, mailtoHref, rowModel } from '../../lib/workersPult'
|
||||
import ProspectPanel from './ProspectPanel'
|
||||
|
||||
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“ nach dem ersten Klick
|
||||
const ACTION_FAILED = 'Das hat nicht geklappt.'
|
||||
@@ -207,7 +208,7 @@ export default function PultList({ api, me }) {
|
||||
const [filterId, setFilterId] = useState('offen')
|
||||
const [drafts, setDrafts] = useState({}) // bearbeitete Texte je Eintrag; was fehlt, zeigt den Entwurf des Dienstes
|
||||
const [finished, setFinished] = useState('') // Meldung über den zuletzt abgeschlossenen Eintrag (seine Zeile ist dann schon weg)
|
||||
const [, setDetailId] = useState(null) // „Details“ öffnet ab P3-T20 das Interessenten-Panel
|
||||
const [detailId, setDetailId] = useState(null) // Interessent, dessen Panel offen ist (null = keins)
|
||||
const filter = PULT_FILTERS.find((f) => f.id === filterId) ?? PULT_FILTERS[0]
|
||||
const { items, loading, error, refresh } = usePultFeed({ api, statuses: filter.statuses })
|
||||
const { employees } = useEmployees()
|
||||
@@ -282,6 +283,7 @@ export default function PultList({ api, me }) {
|
||||
{finished && <div className="msg ok" role="status">{finished}</div>}
|
||||
{error && rows.length > 0 && <div className="msg warn" role="status">Verbindung gestört: {error} Es wird weiter versucht.</div>}
|
||||
{body}
|
||||
{detailId && <ProspectPanel api={api} prospectId={detailId} onClose={() => setDetailId(null)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Reine Ansicht des Interessenten-Panels: macht aus der Antwort von GET /prospect/:id ({ prospect, thread, messages, preview, reply })
|
||||
// das, was auf dem Bildschirm steht. Nur genannte Felder kommen durch (Whitelist): was der Dienst sonst noch mitschickte, auch
|
||||
// eine Telefonnummer, wird nie angezeigt. Kein React, kein Appwrite, kein window/document: unter Node testbar (loadModule).
|
||||
|
||||
import { ageLabel, employeeName, safeExternalUrl } from './workersPult'
|
||||
import { ROLES } from './workersRoster'
|
||||
|
||||
// Anzeigenamen der Plattformen, deren Alarm-Mails der Dienst liest; jede andere Quelle (z. B. eine Domain der Websuche) bleibt, wie sie ist
|
||||
const SOURCE_NAMES = Object.freeze({
|
||||
'kleinanzeigen-alarm': 'Kleinanzeigen',
|
||||
'freelancermap-alarm': 'freelancermap',
|
||||
'freelance-de-alarm': 'freelance.de',
|
||||
})
|
||||
|
||||
// Status des Interessenten (Übersicht §4.2) auf Deutsch
|
||||
const STATUS_NAMES = Object.freeze({
|
||||
gefunden: 'Gefunden',
|
||||
bewertet: 'Bewertet',
|
||||
botlead: 'Bot-Lead',
|
||||
kontaktiert: 'Kontaktiert',
|
||||
gespraech: 'Im Gespräch',
|
||||
ja: 'Zugesagt',
|
||||
vorschau_bau: 'Vorschau im Bau',
|
||||
vorschau_gesendet: 'Vorschau gesendet',
|
||||
angebot: 'Angebot',
|
||||
deal: 'Abschluss',
|
||||
verloren: 'Verloren',
|
||||
gesperrt: 'Gesperrt',
|
||||
})
|
||||
|
||||
// Status der Vorschau (Übersicht §4.13) auf Deutsch
|
||||
const PREVIEW_STATUS_NAMES = Object.freeze({
|
||||
bau: 'wird gebaut',
|
||||
pruefung: 'wird geprüft',
|
||||
zurueck: 'geht zurück an den Bau',
|
||||
fertig: 'fertig',
|
||||
freigabe: 'wartet auf Freigabe',
|
||||
gesendet: 'gesendet',
|
||||
verworfen: 'verworfen',
|
||||
gescheitert: 'gescheitert',
|
||||
})
|
||||
|
||||
const CUSTOMER = 'kunde' // Autor einer eingegangenen Nachricht (Übersicht §4.10)
|
||||
const MENSCH = 'mensch:' // Autor einer Nachricht, die ein Mitarbeiter abgeschickt hat: mensch:<userId>
|
||||
const UNTITLED = 'Interessent'
|
||||
const UNKNOWN_AUTHOR = 'Unbekannt'
|
||||
|
||||
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
const text = (v) => (typeof v === 'string' ? v.trim() : '')
|
||||
// Anzeigename aus einer Tabelle; nur eigene Einträge zählen ('constructor' oder 'toString' sind keine Namen)
|
||||
const named = (table, key) => (typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : null)
|
||||
|
||||
/** Band zum Score der Bewertung: ab 80 hoch, ab 60 mittel (die Schwelle für eine Antwort), darunter niedrig; ohne Zahl leer. */
|
||||
function scoreBand(score) {
|
||||
if (typeof score !== 'number' || !Number.isFinite(score)) return ''
|
||||
if (score >= 80) return 'hoch'
|
||||
if (score >= 60) return 'mittel'
|
||||
return 'niedrig'
|
||||
}
|
||||
|
||||
// Name zum Autor einer Nachricht: Mitarbeiter über die Mitarbeiterliste, Worker über das Rollen-Register, eingegangene Nachricht = Interessent
|
||||
function authorName(author, employees) {
|
||||
if (typeof author !== 'string' || author === '') return UNKNOWN_AUTHOR
|
||||
if (author === CUSTOMER) return UNTITLED
|
||||
if (author.startsWith(MENSCH)) return employeeName(author, employees)
|
||||
return ROLES.find((role) => role.workerId === author)?.name ?? author
|
||||
}
|
||||
|
||||
function factsOf(prospect, nowMs) {
|
||||
const place = [text(prospect.postalCode), text(prospect.city)].filter(Boolean).join(' ')
|
||||
const source = text(prospect.source)
|
||||
const status = text(prospect.status)
|
||||
return [
|
||||
{ label: 'Quelle', value: named(SOURCE_NAMES, source) ?? source },
|
||||
{ label: 'Gefunden', value: ageLabel(prospect.foundAt, nowMs) },
|
||||
{ label: 'Ort', value: place },
|
||||
{ label: 'Status', value: named(STATUS_NAMES, status) ?? status },
|
||||
].filter((fact) => fact.value !== '')
|
||||
}
|
||||
|
||||
function previewLineOf(preview, nowMs) {
|
||||
if (!isRecord(preview)) return ''
|
||||
const status = text(preview.status)
|
||||
const age = ageLabel(preview.createdAt, nowMs)
|
||||
const parts = [
|
||||
named(PREVIEW_STATUS_NAMES, status) ?? status,
|
||||
Number.isInteger(preview.round) && preview.round > 0 ? `Runde ${preview.round}` : '',
|
||||
age ? `erstellt ${age}` : '',
|
||||
text(preview.convertedProjectId) ? 'als Projekt übernommen' : '',
|
||||
].filter(Boolean)
|
||||
return parts.length > 0 ? `Vorschau: ${parts.join(' · ')}` : 'Vorschau vorhanden'
|
||||
}
|
||||
|
||||
/**
|
||||
* Ansicht des Panels. `detail` ist die Antwort von GET /prospect/:id (kaputte Teile ergeben leere Felder, nie einen Fehler),
|
||||
* `nowMs` die Uhrzeit für „vor 3 Std.“ (Standard: jetzt), `employees` die Mitarbeiterliste ({ userId, displayName }) für die Namen der Absender.
|
||||
* score.band ist hoch (ab 80), mittel (60 bis 79), niedrig (darunter) oder '' ohne Score; sourceHref ist null, wenn die Adresse
|
||||
* fehlt oder nicht sicher zu öffnen ist (nur https ohne Zugangsdaten).
|
||||
*/
|
||||
export function prospectView(detail, { nowMs = Date.now(), employees } = {}) {
|
||||
const prospect = isRecord(detail) && isRecord(detail.prospect) ? detail.prospect : {}
|
||||
const messages = isRecord(detail) && Array.isArray(detail.messages) ? detail.messages : []
|
||||
const preview = isRecord(detail) ? detail.preview : null
|
||||
const requestText = typeof prospect.requestText === 'string' && prospect.requestText.trim() !== '' ? prospect.requestText : ''
|
||||
const score = typeof prospect.score === 'number' && Number.isFinite(prospect.score) ? prospect.score : null
|
||||
|
||||
return {
|
||||
title: text(prospect.name) || UNTITLED,
|
||||
facts: factsOf(prospect, nowMs),
|
||||
score: { value: score, band: scoreBand(score), reason: text(prospect.scoreReason) },
|
||||
requestText,
|
||||
sourceHref: safeExternalUrl(prospect.sourceUrl),
|
||||
messages: messages.filter(isRecord).map((m) => ({
|
||||
id: typeof m.$id === 'string' ? m.$id : '',
|
||||
who: authorName(m.author, employees),
|
||||
when: ageLabel(m.sentAt || m.createdAt, nowMs),
|
||||
body: typeof m.body === 'string' ? m.body : '',
|
||||
})),
|
||||
previewLine: previewLineOf(preview, nowMs),
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,20 @@
|
||||
.wk-pult-row .wk-pult-draft { display: block; width: 100%; min-height: 140px; margin: 0 0 12px; }
|
||||
.wk-pult-row .wk-pult-actions { align-items: center; gap: 8px 16px; }
|
||||
|
||||
/* ---------- Interessenten-Panel (Dialog) ---------- */
|
||||
|
||||
/* Fremdtext (Anfrage, Nachrichten): Zeilenumbrüche des Absenders bleiben, lange Wörter und Adressen brechen um */
|
||||
.wk-pult-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
|
||||
.wk-prospect { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
||||
.wk-prospect-score { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; overflow-wrap: anywhere; }
|
||||
.wk-prospect-section { display: flex; flex-direction: column; gap: 8px; min-width: 0; padding-top: 16px; border-top: 1px solid var(--line); }
|
||||
.wk-prospect-section p { margin: 0; }
|
||||
.wk-prospect-title { font-size: 13px; line-height: 20px; font-weight: 600; color: var(--text-2); }
|
||||
.wk-prospect-msgs { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.wk-prospect-msg { min-width: 0; }
|
||||
.wk-prospect-msg-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; margin-bottom: 4px; font-size: 13px; color: var(--text-2); }
|
||||
|
||||
/* ---------- Schmale Bildschirme ---------- */
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
|
||||
@@ -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 <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])
|
||||
})
|
||||
Reference in new issue
Block a user