Abgeschlossene Zeilen tragen jetzt ein Badge (Gesendet grün, Verworfen/Erledigt neutral) und die Zeile "Gesendet von Anna · vor 2 Std." aus doneBy/doneAt statt des Alters ab Anlage. Menschen (mensch:<id>) werden über die Mitarbeiterliste aufgelöst, ein Worker wie max über das Rollen-Register als "Max (automatisch)" und nie als "Kollege". Offene Zeilen bleiben unverändert. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
389 lines
22 KiB
JavaScript
389 lines
22 KiB
JavaScript
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import fs from 'node:fs'
|
||
import path from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import esbuild from 'esbuild'
|
||
|
||
// Die React-Teile sind unter Node nicht ladbar (JSX, Router): geprüft wird der Quelltext (Muster adminRoute.test.mjs); die Logik
|
||
// steckt in P3-T18. Nur usePultFeed läuft wirklich, mit einem kleinen Hook-Läufer anstelle von React (siehe unten).
|
||
const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
|
||
|
||
test('WorkersPage hängt den Reiter freigabe an hochhaus an und zeigt dort die PultList', () => {
|
||
const s = read('src/pages/WorkersPage.jsx')
|
||
assert.match(s, /id: 'hochhaus'/); assert.match(s, /id: 'freigabe'/); assert.match(s, /import PultList from/); assert.match(s, /<PultList[^>]*api=\{api\}/)
|
||
})
|
||
test('PultList: sichere Links, kein innerHTML, Entwurf bearbeitbar, Filter aus PULT_FILTERS', () => {
|
||
const s = read('src/components/workers/PultList.jsx')
|
||
assert.doesNotMatch(s, /dangerouslySetInnerHTML/)
|
||
assert.match(s, /rel="noopener noreferrer"/); assert.match(s, /PULT_FILTERS/); assert.match(s, /copyAndOpen/)
|
||
assert.doesNotMatch(s, /readOnly[^>]*wk-pult-draft|wk-pult-draft[^>]*readOnly/)
|
||
})
|
||
test('usePultFeed: 30-s-Takt nur bei sichtbarem Tab', () => {
|
||
const s = read('src/hooks/usePultFeed.js')
|
||
assert.match(s, /30000/); assert.match(s, /document\.visibilityState/); assert.match(s, /visibilitychange/)
|
||
})
|
||
test('CSS: wk-pult-Klassen und Handy-Umbruch; jede im JSX benutzte wk-Klasse ist definiert', () => {
|
||
const css = read('src/styles/workers.css')
|
||
assert.match(css, /\.wk-pult-row/); assert.match(css, /@media \(max-width: 860px\)[\s\S]*\.wk-pult-row/)
|
||
for (const f of ['PultList', 'ProspectPanel']) { const p = 'src/components/workers/' + f + '.jsx'; if (fs.existsSync(new URL('../../' + p, import.meta.url))) for (const m of read(p).matchAll(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), `${f}: ${m[0]}`) }
|
||
})
|
||
|
||
test('PultList: Aktionen über pultAction, "Als gesendet" sendet den bearbeiteten Text, Verwerfen fragt zuerst nach, Meldungen ok|err', () => {
|
||
const s = read('src/components/workers/PultList.jsx')
|
||
assert.match(s, /api\.pultAction\(row\.id, 'claim'\)/); assert.match(s, /api\.pultAction\(row\.id, 'sent', \{ text \}\)/)
|
||
assert.match(s, /api\.pultAction\(row\.id, 'discard', \{\}\)/); assert.match(s, /api\.pultAction\(row\.id, 'done'\)/)
|
||
// Kopieren & öffnen: Text ist der Inhalt des Textfelds (nicht row.copyText), das Öffnen geschieht ohne Referrer und ohne Rückbezug
|
||
assert.match(s, /copyAndOpen\(\{ text, url: row\.sourceHref, copy: copyTextChecked, open: openTab \}\)/)
|
||
assert.match(s, /window\.open\(url, '_blank', 'noopener,noreferrer'\)/)
|
||
assert.match(s, /Wirklich verwerfen\?/); assert.match(s, /CONFIRM_MS = 4000/)
|
||
for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Erledigt', 'Originalanfrage', 'Nichts wartet auf dich', 'Details']) assert.ok(s.includes(t), t)
|
||
assert.match(s, /className=\{`msg \$\{/); assert.match(s, /'ok'/); assert.match(s, /'err'/)
|
||
// der Mail-Link trägt den bearbeiteten Text, nicht den gespeicherten Entwurf
|
||
assert.match(s, /mailtoHref\(\{[\s\S]*?body: text \}\)/)
|
||
// der Text erscheint nur als Textknoten
|
||
assert.doesNotMatch(s, /innerHTML/)
|
||
})
|
||
|
||
// ---- usePultFeed läuft wirklich: Mini-Läufer für Hooks statt React, falsche document/window mit von Hand weitergestelltem Takt ----
|
||
|
||
const FAKE_REACT = `
|
||
let active = null
|
||
export function __mount(hook, getProps) {
|
||
const run = { hooks: [], index: 0, effects: [], dirty: false, rendering: false, unmounted: false, late: 0, result: undefined }
|
||
run.render = () => {
|
||
if (run.rendering) { run.dirty = true; return }
|
||
run.rendering = true
|
||
do {
|
||
run.dirty = false
|
||
run.index = 0
|
||
active = run
|
||
run.result = hook(getProps())
|
||
active = null
|
||
for (const effect of run.effects.splice(0)) effect()
|
||
} while (run.dirty)
|
||
run.rendering = false
|
||
}
|
||
run.render()
|
||
return {
|
||
result: () => run.result,
|
||
rerender: () => run.render(),
|
||
lateUpdates: () => run.late,
|
||
unmount: () => {
|
||
run.unmounted = true
|
||
for (const h of run.hooks) if (h && typeof h.cleanup === 'function') h.cleanup()
|
||
},
|
||
}
|
||
}
|
||
export function useState(initial) {
|
||
const run = active
|
||
const i = run.index++
|
||
if (!run.hooks[i]) run.hooks[i] = { value: typeof initial === 'function' ? initial() : initial }
|
||
const slot = run.hooks[i]
|
||
return [slot.value, (next) => {
|
||
if (run.unmounted) { run.late += 1; return }
|
||
const value = typeof next === 'function' ? next(slot.value) : next
|
||
if (Object.is(value, slot.value)) return
|
||
slot.value = value
|
||
run.render()
|
||
}]
|
||
}
|
||
export function useRef(initial) {
|
||
const run = active
|
||
const i = run.index++
|
||
if (!run.hooks[i]) run.hooks[i] = { current: initial }
|
||
return run.hooks[i]
|
||
}
|
||
export function useCallback(fn, deps) {
|
||
const run = active
|
||
const i = run.index++
|
||
const slot = run.hooks[i]
|
||
if (slot && deps.every((d, k) => Object.is(d, slot.deps[k]))) return slot.fn
|
||
run.hooks[i] = { fn, deps }
|
||
return fn
|
||
}
|
||
export function useEffect(fn, deps) {
|
||
const run = active
|
||
const i = run.index++
|
||
if (!run.hooks[i]) run.hooks[i] = { deps: undefined, cleanup: undefined }
|
||
const slot = run.hooks[i]
|
||
if (slot.deps && deps && deps.every((d, k) => Object.is(d, slot.deps[k]))) return
|
||
run.effects.push(() => {
|
||
if (typeof slot.cleanup === 'function') slot.cleanup()
|
||
slot.deps = deps
|
||
slot.cleanup = fn()
|
||
})
|
||
}
|
||
`
|
||
|
||
async function loadFeed() {
|
||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||
const fakeReact = {
|
||
name: 'fake-react',
|
||
setup(build) {
|
||
build.onResolve({ filter: /^react$/ }, () => ({ path: 'react', namespace: 'fake-react' }))
|
||
build.onLoad({ filter: /.*/, namespace: 'fake-react' }, () => ({ contents: FAKE_REACT, loader: 'js' }))
|
||
},
|
||
}
|
||
const out = await esbuild.build({
|
||
stdin: { contents: "export { usePultFeed } from './src/hooks/usePultFeed.js'\nexport { __mount } from 'react'", resolveDir: root, loader: 'js' },
|
||
bundle: true, format: 'esm', write: false, platform: 'neutral', plugins: [fakeReact],
|
||
})
|
||
return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
|
||
}
|
||
|
||
// document und window nur für die Dauer eines Tests: Takt (setInterval) und visibilitychange werden von Hand ausgelöst
|
||
function fakeBrowser() {
|
||
const listeners = new Map()
|
||
const timers = new Map()
|
||
let nextTimer = 1
|
||
const doc = {
|
||
visibilityState: 'visible',
|
||
addEventListener: (type, fn) => listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||
removeEventListener: (type, fn) => listeners.set(type, (listeners.get(type) ?? []).filter((f) => f !== fn)),
|
||
}
|
||
const win = {
|
||
setInterval: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id },
|
||
clearInterval: (id) => { timers.delete(id) },
|
||
}
|
||
const saved = { document: globalThis.document, window: globalThis.window }
|
||
globalThis.document = doc
|
||
globalThis.window = win
|
||
return {
|
||
doc,
|
||
timers,
|
||
listenerCount: (type) => (listeners.get(type) ?? []).length,
|
||
fire: (type) => { for (const fn of listeners.get(type) ?? []) fn() },
|
||
tick: () => { for (const t of [...timers.values()]) t.fn() },
|
||
restore: () => { globalThis.document = saved.document; globalThis.window = saved.window },
|
||
}
|
||
}
|
||
|
||
// api.pult-Attrappe: jeder Aufruf ist ein offenes Versprechen, das der Test selbst auflöst
|
||
function fakeApi() {
|
||
const calls = []
|
||
return {
|
||
calls,
|
||
pult: (status) => new Promise((resolve, reject) => { calls.push({ status, resolve, reject }) }),
|
||
}
|
||
}
|
||
const settle = () => new Promise((resolve) => setImmediate(resolve))
|
||
const itemsOf = (...ids) => ({ items: ids.map((id) => ({ $id: id })) })
|
||
|
||
test('usePultFeed: lädt beim Start, zeigt die Einträge, deutscher Fehler bleibt neben den letzten Einträgen stehen', async () => {
|
||
const { usePultFeed, __mount } = await loadFeed()
|
||
const b = fakeBrowser()
|
||
try {
|
||
const api = fakeApi()
|
||
const hook = __mount(() => usePultFeed({ api, statuses: 'offen,in_arbeit' }), () => ({}))
|
||
assert.deepEqual(api.calls.map((c) => c.status), ['offen,in_arbeit'])
|
||
assert.deepEqual([hook.result().loading, hook.result().items, hook.result().error], [true, [], null])
|
||
|
||
api.calls[0].resolve(itemsOf('a', 'b')); await settle()
|
||
assert.deepEqual([hook.result().loading, hook.result().items.map((i) => i.$id), hook.result().error], [false, ['a', 'b'], null])
|
||
|
||
// Takt: ein Abruf schlägt fehl, die Einträge bleiben, der Satz des Dienstes erscheint
|
||
assert.equal([...b.timers.values()][0].ms, 30000)
|
||
b.tick()
|
||
api.calls[1].reject(Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })); await settle()
|
||
assert.deepEqual([hook.result().items.map((i) => i.$id), hook.result().error], [['a', 'b'], 'Der Hustler-Dienst ist gerade nicht erreichbar.'])
|
||
// der nächste Erfolg räumt den Fehler weg
|
||
b.tick(); api.calls[2].resolve(itemsOf('a')); await settle()
|
||
assert.deepEqual([hook.result().items.map((i) => i.$id), hook.result().error], [['a'], null])
|
||
hook.unmount()
|
||
} finally { b.restore() }
|
||
})
|
||
|
||
test('usePultFeed: Takt und Neuladen nur bei sichtbarem Tab; zurück im Vordergrund wird sofort geladen', async () => {
|
||
const { usePultFeed, __mount } = await loadFeed()
|
||
const b = fakeBrowser()
|
||
try {
|
||
const api = fakeApi()
|
||
const hook = __mount(() => usePultFeed({ api, statuses: 'offen' }), () => ({}))
|
||
api.calls[0].resolve(itemsOf('a')); await settle()
|
||
b.doc.visibilityState = 'hidden'
|
||
b.tick(); b.fire('visibilitychange') // versteckt: weder Takt noch Wechsel-Ereignis rufen den Dienst
|
||
assert.equal(api.calls.length, 1)
|
||
b.doc.visibilityState = 'visible'; b.fire('visibilitychange')
|
||
assert.equal(api.calls.length, 2) // zurück im Vordergrund: sofort
|
||
b.tick()
|
||
assert.equal(api.calls.length, 3)
|
||
hook.unmount()
|
||
assert.equal(b.timers.size, 0); assert.equal(b.listenerCount('visibilitychange'), 0) // aufgeräumt
|
||
} finally { b.restore() }
|
||
})
|
||
|
||
test('usePultFeed: nur die letzte Anfrage zählt (späte alte Antwort wird verworfen); refresh() holt sofort neu', async () => {
|
||
const { usePultFeed, __mount } = await loadFeed()
|
||
const b = fakeBrowser()
|
||
try {
|
||
const api = fakeApi()
|
||
const hook = __mount(() => usePultFeed({ api, statuses: 'offen' }), () => ({}))
|
||
const first = api.calls[0]
|
||
const refreshed = hook.result().refresh() // zweite Anfrage, während die erste noch läuft
|
||
assert.equal(api.calls.length, 2)
|
||
api.calls[1].resolve(itemsOf('neu')); await refreshed; await settle()
|
||
first.resolve(itemsOf('alt')); await settle() // die ältere Antwort kommt zuletzt und darf nichts überschreiben
|
||
assert.deepEqual(hook.result().items.map((i) => i.$id), ['neu'])
|
||
// ein später Fehler der alten Anfrage zählt ebenfalls nicht
|
||
b.tick(); const late = api.calls[2]
|
||
b.tick(); api.calls[3].resolve(itemsOf('neuer')); await settle()
|
||
late.reject(new Error('zu spät')); await settle()
|
||
assert.deepEqual([hook.result().items.map((i) => i.$id), hook.result().error], [['neuer'], null])
|
||
hook.unmount()
|
||
} finally { b.restore() }
|
||
})
|
||
|
||
test('usePultFeed: Wechsel des Filters zeigt nie die Einträge des alten Filters und verwirft dessen späte Antwort', async () => {
|
||
const { usePultFeed, __mount } = await loadFeed()
|
||
const b = fakeBrowser()
|
||
try {
|
||
const api = fakeApi()
|
||
let statuses = 'offen,in_arbeit'
|
||
const hook = __mount(() => usePultFeed({ api, statuses }), () => ({}))
|
||
api.calls[0].resolve(itemsOf('a')); await settle()
|
||
statuses = 'veraltet'; hook.rerender()
|
||
assert.equal(api.calls.at(-1).status, 'veraltet')
|
||
assert.deepEqual([hook.result().loading, hook.result().items], [true, []])
|
||
statuses = 'gesendet,verworfen,erledigt'; hook.rerender() // noch einmal gewechselt, bevor 'veraltet' antwortet
|
||
api.calls.find((c) => c.status === 'veraltet').resolve(itemsOf('v')); await settle()
|
||
assert.deepEqual([hook.result().loading, hook.result().items], [true, []])
|
||
api.calls.at(-1).resolve(itemsOf('e')); await settle()
|
||
assert.deepEqual([hook.result().loading, hook.result().items.map((i) => i.$id)], [false, ['e']])
|
||
hook.unmount()
|
||
} finally { b.restore() }
|
||
})
|
||
|
||
test('usePultFeed: nach dem Abbau setzt eine späte Antwort nichts mehr', async () => {
|
||
const { usePultFeed, __mount } = await loadFeed()
|
||
const b = fakeBrowser()
|
||
try {
|
||
const api = fakeApi()
|
||
const hook = __mount(() => usePultFeed({ api, statuses: 'offen' }), () => ({}))
|
||
hook.unmount()
|
||
api.calls[0].resolve(itemsOf('a')); await settle()
|
||
assert.equal(hook.lateUpdates(), 0)
|
||
// auch ein später Fehler setzt nichts
|
||
const second = fakeApi()
|
||
const again = __mount(() => usePultFeed({ api: second, statuses: 'offen' }), () => ({}))
|
||
again.unmount()
|
||
second.calls[0].reject(new Error('zu spät')); await settle()
|
||
assert.equal(again.lateUpdates(), 0)
|
||
} finally { b.restore() }
|
||
})
|
||
|
||
// ---- PultList wird mit echtem React auf einen Text gerendert (renderToStaticMarkup); der Feed, die Mitarbeiterliste und die Zwischenablage sind Attrappen ----
|
||
|
||
const STUBS = {
|
||
usePultFeed: 'export function usePultFeed() { return globalThis.__pultFeed }',
|
||
useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }',
|
||
CopyableCredential: 'export async function copyTextChecked() {}',
|
||
}
|
||
|
||
async function loadList() {
|
||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||
const stubs = {
|
||
name: 'pult-stubs',
|
||
setup(build) {
|
||
build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-stub' }))
|
||
build.onLoad({ filter: /.*/, namespace: 'pult-stub' }, (a) => ({ contents: STUBS[a.path], loader: 'js' }))
|
||
},
|
||
}
|
||
const out = await esbuild.build({
|
||
stdin: {
|
||
contents: "import { createElement } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport PultList from './src/components/workers/PultList.jsx'\nexport const render = (props) => renderToStaticMarkup(createElement(PultList, props))",
|
||
resolveDir: root, loader: 'js',
|
||
},
|
||
bundle: true, format: 'esm', platform: 'node', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [stubs],
|
||
// react-dom/server ist CommonJS und holt sich Node-Module über require: im Data-URL-Modul gibt es keins
|
||
banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(process.cwd() + '/');" },
|
||
})
|
||
return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
|
||
}
|
||
|
||
test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text, sichere Links, Mail-Link mit genau einer Adresse', async () => {
|
||
const { render } = await loadList()
|
||
const now = Date.now()
|
||
const iso = (ms) => new Date(ms).toISOString()
|
||
const me = { id: 'u1', name: 'Justin' }
|
||
const show = (items, extra = {}) => {
|
||
globalThis.__pultFeed = { items, loading: false, error: null, refresh: async () => {}, ...extra }
|
||
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
|
||
return render({ api: {}, me })
|
||
}
|
||
const base = { $id: 'a', kind: 'plattform_antwort', status: 'offen', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 3 * 3600e3), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein', city: 'Namborn', postalCode: '66640', score: 72 } }
|
||
try {
|
||
// offene Plattform-Antwort: alle vier Aktionen, Link mit rel, Kurzinfo, Details; das Textfeld trägt den Entwurf
|
||
let html = show([{ ...base, originalText: 'Original <img src=x onerror=alert(1)>', draft: 'Hallo <script>alert(1)</script>', title: 'T <b>x</b>' }])
|
||
for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Details', '66640 Namborn · Score 72', 'vor 3 Std.']) assert.ok(html.includes(t), t)
|
||
const erledigt = '<button class="btn" type="button">Erledigt</button>' // der Filter „Erledigt“ steht ebenfalls auf der Seite: nur den Knopf der Zeile meinen
|
||
assert.ok(!html.includes(erledigt)); assert.match(html, /<a class="link" href="https:\/\/www\.kleinanzeigen\.de\/s-anzeige\/x\/1-2-3" target="_blank" rel="noopener noreferrer">/)
|
||
assert.match(html, /<textarea class="textarea wk-pult-draft"[^>]*>Hallo <script>alert\(1\)<\/script><\/textarea>/)
|
||
// Fremdtext erscheint nie als Markup
|
||
assert.ok(!html.includes('<script>') && !html.includes('<img') && !html.includes('<b>x</b>')); assert.ok(html.includes('Original <img src=x onerror=alert(1)>'))
|
||
|
||
// Kollege hat übernommen: Name statt „Ich kümmere mich“, der Rest bleibt bedienbar (der Dienst entscheidet mit 409); javascript:-Adresse gibt keinen Link, nur „Kopieren“
|
||
html = show([{ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2', sourceUrl: 'javascript:alert(1)' }])
|
||
assert.ok(html.includes('übernommen von Kenso')); assert.ok(!html.includes('Ich kümmere mich')); assert.ok(html.includes('Als gesendet markieren'))
|
||
assert.ok(!html.includes('href="javascript:')); assert.ok(!html.includes('Anzeige öffnen')); assert.match(html, />Kopieren<\/button>/)
|
||
|
||
// veraltet und „läuft bald ab“
|
||
html = show([{ ...base, status: 'veraltet' }, { ...base, $id: 'b', staleAt: iso(now + 5 * 3600e3) }])
|
||
assert.ok(html.includes('<span class="badge orange">veraltet</span>')); assert.ok(html.includes('läuft in 5 Std. ab'))
|
||
|
||
// nicht sendbare Art: Erledigt statt Senden, aber Verwerfen bleibt; abgeschlossene Einträge haben keine Aktion
|
||
html = show([{ ...base, kind: 'unsicher', draft: '' }])
|
||
assert.ok(html.includes(erledigt)); assert.ok(html.includes('Verwerfen')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('<textarea'))
|
||
html = show([{ ...base, status: 'gesendet' }])
|
||
assert.ok(!html.includes('row-actions')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen')); assert.ok(html.includes('<summary>Entwurf</summary>'))
|
||
|
||
// Mail-Antwort: Link mit genau einer Adresse und kodiertem Text; eine eingeschleuste zweite Adresse ergibt keinen Link
|
||
const mail = { ...base, kind: 'mail_antwort', draft: 'Guten Tag\nBis bald', sourceUrl: '' }
|
||
html = show([{ ...mail, prospect: { $id: 'p2', name: 'Firma', email: 'x@firma.example' } }])
|
||
assert.match(html, /<a class="btn btn-primary" href="mailto:x@firma\.example\?subject=Re%3A%20Firma&body=Guten%20Tag%0D%0ABis%20bald">E-Mail schreiben<\/a>/)
|
||
assert.ok(!html.includes('Kopieren'))
|
||
html = show([{ ...mail, prospect: { $id: 'p2', name: 'Firma', email: 'x@firma.example?bcc=z@w.de' } }])
|
||
assert.ok(!html.includes('mailto:')); assert.ok(html.includes('Keine gültige E-Mail-Adresse hinterlegt.'))
|
||
|
||
// Zustände: laden, leer je Filter, Fehler ohne und mit Einträgen
|
||
assert.ok(show([], { loading: true }).includes('Wird geladen'))
|
||
assert.ok(show([]).includes('Nichts wartet auf dich'))
|
||
html = show([], { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' })
|
||
assert.ok(html.includes('Das Pult ist gerade nicht erreichbar')); assert.ok(html.includes('Der Hustler-Dienst ist gerade nicht erreichbar.')); assert.ok(html.includes('Neu laden'))
|
||
html = show([base], { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' })
|
||
assert.ok(html.includes('class="msg warn"')); assert.ok(html.includes('Suche Webdesigner'))
|
||
// die Filter kommen aus PULT_FILTERS, „Wartet“ ist vorgewählt
|
||
html = show([])
|
||
assert.match(html, /<button type="button" class="on" aria-pressed="true">Wartet<\/button><button type="button" aria-pressed="false">Veraltet<\/button><button type="button" aria-pressed="false">Erledigt<\/button>/)
|
||
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
|
||
})
|
||
|
||
// Endprüfung P3 (W5): Reiter „Erledigt“ – was wurde aus dem Eintrag, wer, wann
|
||
test('PultList (gerendert): abgeschlossene Einträge zeigen Ergebnis, Person und Zeit der Erledigung statt des Alters ab Anlage (W5)', async () => {
|
||
const { render } = await loadList()
|
||
const now = Date.now()
|
||
const iso = (ms) => new Date(ms).toISOString()
|
||
const show = (items) => {
|
||
globalThis.__pultFeed = { items, loading: false, error: null, refresh: async () => {} }
|
||
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Anna' }]
|
||
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
|
||
}
|
||
const base = { kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 4 * 24 * 3600e3), doneBy: 'mensch:u2', doneAt: iso(now - 2 * 3600e3) }
|
||
try {
|
||
const html = show([
|
||
{ ...base, $id: 'a', status: 'gesendet' },
|
||
{ ...base, $id: 'b', status: 'verworfen', title: 'Zweite' },
|
||
{ ...base, $id: 'c', status: 'erledigt', kind: 'unsicher', title: 'Dritte', doneBy: 'max' },
|
||
])
|
||
assert.ok(html.includes('<span class="badge green">Gesendet</span>'))
|
||
assert.ok(html.includes('<span class="badge">Verworfen</span>')); assert.ok(html.includes('<span class="badge">Erledigt</span>'))
|
||
assert.ok(html.includes('<span>Gesendet von Anna · vor 2 Std.</span>')); assert.ok(html.includes('<span>Verworfen von Anna · vor 2 Std.</span>'))
|
||
assert.ok(html.includes('<span>Erledigt von Max (automatisch) · vor 2 Std.</span>'))
|
||
assert.ok(!html.includes('vor 4 Tagen')) // das Alter ab Anlage steht bei Abgeschlossenem nicht mehr da
|
||
assert.ok(!html.includes('Kollege'))
|
||
// offene Zeile: wie bisher Alter ab Anlage, kein Ergebnis-Badge
|
||
const open = show([{ ...base, $id: 'd', status: 'offen', doneBy: undefined, doneAt: undefined }])
|
||
assert.ok(open.includes('<span>vor 4 Tagen</span>')); assert.ok(!open.includes('Gesendet')); assert.ok(!open.includes('badge green'))
|
||
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
|
||
})
|