Bei einer http- oder fremden Adresse zeigte die Zeile „Keine gültige Vorschau-Adresse hinterlegt.“ statt des Links, ließ aber das Freigeben zu: der Mensch gab eine Vorschau frei, die er nicht ansehen kann. Der Knopf ist jetzt gesperrt (auch approve() prüft previewHref), der Hinweis nennt Grund und Ausweg („Entwurf prüfen oder verwerfen“), Verwerfen bleibt frei. Das Zeilenmodell (canApprovePreview) bleibt unverändert. Das Sperren schließt fail-closed: weichen baseUrl des Dienstes und PREVIEW_HOST in WOMS einmal ab, ist Freigeben gesperrt, bis WOMS angepasst ist. Prüfstand prüft den gesperrten Knopf. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
956 lines
62 KiB
JavaScript
956 lines
62 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. usePultFeed und PultList laufen wirklich: im Mini-Läufer anstelle von React (siehe unten), PultList zusätzlich als Text gerendert.
|
||
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/)
|
||
})
|
||
|
||
// ---- gemeinsame Bausteine der Läufer: EIN Mini-Läufer anstelle von React (Hooks, Zustand, Effekte, Baum aus { tag, props, children }) ----
|
||
// ---- und EINE Tabelle mit Attrappen für alles, was PultList von außen holt. usePultFeed und PultList laufen im selben Läufer. ----
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||
const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
|
||
|
||
const FAKE_REACT = `
|
||
export const Fragment = Symbol('fragment')
|
||
export function jsx(type, props, key) { return { type, props: props ?? {}, key } }
|
||
export const jsxs = jsx
|
||
|
||
let current = null // Instanz des Bauteils, dessen Render gerade läuft
|
||
let hookIndex = 0
|
||
|
||
const flat = (children, out = []) => {
|
||
if (Array.isArray(children)) children.forEach((c) => flat(c, out))
|
||
else if (children !== null && children !== undefined && children !== false && children !== true) out.push(children)
|
||
return out
|
||
}
|
||
|
||
// Hängt ein Element (Bauteile mit Hooks, darunter Tags) ein und rendert es; jedes Bauteil behält seinen Zustand unter seinem Pfad im Baum.
|
||
export function __mount(element) {
|
||
const owner = { render, effects: [], late: 0 } // late: Zustandsänderungen, die nach dem Abbau noch ankamen
|
||
const instances = new Map()
|
||
let tree = null
|
||
let rendering = false
|
||
let dirty = false
|
||
|
||
const dispose = (inst) => {
|
||
inst.alive = false
|
||
for (const h of inst.hooks) if (h && typeof h.cleanup === 'function') h.cleanup()
|
||
}
|
||
|
||
const expand = (node, path, visited) => {
|
||
if (typeof node === 'string' || typeof node === 'number') return String(node)
|
||
const { type, props } = node
|
||
if (typeof type === 'function') {
|
||
visited.add(path)
|
||
let inst = instances.get(path)
|
||
if (!inst) { inst = { hooks: [], alive: true, owner }; instances.set(path, inst) }
|
||
current = inst
|
||
hookIndex = 0
|
||
const out = type(props)
|
||
current = null
|
||
return out === null || out === undefined || out === false ? null : expand(out, path + '/c', visited)
|
||
}
|
||
const children = flat(props.children).map((c, i) => expand(c, path + '/' + (c && c.key != null ? 'k:' + c.key : i), visited))
|
||
return { tag: type === Fragment ? 'fragment' : type, props, children }
|
||
}
|
||
|
||
function render() {
|
||
if (rendering) { dirty = true; return }
|
||
rendering = true
|
||
do {
|
||
dirty = false
|
||
const visited = new Set()
|
||
tree = expand(element, 'r', visited)
|
||
for (const [path, inst] of [...instances]) {
|
||
if (visited.has(path)) continue
|
||
dispose(inst)
|
||
instances.delete(path)
|
||
}
|
||
for (const run of owner.effects.splice(0)) run()
|
||
} while (dirty)
|
||
rendering = false
|
||
}
|
||
render()
|
||
return {
|
||
tree: () => tree,
|
||
rerender: render,
|
||
lateUpdates: () => owner.late,
|
||
unmount: () => { for (const inst of instances.values()) dispose(inst); instances.clear() },
|
||
}
|
||
}
|
||
|
||
export function useState(initial) {
|
||
const inst = current
|
||
const i = hookIndex++
|
||
if (!inst.hooks[i]) inst.hooks[i] = { value: typeof initial === 'function' ? initial() : initial }
|
||
const slot = inst.hooks[i]
|
||
return [slot.value, (next) => {
|
||
if (!inst.alive) { inst.owner.late += 1; return }
|
||
const value = typeof next === 'function' ? next(slot.value) : next
|
||
if (Object.is(value, slot.value)) return
|
||
slot.value = value
|
||
inst.owner.render()
|
||
}]
|
||
}
|
||
export function useRef(initial) {
|
||
const inst = current
|
||
const i = hookIndex++
|
||
if (!inst.hooks[i]) inst.hooks[i] = { current: initial }
|
||
return inst.hooks[i]
|
||
}
|
||
export function useCallback(fn, deps) {
|
||
const inst = current
|
||
const i = hookIndex++
|
||
const slot = inst.hooks[i]
|
||
if (slot && deps.every((d, k) => Object.is(d, slot.deps[k]))) return slot.fn
|
||
inst.hooks[i] = { fn, deps }
|
||
return fn
|
||
}
|
||
export function useEffect(fn, deps) {
|
||
const inst = current
|
||
const i = hookIndex++
|
||
if (!inst.hooks[i]) inst.hooks[i] = { deps: undefined, cleanup: undefined, ran: false }
|
||
const slot = inst.hooks[i]
|
||
if (slot.ran && deps && slot.deps && deps.every((d, k) => Object.is(d, slot.deps[k]))) return
|
||
inst.owner.effects.push(() => {
|
||
if (typeof slot.cleanup === 'function') slot.cleanup()
|
||
slot.deps = deps
|
||
slot.ran = true
|
||
slot.cleanup = fn()
|
||
})
|
||
}
|
||
`
|
||
const fakeReact = {
|
||
name: 'fake-react',
|
||
setup(build) {
|
||
build.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: 'react', namespace: 'fake-react' }))
|
||
build.onLoad({ filter: /.*/, namespace: 'fake-react' }, () => ({ contents: FAKE_REACT, loader: 'js' }))
|
||
},
|
||
}
|
||
|
||
// Was PultList von außen holt: der Feed (je Filter eine Antwort aus globalThis.__pultFeedFor), die Mitarbeiterliste, die Zwischenablage, das Panel (Dialog)
|
||
const STUBS = {
|
||
usePultFeed: 'export function usePultFeed({ statuses } = {}) { return globalThis.__pultFeedFor(statuses) }',
|
||
useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }',
|
||
CopyableCredential: 'export async function copyTextChecked() {}',
|
||
ProspectPanel: 'export default function ProspectPanel() { return null }', // das Panel gehört nicht zu diesen Tests (workersProspect.test.mjs)
|
||
}
|
||
const stubs = {
|
||
name: 'pult-stubs',
|
||
setup(build) {
|
||
build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$|\/ProspectPanel$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-stub' }))
|
||
build.onLoad({ filter: /.*/, namespace: 'pult-stub' }, (a) => ({ contents: STUBS[a.path], loader: 'js' }))
|
||
},
|
||
}
|
||
|
||
// ---- usePultFeed läuft wirklich: im Mini-Läufer, falsche document/window mit von Hand weitergestelltem Takt ----
|
||
|
||
async function loadFeed() {
|
||
const { usePultFeed, __mount, jsx } = await importBundle(await esbuild.build({
|
||
stdin: { contents: "export { usePultFeed } from './src/hooks/usePultFeed.js'\nexport { __mount, jsx } from 'react'", resolveDir: ROOT, loader: 'js' },
|
||
bundle: true, format: 'esm', write: false, platform: 'neutral', plugins: [fakeReact],
|
||
}))
|
||
// Der Hook läuft in einem Bauteil, das nichts zeichnet und sein Ergebnis festhält
|
||
const mountHook = (hook) => {
|
||
let result
|
||
const mounted = __mount(jsx(() => { result = hook(); return null }, {}))
|
||
return { result: () => result, rerender: mounted.rerender, lateUpdates: mounted.lateUpdates, unmount: mounted.unmount }
|
||
}
|
||
return { usePultFeed, mountHook }
|
||
}
|
||
|
||
// 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, mountHook } = await loadFeed()
|
||
const b = fakeBrowser()
|
||
try {
|
||
const api = fakeApi()
|
||
const hook = mountHook(() => 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, mountHook } = await loadFeed()
|
||
const b = fakeBrowser()
|
||
try {
|
||
const api = fakeApi()
|
||
const hook = mountHook(() => 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, mountHook } = await loadFeed()
|
||
const b = fakeBrowser()
|
||
try {
|
||
const api = fakeApi()
|
||
const hook = mountHook(() => 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, mountHook } = await loadFeed()
|
||
const b = fakeBrowser()
|
||
try {
|
||
const api = fakeApi()
|
||
let statuses = 'offen,in_arbeit'
|
||
const hook = mountHook(() => 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, mountHook } = await loadFeed()
|
||
const b = fakeBrowser()
|
||
try {
|
||
const api = fakeApi()
|
||
const hook = mountHook(() => 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 = mountHook(() => 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); Attrappen aus STUBS ----
|
||
|
||
async function loadList() {
|
||
return importBundle(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() + '/');" },
|
||
}))
|
||
}
|
||
|
||
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.__pultFeedFor = () => ({ 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" aria-label="Anzeige öffnen: T <b>x<\/b>">/)
|
||
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'))
|
||
// gesendet: nichts mehr zu senden oder zu verwerfen; ohne Interessent bleibt die Zeile ohne Aktionen, mit Interessent nur „Antwort des Kunden einfügen“ (P4-T32, D13)
|
||
html = show([{ ...base, status: 'gesendet', prospect: null }])
|
||
assert.ok(!html.includes('row-actions')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen')); assert.ok(html.includes('<summary>Entwurf</summary>'))
|
||
html = show([{ ...base, status: 'gesendet' }])
|
||
assert.ok(html.includes('row-actions')); assert.ok(html.includes('Antwort des Kunden einfügen')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen'))
|
||
assert.ok(!html.includes('Kopieren')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('Ich kümmere mich'))
|
||
|
||
// 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.__pultFeedFor; 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.__pultFeedFor = () => ({ 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.__pultFeedFor; delete globalThis.__pultEmployees }
|
||
})
|
||
|
||
// Endprüfung P3 (W8): „Details“, „Anzeige öffnen“, Entwurfsfeld und Aktionsgruppe tragen den Titel der Zeile im zugänglichen Namen
|
||
test('PultList (gerendert): wiederkehrende Bedienelemente haben je Zeile einen eigenen zugänglichen Namen, sichtbares Wort vorn (W8)', async () => {
|
||
const { render } = await loadList()
|
||
const show = (items) => {
|
||
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {} })
|
||
globalThis.__pultEmployees = []
|
||
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
|
||
}
|
||
const base = { kind: 'plattform_antwort', status: 'offen', draft: 'Hallo', createdAt: new Date().toISOString(), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
|
||
try {
|
||
const html = show([{ ...base, $id: 'a', title: 'Suche Webdesigner' }, { ...base, $id: 'b', title: 'Homepage für Handwerker <b>x</b> & "Co"' }])
|
||
for (const name of ['Details zu Suche Webdesigner', 'Anzeige öffnen: Suche Webdesigner', 'Entwurf der Antwort: Suche Webdesigner', 'Aktionen: Suche Webdesigner']) {
|
||
assert.ok(html.includes(`aria-label="${name}"`), name)
|
||
}
|
||
// zwei Zeilen ⇒ zwei verschiedene Namen; Fremdtext im Namen ist als Attribut kodiert
|
||
const names = [...html.matchAll(/aria-label="(Details zu [^"]*)"/g)].map((m) => m[1])
|
||
assert.equal(names.length, 2); assert.equal(new Set(names).size, 2)
|
||
assert.ok(html.includes('aria-label="Details zu Homepage für Handwerker <b>x</b> & "Co""'))
|
||
assert.ok(!html.includes('<b>x</b>'))
|
||
// das sichtbare Wort bleibt (WCAG 2.5.3) und steht im Namen vorn
|
||
assert.match(html, /aria-label="Details zu Suche Webdesigner">Details<\/button>/)
|
||
assert.match(html, /aria-label="Anzeige öffnen: Suche Webdesigner">Anzeige öffnen<\/a>/)
|
||
// leerer oder fehlender Titel: schlichter Name, nie „zu undefined“
|
||
for (const title of ['', ' ', undefined]) {
|
||
const bare = show([{ ...base, $id: 'c', title }])
|
||
assert.ok(!bare.includes('undefined'), String(title))
|
||
assert.ok(!bare.includes('Details zu'), String(title)); assert.ok(!bare.includes('Anzeige öffnen:'), String(title))
|
||
assert.ok(bare.includes('aria-label="Entwurf der Antwort"'), String(title)); assert.ok(bare.includes('aria-label="Aktionen"'), String(title))
|
||
assert.match(bare, />Details<\/button>/)
|
||
}
|
||
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
|
||
})
|
||
|
||
// ---- P4-T32: als Mensch antworten, Vorschau freigeben, Vorschau bauen, Antwort des Kunden einfügen ----
|
||
|
||
test('PultList (P4-T32): Quelltext – Aktionen über pultAction, Prüfung mit validateReplyText, Freigabe mit Rückfrage, sichere Vorschau-Adresse', () => {
|
||
const s = read('src/components/workers/PultList.jsx')
|
||
for (const t of [
|
||
"'reply'", "'approve-preview'", "'paste'", "'start-build'", 'validateReplyText', 'canReply', 'canApprovePreview', 'canStartBuild', 'canPaste', 'previewHref',
|
||
'actionErrorText', 'pasteDoneText', 'rel="noopener noreferrer"', 'target="_blank"',
|
||
'Als Mensch antworten', 'Jetzt senden', 'Vorschau ansehen', 'Freigeben & senden', 'Wirklich freigeben?', 'Vorschau bauen', 'Wirklich bauen?',
|
||
'Antwort des Kunden einfügen', 'Einfügen', 'wk-pult-reply', 'wk-pult-paste',
|
||
'Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.',
|
||
]) assert.ok(s.includes(t), t)
|
||
assert.doesNotMatch(s, /dangerouslySetInnerHTML/); assert.doesNotMatch(s, /innerHTML/)
|
||
// jede der vier neuen Aktionen geht über den Client: Antwort mit dem geprüften Text, Freigabe und Bau ohne Body, Einfügen mit dem geprüften Text
|
||
assert.match(s, /api\.pultAction\(row\.id, 'reply', \{ text: [\w.]+ \}\)/)
|
||
assert.match(s, /api\.pultAction\(row\.id, 'approve-preview'\)/)
|
||
assert.match(s, /api\.pultAction\(row\.id, 'start-build'\)/)
|
||
assert.match(s, /api\.pultAction\(row\.id, 'paste', \{ text: [\w.]+ \}\)/)
|
||
// beide Texte werden vor dem Senden mit validateReplyText geprüft, ein Fehler erscheint als Meldung in der Zeile
|
||
assert.equal((s.match(/validateReplyText\(/g) ?? []).length, 2)
|
||
// die Vorschau-Adresse kommt aus rowModel (previewHref) und öffnet ohne Rückbezug in einem neuen Tab
|
||
assert.match(s, /href=\{row\.previewHref\}[^>]*target="_blank"[^>]*rel="noopener noreferrer"/)
|
||
// die Rückfrage vor dem Freigeben nutzt dieselbe Frist wie das Verwerfen (4 s)
|
||
assert.match(s, /CONFIRM_MS = 4000/)
|
||
// „Als Mensch antworten“ ist mit dem Entwurf vorbelegt, „Einfügen“ nie: die Felder sind getrennt und das Einfüge-Feld beginnt leer
|
||
assert.match(s, /fields\.reply \?\? text/); assert.match(s, /fields\.paste \?\? ''/)
|
||
assert.doesNotMatch(s, /fields\.paste \?\? (text|row\.copyText)/); assert.doesNotMatch(s, /fields\.reply \?\? (fields\.paste|'')/)
|
||
// das Fehlerschema der Zeile bleibt: Meldung mit msg ok|err, Fehlertext über actionErrorText (D14: Zeitüberschreitung ⇒ „Kann gesendet sein“)
|
||
assert.match(s, /actionErrorText\(err, action\)/)
|
||
})
|
||
|
||
test('PultList (P4-T32): Aktionsfeld erscheint auch bei gesendet, wenn eine der vier neuen Aktionen möglich ist (D13)', () => {
|
||
const s = read('src/components/workers/PultList.jsx')
|
||
assert.match(s, /row\.canSend \|\| row\.canClaim \|\| row\.canDone \|\| row\.canDiscard \|\| row\.canReply \|\| row\.canApprovePreview \|\| row\.canStartBuild \|\| row\.canPaste/)
|
||
})
|
||
|
||
test('CSS (P4-T32): .wk-pult-reply und .wk-pult-paste mit Haarlinie, volle Breite, auf dem Handy untereinander', () => {
|
||
const css = read('src/styles/workers.css')
|
||
assert.match(css, /\.wk-pult-reply/); assert.match(css, /\.wk-pult-paste/)
|
||
const rule = css.match(/\.wk-pult-row \.wk-pult-reply[^{]*\{[^}]*\}/)?.[0] ?? ''
|
||
assert.match(rule, /\.wk-pult-paste/); assert.match(rule, /border-top: 1px solid var\(--line\)/); assert.match(rule, /width: 100%/)
|
||
// schmale Bildschirme: die Fußzeile des Feldes (Zähler, Knopf) steht untereinander
|
||
const phone = css.slice(css.indexOf('@media (max-width: 860px)'))
|
||
assert.match(phone, /\.wk-pult-panel-foot/); assert.match(phone, /flex-direction: column/)
|
||
})
|
||
|
||
test('PultList (gerendert, P4-T32): Aktionen je Art – Antworten, Freigeben, Bauen, Einfügen; Felder bleiben zu, Fremdtext nur als Text', async () => {
|
||
const { render } = await loadList()
|
||
const now = Date.now()
|
||
const iso = (ms) => new Date(ms).toISOString()
|
||
const show = (items) => {
|
||
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {} })
|
||
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
|
||
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
|
||
}
|
||
const base = { $id: 'a', kind: 'unsicher', status: 'offen', title: 'Unklare Antwort', draft: '', createdAt: iso(now - 3600e3), prospectId: 'p1', prospect: { $id: 'p1', name: 'Verein', email: 'x@verein.example' } }
|
||
const buttonOf = (html, label) => new RegExp(`<button class="(?:btn|link-danger)[^"]*"[^>]*>${label}</button>`).test(html) // nur Knöpfe der Zeile (die Reiter „Wartet“ … „Erledigt“ haben keine Klasse btn)
|
||
try {
|
||
// unsicher mit Interessent und Adresse: antworten, bauen, erledigen, verwerfen; die Felder sind zu
|
||
let html = show([base])
|
||
for (const t of ['Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich']) assert.ok(buttonOf(html, t), t)
|
||
for (const t of ['Antwort des Kunden einfügen', 'Freigeben', 'Jetzt senden', 'wk-pult-reply', 'wk-pult-paste', '<textarea']) assert.ok(!html.includes(t), t)
|
||
assert.match(html, /<button[^>]*aria-expanded="false"[^>]*>Als Mensch antworten<\/button>/)
|
||
|
||
// „Mail ohne Faden“: weder Interessent noch Adresse ⇒ weder Antworten noch Bauen
|
||
html = show([{ ...base, prospectId: undefined, prospect: null }])
|
||
assert.ok(!html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen')); assert.ok(buttonOf(html, 'Erledigt'))
|
||
// Interessent ohne Adresse (Plattform): kein Mail-Kanal ⇒ nicht antworten, aber bauen
|
||
html = show([{ ...base, prospect: { $id: 'p1', name: 'Verein' } }])
|
||
assert.ok(!html.includes('Als Mensch antworten')); assert.ok(html.includes('Vorschau bauen'))
|
||
// Endprüfung P4 (W2): ein toter Interessent (gesperrt, verloren, Deal) bekommt keinen Knopf zum Bauen, ein lebender behält ihn
|
||
for (const status of ['gesperrt', 'verloren', 'deal']) {
|
||
html = show([{ ...base, prospect: { ...base.prospect, status } }])
|
||
assert.ok(!html.includes('Vorschau bauen'), status); assert.ok(buttonOf(html, 'Als Mensch antworten'), status); assert.ok(buttonOf(html, 'Erledigt'), status)
|
||
}
|
||
assert.ok(buttonOf(show([{ ...base, prospect: { ...base.prospect, status: 'gespraech' } }]), 'Vorschau bauen'))
|
||
// von einem Kollegen übernommen: weder antworten noch bauen, der Rest bleibt wie bisher
|
||
html = show([{ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2' }])
|
||
assert.ok(html.includes('übernommen von Kenso')); assert.ok(!html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen')); assert.ok(buttonOf(html, 'Erledigt'))
|
||
// von Max übernommen: „Max (automatisch)“, nie „Kollege“
|
||
html = show([{ ...base, status: 'in_arbeit', claimedBy: 'max' }])
|
||
assert.ok(html.includes('übernommen von Max (automatisch)')); assert.ok(!html.includes('Kollege'))
|
||
// mail_antwort: Mail-Link und Antwort-Funktion nebeneinander
|
||
html = show([{ ...base, kind: 'mail_antwort', draft: 'Guten Tag' }])
|
||
assert.ok(html.includes('E-Mail schreiben')); assert.ok(html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen'))
|
||
// preisfrage, mensch_gewuenscht: antworten
|
||
for (const kind of ['preisfrage', 'mensch_gewuenscht']) assert.ok(show([{ ...base, kind }]).includes('Als Mensch antworten'), kind)
|
||
assert.ok(!show([{ ...base, kind: 'beschwerde' }]).includes('Als Mensch antworten'))
|
||
// abgeschlossen: keine Antwort-Funktion mehr
|
||
for (const status of ['gesendet', 'erledigt', 'verworfen']) {
|
||
html = show([{ ...base, status }])
|
||
assert.ok(!html.includes('Als Mensch antworten'), status); assert.ok(!html.includes('Vorschau bauen'), status); assert.ok(!html.includes('row-actions'), status)
|
||
}
|
||
|
||
// vorschau_freigabe: Vorschau ansehen (sicherer Link) + Freigeben & senden + Hinweis zu Verwerfen; kein Erledigt, keine Anzeige-Adresse, keine Antwort-Funktion
|
||
const freigabe = { ...base, kind: 'vorschau_freigabe', title: 'Vorschau <b>prüfen</b>', draft: 'Hallo, hier ist Ihre Vorschau.', sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }
|
||
html = show([freigabe])
|
||
// der Name trägt den Titel der Zeile (W8), Fremdtext darin ist kodiert
|
||
assert.match(html, /<a class="btn" href="https:\/\/entwurf\.project\.webklar\.com\/AAA\/" target="_blank" rel="noopener noreferrer" aria-label="Vorschau ansehen: Vorschau <b>prüfen<\/b>">Vorschau ansehen<\/a>/)
|
||
assert.ok(html.includes('Freigeben & senden')); assert.ok(buttonOf(html, 'Verwerfen')); assert.ok(html.includes('sendet die Vorschau nicht'))
|
||
for (const t of ['Anzeige öffnen', 'Als Mensch antworten', 'Vorschau bauen', 'Wirklich freigeben?']) assert.ok(!html.includes(t), t)
|
||
assert.ok(!buttonOf(html, 'Erledigt')); assert.ok(!html.includes('<b>prüfen</b>'))
|
||
// gültige Adresse: der Knopf zum Freigeben ist bedienbar
|
||
assert.doesNotMatch(html, /<button[^>]*disabled[^>]*>Freigeben & senden<\/button>/)
|
||
// fremde, unsichere oder fehlende Adresse: kein Link, Hinweis statt Link, und der Knopf zum Freigeben ist gesperrt (Endprüfung W5: der Mensch soll keine
|
||
// Vorschau freigeben, die er nicht ansehen kann); der Hinweis nennt den Grund und den Ausweg
|
||
for (const sourceUrl of ['https://fremd.example/AAA/', 'javascript:alert(1)', 'http://entwurf.project.webklar.com/AAA/', '']) {
|
||
html = show([{ ...freigabe, sourceUrl }])
|
||
assert.ok(!html.includes('Vorschau ansehen</a>'), sourceUrl); assert.ok(!html.includes('href="javascript:'), sourceUrl); assert.ok(!html.includes('fremd.example'), sourceUrl)
|
||
assert.ok(html.includes('Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich – bitte den Entwurf prüfen oder verwerfen.'), sourceUrl)
|
||
assert.match(html, /<button[^>]*disabled[^>]*>Freigeben & senden<\/button>/, sourceUrl)
|
||
assert.ok(buttonOf(html, 'Verwerfen')); assert.doesNotMatch(html, /<button[^>]*disabled[^>]*>Verwerfen<\/button>/, sourceUrl) // Verwerfen bleibt frei
|
||
}
|
||
// freigegeben (erledigt) oder von einem Kollegen übernommen: nichts mehr freizugeben
|
||
assert.ok(!show([{ ...freigabe, status: 'erledigt' }]).includes('Freigeben'))
|
||
html = show([{ ...freigabe, status: 'in_arbeit', claimedBy: 'mensch:u2' }])
|
||
assert.ok(!html.includes('Freigeben')); assert.ok(buttonOf(html, 'Verwerfen'))
|
||
|
||
// plattform_antwort: „Antwort des Kunden einfügen“ in jedem Status außer verworfen, auch bei einem Kollegen; das Feld ist zu
|
||
const plattform = { ...base, kind: 'plattform_antwort', draft: 'Hallo', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
|
||
for (const status of ['offen', 'veraltet', 'gesendet', 'erledigt']) {
|
||
html = show([{ ...plattform, status }])
|
||
assert.ok(buttonOf(html, 'Antwort des Kunden einfügen'), status); assert.ok(!html.includes('wk-pult-paste'), status); assert.ok(!html.includes('Als Mensch antworten'), status)
|
||
}
|
||
html = show([{ ...plattform, status: 'in_arbeit', claimedBy: 'mensch:u2' }])
|
||
assert.ok(buttonOf(html, 'Antwort des Kunden einfügen')); assert.ok(html.includes('übernommen von Kenso'))
|
||
assert.ok(!show([{ ...plattform, status: 'verworfen' }]).includes('Antwort des Kunden einfügen'))
|
||
assert.ok(!show([{ ...plattform, prospect: null }]).includes('Antwort des Kunden einfügen'))
|
||
assert.match(show([{ ...plattform, status: 'in_arbeit', claimedBy: 'mensch:u2' }]), /aria-expanded="false"[^>]*>Antwort des Kunden einfügen<\/button>/)
|
||
// in den anderen Arten gibt es kein Einfügen
|
||
for (const kind of ['mail_antwort', 'unsicher', 'vorschau_freigabe']) assert.ok(!show([{ ...base, kind }]).includes('Antwort des Kunden einfügen'), kind)
|
||
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
|
||
})
|
||
|
||
// ---- PultList wird bedient (P4-T32): derselbe Mini-Läufer (FAKE_REACT) statt React und DOM. Die Komponenten laufen wirklich (Zustand, Effekte, Ereignisse), ----
|
||
// ---- gerendert wird in einen Baum aus { tag, props, children }; Klicks und Eingaben ruft der Test von Hand auf. Der Dienst ist eine Attrappe. ----
|
||
|
||
async function loadUi() {
|
||
return importBundle(await esbuild.build({
|
||
stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as PultList } from './src/components/workers/PultList.jsx'", resolveDir: ROOT, loader: 'js' },
|
||
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact, stubs],
|
||
}))
|
||
}
|
||
|
||
// Baum durchsuchen und bedienen
|
||
const nodes = (node, test, out = []) => {
|
||
if (node === null || typeof node === 'string') return out
|
||
if (test(node)) out.push(node)
|
||
for (const child of node.children) nodes(child, test, out)
|
||
return out
|
||
}
|
||
const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join(''))
|
||
const hasClass = (n, name) => n.tag !== undefined && String(n.props.className ?? '').split(' ').includes(name)
|
||
const clickable = (tree, label) => nodes(tree, (n) => (n.tag === 'button' || n.tag === 'a') && textOf(n).trim() === label)
|
||
// Knöpfe der Zeilen (Liste) und der Reiter (Filter) getrennt: „Erledigt“ ist beides
|
||
const inList = (tree) => nodes(tree, (n) => n.tag === 'ul' && hasClass(n, 'wk-pult-list'))[0] ?? { tag: 'fragment', props: {}, children: [] }
|
||
const inFilters = (tree) => nodes(tree, (n) => n.tag === 'div' && hasClass(n, 'wk-pult-filters'))[0]
|
||
const fieldOf = (tree, labelStart) => nodes(tree, (n) => n.tag === 'textarea' && String(n.props['aria-label'] ?? '').startsWith(labelStart))
|
||
const messages = (tree) => nodes(tree, (n) => n.tag === 'div' && String(n.props.className ?? '').split(' ').includes('msg')).map((n) => ({ kind: n.props.className.split(' ')[1], text: textOf(n), role: n.props.role }))
|
||
|
||
// Ein Lauf: Liste mit den Einträgen des Reiters „Wartet“ (alle anderen Reiter sind leer), fester Dienst, Takt von Hand
|
||
async function startList(items, api) {
|
||
const { __mount, jsx, PultList } = await loadUi()
|
||
const refreshes = []
|
||
const timers = new Map()
|
||
const opened = []
|
||
let nextTimer = 1
|
||
const saved = { window: globalThis.window }
|
||
globalThis.window = {
|
||
setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id },
|
||
clearTimeout: (id) => { timers.delete(id) },
|
||
open: (...args) => { opened.push(args) },
|
||
}
|
||
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
|
||
globalThis.__pultFeedFor = (statuses) => ({ items: statuses === 'offen,in_arbeit' ? items : [], loading: false, error: null, refresh: async () => { refreshes.push(statuses) } })
|
||
const mounted = __mount(jsx(PultList, { api, me: { id: 'u1', name: 'Justin' } }))
|
||
const view = {
|
||
refreshes, timers, opened,
|
||
tree: () => mounted.tree(),
|
||
// Klick auf den (einzigen) Knopf/Link mit dieser Beschriftung; ein gesperrter Knopf löst nichts aus (wie im Browser)
|
||
// row: Nummer der Zeile, wenn mehrere Zeilen denselben Knopf haben
|
||
async click(label, row) {
|
||
const scope = row === undefined ? inList(view.tree()) : nodes(inList(view.tree()), (n) => n.tag === 'li')[row]
|
||
const found = clickable(scope, label)
|
||
assert.equal(found.length, 1, `Knopf „${label}“ (gefunden: ${found.length})`)
|
||
if (!found[0].props.disabled) found[0].props.onClick?.({ preventDefault() {} })
|
||
await settle()
|
||
},
|
||
async tab(label) {
|
||
const found = clickable(inFilters(view.tree()), label)
|
||
assert.equal(found.length, 1, `Reiter „${label}“`)
|
||
found[0].props.onClick()
|
||
await settle()
|
||
},
|
||
link: (label) => clickable(inList(view.tree()), label)[0],
|
||
has: (label) => clickable(inList(view.tree()), label).length > 0,
|
||
disabled: (label) => clickable(inList(view.tree()), label).map((n) => n.props.disabled === true),
|
||
async type(labelStart, value) {
|
||
const found = fieldOf(view.tree(), labelStart)
|
||
assert.equal(found.length, 1, `Textfeld „${labelStart}“ (gefunden: ${found.length})`)
|
||
found[0].props.onChange({ target: { value } })
|
||
await settle()
|
||
},
|
||
value: (labelStart) => fieldOf(view.tree(), labelStart).map((n) => n.props.value),
|
||
messages: () => messages(view.tree()),
|
||
text: () => textOf(view.tree()),
|
||
fireTimers() { for (const [id, t] of [...timers]) { timers.delete(id); t.fn() } },
|
||
end() { mounted.unmount(); globalThis.window = saved.window; delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees },
|
||
}
|
||
await settle()
|
||
return view
|
||
}
|
||
// Dienst-Attrappe: jeder Aufruf wird festgehalten, die Antworten kommen der Reihe nach (Wert, Fehler oder offenes Versprechen)
|
||
function scriptedApi(...outcomes) {
|
||
const calls = []
|
||
return {
|
||
calls,
|
||
pultAction: (...args) => {
|
||
calls.push(args)
|
||
const next = outcomes.shift()
|
||
if (next instanceof Error) return Promise.reject(next)
|
||
return next ?? Promise.resolve({})
|
||
},
|
||
}
|
||
}
|
||
const failure = (message, status) => Object.assign(new Error(message), { status })
|
||
|
||
const unsicher = { $id: 'e1', kind: 'unsicher', status: 'offen', title: 'Unklare Antwort', draft: 'Entwurf von Clara', createdAt: new Date().toISOString(), prospectId: 'p1', prospect: { $id: 'p1', name: 'Verein', email: 'x@verein.example' } }
|
||
|
||
test('PultList (bedient, P4-T32): „Als Mensch antworten“ klappt auf, ist mit dem Entwurf vorbelegt, prüft den Text und zeigt die Sätze des Dienstes', async () => {
|
||
const api = scriptedApi(failure('Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.', 409), failure('Der Hustler-Dienst ist gerade nicht erreichbar.', 0), Promise.resolve({ item: { $id: 'e1' } }))
|
||
const ui = await startList([unsicher], api)
|
||
try {
|
||
assert.deepEqual(ui.value('Text der Antwort'), []) // zu: kein Textfeld
|
||
await ui.click('Als Mensch antworten')
|
||
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara']) // vorbelegt mit dem Entwurf
|
||
assert.ok(ui.text().includes('17 / 8000'))
|
||
assert.equal(nodes(ui.tree(), (n) => n.tag === 'button' && textOf(n) === 'Als Mensch antworten')[0].props['aria-expanded'], true)
|
||
|
||
// leer ⇒ Hinweis in der Zeile, nichts geht hinaus
|
||
await ui.type('Text der Antwort', ' ')
|
||
await ui.click('Jetzt senden')
|
||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Bitte einen Text eingeben.', role: 'alert' }])
|
||
assert.equal(api.calls.length, 0)
|
||
// zu lang ⇒ Zähler rot, Hinweis, nichts geht hinaus
|
||
await ui.type('Text der Antwort', 'a'.repeat(8001))
|
||
assert.match(textOf(nodes(ui.tree(), (n) => n.tag === 'span' && String(n.props.className).includes('wk-pult-count'))[0]), /8001 \/ 8000/)
|
||
assert.ok(String(nodes(ui.tree(), (n) => n.tag === 'span' && String(n.props.className).includes('wk-pult-count'))[0].props.className).includes('over'))
|
||
await ui.click('Jetzt senden')
|
||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Der Text ist zu lang (höchstens 8000 Zeichen).', role: 'alert' }])
|
||
assert.equal(api.calls.length, 0)
|
||
|
||
// Dienst lehnt ab (409): der Satz des Dienstes erscheint als Fehler, der Text bleibt zum Wiederholen stehen, die Liste wird neu geholt
|
||
await ui.type('Text der Antwort', ' Guten Tag, gern. ')
|
||
await ui.click('Jetzt senden')
|
||
assert.deepEqual(api.calls, [['e1', 'reply', { text: 'Guten Tag, gern.' }]]) // getrimmt, an die Id des Eintrags
|
||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.', role: 'alert' }])
|
||
assert.deepEqual(ui.value('Text der Antwort'), [' Guten Tag, gern. ']); assert.equal(ui.refreshes.length, 1)
|
||
// keine Antwort (Zeitüberschreitung, status 0): die Mail kann hinausgegangen sein
|
||
await ui.click('Jetzt senden')
|
||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Kann gesendet sein – Pult neu laden.', role: 'alert' }])
|
||
assert.equal(ui.refreshes.length, 2)
|
||
// Erfolg: der Eintrag verlässt die Liste, die Meldung steht oben, das Feld ist leer geräumt (zurück beim Entwurf), die Liste wird neu geholt
|
||
await ui.click('Jetzt senden')
|
||
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ gesendet.', role: 'status' }])
|
||
assert.equal(ui.refreshes.length, 3); assert.equal(api.calls.length, 3)
|
||
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara'])
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('PultList (bedient, P4-T32): während einer Anfrage sind alle Knöpfe der Zeile gesperrt, ein zweiter Klick sendet nichts', async () => {
|
||
let finish
|
||
const pending = new Promise((resolve) => { finish = resolve })
|
||
const api = scriptedApi(pending)
|
||
const ui = await startList([unsicher], api)
|
||
try {
|
||
await ui.click('Als Mensch antworten')
|
||
await ui.click('Jetzt senden')
|
||
assert.equal(api.calls.length, 1)
|
||
for (const label of ['Jetzt senden', 'Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich']) assert.deepEqual(ui.disabled(label), [true], label)
|
||
await ui.click('Jetzt senden'); await ui.click('Vorschau bauen')
|
||
assert.equal(api.calls.length, 1)
|
||
finish({ item: { $id: 'e1' } }); await settle()
|
||
assert.deepEqual(ui.disabled('Jetzt senden'), [false]); assert.deepEqual(ui.disabled('Vorschau bauen'), [false])
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('PultList (bedient, P4-T32): ein „unsicher“-Eintrag ohne Entwurf öffnet „Als Mensch antworten“ mit leerem Feld', async () => {
|
||
const api = scriptedApi()
|
||
const ui = await startList([{ ...unsicher, draft: undefined }], api)
|
||
try {
|
||
await ui.click('Als Mensch antworten')
|
||
assert.deepEqual(ui.value('Text der Antwort'), ['']); assert.ok(ui.text().includes('0 / 8000'))
|
||
assert.equal(api.calls.length, 0)
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
// Endprüfung P4 (W2): „Vorschau bauen“ startet einen Opus-Lauf und lässt sich in der Oberfläche nicht zurücknehmen: wie Freigeben und Verwerfen
|
||
// fragt der Knopf zuerst nach (4 s). Ein Fehlklick (Handy: der Knopf liegt in voller Breite zwischen anderen) startet keinen Bau mehr.
|
||
test('PultList (bedient, W2): „Vorschau bauen“ – erster Klick fragt nach (4 s, Hinweis), zweiter startet den Bau; Fehler und Erfolg wie bisher', async () => {
|
||
const api = scriptedApi(failure('Für diesen Interessenten gibt es schon eine Vorschau.', 409), Promise.resolve({ item: { $id: 'e1' } }))
|
||
const ui = await startList([{ ...unsicher, draft: undefined }], api)
|
||
try {
|
||
// erster Klick: Rückfrage und Hinweis zu Kosten und Wirkung, nichts geht hinaus; die Rückfrage verfällt nach 4 s
|
||
assert.ok(!ui.text().includes('Opus'))
|
||
await ui.click('Vorschau bauen')
|
||
assert.equal(api.calls.length, 0); assert.ok(ui.has('Wirklich bauen?')); assert.ok(!ui.has('Vorschau bauen'))
|
||
assert.ok(ui.text().includes('Startet einen Opus-Lauf')); assert.ok(ui.text().includes('als hätte der Interessent Ja gesagt'))
|
||
assert.ok(!ui.text().includes('nur wenn')) // kein „nur wenn der Interessent Ja gesagt hat“: der Knopf gilt gerade für unklare Antworten
|
||
assert.deepEqual([...ui.timers.values()].map((t) => t.ms), [4000])
|
||
ui.fireTimers(); await settle()
|
||
assert.ok(ui.has('Vorschau bauen')); assert.ok(!ui.has('Wirklich bauen?')); assert.ok(!ui.text().includes('Opus')); assert.equal(api.calls.length, 0)
|
||
// Rückfrage und zweiter Klick ⇒ Aktion ohne Body; der Dienst lehnt ab: sein Satz erscheint, die Rückfrage beginnt von vorn
|
||
await ui.click('Vorschau bauen'); await ui.click('Wirklich bauen?')
|
||
assert.deepEqual(api.calls, [['e1', 'start-build']])
|
||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Für diesen Interessenten gibt es schon eine Vorschau.', role: 'alert' }])
|
||
assert.ok(ui.has('Vorschau bauen')); assert.ok(!ui.text().includes('Opus'))
|
||
// Erfolg oben bei der Liste
|
||
await ui.click('Vorschau bauen'); await ui.click('Wirklich bauen?')
|
||
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ wird gebaut.', role: 'status' }])
|
||
assert.equal(api.calls.length, 2)
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('PultList (bedient, W2): die Rückfragen vor Bauen, Verwerfen und Freigeben lösen einander ab; Verwerfen sendet nichts ohne zweiten Klick', async () => {
|
||
const api = scriptedApi()
|
||
const ui = await startList([unsicher], api)
|
||
try {
|
||
await ui.click('Vorschau bauen'); assert.ok(ui.has('Wirklich bauen?'))
|
||
await ui.click('Verwerfen'); assert.ok(ui.has('Wirklich verwerfen?')); assert.ok(ui.has('Vorschau bauen')); assert.ok(!ui.text().includes('Opus'))
|
||
await ui.click('Vorschau bauen'); assert.ok(ui.has('Wirklich bauen?')); assert.ok(ui.has('Verwerfen'))
|
||
assert.equal(api.calls.length, 0)
|
||
await ui.click('Wirklich bauen?')
|
||
assert.deepEqual(api.calls, [['e1', 'start-build']])
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('PultList (bedient, P4-T32): Vorschau freigeben – erster Klick fragt nach (4 s), zweiter sendet; keine Antwort heißt „Kann gesendet sein“', async () => {
|
||
const freigabe = { ...unsicher, $id: 'f1', kind: 'vorschau_freigabe', title: 'Vorschau prüfen', draft: 'Hier ist Ihre Vorschau.', sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }
|
||
const api = scriptedApi(failure('Die Vorschau ist nicht freigabebereit.', 409), failure('Der Hustler-Dienst ist gerade nicht erreichbar.', 0), Promise.resolve({ item: { $id: 'f1' }, previewUrl: freigabe.sourceUrl }))
|
||
const ui = await startList([freigabe], api)
|
||
try {
|
||
// der Link zur Vorschau öffnet in einem neuen Tab ohne Rückbezug
|
||
const link = ui.link('Vorschau ansehen')
|
||
assert.deepEqual([link.tag, link.props.href, link.props.target, link.props.rel], ['a', 'https://entwurf.project.webklar.com/AAA/', '_blank', 'noopener noreferrer'])
|
||
assert.ok(!ui.has('Anzeige öffnen')); assert.ok(!ui.has('Erledigt'))
|
||
// erster Klick: Rückfrage, nichts geht hinaus; sie verfällt nach 4 s
|
||
await ui.click('Freigeben & senden')
|
||
assert.equal(api.calls.length, 0); assert.ok(ui.has('Wirklich freigeben?')); assert.ok(!ui.has('Freigeben & senden'))
|
||
assert.deepEqual([...ui.timers.values()].map((t) => t.ms), [4000])
|
||
ui.fireTimers(); await settle()
|
||
assert.ok(ui.has('Freigeben & senden')); assert.ok(!ui.has('Wirklich freigeben?'))
|
||
// Rückfrage und zweiter Klick ⇒ Aktion ohne Body; der Dienst lehnt ab: sein Satz erscheint
|
||
await ui.click('Freigeben & senden'); await ui.click('Wirklich freigeben?')
|
||
assert.deepEqual(api.calls, [['f1', 'approve-preview']])
|
||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Die Vorschau ist nicht freigabebereit.', role: 'alert' }])
|
||
assert.ok(ui.has('Freigeben & senden')) // nach der Aktion beginnt die Rückfrage von vorn
|
||
// Zeitüberschreitung: Hinweis, die Liste wird neu geholt
|
||
await ui.click('Freigeben & senden'); await ui.click('Wirklich freigeben?')
|
||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Kann gesendet sein – Pult neu laden.', role: 'alert' }]); assert.equal(ui.refreshes.length, 2)
|
||
// Erfolg
|
||
await ui.click('Freigeben & senden'); await ui.click('Wirklich freigeben?')
|
||
assert.equal(api.calls.length, 3)
|
||
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Vorschau prüfen“ freigegeben. Max sendet den Link im nächsten Sendefenster.', role: 'status' }])
|
||
// Verwerfen fragt weiterhin für sich nach, und die Rückfragen stören sich nicht
|
||
await ui.click('Verwerfen'); assert.ok(ui.has('Wirklich verwerfen?')); assert.ok(ui.has('Freigeben & senden'))
|
||
await ui.click('Freigeben & senden'); assert.ok(ui.has('Wirklich freigeben?')); assert.ok(ui.has('Verwerfen'))
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
// Endprüfung P4 (W5): ohne gültige Vorschau-Adresse lässt sich nicht freigeben (auch nicht über die Rückfrage), mit gültiger Adresse wie bisher
|
||
test('PultList (bedient, W5): „Freigeben & senden“ ist ohne gültige Vorschau-Adresse gesperrt – kein Klick, keine Rückfrage, keine Aktion', async () => {
|
||
const freigabe = { ...unsicher, $id: 'f1', kind: 'vorschau_freigabe', title: 'Vorschau prüfen', draft: 'Hier ist Ihre Vorschau.' }
|
||
for (const sourceUrl of ['http://entwurf.project.webklar.com/AAA/', 'https://fremd.example/AAA/', '']) {
|
||
const api = scriptedApi()
|
||
const ui = await startList([{ ...freigabe, sourceUrl }], api)
|
||
try {
|
||
assert.deepEqual(ui.disabled('Freigeben & senden'), [true], sourceUrl)
|
||
assert.ok(ui.text().includes('Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich')); assert.ok(!ui.has('Vorschau ansehen'))
|
||
await ui.click('Freigeben & senden')
|
||
assert.ok(ui.has('Freigeben & senden')); assert.ok(!ui.has('Wirklich freigeben?')); assert.equal(api.calls.length, 0)
|
||
assert.deepEqual([...ui.timers.values()], [])
|
||
// Verwerfen bleibt möglich und sendet nichts: der Mensch kann den Eintrag schließen
|
||
assert.deepEqual(ui.disabled('Verwerfen'), [false])
|
||
} finally { ui.end() }
|
||
}
|
||
// gültige Adresse: Freigeben ist frei und fragt wie bisher nach
|
||
const api = scriptedApi()
|
||
const ui = await startList([{ ...freigabe, sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }], api)
|
||
try {
|
||
assert.deepEqual(ui.disabled('Freigeben & senden'), [false]); assert.ok(!ui.text().includes('Keine gültige Vorschau-Adresse'))
|
||
await ui.click('Freigeben & senden'); assert.ok(ui.has('Wirklich freigeben?'))
|
||
await ui.click('Wirklich freigeben?'); assert.deepEqual(api.calls, [['f1', 'approve-preview']])
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('PultList (bedient, P4-T32): „Antwort des Kunden einfügen“ – leeres Feld mit Hinweis, Prüfung, Einfügen, doppelt, Fehler des Dienstes, Text bleibt beim Filterwechsel', async () => {
|
||
const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', status: 'in_arbeit', claimedBy: 'mensch:u2', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
|
||
const api = scriptedApi(Promise.resolve({ status: 'neu', messageId: 'm1', threadId: 't1' }), Promise.resolve({ status: 'doppelt', messageId: 'm1', threadId: 't1' }), failure('Dieser Kontakt ist gesperrt.', 409))
|
||
const ui = await startList([plattform], api)
|
||
try {
|
||
assert.deepEqual(ui.value('Antwort des Kunden'), [])
|
||
await ui.click('Antwort des Kunden einfügen') // auch bei einem Kollegen, der den Eintrag hält
|
||
assert.deepEqual(ui.value('Antwort des Kunden'), ['']) // leer, nie mit Claras Entwurf vorbelegt
|
||
assert.ok(ui.text().includes('Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.'))
|
||
// leer ⇒ Hinweis; nichts geht hinaus
|
||
await ui.click('Einfügen')
|
||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Bitte einen Text eingeben.', role: 'alert' }]); assert.equal(api.calls.length, 0)
|
||
|
||
// einfügen: getrimmter Text an die Id des Eintrags; danach ist das Feld geleert und zu, die Zeile meldet das Einsortieren
|
||
await ui.type('Antwort des Kunden', ' Danke, das passt. ')
|
||
await ui.click('Einfügen')
|
||
assert.deepEqual(api.calls, [['k1', 'paste', { text: 'Danke, das passt.' }]])
|
||
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: 'Eingefügt. Nora sortiert die Antwort ein.', role: 'status' }])
|
||
assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(ui.refreshes.length, 1)
|
||
await ui.click('Antwort des Kunden einfügen')
|
||
assert.deepEqual(ui.value('Antwort des Kunden'), ['']) // nach dem Einfügen wieder leer
|
||
// dieselbe Antwort noch einmal: der Dienst kennt sie schon
|
||
await ui.type('Antwort des Kunden', 'Danke, das passt.')
|
||
await ui.click('Einfügen')
|
||
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: 'Diese Antwort war schon eingefügt.', role: 'status' }])
|
||
assert.deepEqual(ui.value('Antwort des Kunden'), [])
|
||
|
||
// Dienstfehler: Satz als Fehler, der Text bleibt stehen (auch wenn der Reiter gewechselt wird und die Zeile verschwindet)
|
||
await ui.click('Antwort des Kunden einfügen')
|
||
await ui.type('Antwort des Kunden', 'Noch eine Antwort')
|
||
await ui.click('Einfügen')
|
||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Dieser Kontakt ist gesperrt.', role: 'alert' }])
|
||
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort'])
|
||
await ui.tab('Veraltet'); assert.deepEqual(ui.value('Antwort des Kunden'), [])
|
||
await ui.tab('Wartet')
|
||
await ui.click('Antwort des Kunden einfügen')
|
||
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort'])
|
||
// Einfügen und Antworten sind getrennt: an einer Plattform-Antwort gibt es kein „Als Mensch antworten“
|
||
assert.ok(!ui.has('Als Mensch antworten'))
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('PultList (bedient, P4-T32): ein Text von „Als Mensch antworten“ überlebt den Wechsel des Reiters; nach dem Absenden ist er weg', async () => {
|
||
const api = scriptedApi(Promise.resolve({ item: { $id: 'e1' } }))
|
||
const ui = await startList([unsicher], api)
|
||
try {
|
||
await ui.click('Als Mensch antworten')
|
||
await ui.type('Text der Antwort', 'Mein eigener Text')
|
||
await ui.tab('Veraltet'); assert.deepEqual(ui.value('Text der Antwort'), []) // die Zeile ist weg
|
||
await ui.tab('Wartet')
|
||
await ui.click('Als Mensch antworten')
|
||
assert.deepEqual(ui.value('Text der Antwort'), ['Mein eigener Text'])
|
||
await ui.click('Jetzt senden')
|
||
assert.deepEqual(api.calls, [['e1', 'reply', { text: 'Mein eigener Text' }]])
|
||
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara'])
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('PultList (bedient, P4-T32): mehrere Einträge desselben Interessenten – Felder, Texte und Aktionen hängen an der Eintrags-Id', async () => {
|
||
const zweite = { ...unsicher, $id: 'e3', title: 'Zweite Rückfrage', draft: 'Entwurf drei' } // gleicher Interessent p1 wie e1
|
||
const nachfolge = { ...unsicher, $id: 'k9', kind: 'plattform_antwort', title: 'Folge-Antwort', draft: 'Claras Folge', prospect: { $id: 'p1', name: 'Verein' } }
|
||
const api = scriptedApi(Promise.resolve({ item: { $id: 'e3' } }), Promise.resolve({ status: 'neu' }))
|
||
const ui = await startList([{ ...unsicher, draft: 'Entwurf eins' }, zweite, nachfolge], api)
|
||
try {
|
||
assert.equal(nodes(ui.tree(), (n) => n.tag === 'li').length, 3)
|
||
await ui.click('Als Mensch antworten', 0)
|
||
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins']) // nur Zeile 1 ist aufgeklappt
|
||
await ui.click('Als Mensch antworten', 1)
|
||
await ui.type('Text der Antwort: Zweite Rückfrage', 'Text drei')
|
||
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins', 'Text drei']) // die Texte stören sich nicht
|
||
// gesendet wird der Text der Zeile, deren Knopf gedrückt wurde, an die Id dieses Eintrags
|
||
await ui.click('Jetzt senden', 1)
|
||
assert.deepEqual(api.calls, [['e3', 'reply', { text: 'Text drei' }]])
|
||
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins', 'Entwurf drei']) // e3 ist abgeschlossen (Text zurückgesetzt), e1 unberührt
|
||
// Einfügen an der dritten Zeile: nur dort, mit der Id des Eintrags k9
|
||
await ui.click('Antwort des Kunden einfügen')
|
||
await ui.type('Antwort des Kunden: Folge-Antwort', 'Ja, gern')
|
||
await ui.click('Einfügen')
|
||
assert.deepEqual(api.calls[1], ['k9', 'paste', { text: 'Ja, gern' }])
|
||
} finally { ui.end() }
|
||
})
|