Hustler-Pixelwelt T10: Keller (Szene mit Arne nach Status, Register-Buch, Akte, Gründe)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec63e5a43d
commit
1b3fdf5d97
14 files changed
+2247
-312
No files matched your search
+143
-205
@@ -1,134 +1,22 @@
|
||||
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'
|
||||
import { loadModule } from './load.mjs'
|
||||
import {
|
||||
ITEM, KUERZ, PRAXIS, buttons, byClass, fakeApi, fakeFocus, nodes, settle, start, textOf,
|
||||
} from './helpers/kellerRunner.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))
|
||||
// BotLeadList (Hustler-Pixelwelt T10: der Keller) läuft wirklich: im Mini-Läufer anstelle von React und DOM (Muster workersPultUi.test.mjs, Baustein helpers/fakeReact.mjs,
|
||||
// gemeinsamer Läufer helpers/kellerRunner.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.
|
||||
// Die fachlichen Zusicherungen der alten Tabelle gelten unverändert (Abruf, Suche mit Verzögerung, Seiten, Fokus beim Blättern, „Erneut versuchen“, Einzelansicht, Telefonregel,
|
||||
// sichere Links, Leer- und Ladezustände). Umgestellt ist nur, wo sie stehen: Zeilen sind Knöpfe des Buchs (hk-zeile) statt Tabellenzeilen, der Raumfilter sind Reiter statt einer
|
||||
// Auswahlliste, die Blätterknöpfe heißen „Vorherige Seite“ / „Nächste Seite“ (◂ ▸), Quelle und Raum-Beschriftung stehen in der Akte. Kommentare „UMGESTELLT“ nennen jede Änderung.
|
||||
const { ROOM_FILTERS } = await loadModule('src/lib/botleadsView.js')
|
||||
|
||||
// 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
|
||||
}
|
||||
const PREV = 'Vorherige Seite'
|
||||
const NEXT = 'Nächste Seite'
|
||||
const ROOM_VALUES = ROOM_FILTERS.map((f) => f.value) // ['', 'aerzte', 'handwerk', 'selbst', 'laden']
|
||||
const idx = (room) => ROOM_VALUES.indexOf(room)
|
||||
|
||||
test('lädt beim Start (Raum alle, keine Suche, Seite 1), zeigt zuerst „Wird geladen“, dann die Zeilen', async () => {
|
||||
const api = fakeApi()
|
||||
@@ -140,24 +28,42 @@ test('lädt beim Start (Raum alle, keine Suche, Seite 1), zeigt zuerst „Wird g
|
||||
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'])
|
||||
// UMGESTELLT: statt der Tabellenköpfe (Betrieb, Raum, Punkte, Grund, Telefon, Quelle, Gefunden) die Kopfzeile des Buchs; Raum steht als Symbol in der Zeile, Quelle in der Akte
|
||||
assert.deepEqual(byClass(ui.tree(), 'hk-kopfzeile')[0].children.map(textOf), ['', 'Betrieb · Ort · gefunden', 'Punkte', 'Grund', 'Telefon'])
|
||||
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'])
|
||||
// UMGESTELLT: Ort und Funddatum in einer Zeile unter dem Namen, Punkte als Zahl neben dem Balken („82 Punkte“ steht in der Akte und im Label)
|
||||
assert.deepEqual(ui.parts(rows[0]), {
|
||||
name: 'Elektro Beispiel GmbH', sub: '67655 Kaiserslautern · 19.10.2026', score: '82', reason: 'keine veröffentlichte Mail', phone: '0631 111',
|
||||
raumIcon: 'hk-ic hk-ic-raum-handwerk', label: 'Elektro Beispiel GmbH, 67655 Kaiserslautern, 82 Punkte, keine veröffentlichte Mail, gefunden am 19.10.2026',
|
||||
})
|
||||
// Heilberuf: nie eine Nummer
|
||||
assert.equal(ui.parts(rows[1]).phone, 'bewusst nicht gespeichert')
|
||||
assert.equal(ui.parts(rows[1]).raumIcon, 'hk-ic hk-ic-raum-aerzte')
|
||||
assert.equal(ui.parts(rows[1]).sub, '67655 Kaiserslautern · 18.10.2026')
|
||||
assert.equal(rows[0].props['aria-current'], undefined)
|
||||
// Zeilen sind Knöpfe (öffnen die Akte), keine Links
|
||||
assert.ok(rows.every((r) => r.props.type === 'button'))
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Quelle: Link nur über die geprüfte Adresse, neuer Tab ohne Rückbezug; kein Anruf-Link', async () => {
|
||||
test('Quelle: Link nur über die geprüfte Adresse (jetzt in der Akte), 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')
|
||||
// UMGESTELLT: die Liste selbst hat gar keine Links mehr (die Zeilen sind Knöpfe); die Quelle öffnet die Akte
|
||||
assert.equal(nodes(ui.tree(), (n) => n.tag === 'a').length, 0)
|
||||
await ui.openRow(0)
|
||||
let 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'])
|
||||
// zweiter Eintrag: javascript:-Adresse ist kein Link; dritter: Adresse mit Zugangsdaten ebenfalls nicht
|
||||
await ui.click('Nächste Akte')
|
||||
assert.equal(nodes(ui.tree(), (n) => n.tag === 'a').length, 0)
|
||||
await ui.click('Nächste Akte')
|
||||
links = nodes(ui.tree(), (n) => n.tag === 'a')
|
||||
assert.equal(links.length, 0)
|
||||
assert.equal(nodes(ui.tree(), (n) => n.props && typeof n.props.href === 'string' && n.props.href.startsWith('tel:')).length, 0)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
@@ -168,47 +74,74 @@ test('gekürzte Einträge sind grau und tragen „gekürzt“', async () => {
|
||||
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)
|
||||
// UMGESTELLT: Klassen des Buchs (hk-zeile-alt statt wk-bl-cut)
|
||||
assert.equal(normal.props.className.split(' ').includes('hk-zeile-alt'), false)
|
||||
assert.equal(cut.props.className.split(' ').includes('hk-zeile-alt'), 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 () => {
|
||||
test('Raumfilter: Reiter 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']])
|
||||
// UMGESTELLT: fünf Reiter mit aria-pressed (statt der Optionen einer Auswahlliste), Beschriftung = ROOM_FILTERS (aria-label), kurze sichtbare Namen, ein Symbol je Reiter
|
||||
const reiter = ui.reiter()
|
||||
assert.deepEqual(reiter.map((r) => r.props['aria-label']), ROOM_FILTERS.map((f) => f.label))
|
||||
assert.deepEqual(reiter.map((r) => textOf(r)), ['Alle', 'Ärzte', 'Handwerk', 'Selbstständige', 'Läden'])
|
||||
assert.deepEqual(reiter.map((r) => r.props['aria-pressed']), [true, false, false, false, false])
|
||||
assert.ok(reiter.every((r) => r.props.type === 'button'))
|
||||
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||||
await ui.click('Weiter')
|
||||
await ui.click(NEXT)
|
||||
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')
|
||||
await ui.pick(idx('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')
|
||||
assert.equal(ui.activeIndex(), idx('handwerk')) // UMGESTELLT: der gedrückte Reiter statt select.value
|
||||
// noch einmal gewechselt, bevor die erste Antwort kommt; die späte Antwort der älteren Anfrage darf nichts überschreiben
|
||||
await ui.pick('laden')
|
||||
await ui.pick(idx('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'])
|
||||
assert.deepEqual(ui.rows().map((r) => ui.parts(r).name), ['Praxis Dr. Muster'])
|
||||
// derselbe Raum noch einmal gewählt: keine neue Anfrage
|
||||
await ui.pick('laden')
|
||||
await ui.pick(idx('laden'))
|
||||
assert.equal(api.calls.length, 4)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Raum-Reiter: die Zahl steht nur am aktiven Reiter (= total der Antwort), erst wenn die Antwort da ist', async () => {
|
||||
const api = fakeApi()
|
||||
const ui = await start(api)
|
||||
try {
|
||||
assert.deepEqual(ui.reiter().map((r) => r.props['aria-label']), ROOM_FILTERS.map((f) => f.label)) // beim Laden keine Zahl
|
||||
await ui.answer(0, { items: [ITEM], total: 1234, pageSize: 50 })
|
||||
const zahlen = ui.reiter().map((r) => byClass(r, 'hk-kl-reiter-zahl').map(textOf))
|
||||
assert.deepEqual(zahlen, [['1.234'], [], [], [], []])
|
||||
assert.equal(ui.reiter()[0].props['aria-label'], 'Alle Räume, 1.234 Einträge')
|
||||
await ui.pick(idx('aerzte'))
|
||||
assert.deepEqual(ui.reiter().map((r) => byClass(r, 'hk-kl-reiter-zahl').length), [0, 0, 0, 0, 0]) // die neue Anfrage läuft: keine Zahl
|
||||
await ui.answer(1, { items: [PRAXIS], total: 1 })
|
||||
assert.deepEqual(ui.reiter().map((r) => byClass(r, 'hk-kl-reiter-zahl').map(textOf)), [[], ['1'], [], [], []])
|
||||
assert.equal(ui.reiter()[1].props['aria-label'], 'Ärzte & Praxen, 1 Eintrag')
|
||||
// bei einem Fehler steht keine Zahl
|
||||
await ui.pick(idx('selbst'))
|
||||
await ui.fail(2, new Error('weg'))
|
||||
assert.deepEqual(ui.reiter().map((r) => byClass(r, 'hk-kl-reiter-zahl').length), [0, 0, 0, 0, 0])
|
||||
} 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.click(NEXT)
|
||||
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)
|
||||
@@ -222,6 +155,7 @@ test('Suche: 300 ms Verzögerung, nur die letzte Eingabe wird abgefragt, Seite s
|
||||
await ui.type('bäckerei'); ui.fireTimers(); await settle()
|
||||
assert.equal(api.calls.length, 3)
|
||||
assert.equal(ui.search().props.maxLength, 100)
|
||||
assert.equal(ui.search().props['aria-label'], 'Betriebe suchen')
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
@@ -233,16 +167,16 @@ test('Fehler: der Satz aus der API steht als Meldung, keine Liste; der nächste
|
||||
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.pick(idx('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.pick(idx('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.pick(idx('aerzte')); await ui.pick(idx('handwerk'))
|
||||
await ui.fail(3, new Error('zu spät'))
|
||||
assert.deepEqual(ui.errors(), [])
|
||||
} finally { ui.end() }
|
||||
@@ -254,39 +188,42 @@ test('Leerzustände: ohne Filter „Noch keine Bot-Leads.“, mit Filter oder Su
|
||||
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.equal(byClass(ui.tree(), 'hk-liste').length, 0) // UMGESTELLT: keine Liste statt keine Tabelle
|
||||
assert.ok(!ui.text().includes('Keine Einträge')) // keine Seitenzeile
|
||||
await ui.pick('laden')
|
||||
await ui.pick(idx('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.pick(idx(''))
|
||||
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 () => {
|
||||
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.deepEqual([ui.disabled(PREV), ui.disabled(NEXT)], [[true], [false]])
|
||||
await ui.click(PREV) // gesperrt: nichts passiert
|
||||
assert.equal(api.calls.length, 1)
|
||||
await ui.click('Weiter')
|
||||
await ui.click(NEXT)
|
||||
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.click(NEXT)
|
||||
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([ui.disabled(PREV), ui.disabled(NEXT)], [[false], [true]])
|
||||
await ui.click(PREV)
|
||||
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)
|
||||
assert.equal(buttons(ui.tree(), NEXT).length, 0)
|
||||
// die Knöpfe sind echte Knöpfe mit Beschriftung (nur das Symbol ist zu sehen)
|
||||
await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 })
|
||||
assert.ok(buttons(ui.tree(), NEXT).every((b) => b.props.type === 'button' && b.props['aria-label'] === NEXT))
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
@@ -296,7 +233,7 @@ test('Blättern: eine Seite genügt ⇒ nur die Zahl, kein Blättern; Einzahl be
|
||||
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)
|
||||
assert.equal(buttons(ui.tree(), NEXT).length + buttons(ui.tree(), PREV).length, 0)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
@@ -305,8 +242,8 @@ test('Blättern: ist die Seite nach einem Kürzen leer, springt die Liste auf di
|
||||
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')
|
||||
await ui.click(NEXT); await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 })
|
||||
await ui.click(NEXT)
|
||||
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 })
|
||||
@@ -323,19 +260,20 @@ test('bot: ruft die API mit id ab, hebt die Zeile hervor und bietet „Alle anze
|
||||
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
|
||||
assert.equal(nodes(ui.tree(), (n) => n.tag === 'input' || n.props['aria-pressed'] !== undefined).length, 0) // Filter (Reiter) und Suche wirken auf einen Einzeleintrag nicht
|
||||
assert.ok(ui.text().includes('Reine Liste: keine Anrufe, kein Mailversand.'))
|
||||
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
|
||||
assert.equal(buttons(ui.tree(), NEXT).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, '')
|
||||
assert.equal(ui.activeIndex(), idx('')) // UMGESTELLT: „Alle Räume“ ist der gedrückte Reiter statt select.value ''
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
@@ -379,21 +317,21 @@ test('Blättern (C5): nach dem Laden der neuen Seite bekommt die Seitenzeile den
|
||||
try {
|
||||
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||||
assert.deepEqual(dom.focused, []) // nicht schon beim ersten Laden
|
||||
await dom.press('Weiter')
|
||||
await dom.press(NEXT)
|
||||
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')
|
||||
assert.deepEqual(dom.focused, [NEXT])
|
||||
await dom.press(NEXT)
|
||||
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 3 von 3: „Nächste Seite“ ist gesperrt ⇒ „Vorherige Seite“
|
||||
assert.deepEqual(dom.focused, [NEXT, PREV])
|
||||
await dom.press(PREV)
|
||||
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.focused, [NEXT, PREV, PREV])
|
||||
await dom.press(PREV)
|
||||
await ui.answer(4, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 1: „Vorherige Seite“ ist gesperrt ⇒ „Nächste Seite“
|
||||
assert.deepEqual(dom.focused, [NEXT, PREV, PREV, NEXT])
|
||||
// N5: ohne { preventScroll: true } sprängen Chromium und Firefox ans Ende der neuen Seite
|
||||
assert.deepEqual(dom.options, [{ preventScroll: true }, { preventScroll: true }, { preventScroll: true }, { preventScroll: true }])
|
||||
} finally { dom.restore(); ui.end() }
|
||||
})
|
||||
@@ -406,36 +344,36 @@ test('Blättern (N5): hat der Nutzer den Fokus inzwischen selbst woandershin ges
|
||||
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')
|
||||
await dom.press(NEXT)
|
||||
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'])
|
||||
// 2. … oder er wählt den Raum-Reiter mit der Tastatur an, ohne etwas zu ändern (UMGESTELLT: Reiter statt Auswahlliste)
|
||||
await dom.press(NEXT)
|
||||
dom.made.get('reiter').focus()
|
||||
assert.deepEqual(dom.focused, ['reiter'])
|
||||
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')
|
||||
assert.deepEqual(dom.focused, ['reiter']) // kein weiterer Fokus auf die Seitenzeile
|
||||
assert.equal(dom.doc.activeElement.key, 'reiter')
|
||||
// 3. Fokus auf body: zurück an die Seitenzeile
|
||||
dom.focused.length = 0; dom.options.length = 0
|
||||
await dom.press('Weiter')
|
||||
await dom.press(NEXT)
|
||||
dom.doc.activeElement = dom.body
|
||||
await ui.answer(3, { items: [ITEM], total: 400, pageSize: 50 })
|
||||
assert.deepEqual(dom.focused, ['Weiter'])
|
||||
assert.deepEqual(dom.focused, [NEXT])
|
||||
assert.deepEqual(dom.options, [{ preventScroll: true }])
|
||||
// 4. Fokus auf nichts (activeElement null, etwa ein Dokument ohne Fokus): ebenfalls zurück
|
||||
await dom.press('Weiter')
|
||||
await dom.press(NEXT)
|
||||
dom.doc.activeElement = null
|
||||
await ui.answer(4, { items: [ITEM], total: 400, pageSize: 50 })
|
||||
assert.deepEqual(dom.focused, ['Weiter', 'Weiter'])
|
||||
assert.deepEqual(dom.focused, [NEXT, NEXT])
|
||||
// 5. ohne document (Server, Node): nichts geschieht, nichts bricht
|
||||
delete globalThis.document
|
||||
await ui.click('Weiter')
|
||||
await ui.click(NEXT)
|
||||
await ui.answer(5, { items: [ITEM], total: 400, pageSize: 50 })
|
||||
assert.deepEqual(dom.focused, ['Weiter', 'Weiter'])
|
||||
assert.deepEqual(dom.focused, [NEXT, NEXT])
|
||||
} finally { dom.restore(); ui.end() }
|
||||
})
|
||||
|
||||
@@ -446,12 +384,12 @@ test('Blättern (C5): der Fokus springt nicht, wenn der Nutzer inzwischen Raum o
|
||||
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.click(NEXT)
|
||||
await ui.pick(idx('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.click(NEXT)
|
||||
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, [])
|
||||
@@ -460,10 +398,10 @@ test('Blättern (C5): der Fokus springt nicht, wenn der Nutzer inzwischen Raum o
|
||||
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.click(NEXT)
|
||||
await ui.fail(5, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'))
|
||||
assert.deepEqual(dom.focused, [])
|
||||
await ui.pick('laden')
|
||||
await ui.pick(idx('laden'))
|
||||
await ui.answer(6, { items: [ITEM], total: 120, pageSize: 50 })
|
||||
assert.deepEqual(dom.focused, [])
|
||||
} finally { dom.restore(); ui.end() }
|
||||
@@ -474,7 +412,7 @@ test('Fehler beim Blättern (N6): „Erneut versuchen“ wiederholt genau die Se
|
||||
const ui = await start(api)
|
||||
try {
|
||||
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||||
await ui.click('Weiter')
|
||||
await ui.click(NEXT)
|
||||
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 }))
|
||||
@@ -493,9 +431,9 @@ test('Fehler beim Blättern (N6): „Erneut versuchen“ wiederholt genau die Se
|
||||
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.deepEqual(ui.rows().map((r) => ui.parts(r).name), ['Praxis Dr. Muster'])
|
||||
assert.ok(ui.text().includes('Seite 2 von 3 · 120 Einträge'))
|
||||
assert.deepEqual([ui.disabled('Zurück'), ui.disabled('Weiter')], [[false], [false]])
|
||||
assert.deepEqual([ui.disabled(PREV), ui.disabled(NEXT)], [[false], [false]])
|
||||
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
@@ -505,7 +443,7 @@ test('Fehler beim Blättern (N6): scheitert auch der zweite Versuch, bleibt der
|
||||
const ui = await start(api)
|
||||
try {
|
||||
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||||
await ui.click('Weiter')
|
||||
await ui.click(NEXT)
|
||||
await ui.fail(1, new Error('zu langsam'))
|
||||
await ui.click('Erneut versuchen')
|
||||
await ui.fail(2, new Error('immer noch nicht'))
|
||||
@@ -514,13 +452,13 @@ test('Fehler beim Blättern (N6): scheitert auch der zweite Versuch, bleibt der
|
||||
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')
|
||||
await ui.pick(idx('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.deepEqual(ui.rows().map((r) => ui.parts(r).name), ['Elektro Beispiel GmbH'])
|
||||
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
@@ -555,18 +493,18 @@ test('Fehler beim Blättern (N6): nach einem gelungenen Wiederholen bekommt die
|
||||
dom.attach(ui)
|
||||
try {
|
||||
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||||
await dom.press('Weiter')
|
||||
await dom.press(NEXT)
|
||||
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.focused, [NEXT])
|
||||
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 () => {
|
||||
test('Alle anzeigen (W1): nach dem Verlassen der Einzelansicht bekommt der aktive Raum-Reiter den Fokus, nicht beim Einhängen und nicht bei einem anderen Weg zur Liste', async () => {
|
||||
const api = fakeApi()
|
||||
const dom = fakeFocus()
|
||||
let cleared = 0
|
||||
@@ -579,12 +517,12 @@ test('Alle anzeigen (W1): nach dem Verlassen der Einzelansicht bekommt der Raumf
|
||||
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')
|
||||
assert.deepEqual(dom.focused, ['reiter']) // UMGESTELLT: der aktive Reiter („Alle Räume“) statt der Auswahlliste
|
||||
assert.equal(ui.reiter()[ui.activeIndex()].props['aria-label'].startsWith('Alle Räume'), true)
|
||||
// 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'])
|
||||
assert.deepEqual(dom.focused, ['reiter'])
|
||||
} finally { dom.restore(); ui.end() }
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user