Files
tickte-system/tests/unit/hustlerKellerLib.test.mjs
T
ClaudeandClaude Sonnet 5.5 0924a8d2ae Hustler-Pixelwelt T13: Korrekturen Pult und Keller (Sperre laufender Aktionen, Fokus, Kontrast, Handy-Tippflächen, Pause)
Pult: laufende Anfragen je Eintrags-Id in PultList (laufRef, Brief meldet Beginn/Ende): Brief schließen und denselben Eintrag
wieder öffnen sperrt alle Aktionsknöpfe, kein zweites reply/paste/start-build (P1); spätes Ergebnis nennt den Titel (P1b);
Original und Entwurf pre-wrap (P3); Überschrift nimmt beim Einhängen den Fokus (M2); „Pult zuklappen“ mit aria-expanded (K4);
paused -> hk-steht an der Szene (R3); Brief z-index 9 (R4); Textlinks am Handy 40 px (K7).
Keller: Überschrift-Fokus (M2), Reiter-Fokusring innen (M3), Seitenzeile/Punkte/Reiterzahl in Pixelify Sans 13 px (K5), Suchfeld und
Akte-Link 40 px am Handy (K7), Ortszeile mit title (W2), paused -> hk-steht (R3), Akte z-index 9 (R4); Arne ohne Eintrag neutral (R5).
Tests: Sperre laufender Aktionen, Titel im späten Ergebnis, Fokus, aria-expanded, paused, pre-wrap, z-index, Tippflächen, Reiter-Fokus,
Schrift, Kontrast (WCAG) von .hk-zeile-alt/.hk-kl-tag auf Papier und Hover, Arne neutral (alte „Arne schläft ohne Eintrag“-Prüfung ersetzt).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 09:40:06 +00:00

254 lines
17 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; ein Eintrag mit unbrauchbarem Status schläft (Leerlauf)', () => {
for (const workers of [{ 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')
})
// T13 R5: ohne Eintrag für Arne (Tabelle leer, die Seite hat den ersten Zustand noch nicht) ist nichts über ihn bekannt: neutral, kein Schlaf, keine „nicht angeschlossen“-Pose
test('arneAnsicht (T13 R5): ohne Tabelle oder ohne Eintrag für Arne steht er neutral da – nicht arbeitend, ohne Zzz, ohne Punkte, ohne Marker, nicht „nicht angeschlossen“', () => {
const neutral = { status: 'unbekannt', arbeitet: false, pose: 'warten', alarm: false, punkte: false, zzz: false, label: 'Arne, Haiku' }
for (const workers of [undefined, null, {}, [], 'x', 5, { otto: eintrag('arbeitet') }, { ali: eintrag('fehler') }, { arne: null }, { arne: undefined }, { arne: 'arbeitet' }, { arne: 7 }, { arne: [] }]) {
assert.deepEqual(arneAnsicht(workers), neutral, JSON.stringify(workers))
}
// neutral ist weder einer der Status des Dienstes noch „grau“
for (const status of ['arbeitet', 'wartet', 'leerlauf', 'fehler', 'grau']) assert.notEqual(arneAnsicht({ arne: eintrag(status) }).status, 'unbekannt', status)
assert.equal(arneAnsicht({ arne: eintrag('grau') }).status, 'grau')
// die Pose „warten“ gibt es als Sprite und als Klasse im CSS (sonst stünde Arne unsichtbar da)
assert.ok(CSS.has('hk-kl-fig-warten'))
})
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)['"]/)
})