copyTextChecked erfüllt nur, wenn der Text wirklich in der Zwischenablage liegt (Clipboard-API, sonst Notweg mit document.execCommand, dessen Rückgabewert jetzt geprüft wird; das Textfeld wird immer entfernt). copyText bleibt für die Kundenansicht die verschluckende Hülle. Das Pult nimmt die strenge Fassung, sodass die Zeile statt "Kopiert und geöffnet." den Hinweis zum Kopieren von Hand zeigt. Die Reihenfolge Kopieren vor Öffnen bleibt (Nutzer-Geste). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
360 lines
20 KiB
JavaScript
360 lines
20 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 }
|
|
})
|