Workers P2: mountTower (Größe, 30-fps-Schleife, Eingabe, Kamera, Lebenszyklus)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 03:37:47 +00:00
1 parent ad8ee9afe2
commit cb31d1476b
3 files changed
+1141

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
// vm-Sandbox für die Szene des Hustler-Hochhauses (Muster: entwuerfe/smoke.js, nur für Tests).
// scene.js wird mit esbuild zu einem IIFE gebündelt und in einem vm-Kontext ohne window/document ausgeführt:
// der Kontext kennt nur console, Math, Date und JSON. Canvas, Zeichenkontext und Bildtakt sind Attrappen.
import vm from 'node:vm'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')
const SCENE = path.join(repoRoot, 'src/components/workers/engine/scene.js')
const STEP_MS = 1000 / 60
// Das Bündel ist für alle Sandboxen gleich: einmal bauen
let bundle = null
function bundleScene() {
bundle ??= esbuild
.build({ entryPoints: [SCENE], bundle: true, format: 'iife', globalName: 'WKEngine', platform: 'neutral', write: false })
.then((result) => result.outputFiles[0].text)
return bundle
}
/**
* createEngineSandbox({ cssWidth = 960, dpr = 1, record = false })
* → { mountTower, engine, canvas, counts, seenTexts, rects, texts, listeners, listenerOptions,
* opts(extra), tick(seconds), jump(seconds), rafPending(), setCssWidth(w),
* click(cssX, cssY), pointer(type, cssX, cssY, extra), wheel(cssX, cssY, deltaY, { ctrlKey }) }
* counts: { fillRect, fillText } zählt nur (es werden keine Aufrufe gespeichert, sonst sprengen tausende Bilder den Speicher)
* seenTexts: Set der verschiedenen gezeichneten Texte
* rects/texts: nur mit record: true, jedes fillRect { n, x, y, w, h, fill, alpha } und jedes fillText { n, text, x, y, font }
* (n = laufende Nummer über beide Listen, damit die Reihenfolge der Aufrufe vergleichbar ist; alpha folgt save/restore)
* listeners: Typ -> Liste der Rückrufe am Canvas; listenerOptions: Map Rückruf -> Optionen von addEventListener
* tick(s): rückt die Uhr in Schritten von 1000/60 ms vor und führt fällige raf-Rückrufe aus; jump(s) rückt die Uhr ohne Rückrufe
* (ein Stillstand); click/pointer/wheel schicken Ereignisse in CSS-Pixeln relativ zur Leinwand (Rand 0, Breite cssWidth, Höhe 4/5)
*/
export async function createEngineSandbox({ cssWidth = 960, dpr = 1, record = false } = {}) {
const code = await bundleScene()
const counts = { fillRect: 0, fillText: 0 }
const seenTexts = new Set()
const rects = []
const texts = []
const listeners = {}
const listenerOptions = new Map()
const alphaStack = []
let seq = 0
let width = cssWidth
const target = { fillStyle: '#000000', font: '10px sans-serif', globalAlpha: 1, textAlign: 'left', textBaseline: 'alphabetic' }
const ctx = new Proxy(target, {
get(t, prop) {
if (typeof prop === 'symbol') return undefined
if (prop === 'save') return () => alphaStack.push(t.globalAlpha)
if (prop === 'restore') return () => { if (alphaStack.length > 0) t.globalAlpha = alphaStack.pop() }
if (prop === 'measureText') return (text) => ({ width: String(text).length * 7 })
if (prop === 'fillRect') {
return (x, y, w, h) => {
counts.fillRect += 1
if (record) rects.push({ n: seq++, x, y, w, h, fill: t.fillStyle, alpha: t.globalAlpha })
}
}
if (prop === 'fillText') {
return (text, x, y) => {
if (typeof text !== 'string') throw new Error('fillText mit Nicht-String: ' + text)
counts.fillText += 1
seenTexts.add(text)
if (record) texts.push({ n: seq++, text, x, y, font: t.font })
}
}
if (prop === 'createLinearGradient' || prop === 'createRadialGradient') return () => ({ addColorStop() {} })
if (prop === 'getImageData') return () => ({ data: new Uint8ClampedArray(4) })
if (prop in t) return t[prop]
return () => {}
},
set(t, prop, value) {
t[prop] = value
return true
},
})
const canvas = {
width: 300,
height: 150,
style: {},
getContext: () => ctx,
addEventListener(type, fn, options) {
listeners[type] = [...(listeners[type] ?? []), fn]
if (options !== undefined) listenerOptions.set(fn, options)
},
removeEventListener(type, fn) {
listeners[type] = (listeners[type] ?? []).filter((f) => f !== fn)
},
getBoundingClientRect: () => ({ left: 0, top: 0, width, height: width * 0.8 }),
}
target.canvas = canvas
// Bildtakt: raf/caf führen Buch, now ist die Uhr der Sandbox
let clock = 0
let nextId = 0
const queue = new Map()
const raf = (cb) => {
nextId += 1
queue.set(nextId, cb)
return nextId
}
const caf = (id) => {
queue.delete(id)
}
const now = () => clock
function tick(seconds) {
const steps = Math.round(seconds * 60)
for (let i = 0; i < steps; i++) {
clock += STEP_MS
for (const id of [...queue.keys()]) {
const cb = queue.get(id)
if (!cb) continue
queue.delete(id)
cb(clock)
}
}
}
const sandbox = { console, Math, Date, JSON }
vm.createContext(sandbox)
vm.runInContext(code, sandbox, { filename: 'scene.bundle.js' })
const engine = sandbox.WKEngine
const fire = (type, event) => {
for (const fn of [...(listeners[type] ?? [])]) fn(event)
}
const makeEvent = (x, y, extra = {}) => ({
clientX: x,
clientY: y,
button: 0,
pointerId: 1,
ctrlKey: false,
deltaY: 0,
deltaMode: 0,
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true
},
stopPropagation() {},
...extra,
})
return {
mountTower: engine.mountTower,
engine,
canvas,
counts,
seenTexts,
rects,
texts,
listeners,
listenerOptions,
opts: (extra = {}) => ({ raf, caf, now, dpr, ...extra }),
tick,
jump(seconds) {
clock += seconds * 1000
},
rafPending: () => queue.size,
setCssWidth(w) {
width = w
},
click(cssX, cssY) {
fire('click', makeEvent(cssX, cssY))
},
pointer(type, cssX, cssY, extra = {}) {
fire(type, makeEvent(cssX, cssY, extra))
},
wheel(cssX, cssY, deltaY, { ctrlKey = false } = {}) {
const event = makeEvent(cssX, cssY, { deltaY, ctrlKey })
fire('wheel', event)
return event
},
}
}
+577
View File
@@ -0,0 +1,577 @@
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()
})