Workers P4: Pult – als Mensch antworten, Vorschau freigeben, Antwort des Kunden einfügen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
be2e6786b6
commit
533091c333
7 files changed
+1001
-48
No files matched your search
@@ -334,8 +334,12 @@ test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text,
|
||||
// nicht sendbare Art: Erledigt statt Senden, aber Verwerfen bleibt; abgeschlossene Einträge haben keine Aktion
|
||||
html = show([{ ...base, kind: 'unsicher', draft: '' }])
|
||||
assert.ok(html.includes(erledigt)); assert.ok(html.includes('Verwerfen')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('<textarea'))
|
||||
html = show([{ ...base, status: 'gesendet' }])
|
||||
// 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: '' }
|
||||
@@ -419,3 +423,529 @@ test('PultList (gerendert): wiederkehrende Bedienelemente haben je Zeile einen e
|
||||
}
|
||||
} finally { delete globalThis.__pultFeed; 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',
|
||||
'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.__pultFeed = { 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'))
|
||||
// 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>'))
|
||||
// fremde, unsichere oder fehlende Adresse: kein Link, Hinweis statt Link, der Knopf zum Freigeben bleibt
|
||||
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.'), sourceUrl); assert.ok(html.includes('Freigeben & senden'), sourceUrl)
|
||||
}
|
||||
// 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.__pultFeed; delete globalThis.__pultEmployees }
|
||||
})
|
||||
|
||||
// ---- PultList wird bedient (P4-T32): Mini-Läufer 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. ----
|
||||
|
||||
const FAKE_UI = `
|
||||
export const Fragment = Symbol('fragment')
|
||||
export function jsx(type, props, key) { return { type, props: props ?? {}, key } }
|
||||
export const jsxs = jsx
|
||||
|
||||
let current = null
|
||||
let hookIndex = 0
|
||||
let effects = []
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function __mount(element) {
|
||||
const instances = new Map()
|
||||
let tree = null
|
||||
let rendering = false
|
||||
let dirty = false
|
||||
|
||||
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, rerender: render }; 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()
|
||||
effects = []
|
||||
tree = expand(element, 'r', visited)
|
||||
for (const [path, inst] of [...instances]) {
|
||||
if (visited.has(path)) continue
|
||||
inst.alive = false
|
||||
for (const h of inst.hooks) if (h && typeof h.cleanup === 'function') h.cleanup()
|
||||
instances.delete(path)
|
||||
}
|
||||
for (const run of effects.splice(0)) run()
|
||||
} while (dirty)
|
||||
rendering = false
|
||||
}
|
||||
render()
|
||||
return { tree: () => tree, unmount: () => { for (const inst of instances.values()) { inst.alive = false; for (const h of inst.hooks) if (h && typeof h.cleanup === 'function') h.cleanup() } 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) return
|
||||
const value = typeof next === 'function' ? next(slot.value) : next
|
||||
if (Object.is(value, slot.value)) return
|
||||
slot.value = value
|
||||
inst.rerender()
|
||||
}]
|
||||
}
|
||||
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
|
||||
effects.push(() => {
|
||||
if (typeof slot.cleanup === 'function') slot.cleanup()
|
||||
slot.deps = deps
|
||||
slot.ran = true
|
||||
slot.cleanup = fn()
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
const UI_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 (Dialog) gehört nicht zu diesen Tests
|
||||
}
|
||||
|
||||
async function loadUi() {
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const plugin = {
|
||||
name: 'pult-ui',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: 'react', namespace: 'fake-ui' }))
|
||||
build.onLoad({ filter: /.*/, namespace: 'fake-ui' }, () => ({ contents: FAKE_UI, loader: 'js' }))
|
||||
build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$|\/ProspectPanel$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-ui-stub' }))
|
||||
build.onLoad({ filter: /.*/, namespace: 'pult-ui-stub' }, (a) => ({ contents: UI_STUBS[a.path], loader: 'js' }))
|
||||
},
|
||||
}
|
||||
const out = 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: [plugin],
|
||||
})
|
||||
return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
|
||||
}
|
||||
|
||||
// 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; „Vorschau bauen“ startet den Bau', 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 {
|
||||
await ui.click('Als Mensch antworten')
|
||||
assert.deepEqual(ui.value('Text der Antwort'), ['']); assert.ok(ui.text().includes('0 / 8000'))
|
||||
// Bau starten: ohne Body, Fehler des Dienstes als Satz, Erfolg oben bei der Liste
|
||||
await ui.click('Vorschau 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' }])
|
||||
await ui.click('Vorschau 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, 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() }
|
||||
})
|
||||
|
||||
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() }
|
||||
})
|
||||
Reference in new issue
Block a user