Files
ark-stronghold-protocol/test/render/assets.test.js
T
sganggs b7ac003e09 feedback1 ui: operators no longer stay placeholders after a reload or a battle begun in a hidden tab (GitHub #8)
A reload (a phone browser or Chrome's Memory Saver discarding the background tab) made the asset store download
/data/assets.json a second time; createFieldView waited at most 4 s for it, every view looked its model and avatar up
once, and a failed download kept an empty manifest for the page. Now the field host seeds the store with the game
data's copies (seedAssets); without one, ready() retries like data.js, never keeps a failure and tries again with a
bounded backoff, and onChange makes every view re-resolve (UnitView.retryAssets, ItemView.setIcon). A failed or
timed-out Spine load is retried by its view (SPINE_RETRY_MS, acquire { retry }) and at once when the tab is shown.
Battle mode holds the Spine cache (spine.hold()), so a battle that began in a hidden tab keeps its models; the idle
budget still applies and the quiet budget still frees everything in the lobby, room and result.
2026-10-03 16:15:01 +08:00

596 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/render/assets.test.js — public/js/assets.js: URL helpers (synthetic + real manifest), RefLru spine cache
// (refcount, LRU eviction, failures, timeout, concurrency), store (fetch, preload, fallbacks). No PIXI, no DOM.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
avatarUrl, portraitUrl, enemyIconUrl, tokenAvatarUrl, bondIconUrl, bandIconUrl, itemIconUrl, skillIconUrl, uiUrl,
profIconUrl, subProfIconUrl, spineEntry, hasBackSpine, unitPictureUrl, bgmEntry, sfxUrl, unitSfxUrl, baseCharId,
validSpine, RefLru, createAssets, unloadSpineData, spinePages, spineDataWeight, SPINE_WEIGHT_MIN, SPINE_IDLE_BYTES,
SPINE_IDLE_GRACE_MS, SPINE_EVICT_DELAY_MS, SPINE_QUIET_DELAY_MS,
} from '../../public/js/assets.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
/** Fake clock + timers for the spine LRU memory budget (RefLru `now` / `timers`). */
const clock = () => {
const c = { t: 0, pending: [] };
c.now = () => c.t;
c.timers = { set: (fn, ms) => { const h = { fn, at: c.t + ms }; c.pending.push(h); return h; }, clear: (h) => { c.pending = c.pending.filter((x) => x !== h); } };
c.advance = (ms) => { c.t += ms; for (const h of c.pending.filter((x) => x.at <= c.t)) { c.pending = c.pending.filter((x) => x !== h); h.fn(); } };
return c;
};
const SP = (id, extra = {}) => ({ skel: `/assets/spine/${id}.skel`, atlas: `/assets/spine/${id}.atlas`, textures: [`/assets/spine/${id}.png`], pma: false, anims: { idle: 'Idle', attack: { loop: 'Attack' } }, animations: { Idle: 1 }, events: [], hits: {}, bounds: null, ...extra });
const M = {
chars: {
char_002_amiya: { avatar: '/a/amiya.png', avatarE2: '/a/amiya_2.png', portrait: '/p/amiya_1.png', portraitE2: '/p/amiya_2.png', spine: { front: SP('amiya_f'), back: SP('amiya_b') } },
char_010_chen: { avatar: '/a/chen.png', portrait: '/p/chen_1.png', spine: { front: SP('chen_f') } },
},
enemies: {
enemy_1007_slime: { icon: '/e/slime.png', spine: SP('slime', { pma: true }) },
enemy_1305_mhslim: { icon: '/e/mh.png', spineAliasOf: 'enemy_1007_slime' },
enemy_9016_acstmr: { icon: '/e/acstmr.png' },
},
tokens: {
token_a: { owner: 'char_010_chen', avatar: '/t/a.png', spine: SP('tok_a') },
token_b: { owner: 'char_010_chen' },
token_c: { owner: null },
},
bonds: { yanShip: '/b/yan.png' },
bands: { band_bldsk: '/band/bldsk.png' },
items: { trap_1041_acarm041: '/i/1041.png' },
skills: { skchr_x: '/s/x.png' },
skillsById: { skill_y: 'skchr_x' },
ui: { 'skillIcon/empty': '/ui/empty.png', 'battle/sprite_shadow': '/ui/shadow.png' },
prof: { icon: { sniper: '/prof/sniper.png' }, battlecard: { token: '/prof/token.png' }, sub: { fastshot: '/prof/sub/fastshot.png' } },
audio: {
bgm: { prep: { loop: '/bgm/prep.mp3' }, lobby: { intro: '/bgm/i.mp3', loop: '/bgm/l.mp3' } },
bossBgm: { boss_1: { loop: '/bgm/b1.mp3' } },
sfx: { ui: { buy: '/sfx/buy.mp3' }, battle: { deploy: '/sfx/dep.mp3' }, units: { char_010_chen: { attack: '/sfx/c_atk.mp3', skills: { 2: '/sfx/c_s3.mp3' }, skill: '/sfx/c_s.mp3' } } },
},
};
describe('URL helpers (synthetic manifest)', () => {
test('avatars & portraits with E2 handling', () => {
assert.equal(avatarUrl(M, 'char_002_amiya'), '/a/amiya.png');
assert.equal(avatarUrl(M, 'char_002_amiya', { e2: true }), '/a/amiya_2.png');
assert.equal(avatarUrl(M, 'char_002_amiya_2'), '/a/amiya_2.png', 'chess.json asset id with _2');
assert.equal(avatarUrl(M, 'char_010_chen', { e2: true }), '/a/chen.png', 'no E2 ⇒ base');
assert.equal(avatarUrl(M, 'char_010_chen_2'), '/a/chen.png');
assert.equal(avatarUrl(M, 'char_999_none'), null);
assert.equal(avatarUrl(M, ''), null);
assert.equal(avatarUrl(M, 42), null);
assert.equal(avatarUrl(null, 'char_002_amiya'), null);
assert.equal(portraitUrl(M, 'char_002_amiya_1'), '/p/amiya_1.png');
assert.equal(portraitUrl(M, 'char_002_amiya_2'), '/p/amiya_2.png');
assert.equal(portraitUrl(M, 'char_010_chen', { e2: true }), '/p/chen_1.png');
assert.equal(baseCharId('char_002_amiya_2'), 'char_002_amiya');
assert.equal(baseCharId(null), null);
});
test('prototype keys never resolve', () => {
assert.equal(avatarUrl(M, '__proto__'), null);
assert.equal(avatarUrl(M, 'constructor'), null);
assert.equal(uiUrl(M, 'toString'), null);
assert.equal(spineEntry(M, 'hasOwnProperty'), null);
});
test('enemy, token, bond, band, item, skill, ui, prof icons', () => {
assert.equal(enemyIconUrl(M, 'enemy_1007_slime'), '/e/slime.png');
assert.equal(enemyIconUrl(M, 'enemy_x'), null);
assert.equal(tokenAvatarUrl(M, 'token_a'), '/t/a.png');
assert.equal(tokenAvatarUrl(M, 'token_b'), '/a/chen.png', 'owner avatar fallback');
assert.equal(tokenAvatarUrl(M, 'token_c'), '/prof/token.png', 'battlecard fallback');
assert.equal(tokenAvatarUrl(M, 'token_none'), null);
assert.equal(bondIconUrl(M, 'yanShip'), '/b/yan.png');
assert.equal(bandIconUrl(M, 'band_bldsk'), '/band/bldsk.png');
assert.equal(itemIconUrl(M, 'trap_1041_acarm041'), '/i/1041.png');
assert.equal(itemIconUrl(M, { trapId: 'trap_1041_acarm041' }), '/i/1041.png');
assert.equal(itemIconUrl(M, { iconId: 'nope', trapId: 'trap_1041_acarm041' }), null);
assert.equal(skillIconUrl(M, 'skchr_x'), '/s/x.png');
assert.equal(skillIconUrl(M, 'skill_y'), '/s/x.png', 'via skillsById');
assert.equal(skillIconUrl(M, 'nope'), '/ui/empty.png');
assert.equal(skillIconUrl(M, 'nope', { fallback: false }), null);
assert.equal(skillIconUrl(M, 'nope', null), '/ui/empty.png', 'null options tolerated');
assert.equal(avatarUrl(M, 'char_002_amiya', null), '/a/amiya.png');
assert.equal(spineEntry(M, 'char_002_amiya', null).skel, '/assets/spine/amiya_f.skel');
assert.equal(uiUrl(M, 'battle/sprite_shadow'), '/ui/shadow.png');
assert.equal(profIconUrl(M, 'SNIPER'), '/prof/sniper.png');
assert.equal(profIconUrl(M, 'SNIPER', 'large'), null);
assert.equal(subProfIconUrl(M, 'sub_fastshot_icon'), '/prof/sub/fastshot.png');
assert.equal(subProfIconUrl(M, 'fastshot'), '/prof/sub/fastshot.png');
});
test('spine entries: front/back, tokens, enemies, aliases, missing', () => {
assert.equal(spineEntry(M, 'char_002_amiya').skel, '/assets/spine/amiya_f.skel');
assert.equal(spineEntry(M, 'char_002_amiya', { back: true }).skel, '/assets/spine/amiya_b.skel');
assert.equal(spineEntry(M, 'char_010_chen', { back: true }).skel, '/assets/spine/chen_f.skel', 'no Back ⇒ Front');
assert.equal(spineEntry(M, 'char_002_amiya_2').skel, '/assets/spine/amiya_f.skel');
assert.ok(hasBackSpine(M, 'char_002_amiya') && !hasBackSpine(M, 'char_010_chen'));
assert.equal(spineEntry(M, 'token_a').skel, '/assets/spine/tok_a.skel');
assert.equal(spineEntry(M, 'token_b'), null);
assert.equal(spineEntry(M, 'enemy_1007_slime').pma, true);
assert.equal(spineEntry(M, 'enemy_1305_mhslim').skel, '/assets/spine/slime.skel', 'alias');
assert.equal(spineEntry(M, 'enemy_9016_acstmr'), null);
assert.equal(spineEntry(M, 'enemy_5601_entlec'), null, 'no manifest entry at all');
assert.equal(validSpine({ skel: 'javascript:alert(1).skel', atlas: 'x', anims: {} }), false);
assert.equal(validSpine({ skel: '/x.skel', atlas: '/x.atlas', anims: {} }), true);
assert.equal(unitPictureUrl(M, 'enemy_9016_acstmr'), '/e/acstmr.png');
assert.equal(unitPictureUrl(M, 'trap_1041_acarm041'), '/i/1041.png');
assert.equal(unitPictureUrl(M, 'trap_x'), null);
});
test('audio', () => {
assert.equal(bgmEntry(M, 'prep').loop, '/bgm/prep.mp3');
assert.equal(bgmEntry(M, 'boss_1').loop, '/bgm/b1.mp3');
assert.equal(bgmEntry(M, 'x'), null);
assert.equal(sfxUrl(M, 'ui', 'buy'), '/sfx/buy.mp3');
assert.equal(sfxUrl(M, 'ui', 'nope'), null);
assert.equal(unitSfxUrl(M, 'char_010_chen', 'attack'), '/sfx/c_atk.mp3');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 2), '/sfx/c_s3.mp3');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 0), '/sfx/c_s.mp3');
assert.equal(unitSfxUrl(M, 'char_010_chen_2', 'attack'), '/sfx/c_atk.mp3');
assert.equal(unitSfxUrl(M, 'nobody', 'attack'), null);
});
});
describe('URL helpers (real data/assets.json)', () => {
const file = path.join(ROOT, 'data/assets.json');
const hasManifest = existsSync(file);
test('every pool chess resolves avatar + front spine; enemies resolve icon; paths are site-absolute', { skip: !hasManifest }, () => {
const m = JSON.parse(readFileSync(file, 'utf8'));
const chess = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
let n = 0;
for (const c of Object.values(chess)) {
if (!c.visible || !c.assets?.spine) continue;
n++;
const av = avatarUrl(m, c.assets.avatar);
assert.ok(av && av.startsWith('/assets/'), `avatar ${c.chessId} ${c.assets.avatar}`);
const sp = spineEntry(m, c.assets.spine);
assert.ok(sp && sp.skel.endsWith('.skel') && sp.anims && sp.anims.idle, `spine ${c.chessId}`);
if (c.skill?.iconId) assert.ok(skillIconUrl(m, c.skill.iconId, { fallback: false }), `skill icon ${c.chessId}`);
}
assert.ok(n >= 200, `checked ${n}`);
const enemies = JSON.parse(readFileSync(path.join(ROOT, 'data/enemies.json'), 'utf8'));
let withSpine = 0, total = 0;
for (const e of Object.values(enemies)) {
total++;
if (spineEntry(m, e.spine || e.key)) withSpine++;
}
assert.ok(withSpine / total > 0.9, `enemy spines ${withSpine}/${total}`);
assert.equal(spineEntry(m, 'enemy_5601_entlec'), null);
assert.ok(uiUrl(m, 'battle/sprite_shadow'));
for (const b of Object.keys(JSON.parse(readFileSync(path.join(ROOT, 'data/bonds.json'), 'utf8')))) assert.ok(bondIconUrl(m, b), b);
});
});
describe('RefLru', () => {
const tick = () => new Promise((r) => setImmediate(r));
test('dedupes concurrent loads, refcounts, evicts LRU idle entries above max', async () => {
const loads = [], unloads = [];
const lru = new RefLru({ max: 2, load: async (k) => { loads.push(k); return `v:${k}`; }, unload: (k, v) => unloads.push([k, v]) });
const [a1, a2] = await Promise.all([lru.acquire('a'), lru.acquire('a')]);
assert.equal(a1, 'v:a'); assert.equal(a2, 'v:a');
assert.deepEqual(loads, ['a']);
await lru.acquire('b');
await lru.acquire('c');
assert.equal(lru.size, 3, 'all referenced ⇒ nothing evicted');
lru.release('a'); lru.release('a');
assert.deepEqual(unloads, [['a', 'v:a']], 'a evicted once idle');
assert.equal(lru.peek('a'), null);
lru.release('b');
lru.release('c');
assert.equal(lru.size, 2);
await lru.acquire('d');
assert.equal(lru.size, 2);
assert.deepEqual(unloads.map((u) => u[0]), ['a', 'b'], 'least recently used idle entry goes first');
lru.release('zzz'); lru.release('d'); lru.release('d');
assert.equal(lru.stats().refs, 0);
lru.clear();
assert.equal(lru.size, 0);
});
test('failures reject, are remembered for failTtl, then retried', async () => {
let now = 0, calls = 0;
const lru = new RefLru({ failTtl: 1000, now: () => now, load: async () => { calls++; throw new Error('404'); } });
await assert.rejects(lru.acquire('x'), /404/);
await assert.rejects(lru.acquire('x'), /404/);
assert.equal(calls, 1);
now = 5000;
await assert.rejects(lru.acquire('x'), /404/);
assert.equal(calls, 2);
assert.equal(lru.stats().failed, 1);
});
test('timeout rejects slow loads', async () => {
const lru = new RefLru({ timeout: 20, load: () => new Promise(() => {}) });
await assert.rejects(lru.acquire('slow'), /timeout/);
});
test('concurrency cap queues loads', async () => {
let active = 0, peak = 0;
const lru = new RefLru({ concurrency: 2, max: 100, load: async (k) => { active++; peak = Math.max(peak, active); await tick(); await tick(); active--; return k; } });
const all = await Promise.all(['a', 'b', 'c', 'd', 'e'].map((k) => lru.acquire(k)));
assert.deepEqual(all, ['a', 'b', 'c', 'd', 'e']);
assert.equal(peak, 2);
});
test('an entry evicted while loading unloads the late value', async () => {
let resolve;
const unloads = [];
const lru = new RefLru({ max: 1, load: () => new Promise((r) => { resolve = r; }), unload: (k) => unloads.push(k) });
const p = lru.acquire('a');
lru.clear();
resolve('late');
assert.equal(await p, 'late');
assert.deepEqual(unloads, ['a']);
});
test('requires a loader', () => {
assert.throws(() => new RefLru({}), TypeError);
});
// perf hunt: the count cap alone (60) kept ~115–150 MB of idle parsed skeletons pinned, also in the lobby / room
const W = { a: 30, b: 30, c: 30, d: 5 };
test('memory budget: idle weight above maxIdleWeight goes LRU-first once past the grace; referenced never', async () => {
const c = clock(), unloads = [];
const lru = new RefLru({ max: 100, load: async (k) => k, unload: (k) => unloads.push(k), weigh: (k) => W[k],
maxIdleWeight: 50, idleGrace: 1000, now: c.now, timers: c.timers });
for (const k of ['a', 'b', 'c', 'd']) await lru.acquire(k);
lru.release('a'); lru.release('b'); // idle 60 > 50, but both inside the grace (a prep ⇄ battle switch)
assert.deepEqual(unloads, []);
assert.equal(lru.stats().idleWeight, 60);
assert.equal(c.pending.length, 1, 'a sweep is scheduled for the end of the grace');
await lru.acquire('b'); // re-acquired inside the grace: kept, never reloaded
c.advance(1000);
assert.deepEqual(unloads, [], 'idle 30 ≤ 50 after b came back');
lru.release('b'); // idle a+b = 60: a (idle for the whole grace) goes, b is fresh
assert.deepEqual(unloads, ['a']);
lru.release('c'); // idle b+c = 60, both inside their grace
c.advance(400);
assert.deepEqual(unloads, ['a']);
c.advance(700);
assert.deepEqual(unloads, ['a', 'b'], 'the sweep drops b (least recently used); c alone (30) fits the budget');
assert.ok(lru.peek('c') && lru.peek('d'));
assert.equal(lru.stats().weight, 35);
lru.clear();
assert.equal(c.pending.length, 0, 'clear cancels the sweep');
});
test('memory budget: nothing referenced (no scene) ⇒ quietWeight after the grace; unweighted caches unchanged', async () => {
const c = clock(), unloads = [];
const lru = new RefLru({ max: 100, load: async (k) => k, unload: (k) => unloads.push(k), weigh: (k) => W[k],
maxIdleWeight: 1000, quietWeight: 0, idleGrace: 1000, now: c.now, timers: c.timers });
for (const k of ['a', 'd']) await lru.acquire(k);
lru.release('a');
c.advance(5000);
assert.deepEqual(unloads, [], 'd still referenced: the 1000 budget holds a');
lru.release('d'); // the view is gone (lobby / room)
assert.deepEqual(unloads, ['a'], 'quiet budget 0: a (idle past its grace) goes at once, d is inside its grace');
c.advance(1100);
assert.deepEqual(unloads, ['a', 'd'], 'the quiet sweep empties the cache');
assert.equal(lru.size, 0);
// without `weigh` the cache behaves as before (count cap only, no timers)
const plain = new RefLru({ max: 100, load: async (k) => k, unload: (k) => unloads.push(k), maxIdleWeight: 0, quietWeight: 0, now: c.now, timers: c.timers });
await plain.acquire('x'); plain.release('x');
assert.equal(plain.size, 1);
assert.equal(c.pending.length, 0);
});
test('a released-while-loading entry past its grace is dropped as soon as it is ready when over budget', async () => {
const c = clock(), unloads = [];
let resolve;
const lru = new RefLru({ load: () => new Promise((r) => { resolve = r; }), unload: (k) => unloads.push(k), weigh: () => 10,
maxIdleWeight: 0, idleGrace: 100, now: c.now, timers: c.timers });
const p = lru.acquire('a');
lru.release('a');
c.advance(200);
resolve('v');
await p;
assert.deepEqual(unloads, ['a']);
});
// user playtest #3 item 1 (invisible operator models): an unload is asynchronous; a new load of the same key while
// it is in flight must not get the doomed value
test('a load of a key whose unload is still in flight waits for that unload (never the doomed value)', async () => {
const order = [];
let finishUnload;
let n = 0;
const lru = new RefLru({ max: 1,
load: async (k) => { order.push(`load ${k}`); return `${k}#${++n}`; },
unload: (k, v) => { order.push(`unload ${v}`); return new Promise((r) => { finishUnload = () => { order.push(`unloaded ${v}`); r(); }; }); } });
assert.equal(await lru.acquire('a'), 'a#1');
await lru.acquire('b');
lru.release('a'); // over max: a goes, its unload is in flight
assert.ok(lru.unloading('a') && lru.stats().unloading === 1);
const again = lru.acquire('a'); // same task: taken again
await tick();
assert.deepEqual(order, ['load a', 'load b', 'unload a#1'], 'the new load waits');
finishUnload();
assert.equal(await again, 'a#3', 'a fresh value once the old one is gone (b was #2)');
assert.deepEqual(order.slice(3), ['unloaded a#1', 'load a']);
await tick();
assert.equal(lru.unloading('a'), false);
// an unloader that throws or returns nothing never blocks a later load
const lru2 = new RefLru({ max: 1, load: async (k) => k, unload: () => { throw new Error('x'); } });
await lru2.acquire('a'); await lru2.acquire('b'); lru2.release('a');
assert.equal(await lru2.acquire('a'), 'a');
});
test('evictDelay: releases schedule one eviction pass; a scene rebuilt in one go keeps what it takes back', async () => {
const c = clock(), unloads = [];
let loads = 0;
const lru = new RefLru({ max: 2, load: async (k) => { loads++; return k; }, unload: (k) => unloads.push(k), evictDelay: 500, now: c.now, timers: c.timers });
for (const k of ['a', 'b', 'c']) await lru.acquire(k);
// every view destroyed, then the next scene's views built (same task): nothing is dropped meanwhile
for (const k of ['a', 'b', 'c']) lru.release(k);
for (const k of ['a', 'b']) await lru.acquire(k);
assert.deepEqual(unloads, []);
assert.equal(c.pending.length, 1, 'one pass pending');
c.advance(510);
assert.deepEqual(unloads, ['c'], 'the pass drops only what nobody took back');
assert.equal(loads, 3, 'a and b were never reloaded');
});
test('quietDelay: the instant zero of a scene switch is not "no scene"; a lasting one is', async () => {
const c = clock(), unloads = [];
const lru = new RefLru({ max: 100, load: async (k) => k, unload: (k) => unloads.push(k), weigh: () => 10,
maxIdleWeight: 100, quietWeight: 0, idleGrace: 5000, quietDelay: 3000, now: c.now, timers: c.timers });
await lru.acquire('bench'); await lru.acquire('op');
lru.release('bench'); // prep → battle: the bench model idles for the whole battle
c.advance(20000);
lru.release('op'); // battle → prep: nothing referenced for an instant …
assert.deepEqual(unloads, [], 'the budget of a shown scene applies (10 + 10 ≤ 100)');
await lru.acquire('bench'); await lru.acquire('op'); // … the prep views take their models back
c.advance(5000);
assert.deepEqual(unloads, []);
lru.release('bench'); lru.release('op'); // the view is gone for good (lobby)
c.advance(1000);
assert.deepEqual(unloads, [], 'not quiet long enough yet');
c.advance(2100);
assert.deepEqual(unloads, [], 'quiet, but both are still inside their grace');
c.advance(2000);
assert.deepEqual(unloads.sort(), ['bench', 'op'], 'the quiet budget empties the cache');
});
});
describe('Spine store: unload / reload of the same skeleton (user playtest #3 item 1)', () => {
const tick = () => new Promise((r) => setImmediate(r));
/**
* PIXI.Assets semantics that caused the invisible models: `unload` frees an asset a microtask after the call
* (Loader.unload awaits the cached promise, then deletes the cache entry and destroys the asset), and `load` of that
* URL meanwhile returns the cached — doomed — asset.
*/
function pixiAssets() {
const cache = new Map();
let seq = 0;
const loader = {
async unload(url) {
const hit = cache.get(url);
if (!hit) return;
const asset = await hit;
cache.delete(url);
asset.destroyed = true;
},
};
const Assets = {
loader,
load(url) {
if (!cache.has(url)) cache.set(url, Promise.resolve({ url, id: ++seq, destroyed: false, animations: [] }));
return cache.get(url);
},
unload(url) { return loader.unload(url); },
};
const prev = globalThis.PIXI;
globalThis.PIXI = { Assets, spine: {} };
return { Assets, restore: () => { if (prev === undefined) delete globalThis.PIXI; else globalThis.PIXI = prev; } };
}
test('the hazard: PIXI.Assets hands out an asset whose unload is in flight', async () => {
const f = pixiAssets();
try {
await f.Assets.load('/x.skel');
f.Assets.unload('/x.skel');
const again = await f.Assets.load('/x.skel');
await tick();
assert.equal(again.destroyed, true, 'the model a view would build from it has no textures');
} finally { f.restore(); }
});
test('battle → prep after a long battle: the bench models come back alive (defaults), with or without an eviction', async () => {
for (const eager of [false, true]) {
const f = pixiAssets();
try {
const c = clock();
// eager = the old timing (evict at once, no quiet delay): the bench model IS dropped at the instant zero —
// the re-acquire must then wait for its unload and load a fresh skeleton
const a = createAssets({ manifest: M, spineNow: c.now, spineTimers: c.timers, ...(eager ? { spineEvictDelay: 0, spineQuietDelay: 0 } : {}) });
const bench = a.spineEntry('char_010_chen'), op = a.spineEntry('char_002_amiya'), foe = a.spineEntry('enemy_1007_slime');
const bench0 = await a.spine.acquire(bench);
await a.spine.acquire(op);
// prep → battle: the prep views go (the bench model idles), the battle views take the board model and the foe
a.spine.release(bench); a.spine.release(op);
await a.spine.acquire(op); await a.spine.acquire(foe);
c.advance(SPINE_IDLE_GRACE_MS + 20000); // a long battle: the bench model idles past its grace
// battle → prep in one go (render/app.js enterPrepMode + setPrep): every battle view destroyed, prep views built
a.spine.release(op); a.spine.release(foe);
const [b1, o1] = await Promise.all([a.spine.acquire(bench), a.spine.acquire(op)]);
await tick(); await tick();
c.advance(SPINE_EVICT_DELAY_MS + SPINE_QUIET_DELAY_MS + 10);
await tick(); await tick();
assert.equal(b1.destroyed, false, `${eager ? 'eager' : 'default'}: the bench model is alive`);
assert.equal(o1.destroyed, false, `${eager ? 'eager' : 'default'}: the board model is alive`);
if (eager) assert.notEqual(b1, bench0, 'eager: dropped, then loaded afresh');
else assert.equal(b1, bench0, 'default: never dropped, never reloaded');
assert.equal(a.spine.stats().unloading, 0);
} finally { f.restore(); }
}
});
});
describe('spineDataWeight', () => {
test('counts timelines, deform frames, draw orders and mesh arrays; never throws', () => {
const f32 = (n) => new Float32Array(n);
const data = {
bones: [{}, {}], slots: [{}],
skins: [{ attachments: [{ body: { vertices: f32(100), regionUVs: f32(50), triangles: [1, 2, 3] }, head: { offset: f32(8) } }, null] }],
animations: [{ timelines: [{ frames: f32(10000), curves: f32(19 * 3333) }, { frames: f32(10), frameVertices: [f32(2000), f32(2000)] }, { frames: f32(4), drawOrders: [[0, 1], null] }] }],
};
const w = spineDataWeight(data);
const arrays = 4 * (100 + 50 + 8 + 10000 + 19 * 3333 + 10 + 2000 + 2000 + 4) + 8 * (3 + 2);
assert.ok(w > arrays && w < arrays * 2, `${w} vs ${arrays}`);
const bigger = { ...data, animations: [...data.animations, { timelines: [{ frames: f32(100000) }] }] };
assert.ok(spineDataWeight(bigger) - w >= 400000, 'grows with the animation data');
for (const junk of [null, undefined, 3, 'x', {}, { animations: 5, skins: [{ attachments: 'x' }] }, { animations: [null, { timelines: [7] }] }]) {
assert.equal(spineDataWeight(junk), SPINE_WEIGHT_MIN);
}
});
test('the store budgets idle skeletons by weight (defaults: SPINE_IDLE_BYTES, drain when no scene, 15 s grace)', async () => {
const d = createAssets({ manifest: M, loadSpine: async () => ({ animations: [] }), unloadSpine: () => {} });
assert.equal(d.spine.cache.maxIdleWeight, SPINE_IDLE_BYTES);
assert.equal(d.spine.cache.quietWeight, 0);
assert.equal(d.spine.cache.idleGrace, SPINE_IDLE_GRACE_MS);
assert.ok(SPINE_IDLE_BYTES <= 64 * 1024 * 1024 && SPINE_IDLE_GRACE_MS >= 5000);
const c = clock(), unloads = [];
const big = { animations: [{ timelines: [{ frames: new Float32Array(5 * 1024 * 1024) }] }] }; // ~20 MB parsed
const a = createAssets({ manifest: M, loadSpine: async () => big, unloadSpine: (e) => unloads.push(e.skel), spineNow: c.now, spineTimers: c.timers });
const e1 = a.spineEntry('char_002_amiya'), e2 = a.spineEntry('char_010_chen'), e3 = a.spineEntry('enemy_1007_slime'), keep = a.spineEntry('token_a');
for (const e of [e1, e2, e3, keep]) await a.spine.acquire(e);
for (const e of [e1, e2, e3]) a.spine.release(e);
c.advance(SPINE_IDLE_GRACE_MS + 10);
assert.deepEqual(unloads, [e1.skel], '3 × 20 MB idle > 48 MB: the oldest goes although the count cap (60) is far');
assert.ok(a.spine.stats().idleWeight <= SPINE_IDLE_BYTES);
a.spine.release(keep);
c.advance(SPINE_IDLE_GRACE_MS + 10);
assert.equal(a.spine.stats().size, 0, 'no scene references any skeleton (lobby / room): all of them go');
});
});
describe('createAssets store', () => {
test('fetches the manifest once; helpers bind to it; missing manifest ⇒ nulls', async () => {
let fetches = 0;
const a = createAssets({ fetch: async () => { fetches++; return { ok: true, json: async () => M }; } });
assert.equal(a.avatar('char_002_amiya'), null, 'before ready');
await Promise.all([a.ready(), a.ready()]);
assert.equal(fetches, 1);
assert.equal(a.avatar('char_002_amiya'), '/a/amiya.png');
assert.equal(a.spineEntry('enemy_1305_mhslim').skel, '/assets/spine/slime.skel');
assert.equal(a.audio.sfx('battle', 'deploy'), '/sfx/dep.mp3');
const warn = console.warn; console.warn = () => {};
try {
// a failure resolves ready() (fallbacks meanwhile) but is not kept as the manifest (public issue #8 item 5;
// test/render/issue8-placeholders.test.js covers the retries)
const b = createAssets({ fetch: async () => ({ ok: false, status: 404 }) });
assert.deepEqual(await b.ready(), {});
assert.equal(b.avatar('char_002_amiya'), null);
assert.equal(b.manifest, null);
assert.equal(b.loaded, false);
} finally { console.warn = warn; }
});
test('image cache + preload progress (failures resolve to null)', async () => {
const loaded = [];
const a = createAssets({ manifest: M, loadImage: async (u) => { loaded.push(u); if (u.includes('bad')) throw new Error('x'); return { src: u }; } });
const progress = [];
const r = await a.preload(['/a.png', '/bad.png', '/a.png', null, ''], (d, t) => progress.push([d, t]));
assert.deepEqual(r, { ok: 1, failed: 1, total: 2 });
assert.deepEqual(progress.map((p) => p[1]), [2, 2]);
assert.deepEqual((await a.image('/a.png')).src, '/a.png');
assert.equal(await a.image('/bad.png'), null);
assert.equal(loaded.filter((u) => u === '/a.png').length, 1, 'cached');
assert.equal(a.imageNow('/a.png').src, '/a.png');
assert.equal(await a.image(null), null);
});
test('spine acquire/release through the store with an injected loader', async () => {
const a = createAssets({ manifest: M, loadSpine: async (e) => ({ animations: [], from: e.skel }), unloadSpine: () => {}, spineMax: 1 });
const e = a.spineEntry('char_002_amiya');
const d = await a.spine.acquire(e);
assert.equal(d.from, e.skel);
assert.equal(a.spine.peek(e), d);
a.spine.release(e);
await assert.rejects(a.spine.acquire(null), /no spine/);
assert.equal(a.spine.stats().refs, 0);
});
});
describe('spine unload frees the atlas page images too', () => {
// pixi-spine's atlas parser loads every page with loader.load({ src }): the decoded ImageBitmap stays in
// PIXI.Assets.loader.promiseCache unless the page URL itself is unloaded (evicted models leaked ~0.8 MB each)
function fakePixi() {
const calls = { assets: [], loader: [] };
const prev = globalThis.PIXI;
globalThis.PIXI = {
spine: {},
Assets: { unload: async (u) => { calls.assets.push(u); }, loader: { unload: async (u) => { calls.loader.push(u); } } },
};
return { calls, restore: () => { if (prev === undefined) delete globalThis.PIXI; else globalThis.PIXI = prev; } };
}
test('spinePages: manifest textures, else <skel>.png', () => {
assert.deepEqual(spinePages(SP('x')), ['/assets/spine/x.png']);
assert.deepEqual(spinePages({ skel: '/s/a.skel', textures: ['/s/a.png', '/s/a2.png'] }), ['/s/a.png', '/s/a2.png']);
assert.deepEqual(spinePages({ skel: '/s/b.skel' }), ['/s/b.png']);
for (const junk of [null, 1, {}, { textures: [null, 3] }]) assert.deepEqual(spinePages(junk), []);
});
test('unloadSpineData unloads skeleton, atlas and every page (pages straight from the loader), minus kept ones', () => {
const f = fakePixi();
try {
unloadSpineData({ skel: '/s/a.skel', atlas: '/s/a.atlas', textures: ['/s/a.png', '/s/a2.png'] });
assert.deepEqual(f.calls.assets, ['/s/a.skel', '/s/a.atlas']);
assert.deepEqual(f.calls.loader, ['/s/a.png', '/s/a2.png']);
f.calls.loader.length = 0;
unloadSpineData({ skel: '/s/b.skel', textures: ['/s/b.png', '/s/shared.png'] }, null, new Set(['/s/shared.png']));
assert.deepEqual(f.calls.loader, ['/s/b.png']);
assert.doesNotThrow(() => unloadSpineData(null));
} finally { f.restore(); }
});
test('LRU eviction through the store unloads the evicted pages, never pages another cached skeleton uses', async () => {
const f = fakePixi();
try {
const E = (id, pages) => ({ ...SP(id), textures: pages });
const c = clock();
const a = createAssets({ manifest: M, loadSpine: async (e) => ({ animations: [], from: e.skel }), spineMax: 1, spineNow: c.now, spineTimers: c.timers });
const ea = E('a', ['/assets/spine/a.png', '/assets/spine/common.png']);
const eb = E('b', ['/assets/spine/b.png', '/assets/spine/common.png']);
await a.spine.acquire(ea);
await a.spine.acquire(eb);
a.spine.release(ea); // a evicted (max 1) while b still uses common.png …
assert.deepEqual(f.calls.assets, [], '… in the eviction pass a moment later (SPINE_EVICT_DELAY_MS)');
c.advance(SPINE_EVICT_DELAY_MS + 10);
assert.deepEqual(f.calls.assets, ['/assets/spine/a.skel', '/assets/spine/a.atlas']);
assert.deepEqual(f.calls.loader, ['/assets/spine/a.png']);
a.spine.release(eb);
a.spine.clear();
assert.deepEqual(f.calls.loader, ['/assets/spine/a.png', '/assets/spine/b.png', '/assets/spine/common.png']);
} finally { f.restore(); }
});
test('a late value of a dropped load does not unload the URLs a newer load of the same skeleton uses', async () => {
const f = fakePixi();
try {
const pending = [];
const a = createAssets({ manifest: M, loadSpine: (e) => new Promise((r) => pending.push(() => r({ animations: [], from: e.skel }))), spineMax: 5 });
const e = SP('late');
const p1 = a.spine.acquire(e);
a.spine.clear(); // dropped while loading
const p2 = a.spine.acquire(e); // loading again
pending.shift()();
await p1;
assert.deepEqual(f.calls.loader, [], 'the newer entry keeps its pages');
assert.deepEqual(f.calls.assets, []);
pending.shift()();
assert.equal((await p2).from, e.skel);
} finally { f.restore(); }
});
});