Hustler-Pixelwelt T4: reine Ableitungen (Figur, Detail, Kopfzeile, Kamera)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d8912ee363
commit
43b4dcbe9f
2 files changed
+1177
No files matched your search
@@ -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)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
Reference in new issue
Block a user