Files
ark-stronghold-protocol/test/render/feedback3-stuck-load.test.js

149 lines
7.9 KiB
JavaScript

// 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');
c = setup([]);
c[`http://127.0.0.1:8080${e.atlas}`] = {};
assert.equal(forgetPendingSpine(e), 4, 'an absolute key of the same path too');
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();
});
});