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: '' })]) 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(/(?