Files
tickte-system/tests/unit/hustlerKeller.test.mjs
T

743 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`)
}
}
})