N5: Der Fokus geht nach dem Blättern über restoreFocus (workersFocus.js) mit preventScroll an die Seitenzeile zurück, aber nur, wenn er wirklich verloren ging (body, nirgends, alter Knopf ausgehängt). Hat der Nutzer inzwischen Suchfeld oder Raumfilter angesteuert, bleibt er dort. N6: Scheitert eine Anfrage, steht unter der Meldung „Erneut versuchen“; der Knopf stellt genau diese Anfrage (Raum, Suche, Seite oder Eintrag) noch einmal und die Seitenzeile bekommt danach den Fokus zurück. CSS: .wk-bl-retry, am Handy 40 px. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
620 lines
32 KiB
JavaScript
620 lines
32 KiB
JavaScript
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import path from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import esbuild from 'esbuild'
|
||
import { fakeReact } from './helpers/fakeReact.mjs'
|
||
|
||
// BotLeadList läuft wirklich: im Mini-Läufer anstelle von React und DOM (Muster workersPultUi.test.mjs, Baustein helpers/fakeReact.mjs).
|
||
// Gerendert wird in einen Baum aus { tag, props, children }; Eingaben und Klicks ruft der Test von Hand auf, die Zeit (window.setTimeout)
|
||
// läuft von Hand, der Dienst ist eine Attrappe mit offenen Versprechen.
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||
const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
|
||
const settle = () => new Promise((resolve) => setImmediate(resolve))
|
||
|
||
// Die Komponente wird einmal gebündelt; jeder Test hängt sie neu ein (eigener Zustand)
|
||
let bundle
|
||
const loadUi = () => (bundle ??= esbuild.build({
|
||
stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as BotLeadList } from './src/components/workers/BotLeadList.jsx'", resolveDir: ROOT, loader: 'js' },
|
||
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact],
|
||
}).then(importBundle))
|
||
|
||
// Baum durchsuchen
|
||
const nodes = (node, match, out = []) => {
|
||
if (node === null || typeof node === 'string') return out
|
||
if (match(node)) out.push(node)
|
||
for (const child of node.children) nodes(child, match, out)
|
||
return out
|
||
}
|
||
const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join(''))
|
||
const hasClass = (n, name) => String(n.props.className ?? '').split(' ').includes(name)
|
||
const buttons = (tree, label) => nodes(tree, (n) => n.tag === 'button' && textOf(n).trim() === label)
|
||
|
||
// Dienst-Attrappe: jeder Aufruf von botleads ist ein offenes Versprechen, das der Test selbst auflöst
|
||
function fakeApi() {
|
||
const calls = []
|
||
return { calls, botleads: (params) => new Promise((resolve, reject) => { calls.push({ params, resolve, reject }) }) }
|
||
}
|
||
|
||
// DOM-Attrappe für den Fokus (Muster workersPultUi.test.mjs): document mit body und activeElement, ref an select und den Blätterknöpfen liefert __fakeRef.
|
||
// Der Schlüssel ist „select“ oder die Beschriftung des Knopfs. Ein Element gilt nur, solange es im Baum steht (isConnected, wie im Browser); steht es beim
|
||
// nächsten Render nicht im Baum der Runde davor (Seitenzeile während des Ladens), ist es danach ein neues Element, so wie React die Knöpfe neu einhängt.
|
||
// focus() zählt nur für ein eingehängtes Element, merkt sich Schlüssel und Argument und setzt activeElement (Fokus liegt dann auf dem Element).
|
||
function fakeFocus() {
|
||
const body = { name: 'body' }
|
||
const doc = { body, documentElement: { name: 'html' }, activeElement: body }
|
||
const f = { doc, body, focused: [], options: [], tree: () => null, view: null, made: new Map() }
|
||
const keyOf = (tag, props) => (tag === 'button' ? String(props.children) : tag)
|
||
const live = (tag, key) => nodes(f.tree(), (n) => n.tag === tag && keyOf(tag, n.props) === key).length > 0
|
||
const had = Object.hasOwn(globalThis, 'document')
|
||
const saved = globalThis.document
|
||
globalThis.document = doc
|
||
globalThis.__fakeRef = (tag, props) => {
|
||
const key = keyOf(tag, props)
|
||
const known = f.made.get(key)
|
||
if (!known || !live(tag, key)) { // während dieser Runde zeigt f.tree() noch die Runde davor
|
||
f.made.set(key, {
|
||
key, props,
|
||
get disabled() { return this.props.disabled === true },
|
||
get isConnected() { return f.made.get(key) === this && live(tag, key) },
|
||
focus(options) { if (this.isConnected) { f.focused.push(key); f.options.push(options); doc.activeElement = this } },
|
||
})
|
||
}
|
||
f.made.get(key).props = props
|
||
return f.made.get(key)
|
||
}
|
||
// ein Element, das der Nutzer von Hand ansteuert (Suchfeld, Link …): eingehängt, bedienbar
|
||
f.other = (name) => ({ name, isConnected: true, disabled: false, focus() { doc.activeElement = this } })
|
||
f.attach = (view) => { f.view = view; f.tree = () => view.tree() }
|
||
// Der Nutzer drückt einen Knopf: der Browser gibt ihm den Fokus, dann läuft der Klick
|
||
// (Knöpfe ohne ref, z. B. „Erneut versuchen“, sind nur so lange Element, wie ein Knopf dieser Beschriftung im Baum steht)
|
||
f.press = async (label) => {
|
||
doc.activeElement = f.made.get(label) ?? { name: label, disabled: false, get isConnected() { return buttons(f.tree(), label).length > 0 }, focus() { doc.activeElement = this } }
|
||
await f.view.click(label)
|
||
}
|
||
f.restore = () => {
|
||
if (had) globalThis.document = saved
|
||
else delete globalThis.document
|
||
delete globalThis.__fakeRef
|
||
}
|
||
return f
|
||
}
|
||
|
||
const ITEM = {
|
||
id: 'p1', room: 'handwerk', roomLabel: 'Handwerk', name: 'Elektro Beispiel GmbH', postalCode: '67655', city: 'Kaiserslautern', score: 82,
|
||
reason: 'keine veröffentlichte Mail', sourceUrl: 'https://beispiel-elektro.de/impressum', phone: '0631 111', phoneSource: 'https://beispiel-elektro.de/impressum',
|
||
isHealth: false, foundAt: '2026-10-19T20:00:00Z', gekuerzt: false,
|
||
}
|
||
const PRAXIS = { ...ITEM, id: 'p2', room: 'aerzte', roomLabel: 'Ärzte & Praxen', name: 'Praxis Dr. Muster', isHealth: true, phone: '', phoneSource: '', sourceUrl: 'javascript:alert(1)', foundAt: '2026-10-18T20:00:00Z' }
|
||
const KUERZ = { ...ITEM, id: 'p3', name: '[gekürzt]', gekuerzt: true, phone: '', phoneSource: '', sourceUrl: '' }
|
||
|
||
// Ein Lauf: Komponente einhängen, window.setTimeout von Hand, Props zwischendurch änderbar (rerender)
|
||
async function start(api, initial = {}) {
|
||
const { __mount, jsx, BotLeadList } = await loadUi()
|
||
const timers = new Map()
|
||
let nextTimer = 1
|
||
const saved = globalThis.window
|
||
globalThis.window = {
|
||
setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id },
|
||
clearTimeout: (id) => { timers.delete(id) },
|
||
}
|
||
let props = { api, ...initial }
|
||
const mounted = __mount(jsx(() => jsx(BotLeadList, props), {}))
|
||
const view = {
|
||
timers,
|
||
tree: () => mounted.tree(),
|
||
text: () => textOf(view.tree()),
|
||
setProps(next) { props = { ...props, ...next }; mounted.rerender() },
|
||
rows: () => nodes(view.tree(), (n) => n.tag === 'tr').filter((tr) => nodes(tr, (n) => n.tag === 'td').length > 0),
|
||
cells: (tr) => nodes(tr, (n) => n.tag === 'td').map((td) => textOf(td).trim()),
|
||
select: () => nodes(view.tree(), (n) => n.tag === 'select')[0],
|
||
search: () => nodes(view.tree(), (n) => n.tag === 'input')[0],
|
||
errors: () => nodes(view.tree(), (n) => hasClass(n, 'msg') && hasClass(n, 'err')).map((n) => ({ text: textOf(n), role: n.props.role })),
|
||
async pick(room) { view.select().props.onChange({ target: { value: room } }); await settle() },
|
||
async type(value) { view.search().props.onChange({ target: { value } }); await settle() },
|
||
async click(label) {
|
||
const found = buttons(view.tree(), label)
|
||
assert.equal(found.length, 1, `Knopf „${label}“ (gefunden: ${found.length})`)
|
||
if (!found[0].props.disabled) found[0].props.onClick({ preventDefault() {} })
|
||
await settle()
|
||
},
|
||
disabled: (label) => buttons(view.tree(), label).map((n) => n.props.disabled === true),
|
||
fireTimers() { for (const [id, t] of [...timers]) { timers.delete(id); t.fn() } },
|
||
// der Dienst antwortet auf den n-ten Aufruf
|
||
async answer(n, data) { api.calls[n].resolve(data); await settle() },
|
||
async fail(n, err) { api.calls[n].reject(err); await settle() },
|
||
lateUpdates: () => mounted.lateUpdates(),
|
||
end() { mounted.unmount(); globalThis.window = saved },
|
||
}
|
||
await settle()
|
||
return view
|
||
}
|
||
|
||
test('lädt beim Start (Raum alle, keine Suche, Seite 1), zeigt zuerst „Wird geladen“, dann die Zeilen', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api)
|
||
try {
|
||
assert.deepEqual(api.calls.map((c) => c.params), [{ room: '', q: '', page: 1 }])
|
||
assert.ok(ui.text().includes('Reine Liste: keine Anrufe, kein Mailversand.'))
|
||
assert.ok(ui.text().includes('Wird geladen'))
|
||
assert.equal(ui.rows().length, 0)
|
||
await ui.answer(0, { items: [ITEM, PRAXIS], total: 2, pageSize: 50 })
|
||
assert.ok(!ui.text().includes('Wird geladen'))
|
||
assert.deepEqual(nodes(ui.tree(), (n) => n.tag === 'th').map((n) => textOf(n)), ['Betrieb', 'Raum', 'Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden'])
|
||
const rows = ui.rows()
|
||
assert.equal(rows.length, 2)
|
||
assert.deepEqual(ui.cells(rows[0]), ['Elektro Beispiel GmbH67655 Kaiserslautern', 'Handwerk', '82 Punkte', 'keine veröffentlichte Mail', '0631 111', 'Quelle öffnen', '19.10.2026'])
|
||
// Heilberuf: nie eine Nummer, unsichere Quelle ist kein Link
|
||
assert.deepEqual(ui.cells(rows[1]).slice(4), ['bewusst nicht gespeichert', '–', '18.10.2026'])
|
||
assert.equal(rows[0].props['aria-current'], undefined)
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('Quelle: Link nur über die geprüfte Adresse, neuer Tab ohne Rückbezug; kein Anruf-Link', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api)
|
||
try {
|
||
await ui.answer(0, { items: [ITEM, PRAXIS, { ...ITEM, id: 'p4', sourceUrl: 'https://user:pw@evil.example/' }], total: 3 })
|
||
const links = nodes(ui.tree(), (n) => n.tag === 'a')
|
||
assert.equal(links.length, 1)
|
||
assert.deepEqual([links[0].props.href, links[0].props.target, links[0].props.rel], ['https://beispiel-elektro.de/impressum', '_blank', 'noopener noreferrer'])
|
||
assert.equal(nodes(ui.tree(), (n) => n.props && typeof n.props.href === 'string' && n.props.href.startsWith('tel:')).length, 0)
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('gekürzte Einträge sind grau und tragen „gekürzt“', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api)
|
||
try {
|
||
await ui.answer(0, { items: [ITEM, KUERZ], total: 2 })
|
||
const [normal, cut] = ui.rows()
|
||
assert.equal(hasClass(normal, 'wk-bl-cut'), false)
|
||
assert.equal(hasClass(cut, 'wk-bl-cut'), true)
|
||
assert.ok(textOf(cut).includes('gekürzt'))
|
||
assert.ok(!textOf(normal).includes('gekürzt'))
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('Raumfilter: aus ROOM_FILTERS, neue Anfrage mit Seite 1; nur die neueste Anfrage zählt', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api)
|
||
try {
|
||
assert.deepEqual(nodes(ui.select(), (n) => n.tag === 'option').map((o) => [o.props.value, textOf(o)]), [['', 'Alle Räume'], ['aerzte', 'Ärzte & Praxen'], ['handwerk', 'Handwerk'], ['selbst', 'Selbstständige'], ['laden', 'Läden & Gastro']])
|
||
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||
await ui.click('Weiter')
|
||
assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 })
|
||
await ui.answer(1, { items: [PRAXIS], total: 120, pageSize: 50 })
|
||
// Raum wechseln: zurück auf Seite 1, genau eine neue Anfrage
|
||
await ui.pick('handwerk')
|
||
assert.equal(api.calls.length, 3)
|
||
assert.deepEqual(api.calls[2].params, { room: 'handwerk', q: '', page: 1 })
|
||
assert.equal(ui.select().props.value, 'handwerk')
|
||
// noch einmal gewechselt, bevor die erste Antwort kommt; die späte Antwort der älteren Anfrage darf nichts überschreiben
|
||
await ui.pick('laden')
|
||
assert.deepEqual(api.calls[3].params, { room: 'laden', q: '', page: 1 })
|
||
await ui.answer(2, { items: [ITEM], total: 1 })
|
||
assert.equal(ui.rows().length, 0) // „laden“ lädt noch, der alte Raum zeigt nichts
|
||
assert.ok(ui.text().includes('Wird geladen'))
|
||
await ui.answer(3, { items: [PRAXIS], total: 1 })
|
||
assert.deepEqual(ui.rows().map((r) => ui.cells(r)[0]), ['Praxis Dr. Muster67655 Kaiserslautern'])
|
||
// derselbe Raum noch einmal gewählt: keine neue Anfrage
|
||
await ui.pick('laden')
|
||
assert.equal(api.calls.length, 4)
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('Suche: 300 ms Verzögerung, nur die letzte Eingabe wird abgefragt, Seite springt auf 1', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api)
|
||
try {
|
||
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||
await ui.click('Weiter')
|
||
await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 })
|
||
assert.equal(api.calls.length, 2)
|
||
for (const value of ['b', 'bä', ' bäckerei ']) await ui.type(value)
|
||
assert.equal(ui.search().props.value, ' bäckerei ') // das Feld zeigt sofort, was getippt wurde
|
||
assert.equal(api.calls.length, 2) // noch keine Anfrage
|
||
assert.deepEqual([...ui.timers.values()].map((t) => t.ms), [300]) // nur der letzte Zeitgeber lebt
|
||
ui.fireTimers(); await settle()
|
||
assert.equal(api.calls.length, 3)
|
||
assert.deepEqual(api.calls[2].params, { room: '', q: 'bäckerei', page: 1 }) // getrimmt, Seite 1
|
||
// derselbe Suchbegriff noch einmal: keine neue Anfrage
|
||
await ui.type('bäckerei'); ui.fireTimers(); await settle()
|
||
assert.equal(api.calls.length, 3)
|
||
assert.equal(ui.search().props.maxLength, 100)
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('Fehler: der Satz aus der API steht als Meldung, keine Liste; der nächste Erfolg räumt ihn weg', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api)
|
||
try {
|
||
await ui.fail(0, Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 }))
|
||
assert.deepEqual(ui.errors(), [{ text: 'Der Hustler-Dienst ist gerade nicht erreichbar.', role: 'alert' }])
|
||
assert.equal(ui.rows().length, 0)
|
||
assert.ok(!ui.text().includes('Noch keine Bot-Leads.'))
|
||
await ui.pick('laden')
|
||
await ui.answer(1, { items: [ITEM], total: 1 })
|
||
assert.deepEqual(ui.errors(), [])
|
||
assert.equal(ui.rows().length, 1)
|
||
// ein Fehler ohne Satz zeigt trotzdem einen deutschen Text
|
||
await ui.pick('selbst')
|
||
await ui.fail(2, {})
|
||
assert.deepEqual(ui.errors().map((e) => e.text), ['Unbekannter Fehler'])
|
||
// ein später Fehler einer älteren Anfrage zählt nicht
|
||
await ui.pick('aerzte'); await ui.pick('handwerk')
|
||
await ui.fail(3, new Error('zu spät'))
|
||
assert.deepEqual(ui.errors(), [])
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('Leerzustände: ohne Filter „Noch keine Bot-Leads.“, mit Filter oder Suche „Keine Treffer.“', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api)
|
||
try {
|
||
await ui.answer(0, { items: [], total: 0 })
|
||
assert.ok(ui.text().includes('Noch keine Bot-Leads.'))
|
||
assert.equal(nodes(ui.tree(), (n) => n.tag === 'table').length, 0)
|
||
assert.ok(!ui.text().includes('Keine Einträge')) // keine Seitenzeile
|
||
await ui.pick('laden')
|
||
await ui.answer(1, { items: [], total: 0 })
|
||
assert.ok(ui.text().includes('Keine Treffer.'))
|
||
assert.ok(!ui.text().includes('Noch keine Bot-Leads.'))
|
||
await ui.pick('')
|
||
await ui.type('xyz'); ui.fireTimers(); await settle()
|
||
await ui.answer(3, { items: [], total: 0 })
|
||
assert.ok(ui.text().includes('Keine Treffer.'))
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('Blättern: Seitenzeile, Zurück und Weiter, pageSize aus der Antwort', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api)
|
||
try {
|
||
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||
assert.ok(ui.text().includes('Seite 1 von 3 · 120 Einträge'))
|
||
assert.deepEqual([ui.disabled('Zurück'), ui.disabled('Weiter')], [[true], [false]])
|
||
await ui.click('Zurück') // gesperrt: nichts passiert
|
||
assert.equal(api.calls.length, 1)
|
||
await ui.click('Weiter')
|
||
await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 })
|
||
assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 })
|
||
assert.ok(ui.text().includes('Seite 2 von 3 · 120 Einträge'))
|
||
await ui.click('Weiter')
|
||
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 })
|
||
assert.deepEqual([ui.disabled('Zurück'), ui.disabled('Weiter')], [[false], [true]])
|
||
await ui.click('Zurück')
|
||
assert.deepEqual(api.calls[3].params, { room: '', q: '', page: 2 })
|
||
// während einer Anfrage ist die Seitenzeile weg: kein zweiter Klick auf eine veraltete Seitenzahl
|
||
assert.equal(buttons(ui.tree(), 'Weiter').length, 0)
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('Blättern: eine Seite genügt ⇒ nur die Zahl, kein Blättern; Einzahl bei einem Eintrag', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api)
|
||
try {
|
||
await ui.answer(0, { items: [ITEM], total: 1, pageSize: 50 })
|
||
assert.ok(ui.text().includes('Seite 1 von 1 · 1 Eintrag'))
|
||
assert.equal(buttons(ui.tree(), 'Weiter').length + buttons(ui.tree(), 'Zurück').length, 0)
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('Blättern: ist die Seite nach einem Kürzen leer, springt die Liste auf die letzte Seite', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api)
|
||
try {
|
||
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||
await ui.click('Weiter'); await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 })
|
||
await ui.click('Weiter')
|
||
assert.deepEqual(api.calls[2].params, { room: '', q: '', page: 3 })
|
||
await ui.answer(2, { items: [], total: 60, pageSize: 50 }) // inzwischen nur noch zwei Seiten
|
||
assert.deepEqual(api.calls[3].params, { room: '', q: '', page: 2 })
|
||
assert.ok(ui.text().includes('Wird geladen'))
|
||
await ui.answer(3, { items: [ITEM], total: 60, pageSize: 50 })
|
||
assert.ok(ui.text().includes('Seite 2 von 2 · 60 Einträge'))
|
||
assert.equal(api.calls.length, 4)
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('bot: ruft die API mit id ab, hebt die Zeile hervor und bietet „Alle anzeigen“ statt der Filter an', async () => {
|
||
const api = fakeApi()
|
||
let cleared = 0
|
||
const ui = await start(api, { bot: 'p1', onClearBot: () => { cleared += 1 } })
|
||
try {
|
||
assert.deepEqual(api.calls.map((c) => c.params), [{ id: 'p1' }])
|
||
assert.equal(nodes(ui.tree(), (n) => n.tag === 'select' || n.tag === 'input').length, 0) // Filter und Suche wirken auf einen Einzeleintrag nicht
|
||
await ui.answer(0, { items: [ITEM], total: 1, pageSize: 50 })
|
||
const rows = ui.rows()
|
||
assert.equal(rows.length, 1)
|
||
assert.equal(rows[0].props['aria-current'], 'true')
|
||
assert.equal(buttons(ui.tree(), 'Weiter').length, 0) // keine Seitenzeile bei einem Einzeleintrag
|
||
await ui.click('Alle anzeigen')
|
||
assert.equal(cleared, 1)
|
||
// die Seite nimmt bot aus der Adresse: die Liste lädt wieder alle (Filter und Suche zurück)
|
||
ui.setProps({ bot: '' })
|
||
await settle()
|
||
assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 1 })
|
||
assert.equal(ui.select().props.value, '')
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('bot: ein anderer Eintrag über die Adresse lädt neu; nur die neueste Anfrage zählt; nicht gefunden ⇒ Hinweis mit „Alle anzeigen“', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api, { bot: 'p1', onClearBot: () => {} })
|
||
try {
|
||
ui.setProps({ bot: 'p2' }); await settle()
|
||
assert.deepEqual(api.calls.map((c) => c.params), [{ id: 'p1' }, { id: 'p2' }])
|
||
await ui.answer(0, { items: [ITEM], total: 1 }) // späte Antwort für p1
|
||
assert.equal(ui.rows().length, 0)
|
||
await ui.answer(1, { items: [], total: 0 })
|
||
assert.ok(ui.text().includes('Dieser Eintrag ist nicht (mehr) in der Liste.'))
|
||
assert.ok(!ui.text().includes('Noch keine Bot-Leads.'))
|
||
assert.equal(buttons(ui.tree(), 'Alle anzeigen').length, 1)
|
||
// ein Fehler (ungültige Kennung) lässt den Weg zurück offen
|
||
ui.setProps({ bot: 'x y' }); await settle()
|
||
await ui.fail(2, Object.assign(new Error('Ungültige Kennung.'), { status: 400 }))
|
||
assert.deepEqual(ui.errors().map((e) => e.text), ['Ungültige Kennung.'])
|
||
assert.equal(buttons(ui.tree(), 'Alle anzeigen').length, 1)
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('nach dem Abbau setzt eine späte Antwort nichts mehr, ein offener Zeitgeber der Suche wird gelöscht', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api)
|
||
await ui.type('abc')
|
||
assert.equal(ui.timers.size, 1)
|
||
ui.end()
|
||
assert.equal(ui.timers.size, 0)
|
||
api.calls[0].resolve({ items: [ITEM], total: 1 })
|
||
await settle()
|
||
assert.equal(ui.lateUpdates(), 0)
|
||
})
|
||
|
||
test('Blättern (C5): nach dem Laden der neuen Seite bekommt die Seitenzeile den Tastaturfokus zurück, ohne dass die Seite scrollt (N5); ist der Knopf gesperrt, der andere', async () => {
|
||
const api = fakeApi()
|
||
const dom = fakeFocus()
|
||
const ui = await start(api)
|
||
dom.attach(ui)
|
||
try {
|
||
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||
assert.deepEqual(dom.focused, []) // nicht schon beim ersten Laden
|
||
await dom.press('Weiter')
|
||
assert.deepEqual(dom.focused, []) // während der Anfrage ist die Seitenzeile weg
|
||
assert.equal(dom.doc.activeElement.isConnected, false) // und mit ihr der Knopf, der den Fokus hatte: der Fokus ist verloren
|
||
await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 2 von 3
|
||
assert.deepEqual(dom.focused, ['Weiter'])
|
||
await dom.press('Weiter')
|
||
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 3 von 3: „Weiter“ ist gesperrt ⇒ „Zurück“
|
||
assert.deepEqual(dom.focused, ['Weiter', 'Zurück'])
|
||
await dom.press('Zurück')
|
||
await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 2
|
||
assert.deepEqual(dom.focused, ['Weiter', 'Zurück', 'Zurück'])
|
||
await dom.press('Zurück')
|
||
await ui.answer(4, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 1: „Zurück“ ist gesperrt ⇒ „Weiter“
|
||
assert.deepEqual(dom.focused, ['Weiter', 'Zurück', 'Zurück', 'Weiter'])
|
||
// N5: ohne { preventScroll: true } sprängen Chromium und Firefox ans Ende der neuen Seite (der Knopf steht unter der Tabelle)
|
||
assert.deepEqual(dom.options, [{ preventScroll: true }, { preventScroll: true }, { preventScroll: true }, { preventScroll: true }])
|
||
} finally { dom.restore(); ui.end() }
|
||
})
|
||
|
||
test('Blättern (N5): hat der Nutzer den Fokus inzwischen selbst woandershin gesetzt, wird er nicht gestohlen; liegt er auf body oder nirgends, kommt er zurück', async () => {
|
||
const api = fakeApi()
|
||
const dom = fakeFocus()
|
||
const ui = await start(api)
|
||
dom.attach(ui)
|
||
try {
|
||
await ui.answer(0, { items: [ITEM], total: 400, pageSize: 50 })
|
||
// 1. Der Nutzer klickt während des Ladens ins Suchfeld (Fokus dort, aber die Zeitgeber der Suche laufen noch nicht)
|
||
await dom.press('Weiter')
|
||
const search = dom.other('Suchfeld')
|
||
search.focus()
|
||
await ui.answer(1, { items: [ITEM], total: 400, pageSize: 50 })
|
||
assert.deepEqual(dom.focused, [])
|
||
assert.equal(dom.doc.activeElement, search)
|
||
// 2. … oder er wählt den Raumfilter mit der Tastatur an, ohne etwas zu ändern
|
||
await dom.press('Weiter')
|
||
dom.made.get('select').focus()
|
||
assert.deepEqual(dom.focused, ['select'])
|
||
await ui.answer(2, { items: [ITEM], total: 400, pageSize: 50 })
|
||
assert.deepEqual(dom.focused, ['select']) // kein weiterer Fokus auf die Seitenzeile
|
||
assert.equal(dom.doc.activeElement.key, 'select')
|
||
// 3. Fokus auf body: zurück an die Seitenzeile
|
||
dom.focused.length = 0; dom.options.length = 0
|
||
await dom.press('Weiter')
|
||
dom.doc.activeElement = dom.body
|
||
await ui.answer(3, { items: [ITEM], total: 400, pageSize: 50 })
|
||
assert.deepEqual(dom.focused, ['Weiter'])
|
||
assert.deepEqual(dom.options, [{ preventScroll: true }])
|
||
// 4. Fokus auf nichts (activeElement null, etwa ein Dokument ohne Fokus): ebenfalls zurück
|
||
await dom.press('Weiter')
|
||
dom.doc.activeElement = null
|
||
await ui.answer(4, { items: [ITEM], total: 400, pageSize: 50 })
|
||
assert.deepEqual(dom.focused, ['Weiter', 'Weiter'])
|
||
// 5. ohne document (Server, Node): nichts geschieht, nichts bricht
|
||
delete globalThis.document
|
||
await ui.click('Weiter')
|
||
await ui.answer(5, { items: [ITEM], total: 400, pageSize: 50 })
|
||
assert.deepEqual(dom.focused, ['Weiter', 'Weiter'])
|
||
} finally { dom.restore(); ui.end() }
|
||
})
|
||
|
||
test('Blättern (C5): der Fokus springt nicht, wenn der Nutzer inzwischen Raum oder Suche bedient hat oder die Anfrage scheitert', async () => {
|
||
const api = fakeApi()
|
||
const dom = fakeFocus()
|
||
const ui = await start(api)
|
||
dom.attach(ui)
|
||
try {
|
||
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||
await ui.click('Weiter')
|
||
await ui.pick('handwerk') // während die Seite lädt, wechselt der Raum
|
||
await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) // späte Antwort der älteren Anfrage
|
||
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 })
|
||
assert.deepEqual(dom.focused, [])
|
||
await ui.click('Weiter')
|
||
await ui.type('bäckerei') // die Suche wird getippt, bevor die Seite da ist
|
||
await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 })
|
||
assert.deepEqual(dom.focused, [])
|
||
// Raum und Suche selbst verschieben den Fokus nie
|
||
ui.fireTimers(); await settle()
|
||
await ui.answer(4, { items: [ITEM], total: 120, pageSize: 50 })
|
||
assert.deepEqual(dom.focused, [])
|
||
// scheitert die Anfrage, gibt es keine Seitenzeile zum Fokussieren; die Merkung bleibt nicht für später hängen
|
||
await ui.click('Weiter')
|
||
await ui.fail(5, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'))
|
||
assert.deepEqual(dom.focused, [])
|
||
await ui.pick('laden')
|
||
await ui.answer(6, { items: [ITEM], total: 120, pageSize: 50 })
|
||
assert.deepEqual(dom.focused, [])
|
||
} finally { dom.restore(); ui.end() }
|
||
})
|
||
|
||
test('Fehler beim Blättern (N6): „Erneut versuchen“ wiederholt genau die Seitenanfrage; Fehler und Knopf verschwinden, die Seitenzeile ist wieder da', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api)
|
||
try {
|
||
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||
await ui.click('Weiter')
|
||
assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 })
|
||
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0) // beim Laden gibt es keinen Knopf
|
||
await ui.fail(1, Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 }))
|
||
assert.deepEqual(ui.errors(), [{ text: 'Der Hustler-Dienst ist gerade nicht erreichbar.', role: 'alert' }]) // der Satz bleibt unverändert im Alarm
|
||
const retry = buttons(ui.tree(), 'Erneut versuchen')
|
||
assert.equal(retry.length, 1)
|
||
assert.equal(retry[0].props.type, 'button')
|
||
assert.equal(retry[0].props.disabled, undefined)
|
||
assert.equal(api.calls.length, 2)
|
||
await ui.click('Erneut versuchen')
|
||
assert.equal(api.calls.length, 3) // genau eine neue Anfrage, dieselbe Seite
|
||
assert.deepEqual(api.calls[2].params, { room: '', q: '', page: 2 })
|
||
assert.ok(ui.text().includes('Wird geladen'))
|
||
assert.deepEqual(ui.errors(), [])
|
||
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0)
|
||
await ui.answer(2, { items: [PRAXIS], total: 120, pageSize: 50 })
|
||
assert.equal(api.calls.length, 3)
|
||
assert.deepEqual(ui.errors(), [])
|
||
assert.deepEqual(ui.rows().map((r) => ui.cells(r)[0]), ['Praxis Dr. Muster67655 Kaiserslautern'])
|
||
assert.ok(ui.text().includes('Seite 2 von 3 · 120 Einträge'))
|
||
assert.deepEqual([ui.disabled('Zurück'), ui.disabled('Weiter')], [[false], [false]])
|
||
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0)
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('Fehler beim Blättern (N6): scheitert auch der zweite Versuch, bleibt der Knopf; Raumfilter und Suche gehen weiterhin, mit Seite 1', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api)
|
||
try {
|
||
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||
await ui.click('Weiter')
|
||
await ui.fail(1, new Error('zu langsam'))
|
||
await ui.click('Erneut versuchen')
|
||
await ui.fail(2, new Error('immer noch nicht'))
|
||
assert.deepEqual(ui.errors().map((e) => e.text), ['immer noch nicht'])
|
||
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 1)
|
||
await ui.click('Erneut versuchen')
|
||
assert.deepEqual(api.calls[3].params, { room: '', q: '', page: 2 })
|
||
// statt eines weiteren Versuchs ändert der Nutzer den Raum: neue Anfrage mit Seite 1, die späte Antwort des Versuchs zählt nicht
|
||
await ui.pick('handwerk')
|
||
assert.deepEqual(api.calls[4].params, { room: 'handwerk', q: '', page: 1 })
|
||
await ui.answer(3, { items: [PRAXIS], total: 120, pageSize: 50 })
|
||
assert.equal(ui.rows().length, 0)
|
||
await ui.answer(4, { items: [ITEM], total: 1, pageSize: 50 })
|
||
assert.deepEqual(ui.errors(), [])
|
||
assert.deepEqual(ui.rows().map((r) => ui.cells(r)[0]), ['Elektro Beispiel GmbH67655 Kaiserslautern'])
|
||
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0)
|
||
} finally { ui.end() }
|
||
})
|
||
|
||
test('Fehler beim Blättern (N6): „Erneut versuchen“ gibt es auch beim ersten Laden und in der Einzelansicht und wiederholt dort die jeweilige Anfrage', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api)
|
||
try {
|
||
await ui.fail(0, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'))
|
||
await ui.click('Erneut versuchen')
|
||
assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 1 })
|
||
await ui.answer(1, { items: [ITEM], total: 1, pageSize: 50 })
|
||
assert.equal(ui.rows().length, 1)
|
||
} finally { ui.end() }
|
||
const api2 = fakeApi()
|
||
const one = await start(api2, { bot: 'p1', onClearBot: () => {} })
|
||
try {
|
||
await one.fail(0, Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 }))
|
||
assert.equal(buttons(one.tree(), 'Alle anzeigen').length, 1) // der Weg zurück bleibt offen
|
||
await one.click('Erneut versuchen')
|
||
assert.deepEqual(api2.calls.map((c) => c.params), [{ id: 'p1' }, { id: 'p1' }])
|
||
await one.answer(1, { items: [ITEM], total: 1 })
|
||
assert.equal(one.rows().length, 1)
|
||
assert.equal(one.rows()[0].props['aria-current'], 'true')
|
||
} finally { one.end() }
|
||
})
|
||
|
||
test('Fehler beim Blättern (N6): nach einem gelungenen Wiederholen bekommt die Seitenzeile den Fokus zurück (ohne Scrollen), wenn zuvor geblättert wurde', async () => {
|
||
const api = fakeApi()
|
||
const dom = fakeFocus()
|
||
const ui = await start(api)
|
||
dom.attach(ui)
|
||
try {
|
||
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||
await dom.press('Weiter')
|
||
await ui.fail(1, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'))
|
||
assert.deepEqual(dom.focused, []) // keine Seitenzeile, nichts zu fokussieren
|
||
await dom.press('Erneut versuchen') // der Knopf verschwindet beim Laden wie vorher die Seitenzeile
|
||
assert.deepEqual(dom.focused, [])
|
||
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 })
|
||
assert.deepEqual(dom.focused, ['Weiter'])
|
||
assert.deepEqual(dom.options, [{ preventScroll: true }])
|
||
} finally { dom.restore(); ui.end() }
|
||
})
|
||
|
||
test('Alle anzeigen (W1): nach dem Verlassen der Einzelansicht bekommt der Raumfilter den Fokus, nicht beim Einhängen und nicht bei einem anderen Weg zur Liste', async () => {
|
||
const api = fakeApi()
|
||
const dom = fakeFocus()
|
||
let cleared = 0
|
||
const ui = await start(api, { bot: 'p1', onClearBot: () => { cleared += 1 } })
|
||
dom.attach(ui)
|
||
try {
|
||
await ui.answer(0, { items: [ITEM], total: 1 })
|
||
assert.deepEqual(dom.focused, []) // nicht beim Einhängen (das öffnete am Handy die Tastatur oder die Auswahl)
|
||
await ui.click('Alle anzeigen')
|
||
assert.equal(cleared, 1)
|
||
assert.deepEqual(dom.focused, []) // der Knopf steht noch, bis die Seite bot aus der Adresse nimmt
|
||
ui.setProps({ bot: '' }); await settle()
|
||
assert.deepEqual(dom.focused, ['select'])
|
||
assert.equal(ui.select().props['aria-label'], 'Raum')
|
||
// ein weiterer Wechsel in die Einzelansicht und zurück ohne den Knopf (Zurück-Taste des Browsers): kein Fokussprung
|
||
ui.setProps({ bot: 'p2' }); await settle()
|
||
ui.setProps({ bot: '' }); await settle()
|
||
assert.deepEqual(dom.focused, ['select'])
|
||
} finally { dom.restore(); ui.end() }
|
||
})
|
||
|
||
test('Alle anzeigen (W1): ohne bot (Liste von Anfang an) bleibt der Fokus unberührt', async () => {
|
||
const api = fakeApi()
|
||
const dom = fakeFocus()
|
||
const ui = await start(api)
|
||
dom.attach(ui)
|
||
try {
|
||
await ui.answer(0, { items: [ITEM], total: 1 })
|
||
ui.setProps({ bot: 'p1', onClearBot: () => {} }); await settle()
|
||
ui.setProps({ bot: '' }); await settle()
|
||
assert.deepEqual(dom.focused, [])
|
||
} finally { dom.restore(); ui.end() }
|
||
})
|
||
|
||
test('aria-current (C5): nur die Zeile mit der Kennung aus bot, nie eine Zeile ohne Kennung', async () => {
|
||
const api = fakeApi()
|
||
const ui = await start(api)
|
||
try {
|
||
// Liste (bot leer): eine Zeile ohne Kennung (botleadRow macht daraus '') gilt nicht als „aktuell“
|
||
await ui.answer(0, { items: [{ ...ITEM, id: undefined }, { ...PRAXIS, id: 5 }, ITEM], total: 3 })
|
||
assert.equal(ui.rows().length, 3)
|
||
assert.deepEqual(ui.rows().map((r) => r.props['aria-current']), [undefined, undefined, undefined])
|
||
} finally { ui.end() }
|
||
const api2 = fakeApi()
|
||
const one = await start(api2, { bot: 'p1', onClearBot: () => {} })
|
||
try {
|
||
await one.answer(0, { items: [{ ...ITEM, id: '' }, ITEM, PRAXIS], total: 3 })
|
||
assert.deepEqual(one.rows().map((r) => r.props['aria-current']), [undefined, 'true', undefined])
|
||
} finally { one.end() }
|
||
})
|