From 43b4dcbe9f99731e879e2afbd7922bacb6237b75 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 08:04:35 +0000 Subject: [PATCH] Hustler-Pixelwelt T4: reine Ableitungen (Figur, Detail, Kopfzeile, Kamera) Co-Authored-By: Claude Opus 5.5 --- src/lib/hustlerSicht.js | 311 +++++++++++ tests/unit/hustlerSicht.test.mjs | 866 +++++++++++++++++++++++++++++++ 2 files changed, 1177 insertions(+) create mode 100644 src/lib/hustlerSicht.js create mode 100644 tests/unit/hustlerSicht.test.mjs diff --git a/src/lib/hustlerSicht.js b/src/lib/hustlerSicht.js new file mode 100644 index 0000000..cd04bae --- /dev/null +++ b/src/lib/hustlerSicht.js @@ -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) +} diff --git a/tests/unit/hustlerSicht.test.mjs b/tests/unit/hustlerSicht.test.mjs new file mode 100644 index 0000000..670c5ad --- /dev/null +++ b/tests/unit/hustlerSicht.test.mjs @@ -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') +})