// 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); }); }