Files
MatchMaster/tools/test-pyramid-resource-lifecycle.cjs
2026-10-23 18:51:24 +08:00

208 lines
14 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 source = ts.transpileModule(fs.readFileSync('assets/pyramid/scripts/PyramidPanel.ts', 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017, experimentalDecorators: true }
}).outputText;
const flush = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
function observe(promise) {
const result = { status: 'pending' };
promise.then(value => Object.assign(result, { status: 'fulfilled', value }), error => Object.assign(result, { status: 'rejected', error }));
return result;
}
async function fulfilled(result) { await flush(); assert.equal(result.status, 'fulfilled', result.error && result.error.stack); }
function fixture(waitForBundle = false) {
const dead = new WeakSet(), pending = new WeakSet(), requests = [], calls = { render: 0, subscribe: 0, record: 0, instantiate: 0 };
class Node {
constructor(name) { this.name = name; this.children = []; }
addChild(node) { this.children.push(node); }
insertChild(node, index) { this.children.splice(index, 0, node); }
getChildByName(name) { return this.children.find(node => node.name === name); }
setScale() {} setContentSize() {} setPosition() {} on() {}
addComponent(Type) { return new Type(); }
getComponent() { return {}; }
}
Node.EventType = { TOUCH_START: 'touchstart' };
class Graphics { rect() {} fill() {} }
const bundle = name => ({ load(path, type, done) { requests.push({ kind: 'asset', bundle: name, path, done, value: { path, bundle: name } }); } });
const pyramid = bundle('pyramid'), shared = bundle('cloud_rise_home'), publicUI = bundle('publicUI');
let resolveBundle, rejectBundle;
const bundleTask = new Promise((resolve, reject) => { resolveBundle = resolve; rejectBundle = reject; });
if (!waitForBundle) resolveBundle(pyramid);
const service = { loadAsset: (bundle, path, type, done) => bundle.load(path, type, done), bundle: () => bundleTask, data: () => null, recordPriceView: async () => { calls.record++; }, subscribe: () => { calls.subscribe++; return () => {}; } };
const cc = {
Node, Graphics, Color: class {}, BlockInputEvents: class {}, Component: class {}, SpriteFrame: class {}, Material: class {}, Prefab: class {}, Sprite: class {}, Widget: class {},
_decorator: { ccclass: Type => Type, property: () => () => {} },
view: { getVisibleSize: () => ({ width: 1080, height: 2000 }) },
isValid: (object, strict) => !!object && !dead.has(object) && !(strict && pending.has(object)),
assetManager: { getBundle: () => null, loadBundle(name, done) { requests.push({ kind: 'bundle', path: name, done, value: name === 'publicUI' ? publicUI : shared }); } },
instantiate() { calls.instantiate++; throw Error('Destroyed panel must not instantiate rewards'); }
};
const context = { exports: {}, cc, require: () => ({ default: service }) };
vm.runInNewContext(source, context);
const panel = new context.exports.default(), node = new Node('Panel'), design = new Node('Design');
node.addChild(design);
for (const name of ['SummitGlow', 'Rewards', 'Tooltip', 'Free']) design.addChild(new Node(name));
panel.node = node; panel.bundle = pyramid;
panel.goodsFrames = Array.from({ length: 5 }, () => ({})); panel.movingGoodsFrames = Array.from({ length: 5 }, () => ({}));
panel.hourFrame = {}; panel.minuteFrame = {}; panel.countPrefixFrame = {}; panel.countdownBackgroundFrame = {};
panel.countdownFrames = Array.from({ length: 10 }, () => ({}));
panel.button = () => {}; panel.render = () => { calls.render++; }; panel.animationDebug = () => false;
function finish(request, error = null) {
assert(request && !request.finished, 'Expected an outstanding resource callback'); request.finished = true;
assert.doesNotThrow(() => request.done(error, error ? null : request.value), request.path);
}
function destroy(mode) {
if (mode === 'component-pending') pending.add(panel);
else if (mode === 'node-pending') pending.add(node);
else {
dead.add(panel); dead.add(node);
// Cocos 2.4 destructs own object/function fields even for inactive nodes.
for (const key of Object.keys(panel)) if (typeof panel[key] === 'object' || typeof panel[key] === 'function') panel[key] = null;
}
const fields = Object.entries(panel), maps = ['frames', 'digits', 'price', 'probabilityDigits'].filter(key => panel[key]).map(key => [key, Object.entries(panel[key])]);
return () => {
for (const [key, value] of fields) assert.strictEqual(panel[key], value, 'Destroyed panel field changed: ' + key);
for (const [key, entries] of maps) assert.deepEqual(Object.entries(panel[key]), entries, 'Destroyed panel dictionary changed: ' + key);
};
}
return { panel, requests, calls, finish, destroy, resolveBundle: () => resolveBundle(pyramid), rejectBundle,
assets: name => requests.filter(request => request.kind === 'asset' && request.bundle === name && !request.finished),
bundleRequest: name => requests.find(request => request.kind === 'bundle' && request.path === name && !request.finished) };
}
const modes = ['component-pending', 'node-pending', 'destructed'];
for (const mode of modes) {
test('initialization ignores resource callbacks after ' + mode, async () => {
const f = fixture(), result = observe(f.panel.initialize(() => {})); await flush();
assert.equal(f.assets('pyramid').length, 30);
assert(f.assets('pyramid').some(request => request.path === 'art/check'));
const unchanged = f.destroy(mode);
for (const request of f.assets('pyramid')) f.finish(request);
await fulfilled(result); unchanged(); assert.equal(f.calls.subscribe, 0);
});
test('initialization stops after a late Service.bundle result for ' + mode, async () => {
const f = fixture(true), result = observe(f.panel.initialize(() => {}));
const unchanged = f.destroy(mode); f.resolveBundle();
await fulfilled(result); unchanged(); assert.equal(f.requests.length, 0);
});
test('all ten price callbacks settle without writes after ' + mode, async () => {
const f = fixture(), result = observe(f.panel.ensurePriceAssets());
assert.equal(f.assets('pyramid').length, 10); const unchanged = f.destroy(mode);
for (const request of f.assets('pyramid')) f.finish(request);
await fulfilled(result); unchanged();
});
test('probability and local image/material callbacks settle without writes after ' + mode, async () => {
const f = fixture(), result = observe(f.panel.ensureProbabilityAssets());
await flush();
assert.equal(f.assets('pyramid').length, 16); assert.equal(f.bundleRequest('cloud_rise_home'), undefined);
const unchanged = f.destroy(mode);
for (const request of [...f.assets('pyramid'), ...f.assets('cloud_rise_home')]) f.finish(request);
await fulfilled(result); unchanged();
});
test('probability loads require no Cloud Rise bundle after ' + mode, async () => {
const f = fixture(), result = observe(f.panel.ensureProbabilityAssets()), unchanged = f.destroy(mode);
assert.equal(f.bundleRequest('cloud_rise_home'), undefined);
for (const request of f.assets('pyramid')) f.finish(request);
await fulfilled(result); unchanged(); assert.equal(f.assets('cloud_rise_home').length, 0);
});
}
test('a failed initialization can destroy the panel before other Promise.all callbacks arrive', async () => {
const f = fixture(), error = Error('image missing'), result = observe(f.panel.initialize(() => {})); await flush();
f.finish(f.assets('pyramid').find(request => request.path === 'art/selection'), error); await flush();
assert.equal(result.status, 'rejected'); assert.strictEqual(result.error, error);
const unchanged = f.destroy('destructed');
for (const request of f.assets('pyramid')) f.finish(request);
await flush(); unchanged();
});
test('initialization suppresses an already queued load rejection when destroyed before await resumes', async () => {
const f = fixture(), result = observe(f.panel.initialize(() => {})); await flush();
f.finish(f.assets('pyramid')[0], Error('failed before destruction'));
const unchanged = f.destroy('destructed');
for (const request of f.assets('pyramid')) f.finish(request);
await fulfilled(result); unchanged();
});
test('late load errors are ignored without restoring destroyed price/probability state', async () => {
for (const method of ['ensurePriceAssets', 'ensureProbabilityAssets']) {
const f = fixture(), result = observe(f.panel[method]());
await flush();
const unchanged = f.destroy('destructed');
for (const request of [...f.assets('pyramid'), ...f.assets('cloud_rise_home')]) f.finish(request, Error('late failure'));
await fulfilled(result); unchanged();
}
});
test('a queued rejection handler cannot write fields after destruction', async () => {
for (const method of ['ensurePriceAssets', 'ensureProbabilityAssets']) {
const f = fixture(), error = Error('failed before destruction'), result = observe(f.panel[method]());
f.finish(f.assets('pyramid')[0], error);
const unchanged = f.destroy('destructed');
assert.equal(f.bundleRequest('cloud_rise_home'), undefined);
for (const request of f.assets('pyramid')) f.finish(request);
await flush(); assert.notEqual(result.status, 'pending'); unchanged();
if (result.status === 'rejected') assert.strictEqual(result.error, error);
assert.equal(f.assets('cloud_rise_home').length, 0);
}
});
test('late bundle failures settle without starting dependent image requests', async () => {
const initialization = fixture(true), initResult = observe(initialization.panel.initialize(() => {}));
const initUnchanged = initialization.destroy('destructed'); initialization.rejectBundle(Error('late bundle failure'));
await flush(); assert.notEqual(initResult.status, 'pending'); initUnchanged(); assert.equal(initialization.requests.length, 0);
for (const method of ['ensureProbabilityAssets', 'animateRewards']) {
const f = fixture(), result = observe(method === 'animateRewards' ? f.panel.animateRewards([{ id: 1001, count: 1 }]) : f.panel[method]());
const unchanged = f.destroy('destructed');
if (method === 'animateRewards') f.finish(f.bundleRequest('publicUI'), Error('late bundle failure'));
for (const request of f.assets('pyramid')) f.finish(request);
await flush(); assert.notEqual(result.status, 'pending'); unchanged();
assert.equal(f.assets('cloud_rise_home').length + f.assets('publicUI').length, 0);
}
});
test('living panels initialize images and preserve price/probability failures and retries', async () => {
const init = fixture(), initialized = observe(init.panel.initialize(() => {})); await flush();
for (const request of init.assets('pyramid')) init.finish(request);
await fulfilled(initialized); assert.equal(init.panel.frames.check.path, 'art/check'); assert.equal(init.panel.digits[0].path, 'art/digits/0');
assert.equal(init.panel.ready, true); assert.equal(init.calls.subscribe, 1);
for (const method of ['ensurePriceAssets', 'ensureProbabilityAssets']) {
const f = fixture(), error = Error('download failed'), failed = observe(f.panel[method]());
f.finish(f.assets('pyramid')[0], error);
for (const request of f.assets('pyramid')) f.finish(request);
assert.equal(f.bundleRequest('cloud_rise_home'), undefined);
await flush(); assert.equal(failed.status, 'rejected'); assert.strictEqual(failed.error, error);
const task = method === 'ensurePriceAssets' ? 'priceTask' : 'probabilityTask'; assert.equal(f.panel[task], null);
if (method === 'ensurePriceAssets') assert.equal(f.panel.priceError, true);
const retry = observe(f.panel[method]());
for (const request of f.assets('pyramid')) f.finish(request);
assert.equal(f.bundleRequest('cloud_rise_home'), undefined);
await fulfilled(retry);
if (method === 'ensurePriceAssets') { assert.equal(f.panel.priceError, false); assert.equal(f.panel.price[0].path, 'art/price/0'); }
else { assert.equal(f.panel.probabilityDigits[0].path, 'art/probability/0'); assert.equal(f.panel.continueFrame.path, 'art/probability/continue'); assert.equal(f.panel.continueMaterial.path, 'art/probability/continue_outline'); }
}
});
test('reward resource loading stops after destruction before or after publicUI resolves', async () => {
for (const phase of ['bundle', 'prefab']) {
const f = fixture(), result = observe(f.panel.animateRewards([{ id: 1001, count: 1 }]));
if (phase === 'prefab') { f.finish(f.bundleRequest('publicUI')); await flush(); assert.equal(f.assets('publicUI').length, 1); }
const unchanged = f.destroy('destructed');
if (phase === 'bundle') f.finish(f.bundleRequest('publicUI'));
else f.finish(f.assets('publicUI')[0]);
await fulfilled(result); unchanged(); assert.equal(f.calls.instantiate, 0);
if (phase === 'bundle') assert.equal(f.assets('publicUI').length, 0);
}
});
test('reward bundle and prefab rejections preserve live errors but settle after same-tick destruction', async () => {
for (const phase of ['bundle', 'prefab']) for (const destroy of [false, true]) {
const f = fixture(), error = Error('reward resource failed'), result = observe(f.panel.animateRewards([{ id: 1001, count: 1 }]));
if (phase === 'prefab') { f.finish(f.bundleRequest('publicUI')); await flush(); }
f.finish(phase === 'bundle' ? f.bundleRequest('publicUI') : f.assets('publicUI')[0], error);
if (destroy) { const unchanged = f.destroy('destructed'); await fulfilled(result); unchanged(); }
else { await flush(); assert.equal(result.status, 'rejected'); assert.strictEqual(result.error, error); }
assert.equal(f.assets('publicUI').length, 0); assert.equal(f.calls.instantiate, 0);
}
});