import test from 'node:test' import assert from 'node:assert/strict' import { createEngineSandbox } from './helpers/engineVm.mjs' import { makeState, ev, IDS } from './helpers/workersFixtures.mjs' // Szene: mountTower (Größe, 30-fps-Schleife, Eingabe, Kamera, Lebenszyklus) in einer vm-Sandbox ohne window/document const sel = [] const open = async (o = {}, extra = {}) => { const sb = await createEngineSandbox(o) const h = sb.mountTower(sb.canvas, sb.opts({ onSelect: (id) => sel.push(id), ...extra })) return { sb, h } } test('Handle hat die Methoden aus Übersicht §7 und E4; Mount zeichnet sofort', async () => { const { sb, h } = await open() for (const m of ['destroy', 'setState', 'pushEvents', 'select', 'setPaused', 'resize', 'zoomBy', 'resetView', 'locate', 'stats', 'debug']) { assert.equal(typeof h[m], 'function', m) } assert.ok(sb.counts.fillRect > 100) h.destroy() }) test('Größe: Breite·dpr (max. 2400), Höhe 4/5 davon', async () => { const a = await open({ cssWidth: 960, dpr: 1 }) assert.deepEqual([a.sb.canvas.width, a.sb.canvas.height], [960, 768]) a.h.destroy() const b = await open({ cssWidth: 1500, dpr: 2 }) assert.deepEqual([b.sb.canvas.width, b.sb.canvas.height], [2400, 1920]) b.h.destroy() }) test('Takt: höchstens 30 Bilder je Sekunde', async () => { const { sb, h } = await open() h.setState(makeState()) const f0 = h.stats().frames sb.tick(1) const f = h.stats().frames - f0 assert.ok(f >= 28 && f <= 32, String(f)) h.destroy() }) test('Lebenszyklus: destroy doppelt, nichts danach, Listener weg; StrictMode mount → destroy → mount', async () => { const sb = await createEngineSandbox() const a = sb.mountTower(sb.canvas, sb.opts({})) a.destroy() a.destroy() const n = sb.counts.fillRect sb.tick(1) assert.equal(sb.counts.fillRect, n) assert.equal(Object.values(sb.listeners).flat().length, 0) const b = sb.mountTower(sb.canvas, sb.opts({})) sb.tick(0.5) assert.ok(sb.counts.fillRect > n) a.setState(makeState()) a.pushEvents([ev('fund', 'kim')]) b.destroy() assert.equal(Object.values(sb.listeners).flat().length, 0) }) test('fontsReady: Ablehnung oder Zerstörung davor lässt nichts mehr zeichnen', async () => { let resolve const p = new Promise((r) => { resolve = r }) const { sb, h } = await open({}, { fontsReady: p }) h.destroy() const n = sb.counts.fillRect resolve() await new Promise((r) => setImmediate(r)) assert.equal(sb.counts.fillRect, n) // das abgelehnte Versprechen erst nach dem Sandbox-Aufbau erzeugen, sonst „unhandled rejection“ const sb2 = await createEngineSandbox() sb2.mountTower(sb2.canvas, sb2.opts({ fontsReady: Promise.reject(new Error('x')) })).destroy() }) test('reducedMotion: keine Schleife, jede Änderung zeichnet ein Standbild und beruhigt die Regie', async () => { const { sb, h } = await open({}, { reducedMotion: true }) assert.equal(sb.rafPending(), 0) const n = sb.counts.fillRect h.setState(makeState()) h.pushEvents(Array.from({ length: 30 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 4) % 19] }))) assert.ok(sb.counts.fillRect > n) assert.equal(h.debug().pending, 0) assert.equal(sb.rafPending(), 0) h.destroy() }) test('setPaused hält die Schleife an und setzt sie fort', async () => { const { sb, h } = await open() h.setPaused(true) sb.tick(0.2) const n = sb.counts.fillRect sb.tick(1) assert.equal(sb.counts.fillRect, n) h.setPaused(false) sb.tick(0.5) assert.ok(sb.counts.fillRect > n) h.destroy() }) test('Klick trifft über die Umkehr-Transformation, auch gezoomt und verschoben', async () => { const { sb, h } = await open() h.setState(makeState()) sel.length = 0 for (const id of ['otto', 'ben', 'arne']) { const p = h.locate(id) sb.click(p.x, p.y) assert.equal(sel.at(-1), id) } h.zoomBy(2.5) h.select('ben') const p = h.locate('ben') assert.ok(p) sb.click(p.x, p.y) assert.equal(sel.at(-1), 'ben') assert.equal(h.stats().zoom, 2.5) h.resetView() assert.equal(h.stats().zoom, 1) sel.length = 0 h.select('kim') assert.equal(sel.length, 0) h.destroy() }) test('Ziehen verschiebt bei Zoom > 1 und unterdrückt den Klick', async () => { const { sb, h } = await open() h.setState(makeState()) h.zoomBy(2) h.select('bea') const before = h.locate('bea') sel.length = 0 sb.pointer('pointerdown', 400, 300) sb.pointer('pointermove', 460, 300) sb.pointer('pointerup', 460, 300) sb.click(460, 300) assert.equal(sel.length, 0) const after = h.locate('bea') // 60 CSS-Pixel nach rechts gezogen assert.ok(Math.abs(after.x - (before.x + 60)) < 2) assert.ok(Math.abs(after.y - before.y) < 1e-6) h.destroy() }) // ---------- Größe und Startansicht ---------- test('resize() folgt der Bühne; unter 10 CSS-Pixeln wird nicht gemessen', async () => { const { sb, h } = await open({ cssWidth: 960 }) sb.setCssWidth(500) h.resize() assert.deepEqual([sb.canvas.width, sb.canvas.height], [500, 400]) sb.setCssWidth(5) h.resize() assert.deepEqual([sb.canvas.width, sb.canvas.height], [500, 400]) h.destroy() }) test('unsichtbare Bühne (Breite unter 10): nichts wird gezeichnet, bis resize() sie misst', async () => { const { sb, h } = await open({ cssWidth: 5 }) h.setState(makeState()) sb.tick(1) assert.equal(sb.counts.fillRect, 0) assert.equal(h.stats().frames, 0) sb.setCssWidth(960) h.resize() assert.deepEqual([sb.canvas.width, sb.canvas.height], [960, 768]) assert.ok(sb.counts.fillRect > 100) h.destroy() }) test('Startansicht: breite Bühne Zoom 1, schmale fitZoom; die Touch-Aktion folgt dem Zoom', async () => { const wide = await open({ cssWidth: 960 }) assert.equal(wide.h.stats().zoom, 1) assert.equal(wide.sb.canvas.style.touchAction, 'pan-y') wide.h.destroy() const narrow = await open({ cssWidth: 375 }) narrow.h.setState(makeState()) assert.ok(Math.abs(narrow.h.stats().zoom - 560 / 375) < 1e-9) assert.equal(narrow.sb.canvas.style.touchAction, 'none') // alle Figuren der oberen Etagen liegen in der Ansicht (die untere Hälfte des Turms ist bei Zoom > 1 abgeschnitten) const upper = narrow.h.debug().agents.filter((a) => a.y < 120) assert.ok(upper.length >= 10) for (const a of upper) assert.ok(narrow.h.locate(a.id), a.id) narrow.h.resetView() assert.equal(narrow.h.stats().zoom, 1) assert.equal(narrow.sb.canvas.style.touchAction, 'pan-y') narrow.h.destroy() }) test('Startansicht auf schmaler Bühne: alle Räume (x 42..228) liegen in der Breite', async () => { const { sb, h } = await open({ cssWidth: 375, record: true }) const k = sb.canvas.width / (320 / h.stats().zoom) const backWalls = sb.rects.filter((r) => Math.abs(r.w - 186 * k) <= 1) assert.ok(backWalls.length >= 10, 'Rückwand und Bodenstreifen jeder Etage sind 186 Einheiten breit') for (const r of backWalls) assert.ok(r.x >= 0 && r.x + r.w <= sb.canvas.width, JSON.stringify(r)) h.destroy() }) test('resize() zieht den Startausschnitt nach, bis der Nutzer die Kamera bewegt hat', async () => { const { sb, h } = await open({ cssWidth: 375 }) assert.ok(h.stats().zoom > 1.4) sb.setCssWidth(960) h.resize() assert.equal(h.stats().zoom, 1) sb.setCssWidth(375) h.resize() h.zoomBy(2) const z = h.stats().zoom sb.setCssWidth(960) h.resize() assert.equal(h.stats().zoom, z) h.destroy() }) // ---------- Zeichnen ---------- test('Zeichenreihenfolge: Weiß, Außenwelt, Turm, Räume, Aufzug hinten, Figuren, Aufzug vorn, Pausenschleier', async () => { const { sb, h } = await open({ record: true }) const state = makeState({ paused: true, connected: { kim: false } }) h.setState(state) sb.rects.length = 0 sb.texts.length = 0 h.setState(state) // genau ein Bild const white = sb.rects[0] assert.deepEqual([white.x, white.y, white.w, white.h, white.fill], [0, 0, 960, 768, '#ffffff']) const rectN = (pred, what) => { const r = sb.rects.find(pred) assert.ok(r, what) return r.n } const textN = (text) => { const t = sb.texts.find((x) => x.text === text) assert.ok(t, text) return t.n } const outside = rectN((r) => r.fill === '#f7f4f0', 'Erde der Außenwelt') const tower = rectN((r) => r.fill === '#e4e8ef', 'Aufzugschacht') const rooms = textN('noch nicht angeschlossen') const liftBack = rectN((r) => r.fill === '#f1f3f8', 'Rückwand der Kabine') const figure = rectN((r) => r.alpha === 0.55, 'graue Figur') const liftFront = rectN((r) => r.fill === '#ffe9a8', 'Lampe der Kabine') const veil = textN('Pausiert') assert.deepEqual([outside, tower, rooms, liftBack, figure, liftFront, veil].toSorted((a, b) => a - b), [outside, tower, rooms, liftBack, figure, liftFront, veil]) assert.equal(sb.texts.at(-1).text, 'Pausiert') h.destroy() }) test('Effekte liegen über dem Aufzug vorn und unter dem Pausenschleier', async () => { const { sb, h } = await open({ record: true }) h.setState(makeState()) h.pushEvents([ev('vorschau_link', 'max')]) sb.tick(2) // erst Sprechblase, dann fliegt der Brief (Lebensdauer 3,4 s) assert.ok(h.debug().fx > 0) sb.rects.length = 0 sb.texts.length = 0 h.setState(makeState({ paused: true })) // genau ein Bild, der Brief ist noch unterwegs const lamp = sb.rects.find((r) => r.fill === '#ffe9a8') const mail = sb.rects.filter((r) => r.fill === '#7a5cd6' && r.w === 21 && r.h === 15).at(-1) const veil = sb.texts.find((t) => t.text === 'Pausiert') assert.ok(lamp && mail && veil) assert.ok(lamp.n < mail.n && mail.n < veil.n, [lamp.n, mail.n, veil.n].join(' < ')) h.destroy() }) test('Pausenschleier nur bei pausiertem Dienst', async () => { const { sb, h } = await open() h.setState(makeState()) assert.equal(sb.seenTexts.has('Pausiert'), false) h.setState(makeState({ paused: true })) assert.equal(sb.seenTexts.has('Pausiert'), true) h.destroy() }) test('stats().avgDrawMs misst die Zeit eines Bildes mit der Uhr der Szene', async () => { const sb = await createEngineSandbox() let t = 0 const h = sb.mountTower(sb.canvas, sb.opts({ reducedMotion: true, now: () => { t += 2; return t } })) h.setState(makeState()) const s = h.stats() assert.equal(s.frames, 2) // Mount und setState assert.equal(s.avgDrawMs, 2) h.destroy() }) test('Schleife treibt die Regie: eine Übergabe über sieben Etagen läuft in der Szene zu Ende', async () => { const { sb, h } = await open() h.setState(makeState()) const arne = () => h.debug().agents.find((a) => a.id === 'arne') const home = { x: arne().x, y: arne().y, floor: arne().floor } h.pushEvents([ev('uebergabe', 'arne', { to: 'bea' })]) assert.equal(h.debug().pending, 1) let away = false for (let s = 0; s < 40 && h.debug().pending > 0; s++) { sb.tick(1) if (Math.abs(arne().x - home.x) > 10 || arne().floor !== home.floor) away = true } assert.ok(away) assert.equal(h.debug().pending, 0) sb.tick(2) assert.equal(h.debug().lift, 'idle') assert.deepEqual({ x: arne().x, y: arne().y, floor: arne().floor }, home) h.destroy() }) test('dt wird nach einem Stillstand auf DT_MAX gekappt', async () => { const { sb, h } = await open() h.setState(makeState()) h.pushEvents([ev('uebergabe', 'arne', { to: 'bea' })]) sb.tick(1) // 0,4 s Aufnehmen, dann unterwegs zum Aufzug const at = () => h.debug().agents.find((a) => a.id === 'arne') const before = { x: at().x, y: at().y } sb.jump(60) // die Uhr springt, ohne dass ein Bild kommt sb.tick(2 / 60) // ein Bild mit Δt = 60 s const moved = Math.hypot(at().x - before.x, at().y - before.y) assert.ok(moved > 0 && moved <= 46 * sb.engine.DT_MAX + 1e-6, String(moved)) h.destroy() }) // ---------- Eingabe ---------- test('Klick ins Leere wählt ab (onSelect(null))', async () => { const { sb, h } = await open() h.setState(makeState()) sel.length = 0 const p = h.locate('otto') sb.click(p.x, p.y) sb.click(5, 5) assert.deepEqual(sel, ['otto', null]) h.destroy() }) test('coarsePointer weitet die Trefferfläche von 4 auf 10 CSS-Pixel', async () => { for (const [coarsePointer, expected] of [[false, null], [true, 'arne']]) { const { sb, h } = await open({}, { coarsePointer }) h.setState(makeState()) sel.length = 0 const p = h.locate('arne') sb.click(p.x + 25, p.y) // die Figur ist 12 Einheiten = 36 CSS-Pixel breit, 25 > 18 + 4 und 25 < 18 + 10 assert.equal(sel.at(-1), expected, String(coarsePointer)) h.destroy() } }) test('zoomBy zoomt um die Mitte der Ansicht', async () => { const { h } = await open() h.setState(makeState()) const before = h.locate('bea') h.zoomBy(1.4) const after = h.locate('bea') assert.ok(Math.abs(after.x - (480 + (before.x - 480) * 1.4)) < 1, JSON.stringify(after)) assert.ok(Math.abs(after.y - (384 + (before.y - 384) * 1.4)) < 1, JSON.stringify(after)) h.destroy() }) test('Strg+Rad zoomt um den Zeiger, ohne Strg bleibt das Rad der Seite; Schritt begrenzt, passive: false', async () => { const { sb, h } = await open() h.setState(makeState()) const free = sb.wheel(480, 384, -100) assert.equal(free.defaultPrevented, false) assert.equal(h.stats().zoom, 1) const p = h.locate('arne') const e = sb.wheel(p.x, p.y, -100, { ctrlKey: true }) assert.equal(e.defaultPrevented, true) assert.ok(Math.abs(h.stats().zoom - sb.engine.ZOOM_STEP) < 1e-9) const q = h.locate('arne') assert.ok(Math.abs(q.x - p.x) < 1 && Math.abs(q.y - p.y) < 1, 'der Punkt unter dem Zeiger bleibt stehen') sb.wheel(p.x, p.y, 100, { ctrlKey: true }) assert.ok(Math.abs(h.stats().zoom - 1) < 1e-9) sb.wheel(480, 384, -1e6, { ctrlKey: true }) assert.ok(Math.abs(h.stats().zoom - sb.engine.ZOOM_STEP ** 2) < 1e-9) const wheel = sb.listeners.wheel[0] assert.equal(sb.listenerOptions.get(wheel).passive, false) h.destroy() }) test('Ziehen bei Zoom 1 verschiebt nichts, unterdrückt aber den Klick; kleine Bewegung und die rechte Maustaste', async () => { const { sb, h } = await open() h.setState(makeState()) const before = h.locate('bea') sel.length = 0 sb.pointer('pointerdown', 400, 300) sb.pointer('pointermove', 460, 300) sb.pointer('pointerup', 460, 300) sb.click(before.x, before.y) assert.equal(sel.length, 0) assert.deepEqual(h.locate('bea'), before) // der folgende Klick gilt wieder sb.click(before.x, before.y) assert.deepEqual(sel, ['bea']) // bis DRAG_PX Wanderung bleibt ein Klick sb.pointer('pointerdown', before.x, before.y) sb.pointer('pointermove', before.x + sb.engine.DRAG_PX, before.y) sb.pointer('pointerup', before.x + sb.engine.DRAG_PX, before.y) sb.click(before.x, before.y) assert.deepEqual(sel, ['bea', 'bea']) // die rechte Maustaste startet kein Ziehen sb.pointer('pointerdown', 400, 300, { button: 2 }) sb.pointer('pointermove', 460, 300) sb.click(before.x, before.y) assert.deepEqual(sel, ['bea', 'bea', 'bea']) h.destroy() }) test('Zeiger-Erfassung beim Ziehen; ein Fehler dabei stört das Ziehen nicht', async () => { const { sb, h } = await open() h.setState(makeState()) h.zoomBy(2) h.select('bea') const calls = [] sb.canvas.setPointerCapture = (id) => calls.push(['set', id]) sb.canvas.releasePointerCapture = (id) => calls.push(['release', id]) sb.pointer('pointerdown', 400, 300) sb.pointer('pointercancel', 400, 300) assert.deepEqual(calls, [['set', 1], ['release', 1]]) sb.canvas.setPointerCapture = () => { throw new Error('InvalidPointerId') } sb.canvas.releasePointerCapture = () => { throw new Error('InvalidPointerId') } const before = h.locate('bea') sb.pointer('pointerdown', 400, 300) sb.pointer('pointermove', 450, 300) sb.pointer('pointerup', 450, 300) assert.ok(Math.abs(h.locate('bea').x - (before.x + 50)) < 2) h.destroy() }) test('select() von außen holt die Figur bei Zoom > 1 in die Mitte, der Klick selbst verschiebt die Ansicht nicht', async () => { const { sb, h } = await open() h.setState(makeState()) h.zoomBy(3) h.select('rita') const mid = h.locate('rita') assert.ok(Math.abs(mid.x - 480) < 1 && Math.abs(mid.y - 384) < 1, JSON.stringify(mid)) sb.pointer('pointerdown', 400, 300) sb.pointer('pointermove', 450, 300) sb.pointer('pointerup', 450, 300) sb.click(450, 300) // gilt als Ende des Ziehens const dragged = h.locate('rita') assert.ok(Math.abs(dragged.x - (mid.x + 50)) < 2) sel.length = 0 sb.click(dragged.x, dragged.y) assert.deepEqual(sel, ['rita']) h.select('rita') // so antwortet die Seite auf onSelect assert.deepEqual(h.locate('rita'), dragged) h.select('rita') // jetzt von außen (Liste): in die Mitte const again = h.locate('rita') assert.ok(Math.abs(again.x - 480) < 1 && Math.abs(again.y - 384) < 1) h.destroy() }) test('locate: null außerhalb der Ansicht und für Unbekanntes', async () => { const { h } = await open() h.setState(makeState()) h.zoomBy(3) h.select('otto') assert.ok(h.locate('otto')) assert.equal(h.locate('arne'), null) for (const id of ['niemand', '__proto__', 'constructor', '', 42, null, undefined]) assert.equal(h.locate(id), null, String(id)) h.destroy() }) // ---------- Lebenszyklus und Sonderfälle ---------- test('nach destroy ist jeder Aufruf wirkungslos', async () => { const { sb, h } = await open() h.setState(makeState()) h.destroy() const n = sb.counts.fillRect const zoom = h.stats().zoom h.setState(makeState({ paused: true })) h.pushEvents([ev('fund', 'kim')]) h.select('kim') h.zoomBy(2) h.resetView() h.resize() h.setPaused(true) h.setPaused(false) sb.click(100, 100) assert.equal(sb.counts.fillRect, n) assert.equal(h.stats().zoom, zoom) assert.equal(h.locate('kim'), null) assert.equal(sb.rafPending(), 0) }) test('Standbild: das Aufblitzen des Tresors nach einem Deal bleibt nicht stehen', async () => { const { sb, h } = await open({ record: true }, { reducedMotion: true }) h.setState(makeState()) sb.rects.length = 0 h.pushEvents([ev('deal', 'system'), ev('deal', 'system')]) assert.ok(sb.rects.length > 100) assert.equal(sb.rects.filter((r) => r.fill === '#e8b730' && r.alpha > 0 && r.alpha < 0.5).length, 0) h.destroy() }) test('setPaused(true) bestellt keinen Bildrückruf mehr, setPaused(false) genau einen', async () => { const { sb, h } = await open() assert.equal(sb.rafPending(), 1) h.setPaused(true) assert.equal(sb.rafPending(), 0) h.setPaused(true) h.setPaused(false) h.setPaused(false) assert.equal(sb.rafPending(), 1) h.destroy() assert.equal(sb.rafPending(), 0) }) test('ohne wirksames caf läuft nach Pause und Fortsetzen nie eine zweite Schleife', async () => { const { sb, h } = await open({}, { caf: () => {} }) h.setPaused(true) h.setPaused(false) // der alte Rückruf steht noch in der Warteschlange assert.equal(sb.rafPending(), 2) sb.tick(0.1) assert.equal(sb.rafPending(), 1) const f0 = h.stats().frames sb.tick(1) const f = h.stats().frames - f0 assert.ok(f >= 28 && f <= 32, String(f)) h.destroy() }) test('setPaused im Standbild startet keine Schleife', async () => { const { sb, h } = await open({}, { reducedMotion: true }) h.setPaused(true) h.setPaused(false) assert.equal(sb.rafPending(), 0) h.destroy() }) test('ohne raf (Option und global) bleibt es beim Standbild; die Standardwerte werden erst beim Aufruf gelesen', async () => { const sb = await createEngineSandbox() const h = sb.mountTower(sb.canvas, { onSelect() {} }) assert.ok(sb.counts.fillRect > 100) assert.equal(sb.rafPending(), 0) h.setState(makeState()) h.destroy() }) test('fontsReady erfüllt: danach wird einmal neu gezeichnet', async () => { const sb = await createEngineSandbox() const h = sb.mountTower(sb.canvas, sb.opts({ fontsReady: Promise.resolve() })) const n = sb.counts.fillRect await new Promise((r) => setImmediate(r)) assert.ok(sb.counts.fillRect > n) h.destroy() }) test('Canvas ohne 2D-Kontext: ein Handle, das nichts tut', async () => { const sb = await createEngineSandbox() sb.canvas.getContext = () => null const h = sb.mountTower(sb.canvas, sb.opts({})) h.setState(makeState()) h.pushEvents([ev('fund', 'kim')]) h.select('kim') h.zoomBy(2) h.resetView() h.resize() h.setPaused(true) assert.equal(h.locate('kim'), null) assert.equal(h.stats().frames, 0) assert.equal(h.debug().pending, 0) h.destroy() assert.equal(sb.counts.fillRect, 0) assert.equal(sb.rafPending(), 0) assert.equal(Object.values(sb.listeners).flat().length, 0) }) test('debug() und stats(): Form', async () => { const { sb, h } = await open() h.setState(makeState({ connected: { kim: false } })) sb.tick(1) const d = h.debug() assert.equal(d.agents.length, 19) assert.deepEqual(Object.keys(d.agents[0]).sort(), ['floor', 'grey', 'id', 'pose', 'x', 'y']) assert.equal(d.agents.find((a) => a.id === 'kim').grey, true) assert.deepEqual([typeof d.pending, typeof d.lift, typeof d.fx], ['number', 'string', 'number']) const s = h.stats() assert.deepEqual(Object.keys(s).sort(), ['avgDrawMs', 'frames', 'zoom']) assert.ok(s.frames > 25) h.destroy() })