743 lines
40 KiB
JavaScript
743 lines
40 KiB
JavaScript
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`)
|
||
}
|
||
}
|
||
})
|