867 lines
47 KiB
JavaScript
867 lines
47 KiB
JavaScript
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')
|
||
})
|