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)['"]/) })