From 030d0a293870734ac11c5f262da3ac30e0806dc5 Mon Sep 17 00:00:00 2001 From: siruimei07 Date: Thu, 8 Oct 2026 19:13:05 +0800 Subject: [PATCH] =?UTF-8?q?feedback7=20R22-HUD:=20a=20renderer=20that=20lo?= =?UTF-8?q?ses=20the=2012=20s=20race=20no=20longer=20mounts=20its=20canvas?= =?UTF-8?q?=20late=20=E2=80=94=20the=20attempt=20is=20aborted,=20a=20view?= =?UTF-8?q?=20that=20still=20comes=20back=20is=20destroyed,=20the=20host?= =?UTF-8?q?=20is=20no=20longer=20wiped=20(PR=20#302=20by=20@siruimei07);?= =?UTF-8?q?=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no new dependency, no shipped eval). The leak was real on feedback7: mountFieldView raced createFieldView against a timeout and, on failure, removed every child of the host while the factory kept running — Pixi finished later and appended its canvas over the simplified view (taking its clicks); the host wipe could also delete a newer view mounted by the same hook. - ui/fieldHost.js: mountFieldView(host, { signal }) owns an AbortController; the timeout, a failed import or an invalid view aborts it, a view a factory returns after that is destroyed, an outer abort (useFieldView's cleanup) rejects without a fallback; nothing clears the host's children any more. - render/app.js createFieldView takes the signal: the Pixi load / manifest / font waits end on abort, the synchronous build registers its disposables (setupCleanup) so a throw releases the app, tiles, fx, impostors, listeners and observers it made, the board-art and 3D-board waits moved after the view exists (aborting them is view.destroy(), idempotent), destroy() also removes the canvas. render/app/host.js withTimeout(p, ms, signal). - Tests: test/ui/fieldHost-lifecycle.test.js (5, node vm, fake timers: all fail on the parent) and the opt-in real-browser test/render/initialization.browser.test.js (6 scenarios in headless Chrome, 5 fail on the parent: a late Pixi load covering the fallback — with a factory that honours the signal and one that ignores it —, art timeout, a setup exception, an unmount racing a newer mount); test/ui/playtest2.test.js's source pin takes the new indentation. fieldview / browser suites in Chrome unchanged (the two 4× throttled fps tests flake when two Chrome files run at once, pass alone). - The createFieldView body stays unindented inside its new try (the PR's choice, to keep the diff reviewable). - Docs: design/client.md §9. Golden: unchanged (client lifecycle only). --- docs/design/client.md | 3 + public/js/render/app.js | 94 ++++-- public/js/render/app/host.js | 18 +- public/js/ui/fieldHost.js | 75 ++++- test/render/initialization.browser.test.js | 343 +++++++++++++++++++++ test/ui/fieldHost-lifecycle.test.js | 158 ++++++++++ test/ui/playtest2.test.js | 2 +- 7 files changed, 654 insertions(+), 39 deletions(-) create mode 100644 test/render/initialization.browser.test.js create mode 100644 test/ui/fieldHost-lifecycle.test.js diff --git a/docs/design/client.md b/docs/design/client.md index 1d9e476a..44b812de 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -29,6 +29,9 @@ view.holdPiece(uid, {row,col}|null) // keep a dropped piece on a tile w view.setPieceDir(uid, dir) // show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir) view.resize() ; view.destroy() ``` + +`createFieldView(host, { …, signal })` takes an `AbortSignal` (0.2.2, GitHub PR #302 by @siruimei07): an abort during the build — the Pixi load, the manifest, the fonts, the board art and the 3D board waits — destroys what the attempt made (the canvas, the ticker, the listeners, the observers) and rejects, a failure while it is built releases the same partial resources, and `view.destroy()` is idempotent. `ui/fieldHost.js mountFieldView(host, { signal })` aborts that signal when the engine fails, times out (12 s) or the owner unmounts, disposes a view a factory returns after that, and never clears the other children of `host` — the simplified view and a newer mount share it. + Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). --- diff --git a/public/js/render/app.js b/public/js/render/app.js index 32784604..0753abe3 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -148,14 +148,17 @@ export { releaseGl } from './app/host.js'; /** * Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it). * @param {HTMLElement} host - * @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, + * @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, signal?: AbortSignal, * padding?: object|((kind:string, size:{width:number,height:number}) => object), * hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts] */ export async function createFieldView(host, options = {}) { if (!host || typeof host.appendChild !== 'function') throw new TypeError('createFieldView: host element required'); const opts = options && typeof options === 'object' ? options : {}; - const P = await ensurePixi(); + const signal = opts.signal; + signal?.throwIfAborted(); + const P = await withTimeout(ensurePixi(), null, signal); + signal?.throwIfAborted(); const assets = resolveAssets(opts.assets); const data = makeData(opts.data); const settings = { damageNumbers: true, quality: 'high', ...(opts.settings || {}) }; @@ -164,19 +167,28 @@ export async function createFieldView(host, options = {}) { const want3d = boardPref !== '2d' && webgl2Available(boardPref === '3d'); // three.js (~2 MB) is fetched only when the local-art manifest lists the board atlas (in parallel with the art) const artListed = want3d ? boardArtListed(assets).catch(() => false) : Promise.resolve(false); - const threePromise = artListed.then((ok) => (ok ? loadThree() : null)); - const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null)); + const threePromise = artListed.then((ok) => (ok ? loadThree() : null)).catch(() => null); + const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null)).catch(() => null); // the manifest, and the optional local-art manifest in parallel: unit views pick an enemy's local-client model by it // (assets.js spineEntry, DESIGN §13 — 灼热源石虫 / 炽焰源石虫); absent or slow, they draw the web models await withTimeout(Promise.all([assets.ready ? assets.ready() : null, assets.local ? assets.local() : null] - .map((p) => Promise.resolve(p).catch(() => {}))), 4000); + .map((p) => Promise.resolve(p).catch(() => {}))), 4000, signal); + signal?.throwIfAborted(); // web fonts for the bitmap damage numbers / tier chips (never block long) - try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500); } catch { /* ignore */ } + try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500, signal); } catch { /* optional fonts */ } + signal?.throwIfAborted(); const size = () => ({ width: Math.max(1, host.clientWidth || 1), height: Math.max(1, host.clientHeight || 1) }); const dpr = () => Math.min(globalThis.devicePixelRatio || 1, QUALITY_RES[settings.quality] || 2); const boardDpr = () => Math.min(globalThis.devicePixelRatio || 1, BOARD_RES[settings.quality] || 2); const s0 = size(); + // Construction is synchronous until the complete view can own cancellation. If setup throws, + // dispose only the resources acquired by this attempt, never another view's host contents. + const setupCleanup = []; + let destroyed = false; + let view = null; + let onAbort; + try { const app = new P.Application({ // MSAA only where it pays: dense (DPR ≥ 1.5) screens are sharp enough without it and it would cost 4× the fill // transparent: the 3D board canvas shows through (the 2D board paints an opaque backdrop itself) @@ -184,6 +196,11 @@ export async function createFieldView(host, options = {}) { resolution: dpr(), autoDensity: true, powerPreference: 'high-performance', }); const canvas = app.view; + setupCleanup.push(() => { + releaseGl(app.renderer); + try { app.destroy(true, { children: true, texture: false, baseTexture: false }); } + finally { canvas.remove(); } + }); canvas.style.display = 'block'; canvas.style.width = '100%'; canvas.style.height = '100%'; @@ -240,7 +257,6 @@ export async function createFieldView(host, options = {}) { for (const fn of [...set]) { try { fn(payload); } catch (err) { console.error(`[render] ${name} listener failed`, err); } } }; - let destroyed = false; let mode = 'idle'; // 'idle' | 'prep' | 'battle' let stageRec = null; let cam = presetCamera('prep', { width: s0.width, height: s0.height, padding: defaultPadding('prep', s0) }, { hud: hudBands('prep', s0) }); @@ -306,6 +322,7 @@ export async function createFieldView(host, options = {}) { surfaceLayer: (row) => tiles.surfaceLayer(row), }; const tiles = new TileField({ ground: layers.ground, overlay: layers.overlay, props: layers.units, anim: layers.anim }); + setupCleanup.push(() => tiles.destroy()); const fx = new FxSystem({ P, layers, cam: () => cam, settings, assets, heightAt, redVignette: bg.red, surfaceLayer: ctx.surfaceLayer, timeScale: () => ctx.timeScale(), @@ -320,10 +337,12 @@ export async function createFieldView(host, options = {}) { return Math.max(80, Math.min(size().height * 0.4, p.y)); }, }); + setupCleanup.push(() => fx.destroy()); ctx.fx = fx; // battle devices (crates / turrets as sim units): the official crate mesh in the 3D scene, else a Pixi box ctx.createBox = () => switchableBox({ board: () => board3d, pixi: () => tiles.createBox() }); const impostors = new ImpostorAtlas(app.renderer); + setupCleanup.push(() => impostors.destroy()); ctx.impostors = impostors; tiles.setView(bandFor('prep'), camRect(), fieldRows('prep')); // the real board art of the local client (optional): wait briefly so the first frame already uses it; a late @@ -332,7 +351,6 @@ export async function createFieldView(host, options = {}) { if (art && !destroyed) { tiles.setArt(art); tiles.project(cam, true); } return art; }, () => null); - await withTimeout(artPromise, 2500); // ---- 3D board layer (render/board3d, DESIGN §15) ----------------------------------------------------------- let board3d = null; // BoardScene while the 3D board is on @@ -340,6 +358,7 @@ export async function createFieldView(host, options = {}) { let board3dError = null; // lost-context recovery: the THREE module + art pack of the last 3D board, the pending retry, failures so far const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 }; + setupCleanup.push(() => { clearTimeout(recover.timer); disable3d(); }); function enable3d(THREE, pack) { if (destroyed || board3d || !THREE || !pack) return false; recover.THREE = THREE; recover.pack = pack; @@ -409,13 +428,13 @@ export async function createFieldView(host, options = {}) { if (!enable3d(recover.THREE, recover.pack)) scheduleRecover(); }, delay); } - if (want3d) { - const ready = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false); - await withTimeout(ready, 6000); - } + // Attach the board only after synchronous setup completes; enable3d also guards late arrivals. + const boardReady = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false); // the official soft shadow sprite replaces the procedural one once loaded (may already be cached; asked again when the // manifest arrives late) let shadowAsked = false; + let offShadow = null; + setupCleanup.push(() => offShadow?.()); function loadShadow() { const shadowUrl = !shadowAsked && assets.ui ? assets.ui('battle/sprite_shadow') : null; if (!shadowUrl) return; @@ -428,7 +447,11 @@ export async function createFieldView(host, options = {}) { for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t; for (const v of penViews.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t; }; - if (t.baseTexture.valid) use(); else t.baseTexture.once('loaded', use); + if (t.baseTexture.valid) use(); + else { + t.baseTexture.once('loaded', use); + offShadow = () => t.baseTexture.off('loaded', use); + } } catch { /* optional */ } } loadShadow(); @@ -1206,6 +1229,17 @@ export async function createFieldView(host, options = {}) { // the 收起 toggle of PR #149 moved the discs one button to the right) opened the bond popup instead of selecting the // unit. As a click target that holds the finger's point the canvas wins: a tap on the board stays on the tile under it. const onTapTarget = () => {}; + const removeInput = () => { + canvas.removeEventListener('pointerdown', onPointerDown); + canvas.removeEventListener('pointermove', onPointerMove); + canvas.removeEventListener('pointerup', onPointerUp); + canvas.removeEventListener('pointercancel', onPointerCancel); + canvas.removeEventListener('pointerleave', onPointerLeave); + canvas.removeEventListener('contextmenu', onContext); + canvas.removeEventListener('touchend', onTouchEnd); + canvas.removeEventListener('click', onTapTarget); + }; + setupCleanup.push(removeInput); canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerup', onPointerUp); @@ -1655,6 +1689,7 @@ export async function createFieldView(host, options = {}) { let ro = null; if (typeof ResizeObserver === 'function') { ro = new ResizeObserver(() => resize()); + setupCleanup.push(() => ro.disconnect()); ro.observe(host); } // a DPR change without a size change (window dragged to another monitor, zoom) must re-rasterise too; polled @@ -1677,6 +1712,7 @@ export async function createFieldView(host, options = {}) { leader?.view.retryAssets?.(); } const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null; + setupCleanup.push(() => offAssets?.()); const onVisible = () => { if (destroyed || globalThis.document?.visibilityState !== 'visible') return; if (assets.loaded === false && typeof assets.ready === 'function') assets.ready(); @@ -1685,10 +1721,11 @@ export async function createFieldView(host, options = {}) { leader?.view.retryAssets?.(); }; globalThis.document?.addEventListener?.('visibilitychange', onVisible); + setupCleanup.push(() => globalThis.document?.removeEventListener?.('visibilitychange', onVisible)); // ---- public API --------------------------------------------------------------------------------------------- - const view = { + view = { setStage, setCamera, setPrep, @@ -1804,14 +1841,9 @@ export async function createFieldView(host, options = {}) { try { ro?.disconnect(); } catch { /* ignore */ } try { offAssets?.(); } catch { /* ignore */ } globalThis.document?.removeEventListener?.('visibilitychange', onVisible); - canvas.removeEventListener('pointerdown', onPointerDown); - canvas.removeEventListener('pointermove', onPointerMove); - canvas.removeEventListener('pointerup', onPointerUp); - canvas.removeEventListener('pointercancel', onPointerCancel); - canvas.removeEventListener('pointerleave', onPointerLeave); - canvas.removeEventListener('contextmenu', onContext); - canvas.removeEventListener('touchend', onTouchEnd); - canvas.removeEventListener('click', onTapTarget); + removeInput(); + offShadow?.(); + signal?.removeEventListener('abort', onAbort); app.ticker.remove(frame); app.ticker.remove(preRender); app.ticker.remove(postRender); @@ -1829,6 +1861,7 @@ export async function createFieldView(host, options = {}) { try { board3dCanvas?.remove(); } catch { /* ignore */ } releaseGl(app.renderer); try { app.destroy(true, { children: true, texture: false, baseTexture: false }); } catch { /* ignore */ } + canvas.remove(); }, stats() { return { @@ -1848,5 +1881,22 @@ export async function createFieldView(host, options = {}) { pick: { pieceAt, battleUnitAt, penUnitAt, groundTile }, }, }; + // All teardown dependencies now exist. Aborting during either asset wait immediately releases + // the renderer, ticker and listeners; late shared asset results see destroyed and do nothing. + setupCleanup.length = 0; + onAbort = () => view.destroy(); + signal?.addEventListener('abort', onAbort, { once: true }); + signal?.throwIfAborted(); + await withTimeout(artPromise, 2500, signal); + if (want3d) await withTimeout(boardReady, 6000, signal); + signal?.throwIfAborted(); return view; + } catch (err) { + if (view) view.destroy(); + else { + destroyed = true; + for (const dispose of setupCleanup.reverse()) { try { dispose(); } catch { /* continue cleanup */ } } + } + throw err; + } } diff --git a/public/js/render/app/host.js b/public/js/render/app/host.js index fee19919..79d38ff4 100644 --- a/public/js/render/app/host.js +++ b/public/js/render/app/host.js @@ -4,8 +4,22 @@ import { createAssets, assets as defaultAssets } from '../../assets.js'; import { standInRecord } from '../../../../shared/standIn.js'; import { diyRecordOf } from '../../../../shared/diy.js'; -function withTimeout(p, ms) { - return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]); +/** Wait for optional assets, or stop waiting when this view is cancelled. Shared loads keep running. */ +function withTimeout(p, ms, signal) { + let timer; + let onAbort; + const stopped = new Promise((resolve, reject) => { + if (ms != null) timer = setTimeout(resolve, ms); + if (signal) { + onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + } + }); + return Promise.race([p, stopped]).finally(() => { + clearTimeout(timer); + if (onAbort) signal.removeEventListener('abort', onAbort); + }); } /** Wrap whatever the caller passed as `assets` into the store API of public/js/assets.js. */ diff --git a/public/js/ui/fieldHost.js b/public/js/ui/fieldHost.js index eeec6551..f40e4db4 100644 --- a/public/js/ui/fieldHost.js +++ b/public/js/ui/fieldHost.js @@ -132,8 +132,32 @@ function renderPref() { return globalThis.__SP_RENDER__ === 'fallback' ? 'fallback' : 'auto'; } -function withTimeout(p, ms, what) { - return Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`${what} timed out`)), ms))]); +function abortError(signal) { + return signal?.reason ?? new DOMException('Field view initialization cancelled', 'AbortError'); +} + +function checkActive(signal) { + if (signal?.aborted) throw abortError(signal); +} + +function withTimeout(p, ms, what, signal) { + return new Promise((resolve, reject) => { + let settled = false; + let timer; + const finish = (done, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + done(value); + }; + const onAbort = () => finish(reject, abortError(signal)); + signal?.addEventListener('abort', onAbort, { once: true }); + timer = setTimeout(() => finish(reject, new Error(`${what} timed out`)), ms); + // Keep a rejection handler attached even when cancellation wins before this promise settles. + Promise.resolve(p).then((value) => finish(resolve, value), (err) => finish(reject, err)); + if (signal?.aborted) onAbort(); + }); } /** @@ -186,34 +210,55 @@ export function seedAssets(store) { /** * Create a field view in `host`: the render engine when available, else the DOM fallback. * @param {HTMLElement} host + * @param {{ signal?: AbortSignal }} [options] Cancels a pending mount without creating a fallback. * @returns {Promise>} */ -export async function mountFieldView(host) { +export async function mountFieldView(host, { signal } = {}) { + checkActive(signal); const pref = renderPref(); const opts = { data, assets: data.get('assets'), audio, settings: settingsStore.get(), padding: hudPadding, hud: hudBands }; if (pref !== 'fallback') { + const controller = new AbortController(); + const onAbort = () => controller.abort(abortError(signal)); + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) onAbort(); + let engineView = null; + const dispose = (view) => { try { view?.destroy?.(); } catch { /* ignore */ } }; try { + checkActive(controller.signal); // the shared asset store (public/js/assets.js) keeps its Spine cache across remounts (next match, reconnect) - const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import').catch(() => null); + const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import', controller.signal).catch(() => { + checkActive(controller.signal); + return null; + }); + checkActive(controller.signal); if (am?.assets && typeof am.assets.ready === 'function') { opts.assets = am.assets; seedAssets(am.assets); } - const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import'); + const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import', controller.signal); + checkActive(controller.signal); if (typeof mod?.createFieldView !== 'function') throw new Error('createFieldView missing'); - const view = await withTimeout(Promise.resolve(mod.createFieldView(host, opts)), LOAD_TIMEOUT_MS, 'createFieldView'); + // A factory that ignores cancellation still belongs to this attempt when it eventually returns. + const pending = Promise.resolve(mod.createFieldView(host, { ...opts, signal: controller.signal })).then((view) => { + if (controller.signal.aborted) dispose(view); else engineView = view; + return view; + }); + const view = await withTimeout(pending, LOAD_TIMEOUT_MS, 'createFieldView', controller.signal); + checkActive(controller.signal); const missing = METHODS.filter((k) => typeof view?.[k] !== 'function'); - if (missing.length) { - try { view?.destroy?.(); } catch { /* ignore */ } - throw new Error(`view lacks ${missing.join(', ')}`); - } + if (missing.length) throw new Error(`view lacks ${missing.join(', ')}`); return guardView(view, 'engine'); } catch (err) { + controller.abort(); + dispose(engineView); + checkActive(signal); console.warn('[field] render engine unavailable, using the simplified view:', err?.message || err); - // anything the engine left behind in the host goes - try { while (host.firstChild) host.removeChild(host.firstChild); } catch { /* ignore */ } + } finally { + signal?.removeEventListener('abort', onAbort); } } + checkActive(signal); return guardView(createFallbackView(host, { ...opts, assets: data.get('assets') }), 'fallback'); } @@ -228,17 +273,19 @@ export function useFieldView(hostRef) { let dead = false; const host = hostRef.current; if (!host) return undefined; - mountFieldView(host).then((view) => { + const controller = new AbortController(); + mountFieldView(host, { signal: controller.signal }).then((view) => { if (dead) { view.destroy(); return; } viewRef.current = view; globalThis.__SP_VIEW__ = view; // dev / E2E introspection (view.raw.stats?.()) setState({ view, kind: view.kind }); - }, (err) => console.error('[field] mount failed', err)); + }, (err) => { if (!dead && !controller.signal.aborted) console.error('[field] mount failed', err); }); const unsub = settingsStore.subscribe((s) => viewRef.current?.setSettings?.(s)); const onResize = () => viewRef.current?.resize(); window.addEventListener('resize', onResize); return () => { dead = true; + controller.abort(); unsub(); window.removeEventListener('resize', onResize); // the dev hook must not keep the destroyed view — and through its host the whole detached match screen — alive diff --git a/test/render/initialization.browser.test.js b/test/render/initialization.browser.test.js new file mode 100644 index 00000000..b8ba3d17 --- /dev/null +++ b/test/render/initialization.browser.test.js @@ -0,0 +1,343 @@ +// Real renderer startup and cancellation, without downloaded art. +// RENDER_E2E=1 node --test test/render/initialization.browser.test.js +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const OUT = path.join(ROOT, 'test/e2e/out'); +const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const skip = process.env.RENDER_E2E === '1' && existsSync(CHROME) ? false : 'set RENDER_E2E=1 (needs Chrome)'; +const HTML = ` +
`; + +// These wrappers keep the real browser/Pixi behavior and record resources owned by each view. +function trackBrowserResources() { + const state = window.__initialization = { apps: [], observers: [], listeners: [], calls: 0, settled: 0 }; + const timers = new Map(); + const set = window.setTimeout.bind(window), clear = window.clearTimeout.bind(window); + window.setTimeout = (fn, ms, ...args) => { + const id = set(() => { timers.delete(id); fn(...args); }, ms); + if (ms === 12000) timers.set(id, () => fn(...args)); + return id; + }; + window.clearTimeout = (id) => { timers.delete(id); clear(id); }; + state.expire = () => { + for (const [id, fn] of [...timers]) { clear(id); timers.delete(id); fn(); } + }; + const NativeObserver = window.ResizeObserver; + window.ResizeObserver = class extends NativeObserver { + constructor(fn) { super(fn); this.targets = new Set(); state.observers.push(this); } + observe(target, ...args) { this.targets.add(target); return super.observe(target, ...args); } + unobserve(target) { this.targets.delete(target); return super.unobserve(target); } + disconnect() { this.targets.clear(); return super.disconnect(); } + }; + const add = EventTarget.prototype.addEventListener, remove = EventTarget.prototype.removeEventListener; + const tracked = (target, type) => target instanceof HTMLCanvasElement || + (target === document && type === 'visibilitychange') || (target === window && type === 'resize'); + const capture = (opts) => typeof opts === 'boolean' ? opts : !!opts?.capture; + EventTarget.prototype.addEventListener = function (type, fn, opts) { + if (tracked(this, type) && !state.listeners.some((r) => r.target === this && r.type === type && r.fn === fn && r.capture === capture(opts))) { + state.listeners.push({ target: this, type, fn, capture: capture(opts) }); + } + return add.call(this, type, fn, opts); + }; + EventTarget.prototype.removeEventListener = function (type, fn, opts) { + state.listeners = state.listeners.filter((r) => !(r.target === this && r.type === type && r.fn === fn && r.capture === capture(opts))); + return remove.call(this, type, fn, opts); + }; + state.snapshot = () => ({ + apps: state.apps.map(({ app, ticker, canvas }) => ({ destroyed: app.renderer === null, ticking: ticker.started, attached: canvas.isConnected })), + observers: state.observers.filter((r) => r.targets.size).length, + listeners: state.listeners.map((r) => r.type), + canvases: document.querySelectorAll('#host canvas').length, + fallbacks: document.querySelectorAll('#host .ff').length, + }); +} + +function trackPixiApplications() { + const Application = PIXI.Application; + PIXI.Application = class extends Application { + constructor(...args) { + super(...args); + window.__initialization.apps.push({ app: this, ticker: this.ticker, canvas: this.view }); + } + }; +} + +const FACTORY_WRAPPER = `export * from '/js/render/app.js?implementation'; +import { createFieldView as create } from '/js/render/app.js?implementation'; +export async function createFieldView(...args) { + window.__initialization.calls++; + if (window.__initialization.ignoreSignal) args[1] = { ...args[1], signal: undefined }; + try { return await create(...args); } + finally { window.__initialization.settled++; } +}`; + +describe('field initialization in headless Chrome', { skip }, () => { + let srv, browser, pixi; + before(async () => { + const puppeteer = (await import('puppeteer-core')).default; + const { startServer } = await import('../../server/index.js'); + srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] }); + pixi = readFileSync(path.join(ROOT, 'public/vendor/pixi.min.js'), 'utf8') + `\n;(${trackPixiApplications.toString()})();`; + }); + after(async () => { await browser?.close(); await srv?.close(); }); + + async function open(t, { holdPixi = false } = {}) { + const page = await browser.newPage(); + t.after(() => page.close()); + const errors = []; + page.on('pageerror', (err) => errors.push(err.message)); + await page.setViewport({ width: 1100, height: 750 }); + await page.evaluateOnNewDocument(trackBrowserResources); + await page.setRequestInterception(true); + let releasePixi; + const requested = new Promise((resolve) => { + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.pathname === '/initialization-test') return request.respond({ status: 200, contentType: 'text/html', body: HTML }); + if (url.pathname === '/js/render/app.js' && !url.search) return request.respond({ status: 200, contentType: 'text/javascript', body: FACTORY_WRAPPER }); + if (url.pathname === '/vendor/pixi.min.js') { + releasePixi = () => request.respond({ status: 200, contentType: 'text/javascript', body: pixi }); + resolve(); + if (!holdPixi) return releasePixi(); + return; + } + return request.continue(); + }); + }); + await page.goto(`http://127.0.0.1:${srv.port}/initialization-test?board=2d`); + // Empty manifests prevent optional local art requests while preserving the real asset store. + await page.evaluate(async () => { + const { assets } = await import('/js/assets.js'); + assets.seed({ version: 1, chars: {}, enemies: {}, tokens: {}, items: {} }); + assets.seedLocal({ version: 1, files: {} }); + const { data } = await import('/js/data.js'); + await data.load('stages'); + window.__initialization.stage = data.list('stages')[0]; + window.__initialization.host = document.getElementById('host'); + }); + return { page, errors, requested, release: () => releasePixi() }; + } + + async function start(page) { + await page.evaluate(async () => { + const { mountFieldView } = await import('/js/ui/fieldHost.js'); + const s = window.__initialization; + s.mount = mountFieldView(s.host).then((view) => { + s.view = view; + view.setStage(s.stage); + view.setPrep({ board: [], hand: [], temp: [] }, { editable: true }); + s.clicks = []; + view.on('tileClick', (click) => s.clicks.push([click.row, click.col])); + }); + }); + } + + async function pixiFinished(page) { + await page.evaluate(async () => { + const { ensurePixi } = await import('/js/render/app/pixi.js'); + await ensurePixi(); + await new Promise(requestAnimationFrame); + }); + } + + async function clickFallback(page) { + await page.waitForSelector('#host .ff-tile--floor'); + const tile = await page.$eval('#host .ff-tile--floor', (element) => { + const r = element.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2, row: +element.dataset.row, col: +element.dataset.col }; + }); + await page.mouse.click(tile.x, tile.y); + return [tile.row, tile.col]; + } + + test('successful startup owns one running app and destroy releases its browser resources', async (t) => { + const { page, errors } = await open(t); + await start(page); + await page.waitForFunction(() => window.__initialization.view?.kind === 'engine'); + const mounted = await page.evaluate(() => window.__initialization.snapshot()); + assert.equal(mounted.canvases, 1); + assert.deepEqual(mounted.apps, [{ destroyed: false, ticking: true, attached: true }]); + assert.equal(mounted.observers, 1); + assert.ok(mounted.listeners.includes('pointerdown')); + assert.ok(mounted.listeners.includes('visibilitychange')); + const destroyed = await page.evaluate(() => { + const s = window.__initialization; + s.view.destroy(); s.view.destroy(); + return s.snapshot(); + }); + assert.deepEqual(destroyed, { apps: [{ destroyed: true, ticking: false, attached: false }], observers: 0, listeners: [], canvases: 0, fallbacks: 0 }); + assert.deepEqual(errors, []); + }); + + for (const ignoreSignal of [false, true]) test(`a timed-out Pixi load cannot cover the clickable fallback (${ignoreSignal ? 'legacy factory ignores cancellation' : 'cancellable factory'})`, async (t) => { + const { page, errors, requested, release } = await open(t, { holdPixi: true }); + await page.evaluate((value) => { window.__initialization.ignoreSignal = value; }, ignoreSignal); + await start(page); + await requested; + await page.evaluate(() => window.__initialization.expire()); + await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback'); + const target = await clickFallback(page); + assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]); + await release(); + await pixiFinished(page); + await page.waitForFunction(() => window.__initialization.settled === 1); + await clickFallback(page); + const late = await page.evaluate(() => ({ ...window.__initialization.snapshot(), clicks: window.__initialization.clicks })); + assert.deepEqual(late.clicks, [target, target], 'real pointer input still reaches the fallback after Pixi finishes'); + assert.equal(late.canvases, 0, 'the abandoned engine must not append an orphan canvas'); + assert.equal(late.fallbacks, 1); + assert.ok(late.apps.every((app) => app.destroyed && !app.ticking && !app.attached)); + assert.deepEqual(late.listeners, []); + assert.equal(late.observers, 1, 'only the fallback observes the host'); + if (!ignoreSignal) { + mkdirSync(OUT, { recursive: true }); + await page.screenshot({ path: path.join(OUT, 'render-initialization-fallback.png') }); + } + const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); }); + assert.equal(destroyed.observers, 0); + assert.equal(destroyed.canvases, 0); + assert.equal(destroyed.fallbacks, 0); + assert.deepEqual(errors, []); + }); + + test('timeout during board-art startup immediately releases the allocated app and permits a fresh engine', async (t) => { + const { page, errors } = await open(t); + await page.evaluate(async () => { + const { assets } = await import('/js/assets.js'); + const s = window.__initialization, local = assets.local.bind(assets); + assets.local = () => { + if (!s.apps.length) return local(); + assets.local = local; + s.artBlocked = true; + return new Promise((resolve) => { s.releaseArt = async () => resolve(await local()); }); + }; + }); + await start(page); + await page.waitForFunction(() => window.__initialization.artBlocked); + const allocated = await page.evaluate(() => window.__initialization.snapshot()); + assert.deepEqual(allocated.apps, [{ destroyed: false, ticking: true, attached: true }]); + await page.evaluate(() => window.__initialization.expire()); + await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback'); + const cancelled = await page.evaluate(() => window.__initialization.snapshot()); + assert.deepEqual(cancelled.apps, [{ destroyed: true, ticking: false, attached: false }], 'cancellation frees the real renderer before the delayed art resolves'); + assert.equal(cancelled.canvases, 0); + assert.equal(cancelled.observers, 1); + assert.deepEqual(cancelled.listeners, []); + const target = await clickFallback(page); + assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]); + await page.evaluate(async () => { + const s = window.__initialization; + await s.releaseArt(); + s.view.destroy(); + s.view = null; + }); + await page.waitForFunction(() => window.__initialization.settled === 1); + await start(page); + await page.waitForFunction(() => window.__initialization.view?.kind === 'engine'); + const replacement = await page.evaluate(async () => { + await new Promise(requestAnimationFrame); + const s = window.__initialization, app = s.view.raw.debug.app; + app.renderer.render(app.stage); + return s.snapshot(); + }); + assert.deepEqual(replacement.apps, [ + { destroyed: true, ticking: false, attached: false }, + { destroyed: false, ticking: true, attached: true }, + ], 'a replacement engine can render with the surviving shared caches'); + assert.equal(replacement.canvases, 1); + const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); }); + assert.ok(destroyed.apps.every((app) => app.destroyed && !app.ticking && !app.attached)); + assert.equal(destroyed.observers, 0); + assert.deepEqual(destroyed.listeners, []); + assert.equal(destroyed.canvases, 0); + assert.deepEqual(errors, []); + }); + + test('a setup exception releases partial resources without removing another host child', async (t) => { + const { page, errors } = await open(t); + await page.evaluate(async () => { + const { assets } = await import('/js/assets.js'); + const s = window.__initialization, subscribe = assets.onChange.bind(assets); + s.sibling = document.createElement('span'); + s.sibling.textContent = 'Existing host content'; + s.host.appendChild(s.sibling); + // Fail after input and resize resources exist, while the real view is still being built. + assets.onChange = (fn) => { + if (s.observers.some((observer) => observer.targets.has(s.host))) { + assets.onChange = subscribe; + throw new Error('asset subscription unavailable'); + } + return subscribe(fn); + }; + }); + await start(page); + await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback'); + const failed = await page.evaluate(() => ({ ...window.__initialization.snapshot(), sibling: window.__initialization.sibling.isConnected })); + assert.deepEqual(failed.apps, [{ destroyed: true, ticking: false, attached: false }]); + assert.equal(failed.sibling, true, 'failed initialization only cleans up its own resources'); + assert.equal(failed.canvases, 0); + assert.equal(failed.observers, 1); + assert.deepEqual(failed.listeners, []); + const target = await clickFallback(page); + assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]); + const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); }); + assert.equal(destroyed.observers, 0); + assert.equal(destroyed.fallbacks, 0); + assert.deepEqual(errors, []); + }); + + test('unmount during startup cannot erase a newer hook mount on the same host', async (t) => { + const { page, errors, requested, release } = await open(t, { holdPixi: true }); + await page.evaluate(async () => { + const { h, render } = await import('/vendor/preact.module.js'); + const { useFieldView } = await import('/js/ui/fieldHost.js'); + const s = window.__initialization; + function Field() { s.hook = useFieldView({ current: s.host }); return null; } + s.renderHook = (key) => render(key ? h(Field, { key }) : null, document.getElementById('hooks')); + s.renderHook('first'); + }); + await requested; + await page.evaluate(() => { + const s = window.__initialization; + s.renderHook(null); + window.__SP_RENDER__ = 'fallback'; + s.renderHook('second'); + }); + await page.waitForFunction(() => window.__initialization.hook?.kind === 'fallback'); + await page.evaluate(() => { + const s = window.__initialization; + s.current = s.hook.view; + s.current.setStage(s.stage); + s.current.setPrep({ board: [], hand: [], temp: [] }, { editable: true }); + s.clicks = []; + s.current.on('tileClick', (click) => s.clicks.push([click.row, click.col])); + s.expire(); + }); + await release(); + await pixiFinished(page); + await page.waitForFunction(() => window.__initialization.settled === 1); + const late = await page.evaluate(() => ({ ...window.__initialization.snapshot(), current: window.__SP_VIEW__ === window.__initialization.current })); + assert.equal(late.fallbacks, 1, 'the old mount must not clear the newer view from the shared host'); + assert.equal(late.canvases, 0); + assert.equal(late.current, true); + assert.ok(late.apps.every((app) => app.destroyed && !app.ticking && !app.attached)); + const target = await clickFallback(page); + assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]); + const destroyed = await page.evaluate(() => { + window.__initialization.renderHook(null); + return { ...window.__initialization.snapshot(), current: window.__SP_VIEW__ }; + }); + assert.equal(destroyed.current, null); + assert.equal(destroyed.observers, 0); + assert.equal(destroyed.canvases, 0); + assert.equal(destroyed.fallbacks, 0); + assert.deepEqual(destroyed.listeners, []); + assert.deepEqual(errors, []); + }); +}); diff --git a/test/ui/fieldHost-lifecycle.test.js b/test/ui/fieldHost-lifecycle.test.js new file mode 100644 index 00000000..721d56d2 --- /dev/null +++ b/test/ui/fieldHost-lifecycle.test.js @@ -0,0 +1,158 @@ +// Field-host ownership under cancellation and timeout. Browser tests cover the actual renderer; this fixture runs +// the production orchestration with controlled imports and timers, without a DOM or GPU dependency. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { getEventListeners } from 'node:events'; +import vm from 'node:vm'; + +const sourcePath = new URL('../../public/js/ui/fieldHost.js', import.meta.url); +const source = readFileSync(sourcePath, 'utf8') + .replace(/^import .*;$/gm, '').replace(/^export /gm, '') + .replace("import('../assets.js')", 'importAssets()') + .replace("import('../render/app.js')", 'importApp()'); +const METHODS = ['setStage', 'setCamera', 'setPrep', 'enterBattle', 'pushSnapshot', 'pushEvents', 'highlightTiles', 'on', 'resize']; +const flush = async () => { for (let i = 0; i < 16; i++) await Promise.resolve(); }; + +function fixture() { + const timers = new Map(), requests = [], fallbacks = [], warnings = []; + let timerId = 0; + const unrelated = { name: 'unrelated' }; + const host = { + children: [unrelated], + get firstChild() { return this.children[0] || null; }, + appendChild(node) { this.children.push(node); }, + removeChild(node) { const i = this.children.indexOf(node); if (i >= 0) this.children.splice(i, 1); }, + }; + function view(name, valid = true) { + const node = { name }; + host.appendChild(node); + return { + ...(valid ? Object.fromEntries(METHODS.map((k) => [k, () => {}])) : {}), + node, destroyCalls: 0, + destroy() { this.destroyCalls++; host.removeChild(node); }, + }; + } + const context = vm.createContext({ + AbortController, DOMException, URLSearchParams, Promise, + console: { warn: (...args) => warnings.push(args) }, + audio: {}, data: { get() { return null; } }, settingsStore: { get() { return {}; } }, + setTimeout(fn) { timers.set(++timerId, fn); return timerId; }, + clearTimeout(id) { timers.delete(id); }, + importAssets: async () => null, + importApp: async () => ({ + createFieldView(_host, opts) { + // Deliberately ignore cancellation: the host must still dispose a factory's late return value. + return new Promise((resolve, reject) => requests.push({ resolve, reject, signal: opts.signal })); + }, + }), + createFallbackView() { + const fallback = view('fallback'); + fallbacks.push({ view: fallback, rendererAborted: requests.at(-1)?.signal?.aborted }); + return fallback; + }, + }); + vm.runInContext(source + '\nglobalThis.mount = mountFieldView;', context, { filename: sourcePath.pathname }); + return { + host, unrelated, timers, requests, fallbacks, warnings, view, + mount: (signal) => context.mount(host, { signal }), + timeout() { const fn = [...timers.values()].at(-1); assert.ok(fn, 'initialization has a deadline'); fn(); }, + }; +} + +function settled(promise) { + const result = { state: 'pending', value: null }; + promise.then((value) => { result.state = 'fulfilled'; result.value = value; }, (error) => { result.state = 'rejected'; result.value = error; }); + return result; +} + +test('successful initialization releases deadlines and cancellation listeners', async () => { + const f = fixture(), controller = new AbortController(); + const pending = f.mount(controller.signal); + await flush(); + const raw = f.view('engine'); + f.requests[0].resolve(raw); + const mounted = await pending; + assert.equal(mounted.kind, 'engine'); + assert.equal(f.timers.size, 0); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + assert.equal(getEventListeners(f.requests[0].signal, 'abort').length, 0); + mounted.destroy(); + assert.equal(raw.destroyCalls, 1); +}); + +test('cancelling a pending mount rejects without fallback and disposes a late result', async () => { + const f = fixture(), controller = new AbortController(); + const result = settled(f.mount(controller.signal)); + await flush(); + controller.abort(); + await flush(); + assert.equal(result.state, 'rejected'); + assert.equal(result.value.name, 'AbortError'); + assert.equal(f.fallbacks.length, 0); + assert.equal(f.warnings.length, 0); + assert.equal(f.timers.size, 0); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + const late = f.view('late'); + f.requests[0].resolve(late); + await flush(); + assert.equal(late.destroyCalls, 1); + assert.deepEqual(f.host.children, [f.unrelated]); +}); + +test('cancellation between factory resolution and mount continuation disposes the resolved view', async () => { + const f = fixture(), controller = new AbortController(); + const result = settled(f.mount(controller.signal)); + await flush(); + const raw = f.view('settling'); + f.requests[0].resolve(raw); + await Promise.resolve(); + controller.abort(); + await flush(); + assert.equal(result.state, 'rejected'); + assert.equal(result.value.name, 'AbortError'); + assert.equal(raw.destroyCalls, 1); + assert.equal(f.fallbacks.length, 0); + assert.equal(f.timers.size, 0); + assert.deepEqual(f.host.children, [f.unrelated]); +}); + +test('an invalid renderer API is disposed and aborted before fallback mounts', async () => { + const f = fixture(); + const pending = f.mount(); + await flush(); + const invalid = f.view('invalid', false); + f.requests[0].resolve(invalid); + const fallback = await pending; + assert.equal(fallback.kind, 'fallback'); + assert.equal(invalid.destroyCalls, 1); + assert.equal(f.fallbacks[0].rendererAborted, true); + assert.equal(f.timers.size, 0); + assert.deepEqual(f.host.children, [f.unrelated, f.fallbacks[0].view.node]); + fallback.destroy(); +}); + +test('a timed-out factory returning late cannot remove a replacement view or unrelated children', async () => { + const f = fixture(); + const first = f.mount(); + await flush(); + f.timeout(); + const fallback = await first; + assert.equal(fallback.kind, 'fallback'); + fallback.destroy(); + const replacementPending = f.mount(); + await flush(); + const replacement = f.view('replacement'); + f.requests[1].resolve(replacement); + const mounted = await replacementPending; + const late = f.view('late'); + f.requests[0].resolve(late); + await flush(); + assert.equal(late.destroyCalls, 1); + assert.equal(replacement.destroyCalls, 0); + assert.equal(f.fallbacks[0].rendererAborted, true); + assert.equal(f.timers.size, 0); + assert.deepEqual(f.host.children, [f.unrelated, replacement.node]); + mounted.destroy(); +}); diff --git a/test/ui/playtest2.test.js b/test/ui/playtest2.test.js index 8b5e2f16..7cff96b6 100644 --- a/test/ui/playtest2.test.js +++ b/test/ui/playtest2.test.js @@ -126,7 +126,7 @@ describe('8: the detail card never covers the selected unit\'s underframe', () = const { readFileSync } = await import('node:fs'); const app = readFileSync(new URL('../../public/js/render/app.js', import.meta.url), 'utf8'); assert.match(app, /\n {2}canvas\.addEventListener\('click', onTapTarget\);\n/, 'registered with the other canvas listeners'); - assert.match(app, /\n {6}canvas\.removeEventListener\('click', onTapTarget\);\n/, 'dropped on destroy'); + assert.match(app, /\n +canvas\.removeEventListener\('click', onTapTarget\);\n/, 'dropped on destroy'); assert.match(app, /const onTapTarget = \(\) => \{\};/, 'a no-op: the press itself stays with the pointer events'); });