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

867 lines
47 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 Ableitungen der Hustler-Pixelwelt (src/lib/hustlerSicht.js): Figur, Detail, Kopfzeile, Kamera
const S = await loadModule('src/lib/hustlerSicht.js')
const R = await loadModule('src/lib/workersRoster.js')
const V = await loadModule('src/lib/workersView.js')
const W = await loadModule('src/lib/hustlerWelt.js')
const {
HUT, STATUS_ANZEIGE, POSE_NACH_STATUS, AUFGABEN, ZOOM_MAX, SICHT_STANDARD,
anzeigeName, modellKurz, modellName, statusVon, figurZustand, detailModell, blaettern, hudWerte, schilder,
kameraMinS, kameraGrenzen, kameraPassend, kameraZoomAuf, kameraEtage,
} = S
const { ROLES, SEARCH_ROOMS, FLOOR_TITLES, ROOM_TITLES } = R
const { formatEur, formatUsd, formatCount, kpiTiles, traceRows } = V
const { ETAGEN } = W
const NBSP = String.fromCharCode(160)
const STATUS = ['arbeitet', 'wartet', 'leerlauf', 'grau', 'fehler']
const HUETE = ['muetze', 'kappe', 'krone']
const POSEN = ['arbeiten', 'warten', 'schlafen', 'nicht']
const ARIA = /^(arbeitet|wartet auf Auftrag|Leerlauf|noch nicht angeschlossen|Fehler)$/
// Zustandseintrag wie indexWorkers ihn liefert; "grau" ist connected false
function eintrag(status, extra = {}) {
if (status === 'grau') return { status: 'grau', task: '', lastTs: null, connected: false, counters: {}, ...extra }
return { status, task: '', lastTs: null, connected: true, counters: {}, ...extra }
}
// Klassen aus hochhaus.css: Kommentare und url(...) raus, dann alle .hk-Selektoren
const css = readFileSync(new URL('../../src/styles/hustler/hochhaus.css', import.meta.url), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/url\([^)]*\)/g, '')
const CSS_KLASSEN = new Set(css.match(/\.hk-[A-Za-z0-9_-]+/g).map((k) => k.slice(1)))
function fehlendeKlassen(...zeilen) {
const fehlt = new Set()
for (const z of zeilen) {
for (const k of String(z).split(/\s+/).filter(Boolean)) {
if (!k.startsWith('hk-') || !CSS_KLASSEN.has(k)) fehlt.add(k)
}
}
return [...fehlt]
}
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}`)
}
// ---------------------------------------------------------------------------------------------------------------
// Namen und Modelle
test('anzeigeName: otto heißt Overseer, sonst Roster-Name, sonst die Id', () => {
assert.equal(anzeigeName('otto'), 'Overseer')
assert.equal(anzeigeName('lea'), 'Lea')
assert.equal(anzeigeName('bianca'), 'Bianca')
assert.equal(anzeigeName('zed'), 'zed')
assert.equal(anzeigeName('constructor'), 'constructor')
assert.equal(anzeigeName(undefined), '')
assert.equal(anzeigeName(42), '')
// Der Roster selbst bleibt bei Otto
assert.equal(ROLES.find((r) => r.workerId === 'otto').name, 'Otto')
})
test('modellKurz und modellName: Opus, Sonnet, Haiku; unbekannt ist ein Strich', () => {
assert.equal(modellKurz('otto'), 'Opus')
assert.equal(modellKurz('bob'), 'Opus')
assert.equal(modellKurz('bea'), 'Sonnet')
assert.equal(modellKurz('lea'), 'Haiku')
assert.equal(modellKurz('zed'), '–')
assert.equal(modellKurz('constructor'), '–')
for (const r of ROLES) assert.equal(modellKurz(r.workerId), { opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku' }[r.model], r.workerId)
assert.equal(modellName('opus'), 'Claude Opus 5.5')
assert.equal(modellName('sonnet'), 'Claude Sonnet 5.5')
assert.equal(modellName('haiku'), 'Claude Haiku 5.5')
assert.equal(modellName('claude-opus-5-5'), 'Claude Opus 5.5')
assert.equal(modellName('claude-sonnet-5-5'), 'Claude Sonnet 5.5')
assert.equal(modellName('claude-haiku-5-5'), 'Claude Haiku 5.5')
assert.equal(modellName('Opus'), 'Claude Opus 5.5')
assert.equal(modellName(' claude-haiku-5-5 '), 'Claude Haiku 5.5')
for (const falsch of ['claude-opus-5-6', 'gpt-4', '', ' ', null, undefined, 5, {}, [], 'constructor', '__proto__']) {
assert.equal(modellName(falsch), '–', String(falsch))
}
})
test('HUT: Sprite-Hut je Roster-Hut, über hatForWorker', () => {
assert.deepEqual({ ...HUT }, { haiku: 'muetze', sonnet: 'kappe', opus: 'krone', overseer: 'overseer' })
assert.equal(HUT[R.hatForWorker('otto')], 'overseer')
assert.equal(HUT[R.hatForWorker('bob')], 'krone')
assert.equal(HUT[R.hatForWorker('bea')], 'kappe')
assert.equal(HUT[R.hatForWorker('kim')], 'muetze')
})
// ---------------------------------------------------------------------------------------------------------------
// Status
test('statusVon: die fünf Status, unbekannt und fehlend -> leerlauf, connected false übersteuert', () => {
assert.equal(statusVon({ status: 'arbeitet', connected: true }), 'arbeitet')
assert.equal(statusVon({ status: 'wartet' }), 'wartet')
assert.equal(statusVon({ status: 'leerlauf' }), 'leerlauf')
assert.equal(statusVon({ status: 'fehler' }), 'fehler')
assert.equal(statusVon({ status: 'arbeitet', connected: false }), 'grau')
assert.equal(statusVon({ status: 'fehler', connected: false }), 'grau')
assert.equal(statusVon({ status: 'leerlauf', connected: false }), 'grau')
assert.equal(statusVon({ connected: false }), 'grau')
assert.equal(statusVon(eintrag('grau')), 'grau')
for (const e of [{ status: 'schläft' }, { status: '' }, { status: 7 }, { status: null }, { status: 'constructor' }, { status: '__proto__' }, {}, { connected: true }, null, undefined, 'arbeitet', 5, []]) {
assert.equal(statusVon(e), 'leerlauf', JSON.stringify(e))
}
// Nur ein echtes false macht grau, 0 oder Text nicht
assert.equal(statusVon({ status: 'arbeitet', connected: 0 }), 'arbeitet')
assert.equal(statusVon({ status: 'arbeitet', connected: 'false' }), 'arbeitet')
})
test('STATUS_ANZEIGE: Text, Label und Farbe je Status, eingefroren', () => {
assert.deepEqual(Object.keys(STATUS_ANZEIGE), STATUS)
assert.deepEqual(STATUS_ANZEIGE.arbeitet, { text: 'Arbeitet', aria: 'arbeitet', farbe: '#4ade5a' })
assert.deepEqual(STATUS_ANZEIGE.wartet, { text: 'Wartet auf Auftrag', aria: 'wartet auf Auftrag', farbe: '#ffd25a' })
assert.deepEqual(STATUS_ANZEIGE.leerlauf, { text: 'Leerlauf', aria: 'Leerlauf', farbe: '#9aa6d6' })
assert.deepEqual(STATUS_ANZEIGE.grau, { text: 'Noch nicht angeschlossen', aria: 'noch nicht angeschlossen', farbe: '#a4a2ad' })
assert.deepEqual(STATUS_ANZEIGE.fehler, { text: 'Fehler', aria: 'Fehler', farbe: '#ff5a5a' })
// Der Detail-Text deckt sich mit dem Text der alten Oberfläche
for (const s of STATUS) assert.equal(STATUS_ANZEIGE[s].text, V.workerStatusLabel(s), s)
assert.deepEqual({ ...POSE_NACH_STATUS }, { arbeitet: 'arbeiten', wartet: 'warten', leerlauf: 'schlafen', grau: 'nicht', fehler: 'nicht' })
})
// ---------------------------------------------------------------------------------------------------------------
// Figur im Haus
test('figurZustand: alle fünf Status für einen Such-Hustler (Lea)', () => {
const erwartet = {
arbeitet: { pose: 'arbeiten', punkte: false, zzz: false, bildschirm: true, alarm: false, raumGrau: false, label: 'Lea, Haiku, arbeitet' },
wartet: { pose: 'warten', punkte: true, zzz: false, bildschirm: false, alarm: false, raumGrau: false, label: 'Lea, Haiku, wartet auf Auftrag' },
leerlauf: { pose: 'schlafen', punkte: false, zzz: true, bildschirm: false, alarm: false, raumGrau: false, label: 'Lea, Haiku, Leerlauf' },
grau: { pose: 'nicht', punkte: false, zzz: false, bildschirm: false, alarm: false, raumGrau: true, label: 'Lea, Haiku, noch nicht angeschlossen' },
fehler: { pose: 'nicht', punkte: false, zzz: false, bildschirm: false, alarm: true, raumGrau: false, label: 'Lea, Haiku, Fehler' },
}
for (const status of STATUS) {
const f = figurZustand('lea', eintrag(status))
assert.deepEqual(
{ ...f },
{
status, hut: 'muetze', klasse: `hk-spr hk-fig-muetze-${erwartet[status].pose}`, radar: false, raum: 'laden', gedimmt: false, steht: false, ...erwartet[status],
},
status,
)
}
})
test('figurZustand: Hut nach Modell (Haiku Mütze, Sonnet Kappe, Opus Krone)', () => {
assert.equal(figurZustand('kim', eintrag('arbeitet')).klasse, 'hk-spr hk-fig-muetze-arbeiten')
assert.equal(figurZustand('bea', eintrag('wartet')).klasse, 'hk-spr hk-fig-kappe-warten')
assert.equal(figurZustand('bob', eintrag('leerlauf')).klasse, 'hk-spr hk-fig-krone-schlafen')
assert.equal(figurZustand('paul', eintrag('fehler')).klasse, 'hk-spr hk-fig-kappe-nicht')
assert.equal(figurZustand('ben', eintrag('grau')).klasse, 'hk-spr hk-fig-krone-nicht')
})
test('figurZustand: Radar nur für Rita und nur beim Arbeiten', () => {
assert.equal(figurZustand('rita', eintrag('arbeitet')).radar, true)
for (const status of ['wartet', 'leerlauf', 'grau', 'fehler']) assert.equal(figurZustand('rita', eintrag(status)).radar, false, status)
for (const r of ROLES.filter((x) => x.workerId !== 'rita')) assert.equal(figurZustand(r.workerId, eintrag('arbeitet')).radar, false, r.workerId)
})
test('figurZustand: Bildschirm leuchtet beim Arbeiten, außer beim Overseer', () => {
for (const r of ROLES) {
assert.equal(figurZustand(r.workerId, eintrag('arbeitet')).bildschirm, r.workerId !== 'otto', r.workerId)
for (const status of ['wartet', 'leerlauf', 'grau', 'fehler']) assert.equal(figurZustand(r.workerId, eintrag(status)).bildschirm, false, `${r.workerId} ${status}`)
}
})
test('figurZustand: Raum nur für Such-Räume grau (Kim ja, Bea nein), nur bei nicht angeschlossen', () => {
for (const r of ROLES) {
const such = SEARCH_ROOMS.includes(r.room)
const grau = figurZustand(r.workerId, eintrag('grau'))
assert.equal(grau.raumGrau, such, r.workerId)
assert.equal(grau.raum, r.room, r.workerId)
for (const status of ['arbeitet', 'wartet', 'leerlauf', 'fehler']) assert.equal(figurZustand(r.workerId, eintrag(status)).raumGrau, false, `${r.workerId} ${status}`)
}
assert.equal(figurZustand('kim', eintrag('grau')).raumGrau, true)
assert.equal(figurZustand('bea', eintrag('grau')).raumGrau, false)
assert.equal(figurZustand('arne', eintrag('grau')).raumGrau, false)
assert.equal(ROLES.filter((r) => figurZustand(r.workerId, eintrag('grau')).raumGrau).length, SEARCH_ROOMS.length)
})
test('figurZustand: Overseer in allen Status – eigenes Sprite, gedimmt außer beim Arbeiten, nie grau', () => {
for (const status of STATUS) {
const f = figurZustand('otto', eintrag(status))
assert.equal(f.hut, 'overseer', status)
assert.equal(f.pose, 'overseer', status)
assert.equal(f.klasse, 'hk-spr hk-ovs-klein', status)
assert.equal(f.gedimmt, status !== 'arbeitet', status)
assert.equal(f.bildschirm, false, status)
assert.equal(f.radar, false, status)
assert.equal(f.raumGrau, false, status)
assert.equal(f.raum, 'bruecke', status)
assert.equal(f.alarm, status === 'fehler', status)
assert.match(f.label, /^Overseer, Opus, /, status)
}
assert.equal(figurZustand('otto', eintrag('arbeitet')).label, 'Overseer, Opus, arbeitet')
// Andere werden nie gedimmt
for (const r of ROLES.filter((x) => x.workerId !== 'otto')) for (const status of STATUS) assert.equal(figurZustand(r.workerId, eintrag(status)).gedimmt, false)
})
test('figurZustand: Pause setzt steht und ändert sonst nichts', () => {
for (const r of ROLES) {
for (const status of STATUS) {
const lauf = figurZustand(r.workerId, eintrag(status))
const pause = figurZustand(r.workerId, eintrag(status), { paused: true })
assert.equal(lauf.steht, false)
assert.equal(pause.steht, true)
assert.deepEqual({ ...pause, steht: false }, { ...lauf })
}
}
assert.equal(figurZustand('lea', eintrag('arbeitet'), { paused: false }).steht, false)
assert.equal(figurZustand('lea', eintrag('arbeitet'), {}).steht, false)
assert.equal(figurZustand('lea', eintrag('arbeitet'), undefined).steht, false)
})
test('figurZustand: alle 19 Hustler bekommen ein Label im Muster, Namen und Modelle stimmen', () => {
assert.equal(ROLES.length, 19)
const labels = new Set()
for (const r of ROLES) {
for (const status of STATUS) {
const f = figurZustand(r.workerId, eintrag(status))
const [name, modell, aria, zuviel] = f.label.split(', ')
assert.equal(zuviel, undefined, f.label)
assert.equal(name, r.workerId === 'otto' ? 'Overseer' : r.name, f.label)
assert.equal(modell, { opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku' }[r.model], f.label)
assert.match(aria, ARIA, f.label)
assert.equal(aria, STATUS_ANZEIGE[status].aria, f.label)
labels.add(f.label)
}
}
assert.equal(labels.size, 19 * 5)
// Wörtlich aus der Zeichenfläche
assert.equal(figurZustand('ali', eintrag('wartet')).label, 'Ali, Haiku, wartet auf Auftrag')
assert.equal(figurZustand('hanna', eintrag('leerlauf')).label, 'Hanna, Haiku, Leerlauf')
assert.equal(figurZustand('kim', eintrag('grau')).label, 'Kim, Haiku, noch nicht angeschlossen')
assert.equal(figurZustand('bob', eintrag('arbeitet')).label, 'Bob, Opus, arbeitet')
assert.equal(figurZustand('bea', eintrag('wartet')).label, 'Bea, Sonnet, wartet auf Auftrag')
})
test('figurZustand: unbekannte Kennung und kaputte Einträge geben nie undefined oder NaN, Eingabe bleibt unverändert', () => {
const f = figurZustand('zed', eintrag('arbeitet'))
assert.equal(f.hut, 'muetze')
assert.equal(f.raum, null)
assert.equal(f.raumGrau, false)
assert.equal(f.label, 'zed, arbeitet')
for (const kaputt of [null, undefined, 'arbeitet', 7, [], {}, { status: NaN }, { connected: 'nein' }]) {
const g = figurZustand('lea', kaputt)
assert.equal(g.status, 'leerlauf')
assert.equal(g.klasse, 'hk-spr hk-fig-muetze-schlafen')
assert.ok(!/undefined|NaN|null/.test(JSON.stringify(g)), JSON.stringify(kaputt))
}
const eingabe = Object.freeze(eintrag('wartet', { counters: Object.freeze({ gelesen: 3 }) }))
const vorher = JSON.stringify(eingabe)
figurZustand('lea', eingabe, { paused: true })
assert.equal(JSON.stringify(eingabe), vorher)
})
// ---------------------------------------------------------------------------------------------------------------
// CSS-Klassen existieren
test('CSS: jede Klasse, die figurZustand und detailModell liefern können, steht in hochhaus.css', () => {
assert.ok(CSS_KLASSEN.size > 80, `nur ${CSS_KLASSEN.size} Klassen gefunden`)
const klassen = []
// alle Hüte × alle Posen (Haus und Detail), unabhängig davon, welche Kombination die Funktionen erreichen
for (const hut of HUETE) for (const pose of POSEN) klassen.push(`hk-spr hk-fig-${hut}-${pose}`, `hk-spr hk-gr-${hut}-${pose}`)
klassen.push('hk-spr hk-ovs-klein', 'hk-spr hk-gr-overseer', 'hk-sz hk-sz-bruecke', 'hk-sz hk-sz-buero', 'hk-sz hk-sz-nicht')
// alle Hustler × alle Status über die Funktionen selbst
for (const r of ROLES) {
for (const status of STATUS) {
const e = eintrag(status)
klassen.push(figurZustand(r.workerId, e).klasse)
const d = detailModell(r.workerId, e, null, 0)
klassen.push(d.szene, d.figur)
}
}
// Symbole der Kopfzeile
const hud = hudWerte({})
for (const z of hud.zahlen) klassen.push(z.icon)
assert.deepEqual(fehlendeKlassen(...klassen), [])
// Probe: die Prüfung schlägt bei fremden Klassen wirklich an
assert.deepEqual(fehlendeKlassen('hk-spr', 'hk-gibts-nicht', 'spr'), ['hk-gibts-nicht', 'spr'])
})
test('CSS: Klassen der Figuren sind bei allen 19 Hustlern in jedem Status gültig und alle mit hk- davor', () => {
for (const r of ROLES) {
for (const status of STATUS) {
const f = figurZustand(r.workerId, eintrag(status))
for (const k of f.klasse.split(' ')) assert.ok(k.startsWith('hk-'), k)
assert.deepEqual(fehlendeKlassen(f.klasse), [], `${r.workerId} ${status}`)
}
}
})
// ---------------------------------------------------------------------------------------------------------------
// Detailansicht
const JETZT = Date.parse('2026-10-22T10:00:00Z') // 12:00 in Berlin (Sommerzeit bis 25.10.)
const VOLLER_TRACE = Object.freeze({
worker: { name: 'Lea', role: 'Rolle aus dem Dienst', modelId: 'claude-haiku-5-5' },
today: { counters: { gelesen: 38, gefunden: 4 }, tokens: { in: 20000, out: 4800 }, costUsd: 0.21 },
events: [
{ id: 'a', ts: '2026-10-22T08:30:00Z', text: 'Café Morgenrot: Impressum gefunden', link: '/leads/12' },
{ id: 'b', ts: '2026-10-22T09:30:00Z', text: 'Auftrag erhalten', link: 'https://example.org/x' },
{ id: 'c', ts: '2026-10-21T09:30:00Z', text: 'Lauf beendet', link: 'javascript:alert(1)' },
],
})
test('detailModell: voller Trace – alle Felder', () => {
const e = eintrag('arbeitet', { task: ' Liest das Impressum ', counters: { gelesen: 1 } })
const d = detailModell('lea', e, VOLLER_TRACE, JETZT)
assert.deepEqual(Object.keys(d).sort(), ['aufgabe', 'etageRaum', 'figur', 'gerade', 'heute', 'id', 'kosten', 'modell', 'name', 'nummer', 'overlays', 'spur', 'status', 'statusFarbe', 'statusText', 'szene', 'tokens'].sort())
assert.equal(d.id, 'lea')
assert.equal(d.name, 'Lea')
assert.equal(d.status, 'arbeitet')
assert.equal(d.statusText, 'Arbeitet')
assert.equal(d.statusFarbe, '#4ade5a')
assert.equal(d.modell, 'Claude Haiku 5.5')
assert.equal(d.etageRaum, 'Ohne Website · Läden & Gastro')
assert.equal(d.aufgabe, AUFGABEN.lea)
assert.equal(d.gerade, 'Liest das Impressum')
assert.equal(d.heute, `38 gelesen · 4 gefunden`)
assert.equal(d.kosten, `0,21${NBSP}$`)
assert.equal(d.tokens, '24.800')
assert.equal(d.nummer, '9 / 19')
assert.equal(d.szene, 'hk-sz hk-sz-buero')
assert.equal(d.figur, 'hk-spr hk-gr-muetze-arbeiten')
assert.deepEqual({ ...d.overlays }, { monitor: true, dampf: false, zzz: false, wirbel: false, schild: false, alarm: false })
// Spur: neueste zuerst, Zeit in Berlin (heute nur Uhrzeit), sichere Links bleiben Links
assert.deepEqual(d.spur, traceRows(VOLLER_TRACE.events, JETZT))
assert.deepEqual(d.spur.map((z) => z.id), ['b', 'a', 'c'])
assert.equal(d.spur[0].time, '11:30')
assert.equal(d.spur[1].time, '10:30')
assert.match(d.spur[2].time, /^21\.10\. 11:30$/)
assert.deepEqual(d.spur[0].link, { href: 'https://example.org/x', external: true })
assert.deepEqual(d.spur[1].link, { href: '/leads/12', external: false })
})
test('detailModell: unsichere Links bleiben Text', () => {
const bose = ['javascript:alert(1)', 'data:text/html,x', 'vbscript:x', '//evil.example', '/\\evil.example', 'ftp://x.example', '/ok\npath', ' javascript:alert(1)', 'mailto:a@b.de', '', null, 5, {}]
const trace = { events: bose.map((link, i) => ({ id: `e${i}`, ts: `2026-10-22T08:${String(10 + i).padStart(2, '0')}:00Z`, text: `Text ${i}`, link })) }
const d = detailModell('lea', eintrag('arbeitet'), trace, JETZT)
assert.equal(d.spur.length, bose.length)
for (const zeile of d.spur) assert.equal(zeile.link, null, zeile.text)
assert.ok(d.spur.every((z) => typeof z.text === 'string' && z.text.startsWith('Text ')))
})
test('detailModell: leerer Trace (null, fehlend, {}) fällt auf Roster und Zustandseintrag zurück', () => {
const e = eintrag('wartet', { task: '', counters: { gelesen: 2, entwuerfe: 1 } })
for (const leer of [null, undefined, {}, { worker: null, today: null, events: [] }]) {
const d = detailModell('bea', e, leer, JETZT)
assert.equal(d.modell, 'Claude Sonnet 5.5')
assert.equal(d.aufgabe, AUFGABEN.bea)
assert.equal(d.gerade, '–')
assert.equal(d.heute, '2 gelesen · 1 Entwürfe')
assert.equal(d.kosten, '–')
assert.equal(d.tokens, '–')
assert.deepEqual(d.spur, [])
assert.equal(d.etageRaum, 'Bewertung · Waage')
assert.equal(d.nummer, '10 / 19')
}
const ohneZaehler = detailModell('bea', eintrag('wartet'), null, JETZT)
assert.equal(ohneZaehler.heute, '–')
})
test('detailModell: Zähler und Tokens aus dem Trace gehen vor dem Zustandseintrag, Tokens als Zahl oder { in, out }', () => {
const e = eintrag('arbeitet', { counters: { gelesen: 99 } })
const d1 = detailModell('nora', e, { today: { counters: { bewertet: 5 }, tokens: 1200, costUsd: 0 } }, JETZT)
assert.equal(d1.heute, '5 bewertet')
assert.equal(d1.tokens, '1.200')
assert.equal(d1.kosten, `0,00${NBSP}$`)
// Zähler im Trace unbrauchbar -> Zustandseintrag
const d2 = detailModell('nora', e, { today: { counters: 'viel', tokens: { in: 5 } } }, JETZT)
assert.equal(d2.heute, '99 gelesen')
assert.equal(d2.tokens, '5')
// Trace da, aber ohne Tokens -> 0, nicht Strich
assert.equal(detailModell('nora', e, { today: {} }, JETZT).tokens, '0')
})
test('detailModell: kaputter Trace gibt nie NaN oder undefined', () => {
const kaputte = [
'text', 42, [], [1, 2], true,
{ worker: 'Lea', today: 'heute', events: 'viele' },
{ worker: [], today: [], events: {} },
{ worker: { modelId: 5, role: 7, name: {} }, today: { costUsd: 'teuer', tokens: { in: 'a', out: NaN }, counters: [] }, events: [null, 3, 'x', { ts: 5, text: 9, link: [] }] },
{ today: { costUsd: NaN, tokens: Infinity, counters: { gelesen: NaN, passend: 'x' } } },
]
for (const trace of kaputte) {
const d = detailModell('lea', eintrag('arbeitet', { task: 5, counters: 'x' }), trace, JETZT)
const json = JSON.stringify(d)
assert.ok(!/undefined|NaN|Infinity/.test(json), `${JSON.stringify(trace)} -> ${json}`)
assert.equal(d.modell, 'Claude Haiku 5.5')
assert.equal(d.aufgabe, AUFGABEN.lea)
assert.equal(d.gerade, '–')
}
// kaputter Zustandseintrag
for (const e of [null, undefined, 'x', [], { status: 7, task: [], counters: 5 }]) {
const d = detailModell('lea', e, null, JETZT)
assert.equal(d.status, 'leerlauf')
assert.equal(d.gerade, '–')
assert.equal(d.heute, '–')
}
})
test('detailModell: Modell aus dem Trace gewinnt, leer oder unbekannt – Roster beziehungsweise Strich', () => {
assert.equal(detailModell('lea', eintrag('arbeitet'), { worker: { modelId: 'claude-opus-5-5' } }, JETZT).modell, 'Claude Opus 5.5')
assert.equal(detailModell('lea', eintrag('arbeitet'), { worker: { modelId: 'sonnet' } }, JETZT).modell, 'Claude Sonnet 5.5')
assert.equal(detailModell('lea', eintrag('arbeitet'), { worker: { modelId: '' } }, JETZT).modell, 'Claude Haiku 5.5')
assert.equal(detailModell('lea', eintrag('arbeitet'), { worker: { modelId: ' ' } }, JETZT).modell, 'Claude Haiku 5.5')
assert.equal(detailModell('lea', eintrag('arbeitet'), { worker: { modelId: 'claude-xyz-9' } }, JETZT).modell, '–')
assert.equal(detailModell('otto', eintrag('arbeitet'), null, JETZT).modell, 'Claude Opus 5.5')
assert.equal(detailModell('zed', eintrag('arbeitet'), null, JETZT).modell, '–')
})
test('detailModell: Aufgabe – eigener Text für alle 19, sonst Trace-Rolle, sonst Strich', () => {
assert.deepEqual(Object.keys(AUFGABEN), ROLES.map((r) => r.workerId))
for (const r of ROLES) {
const text = AUFGABEN[r.workerId]
assert.ok(typeof text === 'string' && text.length > 20 && text.endsWith('.'), r.workerId)
// der eigene Text gewinnt auch gegen die Rolle aus dem Dienst
assert.equal(detailModell(r.workerId, eintrag('arbeitet'), { worker: { role: 'andere Rolle' } }, JETZT).aufgabe, text, r.workerId)
assert.equal(detailModell(r.workerId, null, null, JETZT).aufgabe, text, r.workerId)
}
assert.equal(AUFGABEN.otto, 'Plant die Nacht, verteilt die Aufträge, achtet aufs Budget und schreibt den Morgenbericht.')
assert.equal(AUFGABEN.rita, 'Sucht im offenen Netz über Suchmaschinen nach frischen „Suche Webdesigner“-Posts.')
assert.equal(AUFGABEN.arne, 'Führt das Nie-doppelt-Register und die Bot-Lead-Liste.')
assert.equal(new Set(Object.values(AUFGABEN)).size, 17) // Bob, Bianca und Ben bauen dasselbe
assert.equal(detailModell('zed', null, { worker: { role: 'macht etwas' } }, JETZT).aufgabe, 'macht etwas')
assert.equal(detailModell('zed', null, { worker: { role: ' ' } }, JETZT).aufgabe, '–')
assert.equal(detailModell('zed', null, null, JETZT).aufgabe, '–')
assert.equal(detailModell('constructor', null, null, JETZT).aufgabe, '–')
})
test('detailModell: Etage · Raum, Nummer und Name für alle 19 aus dem Roster', () => {
ROLES.forEach((r, i) => {
const d = detailModell(r.workerId, eintrag('leerlauf'), null, JETZT)
assert.equal(d.etageRaum, `${FLOOR_TITLES[r.floor]} · ${ROOM_TITLES[r.room]}`, r.workerId)
assert.equal(d.nummer, `${i + 1} / 19`, r.workerId)
assert.equal(d.name, r.workerId === 'otto' ? 'Overseer' : r.name, r.workerId)
})
assert.equal(detailModell('otto', eintrag('arbeitet'), null, JETZT).etageRaum, 'Kommandobrücke · Brücke')
assert.equal(detailModell('arne', eintrag('leerlauf'), null, JETZT).etageRaum, 'Keller · Archiv & Register')
assert.equal(detailModell('zed', null, null, JETZT).etageRaum, '–')
assert.equal(detailModell('zed', null, null, JETZT).nummer, '– / 19')
assert.equal(detailModell('zed', null, null, JETZT).name, 'zed')
})
test('detailModell: Szene, große Figur und Zusätze je Status', () => {
const erwartet = {
arbeitet: { szene: 'hk-sz hk-sz-buero', figur: 'hk-spr hk-gr-kappe-arbeiten', overlays: { monitor: true, dampf: false, zzz: false, wirbel: false, schild: false, alarm: false } },
wartet: { szene: 'hk-sz hk-sz-buero', figur: 'hk-spr hk-gr-kappe-warten', overlays: { monitor: false, dampf: true, zzz: false, wirbel: false, schild: false, alarm: false } },
leerlauf: { szene: 'hk-sz hk-sz-buero', figur: 'hk-spr hk-gr-kappe-schlafen', overlays: { monitor: false, dampf: false, zzz: true, wirbel: false, schild: false, alarm: false } },
grau: { szene: 'hk-sz hk-sz-nicht', figur: 'hk-spr hk-gr-kappe-nicht', overlays: { monitor: false, dampf: false, zzz: false, wirbel: false, schild: true, alarm: false } },
fehler: { szene: 'hk-sz hk-sz-buero', figur: 'hk-spr hk-gr-kappe-nicht', overlays: { monitor: false, dampf: false, zzz: false, wirbel: false, schild: false, alarm: true } },
}
for (const status of STATUS) {
const d = detailModell('max', eintrag(status), null, JETZT)
assert.equal(d.status, status)
assert.equal(d.statusText, STATUS_ANZEIGE[status].text)
assert.equal(d.statusFarbe, STATUS_ANZEIGE[status].farbe)
assert.equal(d.szene, erwartet[status].szene, status)
assert.equal(d.figur, erwartet[status].figur, status)
assert.deepEqual({ ...d.overlays }, erwartet[status].overlays, status)
}
})
test('detailModell: Overseer steht auf der Brücke mit Wirbel, in jedem Status mit demselben Bild', () => {
for (const status of STATUS) {
const d = detailModell('otto', eintrag(status), null, JETZT)
assert.equal(d.szene, 'hk-sz hk-sz-bruecke', status)
assert.equal(d.figur, 'hk-spr hk-gr-overseer', status)
assert.equal(d.overlays.wirbel, true, status)
assert.equal(d.overlays.monitor, false, status)
assert.equal(d.name, 'Overseer')
}
assert.equal(detailModell('otto', eintrag('arbeitet'), null, JETZT).nummer, '1 / 19')
})
test('detailModell: ändert die Eingaben nicht', () => {
const e = Object.freeze(eintrag('arbeitet', { task: 'x', counters: Object.freeze({ gelesen: 1 }) }))
const t = JSON.parse(JSON.stringify(VOLLER_TRACE))
const vorher = JSON.stringify([e, t])
const d = detailModell('lea', e, t, JETZT)
assert.equal(JSON.stringify([e, t]), vorher)
assert.notEqual(d.spur, t.events)
})
test('blaettern: nächster und vorheriger in Roster-Reihenfolge mit Umlauf, unbekannt -> erster', () => {
const ids = ROLES.map((r) => r.workerId)
assert.equal(blaettern('otto', 1), 'rita')
assert.equal(blaettern('rita', -1), 'otto')
assert.equal(blaettern('lea', 1), 'bea')
assert.equal(blaettern('paul', 1), 'arne')
assert.equal(blaettern('arne', 1), 'otto')
assert.equal(blaettern('otto', -1), 'arne')
assert.equal(blaettern('lea', 0), 'lea')
assert.equal(blaettern('lea', 2), 'max')
assert.equal(blaettern('lea', -3), 'ali')
assert.equal(blaettern('lea', 19), 'lea')
assert.equal(blaettern('lea', -19), 'lea')
assert.equal(blaettern('lea', 40), blaettern('lea', 2))
assert.equal(blaettern('lea', 1.9), 'bea')
assert.equal(blaettern('lea', NaN), 'lea')
assert.equal(blaettern('lea', undefined), 'lea')
for (const unbekannt of ['zed', '', undefined, null, 7, 'constructor', '__proto__']) {
assert.equal(blaettern(unbekannt, 1), 'otto', String(unbekannt))
assert.equal(blaettern(unbekannt, -1), 'otto', String(unbekannt))
}
// einmal rundherum trifft alle 19
const gesehen = []
let id = ids[0]
for (let i = 0; i < 19; i += 1) { gesehen.push(id); id = blaettern(id, 1) }
assert.deepEqual(gesehen, ids)
assert.equal(id, ids[0])
})
// ---------------------------------------------------------------------------------------------------------------
// Kopfzeile
const VOLLE_KPIS = Object.freeze({ mrrNewEur: 60, apiTodayUsd: 5.1, dailyBudgetUsd: 6, newAbosMonth: 3, dealsMonth: 3, openThreads: 7, pultOpen: 5, botLeads: 112 })
test('hudWerte: volle Werte', () => {
const h = hudWerte(VOLLE_KPIS)
assert.deepEqual({ ...h.einnahmen }, { text: `+60${NBSP}€/m`, titel: 'Eingenommen pro Monat', sr: 'Eingenommen:' })
assert.deepEqual({ ...h.api }, { text: `-5,10${NBSP}$`, titel: `API-Kosten heute: 5,10${NBSP}$ von 6,00${NBSP}$`, sr: 'API-Kosten heute:', stufe: 'warn' }) // 5,10 von 6,00 sind 85 %
assert.deepEqual(h.zahlen.map((z) => ({ ...z })), [
{ key: 'abos', icon: 'hk-ic hk-stern', titel: 'Neue Abos (Monat)', text: '3', farbe: '#ffd25a' },
{ key: 'deals', icon: 'hk-ic hk-pokal', titel: 'Deals (Monat)', text: '3', farbe: '#ff9f43' },
{ key: 'threads', icon: 'hk-ic hk-blase', titel: 'Gespräche offen', text: '7', farbe: '#4fd1e8' },
{ key: 'pult', icon: 'hk-ic hk-glocke', titel: 'Wartet auf dich', text: '5', farbe: '#ff6fae' },
{ key: 'botleads', icon: 'hk-ic hk-robo', titel: 'Bot-Leads gesamt', text: '112', farbe: '#a78bfa' },
])
// Bindestrich ist ASCII (die Pixelschrift hat kein U+2212)
assert.ok(h.api.text.startsWith('-') && !h.api.text.includes('−'))
// Texte decken sich mit den Kacheln der alten Oberfläche
const kacheln = kpiTiles(VOLLE_KPIS)
for (const z of h.zahlen) {
const k = kacheln.find((t) => t.key === z.key)
assert.equal(z.titel, k.label, z.key)
assert.equal(z.text, k.value, z.key)
}
assert.equal(h.einnahmen.text, `+${formatEur(60)}/m`)
assert.equal(h.api.text, `-${formatUsd(5.1)}`)
})
test('hudWerte: Beträge mit Cent und Tausenderpunkt', () => {
const h = hudWerte({ mrrNewEur: 19.5, apiTodayUsd: 1234.5, newAbosMonth: 1234, dealsMonth: 0, openThreads: 12, pultOpen: 0, botLeads: 20000 })
assert.equal(h.einnahmen.text, `+19,50${NBSP}€/m`)
assert.equal(h.api.text, `-1.234,50${NBSP}$`)
assert.deepEqual(h.zahlen.map((z) => z.text), ['1.234', '0', '12', '0', '20.000'])
})
test('hudWerte: fehlende Werte ergeben Striche (null, undefined, {}, kein Objekt)', () => {
for (const leer of [null, undefined, {}, [], 'kpis', 7]) {
const h = hudWerte(leer)
assert.equal(h.einnahmen.text, '–')
assert.equal(h.api.text, '–')
assert.equal(h.api.stufe, '')
assert.equal(h.api.titel, 'API-Kosten heute: –')
assert.equal(h.zahlen.length, 5)
for (const z of h.zahlen) assert.equal(z.text, '–', z.key)
assert.equal(h.einnahmen.titel, 'Eingenommen pro Monat')
assert.equal(h.einnahmen.sr, 'Eingenommen:')
assert.equal(h.api.sr, 'API-Kosten heute:')
}
})
test('hudWerte: kaputte Werte (NaN, Text, Infinity, negativ, Objekte) ergeben nie NaN oder undefined', () => {
const kaputt = {
mrrNewEur: 'viel', apiTodayUsd: NaN, dailyBudgetUsd: 'sechs', newAbosMonth: NaN, dealsMonth: Infinity, openThreads: '7', pultOpen: {}, botLeads: [],
}
const h = hudWerte(kaputt)
const json = JSON.stringify(h)
assert.ok(!/NaN|undefined|Infinity|null/.test(json), json)
assert.equal(h.einnahmen.text, '–')
assert.equal(h.api.text, '–')
assert.equal(h.api.titel, 'API-Kosten heute: –')
for (const z of h.zahlen) assert.equal(z.text, '–', z.key)
// negative Beträge sind kaputt: kein "+-5" und kein "--5"
const neg = hudWerte({ mrrNewEur: -5, apiTodayUsd: -1, dailyBudgetUsd: 6 })
assert.equal(neg.einnahmen.text, '–')
assert.equal(neg.api.text, '–')
assert.equal(neg.api.titel, 'API-Kosten heute: –')
// Budget ohne Kosten: kein halber Satz
assert.equal(hudWerte({ dailyBudgetUsd: 6 }).api.titel, 'API-Kosten heute: –')
// Nullwerte sind echte Werte
const null0 = hudWerte({ mrrNewEur: 0, apiTodayUsd: 0, dailyBudgetUsd: 6 })
assert.equal(null0.einnahmen.text, `+0${NBSP}€/m`)
assert.equal(null0.api.text, `0,00${NBSP}$`)
assert.equal(null0.api.titel, `API-Kosten heute: 0,00${NBSP}$ von 6,00${NBSP}$`)
// Eingabe bleibt unverändert
const eingabe = Object.freeze({ ...VOLLE_KPIS })
hudWerte(eingabe)
assert.deepEqual({ ...eingabe }, { ...VOLLE_KPIS })
})
test('hudWerte: API-Titel ohne Tagesgrenze nur mit dem ersten Teil', () => {
assert.equal(hudWerte({ apiTodayUsd: 5.1 }).api.titel, `API-Kosten heute: 5,10${NBSP}$`)
assert.equal(hudWerte({ apiTodayUsd: 5.1, dailyBudgetUsd: null }).api.titel, `API-Kosten heute: 5,10${NBSP}$`)
assert.equal(hudWerte({ apiTodayUsd: 5.1, dailyBudgetUsd: 'x' }).api.titel, `API-Kosten heute: 5,10${NBSP}$`)
assert.equal(hudWerte({ apiTodayUsd: 5.1 }).api.stufe, '')
})
test('hudWerte: Budget-Stufen bei 79 %, 80 % und 100 % (wie die 80-%-Regel der Kacheln)', () => {
const stufe = (heute, grenze) => hudWerte({ apiTodayUsd: heute, dailyBudgetUsd: grenze }).api.stufe
assert.equal(stufe(79, 100), '')
assert.equal(stufe(80, 100), 'warn')
assert.equal(stufe(99.99, 100), 'warn')
assert.equal(stufe(100, 100), 'neg')
assert.equal(stufe(150, 100), 'neg')
assert.equal(stufe(4.74, 6), '') // 79 %
assert.equal(stufe(4.8, 6), 'warn') // genau 80 % in ganzen Cent
assert.equal(stufe(6, 6), 'neg')
assert.equal(stufe(0, 6), '')
assert.equal(stufe(5, 0), '')
assert.equal(stufe(5, undefined), '')
assert.equal(stufe(undefined, 6), '')
// gleiche Stufe wie die Kachel "API heute"
for (const heute of [0, 3, 4.74, 4.8, 5.9, 6, 9]) {
const kpis = { apiTodayUsd: heute, dailyBudgetUsd: 6 }
assert.equal(hudWerte(kpis).api.stufe, kpiTiles(kpis).find((t) => t.key === 'api').tone, String(heute))
}
})
test('schilder: Briefkasten-Zähler, Tresor und Keller mit Einzahl und Mehrzahl', () => {
assert.deepEqual({ ...schilder(VOLLE_KPIS) }, { briefkasten: '5', tresor: '3 Deals', keller: '112 Bot-Leads' })
// Briefkasten: bei 0 weg
assert.equal(schilder({ pultOpen: 0 }).briefkasten, null)
assert.equal(schilder({ pultOpen: 1 }).briefkasten, '1')
assert.equal(schilder({ pultOpen: 1234 }).briefkasten, '1.234')
// Einzahl, Null, Mehrzahl
assert.equal(schilder({ dealsMonth: 1 }).tresor, '1 Deal')
assert.equal(schilder({ dealsMonth: 0 }).tresor, '0 Deals')
assert.equal(schilder({ dealsMonth: 2 }).tresor, '2 Deals')
assert.equal(schilder({ botLeads: 1 }).keller, '1 Bot-Lead')
assert.equal(schilder({ botLeads: 0 }).keller, '0 Bot-Leads')
assert.equal(schilder({ botLeads: 1234 }).keller, '1.234 Bot-Leads')
// fehlend oder kaputt -> null, nie "NaN Deals"
for (const leer of [null, undefined, {}, [], 'x']) assert.deepEqual({ ...schilder(leer) }, { briefkasten: null, tresor: null, keller: null })
const kaputt = schilder({ pultOpen: NaN, dealsMonth: 'drei', botLeads: Infinity })
assert.deepEqual({ ...kaputt }, { briefkasten: null, tresor: null, keller: null })
assert.deepEqual({ ...schilder({ pultOpen: -2, dealsMonth: -1, botLeads: -9 }) }, { briefkasten: null, tresor: null, keller: null })
})
// ---------------------------------------------------------------------------------------------------------------
// Kamera: wörtliche Kopie der Formeln aus logik.js der Zeichenfläche als Gegenprobe
const VL = (() => {
const welt = () => ({ w: 2400, h: 1200, haus: { x: 930, y: 366, w: 570, h: 706 }, hud: 84 })
const minS = (g) => { const Wd = welt(); return Math.max(g.w / Wd.w, g.h / Wd.h) }
const grenzen = (st, g) => {
const Wd = welt()
const s = Math.min(5, Math.max(minS(g), st.s))
const x = Math.min(0, Math.max(g.w - Wd.w * s, st.x))
const y = Math.min(g.h * 0.35, Math.max(g.h - Wd.h * s, st.y))
return { s, x, y }
}
const passend = (g) => {
const Wd = welt(), H = Wd.haus
const s = Math.max(minS(g), Math.min((g.w - 48) / H.w, (g.h - Wd.hud - 16) / H.h))
const x = (g.w - H.w * s) / 2 - H.x * s
const y = Wd.hud + (g.h - Wd.hud - 16 - H.h * s) / 2 - H.y * s
return grenzen({ s, x, y }, g)
}
const zoomAuf = (alt, mx, my, ns, g) => {
const n = grenzen({ s: ns, x: 0, y: 0 }, g)
const f = n.s / alt.s
return grenzen({ s: n.s, x: mx - (mx - alt.x) * f, y: my - (my - alt.y) * f }, g)
}
const etage = (oben, unten, g) => {
const Wd = welt()
const s = Math.max(minS(g), Math.min(3, (g.w - 96) / 560, (g.h - Wd.hud - 40) / ((unten - oben) * 3.4)))
const mitte = (oben + unten) / 2
return grenzen({ s, x: g.w / 2 - 1216 * s, y: Wd.hud / 2 + g.h / 2 - mitte * s }, g)
}
return { welt, minS, grenzen, passend, zoomAuf, etage }
})()
const FENSTER = [
{ w: 1280, h: 720 }, { w: 1920, h: 1080 }, { w: 390, h: 700 }, { w: 390, h: 844 }, { w: 2560, h: 1440 },
{ w: 800, h: 400 }, { w: 3840, h: 2160 }, { w: 2400, h: 1200 }, { w: 1024, h: 1366 }, { w: 1500, h: 500 },
]
// Gleichmäßige Zufallszahlen ohne Math.random, damit der Test immer dasselbe prüft
function zufall(seed) {
let z = seed >>> 0
return () => { z = (Math.imul(z, 1664525) + 1013904223) >>> 0; return z / 4294967296 }
}
test('Kamera: gleiche Zahlen wie die Formeln der Zeichenfläche', () => {
const rnd = zufall(20261022)
for (const g of FENSTER) {
assert.equal(kameraMinS(g), VL.minS(g), `minS ${g.w}x${g.h}`)
assert.deepEqual(kameraPassend(g), VL.passend(g), `passend ${g.w}x${g.h}`)
for (const e of ETAGEN) assert.deepEqual(kameraEtage(e.oben, e.unten, g), VL.etage(e.oben, e.unten, g), `etage ${e.id} ${g.w}x${g.h}`)
for (let i = 0; i < 40; i += 1) {
const st = { s: rnd() * 8, x: (rnd() - 0.7) * 6000, y: (rnd() - 0.6) * 3000 }
assert.deepEqual(kameraGrenzen(st, g), VL.grenzen(st, g), `grenzen ${JSON.stringify(st)}`)
const alt = VL.grenzen({ s: 0.3 + rnd() * 4, x: (rnd() - 0.7) * 6000, y: (rnd() - 0.6) * 3000 }, g)
const mx = rnd() * g.w
const my = rnd() * g.h
const neu = rnd() * 9
assert.deepEqual(kameraZoomAuf(alt, mx, my, neu, g), VL.zoomAuf(alt, mx, my, neu, g), `zoomAuf ${JSON.stringify(alt)} ${mx} ${my} ${neu}`)
}
}
// Konstanten der Vorlage kommen aus hustlerWelt.js und stimmen mit der Vorlage überein
const Wd = VL.welt()
assert.deepEqual({ w: W.WELT.w, h: W.WELT.h, hud: W.WELT.hud }, { w: Wd.w, h: Wd.h, hud: Wd.hud })
assert.deepEqual({ ...W.HAUS }, Wd.haus)
assert.equal(W.ETAGEN_SPALTE.x + W.ETAGEN_SPALTE.w / 2, 1216)
})
test('kameraGrenzen: Zoom höchstens 5, nie kleiner als „cover“, kein leerer Rand links/rechts/unten, oben höchstens 35 %', () => {
const rnd = zufall(7)
for (const g of FENSTER) {
const min = kameraMinS(g)
assert.ok(min >= g.w / W.WELT.w && min >= g.h / W.WELT.h)
for (let i = 0; i < 200; i += 1) {
const k = kameraGrenzen({ s: (rnd() - 0.2) * 12, x: (rnd() - 0.5) * 20000, y: (rnd() - 0.5) * 20000 }, g)
const wo = `${g.w}x${g.h} ${JSON.stringify(k)}`
assert.ok(k.s <= ZOOM_MAX && k.s >= min, `Zoom ${wo}`)
// Welt füllt das Fenster: links und rechts kein Rand, unten kein Rand, oben höchstens 35 % der Höhe Himmel
assert.ok(k.x <= 0 && k.x + W.WELT.w * k.s >= g.w - 1e-9, `x ${wo}`)
assert.ok(k.y >= g.h - W.WELT.h * k.s - 1e-9 && k.y <= g.h * 0.35 + 1e-9, `y ${wo}`)
assert.deepEqual(kameraGrenzen(k, g), k, `nicht stabil ${wo}`)
}
}
assert.equal(ZOOM_MAX, 5)
assert.equal(kameraGrenzen({ s: 99, x: 0, y: 0 }, { w: 1280, h: 720 }).s, 5)
assert.equal(kameraGrenzen({ s: 0.01, x: 0, y: 0 }, { w: 1280, h: 720 }).s, kameraMinS({ w: 1280, h: 720 }))
assert.equal(kameraGrenzen({ s: 1, x: 100, y: 0 }, { w: 1280, h: 720 }).x, 0)
assert.equal(kameraGrenzen({ s: 1, x: -9999, y: 0 }, { w: 1280, h: 720 }).x, 1280 - 2400)
assert.equal(kameraGrenzen({ s: 1, x: 0, y: 9999 }, { w: 1280, h: 720 }).y, 720 * 0.35)
assert.equal(kameraGrenzen({ s: 1, x: 0, y: -9999 }, { w: 1280, h: 720 }).y, 720 - 1200)
})
test('kameraGrenzen und Co.: kaputte Eingaben geben endliche Zahlen', () => {
for (const g of [null, undefined, {}, { w: 0, h: 0 }, { w: NaN, h: 720 }, { w: -5, h: 720 }, 'x']) {
assert.equal(kameraMinS(g), kameraMinS(SICHT_STANDARD))
assert.deepEqual(kameraPassend(g), kameraPassend(SICHT_STANDARD))
assert.deepEqual(kameraEtage(552, 608, g), kameraEtage(552, 608, SICHT_STANDARD))
}
const bad = [kameraGrenzen({ s: NaN, x: NaN, y: undefined }, SICHT_STANDARD), kameraGrenzen(null, SICHT_STANDARD), kameraEtage(NaN, undefined, SICHT_STANDARD), kameraEtage(600, 600, SICHT_STANDARD)]
for (const k of bad) for (const v of [k.s, k.x, k.y]) assert.ok(Number.isFinite(v), JSON.stringify(k))
assert.deepEqual({ ...SICHT_STANDARD }, { w: 1280, h: 720 })
})
test('kameraPassend: das ganze Haus liegt mittig unter der Kopfzeile im Fenster (1280×720, 1920×1080, 390×700)', () => {
for (const g of [{ w: 1280, h: 720 }, { w: 1920, h: 1080 }, { w: 390, h: 700 }]) {
const k = kameraPassend(g)
const wo = `${g.w}x${g.h} ${JSON.stringify(k)}`
assert.ok(k.s >= kameraMinS(g), wo)
assert.ok(k.s <= ZOOM_MAX, wo)
const links = W.HAUS.x * k.s + k.x
const rechts = (W.HAUS.x + W.HAUS.w) * k.s + k.x
const oben = W.HAUS.y * k.s + k.y
const unten = (W.HAUS.y + W.HAUS.h) * k.s + k.y
assert.ok(links >= -1e-9 && rechts <= g.w + 1e-9, `Breite ${wo}`)
assert.ok(oben >= W.WELT.hud - 1e-9 && unten <= g.h + 1e-9, `Höhe ${wo}`)
assert.ok(Math.abs(links - (g.w - rechts)) < 1e-6, `mittig ${wo}`)
// Was nicht im Haus ist, füllt trotzdem die Welt: keine Ränder
assert.deepEqual(kameraGrenzen(k, g), k, wo)
}
// Querformat: Höhe begrenzt (Haus 706 hoch, 720 − 84 − 16 = 620 frei); Hochformat: Breite begrenzt
assert.ok(Math.abs(kameraPassend({ w: 1280, h: 720 }).s - (720 - 84 - 16) / 706) < 1e-12)
assert.ok(Math.abs(kameraPassend({ w: 390, h: 700 }).s - (390 - 48) / 570) < 1e-12)
assert.ok(Math.abs(kameraPassend({ w: 1920, h: 1080 }).s - (1080 - 84 - 16) / 706) < 1e-12)
})
test('kameraZoomAuf: der Weltpunkt unter der Maus bleibt stehen', () => {
for (const g of [{ w: 1280, h: 720 }, { w: 1920, h: 1080 }]) {
const alt = kameraPassend(g)
const mx = g.w * 0.5
const my = g.h * 0.55
const unterMaus = (k) => ({ wx: (mx - k.x) / k.s, wy: (my - k.y) / k.s })
const vorher = unterMaus(alt)
for (const faktor of [1.25, 1.5, 2, 0.9]) {
const neu = kameraZoomAuf(alt, mx, my, alt.s * faktor, g)
assert.ok(Math.abs(neu.s - alt.s * faktor) < 1e-9, `Zoom ${g.w} ${faktor}`)
const nachher = unterMaus(neu)
assert.ok(Math.abs(nachher.wx - vorher.wx) < 1e-6 && Math.abs(nachher.wy - vorher.wy) < 1e-6, `Punkt ${g.w} ${faktor}`)
}
// zweimal hintereinander (wie Mausrad) bleibt der Punkt ebenfalls stehen
const z1 = kameraZoomAuf(alt, mx, my, alt.s * 1.25, g)
const z2 = kameraZoomAuf(z1, mx, my, z1.s * 1.25, g)
const nach = unterMaus(z2)
assert.ok(Math.abs(nach.wx - vorher.wx) < 1e-6 && Math.abs(nach.wy - vorher.wy) < 1e-6)
}
const g = { w: 1280, h: 720 }
const alt = kameraPassend(g)
// Grenzen: weiter als 5 geht nicht, kleiner als „cover“ auch nicht
assert.equal(kameraZoomAuf(alt, 640, 360, 50, g).s, ZOOM_MAX)
assert.equal(kameraZoomAuf(alt, 640, 360, 0.001, g).s, kameraMinS(g))
assert.ok(Math.abs(kameraZoomAuf(alt, 640, 360, alt.s, g).s - alt.s) < 1e-12)
// Ergebnis liegt in den Grenzen, auch am Rand des Fensters
for (const [mx, my] of [[0, 0], [1280, 720], [0, 720], [1280, 0]]) {
const k = kameraZoomAuf(alt, mx, my, 4, g)
assert.deepEqual(kameraGrenzen(k, g), k)
}
// Ohne gültige bisherige Kamera: ganzes Haus statt NaN
for (const kaputt of [null, undefined, { s: 0, x: 0, y: 0 }, { s: NaN, x: 0, y: 0 }, { s: 1, x: 'a', y: 0 }]) {
assert.deepEqual(kameraZoomAuf(kaputt, 640, 360, 2, g), kameraPassend(g))
}
// Eingabe bleibt unverändert
const eingabe = Object.freeze({ ...alt })
kameraZoomAuf(eingabe, 100, 100, 2, g)
assert.deepEqual({ ...eingabe }, alt)
})
test('kameraEtage: die Etage liegt mittig im Bild, höchstens 3-fach, nie kleiner als „cover“', () => {
for (const g of [{ w: 1280, h: 720 }, { w: 1920, h: 1080 }, { w: 390, h: 844 }]) {
for (const e of ETAGEN) {
const k = kameraEtage(e.oben, e.unten, g)
const wo = `${e.id} ${g.w}x${g.h} ${JSON.stringify(k)}`
assert.ok(k.s <= 3 + 1e-12 && k.s >= kameraMinS(g) - 1e-12, `Zoom ${wo}`)
assert.deepEqual(kameraGrenzen(k, g), k, wo)
// mittig, solange die Grenzen es nicht verhindern (oben und unten im Haus kann die Welt nicht weiter wandern)
const unbegrenzt = { x: g.w / 2 - 1216 * k.s, y: W.WELT.hud / 2 + g.h / 2 - ((e.oben + e.unten) / 2) * k.s }
if (Math.abs(unbegrenzt.x - k.x) < 1e-9) assert.ok(Math.abs(1216 * k.s + k.x - g.w / 2) < 1e-6, `Mitte x ${wo}`)
if (Math.abs(unbegrenzt.y - k.y) < 1e-9) assert.ok(Math.abs(((e.oben + e.unten) / 2) * k.s + k.y - (W.WELT.hud / 2 + g.h / 2)) < 1e-6, `Mitte y ${wo}`)
}
}
// Auf Desktop sind alle Etagen wirklich mittig (nichts wird begrenzt)
for (const g of [{ w: 1280, h: 720 }, { w: 1920, h: 1080 }]) {
for (const e of ETAGEN) {
const k = kameraEtage(e.oben, e.unten, g)
assert.ok(Math.abs(1216 * k.s + k.x - g.w / 2) < 1e-6, `x ${e.id} ${g.w}`)
assert.ok(Math.abs(((e.oben + e.unten) / 2) * k.s + k.y - (W.WELT.hud / 2 + g.h / 2)) < 1e-6, `y ${e.id} ${g.w}`)
// die ganze Etage ist im Bild, unter der Kopfzeile
assert.ok(e.oben * k.s + k.y >= W.WELT.hud - 1e-6 && e.unten * k.s + k.y <= g.h + 1e-6, `Etage im Bild ${e.id} ${g.w}`)
}
}
assert.ok(Math.abs(kameraEtage(720, 776, { w: 1280, h: 720 }).s - (1280 - 96) / 560) < 1e-12)
assert.equal(kameraEtage(720, 776, { w: 1920, h: 1080 }).s, 3)
})
// ---------------------------------------------------------------------------------------------------------------
// Aufbau des Moduls
test('Konstanten sind eingefroren', () => {
for (const [name, wert] of Object.entries(S)) {
if (typeof wert === 'function') continue
tiefEingefroren(wert, name)
}
for (const name of ['HUT', 'STATUS_ANZEIGE', 'POSE_NACH_STATUS', 'AUFGABEN', 'SICHT_STANDARD', 'ZOOM_MAX']) assert.ok(name in S, name)
assert.throws(() => { 'use strict'; HUT.haiku = 'x' }, TypeError)
assert.throws(() => { 'use strict'; STATUS_ANZEIGE.arbeitet.farbe = 'rot' }, TypeError)
assert.throws(() => { 'use strict'; AUFGABEN.otto = 'x' }, TypeError)
})
test('Modul ist rein: nur erlaubte Importe, kein React, kein DOM, kein innerHTML', () => {
const quelle = readFileSync(new URL('../../src/lib/hustlerSicht.js', import.meta.url), 'utf8')
const importe = [...quelle.matchAll(/^import[\s\S]*?from\s+'([^']+)'/gm)].map((m) => m[1])
assert.deepEqual(importe.sort(), ['./hustlerWelt.js', './workersRoster.js', './workersView.js'])
assert.ok(!/\b(window|document|navigator|localStorage|innerHTML|dangerouslySetInnerHTML|fetch)\b/.test(quelle.replace(/\/\/.*$/gm, '')))
assert.ok(!/Math\.random|Date\.now|new Date/.test(quelle), 'rein: keine Uhr, kein Zufall')
})