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() }
|
||||
})
|
||||
|
||||
|
||||
@@ -79,26 +79,48 @@ test('pageInfo: Seiten, Blättern und Beschriftung', () => {
|
||||
assert.deepEqual(pageInfo({}), { pages: 1, hasPrev: false, hasNext: false, label: 'Keine Einträge' })
|
||||
})
|
||||
|
||||
test('BotLeadList.jsx: Links nur aus der Zeilen-Ableitung, extern mit rel noopener, kein HTML-Einschub, Knöpfe mit type="button"', () => {
|
||||
const src = read('src/components/workers/BotLeadList.jsx')
|
||||
assert.match(src, /botleadRow\(/)
|
||||
assert.match(src, /rel="noopener noreferrer"/); assert.match(src, /target="_blank"/)
|
||||
// jeder href kommt aus row.sourceHref (botleadRow → safeHref), nie aus dem Eintrag selbst
|
||||
assert.match(src, /href=\{row\.sourceHref\}/)
|
||||
assert.doesNotMatch(src, /\bhref=\{(?!row\.sourceHref\})/)
|
||||
assert.doesNotMatch(src, /\bhref="/)
|
||||
assert.doesNotMatch(src, /dangerouslySetInnerHTML|innerHTML/)
|
||||
// Quelltext des Kellers (BotLeadList.jsx und keller/*, Hustler-Pixelwelt T10): die Zeilen-Ableitung steckt in src/lib/hustlerKeller.js (eintragModell → botleadRow),
|
||||
// Links gibt es nur in der Akte. UMGESTELLT gegenüber der alten Tabelle: die Prüfungen laufen über alle Dateien des Kellers statt über BotLeadList.jsx allein.
|
||||
const KELLER_DATEIEN = [
|
||||
'src/components/workers/BotLeadList.jsx',
|
||||
'src/components/workers/keller/KellerSzene.jsx',
|
||||
'src/components/workers/keller/RegisterBuch.jsx',
|
||||
'src/components/workers/keller/Akte.jsx',
|
||||
]
|
||||
|
||||
// Quelltext ohne Kommentare (die Kommentare nennen auch, was es nicht gibt, etwa „kein innerHTML“)
|
||||
const ohneKommentare = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
|
||||
|
||||
test('Keller: Links nur aus der Zeilen-Ableitung, extern mit rel noopener, kein HTML-Einschub, Knöpfe mit type="button"', () => {
|
||||
const quellen = KELLER_DATEIEN.map((p) => ({ p, src: ohneKommentare(read(p)) }))
|
||||
const alle = quellen.map((q) => q.src).join('\n')
|
||||
// die Ableitung läuft über botleadRow (safeHref für die Quelle, Telefonregel, Datum)
|
||||
assert.match(ohneKommentare(read('src/components/workers/BotLeadList.jsx')), /eintragModell\(/)
|
||||
assert.match(read('src/lib/hustlerKeller.js'), /botleadRow\(/)
|
||||
// genau ein Link im ganzen Keller: die Quelle in der Akte, extern in neuem Tab ohne Rückbezug
|
||||
const akte = ohneKommentare(read('src/components/workers/keller/Akte.jsx'))
|
||||
assert.match(akte, /rel="noopener noreferrer"/); assert.match(akte, /target="_blank"/)
|
||||
// jeder href kommt aus e.sourceHref (eintragModell → botleadRow → safeHref), nie aus dem Eintrag selbst
|
||||
assert.match(akte, /href=\{e\.sourceHref\}/)
|
||||
assert.equal((alle.match(/\bhref=/g) || []).length, 1)
|
||||
assert.doesNotMatch(alle, /\bhref=\{(?!e\.sourceHref\})/)
|
||||
assert.doesNotMatch(alle, /\bhref="/)
|
||||
assert.equal((alle.match(/<a\b/g) || []).length, 1)
|
||||
assert.doesNotMatch(alle, /dangerouslySetInnerHTML|innerHTML/)
|
||||
// kein Anruf-Link, kein Mailversand
|
||||
assert.doesNotMatch(src, /tel:|mailto:/)
|
||||
assert.doesNotMatch(alle, /tel:|mailto:/)
|
||||
// WOMS-Button hat keinen Standard-type: jeder Knopf nennt ihn
|
||||
assert.equal((src.match(/<(?:button|Button)\b/g) || []).length > 0, true)
|
||||
assert.equal((src.match(/<(?:button|Button)\b/g) || []).length, (src.match(/<(?:button|Button)\b[^>]*type="button"/g) || []).length)
|
||||
for (const { p, src } of quellen) {
|
||||
assert.equal((src.match(/<(?:button|Button)\b/g) || []).length, (src.match(/<(?:button|Button)\b[^>]*type="button"/g) || []).length, p)
|
||||
}
|
||||
assert.ok((alle.match(/<button\b/g) || []).length > 0)
|
||||
// kein Polling
|
||||
assert.doesNotMatch(src, /setInterval/)
|
||||
assert.doesNotMatch(alle, /setInterval/)
|
||||
})
|
||||
|
||||
test('BotLeadList.jsx: Zeilen ohne Kennung teilen sich keinen key (Ausweichwert aus der Position), aria-current nur in der Einzelansicht', () => {
|
||||
const src = read('src/components/workers/BotLeadList.jsx')
|
||||
assert.match(src, /key=\{row\.id \|\| /)
|
||||
assert.match(src, /aria-current=\{single && row\.id === botId \?/)
|
||||
test('Keller: Zeilen ohne Kennung teilen sich keinen key (Ausweichwert aus der Position), aria-current nur in der Einzelansicht', () => {
|
||||
const src = ohneKommentare(read('src/components/workers/keller/RegisterBuch.jsx'))
|
||||
assert.match(src, /key=\{z\.id \|\| /)
|
||||
assert.match(src, /aktuell=\{einzeln && z\.id === botId\}/)
|
||||
assert.match(src, /aria-current=\{aktuell \? 'true' : undefined\}/)
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
// Läufer für die Tests des Kellers (BotLeadList, keller/*): bündelt die Komponente mit esbuild und dem Mini-Läufer fakeReact (React und DOM gibt es unter Node nicht),
|
||||
// rendert in einen Baum aus { tag, props, children }; Eingaben, Klicks und Tasten ruft der Test von Hand auf, die Zeit (window.setTimeout) läuft von Hand, der Dienst
|
||||
// ist eine Attrappe mit offenen Versprechen. Gemeinsam für tests/unit/botleadList.test.mjs und tests/unit/hustlerKeller.test.mjs.
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import esbuild from 'esbuild'
|
||||
import { fakeReact } from './fakeReact.mjs'
|
||||
|
||||
export 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')}`)
|
||||
export const settle = () => new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
// Die Komponente wird einmal gebündelt; jeder Test hängt sie neu ein (eigener Zustand)
|
||||
let bundle
|
||||
export 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 ----------
|
||||
export 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
|
||||
}
|
||||
export const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join(''))
|
||||
export const classesOf = (n) => String(n.props.className ?? '').split(' ').filter(Boolean)
|
||||
export const hasClass = (n, name) => classesOf(n).includes(name)
|
||||
export const byClass = (tree, name) => nodes(tree, (n) => hasClass(n, name))
|
||||
// Knopf nach Beschriftung: aria-label oder sichtbarer Text
|
||||
export const buttons = (tree, label) => nodes(tree, (n) => n.tag === 'button' && (n.props['aria-label'] === label || textOf(n).trim() === label))
|
||||
// alle hk-Klassen eines Baums
|
||||
export const allClasses = (tree) => {
|
||||
const set = new Set()
|
||||
nodes(tree, (n) => { for (const c of classesOf(n)) if (c.startsWith('hk-')) set.add(c); return false })
|
||||
return set
|
||||
}
|
||||
|
||||
// Dienst-Attrappe: jeder Aufruf von botleads ist ein offenes Versprechen, das der Test selbst auflöst
|
||||
export function fakeApi() {
|
||||
const calls = []
|
||||
return { calls, botleads: (params) => new Promise((resolve, reject) => { calls.push({ params, resolve, reject }) }) }
|
||||
}
|
||||
|
||||
// DOM-Attrappe für den Fokus: document mit body, activeElement und querySelector; ref an dem, was die Komponente anfasst, liefert __fakeRef.
|
||||
// Schlüssel eines Elements: „reiter“ (der aktive Raum-Reiter, aria-pressed), sonst die aria-label des Knopfs („Nächste Seite“, „Schließen“ …) oder sein Text, sonst der Tag.
|
||||
// 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. Zeilen des Buchs gibt es über querySelector('[data-hk-zeile="<Nr>"]') (Schlüssel „zeile <Nr>“), wie der Browser sie der Akte liefert.
|
||||
export 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) => {
|
||||
if (tag !== 'button') return tag
|
||||
if (props['aria-pressed'] !== undefined) return 'reiter'
|
||||
return String(props['aria-label'] ?? props.children)
|
||||
}
|
||||
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)
|
||||
}
|
||||
doc.querySelector = (sel) => {
|
||||
const m = /^\[data-hk-zeile="(\d+)"\]$/.exec(sel)
|
||||
if (!m) return null
|
||||
const nr = Number(m[1])
|
||||
if (nodes(f.tree(), (n) => n.tag === 'button' && n.props['data-hk-zeile'] === nr).length === 0) return null
|
||||
const key = `zeile ${nr}`
|
||||
return { key, isConnected: true, disabled: false, focus(options) { f.focused.push(key); f.options.push(options); doc.activeElement = this } }
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
// Der Nutzer klickt eine Zeile des Buchs (Fokus auf der Zeile, dann der Klick)
|
||||
f.pressRow = async (nr) => {
|
||||
const key = `zeile ${nr}`
|
||||
const el = { key, isConnected: true, disabled: false, focus(options) { f.focused.push(key); f.options.push(options); doc.activeElement = this } }
|
||||
doc.activeElement = el
|
||||
await f.view.openRow(nr)
|
||||
return el
|
||||
}
|
||||
f.restore = () => {
|
||||
if (had) globalThis.document = saved
|
||||
else delete globalThis.document
|
||||
delete globalThis.__fakeRef
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// ---------- Einträge des Dienstes (Form von GET /botleads) ----------
|
||||
export 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,
|
||||
}
|
||||
export 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' }
|
||||
export 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) ----------
|
||||
export 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() },
|
||||
// Zeilen des Buchs (Knöpfe) und ihre Teile
|
||||
rows: () => nodes(view.tree(), (n) => n.tag === 'button' && hasClass(n, 'hk-zeile')),
|
||||
parts: (row) => {
|
||||
const zwei = byClass(row, 'hk-zwei-zeilen')
|
||||
return {
|
||||
name: textOf(byClass(row, 'hk-kl-name')[0]),
|
||||
sub: textOf(byClass(row, 'hk-klein')[0]),
|
||||
score: textOf(byClass(row, 'hk-kl-punkte-text')[0]),
|
||||
reason: textOf(zwei[0]),
|
||||
phone: textOf(zwei[1]),
|
||||
raumIcon: row.children[0].props.className,
|
||||
label: row.props['aria-label'],
|
||||
}
|
||||
},
|
||||
// Raum-Reiter (Knöpfe mit aria-pressed) in der Reihenfolge der Leiste
|
||||
reiter: () => nodes(view.tree(), (n) => n.tag === 'button' && n.props['aria-pressed'] !== undefined),
|
||||
activeIndex: () => view.reiter().findIndex((r) => r.props['aria-pressed'] === true),
|
||||
search: () => nodes(view.tree(), (n) => n.tag === 'input')[0],
|
||||
errors: () => byClass(view.tree(), 'hk-kl-fehler').map((n) => ({ text: textOf(n), role: n.props.role })),
|
||||
async pick(index) { view.reiter()[index].props.onClick({}); 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()
|
||||
},
|
||||
async openRow(nr) { view.rows()[nr].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() } },
|
||||
// Akte: der Dialog, seine Teile, Tasten am äußeren Rahmen
|
||||
akte: () => nodes(view.tree(), (n) => n.props.role === 'dialog')[0] ?? null,
|
||||
akteDl: () => {
|
||||
const dl = byClass(view.tree(), 'hk-kl-akte-dl')[0]
|
||||
const out = {}
|
||||
let name = null
|
||||
for (const c of dl ? dl.children : []) {
|
||||
if (c === null || typeof c === 'string') continue
|
||||
if (c.tag === 'dt') name = textOf(c)
|
||||
else if (c.tag === 'dd') out[name] = c
|
||||
}
|
||||
return out
|
||||
},
|
||||
async key(key, extra = {}) {
|
||||
const ueber = byClass(view.tree(), 'hk-kl-akte-ueber')[0]
|
||||
assert.ok(ueber, 'keine Akte offen')
|
||||
const ev = { key, shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, currentTarget: ueber, prevented: false, preventDefault() { ev.prevented = true }, stopPropagation() {}, ...extra }
|
||||
ueber.props.onKeyDown(ev)
|
||||
await settle()
|
||||
return ev
|
||||
},
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
// Gründe der Bot-Lead-Liste (src/lib/botleadGruende.js, Hustler-Pixelwelt T10, Spec E12): Grund-Text des Dienstes -> Symbol und Erklärung für die Akte.
|
||||
// Die Fixture tests/fixtures/botlead-gruende.json trägt alle 14 Texte des Dienstes (workers-dev-p6/src/scoring/business.js, GRUND, Stand d0d66f9); sie ist unabhängig vom Code unter Test.
|
||||
|
||||
const { grundInfo, grundBekannt, ALLGEMEIN, GRUND_SYMBOLE } = await loadModule('src/lib/botleadGruende.js')
|
||||
const fixture = JSON.parse(readFileSync(new URL('../fixtures/botlead-gruende.json', import.meta.url), 'utf8'))
|
||||
const read = (p) => readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
|
||||
|
||||
// Symbol je Grund (Tabelle GRUENDE der Zeichenfläche, keller/entwurf/logik.js; „Lauf unterbrochen“ bekommt das Pausen-Symbol)
|
||||
const SYMBOL = {
|
||||
KEINE_MAIL: 'brief-x', MX: 'brief-zurueck', KETTE: 'kette', WEBSITE_GUT: 'haken', SCHWELLE: 'pfeil-runter', VERALTET: 'uhr', BEKANNT: 'doppelt',
|
||||
BEKANNT_NAME: 'doppelt', GESPERRT: 'stopp', LESEN: 'seite-kaputt', IMPRESSUM_FREMD: 'fremd', ABGEBROCHEN: 'pause', BEWERTUNG_AUSGEFALLEN: 'waage-kaputt', NAME_FEHLT: 'etikett-leer',
|
||||
}
|
||||
|
||||
// Klassen aus keller.css und hustler-keller-ui.css (ohne Kommentare und url(...))
|
||||
const cssKlassen = (datei) => new Set((read(datei).replace(/\/\*[\s\S]*?\*\//g, '').replace(/url\([^)]*\)/g, '').match(/\.hk-[A-Za-z0-9_-]+/g) ?? []).map((k) => k.slice(1)))
|
||||
const CSS = new Set([...cssKlassen('src/styles/hustler/keller.css'), ...cssKlassen('src/styles/hustler/hustler-keller-ui.css')])
|
||||
|
||||
test('Fixture: 14 Texte des Dienstes mit Quelle und Stand', () => {
|
||||
assert.match(fixture.quelle, /scoring\/business\.js/)
|
||||
assert.match(fixture.quelle, /d0d66f9/)
|
||||
assert.equal(fixture.stand, 'd0d66f9')
|
||||
const texte = Object.values(fixture.gruende)
|
||||
assert.equal(texte.length, 14)
|
||||
assert.equal(new Set(texte).size, 14)
|
||||
assert.ok(texte.every((t) => typeof t === 'string' && t.trim() === t && t !== ''))
|
||||
assert.deepEqual(Object.keys(fixture.gruende).sort(), Object.keys(SYMBOL).sort())
|
||||
assert.equal(fixture.gruende.SCHWELLE, 'Punktzahl unter 55') // so, wie der Dienst den Text mit der Grenze bildet
|
||||
})
|
||||
|
||||
test('grundInfo: jeder Grund der Fixture hat sein Symbol und eine eigene Erklärung (nie der allgemeine Satz)', () => {
|
||||
const saetze = new Set()
|
||||
for (const [name, text] of Object.entries(fixture.gruende)) {
|
||||
assert.equal(grundBekannt(text), true, name)
|
||||
const info = grundInfo(text)
|
||||
assert.equal(info.icon, `hk-ic hk-ic-${SYMBOL[name]}`, name)
|
||||
assert.equal(typeof info.erklaerung, 'string')
|
||||
assert.ok(info.erklaerung.length > 15 && info.erklaerung.endsWith('.'), `${name}: ${info.erklaerung}`)
|
||||
assert.notEqual(info.erklaerung, ALLGEMEIN.erklaerung, name)
|
||||
assert.deepEqual(Object.keys(info).sort(), ['erklaerung', 'icon'])
|
||||
saetze.add(info.erklaerung)
|
||||
}
|
||||
assert.equal(saetze.size, 14) // auch die beiden „schon bekannt“ haben je einen eigenen Satz
|
||||
})
|
||||
|
||||
test('grundInfo: Wortlaut der Erklärungen aus der Zeichenfläche, „Lauf unterbrochen“ im selben Ton', () => {
|
||||
assert.equal(grundInfo('keine veröffentlichte Mail').erklaerung, 'Auf der Website steht keine E-Mail-Adresse. Ohne veröffentlichte Adresse schreibt der Hustler nie.')
|
||||
assert.equal(grundInfo('Punktzahl unter 55').erklaerung, 'Zu wenige Punkte für eine Mail (Grenze 55).')
|
||||
assert.equal(grundInfo('Sperrliste').erklaerung, 'Hat widersprochen oder steht auf der Sperrliste.')
|
||||
assert.equal(grundInfo('Bewertung ausgefallen').erklaerung, 'Bea konnte den Betrieb nicht bewerten.')
|
||||
const abbruch = grundInfo('Lauf unterbrochen')
|
||||
assert.equal(abbruch.icon, 'hk-ic hk-ic-pause')
|
||||
assert.match(abbruch.erklaerung, /unterbrochen/)
|
||||
assert.match(abbruch.erklaerung, /keine Mail/)
|
||||
})
|
||||
|
||||
test('grundInfo: die Grenze in „Punktzahl unter N“ kommt aus dem Text des Dienstes (ändert er sie, stimmt die Erklärung weiter)', () => {
|
||||
assert.equal(grundInfo('Punktzahl unter 60').erklaerung, 'Zu wenige Punkte für eine Mail (Grenze 60).')
|
||||
assert.equal(grundInfo('Punktzahl unter 60').icon, 'hk-ic hk-ic-pfeil-runter')
|
||||
assert.equal(grundBekannt('Punktzahl unter 60'), true)
|
||||
for (const nein of ['Punktzahl unter', 'Punktzahl unter x', 'Punktzahl unter 5 5', 'Punktzahl unter 0055', 'Punktzahl unter 55 Punkte']) assert.equal(grundBekannt(nein), false, nein)
|
||||
})
|
||||
|
||||
test('grundInfo: Leerraum am Rand zählt nicht, Groß- und Kleinschreibung schon', () => {
|
||||
assert.deepEqual(grundInfo(' Sperrliste \n'), grundInfo('Sperrliste'))
|
||||
assert.equal(grundBekannt('sperrliste'), false)
|
||||
})
|
||||
|
||||
test('grundInfo: unbekannter Text ergibt das neutrale Symbol und einen allgemeinen Satz, nie einen Fehler', () => {
|
||||
for (const unbekannt of ['irgendein neuer Grund', '', ' ', null, undefined, 42, {}, [], 'constructor', '__proto__', 'toString', 'hasOwnProperty']) {
|
||||
const info = grundInfo(unbekannt)
|
||||
assert.deepEqual(info, { icon: 'hk-ic hk-ic-akte', erklaerung: ALLGEMEIN.erklaerung }, String(unbekannt))
|
||||
assert.equal(grundBekannt(unbekannt), false)
|
||||
}
|
||||
assert.match(ALLGEMEIN.erklaerung, /^Der Hustler schreibt diesem Betrieb nicht\./)
|
||||
})
|
||||
|
||||
test('Symbole: jede Klasse, die grundInfo liefern kann, steht in keller.css oder hustler-keller-ui.css', () => {
|
||||
const klassen = new Set()
|
||||
for (const text of [...Object.values(fixture.gruende), 'Punktzahl unter 70', 'unbekannt']) {
|
||||
const { icon } = grundInfo(text)
|
||||
assert.match(icon, /^hk-ic hk-ic-[a-z-]+$/)
|
||||
for (const k of icon.split(' ')) klassen.add(k)
|
||||
}
|
||||
assert.ok(klassen.size >= 14)
|
||||
for (const k of klassen) assert.ok(CSS.has(k), `${k} fehlt in keller.css / hustler-keller-ui.css`)
|
||||
// GRUND_SYMBOLE nennt genau diese Symbole
|
||||
assert.deepEqual([...GRUND_SYMBOLE].sort(), [...new Set([...Object.values(SYMBOL), 'akte'])].sort())
|
||||
for (const s of GRUND_SYMBOLE) assert.ok(CSS.has(`hk-ic-${s}`), s)
|
||||
})
|
||||
|
||||
test('botleadGruende.js: rein (kein React, kein DOM, keine Importe), keine Beispieldaten', () => {
|
||||
const src = read('src/lib/botleadGruende.js').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
|
||||
assert.doesNotMatch(src, /^\s*import\b/m)
|
||||
assert.doesNotMatch(src, /\b(?:window|document|fetch)\b/)
|
||||
assert.doesNotMatch(src, /innerHTML/)
|
||||
})
|
||||
@@ -0,0 +1,742 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { loadModule } from './load.mjs'
|
||||
import {
|
||||
ITEM, KUERZ, PRAXIS, ROOT, allClasses, buttons, byClass, classesOf, fakeApi, fakeFocus, nodes, settle, start, textOf,
|
||||
} from './helpers/kellerRunner.mjs'
|
||||
|
||||
// Der Keller (Hustler-Pixelwelt T10, Spec E12/E13/E14): Szene mit Arne nach Status (keller/KellerSzene.jsx), Register-Buch (keller/RegisterBuch.jsx), Akte
|
||||
// (keller/Akte.jsx) und die Hülle BotLeadList.jsx (◂ Hochhaus, Schild, „Register zuklappen“). Abruf, Suche, Seiten, Fokus beim Blättern und Einzelansicht prüft
|
||||
// botleadList.test.mjs; hier steht, was neu dazukommt, und die Ordnung von Klassen, Stilen und Quelltext.
|
||||
const { ROOM_FILTERS } = await loadModule('src/lib/botleadsView.js')
|
||||
const { grundInfo } = await loadModule('src/lib/botleadGruende.js')
|
||||
|
||||
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8')
|
||||
const ohneKommentare = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
|
||||
|
||||
const SELBST = { ...ITEM, id: 'p5', room: 'selbst', roomLabel: 'Selbstständige', name: 'Fotostudio Muster', reason: 'Kette oder Franchise', score: 74, foundAt: '2026-10-17T20:00:00Z' }
|
||||
const LADEN = { ...ITEM, id: 'p6', room: 'laden', roomLabel: 'Läden & Gastro', name: 'Bäckerei Probe', reason: 'Punktzahl unter 55', score: 40, phoneSource: '', foundAt: '2026-10-16T20:00:00Z' }
|
||||
const SEITE = [ITEM, PRAXIS, KUERZ, SELBST, LADEN]
|
||||
const eintrag = (status) => (status === 'grau' ? { status: 'grau', task: '', lastTs: null, connected: false, counters: {} } : { status, task: '', lastTs: null, connected: true, counters: {} })
|
||||
const idx = (room) => ROOM_FILTERS.findIndex((f) => f.value === room)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Szene: Arne nach Status
|
||||
|
||||
async function szene(workers) {
|
||||
const api = fakeApi()
|
||||
const ui = await start(api, workers === undefined ? {} : { workers })
|
||||
return { api, ui }
|
||||
}
|
||||
const sz = (ui) => byClass(ui.tree(), 'hk-kl-szene')[0]
|
||||
const has = (ui, name) => byClass(ui.tree(), name).length > 0
|
||||
const RUHENDE = ['hk-sz-rohrpost', 'hk-sz-schublade', 'hk-sz-buch-stempel']
|
||||
|
||||
test('Szene: Wurzel .hk-keller, die Szene ist Zierde (aria-hidden) und hat alle Ebenen der Vorlage in der Reihenfolge der Vorlage', async () => {
|
||||
const { ui } = await szene({ arne: eintrag('arbeitet') })
|
||||
try {
|
||||
const tree = ui.tree()
|
||||
assert.equal(tree.tag, 'div')
|
||||
assert.deepEqual(classesOf(tree), ['hk-keller'])
|
||||
assert.equal(sz(ui).props['aria-hidden'], 'true')
|
||||
// Ebenen: Raum, Fenster mit Schnee/Passant/Kreuz, Dampf, Stempel, Rohrpost, Schublade, Maus, Blinkpunkte, Licht (Tisch, zwei Lampen, Halle), Staub
|
||||
const ebenen = byClass(sz(ui), 'hk-sz-ebene').map((n) => classesOf(n).find((c) => c !== 'hk-sz-ebene'))
|
||||
assert.deepEqual(ebenen, ['hk-sz-bild', 'hk-sz-licht-tisch', 'hk-sz-licht-92', 'hk-sz-licht-236', 'hk-sz-licht-halle', 'hk-sz-staub'])
|
||||
for (const c of ['hk-sz-fensterschnee', 'hk-sz-passant-weg', 'hk-sz-passant', 'hk-sz-kreuz', 'hk-sz-dampf', 'hk-sz-maus-weg', 'hk-sz-maus']) assert.equal(byClass(sz(ui), c).length, 1, c)
|
||||
assert.equal(byClass(sz(ui), 'hk-sz-led').length, 6)
|
||||
// die Reihenfolge der Dinge in der Bühne: Raum zuerst, Licht zuletzt (Licht liegt über Arne und Maus)
|
||||
const kinder = byClass(sz(ui), 'hk-kl-buehne')[0].children.map((n) => classesOf(n).join(' '))
|
||||
assert.ok(kinder.indexOf('hk-arne-weg') < kinder.indexOf('hk-sz-ebene hk-sz-licht-tisch'))
|
||||
assert.equal(kinder[0], 'hk-sz-ebene hk-sz-bild')
|
||||
assert.equal(kinder.at(-1), 'hk-sz-ebene hk-sz-staub')
|
||||
// Blinkpunkte: Animationen mit den umbenannten Keyframes (hk-k-k-led-…)
|
||||
const led = byClass(sz(ui), 'hk-sz-led').map((n) => n.props.style.animation.split(' ')[0])
|
||||
assert.deepEqual(led, ['hk-k-k-led-weiche-gruen', 'hk-k-k-led-weiche-bernstein', 'hk-k-k-led-sicherung-1', 'hk-k-k-led-sicherung-2', 'hk-k-k-led-sicherung-3', 'hk-k-k-led-verteiler'])
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Arne arbeitet: Ablauf-Schleife samt Rohrpost, Stempel und Schublade, kein Haus-Figur, kein Marker', async () => {
|
||||
const { ui } = await szene({ arne: eintrag('arbeitet') })
|
||||
try {
|
||||
assert.equal(sz(ui).props['data-hk-arne'], 'arbeitet')
|
||||
assert.equal(byClass(sz(ui), 'hk-arne-weg').length, 1)
|
||||
assert.equal(byClass(byClass(sz(ui), 'hk-arne-weg')[0], 'hk-arne-bild').length, 1)
|
||||
assert.equal(has(ui, 'hk-kl-arne'), false)
|
||||
assert.equal(has(ui, 'hk-alarm'), false)
|
||||
for (const c of RUHENDE) {
|
||||
const n = byClass(sz(ui), c)
|
||||
assert.equal(n.length, 1, c)
|
||||
assert.equal(classesOf(n[0]).includes('hk-kl-ruht'), false, `${c} läuft`)
|
||||
}
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Arne arbeitet nicht: Haus-Figur in der Pose des Status am Pult, Rohrpost, Stempel und Schublade ruhen', async () => {
|
||||
const faelle = [
|
||||
{ status: 'wartet', pose: 'warten', punkte: true, zzz: false, alarm: false },
|
||||
{ status: 'leerlauf', pose: 'schlafen', punkte: false, zzz: true, alarm: false },
|
||||
{ status: 'grau', pose: 'nicht', punkte: false, zzz: false, alarm: false },
|
||||
{ status: 'fehler', pose: 'nicht', punkte: false, zzz: false, alarm: true },
|
||||
]
|
||||
for (const f of faelle) {
|
||||
const { ui } = await szene({ arne: eintrag(f.status) })
|
||||
try {
|
||||
assert.equal(sz(ui).props['data-hk-arne'], f.status)
|
||||
assert.equal(has(ui, 'hk-arne-weg'), false, f.status)
|
||||
assert.equal(has(ui, 'hk-arne-bild'), false, f.status)
|
||||
const figur = byClass(sz(ui), 'hk-kl-fig')
|
||||
assert.equal(figur.length, 1, f.status)
|
||||
assert.deepEqual(classesOf(figur[0]), ['hk-spr', 'hk-kl-fig', `hk-kl-fig-${f.pose}`], f.status)
|
||||
assert.equal(has(ui, 'hk-kl-punkte'), f.punkte, `${f.status} punkte`)
|
||||
assert.equal(has(ui, 'hk-kl-zzz'), f.zzz, `${f.status} zzz`)
|
||||
assert.equal(has(ui, 'hk-alarm'), f.alarm, `${f.status} alarm`)
|
||||
if (f.alarm) assert.equal(textOf(byClass(sz(ui), 'hk-alarm')[0]), '!')
|
||||
for (const c of RUHENDE) assert.ok(classesOf(byClass(sz(ui), c)[0]).includes('hk-kl-ruht'), `${f.status}: ${c} ruht nicht`)
|
||||
// Arne steht an seinem Pult: Lage aus ARNE_RUHE
|
||||
const platz = byClass(sz(ui), 'hk-kl-arne')[0].props.style
|
||||
assert.deepEqual([platz.position, platz.left, platz.top], ['absolute', '116px', '286px'])
|
||||
} finally { ui.end() }
|
||||
}
|
||||
})
|
||||
|
||||
test('Arne ohne Zustand (Tabelle noch leer, kein Eintrag): er schläft; der Status wechselt mit den Daten der Seite', async () => {
|
||||
const { ui } = await szene(undefined)
|
||||
try {
|
||||
assert.equal(sz(ui).props['data-hk-arne'], 'leerlauf')
|
||||
assert.ok(has(ui, 'hk-kl-fig-schlafen') && has(ui, 'hk-kl-zzz'))
|
||||
ui.setProps({ workers: { arne: eintrag('arbeitet') } })
|
||||
assert.ok(has(ui, 'hk-arne-weg') && !has(ui, 'hk-kl-fig'))
|
||||
ui.setProps({ workers: { arne: eintrag('fehler') } })
|
||||
assert.ok(!has(ui, 'hk-arne-weg') && has(ui, 'hk-kl-fig-nicht') && has(ui, 'hk-alarm'))
|
||||
ui.setProps({ workers: { arne: eintrag('wartet') } })
|
||||
assert.ok(has(ui, 'hk-kl-fig-warten') && has(ui, 'hk-kl-punkte') && !has(ui, 'hk-alarm'))
|
||||
ui.setProps({ workers: {} })
|
||||
assert.ok(has(ui, 'hk-kl-fig-schlafen'))
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Szene: Einpassen „cover“ aus der Größe des Rahmens, folgt dem Beobachter und löst ihn beim Abbau', async () => {
|
||||
const rect = { width: 1280, height: 720 }
|
||||
const beobachter = []
|
||||
let getrennt = 0
|
||||
const hatte = Object.hasOwn(globalThis, 'ResizeObserver')
|
||||
const alt = globalThis.ResizeObserver
|
||||
globalThis.ResizeObserver = class { constructor(cb) { this.cb = cb; this.ziele = []; beobachter.push(this) } observe(el) { this.ziele.push(el) } disconnect() { getrennt += 1 } }
|
||||
globalThis.__fakeRef = (tag, props) => (props.className === 'hk-kl-szene' ? { getBoundingClientRect: () => ({ ...rect }) } : null)
|
||||
const api = fakeApi()
|
||||
const ui = await start(api, {})
|
||||
try {
|
||||
const transform = () => byClass(ui.tree(), 'hk-kl-buehne')[0].props.style.transform
|
||||
assert.equal(beobachter.length, 1)
|
||||
assert.equal(beobachter[0].ziele.length, 1)
|
||||
assert.equal(transform(), 'translate(0.0px, 0.0px) scale(2.0000)')
|
||||
rect.width = 1920; rect.height = 1080
|
||||
beobachter[0].cb(); await settle()
|
||||
assert.equal(transform(), 'translate(0.0px, 0.0px) scale(3.0000)')
|
||||
rect.width = 2560; rect.height = 720
|
||||
beobachter[0].cb(); await settle()
|
||||
assert.equal(transform(), 'translate(0.0px, -576.0px) scale(4.0000)')
|
||||
// Handy hochkant: links verankert
|
||||
rect.width = 390; rect.height = 844
|
||||
beobachter[0].cb(); await settle()
|
||||
assert.match(transform(), /^translate\(-166\.\d+px, 0\.0px\) scale\(2\.3444\)$/)
|
||||
// ein Rahmen ohne Größe (noch nicht gelegt) lässt alles, wie es ist
|
||||
const davor = transform()
|
||||
rect.width = 0; rect.height = 0
|
||||
beobachter[0].cb(); await settle()
|
||||
assert.equal(transform(), davor)
|
||||
assert.equal(getrennt, 0)
|
||||
} finally { ui.end(); delete globalThis.__fakeRef; if (hatte) globalThis.ResizeObserver = alt; else delete globalThis.ResizeObserver }
|
||||
assert.equal(getrennt, 1)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Hülle: ◂ Hochhaus, Schild, Register zuklappen
|
||||
|
||||
test('◂ Hochhaus ruft onBack; ohne onBack bricht nichts', async () => {
|
||||
let zurueck = 0
|
||||
const mit = await start(fakeApi(), { onBack: () => { zurueck += 1 } })
|
||||
try {
|
||||
const knopf = buttons(mit.tree(), 'Zurück zum Hochhaus')
|
||||
assert.equal(knopf.length, 1)
|
||||
assert.equal(knopf[0].props.type, 'button')
|
||||
assert.ok(textOf(knopf[0]).includes('Hochhaus'))
|
||||
assert.deepEqual(byClass(knopf[0], 'hk-ic').map((n) => classesOf(n).join(' ')), ['hk-ic hk-ic-zurueck'])
|
||||
await mit.click('Zurück zum Hochhaus')
|
||||
assert.equal(zurueck, 1)
|
||||
} finally { mit.end() }
|
||||
const ohne = await start(fakeApi(), {})
|
||||
try {
|
||||
await ohne.click('Zurück zum Hochhaus') // keine Prop onBack: nichts geschieht, nichts bricht
|
||||
} finally { ohne.end() }
|
||||
// auch in der Einzelansicht und bei einem Fehler bleibt der Weg zurück
|
||||
const einzeln = await start(fakeApi(), { bot: 'p1', onClearBot: () => {}, onBack: () => { zurueck += 1 } })
|
||||
try {
|
||||
await einzeln.fail(0, new Error('weg'))
|
||||
await einzeln.click('Zurück zum Hochhaus')
|
||||
assert.equal(zurueck, 2)
|
||||
} finally { einzeln.end() }
|
||||
})
|
||||
|
||||
test('Schild: Überschrift „Bot-Leads“ und die Zahl aus kpis.botLeads (1.234 mit Punkt), ohne brauchbare Zahl keine Zahl', async () => {
|
||||
const faelle = [[{ botLeads: 112 }, '112'], [{ botLeads: 1234 }, '1.234'], [{ botLeads: 0 }, '0'], [undefined, null], [{}, null], [{ botLeads: NaN }, null], [{ botLeads: '5' }, null], [null, null]]
|
||||
for (const [kpis, erwartet] of faelle) {
|
||||
const api = fakeApi()
|
||||
const ui = await start(api, { kpis })
|
||||
try {
|
||||
assert.equal(textOf(nodes(ui.tree(), (n) => n.tag === 'h1')[0]), 'Bot-Leads')
|
||||
const zahl = byClass(ui.tree(), 'hk-kl-zahl')
|
||||
assert.deepEqual(zahl.map(textOf), erwartet === null ? [] : [erwartet], JSON.stringify(kpis))
|
||||
} finally { ui.end() }
|
||||
}
|
||||
})
|
||||
|
||||
test('Register zuklappen: nur die Szene bleibt, nichts wird neu abgerufen, Raum und Suche bleiben beim Aufklappen', async () => {
|
||||
const api = fakeApi()
|
||||
const ui = await start(api, {})
|
||||
try {
|
||||
await ui.answer(0, { items: [ITEM, PRAXIS], total: 2, pageSize: 50 })
|
||||
await ui.pick(idx('handwerk'))
|
||||
await ui.answer(1, { items: [ITEM], total: 1, pageSize: 50 })
|
||||
await ui.type('elektro')
|
||||
assert.equal(api.calls.length, 2)
|
||||
const toggle = () => buttons(ui.tree(), 'Register zuklappen')
|
||||
assert.equal(toggle().length, 1)
|
||||
assert.equal(toggle()[0].props['aria-expanded'], true)
|
||||
assert.equal(toggle()[0].props.type, 'button')
|
||||
assert.equal(byClass(ui.tree(), 'hk-buch').length, 1)
|
||||
await ui.click('Register zuklappen')
|
||||
assert.equal(byClass(ui.tree(), 'hk-buch').length, 0)
|
||||
assert.equal(ui.rows().length, 0)
|
||||
assert.equal(ui.reiter().length, 0)
|
||||
assert.equal(ui.search(), undefined)
|
||||
assert.equal(byClass(ui.tree(), 'hk-kl-szene').length, 1) // die Szene steht ganz
|
||||
const auf = buttons(ui.tree(), 'Register aufklappen')
|
||||
assert.equal(auf.length, 1)
|
||||
assert.equal(auf[0].props['aria-expanded'], false)
|
||||
assert.ok(textOf(auf[0]).includes('Register aufklappen'))
|
||||
assert.equal(buttons(ui.tree(), 'Register zuklappen').length, 0)
|
||||
assert.equal(byClass(ui.tree(), 'hk-kl-schild').length, 1) // Kopfzeile bleibt
|
||||
assert.equal(api.calls.length, 2) // nichts abgerufen
|
||||
await ui.click('Register aufklappen')
|
||||
assert.equal(api.calls.length, 2)
|
||||
assert.equal(ui.rows().length, 1)
|
||||
assert.equal(ui.activeIndex(), idx('handwerk'))
|
||||
assert.equal(ui.search().props.value, 'elektro')
|
||||
assert.equal(buttons(ui.tree(), 'Register zuklappen').length, 1)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Register zuklappen: auch beim Laden, im Fehler und in der Einzelansicht; Antworten kommen trotzdem an', async () => {
|
||||
const api = fakeApi()
|
||||
const ui = await start(api, {})
|
||||
try {
|
||||
await ui.click('Register zuklappen') // die erste Antwort steht noch aus
|
||||
await ui.answer(0, { items: [ITEM], total: 1 })
|
||||
assert.equal(ui.rows().length, 0)
|
||||
await ui.click('Register aufklappen')
|
||||
assert.equal(ui.rows().length, 1)
|
||||
assert.equal(api.calls.length, 1)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Akte
|
||||
|
||||
test('Akte: Dialog zur gewählten Zeile mit Raum, Scout, Name, Ort, Punkten, Grund samt Erklärung, Telefon, Quelle und Funddatum', async () => {
|
||||
const api = fakeApi()
|
||||
const ui = await start(api, {})
|
||||
try {
|
||||
await ui.answer(0, { items: SEITE, total: 5, pageSize: 50 })
|
||||
assert.equal(ui.akte(), null)
|
||||
await ui.openRow(0)
|
||||
const dialog = ui.akte()
|
||||
assert.equal(dialog.tag, 'section')
|
||||
assert.equal(dialog.props.role, 'dialog')
|
||||
assert.equal(dialog.props['aria-modal'], 'true')
|
||||
assert.equal(dialog.props['aria-label'], 'Akte Elektro Beispiel GmbH')
|
||||
assert.equal(dialog.props.tabIndex, -1)
|
||||
assert.equal(textOf(byClass(dialog, 'hk-kl-akte-raum')[0]), 'Handwerk · gefunden von Hanna')
|
||||
assert.equal(textOf(nodes(dialog, (n) => n.tag === 'h2')[0]), 'Elektro Beispiel GmbH')
|
||||
assert.equal(textOf(byClass(dialog, 'hk-kl-akte-ort')[0]), '67655 Kaiserslautern')
|
||||
const dl = ui.akteDl()
|
||||
assert.deepEqual(Object.keys(dl), ['Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden'])
|
||||
assert.equal(textOf(byClass(dl.Punkte, 'hk-kl-punkte-text')[0]), '82 Punkte')
|
||||
assert.deepEqual(byClass(dl.Punkte, 'hk-kl-balken-fuell').map((n) => [classesOf(n).join(' '), n.props.style.width]), [['hk-kl-balken-fuell hk-kl-farbe-gut', 79]])
|
||||
assert.equal(textOf(byClass(dl.Grund, 'hk-kl-akte-grundtext')[0]), 'keine veröffentlichte Mail')
|
||||
assert.equal(textOf(byClass(dl.Grund, 'hk-kl-akte-erklaerung')[0]), grundInfo('keine veröffentlichte Mail').erklaerung)
|
||||
assert.deepEqual(byClass(dl.Grund, 'hk-ic').map((n) => classesOf(n).join(' ')), ['hk-ic hk-ic-brief-x'])
|
||||
assert.equal(textOf(dl.Telefon), '0631 111')
|
||||
assert.deepEqual(byClass(dl.Telefon, 'hk-ic').map((n) => classesOf(n).join(' ')), ['hk-ic hk-ic-telefon'])
|
||||
const link = nodes(dl.Quelle, (n) => n.tag === 'a')
|
||||
assert.equal(link.length, 1)
|
||||
assert.deepEqual([link[0].props.href, link[0].props.target, link[0].props.rel, link[0].props['aria-label']], ['https://beispiel-elektro.de/impressum', '_blank', 'noopener noreferrer', 'Quelle öffnen: Elektro Beispiel GmbH'])
|
||||
assert.equal(textOf(link[0]), 'Quelle öffnen')
|
||||
assert.equal(textOf(dl.Gefunden), '19.10.2026')
|
||||
assert.ok(textOf(dialog).includes('Nur zum Nachsehen, hier wird nichts verschickt.'))
|
||||
// Zeile und Akte gehören zusammen: die Akte kennt Nr. und Seite
|
||||
assert.ok(textOf(byClass(dialog, 'hk-kl-sr')[0]).startsWith('Akte 1 von 5: Elektro Beispiel GmbH'))
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Akte: Heilberuf (Ärzte & Praxen, Ali): nie eine Nummer, Schloss; unsichere Quelle ist kein Link', async () => {
|
||||
const api = fakeApi()
|
||||
const ui = await start(api, {})
|
||||
try {
|
||||
await ui.answer(0, { items: SEITE, total: 5 })
|
||||
await ui.openRow(1)
|
||||
assert.equal(textOf(byClass(ui.akte(), 'hk-kl-akte-raum')[0]), 'Ärzte & Praxen · gefunden von Ali')
|
||||
const dl = ui.akteDl()
|
||||
assert.equal(textOf(dl.Telefon), 'bewusst nicht gespeichert')
|
||||
assert.deepEqual(byClass(dl.Telefon, 'hk-ic').map((n) => classesOf(n).join(' ')), ['hk-ic hk-ic-schloss'])
|
||||
assert.equal(nodes(dl.Quelle, (n) => n.tag === 'a').length, 0)
|
||||
assert.equal(textOf(dl.Quelle), '–')
|
||||
assert.equal(nodes(ui.akte(), (n) => n.tag === 'a').length, 0)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Akte: auch ein Heilberuf mit Altbestand (Nummer und Quelle im Datensatz) zeigt die Nummer nie', async () => {
|
||||
const api = fakeApi()
|
||||
const ui = await start(api, {})
|
||||
try {
|
||||
await ui.answer(0, { items: [{ ...PRAXIS, phone: '0631 999', phoneSource: 'https://alt.example' }], total: 1 })
|
||||
await ui.openRow(0)
|
||||
assert.equal(textOf(ui.akteDl().Telefon), 'bewusst nicht gespeichert')
|
||||
assert.ok(!textOf(ui.akte()).includes('0631 999'))
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Akte: Scout je Raum (Sven, Lea), Telefon ohne Quelle „nicht verfügbar“, unbekannter Raum ohne Scout, gekürzter Eintrag mit „gekürzt“', async () => {
|
||||
const api = fakeApi()
|
||||
const ui = await start(api, {})
|
||||
try {
|
||||
await ui.answer(0, { items: [...SEITE, { ...ITEM, id: 'p7', room: 'xyz', roomLabel: 'Sonstige' }], total: 6 })
|
||||
await ui.openRow(3) // selbst
|
||||
assert.equal(textOf(byClass(ui.akte(), 'hk-kl-akte-raum')[0]), 'Selbstständige · gefunden von Sven')
|
||||
assert.equal(textOf(ui.akteDl().Telefon), '0631 111')
|
||||
await ui.click('Nächste Akte') // laden: Nummer ohne Quelle gilt als nicht belegt
|
||||
assert.equal(textOf(byClass(ui.akte(), 'hk-kl-akte-raum')[0]), 'Läden & Gastro · gefunden von Lea')
|
||||
assert.equal(textOf(ui.akteDl().Telefon), 'nicht verfügbar')
|
||||
assert.equal(textOf(byClass(ui.akteDl().Grund, 'hk-kl-akte-erklaerung')[0]), 'Zu wenige Punkte für eine Mail (Grenze 55).')
|
||||
assert.deepEqual(byClass(ui.akteDl().Punkte, 'hk-kl-balken-fuell').map((n) => classesOf(n).join(' ')), ['hk-kl-balken-fuell hk-kl-farbe-schlecht'])
|
||||
await ui.click('Nächste Akte') // Raum ohne Scout
|
||||
assert.equal(textOf(byClass(ui.akte(), 'hk-kl-akte-raum')[0]), 'Sonstige')
|
||||
await ui.openRow(2) // gekürzt
|
||||
assert.deepEqual(Object.keys(ui.akteDl()), ['Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden', 'Kürzung'])
|
||||
assert.equal(textOf(ui.akteDl().Kürzung), 'gekürzt')
|
||||
assert.equal(textOf(ui.akteDl().Quelle), '–')
|
||||
assert.equal(ui.akte().props['aria-label'], 'Akte [gekürzt]')
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Akte: unbekannter Grund zeigt das neutrale Symbol und den allgemeinen Satz', async () => {
|
||||
const api = fakeApi()
|
||||
const ui = await start(api, {})
|
||||
try {
|
||||
await ui.answer(0, { items: [{ ...ITEM, reason: 'ein ganz neuer Grund' }], total: 1 })
|
||||
await ui.openRow(0)
|
||||
const g = ui.akteDl().Grund
|
||||
assert.equal(textOf(byClass(g, 'hk-kl-akte-grundtext')[0]), 'ein ganz neuer Grund')
|
||||
assert.deepEqual(byClass(g, 'hk-ic').map((n) => classesOf(n).join(' ')), ['hk-ic hk-ic-akte'])
|
||||
assert.match(textOf(byClass(g, 'hk-kl-akte-erklaerung')[0]), /^Der Hustler schreibt diesem Betrieb nicht\./)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Akte: Blättern ◂ ▸ innerhalb der Seite (im Kreis), mit den Pfeiltasten, Alt+Pfeil bleibt dem Browser', async () => {
|
||||
const api = fakeApi()
|
||||
const ui = await start(api, {})
|
||||
try {
|
||||
await ui.answer(0, { items: [ITEM, PRAXIS, KUERZ], total: 120, pageSize: 50 })
|
||||
const name = () => textOf(nodes(ui.akte(), (n) => n.tag === 'h2')[0])
|
||||
const live = () => textOf(byClass(ui.akte(), 'hk-kl-sr')[0])
|
||||
await ui.openRow(1)
|
||||
assert.equal(name(), 'Praxis Dr. Muster')
|
||||
assert.equal(live(), 'Akte 2 von 3: Praxis Dr. Muster')
|
||||
await ui.click('Nächste Akte')
|
||||
assert.equal(name(), '[gekürzt]')
|
||||
await ui.click('Nächste Akte') // am Ende der Seite wieder von vorn
|
||||
assert.equal(name(), 'Elektro Beispiel GmbH')
|
||||
assert.equal(live(), 'Akte 1 von 3: Elektro Beispiel GmbH')
|
||||
await ui.click('Vorherige Akte') // vom Anfang ans Ende
|
||||
assert.equal(name(), '[gekürzt]')
|
||||
const rechts = await ui.key('ArrowRight')
|
||||
assert.equal(rechts.prevented, true)
|
||||
assert.equal(name(), 'Elektro Beispiel GmbH')
|
||||
const links = await ui.key('ArrowLeft')
|
||||
assert.equal(links.prevented, true)
|
||||
assert.equal(name(), '[gekürzt]')
|
||||
for (const mod of [{ altKey: true }, { ctrlKey: true }, { metaKey: true }]) {
|
||||
const ev = await ui.key('ArrowLeft', mod)
|
||||
assert.equal(ev.prevented, false)
|
||||
assert.equal(name(), '[gekürzt]')
|
||||
}
|
||||
// die Akte blättert nur in der Seite: kein neuer Abruf
|
||||
assert.equal(api.calls.length, 1)
|
||||
// beide Pfeile sind Knöpfe mit Beschriftung (Symbol ohne Text)
|
||||
for (const label of ['Vorherige Akte', 'Nächste Akte']) {
|
||||
const b = buttons(ui.akte(), label)
|
||||
assert.equal(b.length, 1)
|
||||
assert.equal(b[0].props.type, 'button')
|
||||
}
|
||||
// ein einzelner Eintrag: Blättern bleibt, wo es ist
|
||||
} finally { ui.end() }
|
||||
const api2 = fakeApi()
|
||||
const eins = await start(api2, {})
|
||||
try {
|
||||
await eins.answer(0, { items: [ITEM], total: 1 })
|
||||
await eins.openRow(0)
|
||||
await eins.click('Nächste Akte')
|
||||
assert.equal(textOf(nodes(eins.akte(), (n) => n.tag === 'h2')[0]), 'Elektro Beispiel GmbH')
|
||||
await eins.click('Vorherige Akte')
|
||||
assert.equal(textOf(nodes(eins.akte(), (n) => n.tag === 'h2')[0]), 'Elektro Beispiel GmbH')
|
||||
} finally { eins.end() }
|
||||
})
|
||||
|
||||
test('Akte schließen: Esc, Schließen-Knopf und Hintergrund; Esc wird verbraucht', async () => {
|
||||
const api = fakeApi()
|
||||
const ui = await start(api, {})
|
||||
try {
|
||||
await ui.answer(0, { items: SEITE, total: 5 })
|
||||
await ui.openRow(0)
|
||||
const esc = await ui.key('Escape')
|
||||
assert.equal(esc.prevented, true)
|
||||
assert.equal(ui.akte(), null)
|
||||
await ui.openRow(0)
|
||||
await ui.click('Schließen')
|
||||
assert.equal(ui.akte(), null)
|
||||
await ui.openRow(0)
|
||||
const hinten = buttons(ui.tree(), 'Akte schließen')
|
||||
assert.equal(hinten.length, 1)
|
||||
assert.equal(hinten[0].props.tabIndex, -1) // der Hintergrund-Knopf kommt nicht in die Tab-Reihenfolge
|
||||
await ui.click('Akte schließen')
|
||||
assert.equal(ui.akte(), null)
|
||||
// andere Tasten schließen nichts
|
||||
await ui.openRow(0)
|
||||
for (const k of ['Enter', ' ', 'a', 'ArrowUp']) await ui.key(k)
|
||||
assert.notEqual(ui.akte(), null)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Akte: Fokus auf „Schließen“ beim Öffnen, Tab bleibt im Dialog, beim Schließen zurück auf die Zeile (die zuletzt gezeigte)', async () => {
|
||||
const api = fakeApi()
|
||||
const dom = fakeFocus()
|
||||
const ui = await start(api, {})
|
||||
dom.attach(ui)
|
||||
try {
|
||||
await ui.answer(0, { items: [ITEM, PRAXIS, KUERZ], total: 3 })
|
||||
await dom.pressRow(1)
|
||||
assert.deepEqual(dom.focused, ['Schließen'])
|
||||
assert.deepEqual(dom.options, [{ preventScroll: true }])
|
||||
assert.equal(dom.doc.activeElement.key, 'Schließen')
|
||||
// Fokusfalle: Tab am letzten Knopf (Nächste Akte) → Schließen; Shift+Tab am ersten → Nächste Akte; dazwischen darf der Browser selbst weiter
|
||||
dom.doc.activeElement = dom.made.get('Nächste Akte')
|
||||
let ev = await ui.key('Tab')
|
||||
assert.equal(ev.prevented, true)
|
||||
assert.equal(dom.doc.activeElement.key, 'Schließen')
|
||||
ev = await ui.key('Tab', { shiftKey: true })
|
||||
assert.equal(ev.prevented, true)
|
||||
assert.equal(dom.doc.activeElement.key, 'Nächste Akte')
|
||||
dom.doc.activeElement = dom.made.get('Schließen')
|
||||
ev = await ui.key('Tab')
|
||||
assert.equal(ev.prevented, false) // Schließen → Vorherige Akte: der Browser geht weiter
|
||||
dom.doc.activeElement = dom.other('Vorherige Akte')
|
||||
ev = await ui.key('Tab', { shiftKey: true })
|
||||
assert.equal(ev.prevented, false)
|
||||
// auch wenn der Fokus auf der Karte selbst liegt, führt Shift+Tab ans Ende
|
||||
const karte = ui.akte()
|
||||
dom.doc.activeElement = { name: 'karte' }
|
||||
ev = await ui.key('Tab', { shiftKey: true, currentTarget: { name: 'karte' } })
|
||||
assert.equal(ev.prevented, false)
|
||||
dom.doc.activeElement = dom.other('x')
|
||||
// in der Akte weiterblättern, dann Esc: der Fokus geht an die Zeile des zuletzt gezeigten Eintrags
|
||||
await ui.click('Nächste Akte')
|
||||
assert.equal(karte.props.role, 'dialog')
|
||||
dom.focused.length = 0; dom.options.length = 0
|
||||
await ui.key('Escape')
|
||||
assert.equal(ui.akte(), null)
|
||||
assert.deepEqual(dom.focused, ['zeile 2'])
|
||||
assert.equal(dom.doc.activeElement.key, 'zeile 2')
|
||||
} finally { dom.restore(); ui.end() }
|
||||
})
|
||||
|
||||
test('Akte: findet der Browser die Zeile nicht mehr, geht der Fokus an das Element, das beim Öffnen den Fokus hatte; ohne beides bleibt er unberührt', async () => {
|
||||
const api = fakeApi()
|
||||
const dom = fakeFocus()
|
||||
const ui = await start(api, {})
|
||||
dom.attach(ui)
|
||||
try {
|
||||
await ui.answer(0, { items: [ITEM, PRAXIS], total: 2 })
|
||||
dom.doc.querySelector = () => null
|
||||
await dom.pressRow(0)
|
||||
dom.focused.length = 0
|
||||
await ui.key('Escape')
|
||||
assert.deepEqual(dom.focused, ['zeile 0']) // der Opener (Zeilenknopf von pressRow)
|
||||
// beim Öffnen hatte body den Fokus (Safari fokussiert Knöpfe nicht): ohne Zeile und Opener bleibt der Fokus, wo er ist
|
||||
dom.doc.activeElement = dom.body
|
||||
await ui.openRow(1)
|
||||
dom.focused.length = 0
|
||||
dom.doc.activeElement = dom.other('Irgendwo')
|
||||
await ui.key('Escape')
|
||||
assert.deepEqual(dom.focused, [])
|
||||
// ohne document (Node, Server) bricht nichts
|
||||
delete globalThis.document
|
||||
await ui.openRow(0)
|
||||
await ui.key('Escape')
|
||||
assert.equal(ui.akte(), null)
|
||||
} finally { dom.restore(); ui.end() }
|
||||
})
|
||||
|
||||
test('Akte: eine neue Anfrage (Raum, Suche, Seite, Fehler) schließt sie; sie steht nie über „Wird geladen“ oder einer Fehlermeldung', async () => {
|
||||
const api = fakeApi()
|
||||
const ui = await start(api, {})
|
||||
try {
|
||||
await ui.answer(0, { items: SEITE, total: 120, pageSize: 50 })
|
||||
await ui.openRow(0)
|
||||
assert.notEqual(ui.akte(), null)
|
||||
await ui.pick(idx('laden'))
|
||||
assert.equal(ui.akte(), null)
|
||||
assert.ok(ui.text().includes('Wird geladen'))
|
||||
await ui.answer(1, { items: SEITE, total: 5 })
|
||||
assert.equal(ui.akte(), null) // sie kommt nicht von selbst wieder
|
||||
await ui.openRow(4)
|
||||
await ui.type('x'); ui.fireTimers(); await settle()
|
||||
assert.equal(ui.akte(), null)
|
||||
await ui.fail(2, new Error('weg'))
|
||||
assert.equal(ui.akte(), null)
|
||||
assert.deepEqual(ui.errors().map((e) => e.text), ['weg'])
|
||||
await ui.click('Erneut versuchen')
|
||||
await ui.answer(3, { items: SEITE, total: 120, pageSize: 50 })
|
||||
assert.equal(ui.akte(), null)
|
||||
await ui.openRow(0)
|
||||
await ui.click('Nächste Seite')
|
||||
assert.equal(ui.akte(), null)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Zeile: ein Knopf (Beschriftung für Screenreader, Nr. für die Fokus-Rückgabe), die Akte öffnet nur durch einen Klick', async () => {
|
||||
const api = fakeApi()
|
||||
const ui = await start(api, {})
|
||||
try {
|
||||
await ui.answer(0, { items: SEITE, total: 5 })
|
||||
const rows = ui.rows()
|
||||
assert.deepEqual(rows.map((r) => r.props['data-hk-zeile']), [0, 1, 2, 3, 4])
|
||||
assert.equal(rows[1].props['aria-label'], 'Praxis Dr. Muster, 67655 Kaiserslautern, 82 Punkte, keine veröffentlichte Mail, gefunden am 18.10.2026')
|
||||
assert.equal(rows[2].props['aria-label'], '[gekürzt] (gekürzt), 67655 Kaiserslautern, 82 Punkte, keine veröffentlichte Mail, gefunden am 19.10.2026')
|
||||
assert.equal(ui.akte(), null)
|
||||
// die Raum-Symbole je Zeile
|
||||
assert.deepEqual(rows.map((r) => ui.parts(r).raumIcon), ['hk-ic hk-ic-raum-handwerk', 'hk-ic hk-ic-raum-aerzte', 'hk-ic hk-ic-raum-handwerk', 'hk-ic hk-ic-raum-selbst', 'hk-ic hk-ic-raum-laden'])
|
||||
// Grund-Symbol je Zeile
|
||||
const symbole = rows.map((r) => classesOf(byClass(r, 'hk-zelle')[1].children[0]).join(' '))
|
||||
assert.deepEqual(symbole, ['hk-ic hk-ic-brief-x', 'hk-ic hk-ic-brief-x', 'hk-ic hk-ic-brief-x', 'hk-ic hk-ic-kette', 'hk-ic hk-ic-pfeil-runter'])
|
||||
// Punkte-Balken: Farbe und Breite aus der Punktzahl
|
||||
assert.deepEqual(rows.map((r) => byClass(r, 'hk-kl-balken-fuell').map((n) => `${classesOf(n).at(-1)} ${n.props.style.width}`)[0]), ['hk-kl-farbe-gut 39', 'hk-kl-farbe-gut 39', 'hk-kl-farbe-gut 39', 'hk-kl-farbe-mittel 36', 'hk-kl-farbe-schlecht 19'])
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Klassen, Stile, Quelltext
|
||||
|
||||
const CSS_DATEIEN = ['src/styles/hustler/keller.css', 'src/styles/hustler/hustler-keller-ui.css', 'src/styles/hustler/hustler-ui.css']
|
||||
const cssOhne = (p) => read(p).replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
const cssKlassen = (p) => new Set((cssOhne(p).replace(/url\([^)]*\)/g, '').match(/\.hk-[A-Za-z0-9_-]+/g) ?? []).map((k) => k.slice(1)))
|
||||
const CSS_ALLE = new Set(CSS_DATEIEN.flatMap((p) => [...cssKlassen(p)]))
|
||||
|
||||
test('Klassen: jede gerenderte Klasse beginnt mit hk- und steht in keller.css, hustler-keller-ui.css oder hustler-ui.css (Alarm-Marker)', async () => {
|
||||
const gesehen = new Set()
|
||||
const sammle = (ui) => { for (const c of allClasses(ui.tree())) gesehen.add(c) }
|
||||
const alleKlassen = (ui) => {
|
||||
nodes(ui.tree(), (n) => { for (const c of classesOf(n)) assert.match(c, /^hk-[a-z0-9-]+$/, `Klasse ohne hk-: ${c}`); return false })
|
||||
}
|
||||
// alle Arne-Zustände
|
||||
for (const s of ['arbeitet', 'wartet', 'leerlauf', 'grau', 'fehler']) {
|
||||
const { ui } = await szene({ arne: eintrag(s) })
|
||||
try { sammle(ui); alleKlassen(ui) } finally { ui.end() }
|
||||
}
|
||||
// Liste mit allen Räumen, gekürzt, Heilberuf, Akte (offen, mit Link und ohne)
|
||||
const api = fakeApi()
|
||||
const ui = await start(api, { kpis: { botLeads: 5 }, workers: { arne: eintrag('arbeitet') } })
|
||||
try {
|
||||
sammle(ui); alleKlassen(ui) // Laden
|
||||
await ui.answer(0, { items: [...SEITE, { ...ITEM, id: 'p9', score: 20 }, { ...ITEM, id: 'p10', score: undefined }], total: 120, pageSize: 50 })
|
||||
sammle(ui); alleKlassen(ui)
|
||||
for (const nr of [0, 1, 2, 3, 4, 5, 6]) {
|
||||
await ui.openRow(nr)
|
||||
sammle(ui); alleKlassen(ui)
|
||||
}
|
||||
await ui.key('Escape')
|
||||
await ui.click('Register zuklappen')
|
||||
sammle(ui); alleKlassen(ui)
|
||||
await ui.click('Register aufklappen')
|
||||
await ui.pick(idx('laden'))
|
||||
await ui.fail(1, new Error('weg'))
|
||||
sammle(ui); alleKlassen(ui) // Fehler
|
||||
await ui.pick(idx('selbst'))
|
||||
await ui.answer(2, { items: [], total: 0 })
|
||||
sammle(ui); alleKlassen(ui) // leer
|
||||
} finally { ui.end() }
|
||||
const einzeln = await start(fakeApi(), { bot: 'p1', onClearBot: () => {} })
|
||||
try {
|
||||
sammle(einzeln)
|
||||
await einzeln.answer(0, { items: [ITEM], total: 1 })
|
||||
sammle(einzeln); alleKlassen(einzeln) // Einzelansicht
|
||||
} finally { einzeln.end() }
|
||||
assert.ok(gesehen.size > 60, `nur ${gesehen.size} Klassen gesehen`)
|
||||
const fehlen = [...gesehen].filter((c) => !CSS_ALLE.has(c))
|
||||
assert.deepEqual(fehlen, [], `Klassen ohne Regel: ${fehlen.join(', ')}`)
|
||||
// Zustandsklassen der Pose und der Punkte-Farbe kommen vor
|
||||
for (const c of ['hk-kl-fig-warten', 'hk-kl-fig-schlafen', 'hk-kl-fig-nicht', 'hk-kl-farbe-gut', 'hk-kl-farbe-mittel', 'hk-kl-farbe-schlecht', 'hk-kl-farbe-leer', 'hk-alarm', 'hk-arne-weg', 'hk-zeile-alt', 'hk-reiter-an', 'hk-kl-tag']) {
|
||||
assert.ok(gesehen.has(c), `${c} nie gerendert`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Quelltext: keine CSS- und Bild-Importe in den Komponenten, kein innerHTML, keine Inline-Bilder', () => {
|
||||
const dateien = ['src/components/workers/BotLeadList.jsx', 'src/components/workers/keller/KellerSzene.jsx', 'src/components/workers/keller/RegisterBuch.jsx', 'src/components/workers/keller/Akte.jsx', 'src/lib/hustlerKeller.js', 'src/lib/botleadGruende.js']
|
||||
for (const p of dateien) {
|
||||
const src = ohneKommentare(read(p))
|
||||
assert.doesNotMatch(src, /^\s*import\s[^;]*?['"][^'"]*\.(?:css|scss|png|jpe?g|svg|gif|webp)['"]/m, `${p}: Import von CSS oder Bild`)
|
||||
assert.doesNotMatch(src, /^\s*import\s+['"][^'"]*\.(?:css|png|svg)['"]/m, `${p}: Import von CSS oder Bild`)
|
||||
assert.doesNotMatch(src, /\brequire\(/, p)
|
||||
assert.doesNotMatch(src, /dangerouslySetInnerHTML|innerHTML|outerHTML|insertAdjacentHTML/, p)
|
||||
assert.doesNotMatch(src, /url\(|data:image|\.png|\.svg/, `${p}: Bildadresse im Quelltext`)
|
||||
assert.doesNotMatch(src, /\bnew Image\b|<img\b/, p)
|
||||
}
|
||||
// BotLeadList und die Teile importieren nur React, die eigenen Teile und Dateien aus src/lib
|
||||
const erlaubt = /^(?:react|\.\/keller\/(?:Akte|KellerSzene|RegisterBuch)|\.\.\/\.\.\/lib\/[A-Za-z]+|\.\.\/\.\.\/\.\.\/lib\/[A-Za-z]+)$/
|
||||
for (const p of dateien.slice(0, 4)) {
|
||||
for (const m of ohneKommentare(read(p)).matchAll(/^\s*import\b[^'"]*['"]([^'"]+)['"]/gm)) assert.match(m[1], erlaubt, `${p}: ${m[1]}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Zerlegt eine CSS-Datei in Regeln { selektor, koerper, kontext } und Keyframe-Namen
|
||||
function zerlege(css) {
|
||||
const regeln = []
|
||||
const keyframes = []
|
||||
let i = 0
|
||||
function liste(kontext) {
|
||||
for (;;) {
|
||||
while (i < css.length && /\s/.test(css[i])) i += 1
|
||||
if (i >= css.length) return
|
||||
if (css[i] === '}') { i += 1; return }
|
||||
const start = i
|
||||
while (i < css.length && css[i] !== '{' && css[i] !== '}') i += 1
|
||||
const kopf = css.slice(start, i).trim()
|
||||
assert.equal(css[i], '{', `Regel ohne Block: ${kopf.slice(0, 60)}`)
|
||||
i += 1
|
||||
if (kopf.startsWith('@keyframes')) {
|
||||
keyframes.push(kopf.slice('@keyframes'.length).trim())
|
||||
let tiefe = 1
|
||||
while (i < css.length && tiefe > 0) { if (css[i] === '{') tiefe += 1; if (css[i] === '}') tiefe -= 1; i += 1 }
|
||||
} else if (kopf.startsWith('@media')) {
|
||||
liste(kopf)
|
||||
} else {
|
||||
const b = i
|
||||
while (i < css.length && css[i] !== '}') i += 1
|
||||
regeln.push({ selektor: kopf, koerper: css.slice(b, i), kontext })
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
liste('')
|
||||
return { regeln, keyframes }
|
||||
}
|
||||
|
||||
const UI_CSS = cssOhne('src/styles/hustler/hustler-keller-ui.css')
|
||||
const ui = zerlege(UI_CSS)
|
||||
|
||||
test('hustler-keller-ui.css: jede Regel steht unter .hk-keller, jede Klasse beginnt mit hk-, eigene Klassen heißen hk-kl-…, Keyframes hk-kl-…', () => {
|
||||
assert.ok(ui.regeln.length > 60, `nur ${ui.regeln.length} Regeln`)
|
||||
for (const r of ui.regeln) {
|
||||
for (const teil of r.selektor.split(',').map((s) => s.trim())) {
|
||||
assert.ok(teil.startsWith('.hk-keller') || teil.startsWith('.hk-seite > .hk-keller'), `nicht unter .hk-keller: ${teil}`)
|
||||
assert.ok(!/(^|[\s>+~(,])(?:html|body|:root)(?![\w-])/i.test(teil), `html/body/:root in ${teil}`)
|
||||
for (const m of teil.matchAll(/\.(-?[A-Za-z_][\w-]*)/g)) assert.match(m[1], /^hk-/, `${teil}: ${m[1]}`)
|
||||
}
|
||||
}
|
||||
for (const k of ui.keyframes) assert.match(k, /^hk-kl-/, k)
|
||||
assert.equal(new Set(ui.keyframes).size, ui.keyframes.length)
|
||||
assert.match(UI_CSS.split('\n')[0] ?? '', /^/)
|
||||
assert.match(read('src/styles/hustler/hustler-keller-ui.css'), /^\/\* Keller \(BotLeadList\.jsx und keller\/\*/)
|
||||
})
|
||||
|
||||
test('hustler-keller-ui.css: jede Bilddatei aus url(...) gibt es unter src/assets/hustler, jede genannte Animation ist ein Keyframe aus dieser Datei oder aus keller.css', () => {
|
||||
const urls = [...UI_CSS.matchAll(/url\(\s*([^)]*?)\s*\)/g)].map((m) => m[1].replace(/^["']|["']$/g, ''))
|
||||
assert.ok(urls.length >= 5)
|
||||
const assets = path.join(ROOT, 'src/assets/hustler')
|
||||
for (const u of urls) {
|
||||
assert.match(u, /^\.\.\/\.\.\/assets\/hustler\/[\w./-]+\.png$/, u)
|
||||
const ziel = path.resolve(path.join(ROOT, 'src/styles/hustler'), u)
|
||||
assert.ok(ziel.startsWith(assets + path.sep) && existsSync(ziel) && statSync(ziel).isFile(), `${u} fehlt`)
|
||||
}
|
||||
assert.deepEqual([...new Set(urls)].map((u) => u.replace('../../assets/hustler/', '')).sort(), [
|
||||
'figuren/muetze-nicht.png', 'figuren/muetze-schlafen.png', 'figuren/muetze-warten.png', 'figuren/punkte.png', 'figuren/zzz.png',
|
||||
])
|
||||
const kf = new Set([...ui.keyframes, ...zerlege(cssOhne('src/styles/hustler/keller.css')).keyframes])
|
||||
const woerter = new Set(['none', 'infinite', 'linear', 'steps', 'end', 'ease', 'ease-in-out'])
|
||||
let geprueft = 0
|
||||
for (const r of ui.regeln) {
|
||||
for (const d of r.koerper.split(';')) {
|
||||
const k = d.indexOf(':')
|
||||
if (k < 0 || !/^\s*(?:-webkit-)?animation(?:-name)?\s*$/.test(d.slice(0, k))) continue
|
||||
for (const w of d.slice(k + 1).split(/[\s,]+/).filter(Boolean)) {
|
||||
if (/^[+-]?[\d.]+(?:ms|s)?$/.test(w) || w.includes('(') || w.endsWith(')') || woerter.has(w)) continue
|
||||
assert.ok(kf.has(w), `Animation ${w} ohne Keyframes`)
|
||||
geprueft += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(geprueft >= 5, `nur ${geprueft} Animationen`)
|
||||
})
|
||||
|
||||
test('hustler-keller-ui.css: jede eigene Klasse (hk-kl-…) wird gebraucht, jede gerenderte eigene Klasse hat eine Regel; weniger Bewegung stellt Arnes Animationen ab', () => {
|
||||
const quellen = ['src/components/workers/BotLeadList.jsx', 'src/components/workers/keller/KellerSzene.jsx', 'src/components/workers/keller/RegisterBuch.jsx', 'src/components/workers/keller/Akte.jsx', 'src/lib/hustlerKeller.js']
|
||||
.map((p) => ohneKommentare(read(p))).join('\n')
|
||||
const eigene = [...cssKlassen('src/styles/hustler/hustler-keller-ui.css')].filter((k) => k.startsWith('hk-kl-'))
|
||||
assert.ok(eigene.length > 40)
|
||||
// dynamisch gebildete Klassen (hk-kl-fig-<Pose>, hk-kl-farbe-<Farbe>) stehen mit ihrem Vorsatz im Quelltext, alle Fälle als Regel (unten)
|
||||
for (const k of eigene) assert.ok(quellen.includes(k) || /^hk-kl-(?:fig|farbe)-/.test(k), `${k} steht im CSS, aber in keiner Komponente`)
|
||||
assert.ok(quellen.includes('hk-kl-fig-${') && quellen.includes('hk-kl-farbe-${'))
|
||||
for (const pose of ['warten', 'schlafen', 'nicht']) assert.ok(eigene.includes(`hk-kl-fig-${pose}`))
|
||||
for (const farbe of ['gut', 'mittel', 'schlecht', 'leer']) assert.ok(eigene.includes(`hk-kl-farbe-${farbe}`))
|
||||
// jede Regel mit einer Animation (außer „none“) steht im Block „weniger Bewegung“
|
||||
const reduce = ui.regeln.filter((r) => r.kontext.includes('prefers-reduced-motion'))
|
||||
assert.ok(reduce.length >= 1)
|
||||
const reduziert = reduce.map((r) => r.selektor).join(',')
|
||||
for (const r of ui.regeln.filter((x) => !x.kontext.includes('prefers-reduced-motion'))) {
|
||||
const anim = /(?:^|;)\s*animation\s*:\s*([^;]+)/.exec(r.koerper)
|
||||
if (!anim || /^\s*none\b/.test(anim[1])) continue
|
||||
for (const teil of r.selektor.split(',').map((s) => s.trim())) assert.ok(reduziert.includes(teil), `${teil} animiert, aber nicht in „weniger Bewegung“`)
|
||||
}
|
||||
for (const r of reduce) assert.match(r.koerper, /animation\s*:\s*none/)
|
||||
})
|
||||
|
||||
test('hustler-keller-ui.css: Handy unter 720 px (E13): Buch in voller Breite über abgedunkelter Szene, Akte als Vollbild', () => {
|
||||
const handy = ui.regeln.filter((r) => /max-width:\s*719px/.test(r.kontext))
|
||||
assert.ok(handy.length >= 8)
|
||||
const finde = (sel) => handy.find((r) => r.selektor.split(',').map((s) => s.trim()).includes(sel))
|
||||
assert.match(finde('.hk-keller .hk-kl-dunkel').koerper, /display:\s*block[\s\S]*rgba\(6, 7, 18, 0\.62\)/)
|
||||
assert.match(finde('.hk-keller .hk-kl-ui').koerper, /left:\s*8px[\s\S]*width:\s*auto/)
|
||||
const akte = finde('.hk-keller .hk-akte.hk-kl-akte').koerper
|
||||
assert.match(akte, /width:\s*100%/); assert.match(akte, /height:\s*100%/); assert.match(akte, /max-height:\s*none/)
|
||||
assert.match(finde('.hk-keller .hk-kl-akte-ueber').koerper, /padding:\s*0/)
|
||||
// die Abdunklung ist am Desktop aus
|
||||
assert.match(ui.regeln.find((r) => r.selektor === '.hk-keller .hk-kl-dunkel' && r.kontext === '').koerper, /display:\s*none/)
|
||||
// Reiter wischen seitlich, Spalten Punkte und Telefon entfallen
|
||||
assert.match(finde('.hk-keller .hk-kl-reiter').koerper, /overflow-x:\s*auto/)
|
||||
assert.ok(finde('.hk-keller .hk-kl-buch .hk-kl-spalte-punkte'))
|
||||
})
|
||||
|
||||
test('hustler-keller-ui.css: Überschreibungen erzeugter Regeln haben höhere Spezifität (Reihenfolge der Stylesheets egal)', () => {
|
||||
// Wo eine eigene Regel eine erzeugte aus keller.css überstimmt (Knopf, Zeile, Reiter, Akte, Symbol, Suche), steht eine zweite Klasse davor
|
||||
const erzeugt = ['hk-knopf', 'hk-zeile', 'hk-kopfzeile', 'hk-reiter', 'hk-akte', 'hk-ic', 'hk-suche', 'hk-zelle', 'hk-sz-rohrpost', 'hk-sz-schublade', 'hk-sz-buch-stempel']
|
||||
for (const r of ui.regeln) {
|
||||
for (const teil of r.selektor.split(',').map((s) => s.trim())) {
|
||||
const klassen = [...teil.matchAll(/\.(hk-[\w-]+)/g)].map((m) => m[1]).filter((k) => k !== 'hk-keller')
|
||||
const trifft = klassen.filter((k) => erzeugt.includes(k))
|
||||
if (trifft.length === 0) continue
|
||||
// eine erzeugte Klasse im Selektor: mindestens zwei Klassen nach .hk-keller oder ein Elementname
|
||||
assert.ok(klassen.length >= 2 || /\.hk-[\w-]+\s*:|input\./.test(teil) || /\sinput\.hk/.test(teil), `${teil}: nur eine erzeugte Klasse, Reihenfolge der Stylesheets entscheidet`)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
// Reine Daten und Ableitungen des Kellers (src/lib/hustlerKeller.js, Hustler-Pixelwelt T10, Spec E12/E13): Einpassen der Szene, Lage der Dinge, Arne nach Status,
|
||||
// Raum-Reiter, Scout, Punkte-Anzeige, Zeilenmodell eines Bot-Leads.
|
||||
|
||||
const K = await loadModule('src/lib/hustlerKeller.js')
|
||||
const V = await loadModule('src/lib/botleadsView.js')
|
||||
const { SZENE, kellerEinpassen, FENSTER, DAMPF, BUCH_STEMPEL, ROHRPOST, SCHUBLADE, MAUS_STREIFEN, ARNE_RUHE, LEDS, REITER, raumIcon, scoutFuer, punkteInfo, eintragModell, arneAnsicht } = K
|
||||
const { ROOM_FILTERS } = V
|
||||
|
||||
const read = (p) => readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
|
||||
const ohneKommentare = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
const kellerCss = ohneKommentare(read('src/styles/hustler/keller.css'))
|
||||
const uiCss = ohneKommentare(read('src/styles/hustler/hustler-keller-ui.css'))
|
||||
const klassen = (css) => new Set((css.replace(/url\([^)]*\)/g, '').match(/\.hk-[A-Za-z0-9_-]+/g) ?? []).map((k) => k.slice(1)))
|
||||
const CSS = new Set([...klassen(kellerCss), ...klassen(uiCss)])
|
||||
const KEYFRAMES = new Set([...kellerCss.matchAll(/@keyframes\s+([\w-]+)/g)].map((m) => m[1]))
|
||||
const px = (s) => Number.parseFloat(s)
|
||||
|
||||
function tiefEingefroren(wert, pfad) {
|
||||
if (wert === null || typeof wert !== 'object') return
|
||||
assert.ok(Object.isFrozen(wert), `${pfad} ist nicht eingefroren`)
|
||||
for (const [k, v] of Object.entries(wert)) tiefEingefroren(v, `${pfad}.${k}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Szene
|
||||
|
||||
test('kellerEinpassen: „cover“ links verankert, Maßstab = größeres Seitenverhältnis, Vorgabe ohne Messung', () => {
|
||||
assert.deepEqual(SZENE, { breite: 640, hoehe: 360 })
|
||||
assert.deepEqual(kellerEinpassen(1280, 720), { s: 2, x: 0, y: 0 })
|
||||
assert.deepEqual(kellerEinpassen(1920, 1080), { s: 3, x: 0, y: 0 })
|
||||
// breiter als hoch: der Maßstab folgt der Höhe, links bleibt verankert (x nie positiv, nur ein Teil des Überstands wandert)
|
||||
const schmal = kellerEinpassen(1280, 900)
|
||||
assert.equal(schmal.s, 2.5)
|
||||
assert.equal(schmal.x, -48)
|
||||
assert.equal(schmal.y, 0)
|
||||
// sehr breit: der Maßstab folgt der Breite, die Szene steht tiefer (der untere Rand bleibt, höchstens 36 Szenen-Pixel gehen unten verloren)
|
||||
const breit = kellerEinpassen(2560, 720)
|
||||
assert.equal(breit.s, 4)
|
||||
assert.equal(breit.x, 0)
|
||||
assert.equal(breit.y, -576)
|
||||
// Handy hochkant
|
||||
const handy = kellerEinpassen(390, 844)
|
||||
assert.ok(Math.abs(handy.s - 844 / 360) < 1e-9)
|
||||
assert.ok(handy.x < 0 && handy.x > -(640 * handy.s - 390))
|
||||
assert.equal(handy.y, 0)
|
||||
// ohne brauchbare Größe: Vorgabe der Vorlage
|
||||
for (const [b, h] of [[0, 0], [NaN, 720], [1280, NaN], [-1, 700], [1280, 0], [undefined, undefined], ['1280', '720']]) {
|
||||
assert.deepEqual(kellerEinpassen(b, h), { s: 2, x: 0, y: 0 }, `${b} x ${h}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('kellerEinpassen: die Szene deckt den Rahmen immer ganz (cover) und liegt nie rechts vom linken Rand', () => {
|
||||
for (const [b, h] of [[1280, 720], [1920, 1080], [1048, 720], [390, 844], [768, 1024], [2560, 1440], [3440, 1440], [320, 568], [1000, 300]]) {
|
||||
const { s, x, y } = kellerEinpassen(b, h)
|
||||
assert.ok(640 * s >= b - 1e-9 && 360 * s >= h - 1e-9, `${b} x ${h}: Szene kleiner als der Rahmen`)
|
||||
assert.ok(x <= 0 && x + 640 * s >= b - 1e-9, `${b} x ${h}: Lücke links oder rechts (x ${x})`)
|
||||
assert.ok(y <= 0 && y + 360 * s >= h - 1e-9, `${b} x ${h}: Lücke oben oder unten (y ${y})`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Lage der Dinge: alles liegt in der 640 x 360 großen Szene, Arne am Pult steht auf der Laufline, Daten sind eingefroren', () => {
|
||||
for (const [name, o] of Object.entries({ FENSTER, DAMPF, BUCH_STEMPEL, ROHRPOST, SCHUBLADE, MAUS_STREIFEN, ARNE_RUHE })) {
|
||||
assert.equal(o.position, 'absolute', name)
|
||||
assert.ok(px(o.left) >= 0 && px(o.top) >= 0 && px(o.left) < 640 && px(o.top) < 360, name)
|
||||
assert.match(o.left, /^\d+px$/); assert.match(o.top, /^\d+px$/)
|
||||
}
|
||||
// Maße der Vorlage (BotLeads.dc.html)
|
||||
assert.deepEqual([FENSTER.left, FENSTER.top, FENSTER.width, FENSTER.height, FENSTER.overflow], ['23px', '34px', '46px', '29px', 'hidden'])
|
||||
assert.deepEqual([ROHRPOST.left, ROHRPOST.top], ['130px', '224px'])
|
||||
assert.deepEqual([SCHUBLADE.left, SCHUBLADE.top], ['214px', '279px'])
|
||||
assert.deepEqual([BUCH_STEMPEL.left, BUCH_STEMPEL.top], ['60px', '252px'])
|
||||
assert.deepEqual([DAMPF.left, DAMPF.top], ['26px', '247px'])
|
||||
assert.deepEqual([MAUS_STREIFEN.left, MAUS_STREIFEN.top, MAUS_STREIFEN.width, MAUS_STREIFEN.height], ['30px', '336px', '238px', '24px'])
|
||||
assert.equal(px(MAUS_STREIFEN.top) + px(MAUS_STREIFEN.height), 360)
|
||||
// Haus-Figur 24 x 32: die Füße stehen auf der Laufline (318), Mitte beim Halt am Pult (128)
|
||||
assert.equal(px(ARNE_RUHE.top) + 32, 318)
|
||||
assert.equal(px(ARNE_RUHE.left) + 12, 128)
|
||||
for (const [name, o] of Object.entries({ SZENE, FENSTER, DAMPF, BUCH_STEMPEL, ROHRPOST, SCHUBLADE, MAUS_STREIFEN, ARNE_RUHE, LEDS, REITER })) tiefEingefroren(o, name)
|
||||
})
|
||||
|
||||
test('Blinkpunkte: sechs, jeder mit Keyframes aus keller.css (hk-k-k-led-…), Maße und Takte der Vorlage', () => {
|
||||
assert.deepEqual(LEDS.map((l) => l.name), ['weiche-gruen', 'weiche-bernstein', 'sicherung-1', 'sicherung-2', 'sicherung-3', 'verteiler'])
|
||||
for (const l of LEDS) {
|
||||
const m = /^(hk-k-k-led-[a-z0-9-]+) (\d+)ms steps\(1,end\) infinite$/.exec(l.stil.animation)
|
||||
assert.ok(m, l.stil.animation)
|
||||
assert.equal(m[1], `hk-k-k-led-${l.name}`)
|
||||
assert.ok(KEYFRAMES.has(m[1]), `${m[1]} fehlt in keller.css`)
|
||||
assert.equal(l.stil.height, '2px')
|
||||
assert.match(l.stil.background, /^#[0-9a-f]{6}$/)
|
||||
assert.ok(px(l.stil.left) < 640 && px(l.stil.top) < 360)
|
||||
}
|
||||
assert.deepEqual(LEDS.map((l) => px(l.stil.animation.split(' ')[1])), [1800, 2600, 3000, 2200, 3400, 2000])
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Arne
|
||||
|
||||
const eintrag = (status) => (status === 'grau' ? { status: 'grau', task: '', lastTs: null, connected: false, counters: {} } : { status, task: '', lastTs: null, connected: true, counters: {} })
|
||||
|
||||
test('arneAnsicht: arbeitet ⇒ Ablauf-Schleife; sonst Haus-Figur nach Status (warten, schlafen, nicht), Fehler mit Marker', () => {
|
||||
const arbeit = arneAnsicht({ arne: eintrag('arbeitet') })
|
||||
assert.deepEqual(arbeit, { status: 'arbeitet', arbeitet: true, pose: 'arbeiten', alarm: false, punkte: false, zzz: false, label: 'Arne, Haiku, arbeitet' })
|
||||
assert.deepEqual(arneAnsicht({ arne: eintrag('wartet') }), { status: 'wartet', arbeitet: false, pose: 'warten', alarm: false, punkte: true, zzz: false, label: 'Arne, Haiku, wartet auf Auftrag' })
|
||||
assert.deepEqual(arneAnsicht({ arne: eintrag('leerlauf') }), { status: 'leerlauf', arbeitet: false, pose: 'schlafen', alarm: false, punkte: false, zzz: true, label: 'Arne, Haiku, Leerlauf' })
|
||||
assert.deepEqual(arneAnsicht({ arne: eintrag('grau') }), { status: 'grau', arbeitet: false, pose: 'nicht', alarm: false, punkte: false, zzz: false, label: 'Arne, Haiku, noch nicht angeschlossen' })
|
||||
assert.deepEqual(arneAnsicht({ arne: eintrag('fehler') }), { status: 'fehler', arbeitet: false, pose: 'nicht', alarm: true, punkte: false, zzz: false, label: 'Arne, Haiku, Fehler' })
|
||||
})
|
||||
|
||||
test('arneAnsicht: nur „arbeitet“ arbeitet; ohne Tabelle, ohne Eintrag oder mit Unsinn schläft Arne (Leerlauf)', () => {
|
||||
for (const workers of [undefined, null, {}, [], 'x', 5, { otto: eintrag('arbeitet') }, { arne: null }, { arne: 'arbeitet' }, { arne: { status: 'irgendwas' } }, { arne: {} }]) {
|
||||
const a = arneAnsicht(workers)
|
||||
assert.equal(a.arbeitet, false, JSON.stringify(workers))
|
||||
assert.equal(a.status, 'leerlauf')
|
||||
assert.equal(a.pose, 'schlafen')
|
||||
assert.equal(a.alarm, false)
|
||||
}
|
||||
// ein Eintrag anderer Hustler ändert nichts: nur arne zählt
|
||||
assert.equal(arneAnsicht({ arne: eintrag('wartet'), otto: eintrag('arbeitet'), ali: eintrag('fehler') }).status, 'wartet')
|
||||
// arbeitet und gleichzeitig nicht angeschlossen: grau gewinnt (figurZustand)
|
||||
assert.equal(arneAnsicht({ arne: { ...eintrag('arbeitet'), connected: false } }).status, 'grau')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Register-Buch
|
||||
|
||||
test('REITER: Reihenfolge, Werte und Beschriftungen von ROOM_FILTERS, kurze sichtbare Namen, Symbol und Farbe je Reiter aus keller.css', () => {
|
||||
assert.deepEqual(REITER.map((r) => r.value), ROOM_FILTERS.map((f) => f.value))
|
||||
assert.deepEqual(REITER.map((r) => r.label), ROOM_FILTERS.map((f) => f.label))
|
||||
assert.deepEqual(REITER.map((r) => r.kurz), ['Alle', 'Ärzte', 'Handwerk', 'Selbstständige', 'Läden'])
|
||||
assert.deepEqual(REITER.map((r) => r.icon), ['hk-ic hk-ic-alle', 'hk-ic hk-ic-raum-aerzte', 'hk-ic hk-ic-raum-handwerk', 'hk-ic hk-ic-raum-selbst', 'hk-ic hk-ic-raum-laden'])
|
||||
assert.deepEqual(REITER.map((r) => r.klasse), ['hk-reiter hk-reiter-alle', 'hk-reiter hk-reiter-aerzte', 'hk-reiter hk-reiter-handwerk', 'hk-reiter hk-reiter-selbst', 'hk-reiter hk-reiter-laden'])
|
||||
for (const r of REITER) for (const k of `${r.icon} ${r.klasse}`.split(' ')) assert.ok(CSS.has(k), `${k} fehlt im CSS`)
|
||||
})
|
||||
|
||||
test('raumIcon: Symbol je Raum, ein unbekannter Raum zeigt den Aktenstapel', () => {
|
||||
assert.equal(raumIcon('aerzte'), 'hk-ic hk-ic-raum-aerzte')
|
||||
assert.equal(raumIcon('handwerk'), 'hk-ic hk-ic-raum-handwerk')
|
||||
assert.equal(raumIcon('selbst'), 'hk-ic hk-ic-raum-selbst')
|
||||
assert.equal(raumIcon('laden'), 'hk-ic hk-ic-raum-laden')
|
||||
for (const unbekannt of ['', 'netz', 'xyz', 'constructor', '__proto__', undefined, null, 3, {}]) assert.equal(raumIcon(unbekannt), 'hk-ic hk-ic-alle', String(unbekannt))
|
||||
for (const room of ['aerzte', 'handwerk', 'selbst', 'laden', '']) for (const k of raumIcon(room).split(' ')) assert.ok(CSS.has(k), k)
|
||||
})
|
||||
|
||||
test('scoutFuer: Raum → Hustler der Etage „Ohne Website“ (aerzte → Ali, handwerk → Hanna, selbst → Sven, laden → Lea), sonst leer', () => {
|
||||
assert.equal(scoutFuer('aerzte'), 'Ali')
|
||||
assert.equal(scoutFuer('handwerk'), 'Hanna')
|
||||
assert.equal(scoutFuer('selbst'), 'Sven')
|
||||
assert.equal(scoutFuer('laden'), 'Lea')
|
||||
// Räume anderer Etagen (Rita im Netz, Arne im Archiv) und alles Unbekannte haben keinen Scout
|
||||
for (const room of ['netz', 'archiv', 'bruecke', 'waage', '', 'xyz', 'constructor', undefined, null, 7]) assert.equal(scoutFuer(room), '', String(room))
|
||||
})
|
||||
|
||||
test('punkteInfo: Zahl, Balkenbreite (48 und 96 px) und Farbe (ab 75 gut, ab 55 mittel, darunter schlecht, ohne Zahl leer)', () => {
|
||||
assert.deepEqual(punkteInfo(82), { text: '82', breite: 39, breiteGross: 79, farbe: 'gut' })
|
||||
assert.deepEqual(punkteInfo(75), { text: '75', breite: 36, breiteGross: 72, farbe: 'gut' })
|
||||
assert.equal(punkteInfo(74).farbe, 'mittel')
|
||||
assert.equal(punkteInfo(55).farbe, 'mittel')
|
||||
assert.equal(punkteInfo(54).farbe, 'schlecht')
|
||||
assert.deepEqual(punkteInfo(0), { text: '0', breite: 0, breiteGross: 0, farbe: 'schlecht' })
|
||||
assert.deepEqual(punkteInfo(100), { text: '100', breite: 48, breiteGross: 96, farbe: 'gut' })
|
||||
assert.equal(punkteInfo(81.6).text, '82') // gerundet wie scoreLabel in botleadRow
|
||||
// der Balken bleibt im Rahmen, der Text nennt die Zahl des Dienstes
|
||||
assert.deepEqual(punkteInfo(130), { text: '130', breite: 48, breiteGross: 96, farbe: 'gut' })
|
||||
assert.deepEqual(punkteInfo(-5), { text: '-5', breite: 0, breiteGross: 0, farbe: 'schlecht' })
|
||||
for (const nichts of [undefined, null, NaN, Infinity, 'x', '82', {}]) assert.deepEqual(punkteInfo(nichts), { text: '–', breite: 0, breiteGross: 0, farbe: 'leer' }, String(nichts))
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
test('eintragModell: Zeilenmodell von botleadRow plus Raum-Symbol, Scout, Punkte, Grund mit Erklärung, Telefon-Symbol und Label', () => {
|
||||
const m = eintragModell(ITEM)
|
||||
assert.deepEqual({ ...m, punkte: undefined }, {
|
||||
...V.botleadRow(ITEM),
|
||||
raum: 'handwerk', raumIcon: 'hk-ic hk-ic-raum-handwerk', scout: 'Hanna', punkte: undefined,
|
||||
grundIcon: 'hk-ic hk-ic-brief-x', erklaerung: 'Auf der Website steht keine E-Mail-Adresse. Ohne veröffentlichte Adresse schreibt der Hustler nie.',
|
||||
telefonIcon: 'hk-ic hk-ic-telefon',
|
||||
label: 'Elektro Beispiel GmbH, 67655 Kaiserslautern, 82 Punkte, keine veröffentlichte Mail, gefunden am 19.10.2026',
|
||||
})
|
||||
assert.deepEqual(m.punkte, punkteInfo(82))
|
||||
assert.equal(m.sourceHref, 'https://beispiel-elektro.de/impressum')
|
||||
assert.equal(m.phone, '0631 111')
|
||||
})
|
||||
|
||||
test('eintragModell: Heilberuf nie mit Nummer (Schloss), unsichere Quelle ohne Link, ohne Quelle der Nummer „nicht verfügbar“, gekürzt mit Schere', () => {
|
||||
const praxis = eintragModell({ ...ITEM, id: 'p2', room: 'aerzte', roomLabel: 'Ärzte & Praxen', isHealth: true, sourceUrl: 'javascript:alert(1)' })
|
||||
assert.equal(praxis.phone, 'bewusst nicht gespeichert')
|
||||
assert.equal(praxis.telefonIcon, 'hk-ic hk-ic-schloss')
|
||||
assert.equal(praxis.sourceHref, '')
|
||||
assert.equal(praxis.scout, 'Ali')
|
||||
// ein Altbestand mit Nummer und Quelle verrät sie bei einem Heilberuf trotzdem nicht
|
||||
assert.equal(eintragModell({ ...ITEM, isHealth: true }).phone, 'bewusst nicht gespeichert')
|
||||
assert.equal(eintragModell({ ...ITEM, phoneSource: '' }).phone, 'nicht verfügbar')
|
||||
assert.equal(eintragModell({ ...ITEM, phoneSource: '' }).telefonIcon, 'hk-ic hk-ic-telefon')
|
||||
assert.equal(eintragModell({ ...ITEM, sourceUrl: 'https://user:pw@evil.example/' }).sourceHref, '')
|
||||
assert.equal(eintragModell({ ...ITEM, sourceUrl: 'http://alt.example/kontakt' }).sourceHref, 'http://alt.example/kontakt')
|
||||
const kuerz = eintragModell({ ...ITEM, name: '[gekürzt]', gekuerzt: true, phone: '', phoneSource: '', sourceUrl: '' })
|
||||
assert.equal(kuerz.gekuerzt, true)
|
||||
assert.equal(kuerz.telefonIcon, 'hk-ic hk-ic-schere')
|
||||
assert.ok(kuerz.label.startsWith('[gekürzt] (gekürzt), '))
|
||||
// jedes Symbol des Modells gibt es im CSS
|
||||
for (const m of [eintragModell(ITEM), praxis, kuerz]) for (const k of `${m.raumIcon} ${m.grundIcon} ${m.telefonIcon}`.split(' ')) assert.ok(CSS.has(k), k)
|
||||
})
|
||||
|
||||
test('eintragModell: fehlende oder kaputte Werte ergeben Platzhalter, nie undefined, NaN oder einen Fehler', () => {
|
||||
for (const kaputt of [null, undefined, 42, 'x', [], {}, { room: 5, score: 'x', reason: null, name: 7 }]) {
|
||||
const m = eintragModell(kaputt)
|
||||
assert.equal(m.title, 'nicht verfügbar')
|
||||
assert.equal(m.scoreLabel, '–')
|
||||
assert.equal(m.reason, '–')
|
||||
assert.equal(m.raum, '')
|
||||
assert.equal(m.raumIcon, 'hk-ic hk-ic-alle')
|
||||
assert.equal(m.scout, '')
|
||||
assert.deepEqual(m.punkte, { text: '–', breite: 0, breiteGross: 0, farbe: 'leer' })
|
||||
assert.equal(m.grundIcon, 'hk-ic hk-ic-akte') // Grund „–“ ist nicht bekannt: neutrales Symbol
|
||||
assert.equal(m.sourceHref, '')
|
||||
assert.ok(!/undefined|NaN/.test(JSON.stringify(m)), JSON.stringify(m))
|
||||
assert.equal(typeof m.label, 'string')
|
||||
}
|
||||
// unbekannter Raum und unbekannter Grund
|
||||
const m = eintragModell({ ...ITEM, room: 'xyz', reason: 'irgendein neuer Grund' })
|
||||
assert.equal(m.scout, '')
|
||||
assert.equal(m.raumIcon, 'hk-ic hk-ic-alle')
|
||||
assert.equal(m.grundIcon, 'hk-ic hk-ic-akte')
|
||||
assert.match(m.erklaerung, /Der Hustler schreibt diesem Betrieb nicht/)
|
||||
})
|
||||
|
||||
test('hustlerKeller.js: rein (kein React, kein DOM, kein CSS und keine Bilder), nur Importe aus src/lib', () => {
|
||||
const src = read('src/lib/hustlerKeller.js').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
|
||||
const importe = [...src.matchAll(/^\s*import\b[^'"]*['"]([^'"]+)['"]/gm)].map((m) => m[1])
|
||||
assert.deepEqual(importe.sort(), ['./botleadGruende.js', './botleadsView.js', './hustlerSicht.js', './workersRoster.js'])
|
||||
assert.doesNotMatch(src, /\b(?:window|document|fetch|localStorage)\b/)
|
||||
assert.doesNotMatch(src, /innerHTML/)
|
||||
assert.doesNotMatch(src, /\.(?:css|png|svg|jpg)['"]/)
|
||||
})
|
||||
Reference in new issue
Block a user