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() } })