Files
ark-stronghold-protocol/test/render/assets.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

453 lines
24 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,
} 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']);
});
});
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 {
const b = createAssets({ fetch: async () => ({ ok: false, status: 404 }) });
await b.ready();
assert.equal(b.avatar('char_002_amiya'), null);
assert.deepEqual(b.manifest, {});
} 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 a = createAssets({ manifest: M, loadSpine: async (e) => ({ animations: [], from: e.skel }), spineMax: 1 });
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, ['/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(); }
});
});