MatchMaster/tools/test-gameplay-intro-loading.cjs

197 lines
11 KiB
JavaScript

// Run: node --test tools/test-gameplay-intro-loading.cjs
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require('typescript');
function loadClass(file, names, globals) {
const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true);
const declaration = source.statements.find(ts.isClassDeclaration);
const members = names.map(name => declaration.members.find(m => m.name && m.name.getText(source) === name).getText(source));
return vm.runInNewContext(ts.transpileModule(`class ${declaration.name.text} { ${members.join('\n')} }\n${declaration.name.text}`, {
compilerOptions: { target: ts.ScriptTarget.ES2017 },
}).outputText, globals);
}
const flush = async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); };
function fixture() {
const state = { blocksBusy: false, entryBusy: false, destroyed: false, requests: [], schedules: [], timers: [], bundles: new Map(), released: [] };
const pipeline = () => ({ pipes: [], insert(fn, index) { this.pipes.splice(index, 0, fn); } });
const cc = {
sys: { platform: 'wechat', WECHAT_GAME: 'wechat' }, fx: {}, warn() {},
Node: { EventType: { TOUCH_END: 'touchend' } },
isValid: node => !!node && !node.destroyed,
assetManager: {
pipeline: pipeline(), fetchPipeline: pipeline(),
getBundle: name => state.bundles.get(name),
removeBundle: bundle => state.bundles.delete(bundle.name),
loadBundle: () => assert.fail('background predownload must not load a Bundle'),
},
};
const wx = { preDownloadSubpackage: options => state.requests.push(options) };
const BlockAssetManager = { instance: { hasPendingLoads: () => state.blocksBusy } };
const mod = { exports: {} };
vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/Script/module/Tool/GameTool.ts', 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS },
}).outputText, {
module: mod, exports: mod.exports, cc, wx, console: { log() {} },
require: name => name.endsWith('/BlockAssetManager') ? { default: BlockAssetManager } : { default: {}, MiniGameSdk: {} },
});
const tool = cc.fx.GameTool = mod.exports.GameTool;
tool.trackResourceLoads();
cc.fx.GameConfig = { isGameplayEntryPreparing: () => state.entryBusy };
const Manager = loadClass('assets/Script/SceneManager.ts', [
'activeInstance', 'scheduleGameplayIntroPredownload', 'warmupGameplayPopupBundles',
'releaseDynamicPopup', 'releaseDynamicPopupBundle', 'releaseDynamicPopupBundleIfUnused',
'releaseOrphanedPopupBundle', 'onDestroy',
], { cc, wx, console: { log() {} }, setTimeout: fn => state.timers.push(fn) });
const manager = Object.assign(new Manager(), {
node: { getChildByName: () => null, off() {} },
dynamicPopupStates: {}, dynamicPopupLoads: {},
isPopupRuntimeAlive: () => !state.destroyed,
scheduleOnce: (fn, delay) => state.schedules.push({ fn, delay }),
finishDynamicPopupLoad() {}, unbindLosePanel() {}, unbindPropWindowPanel() {},
clearDynamicPopupRetry() {}, clearFlyCoins() {}, disposeJungleTreasure() {},
});
tool.shouldRetainGameplayPopupBundlesForGameSceneReload = () => true;
Manager.activeInstance = manager;
const tick = () => { assert.ok(state.schedules.length); state.schedules.shift().fn(); };
const bundle = name => {
const result = { name, releaseAll: () => state.released.push(name) };
state.bundles.set(name, result); return result;
};
return { state, cc, wx, tool, manager, Manager, tick, bundle };
}
test('resource tracking preserves pipeline input/results and drains success and failure exactly once', () => {
const { cc, tool } = fixture();
tool.trackResourceLoads();
for (const pipeline of [cc.assetManager.pipeline, cc.assetManager.fetchPipeline]) {
assert.equal(pipeline.pipes.length, 1, 'tracking is installed once');
let callbackArgs;
const task = { input: ['asset'], onComplete(...args) { callbackArgs = args; } };
pipeline.pipes[0](task, () => assert.equal(task.output, task.input));
assert.equal(tool.hasPendingGameplayResourceLoads(), true);
const error = new Error('download failed');
task.onComplete(error, 'result');
assert.deepEqual(callbackArgs, [error, 'result']);
assert.equal(tool.hasPendingGameplayResourceLoads(), false);
}
});
test('Block idle check includes queued, in-flight, decoding and whole background batches', () => {
const Block = loadClass('assets/Script/module/Tool/BlockAssetManager.ts', ['hasPendingLoads'], {});
const block = Object.assign(new Block(), {
foregroundLoads: 0, entryDownloadHolds: 0, backgroundRunning: false,
loading: new Map(), fileDownloads: new Map(), downloadQueue: [],
});
assert.equal(block.hasPendingLoads(), false);
for (const key of ['foregroundLoads', 'entryDownloadHolds']) {
block[key] = 1; assert.equal(block.hasPendingLoads(), true); block[key] = 0;
}
for (const key of ['loading', 'fileDownloads']) {
block[key].set('pending', {}); assert.equal(block.hasPendingLoads(), true); block[key].clear();
}
block.backgroundRunning = true; assert.equal(block.hasPendingLoads(), true); block.backgroundRunning = false;
block.downloadQueue.push({}); assert.equal(block.hasPendingLoads(), true);
});
test('intro waits for block queue, engine loading, popup downloads, user popup and stable idle', async () => {
const { state, cc, tool, manager, tick } = fixture();
manager.scheduleGameplayIntroPredownload();
manager.scheduleGameplayIntroPredownload();
assert.equal(state.schedules.length, 1);
state.blocksBusy = true; tick(); tick();
state.blocksBusy = false;
const task = { input: 'queued asset' };
cc.assetManager.pipeline.pipes[0](task, () => {}); tick();
task.onComplete(null, {});
tool._gameplayPopupSubpackagePreloadPromise = {}; tick();
tool._gameplayPopupSubpackagePreloadPromise = null;
tool._gameplayPopupMemoryWarmupPromise = {}; tick();
tool._gameplayPopupMemoryWarmupPromise = null;
manager.dynamicPopupLoads.pause = true; tick();
delete manager.dynamicPopupLoads.pause;
state.entryBusy = true; tick(); state.entryBusy = false;
tick(); assert.equal(state.requests.length, 0);
tick(); assert.equal(state.requests.length, 1);
assert.equal(state.requests[0].name, 'gameplay_intro');
state.requests[0].success(); await flush();
assert.equal(tool.isGameplayIntroDownloaded(), true);
assert.equal(state.bundles.size, 0, 'disk caching does not register a Bundle');
await tool.predownloadGameplayIntro();
assert.equal(state.requests.length, 1, 'successful session cache avoids duplicate download checks');
});
test('failure retries only after a delay and another idle check; leaving scene cancels pending work', async () => {
const { state, manager, tick } = fixture();
manager.scheduleGameplayIntroPredownload(); tick(); tick();
state.requests[0].fail(new Error('offline')); await flush();
assert.equal(state.schedules[0].delay, 10);
state.blocksBusy = true; tick(); tick();
assert.equal(state.requests.length, 1);
state.blocksBusy = false; tick(); tick();
assert.equal(state.requests.length, 2);
state.destroyed = true; state.requests[1].fail(new Error('offline')); await flush();
assert.equal(state.schedules.length, 0);
});
test('departing before idle never starts a download; old SDK does not execute a subpackage', () => {
const f = fixture(); f.manager.scheduleGameplayIntroPredownload();
f.state.destroyed = true; f.tick(); assert.equal(f.state.requests.length, 0);
const old = fixture(); delete old.wx.preDownloadSubpackage;
old.wx.loadSubpackage = () => assert.fail('must not execute scripts as background predownload');
old.manager.scheduleGameplayIntroPredownload(); assert.equal(old.state.schedules.length, 0);
});
test('concurrent callers share one download, and cached Bundle needs no predownload', async () => {
const { tool, state, bundle } = fixture();
const first = tool.predownloadGameplayIntro();
assert.equal(tool.predownloadGameplayIntro(), first);
state.requests[0].success(); assert.equal(await first, true);
const second = fixture(); second.bundle('gameplay_intro');
assert.equal(await second.tool.predownloadGameplayIntro(), true);
assert.equal(second.state.requests.length, 0);
});
test('scene reload releases intro resources while retaining ordinary popup bundles', () => {
const { state, manager, bundle } = fixture();
for (const [name, bundleName] of [['gameplayIntro', 'gameplay_intro'], ['pause', 'pause']]) {
const root = { active: true, removeFromParent() {}, destroy() { this.destroyed = true; } };
manager.dynamicPopupStates[name] = { bundleName, bundle: bundle(bundleName), root, callbacks: [], requestId: 1 };
}
manager.onDestroy();
assert.deepEqual(state.released, ['gameplay_intro']);
assert.equal(state.bundles.has('pause'), true);
assert.equal(state.bundles.has('gameplay_intro'), false);
});
test('unowned intro bundle is cleaned even with retention enabled and no popup state', () => {
const { state, manager, bundle } = fixture(); bundle('gameplay_intro'); manager.onDestroy();
assert.deepEqual(state.released, ['gameplay_intro']);
});
for (const owned of [false, true]) {
test(`late old-scene intro callback respects actual new-scene ownership: ${owned}`, () => {
const { state, manager, Manager, bundle } = fixture(); const intro = bundle('gameplay_intro');
if (owned) manager.dynamicPopupStates.gameplayIntro = { bundleName: 'gameplay_intro', loading: true };
Manager.releaseOrphanedPopupBundle('gameplay_intro', intro); state.timers.shift()();
assert.equal(state.bundles.has('gameplay_intro'), owned);
});
}
for (const mode of ['skip', 'cached', 'load', 'failure']) {
test(`low-memory warmup signals completion so idle download can start: ${mode}`, () => {
const { cc, tool, manager, bundle } = fixture(); let calls = 0, done;
tool.isIOSDevice = () => mode !== 'skip';
if (mode === 'cached') bundle('pause');
cc.assetManager.loadBundle = (name, callback) => { assert.equal(name, 'pause'); done = callback; };
manager.warmupGameplayPopupBundles(false, null, error => {
assert.equal(!!error, mode === 'failure'); calls++;
});
if (done) { assert.equal(calls, 0); done(mode === 'failure' ? new Error('offline') : null, {}); }
assert.equal(calls, 1);
});
}