diff --git a/public/js/assets.js b/public/js/assets.js index cebbf086..03c7c0f8 100644 --- a/public/js/assets.js +++ b/public/js/assets.js @@ -19,6 +19,9 @@ // and the quiet budget emptied the cache ≈ 18 s later — back in the tab every unit was an avatar diamond until its // model downloaded again (public issue #8 item 5). The idle budget still applies while it holds. // A model that fails or times out is retried by its view (bounded, render/units.js), `acquire(entry, { retry: true })`. +// A load that may never settle (GitHub #68: begun in a hidden tab, still pending when the tab came back) is started again +// by its view (`spine.restart(entry)`, RefLru.restart); such a fresh load — and any load after a failure — drops PIXI's +// still-pending requests of that skeleton first (forgetPendingSpine), so it does not join them. // // Pure at import time: no PIXI, no DOM access until a loader actually runs (Node tests import this file). // @@ -237,7 +240,11 @@ export function unitSfxUrl(m, id, kind, skillIndex) { * Entries with refs > 0 are never evicted; the cache may exceed `max` while everything is in use. * Failures are remembered for `failTtl` ms (so a missing model is not refetched every frame) and rethrown — except for * `acquire(key, arg, { retry: true })`, which drops a remembered failure nobody references and loads again (a view - * retrying a failed or timed-out model: render/units.js). + * retrying a failed or timed-out model: render/units.js). `restart(key, arg)` starts a load still in flight again (GitHub + * #68: one begun in a hidden tab may never settle): a new attempt of the same entry — its refs and the promise its + * holders wait on stay —, the earlier attempt is abandoned (its concurrency slot freed, a late result ignored: PIXI shares + * its resources by URL, so nothing is unloaded), the first attempt to succeed settles the entry and only the newest + * attempt's failure fails it. A load after a failure and a restart are `fresh` (`load(key, arg, { fresh: true })`). * Memory budget (optional): `weigh(key, value, arg)` → cost of a ready value; idle (refs 0) ready entries are evicted, * least recently used first, while their total weight exceeds `maxIdleWeight` — or `quietWeight` once nothing at all * has been referenced and no scene holds the cache (`hold()`) for `quietDelay` ms (no scene on screen; the instant zero @@ -305,29 +312,14 @@ export class RefLru { */ acquire(key, arg, opts) { let e = this.map.get(key); - if (e && e.state === 'failed' && (this.now() - e.failedAt > this.failTtl || (opts && opts.retry && e.refs === 0))) { this.map.delete(key); e = null; } + let fresh = false; + if (e && e.state === 'failed' && (this.now() - e.failedAt > this.failTtl || (opts && opts.retry && e.refs === 0))) { this.map.delete(key); e = null; fresh = true; } if (!e) { - e = { key, promise: null, value: null, state: 'loading', refs: 0, used: ++this._tick, weight: 0, idleSince: null }; + e = { key, promise: null, value: null, state: 'loading', refs: 0, used: ++this._tick, weight: 0, idleSince: null, attempt: 0, abort: null, settle: null }; this.map.set(key, e); - // an unload of this key still in flight: load again only once it has settled (never the doomed value) - const load = () => { - const pending = this._unloading.get(key); - return pending ? pending.then(() => this._load(key, arg)) : this._load(key, arg); - }; - e.promise = this._schedule(() => this._withTimeout(load(), key)).then( - (v) => { - if (this.map.get(key) !== e) { this._unloadNow(key, v, e); return v; } - e.value = v; e.state = 'ready'; - if (this._weigh) { let w = 0; try { w = Number(this._weigh(key, v, arg)); } catch { /* ignore */ } e.weight = w > 0 ? w : 0; } - this._requestEvict(); - return v; - }, - (err) => { - e.state = 'failed'; e.error = err; e.failedAt = this.now(); - throw err; - }, - ); + e.promise = new Promise((resolve, reject) => { e.settle = { resolve, reject }; }); e.promise.catch(() => {}); + this._attempt(e, key, arg, fresh); } e.refs++; this._refs++; @@ -337,6 +329,51 @@ export class RefLru { return e.promise; } + /** + * Start the load of `key` again while it is still in flight (see the header); false when it is not loading (ready, + * failed or unknown). + */ + restart(key, arg) { + const e = this.map.get(key); + if (!e || e.state !== 'loading' || !e.settle) return false; + this._attempt(e, key, arg, true); + return true; + } + + /** One load attempt of entry `e` (see the header): the first success settles it, only the newest attempt's failure. */ + _attempt(e, key, arg, fresh) { + const n = ++e.attempt; + if (e.abort) { const abort = e.abort; e.abort = null; abort(); } + // an unload of this key still in flight: load again only once it has settled (never the doomed value) + const load = () => { + const pending = this._unloading.get(key); + const go = () => this._load(key, arg, fresh ? { fresh: true } : undefined); + return pending ? pending.then(go) : go(); + }; + const run = () => (n === e.attempt + ? this._withTimeout(load(), key, (abort) => { if (n === e.attempt) e.abort = abort; }) + : Promise.reject(new Error(`load superseded: ${key}`))); + this._schedule(run).then( + (v) => { + const s = e.settle; + if (!s) return; // settled by another attempt: a late duplicate (PIXI shares its resources by URL — keep them) + e.settle = null; e.abort = null; + if (this.map.get(key) !== e) { this._unloadNow(key, v, e); s.resolve(v); return; } + e.value = v; e.state = 'ready'; + if (this._weigh) { let w = 0; try { w = Number(this._weigh(key, v, arg)); } catch { /* ignore */ } e.weight = w > 0 ? w : 0; } + this._requestEvict(); + s.resolve(v); + }, + (err) => { + const s = e.settle; + if (!s || n !== e.attempt) return; // an abandoned attempt: the newest one decides + e.settle = null; e.abort = null; + e.state = 'failed'; e.error = err; e.failedAt = this.now(); + s.reject(err); + }, + ); + } + /** Drop one reference (never below 0). */ release(key) { const e = this.map.get(key); @@ -477,11 +514,14 @@ export class RefLru { }); } - _withTimeout(p, key) { - if (!(this.timeout > 0)) return p; + /** `p` bounded by the timeout; `onAbort(abort)` receives a function that rejects it at once (a restart). */ + _withTimeout(p, key, onAbort) { + if (!(this.timeout > 0) && !onAbort) return p; return new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error(`load timeout: ${key}`)), this.timeout); - p.then((v) => { clearTimeout(t); resolve(v); }, (err) => { clearTimeout(t); reject(err); }); + const t = this.timeout > 0 ? setTimeout(() => reject(new Error(`load timeout: ${key}`)), this.timeout) : null; + const stop = () => { if (t !== null) clearTimeout(t); }; + if (onAbort) onAbort(() => { stop(); reject(new Error(`load restarted: ${key}`)); }); + p.then((v) => { stop(); resolve(v); }, (err) => { stop(); reject(err); }); }); } } @@ -506,15 +546,39 @@ export function loadImageElement(url) { } /** Default Spine loader: PIXI.Assets.load(skel) → spineData (needs globalThis.PIXI + PIXI.spine). */ -export async function loadSpineData(entry) { +export async function loadSpineData(entry, opts) { const PIXI = globalThis.PIXI; if (!PIXI || !PIXI.Assets || !PIXI.spine) throw new Error('PIXI / pixi-spine not loaded'); + if (opts && opts.fresh) forgetPendingSpine(entry, opts.keep); const res = await PIXI.Assets.load(entry.skel); const data = res && (res.spineData || res); if (!data || !Array.isArray(data.animations)) throw new Error(`bad spine data: ${entry.skel}`); return data; } +/** + * Forget PIXI's requests of a skeleton that never finished loading — its .skel is not in `PIXI.Assets.cache` — so a fresh + * load asks for them again instead of joining them (GitHub #68: a request begun in a hidden tab may never settle, and + * `PIXI.Assets.load` of the same URL hands out the same pending promise): the .skel's and, unless the atlas finished + * (then its pages did too), the .atlas's and its pages' entries of the loader's promise cache. Pages in `keep` (another + * cached skeleton's) stay. Returns how many were dropped; best effort (0 without that cache). + * @param {object} entry manifest spine entry { skel, atlas?, textures? } + * @param {Set} [keep] + */ +export function forgetPendingSpine(entry, keep) { + const PIXI = globalThis.PIXI; + const cache = PIXI && PIXI.Assets && PIXI.Assets.loader && PIXI.Assets.loader.promiseCache; + const skel = entry && entry.skel; + if (!skel || !cache || typeof cache !== 'object') return 0; + const done = (u) => { try { return !!PIXI.Assets.cache?.has?.(u); } catch { return true; } }; + if (done(skel)) return 0; + const atlas = (typeof entry.atlas === 'string' && entry.atlas) || skel.replace(/\.skel$/, '.atlas'); + const urls = done(atlas) ? [skel] : [skel, atlas, ...spinePages(entry)]; + let n = 0; + for (const u of urls) if (!(keep && keep.has(u)) && Object.hasOwn(cache, u)) { delete cache[u]; n++; } + return n; +} + /** * Estimated memory (bytes) held by a parsed skeleton (pixi-spine 3.8 SkeletonData): its typed/plain arrays (mesh * vertices/UVs/triangles, timeline frames/curves, deform frames, draw orders) plus a flat cost per object. A parsed @@ -722,7 +786,15 @@ export function createAssets(options) { const spineEntries = new Map(); // skel URL → manifest entry (for the atlas / page URLs on unload) const spine = new RefLru({ - load: (key, entry) => { spineEntries.set(key, entry); return (opts.loadSpine || loadSpineData)(entry); }, + load: (key, entry, o) => { + spineEntries.set(key, entry); + if (!(o && o.fresh)) return (opts.loadSpine || loadSpineData)(entry); + // a fresh load (a restart, or after a failure): not joined to a request that may never settle — but the pages + // another cached skeleton uses stay (forgetPendingSpine `keep`) + const keep = new Set(); + for (const [k, other] of spineEntries) if (k !== key && spine.map.has(k)) for (const u of spinePages(other)) keep.add(u); + return (opts.loadSpine || loadSpineData)(entry, { fresh: true, keep }); + }, unload: (key, value, rec) => { // a late value of an entry that was dropped while loading, with the key already loading/cached again: the // PIXI caches are shared by URL, so unloading would pull the resources out from under the new entry @@ -848,10 +920,12 @@ export function createAssets(options) { }, /** * Spine data LRU: acquire(entry, { retry }) → Promise (`retry`: load again after a remembered failure); - * release(entry); peek(entry); hold() → release function (a scene that will need its skeletons again: RefLru). + * restart(entry) → bool (a load still in flight starts again: RefLru.restart); release(entry); peek(entry); + * hold() → release function (a scene that will need its skeletons again: RefLru). */ spine: { acquire: (entry, o) => (validSpine(entry) ? spine.acquire(entry.skel, entry, o) : Promise.reject(new Error('no spine entry'))), + restart: (entry) => (validSpine(entry) ? spine.restart(entry.skel, entry) : false), release: (entry) => { if (entry && entry.skel) spine.release(entry.skel); }, peek: (entry) => (entry && entry.skel ? spine.peek(entry.skel) : null), hold: () => spine.hold(), diff --git a/public/js/render/units.js b/public/js/render/units.js index 33d2632f..739c3988 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -8,7 +8,9 @@ // (the plain 源石虫) is drawn tinted toward the slug's own colours (ALIAS_TINT). A model that failed or timed out is // loaded again after SPINE_RETRY_MS (bounded), and `retryAssets()` re-resolves a view's picture and model when the // asset manifest arrives after the view was built or the tab is shown again (render/app.js; public issue #8 item 5: -// after a reload whose manifest was slow or failed, every operator stayed the image-less placeholder for good). Every +// after a reload whose manifest was slow or failed, every operator stayed the image-less placeholder for good); a load +// begun while the tab was hidden and still in flight SPINE_STUCK_MS after it is shown again is started again +// (assets.js spine.restart: GitHub #68, such a load may never settle — its view stayed the placeholder). Every // bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw calls. // // Placement: feet anchored at world (x, y, z); scale = camera px-per-tile at the feet × UNIT.modelScale, so @@ -107,6 +109,13 @@ const PIC_WAIT_MS = 400; * shown again). Counted from the failure; a hidden tab runs no frames, so nothing is retried while hidden. */ export const SPINE_RETRY_MS = Object.freeze([2000, 6000, 15000, 30000]); +/** + * A Spine load begun while the tab was hidden and still in flight this long (ms, real time) after the tab is shown again + * is started again (`retryAssets` arms it, `update` fires it; GitHub #68: such a load — a request the hidden tab left + * hanging — may never settle, and the view stayed the placeholder). A load that finishes within it is never doubled. + * [ASSUMED] the length: a healthy load finishes within a few seconds once the tab is visible. + */ +export const SPINE_STUCK_MS = 5000; /** Heights above this count as standing on a raised top (bench pads are the lowest raised tiles, 0.16). */ const RAISED_Z = 0.12; @@ -365,6 +374,8 @@ export class UnitView { this._spineBusy = false; // a Spine load of this view is in flight this._spineTries = 0; // failed loads since the last model (SPINE_RETRY_MS) this._retryAt = 0; // when the next retry is due (ms, performance clock; 0 = none) + this._spineHidden = false; // the load in flight began while the tab was hidden (SPINE_STUCK_MS) + this._stuckAt = 0; // when that load is started again unless it settled (ms; 0 = none) this.baseTint = 0xffffff; // the drawn model's own tint (ALIAS_TINT), under the status tints this.hud = new P.Container(); @@ -431,12 +442,16 @@ export class UnitView { * Re-resolve what this view could not draw yet (public issue #8 item 5): the picture when it has none (no avatar URL — * the asset manifest arrived after the view was built — or the image failed) and, at once, the Spine model when none * is shown and none is loading (no manifest entry then, or a load that failed / timed out). render/app.js calls it for - * every view when a manifest arrives (assets.js onChange) and when the tab is shown again. Nothing to do otherwise. + * every view when a manifest arrives (assets.js onChange) and when the tab is shown again. A load still in flight that + * began while the tab was hidden gets SPINE_STUCK_MS from now (the tab visible) to finish, then `update` starts it again + * (GitHub #68). Nothing to do otherwise. */ retryAssets() { if (this.destroyed) return; if (!this._pic || this._pic.state === 'none') this._loadPicture(); - if (!this.actor && !this._spineBusy) { this._retryAt = 0; this._loadSpine(true); } + if (this.actor) return; + if (!this._spineBusy) { this._retryAt = 0; this._loadSpine(true); return; } + if (this._spineHidden && !this._stuckAt && !globalThis.document?.hidden) this._stuckAt = nowMs() + SPINE_STUCK_MS; } /** @@ -451,8 +466,10 @@ export class UnitView { this.entry = entry; const req = this._spineReq = (this._spineReq || 0) + 1; this._spineBusy = true; + this._spineHidden = !!globalThis.document?.hidden; + this._stuckAt = 0; a.spine.acquire(entry, retry ? { retry: true } : undefined).then((data) => { - if (req === this._spineReq) this._spineBusy = false; + if (req === this._spineReq) { this._spineBusy = false; this._stuckAt = 0; } if (this.destroyed || req !== this._spineReq) { this._releaseEntry(entry); return; } this._spineTries = 0; this._retryAt = 0; @@ -496,7 +513,7 @@ export class UnitView { if (deployed != null) { this.actor.deploy(); if (deployed > 0) this.actor.update(deployed); } } }, () => { - if (req === this._spineReq) this._spineBusy = false; + if (req === this._spineReq) { this._spineBusy = false; this._stuckAt = 0; } this._releaseEntry(entry); if (this.destroyed || req !== this._spineReq) return; if (entry.fallback) { this._acquireSpine(entry.fallback, id, retry); return; } @@ -850,6 +867,11 @@ export class UnitView { if (this._modelDirty) { this._modelDirty = false; this._syncModel(); } // a failed / timed-out model load is tried again once its wait is over (SPINE_RETRY_MS; frames only: never hidden) if (this._retryAt && nowMs() >= this._retryAt) { this._retryAt = 0; if (!this.actor && !this._spineBusy) this._loadSpine(true); } + // a load begun in a hidden tab and still in flight SPINE_STUCK_MS after it came back: started again (GitHub #68) + if (this._stuckAt && nowMs() >= this._stuckAt) { + this._stuckAt = 0; + if (!this.actor && this._spineBusy && this.entry) { this._spineHidden = false; this.ctx.assets?.spine?.restart?.(this.entry); } + } if (this.zTarget != null && this.z !== this.zTarget) { const d = this.zTarget - this.z; this.z = Math.abs(d) < 1e-3 ? this.zTarget : this.z + d * Math.min(1, dt * 12); diff --git a/test/render/feedback3-stuck-load.test.js b/test/render/feedback3-stuck-load.test.js new file mode 100644 index 00000000..397c3851 --- /dev/null +++ b/test/render/feedback3-stuck-load.test.js @@ -0,0 +1,145 @@ +// test/render/feedback3-stuck-load.test.js — GitHub #68 (0.1.3): placeholder models stayed after switching back to a +// tab that had been hidden. A Spine load begun while the tab was hidden can stay pending (a request the background tab +// left hanging); UnitView.retryAssets — called when the tab is shown — only reloaded views with no load in flight, and +// every retry joined PIXI's same pending request. Now such a load gets SPINE_STUCK_MS after the tab is visible, then it +// is started again in place (RefLru.restart: refs and the waiting promise kept, the first success wins, the abandoned +// attempt's slot freed), and a fresh load forgets PIXI's pending requests of that skeleton (forgetPendingSpine). +// Headless fake PIXI (test/render/fakepixi.js). + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { installFakePixi, fakeViewCtx } from './fakepixi.js'; +import { presetCamera } from '../../public/js/render/projection.js'; +import { createAssets, RefLru, forgetPendingSpine } from '../../public/js/assets.js'; + +const tick = () => new Promise((r) => setImmediate(r)); +const never = () => new Promise(() => {}); +const SP = (id) => ({ skel: `/assets/spine/${id}.skel`, atlas: `/assets/spine/${id}.atlas`, textures: [`/assets/spine/${id}.png`], anims: { idle: 'Idle' }, animations: { Idle: 1 } }); +const M = { chars: { char_002_amiya: { avatar: '/a/amiya.png', spine: { front: SP('amiya_f') } } }, ui: {} }; + +describe('RefLru.restart', () => { + test('a load in flight starts again in place: the holders\' promise settles with the new attempt, refs kept, fresh', async () => { + const calls = []; + let resolveOld; + const lru = new RefLru({ timeout: 0, load: (k, arg, o) => { calls.push(o?.fresh ? 'fresh' : 'plain'); return calls.length === 1 ? new Promise((r) => { resolveOld = r; }) : Promise.resolve(`${k}#2`); }, unload: () => { throw new Error('nothing to unload'); } }); + const p = lru.acquire('k'); + assert.equal(lru.restart('k'), true); + assert.equal(await p, 'k#2', 'the waiting holder gets the restarted load'); + assert.deepEqual(calls, ['plain', 'fresh']); + assert.equal(lru.map.get('k').refs, 1); + resolveOld('k#1'); // the abandoned request settles late: ignored, nothing unloaded + await tick(); + assert.equal(lru.peek('k'), 'k#2'); + assert.equal(lru.restart('k'), false, 'ready: nothing to restart'); + assert.equal(lru.restart('nope'), false); + }); + + test('the abandoned attempt frees its concurrency slot; only the newest attempt\'s failure fails the entry', async () => { + const started = []; + const lru = new RefLru({ timeout: 0, concurrency: 1, load: (k) => { started.push(k); return k === 'a' && started.filter((x) => x === 'a').length === 1 ? never() : Promise.resolve(k); } }); + const pa = lru.acquire('a'); + const pb = lru.acquire('b'); + await tick(); + assert.deepEqual(started, ['a'], 'b waits behind the hung load of a'); + lru.restart('a'); + assert.equal(await pb, 'b', 'the hung attempt\'s slot is freed at once'); + assert.equal(await pa, 'a'); + // failures: an abandoned attempt failing changes nothing, the newest one failing fails the entry + let n = 0, rejectOld; + const f = new RefLru({ timeout: 0, load: () => (++n === 1 ? new Promise((_, rej) => { rejectOld = rej; }) : Promise.reject(new Error('second'))) }); + const pf = f.acquire('x'); + f.restart('x'); + await assert.rejects(pf, /second/); + rejectOld(new Error('first')); + await tick(); + assert.equal(f.map.get('x').error.message, 'second'); + }); +}); + +test('forgetPendingSpine: PIXI\'s pending requests of an unfinished skeleton go, finished ones and kept pages stay', () => { + const prev = globalThis.PIXI; + const e = SP('x'); + const page = e.textures[0]; + const setup = (done) => { + const promiseCache = { [e.skel]: {}, [e.atlas]: {}, [page]: {}, other: {} }; + globalThis.PIXI = { Assets: { loader: { promiseCache }, cache: new Map(done.map((u) => [u, {}])) } }; + return promiseCache; + }; + try { + let c = setup([]); + assert.equal(forgetPendingSpine(e), 3); + assert.deepEqual(Object.keys(c), ['other']); + c = setup([e.atlas]); + assert.equal(forgetPendingSpine(e), 1, 'the atlas (and so its pages) finished: only the .skel'); + assert.ok(c[e.atlas] && c[page]); + c = setup([e.skel]); + assert.equal(forgetPendingSpine(e), 0, 'loaded: nothing pending'); + c = setup([]); + forgetPendingSpine(e, new Set([page])); + assert.ok(c[page], 'a page another cached skeleton uses stays'); + globalThis.PIXI = {}; + assert.equal(forgetPendingSpine(e), 0, 'no loader cache: nothing'); + } finally { globalThis.PIXI = prev; } +}); + +describe('views restart a load begun in a hidden tab (render/units.js)', () => { + let fake, UnitView, SPINE_STUCK_MS; + before(async () => { + fake = installFakePixi(); + ({ UnitView, SPINE_STUCK_MS } = await import('../../public/js/render/units.js')); + }); + after(() => fake.restore()); + const cam = () => presetCamera('prep', { width: 1280, height: 720 }); + const unit = (assets) => new UnitView(fakeViewCtx(fake.P, { assets, cam }), { id: 1, side: 'ally', kind: 'chess', defId: 'char_002_amiya', spine: 'char_002_amiya', avatar: 'char_002_amiya', tier: 3, x: 5, y: 12, maxHp: 1000 }, { prep: true }); + const hidden = (on) => { globalThis.document.hidden = on; globalThis.document.visibilityState = on ? 'hidden' : 'visible'; }; + + test('pending when the tab is shown: SPINE_STUCK_MS later it is loaded again (fresh) and the model shows', async () => { + assert.equal(SPINE_STUCK_MS, 5000); + const calls = []; + const a = createAssets({ manifest: M, spineTimeout: 0, loadImage: async () => null, unloadSpine: () => {}, + loadSpine: (e, o) => { calls.push(o?.fresh ? 'fresh' : 'plain'); return calls.length === 1 ? never() : Promise.resolve({ animations: [{ name: 'Idle' }] }); } }); + hidden(true); + const v = unit(a); + await tick(); + assert.equal(v._spineBusy, true); + v.retryAssets(); // the manifest arriving while still hidden arms nothing + assert.equal(v._stuckAt, 0); + hidden(false); + v.retryAssets(); // render/app.js visibilitychange + assert.ok(v._stuckAt > 0, 'armed: SPINE_STUCK_MS to finish'); + v.update(1 / 60, cam(), 0); + await tick(); + assert.deepEqual(calls, ['plain'], 'not before its time: a load about to succeed is not doubled'); + v._stuckAt = 1; // due + v.update(1 / 60, cam(), 0); + await tick(); await tick(); + assert.deepEqual(calls, ['plain', 'fresh'], 'started again, not joined to the pending request'); + assert.ok(v.actor && v.spineReady, 'the model is shown'); + assert.equal(a.spine.stats().refs, 1, 'one acquire, one reference'); + v.destroy(); + assert.equal(a.spine.stats().refs, 0); + }); + + test('a load that finishes within the grace is never doubled; one begun while visible is left to its timeout', async () => { + let resolve; + const calls = []; + const a = createAssets({ manifest: M, spineTimeout: 0, loadImage: async () => null, unloadSpine: () => {}, + loadSpine: () => { calls.push(1); return new Promise((r) => { resolve = r; }); } }); + hidden(true); + const v = unit(a); + hidden(false); + v.retryAssets(); + assert.ok(v._stuckAt > 0); + resolve({ animations: [{ name: 'Idle' }] }); + await tick(); await tick(); + assert.ok(v.actor); + assert.equal(v._stuckAt, 0, 'settled: disarmed'); + v.update(1 / 60, cam(), 0); + assert.equal(calls.length, 1); + v.destroy(); + const w = unit(createAssets({ manifest: M, spineTimeout: 0, loadImage: async () => null, loadSpine: () => never() })); + w.retryAssets(); + assert.equal(w._stuckAt, 0, 'begun while visible: the 20 s timeout and the bounded retries handle it'); + w.destroy(); + }); +});