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

244 lines
16 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 { 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)['"]/)
})