Hustler-Pixelwelt T4: reine Ableitungen (Figur, Detail, Kopfzeile, Kamera)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-22 08:04:35 +00:00
1 parent d8912ee363
commit 43b4dcbe9f
2 files changed
+1177

No files matched your search

+311
View File
@@ -0,0 +1,311 @@
// Reine Ableitungen der Hustler-Pixelwelt (Spec E5-E7, E10): Figur nach Status, Detailansicht, Kopfzeile, Kamera.
// Kein React, kein DOM, kein Appwrite: unter Node testbar (loadModule). Klassen heißen genau wie in
// src/styles/hustler/hochhaus.css (alle mit hk- davor). Keine Beispieldaten: die Aufgaben-Texte sind UI-Text.
import { FLOOR_TITLES, MODEL_IDS, ROLES, ROOM_TITLES, SEARCH_ROOMS, hatForWorker, roleOf } from './workersRoster.js'
import { counterLines, formatCount, formatEur, formatUsd, kpiTiles, tokenTotal, traceRows } from './workersView.js'
import { ETAGEN_SPALTE, HAUS, WELT } from './hustlerWelt.js'
const DASH = '–'
const OVERSEER_ID = 'otto'
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
const isNum = (n) => typeof n === 'number' && Number.isFinite(n)
const text = (v) => (typeof v === 'string' ? v.trim() : '')
// ---------------------------------------------------------------------------------------------------------------
// Namen und Modelle
/** Hut-Namen der Sprites je Hut des Rosters (hatForWorker: haiku/sonnet/opus/overseer). */
export const HUT = Object.freeze({ haiku: 'muetze', sonnet: 'kappe', opus: 'krone', overseer: 'overseer' })
const MODELL_KURZ = Object.freeze({ opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku' })
const MODELL_NAME = Object.freeze({ opus: 'Claude Opus 5.5', sonnet: 'Claude Sonnet 5.5', haiku: 'Claude Haiku 5.5' })
/** Anzeige-Name: otto heißt in der Oberfläche „Overseer“, sonst Roster-Name, sonst die Id selbst. */
export function anzeigeName(workerId) {
if (workerId === OVERSEER_ID) return 'Overseer'
const r = roleOf(workerId)
if (r) return r.name
return typeof workerId === 'string' ? workerId : ''
}
/** 'Opus' | 'Sonnet' | 'Haiku' nach dem Modell der Rolle; unbekannte Kennung '–'. */
export function modellKurz(workerId) {
const r = roleOf(workerId)
return r && Object.hasOwn(MODELL_KURZ, r.model) ? MODELL_KURZ[r.model] : DASH
}
/** 'Claude Opus 5.5' usw. aus Kurzname ('opus') oder voller Modell-Id ('claude-opus-5-5'); sonst '–'. */
export function modellName(model) {
if (typeof model !== 'string') return DASH
const m = model.trim().toLowerCase()
if (Object.hasOwn(MODELL_NAME, m)) return MODELL_NAME[m]
const kurz = Object.keys(MODEL_IDS).find((k) => MODEL_IDS[k] === m)
return kurz ? MODELL_NAME[kurz] : DASH
}
// ---------------------------------------------------------------------------------------------------------------
// Status
const STATUS_BEKANNT = new Set(['arbeitet', 'wartet', 'leerlauf', 'fehler'])
/** Anzeige-Status des Zustandseintrags: nicht angeschlossen -> 'grau', sonst der Dienst-Status, alles andere 'leerlauf'. */
export function statusVon(eintrag) {
if (!isRecord(eintrag)) return 'leerlauf'
if (eintrag.connected === false) return 'grau'
return typeof eintrag.status === 'string' && STATUS_BEKANNT.has(eintrag.status) ? eintrag.status : 'leerlauf'
}
/** Anzeige je Status: text (Detailansicht), aria (Figuren-Label), farbe (Punkt neben dem Status). */
export const STATUS_ANZEIGE = Object.freeze({
arbeitet: Object.freeze({ text: 'Arbeitet', aria: 'arbeitet', farbe: '#4ade5a' }),
wartet: Object.freeze({ text: 'Wartet auf Auftrag', aria: 'wartet auf Auftrag', farbe: '#ffd25a' }),
leerlauf: Object.freeze({ text: 'Leerlauf', aria: 'Leerlauf', farbe: '#9aa6d6' }),
grau: Object.freeze({ text: 'Noch nicht angeschlossen', aria: 'noch nicht angeschlossen', farbe: '#a4a2ad' }),
fehler: Object.freeze({ text: 'Fehler', aria: 'Fehler', farbe: '#ff5a5a' }),
})
/** Pose des Sprites je Status (grau und Fehler stehen). */
export const POSE_NACH_STATUS = Object.freeze({ arbeitet: 'arbeiten', wartet: 'warten', leerlauf: 'schlafen', grau: 'nicht', fehler: 'nicht' })
// ---------------------------------------------------------------------------------------------------------------
// Figur im Haus
/**
* Alles, was die Figur im Haus braucht (Spec E5): Sprite-Klasse, Zusatz-Symbole, graue Raum-Ebene, Label.
* `eintrag` = Zustandseintrag { status, task, lastTs, connected, counters } aus indexWorkers; paused = alles steht still.
*/
export function figurZustand(workerId, eintrag, { paused = false } = {}) {
const status = statusVon(eintrag)
const ovs = workerId === OVERSEER_ID
const hut = HUT[hatForWorker(workerId)]
const pose = ovs ? 'overseer' : POSE_NACH_STATUS[status]
const rolle = roleOf(workerId)
const raum = rolle ? rolle.room : null
const modell = modellKurz(workerId)
return {
status,
hut,
pose,
klasse: ovs ? 'hk-spr hk-ovs-klein' : `hk-spr hk-fig-${hut}-${pose}`,
punkte: status === 'wartet',
zzz: status === 'leerlauf',
bildschirm: status === 'arbeitet' && !ovs,
radar: workerId === 'rita' && status === 'arbeitet',
alarm: status === 'fehler',
raum,
raumGrau: status === 'grau' && raum !== null && SEARCH_ROOMS.includes(raum),
gedimmt: ovs && status !== 'arbeitet',
steht: paused === true,
label: [anzeigeName(workerId), modell === DASH ? '' : modell, STATUS_ANZEIGE[status].aria].filter((t) => t !== '').join(', '),
}
}
// ---------------------------------------------------------------------------------------------------------------
// Detailansicht
/** Aufgabe je Hustler (UI-Text der Zeichenfläche, wörtlich). */
export const AUFGABEN = Object.freeze({
otto: 'Plant die Nacht, verteilt die Aufträge, achtet aufs Budget und schreibt den Morgenbericht.',
rita: 'Sucht im offenen Netz über Suchmaschinen nach frischen „Suche Webdesigner“-Posts.',
kim: 'Liest die Suchauftrags-Mails von Kleinanzeigen und ähnlichen Portalen.',
pia: 'Liest Projekt-Alerts und RSS der Portale (freelancermap, freelance.de und weitere).',
finn: 'Durchsucht Foren und Frage-Seiten, nur wo es erlaubt ist.',
ali: 'Branchen-Scout für Ärzte und Praxen ohne oder mit veralteter Website.',
hanna: 'Branchen-Scout für Handwerksbetriebe ohne oder mit veralteter Website.',
sven: 'Branchen-Scout für Selbstständige ohne oder mit veralteter Website.',
lea: 'Branchen-Scout für Läden und Gastro ohne oder mit veralteter Website.',
bea: 'Bewertet Anfragen und Kandidaten und verteilt Top 3 und Rest.',
max: 'Schreibt Mails und ist der einzige Absender: Erstmail, Nachfassen und Antworten.',
clara: 'Schreibt nur Antworten auf Anfragen (Plattform ins Pult, Mail an Max).',
nora: 'Liest und sortiert alle eingehenden Antworten.',
theo: 'Schreibt die Nachrichten rund um den Kauf (als Entwurf).',
bob: 'Baut Vorschau-Websites, nur nach einem Ja.',
bianca: 'Baut Vorschau-Websites, nur nach einem Ja.',
ben: 'Baut Vorschau-Websites, nur nach einem Ja.',
paul: 'Prüft jede Vorschau am Desktop und am Handy und gibt sie frei oder schickt sie zurück.',
arne: 'Führt das Nie-doppelt-Register und die Bot-Lead-Liste.',
})
function aufgabeVon(workerId, trace, rolle) {
if (typeof workerId === 'string' && Object.hasOwn(AUFGABEN, workerId)) return AUFGABEN[workerId]
const info = isRecord(trace) && isRecord(trace.worker) ? trace.worker : null
return text(info && info.role) || text(rolle && rolle.role) || DASH
}
function heuteVon(trace, eintrag) {
const today = isRecord(trace) && isRecord(trace.today) ? trace.today : null
const zaehler = today && isRecord(today.counters) ? today.counters : isRecord(eintrag) ? eintrag.counters : null
const zeilen = counterLines(zaehler).map((c) => `${c.value} ${c.label}`)
return zeilen.length > 0 ? zeilen.join(' · ') : DASH
}
/**
* Felder der Detailansicht (Spec E10). trace = Antwort von api.trace (oder null/kaputt), now = Uhr für die Spur.
* Overseer steht auf der Brücke, ein nicht angeschlossener Hustler in der Stecker-Szene, alle anderen im Büro.
*/
export function detailModell(workerId, eintrag, trace, now) {
const status = statusVon(eintrag)
const anzeige = STATUS_ANZEIGE[status]
const ovs = workerId === OVERSEER_ID
const rolle = roleOf(workerId)
const info = isRecord(trace) && isRecord(trace.worker) ? trace.worker : null
const modelId = text(info && info.modelId) !== '' ? info.modelId : rolle ? rolle.model : null
const today = isRecord(trace) && isRecord(trace.today) ? trace.today : null
const hut = HUT[hatForWorker(workerId)]
const index = ROLES.findIndex((r) => r.workerId === workerId)
return {
id: workerId,
name: anzeigeName(workerId),
status,
statusText: anzeige.text,
statusFarbe: anzeige.farbe,
modell: modellName(modelId),
etageRaum: rolle ? `${FLOOR_TITLES[rolle.floor]} · ${ROOM_TITLES[rolle.room]}` : DASH,
aufgabe: aufgabeVon(workerId, trace, rolle),
gerade: text(isRecord(eintrag) ? eintrag.task : null) || DASH,
heute: heuteVon(trace, eintrag),
kosten: formatUsd(today ? today.costUsd : null),
tokens: formatCount(today ? tokenTotal(today.tokens) : null),
spur: traceRows(isRecord(trace) ? trace.events : null, now),
nummer: `${index >= 0 ? index + 1 : DASH} / ${ROLES.length}`,
szene: ovs ? 'hk-sz hk-sz-bruecke' : status === 'grau' ? 'hk-sz hk-sz-nicht' : 'hk-sz hk-sz-buero',
figur: ovs ? 'hk-spr hk-gr-overseer' : `hk-spr hk-gr-${hut}-${POSE_NACH_STATUS[status]}`,
overlays: {
monitor: status === 'arbeitet' && !ovs,
dampf: status === 'wartet',
zzz: status === 'leerlauf',
wirbel: ovs,
schild: status === 'grau',
alarm: status === 'fehler',
},
}
}
/** Nächster (schritt > 0) oder vorheriger Hustler in Roster-Reihenfolge mit Umlauf; unbekannte Kennung -> der erste. */
export function blaettern(workerId, schritt) {
const n = ROLES.length
const i = ROLES.findIndex((r) => r.workerId === workerId)
if (i < 0) return ROLES[0].workerId
const d = isNum(schritt) ? Math.trunc(schritt) : 0
return ROLES[(((i + d) % n) + n) % n].workerId
}
// ---------------------------------------------------------------------------------------------------------------
// Kopfzeile (HUD)
const API_TITEL = 'API-Kosten heute'
const HUD_ZAHLEN = Object.freeze([
Object.freeze({ key: 'abos', icon: 'hk-ic hk-stern', titel: 'Neue Abos (Monat)', farbe: '#ffd25a', feld: 'newAbosMonth' }),
Object.freeze({ key: 'deals', icon: 'hk-ic hk-pokal', titel: 'Deals (Monat)', farbe: '#ff9f43', feld: 'dealsMonth' }),
Object.freeze({ key: 'threads', icon: 'hk-ic hk-blase', titel: 'Gespräche offen', farbe: '#4fd1e8', feld: 'openThreads' }),
Object.freeze({ key: 'pult', icon: 'hk-ic hk-glocke', titel: 'Wartet auf dich', farbe: '#ff6fae', feld: 'pultOpen' }),
Object.freeze({ key: 'botleads', icon: 'hk-ic hk-robo', titel: 'Bot-Leads gesamt', farbe: '#a78bfa', feld: 'botLeads' }),
])
/**
* Werte der Kopfzeile aus state.kpis: grüne und rote Münze, fünf Icons mit Zahl. Fehlende oder kaputte Werte
* ergeben '–', nie NaN. Das Minus der Kosten ist ein ASCII-Bindestrich (die Pixelschrift hat kein U+2212).
*/
export function hudWerte(kpis) {
const k = isRecord(kpis) ? kpis : {}
const stufe = kpiTiles(k).find((t) => t.key === 'api').tone
const einnahmen = isNum(k.mrrNewEur) && k.mrrNewEur >= 0 ? `+${formatEur(k.mrrNewEur)}/m` : DASH
const heute = isNum(k.apiTodayUsd) && k.apiTodayUsd >= 0 ? k.apiTodayUsd : null
const kosten = heute === null ? DASH : heute === 0 ? formatUsd(0) : `-${formatUsd(heute)}`
const grenze = heute !== null && isNum(k.dailyBudgetUsd) ? ` von ${formatUsd(k.dailyBudgetUsd)}` : ''
return {
einnahmen: { text: einnahmen, titel: 'Eingenommen pro Monat', sr: 'Eingenommen:' },
api: { text: kosten, titel: `${API_TITEL}: ${heute === null ? DASH : formatUsd(heute)}${grenze}`, sr: `${API_TITEL}:`, stufe },
zahlen: HUD_ZAHLEN.map((z) => ({ key: z.key, icon: z.icon, titel: z.titel, text: formatCount(k[z.feld]), farbe: z.farbe })),
}
}
function zaehlerSchild(n, einzahl, mehrzahl) {
if (!isNum(n) || n < 0) return null
return `${formatCount(n)} ${n === 1 ? einzahl : mehrzahl}`
}
/** Schilder im Haus: Briefkasten-Zähler (bei 0 weg), Tresor- und Keller-Schild; fehlende Werte ergeben null. */
export function schilder(kpis) {
const k = isRecord(kpis) ? kpis : {}
return {
briefkasten: isNum(k.pultOpen) && k.pultOpen > 0 ? formatCount(k.pultOpen) : null,
tresor: zaehlerSchild(k.dealsMonth, 'Deal', 'Deals'),
keller: zaehlerSchild(k.botLeads, 'Bot-Lead', 'Bot-Leads'),
}
}
// ---------------------------------------------------------------------------------------------------------------
// Kamera (Formeln aus logik.js der Zeichenfläche). g = Größe des Sichtfensters { w, h }; Ergebnis { s, x, y }:
// Welt-Pixel mal s plus Verschiebung x/y. Ungültige Eingaben (0, NaN) fallen auf die Entwurfsgröße zurück.
/** Größte Vergrößerung. */
export const ZOOM_MAX = 5
/** Größe des Sichtfensters, wenn noch keine gemessen ist (wie im Entwurf). */
export const SICHT_STANDARD = Object.freeze({ w: 1280, h: 720 })
const RAND_SEITE = 48 // „ganzes Haus“: so viel Luft links und rechts zusammen
const RAND_UNTEN = 16 // „ganzes Haus“: Luft unter dem Haus
const ETAGE_SEITE = 96 // Etage heranzoomen: Luft links und rechts zusammen
const ETAGE_BREITE = ETAGEN_SPALTE.w + 48 // Breite, die eine Etage im Bild füllen soll (Spalte plus Rand, 560)
const ETAGE_ZOOM_MAX = 3
const ETAGE_HOEHE = 3.4 // Etagenhöhe mal dieser Faktor passt in die Höhe unter der Kopfzeile
const ETAGE_HOEHE_RAND = 40
const OBEN_FREI = 0.35 // so viel der Höhe darf oben über der Welt frei bleiben (Himmel)
function sicht(g) {
return isRecord(g) && isNum(g.w) && g.w > 0 && isNum(g.h) && g.h > 0 ? g : SICHT_STANDARD
}
/** Kleinster Zoom: die Welt füllt das Fenster ganz („cover“). */
export function kameraMinS(g) {
const v = sicht(g)
return Math.max(v.w / WELT.w, v.h / WELT.h)
}
/** Kamera in die Grenzen zwingen: Zoom zwischen „cover“ und 5, links/rechts/unten kein leerer Rand, oben höchstens 35 %. */
export function kameraGrenzen(kamera, g) {
const v = sicht(g)
const k = isRecord(kamera) ? kamera : {}
const min = kameraMinS(v)
const s = Math.min(ZOOM_MAX, Math.max(min, isNum(k.s) ? k.s : min))
const x = Math.min(0, Math.max(v.w - WELT.w * s, isNum(k.x) ? k.x : 0))
const y = Math.min(v.h * OBEN_FREI, Math.max(v.h - WELT.h * s, isNum(k.y) ? k.y : 0))
return { s, x, y }
}
/** „Ganzes Haus“: das Haus mittig unter der Kopfzeile, so groß wie es passt (nie kleiner als „cover“). */
export function kameraPassend(g) {
const v = sicht(g)
const s = Math.max(kameraMinS(v), Math.min((v.w - RAND_SEITE) / HAUS.w, (v.h - WELT.hud - RAND_UNTEN) / HAUS.h))
const x = (v.w - HAUS.w * s) / 2 - HAUS.x * s
const y = WELT.hud + (v.h - WELT.hud - RAND_UNTEN - HAUS.h * s) / 2 - HAUS.y * s
return kameraGrenzen({ s, x, y }, v)
}
/**
* Auf neuen Zoom `neuS` um den Punkt (mx, my) im Fenster: der Weltpunkt unter dem Zeiger bleibt dort (soweit die
* Grenzen es erlauben). Ohne gültige bisherige Kamera (s <= 0, NaN) bleibt nur „ganzes Haus“.
*/
export function kameraZoomAuf(alt, mx, my, neuS, g) {
const v = sicht(g)
if (!isRecord(alt) || !isNum(alt.s) || alt.s <= 0 || !isNum(alt.x) || !isNum(alt.y)) return kameraPassend(v)
const px = isNum(mx) ? mx : v.w / 2
const py = isNum(my) ? my : v.h / 2
const n = kameraGrenzen({ s: neuS, x: 0, y: 0 }, v)
const f = n.s / alt.s
return kameraGrenzen({ s: n.s, x: px - (px - alt.x) * f, y: py - (py - alt.y) * f }, v)
}
/** Eine Etage (Klickbereich oben..unten in Weltpixeln) mittig heranzoomen, höchstens 3-fach. */
export function kameraEtage(oben, unten, g) {
const v = sicht(g)
const hoehe = isNum(oben) && isNum(unten) && unten > oben ? unten - oben : 1
const mitte = isNum(oben) && isNum(unten) ? (oben + unten) / 2 : 0
const s = Math.max(kameraMinS(v), Math.min(ETAGE_ZOOM_MAX, (v.w - ETAGE_SEITE) / ETAGE_BREITE, (v.h - WELT.hud - ETAGE_HOEHE_RAND) / (hoehe * ETAGE_HOEHE)))
return kameraGrenzen({ s, x: v.w / 2 - (ETAGEN_SPALTE.x + ETAGEN_SPALTE.w / 2) * s, y: WELT.hud / 2 + v.h / 2 - mitte * s }, v)
}
+866
View File
@@ -0,0 +1,866 @@
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')
})