Die zeichengleiche Inline-Kopie (FAKE_REACT und Plug-in) ist entfernt; der Baustein ist jetzt die einzige Fassung für workersPultUi.test.mjs und botleadList.test.mjs. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1067 lines
72 KiB
JavaScript
1067 lines
72 KiB
JavaScript
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import fs from 'node:fs'
|
||
import path from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import esbuild from 'esbuild'
|
||
import { fakeReact } from './helpers/fakeReact.mjs'
|
||
|
||
// 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 (helpers/fakeReact.mjs), 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 }), er liegt in helpers/fakeReact.mjs ----
|
||
// ---- 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')}`)
|
||
|
||
// 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', 'Abbrechen', 'data-opens', 'Escape', 'restoreFocus', 'activeControl', 'tabIndex={-1}',
|
||
'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, Knöpfe) 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/)
|
||
// Endprüfung W4: „Abbrechen“ und „Jetzt senden“ stehen nebeneinander (Gruppe .wk-pult-panel-btns), auf dem Handy untereinander über die volle Breite
|
||
assert.match(css, /\.wk-pult-panel-btns\s*\{[^}]*display: flex/); assert.match(phone, /\.wk-pult-panel-btns\s*\{[^}]*flex-direction: column-reverse/)
|
||
})
|
||
|
||
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 (helpers/fakeReact.mjs) 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()
|
||
},
|
||
// Taste im Feld mit dieser Klasse (wk-pult-reply, wk-pult-paste); Ergebnis: das Ereignis, damit der Test preventDefault sehen kann
|
||
async key(panelClass, key = 'Escape') {
|
||
const panel = nodes(view.tree(), (n) => n.tag === 'div' && hasClass(n, panelClass))[0]
|
||
assert.ok(panel, 'Feld ' + panelClass)
|
||
const event = { key, defaultPrevented: false, stopped: false, preventDefault() { this.defaultPrevented = true }, stopPropagation() { this.stopped = true } }
|
||
panel.props.onKeyDown?.(event)
|
||
await settle()
|
||
return event
|
||
},
|
||
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 })
|
||
|
||
// document-Attrappe für den Fokus (nur für die Dauer eines Tests): activeElement stellt der Test, focus() eines Elements setzt es. Elemente, die der Bauteil über
|
||
// ref anfasst (Zeile, Aktionsgruppe, Meldung), liefert __fakeRef je Klasse; querySelector antwortet aus f.found (Selektor -> Element), sonst null.
|
||
function fakeFocus() {
|
||
const body = { name: 'body' }
|
||
const doc = { body, documentElement: { name: 'html' }, activeElement: body }
|
||
const f = { doc, body, found: new Map(), made: new Map() }
|
||
f.el = (name, extra = {}) => ({ name, isConnected: true, disabled: false, focusCalls: 0, focus() { this.focusCalls += 1; doc.activeElement = this }, querySelector: (selector) => f.found.get(selector) ?? null, ...extra })
|
||
f.ref = (key) => f.made.get(key) // das Element hinter ref an Tag/Klasse key
|
||
const had = Object.hasOwn(globalThis, 'document')
|
||
const saved = globalThis.document
|
||
globalThis.document = doc
|
||
globalThis.__fakeRef = (tag, props) => {
|
||
const key = String(props.className ?? tag).split(' ')[0]
|
||
if (!f.made.has(key)) f.made.set(key, f.el(key))
|
||
return f.made.get(key)
|
||
}
|
||
f.restore = () => {
|
||
if (had) globalThis.document = saved
|
||
else delete globalThis.document
|
||
delete globalThis.__fakeRef
|
||
}
|
||
return f
|
||
}
|
||
|
||
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 (W4): die aufgeklappten Felder schließen mit Esc und mit „Abbrechen“; der Fokus geht an den Knopf zurück, der sie öffnete, der Text bleibt.
|
||
// Während einer Anfrage bleiben beide gesperrt (das Feld soll nicht mitten im Senden verschwinden).
|
||
test('PultList (bedient, W4): Esc und „Abbrechen“ schließen „Als Mensch antworten“, geben den Fokus an den Knopf zurück und behalten den Text', async () => {
|
||
const f = fakeFocus()
|
||
let finish
|
||
const api = scriptedApi(new Promise((resolve) => { finish = resolve }))
|
||
const ui = await startList([unsicher], api)
|
||
try {
|
||
const opener = f.el('opener')
|
||
f.found.set('[data-opens="reply"]', opener)
|
||
assert.equal(clickable(inList(ui.tree()), 'Als Mensch antworten')[0].props['data-opens'], 'reply') // der Auslöser trägt seine Kennung
|
||
assert.ok(!ui.has('Abbrechen'))
|
||
await ui.click('Als Mensch antworten')
|
||
assert.ok(ui.has('Abbrechen'))
|
||
await ui.type('Text der Antwort', 'Mein Text')
|
||
// eine andere Taste ändert nichts
|
||
let event = await ui.key('wk-pult-reply', 'a')
|
||
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text']); assert.equal(event.defaultPrevented, false)
|
||
// Esc: Feld zu, Fokus auf dem Auslöser, das Ereignis ist behandelt (die Handy-Leiste schließt sonst mit)
|
||
event = await ui.key('wk-pult-reply')
|
||
assert.deepEqual(ui.value('Text der Antwort'), []); assert.equal(opener.focusCalls, 1); assert.equal(f.doc.activeElement, opener)
|
||
assert.ok(event.defaultPrevented); assert.ok(event.stopped)
|
||
assert.equal(nodes(ui.tree(), (n) => n.tag === 'button' && textOf(n) === 'Als Mensch antworten')[0].props['aria-expanded'], false)
|
||
// der Text bleibt erhalten
|
||
await ui.click('Als Mensch antworten')
|
||
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text'])
|
||
// Abbrechen: wie Esc
|
||
await ui.click('Abbrechen')
|
||
assert.deepEqual(ui.value('Text der Antwort'), []); assert.equal(opener.focusCalls, 2); assert.equal(api.calls.length, 0)
|
||
// ein zweiter Klick auf den Auslöser schließt wie bisher, ohne den Fokus zu bewegen (er liegt schon dort)
|
||
await ui.click('Als Mensch antworten'); await ui.click('Als Mensch antworten')
|
||
assert.deepEqual(ui.value('Text der Antwort'), []); assert.equal(opener.focusCalls, 2)
|
||
// während der Anfrage sind Esc und Abbrechen gesperrt
|
||
await ui.click('Als Mensch antworten')
|
||
await ui.click('Jetzt senden')
|
||
assert.equal(api.calls.length, 1); assert.deepEqual(ui.disabled('Abbrechen'), [true])
|
||
event = await ui.key('wk-pult-reply')
|
||
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text']); assert.equal(event.defaultPrevented, false); assert.equal(opener.focusCalls, 2)
|
||
await ui.click('Abbrechen')
|
||
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text'])
|
||
finish({ item: { $id: 'e1' } }); await settle()
|
||
} finally { ui.end(); f.restore() }
|
||
})
|
||
|
||
test('PultList (bedient, W4): Esc und „Abbrechen“ schließen auch „Antwort des Kunden einfügen“ und kehren an dessen Knopf zurück', async () => {
|
||
const f = fakeFocus()
|
||
const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', 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()
|
||
const ui = await startList([plattform], api)
|
||
try {
|
||
const opener = f.el('opener')
|
||
f.found.set('[data-opens="paste"]', opener)
|
||
assert.equal(clickable(inList(ui.tree()), 'Antwort des Kunden einfügen')[0].props['data-opens'], 'paste')
|
||
await ui.click('Antwort des Kunden einfügen')
|
||
await ui.type('Antwort des Kunden', 'Noch nicht gesendet')
|
||
await ui.key('wk-pult-paste')
|
||
assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(opener.focusCalls, 1)
|
||
await ui.click('Antwort des Kunden einfügen')
|
||
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch nicht gesendet']) // der Text bleibt
|
||
await ui.click('Abbrechen')
|
||
assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(opener.focusCalls, 2); assert.equal(api.calls.length, 0)
|
||
// „Abbrechen“ liegt vor dem Absenden (Einfügen bleibt der Hauptknopf)
|
||
await ui.click('Antwort des Kunden einfügen')
|
||
const names = nodes(ui.tree(), (n) => n.tag === 'div' && hasClass(n, 'wk-pult-panel-btns'))[0].children.map(textOf)
|
||
assert.deepEqual(names, ['Abbrechen', 'Einfügen'])
|
||
} finally { ui.end(); f.restore() }
|
||
})
|
||
|
||
// Endprüfung P4 (W3): ein gesperrter Knopf verliert den Fokus an <body>. Nach der Anfrage gibt die Zeile ihn zurück (an den Knopf, sonst ans Feld, an den
|
||
// Knopf, der das Feld öffnete, oder an die Zeile); verlässt eine Zeile die Liste, geht er an die Meldung oben. Wer den Fokus inzwischen selbst
|
||
// woanders hat, wird nicht gestört. Der Test spielt den Browser nach: activeElement stellt der Test (Klick: Knopf; Sperre: body).
|
||
const PANEL_FIELD = '.wk-pult-reply textarea, .wk-pult-paste textarea'
|
||
|
||
test('PultList (bedient, W3): nach einem Fehler beim Senden steht der Fokus wieder auf „Jetzt senden“; hat die Person inzwischen selbst gewählt, bleibt er dort', async () => {
|
||
for (const moved of [false, true]) {
|
||
const f = fakeFocus()
|
||
let reject
|
||
const api = scriptedApi(new Promise((_, no) => { reject = no }))
|
||
const ui = await startList([unsicher], api)
|
||
try {
|
||
await ui.click('Als Mensch antworten')
|
||
const submit = f.el('submit')
|
||
f.doc.activeElement = submit // der Klick hat den Knopf fokussiert
|
||
await ui.click('Jetzt senden')
|
||
const other = f.el('other')
|
||
f.doc.activeElement = moved ? other : f.body // der gesperrte Knopf verliert den Fokus (Chromium) – oder die Person ist schon woandershin gegangen
|
||
reject(failure('Wird schon von einem Kollegen bearbeitet.', 409)); await settle()
|
||
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Wird schon von einem Kollegen bearbeitet.', role: 'alert' }]) // die Zeile bleibt
|
||
if (moved) assert.deepEqual([f.doc.activeElement, submit.focusCalls], [other, 0])
|
||
else assert.deepEqual([f.doc.activeElement, submit.focusCalls], [submit, 1])
|
||
} finally { ui.end(); f.restore() }
|
||
}
|
||
})
|
||
|
||
test('PultList (bedient, W3): ist der Knopf nach der Aktion weg („Ich kümmere mich“), geht der Fokus an die Zeile; sie ist mit tabIndex -1 fokussierbar', async () => {
|
||
const f = fakeFocus()
|
||
let finish
|
||
const api = scriptedApi(new Promise((resolve) => { finish = resolve }))
|
||
const ui = await startList([unsicher], api)
|
||
try {
|
||
const li = nodes(ui.tree(), (n) => n.tag === 'li')[0]
|
||
assert.equal(li.props.tabIndex, -1)
|
||
const claim = f.el('claim')
|
||
f.doc.activeElement = claim
|
||
await ui.click('Ich kümmere mich')
|
||
f.doc.activeElement = f.body
|
||
claim.isConnected = false // nach „übernommen“ gibt es den Knopf nicht mehr
|
||
finish({ item: { $id: 'e1' } }); await settle()
|
||
const row = f.ref('wk-pult-row')
|
||
assert.deepEqual([row.focusCalls, f.doc.activeElement], [1, row])
|
||
assert.equal(claim.focusCalls, 0)
|
||
} finally { ui.end(); f.restore() }
|
||
})
|
||
|
||
test('PultList (bedient, W3): nach dem Einfügen (Feld klappt zu) geht der Fokus an „Antwort des Kunden einfügen“; nach einem Fehler bleibt er im Feld', async () => {
|
||
const f = fakeFocus()
|
||
const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
|
||
let reject
|
||
let finish
|
||
const api = scriptedApi(new Promise((_, no) => { reject = no }), new Promise((resolve) => { finish = resolve }))
|
||
const ui = await startList([plattform], api)
|
||
try {
|
||
const opener = f.el('opener'); f.found.set('[data-opens="paste"]', opener)
|
||
const field = f.el('textarea'); f.found.set(PANEL_FIELD, field)
|
||
await ui.click('Antwort des Kunden einfügen')
|
||
await ui.type('Antwort des Kunden', 'Danke, passt.')
|
||
// Fehler: das Feld bleibt offen, der Knopf „Einfügen“ ist noch da und bekommt den Fokus zurück
|
||
const insert = f.el('insert'); f.doc.activeElement = insert
|
||
await ui.click('Einfügen')
|
||
f.doc.activeElement = f.body
|
||
reject(failure('Dieser Kontakt ist gesperrt.', 409)); await settle()
|
||
assert.deepEqual([f.doc.activeElement, insert.focusCalls], [insert, 1])
|
||
// Erfolg: das Feld klappt zu (Textfeld und Knopf „Einfügen“ sind weg), der Fokus geht an „Antwort des Kunden einfügen“ (vor der Zeile)
|
||
f.found.delete(PANEL_FIELD)
|
||
f.doc.activeElement = insert
|
||
await ui.click('Einfügen')
|
||
f.doc.activeElement = f.body
|
||
insert.isConnected = false
|
||
finish({ status: 'neu' }); await settle()
|
||
assert.deepEqual([opener.focusCalls, f.doc.activeElement], [1, opener])
|
||
assert.equal(field.focusCalls, 0); assert.equal(f.ref('wk-pult-row').focusCalls, 0)
|
||
// und bleibt das Feld offen, aber der Knopf ist weg, nimmt das Textfeld den Fokus (vor dem Auslöser)
|
||
} finally { ui.end(); f.restore() }
|
||
})
|
||
|
||
test('PultList (bedient, W3): verlässt die Zeile die Liste, geht der Fokus an die Meldung oben (auch bei gleichem Text); sie ist mit tabIndex -1 fokussierbar', async () => {
|
||
const f = fakeFocus()
|
||
const zweite = { ...unsicher, $id: 'e2' } // gleicher Titel: dieselbe Meldung zweimal
|
||
let finish
|
||
const api = scriptedApi(undefined, undefined, new Promise((resolve) => { finish = resolve }))
|
||
const ui = await startList([unsicher, zweite], api)
|
||
try {
|
||
// der Knopf, den die Anfrage gerade sperrt (disabled): der Browser lässt ihn erst beim nächsten Takt los, dann liegt der Fokus auf <body>
|
||
const done0 = f.el('done0', { disabled: true }); f.doc.activeElement = done0
|
||
await ui.click('Erledigt', 0)
|
||
const status = f.ref('msg')
|
||
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ erledigt.', role: 'status' }])
|
||
assert.equal(nodes(ui.tree(), (n) => n.tag === 'div' && hasClass(n, 'msg'))[0].props.tabIndex, -1)
|
||
assert.deepEqual([status.focusCalls, f.doc.activeElement], [1, status])
|
||
assert.equal(done0.focusCalls, 0)
|
||
// zweite Zeile, gleiche Meldung: wieder auf die Meldung
|
||
f.doc.activeElement = f.el('done1', { disabled: true })
|
||
await ui.click('Erledigt', 1)
|
||
assert.equal(status.focusCalls, 2); assert.equal(f.doc.activeElement, status)
|
||
// hat die Person den Fokus während der Anfrage selbst woanders hingesetzt, bleibt er dort
|
||
f.doc.activeElement = f.el('done2', { disabled: true })
|
||
await ui.click('Erledigt', 0)
|
||
const other = f.el('other'); f.doc.activeElement = other
|
||
finish({}); await settle()
|
||
assert.deepEqual([status.focusCalls, f.doc.activeElement], [2, other])
|
||
} finally { ui.end(); f.restore() }
|
||
})
|
||
|
||
test('PultList (bedient, W3): ohne document (Server, Node) und ohne Fokus vor dem Klick passiert nichts', async () => {
|
||
const api = scriptedApi()
|
||
const ui = await startList([unsicher], api) // kein document, kein fakeRef: refs bleiben null
|
||
try {
|
||
await ui.click('Ich kümmere mich')
|
||
await ui.click('Erledigt')
|
||
assert.equal(api.calls.length, 2)
|
||
} finally { ui.end() }
|
||
// das document ist da, aber der Klick hatte keinen Fokus (Safari fokussiert Knöpfe nicht): nichts zurückzugeben, nichts wird bewegt – auch nicht zur Meldung oben
|
||
const f = fakeFocus()
|
||
const ui2 = await startList([unsicher], scriptedApi())
|
||
try {
|
||
await ui2.click('Ich kümmere mich')
|
||
assert.deepEqual([f.ref('wk-pult-row').focusCalls, f.doc.activeElement], [0, f.body])
|
||
await ui2.click('Erledigt')
|
||
assert.ok(ui2.text().includes('erledigt.'))
|
||
assert.deepEqual([f.ref('msg').focusCalls, f.doc.activeElement], [0, f.body])
|
||
} finally { ui2.end(); f.restore() }
|
||
})
|
||
|
||
// 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() }
|
||
})
|