Files
MatchMaster/tools/test-activity-memory-policy.cjs
2026-10-23 18:51:24 +08:00

139 lines
9.6 KiB
JavaScript

const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('node:fs'), vm = require('node:vm'), ts = require('typescript');
const flush = () => new Promise(resolve => setImmediate(resolve));
function load(file, cc, loader) {
const exports = {};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2018 }
}).outputText, { exports, cc, console, require: name => ({ default: name.includes('ActivityBundleLoader') ? loader : {} }) });
return exports.default;
}
function fixture(kind) {
const draws = [], pending = [], released = [], loads = [], bundles = new Map();
let holds = 0;
const retain = () => { holds++; let closed = false; return () => { if (!closed) { closed = true; holds--; } }; };
function bundle(name) {
if (!bundles.has(name)) bundles.set(name, { name,
load(path, type, done) { loads.push([name, path]); pending.push({ name, path, done }); },
releaseAll() { released.push(name); }
});
return bundles.get(name);
}
const cc = { Prefab: class {}, SpriteFrame: class {}, director: { once: (_event, fn) => draws.push(fn) }, Director: { EVENT_AFTER_DRAW: 1 },
assetManager: { loadBundle(name, options, done) { done(null, bundle(name)); } } };
const loader = { ensureBundle: async name => bundle(name), prepareGroup: async () => {}, retainGroup: retain,
acquireBundle: async name => ({ bundle: bundle(name), release: retain() }) };
const S = load('assets/Script/module/Config/' + kind + 'Service.ts', cc, loader);
let data = { run: { status: 'playing', stage: 1, expiresAt: 100, stages: [{ stage: 1 }] } }, unseen = false, elimination = null;
if (kind === 'CloudRise') {
S.data = () => data; S.now = () => 1; S.unseenResult = () => unseen; S.elimination = () => elimination;
}
return { S, cc, bundle, released, loads, pending, holds: () => holds,
setRun(stage, status = 'playing') { data.run = { status, stage, expiresAt: 100, stages: [{ stage }] }; S.emit(); },
unseen(value) { unseen = value; S.emit(); }, elimination(value) { elimination = value; S.emit(); },
draw() { draws.splice(0).forEach(fn => fn()); },
drain() { for (let i = 0; i < 8 && draws.length; i++) draws.splice(0).forEach(fn => fn()); assert.equal(draws.length, 0); },
finish(name, error = null) {
const index = pending.findIndex(p => p.name === name); assert(index >= 0, name);
pending.splice(index, 1)[0].done(error, error ? null : { name });
},
async page(name) {
const task = S.pageFragment(name === 'matching' ? 'matching' : 'stage', Number(name.slice(-1)) || 1);
await flush(); this.finish(name); await task; this.drain();
}
};
}
test('Cloud Rise retains the current stage across scene exits and releases old stage/matching only after their users exit', async () => {
const f = fixture('CloudRise'), home = f.S.retainResources();
await f.page('stage1'); home(); f.drain(); assert.deepEqual(f.released, []);
const oldSettlement = f.S.retainPageResources('stage', 1), match = f.S.retainPageResources('matching');
await f.page('matching');
const next = f.S.retainPageResources('stage', 2); await f.page('stage2');
f.setRun(2); f.drain(); assert.deepEqual(f.released, []);
match(); oldSettlement(); assert.deepEqual(f.released, []);
f.drain(); assert.deepEqual(f.released.sort(), ['matching', 'stage1']);
next(); f.drain(); assert(!f.released.includes('stage2')); assert(!f.released.includes('art'));
const before = f.loads.length; await f.S.pageFragment('stage', 2); f.drain(); assert.equal(f.loads.length, before);
f.setRun(2, 'failed'); f.drain(); assert(f.released.includes('stage2')); assert.equal(f.released.at(-1), 'art'); assert.equal(f.holds(), 0);
});
test('panel destruction releases failed-stage assets after the final draw but preserves an ongoing challenge', async () => {
const { loadMembers } = require('./new-player-ab-test-support.cjs');
for (const status of ['failed', 'playing']) {
const f = fixture('CloudRise'), release = f.S.retainPageResources('stage', 1);
await f.page('stage1'); f.setRun(1, status); f.drain();
const Panel = loadMembers('assets/cloud_rise_home/scripts/CloudRisePanel.ts', ['onDestroy'], {
cc: { Tween: { stopAllByTarget() {} } }, Service: { cancelMatch() { assert.fail('not matching'); } }
});
const panel = Object.assign(new Panel(), {
pageReleases: { stage1: release }, matchPhase: 'idle', matchRequest: 0, node: { off() {} },
stopOverviewSparkles() {}, stopVictorySparkles() {}, hideRewardHint() {}, resetCloudFloat() {},
progressAvatarPool: [], matchingAvatarPool: [], matchingAvatars: [], matchingSlots: [], victoryGroups: [], animationWaits: []
});
panel.onDestroy(); panel.onDestroy();
assert.deepEqual(f.released, [], 'onDestroy must not free assets before deferred node destruction');
f.drain();
assert.equal(f.S.pageUsers.stage1, 0, 'all page leases close exactly once');
if (status === 'playing') {
assert.deepEqual(f.released, [], 'the current challenge still owns its stage');
f.setRun(1, 'failed'); f.drain();
}
assert.deepEqual(f.released, ['stage1', 'art']); assert.equal(f.holds(), 0);
assert.equal(Object.keys(f.S.pageTasks).length, 0);
}
});
test('Cloud Rise protects unfinished loads, pending results and elimination pages, and can reload a reclaimed Bundle', async () => {
const f = fixture('CloudRise');
const old = f.S.pageFragment('stage', 1); await flush();
f.setRun(2); f.drain(); assert.deepEqual(f.released, []);
f.finish('stage1'); await old; f.drain(); assert.deepEqual(f.released, ['stage1', 'art']);
await f.page('stage2'); f.unseen(true); f.setRun(2, 'completed'); f.drain(); assert.equal(f.released.filter(n => n === 'stage2').length, 0);
f.elimination({ after: { stage: 2 } }); f.unseen(false); f.drain(); assert(!f.released.includes('stage2'));
f.elimination(null); f.drain(); assert(f.released.includes('stage2'));
f.setRun(1); await f.page('stage1'); assert.equal(f.loads.filter(([name]) => name === 'stage1').length, 2);
assert.equal(f.S.pageTasks.stage1.bundle, f.bundle('stage1'));
});
test('Cloud Rise does not warm matching while a stage is playing even if registration remains available', async () => {
const f = fixture('CloudRise'); let warmed = 0;
f.S.data = () => ({ available: true, period: { startsAt: 0, endsAt: 100 }, run: { status: 'playing', expiresAt: 100, stages: [{ stage: 2 }] } });
f.S.pagePrefab = async () => ({}); f.S.pageFragment = async () => { warmed++; };
f.S.preloadActivity(); await flush(); assert.equal(warmed, 0);
});
test('Cloud Rise does not release old shared dependencies while the next stage is still loading', async () => {
const f = fixture('CloudRise'); await f.page('stage1');
const next = f.S.pageFragment('stage', 2); await flush();
f.setRun(2); f.drain(); assert.deepEqual(f.released, []);
f.finish('stage2'); await next; f.drain(); assert.deepEqual(f.released, ['stage1']);
assert(!f.released.includes('art'));
});
test('Pyramid waits for both views, all asset callbacks and the final draw, then reloads using the same Bundle', async () => {
const f = fixture('Pyramid'), a = await f.S.acquireResources(), b = await f.S.acquireResources();
const prefab = f.S.prefab(); await flush(); f.finish('pyramid'); await prefab;
f.S.loadAsset(f.bundle('pyramid'), 'art/price/0', f.cc.SpriteFrame, () => {});
a(); a(); f.drain(); assert.deepEqual(f.released, []);
b(); f.drain(); assert.deepEqual(f.released, []); assert(f.holds() > 0);
f.finish('pyramid', Error('late failed image')); assert.deepEqual(f.released, []);
f.drain(); assert.deepEqual(f.released, ['pyramid']); assert.equal(f.holds(), 0); assert.equal(f.S.prefabTask, null);
const next = await f.S.acquireResources(), reloaded = f.S.prefab(); await flush(); f.finish('pyramid'); await reloaded;
assert.equal(f.loads.filter(([, path]) => path === 'prefab/Pyramid').length, 2);
next(); f.drain(); assert.equal(f.released.length, 2);
});
test('Pyramid reopening before the scheduled draw cancels cleanup until the new user exits', async () => {
const f = fixture('Pyramid'), close = await f.S.acquireResources(); close();
const next = f.S.acquireResources(); f.draw(); assert.deepEqual(f.released, []);
const finish = await next; finish(); f.drain(); assert.deepEqual(f.released, ['pyramid']); assert.equal(f.holds(), 0);
});
test('Pyramid owns its continue texture, material and Effect with independent UUIDs', () => {
const from = 'assets/cloud_rise_home/images/', to = 'assets/pyramid/art/probability/';
const read = file => JSON.parse(fs.readFileSync(file, 'utf8'));
for (const name of ['continue.png', 'continue_outline.mtl', 'continue_outline.effect']) {
assert.notEqual(read(from + name + '.meta').uuid, read(to + name + '.meta').uuid);
}
assert.deepEqual(fs.readFileSync(from + 'continue.png'), fs.readFileSync(to + 'continue.png'));
const texture = read(to + 'continue.png.meta');
assert.equal(texture.subMetas.continue.rawTextureUuid, texture.uuid);
assert.notEqual(texture.subMetas.continue.uuid, read(from + 'continue.png.meta').subMetas.continue.uuid);
assert.equal(read(to + 'continue_outline.mtl')._effectAsset.__uuid__, read(to + 'continue_outline.effect.meta').uuid);
assert(!fs.readFileSync('assets/pyramid/scripts/PyramidPanel.ts', 'utf8').includes('cloud_rise_home'));
});