Files
tickte-system/tests/unit/hustlerRegie.test.mjs

986 lines
46 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync, statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'
import { loadModule } from './load.mjs'
import { FAKE_REACT } from './helpers/fakeReact.mjs'
import { ev, IDS } from './helpers/workersFixtures.mjs'
// Regie der Hustler-Pixelwelt (T8, Spec E9):
// 1. src/lib/hustlerRegie.js: Ereignisse -> Effekte (Blase, Symbol, Flug, Jubel), Höchstzahl, Rückstau, Zeitablauf mit eigener Uhr, reduziert, flush, kaputte Eingaben.
// 2. RegieEbene.jsx im Mini-Läufer anstelle von React (helpers/fakeReact.mjs, um forwardRef und useImperativeHandle ergänzt), mit eigenem Zeitgeber,
// eigener Uhr, matchMedia und document.
// 3. hustler-regie.css: Bilder vorhanden, Klassen, Keyframes, „weniger Bewegung“.
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const R = await loadModule('src/lib/hustlerRegie.js')
const E = await loadModule('src/lib/workersEvents.js')
const W = await loadModule('src/lib/hustlerWelt.js')
const { createRegie, PLAETZE, DAUER, ICON_NACH_FX, kopfPunkt, koerperPunkt } = R
const { EVENT_KINDS } = E
const { HUSTLER } = W
// ---------- Hilfen ----------
// Eigene Uhr: jetzt() liefert den Stand, vor(ms) stellt ihn weiter
function uhrVon(start = 1_000_000) {
let t = start
return { jetzt: () => t, vor: (ms) => { t += ms }, stand: () => t }
}
// Alle Effekte, die im Lauf von `bis` Millisekunden einmal sichtbar waren (Schritt 100 ms), in der Reihenfolge ihres Erscheinens
function sammeln(regie, uhr, bis = 8000, schritt = 100) {
const gesehen = new Map()
for (let ms = 0; ms <= bis; ms += schritt) {
for (const e of regie.tick()) if (!gesehen.has(e.id)) gesehen.set(e.id, e)
uhr.vor(schritt)
}
return [...gesehen.values()]
}
// Kurzform eines Effekts für Vergleiche: art[:icon][:wer:text]
const kurz = (e) => (e.art === 'blase' ? `blase:${e.wer}:${e.text}` : e.icon ? `${e.art}:${e.icon}` : e.art)
const ARTEN = new Set(['blase', 'icon', 'flug', 'jubel'])
function pruefeForm(e) {
assert.ok(ARTEN.has(e.art), `art ${e.art}`)
assert.equal(typeof e.id, 'string')
assert.ok(Number.isFinite(e.x) && Number.isFinite(e.y), 'x/y')
assert.ok(Number.isFinite(e.start) && e.dauer > 0, 'start/dauer')
assert.equal(e.dauer, DAUER[e.art])
if (e.art === 'flug') assert.ok(Number.isFinite(e.x2) && Number.isFinite(e.y2), 'x2/y2')
else assert.ok(!('x2' in e) && !('y2' in e), 'x2/y2 nur beim Flug')
if (e.art === 'blase') assert.ok(typeof e.text === 'string' && e.text !== '')
assert.ok(Object.isFrozen(e))
}
// ---------- Plätze und Punkte ----------
test('PLAETZE: vier Plätze in Weltkoordinaten (Briefkasten, Tresor-Tür, Keller, vor dem Eingang links), eingefroren', () => {
assert.deepEqual(Object.keys(PLAETZE).sort(), ['archiv', 'aussen', 'pult', 'tresor'])
assert.deepEqual({ ...PLAETZE.pult }, { x: 1240, y: 920 })
assert.deepEqual({ ...PLAETZE.tresor }, { x: 1375, y: 975 })
assert.deepEqual({ ...PLAETZE.archiv }, { x: 1250, y: 1040 })
assert.deepEqual({ ...PLAETZE.aussen }, { x: 900, y: 995 })
assert.ok(Object.isFrozen(PLAETZE))
for (const p of Object.values(PLAETZE)) assert.ok(Object.isFrozen(p))
assert.ok(PLAETZE.aussen.x < W.HAUS_BILD.x, 'aussen liegt links vom Haus')
})
test('Figurpunkte: Kopf = Anker a, f − 36 (Overseer höher), Körper = f − 16; unbekannte Kennungen -> null', () => {
assert.equal(IDS.length, 19)
for (const id of IDS) {
const h = HUSTLER[id]
assert.ok(h, `${id} fehlt in HUSTLER`)
assert.deepEqual(kopfPunkt(id), { x: h.a, y: id === 'otto' ? h.f - 44 : h.f - 36 }, id)
assert.deepEqual(koerperPunkt(id), { x: h.a, y: h.f - 16 }, id)
}
assert.ok(kopfPunkt('otto').y < HUSTLER.otto.f - 36, 'Overseer: Blase höher')
for (const id of ['niemand', '', 'constructor', '__proto__', 'toString', 'pult', 'system', null, undefined, 5, {}]) {
assert.equal(kopfPunkt(id), null, String(id))
assert.equal(koerperPunkt(id), null, String(id))
}
})
// ---------- Zuordnung je Ereignisart ----------
// Ein Ereignis je Art (alle 26 aus EVENT_KINDS) mit den erwarteten Effekten, unabhängig vom Code unter Test aus Übersicht §7 abgeleitet
const TABELLE = {
auftrag: { w: 'lea', extra: { to: 'max' }, soll: ['blase:lea:Max, los!', 'blase:max:Verstanden'] },
leerlauf: { w: 'lea', soll: [] },
gelesen: { w: 'lea', extra: { meta: { count: 3 } }, soll: ['blase:lea:3 gelesen'] },
fund: { w: 'lea', soll: ['blase:lea:Neuer Fund!', 'icon:akte'] },
doppelt: { w: 'lea', soll: ['blase:lea:Schon bekannt', 'icon:stopp'] },
uebergabe: { w: 'lea', extra: { to: 'bea' }, soll: ['flug:umschlag'] },
bewertet: { w: 'bea', extra: { meta: { score: 82 } }, soll: ['blase:bea:82 Punkte'] },
top3: { w: 'bea', soll: ['blase:bea:Top 3!', 'icon:haken'] },
botlead: { w: 'arne', soll: ['blase:arne:Abgelegt', 'icon:akte'] },
entwurf: { w: 'clara', soll: ['blase:clara:Entwurf fertig'] },
pult: { w: 'clara', soll: ['blase:clara:Zur Freigabe', 'flug:umschlag'] },
gesendet: { w: 'max', soll: ['blase:max:Raus damit!', 'flug:umschlag'] },
eingang: { w: 'nora', soll: ['blase:nora:Antwort!', 'flug:umschlag'] },
sortiert: { w: 'nora', extra: { meta: { classification: 'ja' } }, soll: ['blase:nora:Ja!', 'icon:haken'] },
bau_start: { w: 'bob', soll: ['blase:bob:Los geht’s'] },
bau_fertig: { w: 'bob', soll: ['blase:bob:Fertig!', 'flug:umschlag'] },
pruefung_zurueck: { w: 'paul', extra: { to: 'bob', meta: { round: 2 } }, soll: ['blase:paul:Runde 2 zurück', 'flug:umschlag', 'icon:stopp'] },
pruefung_ok: { w: 'paul', soll: ['blase:paul:Passt!', 'icon:haken'] },
vorschau_link: { w: 'bob', soll: ['blase:bob:Link raus', 'flug:umschlag'] },
angebot: { w: 'theo', soll: ['blase:theo:Kauf-Link!', 'icon:akte'] },
deal: { w: 'otto', soll: ['blase:otto:Deal! Super!', 'flug:muenze', 'flug:muenze', 'flug:muenze', 'jubel:stern'] },
gesperrt: { w: 'system', soll: ['blase:arne:Gesperrt', 'icon:stopp'] },
budget_warnung: { w: 'otto', soll: ['blase:otto:Budget 80 %', 'icon:alarm'] },
budget_stopp: { w: 'otto', soll: ['blase:otto:Budget leer', 'icon:alarm'] },
pause: { w: 'system', soll: [] },
fehler: { w: 'lea', soll: ['blase:lea:Fehler', 'icon:alarm'] },
}
test('Zuordnung: die Tabelle deckt genau die 26 Ereignisarten ab', () => {
assert.equal(EVENT_KINDS.length, 26)
assert.deepEqual(Object.keys(TABELLE).sort(), [...EVENT_KINDS].sort())
})
for (const kind of EVENT_KINDS) {
test(`Zuordnung: ${kind} läuft ohne Fehler und ergibt genau die erwarteten Effekte (wohlgeformt, Münzen mit Versatz)`, () => {
const { w, extra, soll } = TABELLE[kind]
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt, max: 50, rueckstau: 50 })
r.push([ev(kind, w, extra)])
const effekte = sammeln(r, uhr)
effekte.forEach(pruefeForm)
assert.deepEqual(effekte.map(kurz).sort(), [...soll].sort())
assert.equal(r.wartende(), 0)
uhr.vor(10_000)
assert.deepEqual(r.tick(), [])
assert.equal(r.naechsteIn(), null)
})
}
test('task und pose erzeugen nichts: leerlauf und pause bleiben leer, auch direkt nach dem Push', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt })
r.push([ev('leerlauf', 'lea'), ev('pause', 'system')])
assert.deepEqual(r.tick(), [])
assert.equal(r.naechsteIn(), null)
})
test('say: Sprechblase 2,5 s an der Spitze über dem Kopf des Workers, Text so lang wie im Schritt (höchstens 20 Zeichen)', () => {
const uhr = uhrVon(42_000)
const r = createRegie({ jetzt: uhr.jetzt })
r.push([ev('gelesen', 'lea', { meta: { count: 3 } })])
const [b, ...rest] = r.tick()
assert.equal(rest.length, 0)
assert.equal(b.art, 'blase'); assert.equal(b.wer, 'lea'); assert.equal(b.text, '3 gelesen')
assert.deepEqual([b.x, b.y], [HUSTLER.lea.a, HUSTLER.lea.f - 36])
assert.equal(b.start, 42_000); assert.equal(b.dauer, 2500)
assert.ok(b.text.length <= 20)
// Overseer: Blase höher als bei den anderen
r.push([ev('budget_stopp', 'otto')])
const otto = r.tick().find((e) => e.art === 'blase' && e.wer === 'otto')
assert.deepEqual([otto.x, otto.y], [HUSTLER.otto.a, HUSTLER.otto.f - 44])
})
test('fund: Blase „Neuer Fund!“ am Kopf plus Symbol Akte neben der Figur (1,2 s)', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt })
r.push([ev('fund', 'ali')])
const effekte = r.tick()
assert.equal(effekte.length, 2)
const blase = effekte.find((e) => e.art === 'blase')
const icon = effekte.find((e) => e.art === 'icon')
assert.equal(blase.text, 'Neuer Fund!'); assert.deepEqual([blase.x, blase.y], [HUSTLER.ali.a, HUSTLER.ali.f - 36])
assert.equal(icon.icon, 'akte'); assert.equal(icon.dauer, 1200)
// das Symbol sitzt neben der Figur (rechts), nicht auf der Blase
assert.ok(icon.x > HUSTLER.ali.a && icon.x < HUSTLER.ali.a + 40)
assert.ok(icon.y > blase.y && icon.y < HUSTLER.ali.f)
})
test('fx am Ort: Symbole paper/drawer -> akte, stampOk -> haken, stampNo -> stopp, alert -> alarm (ICON_NACH_FX)', () => {
assert.deepEqual({ ...ICON_NACH_FX }, { paper: 'akte', stampOk: 'haken', stampNo: 'stopp', drawer: 'akte', alert: 'alarm' })
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt, max: 50, rueckstau: 50 })
r.push([ev('fund', 'lea'), ev('top3', 'bea'), ev('doppelt', 'sven'), ev('botlead', 'arne'), ev('fehler', 'kim')])
const icons = r.tick().filter((e) => e.art === 'icon')
assert.deepEqual(icons.map((e) => e.icon), ['akte', 'haken', 'stopp', 'akte', 'alarm'])
})
test('gesendet: Umschlag fliegt 900 ms von Max nach „aussen“; von system (Mensch am Pult) vom Briefkasten nach „aussen“', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt })
r.push([ev('gesendet', 'max')])
const flug = r.tick().find((e) => e.art === 'flug')
assert.equal(flug.icon, 'umschlag'); assert.equal(flug.dauer, 900)
assert.deepEqual([flug.x, flug.y], [HUSTLER.max.a, HUSTLER.max.f - 16])
assert.deepEqual([flug.x2, flug.y2], [PLAETZE.aussen.x, PLAETZE.aussen.y])
r.flush()
r.push([ev('gesendet', 'system')])
const alle = r.tick()
assert.deepEqual(alle.map(kurz), ['flug:umschlag'])
assert.deepEqual([alle[0].x, alle[0].y, alle[0].x2, alle[0].y2], [PLAETZE.pult.x, PLAETZE.pult.y, PLAETZE.aussen.x, PLAETZE.aussen.y])
})
test('give und fx mit Ziel: Flüge zwischen Figuren, zum Briefkasten und von draußen zum Worker; gleicher Start und Ziel fliegt nicht', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt, max: 50, rueckstau: 50 })
r.push([ev('uebergabe', 'lea', { to: 'bea' }), ev('pult', 'clara'), ev('eingang', 'nora'), ev('uebergabe', 'lea', { to: 'lea' })])
const fluege = r.tick().filter((e) => e.art === 'flug')
assert.equal(fluege.length, 3)
const koerper = (id) => [HUSTLER[id].a, HUSTLER[id].f - 16]
assert.deepEqual([fluege[0].x, fluege[0].y, fluege[0].x2, fluege[0].y2], [...koerper('lea'), ...koerper('bea')])
assert.deepEqual([fluege[1].x, fluege[1].y, fluege[1].x2, fluege[1].y2], [...koerper('clara'), PLAETZE.pult.x, PLAETZE.pult.y])
assert.deepEqual([fluege[2].x, fluege[2].y, fluege[2].x2, fluege[2].y2], [PLAETZE.aussen.x, PLAETZE.aussen.y, ...koerper('nora')])
})
test('deal: drei Münzflüge von „aussen“ zum Tresor mit 0,4 s Versatz, Jubel-Stern und Blase beim Overseer', () => {
const uhr = uhrVon(10_000)
const r = createRegie({ jetzt: uhr.jetzt, max: 8 })
r.push([ev('deal', 'otto')])
// sofort: erste Münze, Jubel, Blase; die anderen Münzen sind noch nicht dran
assert.deepEqual(r.tick().map(kurz).sort(), ['blase:otto:Deal! Super!', 'flug:muenze', 'jubel:stern'])
assert.equal(r.wartende(), 2)
assert.equal(r.naechsteIn(), 400)
uhr.vor(399)
assert.equal(r.tick().filter((e) => e.icon === 'muenze').length, 1)
uhr.vor(1)
assert.equal(r.tick().filter((e) => e.icon === 'muenze').length, 2)
uhr.vor(400)
const muenzen = r.tick().filter((e) => e.icon === 'muenze')
assert.equal(muenzen.length, 3)
assert.deepEqual(muenzen.map((m) => m.start), [10_000, 10_400, 10_800], 'Versatz 400 ms')
for (const m of muenzen) {
assert.deepEqual([m.x, m.y, m.x2, m.y2], [PLAETZE.aussen.x, PLAETZE.aussen.y, PLAETZE.tresor.x, PLAETZE.tresor.y])
assert.equal(m.dauer, 900)
}
const jubel = r.tick().find((e) => e.art === 'jubel')
assert.equal(jubel.wer, 'otto'); assert.equal(jubel.icon, 'stern'); assert.equal(jubel.dauer, 1200)
assert.ok(Math.abs(jubel.x - HUSTLER.otto.a) <= 40 && jubel.y < HUSTLER.otto.f && jubel.y > HUSTLER.otto.f - 40, 'Stern neben dem Overseer')
})
test('deal mit der Höchstzahl 4: die dritte Münze wartet, bis die erste (900 ms) im Ziel ist', () => {
const uhr = uhrVon(10_000)
const r = createRegie({ jetzt: uhr.jetzt })
r.push([ev('deal', 'otto')])
r.tick()
uhr.vor(800)
assert.equal(r.tick().filter((e) => e.icon === 'muenze').length, 2)
assert.equal(r.tick().length, 4)
uhr.vor(100)
const muenzen = r.tick().filter((e) => e.icon === 'muenze')
assert.equal(muenzen.length, 2) // erste weg, dritte da
assert.equal(muenzen[1].start, 10_900)
})
// ---------- Höchstzahl, Rückstau, Zeitablauf ----------
test('Höchstzahl: nie mehr als max zugleich sichtbar (Standard 4), wartende rücken nach, sobald einer abläuft', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt })
r.push(IDS.slice(0, 6).map((id) => ev('fund', id))) // 12 Entwürfe
let gesehen = 0
for (let ms = 0; ms <= 20_000; ms += 50) {
const jetzt = r.tick()
assert.ok(jetzt.length <= 4, `${jetzt.length} bei ${ms} ms`)
gesehen = Math.max(gesehen, jetzt.length)
uhr.vor(50)
}
assert.equal(gesehen, 4)
assert.deepEqual(r.tick(), [])
assert.equal(r.wartende(), 0)
// eine andere Höchstzahl
const r2 = createRegie({ jetzt: uhr.jetzt, max: 2 })
r2.push(IDS.slice(0, 5).map((id) => ev('fund', id)))
assert.equal(r2.tick().length, 2)
// erster Ablauf: Symbole nach 1,2 s, dann rücken die nächsten nach (Start = Zeitpunkt des Ablaufs)
const anfang = uhr.stand()
uhr.vor(1200)
const danach = r2.tick()
assert.equal(danach.length, 2)
assert.ok(danach.some((e) => e.start === anfang + 1200))
})
test('Reihenfolge der Wartenden: wer zuerst kam, startet zuerst (max 1)', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt, max: 1 })
r.push(['kim', 'pia', 'finn'].map((id, i) => ev('gelesen', id, { meta: { count: i + 1 } })))
const texte = []
for (let i = 0; i < 3; i += 1) {
texte.push(...r.tick().map((e) => e.text))
uhr.vor(2500)
}
assert.deepEqual(texte, ['1 gelesen', '2 gelesen', '3 gelesen'])
})
test('Rückstau: mehr als 12 Wartende -> die ältesten fallen weg, die neuesten bleiben (19 Blasen: 1-4 laufen, 5-7 fallen weg, 8-19 warten)', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt })
r.push(IDS.map((id, i) => ev('gelesen', id, { meta: { count: i + 1 } })))
assert.equal(r.tick().length, 4)
assert.equal(r.wartende(), 12)
const effekte = sammeln(r, uhr, 20_000, 50)
const zahlen = effekte.map((e) => Number.parseInt(e.text, 10))
assert.deepEqual(zahlen, [1, 2, 3, 4, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
assert.equal(r.wartende(), 0)
})
test('Rückstau einstellbar: max 1, rueckstau 3, acht Blasen -> 1 läuft, 2-5 fallen weg, danach 6, 7, 8', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt, max: 1, rueckstau: 3 })
r.push(IDS.slice(0, 8).map((id, i) => ev('gelesen', id, { meta: { count: i + 1 } })))
assert.equal(r.wartende(), 3)
const texte = []
for (let i = 0; i < 6; i += 1) {
texte.push(...r.tick().map((e) => e.text))
uhr.vor(2500)
}
assert.deepEqual(texte, ['1 gelesen', '6 gelesen', '7 gelesen', '8 gelesen'])
})
test('Rückstau 0: was nicht sofort starten kann, fällt weg', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt, max: 1, rueckstau: 0 })
r.push(IDS.slice(0, 3).map((id, i) => ev('gelesen', id, { meta: { count: i + 1 } })))
assert.equal(r.wartende(), 0)
assert.deepEqual(r.tick().map((e) => e.text), ['1 gelesen'])
})
test('Zeitablauf mit eigener Uhr: Blase 2500 ms, Symbol 1200 ms, Flug 900 ms; naechsteIn zählt bis zum nächsten Ablauf', () => {
const uhr = uhrVon(5000)
const r = createRegie({ jetzt: uhr.jetzt })
r.push([ev('fund', 'lea'), ev('gesendet', 'max')])
assert.equal(r.tick().length, 4) // Blase + Akte (lea), Blase + Flug (max)
assert.equal(r.naechsteIn(), 900)
uhr.vor(899)
assert.equal(r.tick().length, 4)
assert.equal(r.naechsteIn(), 1)
uhr.vor(1)
assert.deepEqual(r.tick().map(kurz).sort(), ['blase:lea:Neuer Fund!', 'blase:max:Raus damit!', 'icon:akte'])
assert.equal(r.naechsteIn(), 300)
uhr.vor(300)
assert.deepEqual(r.tick().map(kurz).sort(), ['blase:lea:Neuer Fund!', 'blase:max:Raus damit!'])
uhr.vor(1299)
assert.equal(r.tick().length, 2)
uhr.vor(1)
assert.deepEqual(r.tick(), [])
assert.equal(r.naechsteIn(), null)
})
test('tick: neues Feld je Aufruf, dieselben (eingefrorenen) Effekte, solange sie laufen; start bleibt', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt })
r.push([ev('fund', 'lea')])
const a = r.tick()
uhr.vor(500)
const b = r.tick()
assert.notEqual(a, b)
assert.equal(a[0], b[0])
assert.equal(b[0].start, 1_000_000)
a.pop()
assert.equal(r.tick().length, 2, 'das Feld gehört dem Aufrufer')
assert.equal(new Set(r.tick().map((e) => e.id)).size, 2)
})
test('Standarduhr: ohne jetzt wird Date.now benutzt', () => {
const r = createRegie()
const vorher = Date.now()
r.push([ev('fund', 'lea')])
const [e] = r.tick()
assert.ok(e.start >= vorher && e.start <= Date.now())
})
// ---------- Eine Blase je Worker ----------
test('eine Blase je Worker: die neue ersetzt die laufende, auch wenn alle Plätze belegt sind', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt, max: 1 })
r.push([ev('gelesen', 'lea', { meta: { count: 1 } })])
assert.deepEqual(r.tick().map((e) => e.text), ['1 gelesen'])
uhr.vor(500)
r.push([ev('gelesen', 'lea', { meta: { count: 2 } })])
assert.deepEqual(r.tick().map((e) => e.text), ['2 gelesen'])
assert.equal(r.wartende(), 0)
assert.equal(r.tick()[0].start, uhr.stand())
// andere Worker haben eigene Blasen
const r2 = createRegie({ jetzt: uhr.jetzt })
r2.push([ev('gelesen', 'lea', { meta: { count: 1 } }), ev('gelesen', 'sven', { meta: { count: 2 } }), ev('gelesen', 'lea', { meta: { count: 3 } })])
assert.deepEqual(r2.tick().map((e) => `${e.wer}:${e.text}`).sort(), ['lea:3 gelesen', 'sven:2 gelesen'])
})
test('eine Blase je Worker: eine noch wartende Blase wird von der nächsten desselben Workers ersetzt', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt, max: 1 })
r.push([ev('gelesen', 'kim', { meta: { count: 1 } })]) // läuft
r.push([ev('gelesen', 'lea', { meta: { count: 2 } })]) // wartet
r.push([ev('gelesen', 'lea', { meta: { count: 3 } })]) // ersetzt die wartende
assert.equal(r.wartende(), 1)
const texte = []
for (let i = 0; i < 3; i += 1) {
texte.push(...r.tick().map((e) => e.text))
uhr.vor(2500)
}
assert.deepEqual(texte, ['1 gelesen', '3 gelesen'])
})
test('Blasen ersetzen nur Blasen: Symbole und Flüge desselben Workers laufen weiter', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt })
r.push([ev('fund', 'lea')])
uhr.vor(300)
r.push([ev('gelesen', 'lea', { meta: { count: 9 } })])
assert.deepEqual(r.tick().map(kurz).sort(), ['blase:lea:9 gelesen', 'icon:akte'])
})
// ---------- reduziert, flush ----------
test('reduziert: nur Blasen, alles andere (Symbole, Flüge, Jubel) fällt weg', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt, reduziert: true })
r.push([ev('fund', 'lea'), ev('gesendet', 'max'), ev('deal', 'otto'), ev('top3', 'bea')])
const effekte = sammeln(r, uhr)
assert.deepEqual(effekte.map(kurz).sort(), ['blase:bea:Top 3!', 'blase:lea:Neuer Fund!', 'blase:max:Raus damit!', 'blase:otto:Deal! Super!'])
assert.equal(r.wartende(), 0)
})
test('setReduziert: schaltet zur Laufzeit um, entfernt laufende und wartende Nicht-Blasen sofort; danach wieder alles', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt })
r.push([ev('deal', 'otto')])
assert.equal(r.tick().length, 3)
assert.equal(r.wartende(), 2)
r.setReduziert(true)
assert.deepEqual(r.tick().map(kurz), ['blase:otto:Deal! Super!'])
assert.equal(r.wartende(), 0)
assert.equal(r.naechsteIn(), 2500)
r.push([ev('fund', 'lea')])
assert.deepEqual(r.tick().map(kurz).sort(), ['blase:lea:Neuer Fund!', 'blase:otto:Deal! Super!'])
r.setReduziert(false)
r.push([ev('top3', 'bea')])
assert.ok(r.tick().some((e) => e.art === 'icon' && e.icon === 'haken'))
})
test('flush: leert Warteschlange und laufende Effekte; danach geht es normal weiter', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt, max: 2 })
r.push(IDS.slice(0, 6).map((id) => ev('fund', id)))
assert.equal(r.tick().length, 2)
assert.ok(r.wartende() > 0)
r.flush()
assert.deepEqual(r.tick(), [])
assert.equal(r.wartende(), 0)
assert.equal(r.naechsteIn(), null)
uhr.vor(60_000)
assert.deepEqual(r.tick(), [])
r.push([ev('top3', 'bea')])
assert.equal(r.tick().length, 2)
// Kennungen bleiben auch nach flush eindeutig
const ids = r.tick().map((e) => e.id)
r.flush()
r.push([ev('top3', 'bea')])
assert.ok(r.tick().every((e) => !ids.includes(e.id)))
})
test('naechsteIn: null im Leerlauf, Rest der Laufzeit, Beginn der nächsten verzögerten Münze', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt })
assert.equal(r.naechsteIn(), null)
r.push([ev('gelesen', 'lea')])
assert.equal(r.naechsteIn(), 2500)
uhr.vor(1000)
assert.equal(r.naechsteIn(), 1500)
uhr.vor(1500)
assert.equal(r.naechsteIn(), 0) // abgelaufen, aber noch nicht abgeholt
assert.deepEqual(r.tick(), [])
assert.equal(r.naechsteIn(), null)
})
// ---------- Unbekannte Worker, kaputte Eingaben ----------
test('unbekannte Worker und Plätze erzeugen nichts', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt })
r.push([
ev('fund', 'niemand'), ev('gelesen', ''), ev('fund', '__proto__'), ev('fund', 'constructor'), ev('fund', 'toString'),
ev('uebergabe', 'lea', { to: 'niemand' }), ev('uebergabe', 'niemand', { to: 'lea' }), ev('pult', 'niemand'), ev('eingang', 'niemand'),
ev('auftrag', 'otto', { to: 'niemand' }),
])
// auftrag an einen Unbekannten bleibt ein reiner task-Schritt: auch Ottos Blase „… los!“ entfällt
assert.deepEqual(r.tick().map(kurz), [])
assert.equal(r.wartende(), 0)
})
test('kaputte Eingaben: push mit Nicht-Listen, Nicht-Ereignissen, falschen Feldern und werfenden Getter wirft nie; gute Ereignisse laufen trotzdem', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt, max: 50, rueckstau: 50 })
for (const kaputt of [undefined, null, 5, 'text', {}, new Set([1]), () => {}]) assert.doesNotThrow(() => r.push(kaputt))
const werfend = { id: 'x', ts: '2026-10-17T20:00:00.000Z', workerId: 'lea', text: 'A', get kind() { throw new Error('kaputt') } }
assert.doesNotThrow(() => r.push([
null, undefined, 5, 'fund', [], {}, { kind: 'fund' }, { kind: 'fund', workerId: 5 }, { kind: 7, workerId: 'lea' },
{ kind: 'unbekannt', workerId: 'lea', text: 'T' }, { kind: 'gelesen', workerId: 'lea', meta: '{kaputt' }, { kind: 'gelesen', workerId: 'lea', meta: [] },
{ kind: 'deal', workerId: 'otto', meta: 5, text: {} }, werfend, ev('top3', 'bea'),
]))
const effekte = r.tick()
effekte.forEach(pruefeForm)
assert.ok(effekte.some((e) => e.art === 'blase' && e.wer === 'bea' && e.text === 'Top 3!'))
assert.ok(effekte.some((e) => e.art === 'blase' && e.wer === 'lea' && e.text === 'Gelesen'), 'kaputtes meta zählt wie leer')
assert.equal(effekte.filter((e) => e.art === 'blase' && e.wer === 'lea').length, 1)
})
test('Blasen mit leerem oder fremdem Text entfallen; Text wird nur gekürzt, nie als HTML gedeutet', () => {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt, max: 50 })
// eventToSteps setzt feste Texte; Ereignis-Texte landen nur in task und erscheinen nie in einer Blase
r.push([ev('fund', 'lea', { text: '<img src=x onerror=alert(1)>' })])
const texte = r.tick().filter((e) => e.art === 'blase').map((e) => e.text)
assert.deepEqual(texte, ['Neuer Fund!'])
})
test('Optionen: ungültige max/rueckstau/jetzt fallen auf die Standardwerte zurück; kaputte Uhr stürzt nicht', () => {
for (const max of [NaN, 0, -3, 'a', null, Infinity]) {
const uhr = uhrVon()
const r = createRegie({ jetzt: uhr.jetzt, max, rueckstau: 'x' })
r.push(IDS.slice(0, 6).map((id) => ev('fund', id)))
assert.equal(r.tick().length, 4, `max ${String(max)}`)
assert.equal(r.wartende(), 8)
}
const r2 = createRegie({ jetzt: () => NaN })
assert.doesNotThrow(() => { r2.push([ev('fund', 'lea')]); r2.tick(); r2.naechsteIn() })
const r3 = createRegie({})
assert.doesNotThrow(() => r3.push([ev('fund', 'lea')]))
})
test('Quelltext: rein (kein React, window, document, Zeitgeber); Importe nur workersEvents, hustlerWelt, workersRoster', () => {
const src = readFileSync(path.join(ROOT, 'src/lib/hustlerRegie.js'), 'utf8')
const code = src.replace(/\/\/.*$/gm, '')
assert.doesNotMatch(code, /\b(window|document|setTimeout|setInterval|localStorage)\b/)
assert.doesNotMatch(code, /from 'react'|from "react"/)
const importe = [...src.matchAll(/^import .* from '([^']+)'/gm)].map((m) => m[1])
for (const i of importe) assert.ok(['./workersEvents.js', './hustlerWelt.js', './workersRoster.js'].includes(i), i)
assert.doesNotMatch(src, /engine\/director/)
})
// ---------- RegieEbene (Mini-Läufer) ----------
// forwardRef und useImperativeHandle liefert der gemeinsame Mini-Läufer (helpers/fakeReact.mjs)
const FAKE_REACT_MIT_REF = FAKE_REACT
const fakeReactMitRef = {
name: 'fake-react-mit-ref',
setup(build) {
build.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: 'react', namespace: 'fake-react' }))
build.onLoad({ filter: /.*/, namespace: 'fake-react' }, () => ({ contents: FAKE_REACT_MIT_REF, loader: 'js' }))
},
}
let ebeneBundle
const ladeEbene = () => (ebeneBundle ??= esbuild.build({
stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as RegieEbene } from './src/components/workers/hochhaus/RegieEbene.jsx'", resolveDir: ROOT, loader: 'js' },
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReactMitRef],
}).then((out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)))
// Umgebung mit eigenem Zeitgeber, eigener Uhr (Date.now), matchMedia und document; ende() stellt alles zurück
function umgebung({ bewegung = false, verdeckt = false, ohneFenster = false } = {}) {
const alt = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout, setInterval: globalThis.setInterval, now: Date.now, window: globalThis.window, document: globalThis.document }
let t = 1_000_000
let nr = 0
const zeitgeber = new Map()
const intervalle = []
globalThis.setTimeout = (fn, ms) => { nr += 1; zeitgeber.set(nr, { fn, bei: t + (ms ?? 0) }); return nr }
globalThis.clearTimeout = (id) => { zeitgeber.delete(id) }
globalThis.setInterval = (...args) => { intervalle.push(args); return 0 }
Date.now = () => t
const media = {
matches: bewegung,
zuhoerer: new Set(),
addEventListener(art, fn) { if (art === 'change') this.zuhoerer.add(fn) },
removeEventListener(art, fn) { if (art === 'change') this.zuhoerer.delete(fn) },
aendern(matches) { this.matches = matches; for (const fn of [...this.zuhoerer]) fn({ matches }) },
}
const doc = {
hidden: verdeckt,
zuhoerer: new Set(),
addEventListener(art, fn) { if (art === 'visibilitychange') this.zuhoerer.add(fn) },
removeEventListener(art, fn) { if (art === 'visibilitychange') this.zuhoerer.delete(fn) },
sichtwechsel(hidden) { this.hidden = hidden; for (const fn of [...this.zuhoerer]) fn({}) },
}
const anfragen = []
if (ohneFenster) { delete globalThis.window; delete globalThis.document } else {
globalThis.window = { matchMedia: (q) => { anfragen.push(q); return media } }
globalThis.document = doc
}
return {
media, doc, anfragen, intervalle,
jetzt: () => t,
offene: () => zeitgeber.size,
// Zeit vorstellen und dabei die fälligen Zeitgeber in ihrer Reihenfolge auslösen (t steht dann auf dem Zeitpunkt des Zeitgebers)
vor(ms) {
const ziel = t + ms
for (;;) {
const faellig = [...zeitgeber].filter(([, z]) => z.bei <= ziel).sort((a, b) => a[1].bei - b[1].bei)[0]
if (!faellig) break
zeitgeber.delete(faellig[0])
t = Math.max(t, faellig[1].bei)
faellig[1].fn()
}
t = ziel
},
ende() {
globalThis.setTimeout = alt.setTimeout; globalThis.clearTimeout = alt.clearTimeout; globalThis.setInterval = alt.setInterval; Date.now = alt.now
if (alt.window === undefined) delete globalThis.window; else globalThis.window = alt.window
if (alt.document === undefined) delete globalThis.document; else globalThis.document = alt.document
},
}
}
const nodes = (node, passt, out = []) => {
if (node === null || typeof node === 'string') return out
if (passt(node)) out.push(node)
for (const kind of node.children) nodes(kind, passt, out)
return out
}
const textVon = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textVon).join(''))
const hatKlasse = (n, name) => String(n.props.className ?? '').split(' ').includes(name)
const nachKlasse = (baum, name) => nodes(baum, (n) => typeof n.tag === 'string' && hatKlasse(n, name))
// Ebene einhängen; liefert Baum, Handle und Abbau
async function ebene(umg) {
const { __mount, jsx, RegieEbene } = await ladeEbene()
const ref = { current: null }
const gemountet = __mount(jsx(RegieEbene, { ref }))
return { ref, tree: () => gemountet.tree(), abbau: () => gemountet.unmount(), spaet: () => gemountet.lateUpdates() }
}
test('RegieEbene: leere Ebene mit hk-regie, aria-hidden, Handle { push, flush }, kein Zeitgeber und kein Intervall im Ruhezustand', async () => {
const umg = umgebung()
try {
const v = await ebene(umg)
const wurzel = v.tree()
assert.equal(wurzel.tag, 'div'); assert.equal(wurzel.props.className, 'hk-regie'); assert.equal(wurzel.props['aria-hidden'], 'true')
assert.deepEqual(wurzel.children, [])
assert.deepEqual(Object.keys(v.ref.current).sort(), ['flush', 'push'])
assert.equal(umg.offene(), 0)
assert.equal(umg.intervalle.length, 0)
assert.deepEqual(umg.anfragen, ['(prefers-reduced-motion: reduce)'])
assert.equal(umg.media.zuhoerer.size, 1); assert.equal(umg.doc.zuhoerer.size, 1)
v.abbau()
} finally { umg.ende() }
})
test('RegieEbene: Blase in Weltkoordinaten (left/top am Kopf, --dauer), Text als Textknoten; nach Ablauf weg und kein Zeitgeber mehr', async () => {
const umg = umgebung()
try {
const v = await ebene(umg)
v.ref.current.push([ev('gelesen', 'lea', { meta: { count: 3 } })])
const blasen = nachKlasse(v.tree(), 'hk-regie-blase')
assert.equal(blasen.length, 1)
assert.deepEqual({ ...blasen[0].props.style }, { left: `${HUSTLER.lea.a}px`, top: `${HUSTLER.lea.f - 36}px`, '--dauer': '2500ms' })
const text = nachKlasse(blasen[0], 'hk-regie-blase-text')
assert.equal(text.length, 1)
assert.deepEqual(text[0].children, ['3 gelesen'])
assert.equal(umg.offene(), 1, 'genau ein Zeitgeber bis zum Ablauf')
umg.vor(2499)
assert.equal(nachKlasse(v.tree(), 'hk-regie-blase').length, 1)
umg.vor(1)
assert.deepEqual(v.tree().children, [])
assert.equal(umg.offene(), 0)
assert.equal(umg.intervalle.length, 0)
v.abbau()
} finally { umg.ende() }
})
test('RegieEbene: Symbol (Klasse hk-regie-icon + hk-regie-bild-akte) neben der Figur, Blase bleibt länger als das Symbol', async () => {
const umg = umgebung()
try {
const v = await ebene(umg)
v.ref.current.push([ev('fund', 'lea')])
const icons = nachKlasse(v.tree(), 'hk-regie-icon')
assert.equal(icons.length, 1)
assert.ok(hatKlasse(icons[0], 'hk-regie-bild-akte'))
assert.equal(icons[0].props.style.left, `${HUSTLER.lea.a + 22}px`)
assert.equal(icons[0].props.style.top, `${HUSTLER.lea.f - 26}px`)
assert.equal(icons[0].props.style['--dauer'], '1200ms')
assert.equal(nachKlasse(v.tree(), 'hk-regie-blase').length, 1)
umg.vor(1200)
assert.equal(nachKlasse(v.tree(), 'hk-regie-icon').length, 0)
assert.equal(nachKlasse(v.tree(), 'hk-regie-blase').length, 1)
umg.vor(1300)
assert.deepEqual(v.tree().children, [])
assert.equal(umg.offene(), 0)
v.abbau()
} finally { umg.ende() }
})
test('RegieEbene: Flug mit Start (left/top) und Weg zum Ziel (--dx/--dy); gesendet fliegt von Max nach „aussen“', async () => {
const umg = umgebung()
try {
const v = await ebene(umg)
v.ref.current.push([ev('gesendet', 'max')])
const fluege = nachKlasse(v.tree(), 'hk-regie-flug')
assert.equal(fluege.length, 1)
assert.ok(hatKlasse(fluege[0], 'hk-regie-bild-umschlag'))
const sx = HUSTLER.max.a
const sy = HUSTLER.max.f - 16
assert.deepEqual({ ...fluege[0].props.style }, { left: `${sx}px`, top: `${sy}px`, '--dauer': '900ms', '--dx': `${PLAETZE.aussen.x - sx}px`, '--dy': `${PLAETZE.aussen.y - sy}px` })
umg.vor(900)
assert.equal(nachKlasse(v.tree(), 'hk-regie-flug').length, 0)
v.abbau()
} finally { umg.ende() }
})
test('RegieEbene: deal zeigt Münze 1, Jubel und Blase, 400 ms später Münze 2, und Münze 3 sobald ein Platz frei ist (Höchstzahl 4)', async () => {
const umg = umgebung()
try {
const v = await ebene(umg)
v.ref.current.push([ev('deal', 'otto')])
const muenzen = () => nachKlasse(v.tree(), 'hk-regie-bild-muenze')
assert.equal(muenzen().length, 1)
assert.equal(nachKlasse(v.tree(), 'hk-regie-jubel').length, 1)
assert.ok(hatKlasse(nachKlasse(v.tree(), 'hk-regie-jubel')[0], 'hk-regie-bild-stern'))
assert.equal(nachKlasse(v.tree(), 'hk-regie-blase').length, 1)
umg.vor(400)
assert.equal(muenzen().length, 2)
umg.vor(400)
assert.equal(muenzen().length, 2, 'bei 800 ms sind vier Plätze belegt')
umg.vor(100)
assert.equal(muenzen().length, 2, 'erste Münze weg, dritte da')
umg.vor(400)
assert.equal(muenzen().length, 1)
umg.vor(2500)
assert.deepEqual(v.tree().children, [])
assert.equal(umg.offene(), 0)
v.abbau()
} finally { umg.ende() }
})
test('RegieEbene: Elemente behalten ihre key-Kennung, solange sie laufen (kein Neustart der CSS-Animation durch andere Effekte)', async () => {
const umg = umgebung()
try {
const v = await ebene(umg)
v.ref.current.push([ev('gelesen', 'lea')])
const erste = nachKlasse(v.tree(), 'hk-regie-blase')[0]
umg.vor(300)
v.ref.current.push([ev('gelesen', 'sven')])
const blasen = nachKlasse(v.tree(), 'hk-regie-blase')
assert.equal(blasen.length, 2)
assert.equal(blasen[0].props.style.left, erste.props.style.left)
v.abbau()
} finally { umg.ende() }
})
test('RegieEbene: „weniger Bewegung“ -> nur Blasen; Wechsel über den Änderungs-Listener wirkt sofort (laufende Flüge verschwinden)', async () => {
const umg = umgebung({ bewegung: true })
try {
const v = await ebene(umg)
v.ref.current.push([ev('deal', 'otto'), ev('fund', 'lea')])
assert.equal(nachKlasse(v.tree(), 'hk-regie-blase').length, 2)
for (const k of ['hk-regie-flug', 'hk-regie-icon', 'hk-regie-jubel']) assert.equal(nachKlasse(v.tree(), k).length, 0, k)
umg.media.aendern(false)
v.ref.current.push([ev('deal', 'otto')])
assert.equal(nachKlasse(v.tree(), 'hk-regie-flug').length, 1)
assert.equal(nachKlasse(v.tree(), 'hk-regie-jubel').length, 1)
umg.media.aendern(true)
assert.equal(nachKlasse(v.tree(), 'hk-regie-flug').length, 0)
assert.equal(nachKlasse(v.tree(), 'hk-regie-jubel').length, 0)
assert.ok(nachKlasse(v.tree(), 'hk-regie-blase').length >= 1)
umg.vor(5000)
assert.deepEqual(v.tree().children, [])
assert.equal(umg.offene(), 0)
v.abbau()
} finally { umg.ende() }
})
test('RegieEbene: verdeckter Tab leert die Ebene und stoppt den Zeitgeber; solange er verdeckt ist, kommt nichts an; danach geht es weiter', async () => {
const umg = umgebung()
try {
const v = await ebene(umg)
v.ref.current.push([ev('deal', 'otto'), ev('fund', 'lea')])
assert.ok(v.tree().children.length > 0)
assert.ok(umg.offene() > 0)
umg.doc.sichtwechsel(true)
assert.deepEqual(v.tree().children, [])
assert.equal(umg.offene(), 0)
v.ref.current.push([ev('fund', 'bea')])
assert.deepEqual(v.tree().children, [], 'verdeckt: nichts sammelt sich an')
assert.equal(umg.offene(), 0)
umg.doc.sichtwechsel(false)
assert.deepEqual(v.tree().children, [])
v.ref.current.push([ev('fund', 'bea')])
assert.equal(nachKlasse(v.tree(), 'hk-regie-blase').length, 1)
v.abbau()
} finally { umg.ende() }
})
test('RegieEbene: flush über das Handle leert alles und räumt den Zeitgeber auf', async () => {
const umg = umgebung()
try {
const v = await ebene(umg)
v.ref.current.push([ev('deal', 'otto'), ev('fund', 'lea')])
assert.ok(umg.offene() > 0)
v.ref.current.flush()
assert.deepEqual(v.tree().children, [])
assert.equal(umg.offene(), 0)
v.ref.current.push([ev('fund', 'lea')])
assert.equal(nachKlasse(v.tree(), 'hk-regie-blase').length, 1)
v.abbau()
} finally { umg.ende() }
})
test('RegieEbene: Abbau räumt Zeitgeber und Beobachter auf, nimmt das Handle zurück; späte Aufrufe und Zeitgeber ändern nichts', async () => {
const umg = umgebung()
try {
const v = await ebene(umg)
const handle = v.ref.current
handle.push([ev('deal', 'otto'), ev('fund', 'lea')])
assert.ok(umg.offene() > 0)
v.abbau()
assert.equal(umg.offene(), 0)
assert.equal(umg.media.zuhoerer.size, 0)
assert.equal(umg.doc.zuhoerer.size, 0)
assert.equal(v.ref.current, null)
handle.push([ev('fund', 'lea')])
handle.flush()
assert.equal(umg.offene(), 0, 'nach dem Abbau startet kein neuer Zeitgeber')
assert.equal(v.spaet(), 0, 'keine Zustandsänderung nach dem Abbau')
assert.equal(umg.intervalle.length, 0)
} finally { umg.ende() }
})
test('RegieEbene: ohne window und document (z. B. Vorschau) läuft sie trotzdem', async () => {
const umg = umgebung({ ohneFenster: true })
try {
const v = await ebene(umg)
v.ref.current.push([ev('fund', 'lea')])
assert.equal(nachKlasse(v.tree(), 'hk-regie-icon').length, 1)
umg.vor(5000)
assert.deepEqual(v.tree().children, [])
v.abbau()
assert.equal(umg.offene(), 0)
} finally { umg.ende() }
})
test('RegieEbene: kaputte Eingaben über das Handle werfen nicht', async () => {
const umg = umgebung()
try {
const v = await ebene(umg)
for (const kaputt of [undefined, null, 'x', {}, [null, 5, { kind: 'fund' }]]) assert.doesNotThrow(() => v.ref.current.push(kaputt))
assert.deepEqual(v.tree().children, [])
assert.equal(umg.offene(), 0)
v.abbau()
} finally { umg.ende() }
})
test('RegieEbene: Quelltext ohne dangerouslySetInnerHTML/innerHTML und ohne Dauer-Intervall, Klassen alle mit hk-regie', () => {
const src = readFileSync(path.join(ROOT, 'src/components/workers/hochhaus/RegieEbene.jsx'), 'utf8')
assert.doesNotMatch(src, /dangerouslySetInnerHTML|innerHTML|setInterval/)
assert.doesNotMatch(src, /^import .*\.(css|png)'/m)
for (const m of src.matchAll(/className=(?:"([^"]+)"|\{`([^`]+)`\})/g)) {
for (const klasse of (m[1] ?? m[2]).split(/\s+/)) assert.match(klasse, /^hk-regie/, klasse)
}
assert.doesNotMatch(src, /engine\/director/)
})
// ---------- hustler-regie.css ----------
const cssText = readFileSync(path.join(ROOT, 'src/styles/hustler/hustler-regie.css'), 'utf8')
const cssOhneKommentare = cssText.replace(/\/\*[\s\S]*?\*\//g, ' ')
// -> { regeln: [{ selektor, koerper, kontext }], keyframes: [name] }
function zerlege(text) {
const regeln = []
const keyframes = []
let i = 0
function liste(kontext) {
for (;;) {
while (i < text.length && /\s/.test(text[i])) i += 1
if (i >= text.length) return
if (text[i] === '}') { i += 1; return }
const start = i
while (i < text.length && text[i] !== '{' && text[i] !== '}') i += 1
const kopf = text.slice(start, i).trim()
assert.equal(text[i], '{', `Regel ohne Block: ${kopf.slice(0, 60)}`)
i += 1
if (kopf.startsWith('@keyframes')) {
keyframes.push(kopf.slice('@keyframes'.length).trim())
let tiefe = 1
while (i < text.length && tiefe > 0) { if (text[i] === '{') tiefe += 1; if (text[i] === '}') tiefe -= 1; i += 1 }
} else if (kopf.startsWith('@media')) {
liste(kopf)
} else {
const ks = i
while (i < text.length && text[i] !== '}') i += 1
regeln.push({ selektor: kopf, koerper: text.slice(ks, i), kontext })
i += 1
}
}
}
liste('')
return { regeln, keyframes }
}
const css = zerlege(cssOhneKommentare)
test('CSS: Kopfkommentar nennt Aufgabe und Namensräume; mehr als zehn Regeln gelesen', () => {
assert.match(cssText.split('\n')[0], /^\/\* Regie der Hochhaus-Welt/)
assert.ok(css.regeln.length > 10, `nur ${css.regeln.length} Regeln`)
})
test('CSS: jede Bilddatei aus url(...) existiert unter src/assets/hustler (relativer Pfad, PNG), und die gewünschten Icons sind alle da', () => {
const urls = [...cssOhneKommentare.matchAll(/url\(\s*([^)]*?)\s*\)/g)].map((m) => m[1].replace(/^["']|["']$/g, ''))
assert.equal(urls.length, 7)
const assets = path.join(ROOT, 'src/assets/hustler')
for (const u of urls) {
assert.match(u, /^\.\.\/\.\.\/assets\/hustler\/[\w./-]+\.png$/, u)
const ziel = path.resolve(path.join(ROOT, 'src/styles/hustler'), u)
assert.ok(ziel.startsWith(assets + path.sep), `${u} liegt außerhalb`)
assert.ok(existsSync(ziel) && statSync(ziel).isFile(), `${u} fehlt`)
}
assert.deepEqual([...urls].map((u) => u.replace('../../assets/hustler/', '')).sort(), [
'keller/ui/akte-icon.png', 'keller/ui/haken.png', 'keller/ui/stopp.png', 'pult/ui/art-beschwerde.png', 'pult/ui/art-mail.png', 'ui/muenze-gruen.png', 'ui/stern.png',
])
})
test('CSS: jede Klasse in einem Selektor heißt hk-regie…, jede Regel steht unter .hk-hochhaus (auch im Media-Block)', () => {
for (const r of css.regeln) {
for (const teil of r.selektor.split(',').map((s) => s.trim())) {
assert.ok(teil.startsWith('.hk-hochhaus '), `nicht unter .hk-hochhaus: ${teil}`)
const klassen = [...teil.matchAll(/\.(-?[A-Za-z_][\w-]*)/g)].map((m) => m[1]).slice(1)
assert.ok(klassen.length >= 1, `keine Klasse: ${teil}`)
for (const k of klassen) assert.match(k, /^hk-regie(-|$)/, `${teil}: ${k}`)
}
}
for (const name of css.keyframes) assert.match(name, /^hk-regie-/, name)
})
test('CSS: alle von der Ebene gesetzten Klassen sind definiert, auch jedes Bild je Symbolname (Regie-Namen)', () => {
const definiert = new Set(css.regeln.flatMap((r) => [...r.selektor.matchAll(/\.(hk-regie[\w-]*)/g)].map((m) => m[1])))
const src = readFileSync(path.join(ROOT, 'src/components/workers/hochhaus/RegieEbene.jsx'), 'utf8')
for (const m of src.matchAll(/hk-regie[\w-]*/g)) {
if (m[0] === 'hk-regie-bild-') continue // dynamisch: je Symbolname
assert.ok(definiert.has(m[0]), `${m[0]} fehlt in hustler-regie.css`)
}
const symbole = new Set([...Object.values(ICON_NACH_FX), 'umschlag', 'muenze', 'stern'])
assert.deepEqual([...symbole].sort(), ['akte', 'alarm', 'haken', 'muenze', 'stern', 'stopp', 'umschlag'])
for (const s of symbole) assert.ok(definiert.has(`hk-regie-bild-${s}`), `hk-regie-bild-${s}`)
})
test('CSS: Keyframes sind definiert und werden benutzt; Dauer kommt aus --dauer', () => {
const benutzt = new Set([...cssOhneKommentare.matchAll(/animation:\s*(hk-regie-[\w-]+)/g)].map((m) => m[1]))
assert.deepEqual([...benutzt].sort(), [...css.keyframes].sort())
for (const name of benutzt) assert.ok(css.keyframes.includes(name), name)
for (const m of cssOhneKommentare.matchAll(/animation:\s*hk-regie-[\w-]+\s+var\(--dauer, (\d+)ms\)/g)) assert.ok(Number(m[1]) > 0)
// Standarddauer in der CSS = Dauer der Regie
assert.match(cssOhneKommentare, new RegExp(`hk-regie-blase var\\(--dauer, ${DAUER.blase}ms\\)`))
assert.match(cssOhneKommentare, new RegExp(`hk-regie-pop var\\(--dauer, ${DAUER.icon}ms\\)`))
assert.match(cssOhneKommentare, new RegExp(`hk-regie-flug var\\(--dauer, ${DAUER.flug}ms\\)`))
assert.match(cssOhneKommentare, new RegExp(`hk-regie-jubel var\\(--dauer, ${DAUER.jubel}ms\\)`))
})
test('CSS: Ebene nimmt keine Klicks an, Pixel-Darstellung, Symbole 16 px (Sprite-Blätter: nur das erste Bild), „weniger Bewegung“ ohne Animation', () => {
const regel = (teil) => css.regeln.filter((r) => r.selektor.split(',').map((s) => s.trim()).includes(teil))
const ebeneRegeln = regel('.hk-hochhaus .hk-regie')
assert.equal(ebeneRegeln.length, 1)
assert.match(ebeneRegeln[0].koerper, /pointer-events:\s*none/)
assert.match(ebeneRegeln[0].koerper, /image-rendering:\s*pixelated/)
assert.ok(regel('.hk-hochhaus .hk-regie *').some((r) => /pointer-events:\s*none/.test(r.koerper)))
const bild = regel('.hk-hochhaus .hk-regie-icon')[0]
assert.match(bild.koerper, /width:\s*16px/); assert.match(bild.koerper, /height:\s*16px/)
assert.match(bild.koerper, /image-rendering:\s*pixelated/)
assert.match(bild.koerper, /background-position:\s*0 0/) // erstes Bild
assert.match(bild.koerper, /background-size:\s*auto 16px/) // Sprite-Blatt in Originalgröße, nur Zelle 1 sichtbar
// Blase: Pixelify Sans, dunkler Rand, Dreieck
const text = regel('.hk-hochhaus .hk-regie-blase-text')[0].koerper
assert.match(text, /Pixelify Sans/); assert.match(text, /box-shadow/)
assert.ok(regel('.hk-hochhaus .hk-regie-blase-text::after').length === 1)
// weniger Bewegung
const media = css.regeln.filter((r) => r.kontext.includes('prefers-reduced-motion: reduce'))
assert.ok(media.length >= 1)
const ohne = media.flatMap((r) => r.selektor.split(',').map((s) => s.trim()))
for (const k of ['blase-text', 'icon', 'flug', 'jubel']) assert.ok(ohne.includes(`.hk-hochhaus .hk-regie-${k}`), k)
for (const r of media) assert.match(r.koerper, /animation:\s*none/)
})
test('CSS: Symbole auf die Größen der Welt begrenzt (12–16 px), keine @import/@font-face/googleapis', () => {
for (const r of css.regeln) {
for (const m of r.koerper.matchAll(/(?<![\w-])(?:width|height):\s*(\d+)px/g)) assert.ok(Number(m[1]) <= 16, `${r.selektor}: ${m[0]}`)
}
assert.doesNotMatch(cssOhneKommentare, /@import|@font-face|googleapis|\/_blob\//)
})