feedback3 WD: a model load begun in a hidden tab and still pending 5 s after the tab is shown is started again, not joined to PIXI's pending request (GitHub #68)
This commit is contained in:
1 parent
41739beaaf
commit
2582367cea
3 files changed
+274
-33
No files matched your search
+102
-28
@@ -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<string>} [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<spineData> (`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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user