// Run: node --test tools/test-first-game-entry.cjs const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const ts = require('typescript'); const root = path.resolve(__dirname, '..'); function parse(file) { return ts.createSourceFile(file, fs.readFileSync(path.join(root, file), 'utf8'), ts.ScriptTarget.Latest, true); } function loadClass(file, names, globals) { const source = parse(file); const declaration = source.statements.find(ts.isClassDeclaration); const members = names.map(name => { const member = declaration.members.find(node => node.name && node.name.getText(source) === name); assert.ok(member, `${file}: ${name}`); return member.getText(source); }); const code = `class ${declaration.name.text} { ${members.join('\n')} }\n${declaration.name.text};`; return vm.runInNewContext(ts.transpileModule(code, { compilerOptions: { target: ts.ScriptTarget.ES2017 }, }).outputText, globals); } const flush = async () => { for (let i = 0; i < 15; i++) await Promise.resolve(); }; function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } function fixture() { const state = { scene: { name: 'HomeScene' }, eligible: true, events: [], timers: [], errors: [], preloads: [], holds: 0, downloads: deferred(), blocks: deferred(), bundleCallbacks: [], jsonCallbacks: [], logs: [] }; const controller = { openLoad() { state.loading = true; }, closeLoad() { state.loading = false; }, playGameEntryTransition(force) { assert.equal(force, true); state.events.push('up'); }, }; const cc = { warn() {}, isValid: value => !!value && !value.destroyed, fx: {}, find: () => ({ getComponent: () => controller }), sys: { platform: 'web', WECHAT_GAME: 'wechat' }, assetManager: { loadBundle(name, options, done) { assert.equal(options.priority, 10); state.bundleCallbacks.push(done); }, loadAny(request, options, done) { assert.equal(request.bundle, 'custom'); assert.match(request.path, /^Json\/level/); assert.equal(options.priority, 10); state.jsonCallbacks.push(done); }, bundles: { find: () => ({ preloadScene(name, options, done) { assert.equal(options.preset, 'scene'); assert.equal(options.priority, 10); state.preloads.push(done); } }) } }, director: { getScene: () => state.scene, preloadScene: (name, progress, done) => state.preloads.push(done), loadScene: (name, done) => { if (state.sceneError) { done(state.sceneError); return true; } if (state.rejected) return false; state.scene = { name }; state.events.push('loadScene'); done(null); return true; }, }, }; const GameTool = { maxLevel: () => false, shouldPreloadGameplayPopupInstances: () => state.eligible, areGameplayPopupSubpackagesDownloaded: () => true, ensureGameplayPopupSubpackagesDownloaded: () => { state.events.push('download-check'); return state.downloads.promise; }, warmupHomeGameplayPopupAssets: () => { state.events.push('home-popups'); return Promise.resolve(true); }, predownloadGameplayPopupSubpackages: () => state.events.push('disk'), getMissingGameplayPopupSubpackages: () => ['pause'], beginGameplayPopupGameSceneReloadRetention: () => state.eligible, finishGameplayPopupGameSceneReloadRetention: () => state.events.push('retention-finished'), }; const BlockAssetManager = { instance: { holdBackgroundDownloads() { state.holds++; let released = false; return () => { if (!released) { released = true; state.holds--; } }; }, startBackgroundCache: () => state.events.push('background'), } }; const Config = loadClass('assets/Script/module/Config/GameConfig.ts', [ 'firstGameplayEntryComplete', 'firstGameplayEntryPending', 'sceneLoadRequest', 'cancelBlockLoading', 'gameplayScenePreload', 'gameplaySceneForegroundPreload', 'pendingEntryDownloadRelease', 'takeEntryDownloadRelease', 'preloadGameplayScene', 'timeGameplayStage', 'beginGameplayEntryPreparation', 'gameplayEntryStartedAt', 'isGameplayEntryPreparing', 'levelLoadRequest', 'LEVEL_INFO_init', 'applyLevelData', 'loadLevelFromBundle', 'shouldPrepareFirstGameplayEntry', 'createBlockLoading', 'enterGameSceneWhenReady', ], { cc, GameTool, BlockAssetManager, console: { log: (...args) => state.logs.push(args.join(' ')) }, setTimeout: (done, delay) => state.timers.push({ done, delay }) }); Config.BLOCK_INFO = []; Config.GM_INFO = { level: 0, otherLevel: 0 }; Config.prepareBlockWindow = () => { state.events.push('blocks'); return state.blocks.promise; }; Config.warmupHomeBlockAssets = () => state.blocks.promise; Config.showBlockLoadError = (error, retry) => state.errors.push({ error, retry }); cc.fx = { GameConfig: Config, GameTool }; return { state, cc, Config, BlockAssetManager }; } async function resourcesReady(state) { state.downloads.resolve(true); state.blocks.resolve(true); state.preloads.at(-1)(null); await flush(); } test('entry prepares disk packages, blocks and scene in parallel; popup Prefabs never gate it', async () => { const { state, Config } = fixture(); Config.enterGameSceneWhenReady(); assert.equal(state.loading, true); assert.equal(state.preloads.length, 1); assert.ok(state.events.includes('blocks')); assert.equal(state.holds, 1); await resourcesReady(state); assert.equal(state.events.includes('home-popups'), false); assert.equal(state.loading, false); assert.equal(state.timers[0].delay, 1000, 'preserve the existing transition animation duration'); state.timers[0].done(); assert.equal(Config.firstGameplayEntryPending, true); assert.equal(Config.firstGameplayEntryComplete, false); assert.equal(state.holds, 1, 'scene launch is not map readiness'); Config.takeEntryDownloadRelease()(); assert.equal(state.holds, 0); }); for (const mode of ['low', 'later-home', 'restart']) { test(`${mode}: no popup barrier and caller transition delay remains unchanged`, async () => { const { state, Config } = fixture(); if (mode === 'low') state.eligible = false; if (mode === 'later-home') Config.firstGameplayEntryComplete = true; if (mode === 'restart') state.scene.name = 'GameScene'; Config.enterGameSceneWhenReady(27); await resourcesReady(state); assert.equal(state.timers[0].delay, 27); state.timers[0].done(); assert.equal(Config.firstGameplayEntryPending, false); assert.equal(state.events.includes('home-popups'), false); assert.equal(state.events.includes('retention-finished'), mode === 'restart'); }); } for (const stage of ['downloads', 'blocks', 'loadScene', 'rejected']) { test(`${stage} failure releases background hold and permits retry`, async () => { const { state, Config } = fixture(); Config.enterGameSceneWhenReady(); state.downloads.resolve(stage !== 'downloads'); if (stage === 'blocks') state.blocks.reject(new Error('blocks')); else state.blocks.resolve(true); state.preloads[0](stage === 'preload' ? new Error('preload') : null); await flush(); if (stage === 'loadScene') state.sceneError = new Error('scene'); if (stage === 'rejected') state.rejected = true; if (state.timers.length) state.timers[0].done(); assert.equal(state.errors.length, 1); assert.equal(state.holds, 0); assert.equal(Config.firstGameplayEntryComplete, false); state.sceneError = null; state.rejected = false; state.blocks = deferred(); state.downloads = deferred(); state.errors[0].retry(); await resourcesReady(state); state.timers.at(-1).done(); assert.equal(state.scene.name, 'GameScene'); assert.equal(state.holds, 1); }); } test('foreground scene preparation does not wait for the lower-priority Home request', async () => { const { state, Config } = fixture(); const early = Config.preloadGameplayScene(); Config.enterGameSceneWhenReady(); assert.equal(state.preloads.length, 2); await resourcesReady(state); state.timers[0].done(); assert.equal(state.scene.name, 'GameScene'); state.preloads[0](null); await early; Config.preloadGameplayScene(); assert.equal(state.preloads.length, 3); state.preloads[2](null); }); test('superseded or departed entry cannot close a newer Loading or leak its hold', async () => { const { state, Config } = fixture(); Config.enterGameSceneWhenReady(); const old = state.blocks; state.blocks = deferred(); Config.enterGameSceneWhenReady(); assert.equal(state.holds, 1); old.resolve(true); state.downloads.resolve(true); state.preloads[0](null); await flush(); assert.equal(state.loading, true); assert.equal(state.holds, 1); state.scene = { name: 'OtherScene' }; state.blocks.resolve(true); await flush(); assert.equal(state.holds, 0); assert.equal(state.events.includes('up'), false); }); test('old Map release cannot unpause a new entry', async () => { const { state, Config } = fixture(); Config.enterGameSceneWhenReady(); await resourcesReady(state); state.timers[0].done(); const oldMapRelease = Config.takeEntryDownloadRelease(); Config.enterGameSceneWhenReady(); assert.equal(state.holds, 2); oldMapRelease(); oldMapRelease(); assert.equal(state.holds, 1); Config.takeEntryDownloadRelease()(); assert.equal(state.holds, 0); }); for (const high of [true, false]) { test(`Home idle policy high=${high}: only high tier preloads GameScene, no high popup load`, async () => { const { state, cc, Config, BlockAssetManager } = fixture(); state.eligible = high; const Home = loadClass('assets/Script/JiaZai.ts', ['startHomeGameplayWarmup'], { cc, BlockAssetManager, console: { log() {} } }); Object.assign(new Home(), { node: {}, homeGameplayWarmupPending: true, isHomeRuntimeAlive: () => true, scheduleOnce() {}, unschedule() {}, flushHomeAutomaticTasks() {} }).startHomeGameplayWarmup(); assert.equal(state.events[0], 'disk'); assert.equal(state.preloads.length, high ? 1 : 0, 'scene starts before block warmup completes'); state.blocks.resolve(true); await flush(); assert.equal(state.preloads.length, high ? 1 : 0); if (high) { state.preloads[0](null); await flush(); } assert.equal(state.events.includes('home-popups'), !high, 'low helper only warms Pause Bundle'); assert.ok(state.events.includes('background')); }); } function mapFixture() { const context = fixture(); const { state, cc, Config } = context; state.scene.name = 'GameScene'; state.popupCallbacks = []; state.frames = []; const Manager = loadClass('assets/Script/SceneManager.ts', ['warmupGameplayPopupBundles'], { cc }); const manager = Object.assign(new Manager(), { node: { getChildByName: () => ({}) }, isPopupRuntimeAlive: () => !state.destroyed, scheduleOnce: done => state.frames.push(done), loadDynamicPopup(name, bundle, prefab, parent, done, showLoading) { assert.equal(showLoading, false); state.popupCallbacks.push({ name, done }); }, releaseDynamicPopup: () => assert.fail('warmup failure must not clear a concurrent user open/retry'), }); const Map = loadClass('assets/Script/Map.ts', [ 'beginGameEntry', 'finishFirstGameplayEntry', 'resumeEntryBackgroundDownloads', 'onDestroy', ], { cc }); const map = Object.assign(new Map(), { node: { parent: { parent: { parent: { getChildByName: () => ({ active: false }) } } } }, SceneManager: manager, isMapRuntimeAlive: () => !state.destroyed, ensureWinLoaded: done => state.popupCallbacks.push({ name: 'win', done }), playEntryReveal: done => { state.events.push('down'); state.revealed = done; }, showEnterNewModeIfNeeded: () => state.events.push('new-mode'), scheduleOnce: done => state.frames.push(done), releaseWinBundle: () => {}, releaseBlockAssets: () => {}, }); Map._instance = map; Config.firstGameplayEntryPending = true; Config.pendingEntryDownloadRelease = context.BlockAssetManager.instance.holdBackgroundDownloads(); map.beginGameEntry(); const source = parse('assets/Script/Map.ts'); let initializer; function visit(node) { if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'schedulePopupWarmup') initializer = node.initializer.getText(source); ts.forEachChild(node, visit); } visit(source); assert.ok(initializer); const code = ts.transpileModule(`(function () { let wallReady = false, blockReady = false, popupWarmupScheduled = false; const schedulePopupWarmup = ${initializer}; return { wall() { wallReady = true; schedulePopupWarmup(); }, block() { blockReady = true; schedulePopupWarmup(); } }; })`, { compilerOptions: { target: ts.ScriptTarget.ES2017 } }).outputText; return { ...context, map, ready: vm.runInNewContext(code, { cc }).call(map) }; } test('map readiness reveals immediately; popup warmup follows pause, win, propWindow, newMode, lose across frames', () => { const { state, ready, Config } = mapFixture(); ready.wall(); assert.equal(state.holds, 1); assert.equal(state.events.includes('down'), false); ready.block(); assert.equal(state.holds, 0); assert.equal(Config.firstGameplayEntryComplete, true); assert.equal(state.events.at(-1), 'down'); assert.equal(state.popupCallbacks.length, 0); const order = []; for (let i = 0; i < 5; i++) { state.frames.shift()(); const pending = state.popupCallbacks.shift(); order.push(pending.name); pending.done(null); assert.equal(state.popupCallbacks.length, 0, 'next popup must wait another frame'); } assert.deepEqual(order, ['pause', 'win', 'propWindow', 'newMode', 'lose']); state.revealed(); assert.equal(state.events.at(-1), 'new-mode'); }); for (const failure of ['pause', 'win']) { test(`${failure} warmup failure does not block gameplay or remaining popups`, () => { const { state, ready, Config } = mapFixture(); ready.wall(); ready.block(); for (let i = 0; i < 5; i++) { state.frames.shift()(); const pending = state.popupCallbacks.shift(); pending.done(pending.name === failure ? new Error('warmup') : null); } assert.equal(Config.firstGameplayEntryComplete, true); assert.equal(state.errors.length, 0); }); } for (const reason of ['memory', 'destroy']) { test(`${reason} stops subsequent popup warmup even without a completion callback`, () => { const { state, ready } = mapFixture(); ready.wall(); ready.block(); state.frames.shift()(); if (reason === 'memory') state.eligible = false; else state.destroyed = true; state.popupCallbacks.shift().done(null); while (state.frames.length) state.frames.shift()(); assert.equal(state.popupCallbacks.length, 0); assert.equal(state.holds, 0); }); } test('background hold blocks only queued background work and supports nested/idempotent release', () => { const Manager = loadClass('assets/Script/module/Tool/BlockAssetManager.ts', [ 'entryDownloadHolds', 'holdBackgroundDownloads', 'pumpDownloadQueue', ], {}); Manager.FOREGROUND_DOWNLOAD_LIMIT = 10; Manager.BACKGROUND_CONCURRENCY = 8; const started = []; const manager = Object.assign(new Manager(), { foregroundLoads: 0, activeFileDownloads: 0, activeBackgroundDownloads: 0, downloadQueue: [], startUserDownload: job => started.push(job.path) }); const a = manager.holdBackgroundDownloads(), b = manager.holdBackgroundDownloads(); manager.downloadQueue = [{ path: 'bulk', priority: -1, background: true }, { path: 'current', priority: 1, background: false }]; manager.pumpDownloadQueue(); assert.deepEqual(started, ['current']); a(); a(); assert.deepEqual(started, ['current']); b(); assert.deepEqual(started, ['current', 'bulk']); assert.equal(manager.entryDownloadHolds, 0); }); test('Map destruction before readiness releases its hold; late map callbacks cannot warm popups', () => { const { state, map, ready, Config } = mapFixture(); assert.equal(state.holds, 1); state.destroyed = true; map.onDestroy(); map.onDestroy(); ready.wall(); ready.block(); assert.equal(state.holds, 0); assert.equal(state.frames.length, 0); assert.equal(Config.firstGameplayEntryPending, false); }); test('memory downgrade before map readiness still reveals the map but skips Prefab warmup', () => { const { state, ready, Config } = mapFixture(); state.eligible = false; ready.wall(); assert.equal(state.events.includes('down'), false); ready.block(); state.frames.shift()(); assert.equal(Config.firstGameplayEntryComplete, true); assert.equal(state.holds, 0); assert.equal(state.events.at(-1), 'down'); assert.equal(state.popupCallbacks.length, 0); }); test('JSON pending does not delay package downloads or foreground scene preparation; no duplicate entry task', async () => { const { state, Config } = fixture(); Config.LEVEL_INFO_init(true, 0, false); assert.equal(state.preloads.length, 1); assert.equal(state.holds, 1); assert.ok(state.events.includes('download-check')); assert.equal(state.events.includes('blocks'), false); state.bundleCallbacks[0](null, {}); assert.equal(state.events.includes('blocks'), false); state.jsonCallbacks[0](null, { json: { BLOCK_INFO: [[]], LEVEL_INFO: [{ id: 1 }], WALL_INFO: [] } }); assert.equal(state.preloads.length, 1); assert.equal(state.events.filter(e => e === 'download-check').length, 1); state.downloads.resolve(true); state.blocks.resolve(true); await flush(); state.timers[0].done(); assert.equal(state.scene.name, 'GameScene', 'must launch while foreground prefetch is still pending'); state.preloads[0](null); await flush(); assert.equal(state.logs.some(line => line.includes('[进关耗时]')), false, 'entry timing logs are disabled'); }); for (const failure of ['custom', 'json', 'departed']) { test(`${failure} during early preparation releases the pause and cannot launch a stale scene`, () => { const { state, Config } = fixture(); Config.LEVEL_INFO_init(true, 0, false); if (failure === 'departed') state.scene = { name: 'OtherScene' }; state.bundleCallbacks[0](failure === 'custom' ? new Error('custom') : null, { getInfoWithPath: () => ({}) }); if (failure === 'json') state.jsonCallbacks[0](new Error('json')); assert.equal(state.holds, 0); assert.equal(state.timers.length, 0); }); } test('prefetch failure does not fail the entry: the actual scene load owns retry/error reporting', async () => { const { state, Config } = fixture(); Config.enterGameSceneWhenReady(); state.preloads[0](new Error('prefetch')); await flush(); assert.equal(state.errors.length, 0); state.downloads.resolve(true); state.blocks.resolve(true); await flush(); state.timers[0].done(); assert.equal(state.scene.name, 'GameScene'); assert.equal(state.errors.length, 0); }); test('late custom callback from an older entry cannot release the newer entry hold', () => { const { state, Config } = fixture(); Config.LEVEL_INFO_init(true, 0, false); Config.LEVEL_INFO_init(true, 0, false); assert.equal(state.holds, 1); state.bundleCallbacks[0](new Error('stale')); assert.equal(state.holds, 1); assert.equal(state.errors.length, 0); state.bundleCallbacks[1](new Error('current')); assert.equal(state.holds, 0); assert.equal(state.errors.length, 1); }); test('entry preparation leaves Home resource scheduling untouched', () => { const { state, cc, Config } = fixture(); cc.find = () => assert.fail('entry preparation must not take control of Home resource tasks'); const entry = Config.beginGameplayEntryPreparation(); assert.equal(state.holds, 1); assert.ok(state.events.includes('download-check')); assert.equal(state.preloads.length, 1); entry.release(); assert.equal(state.holds, 0); }); function homeFixture(high = true) { const context = fixture(); const { state, cc, Config, BlockAssetManager } = context; Object.assign(state, { eligible: high, homeFrames: [], homeBundleLoads: [], homePrefabLoads: [], bundles: new Map(), toasts: [], opened: [], automatic: [], bundleDependencies: {}, delayedBundles: new Set(), pendingBundles: [], warnings: [] }); Object.assign(Config.GM_INFO, { level: 20, canIos: true, first: false, otherLevel: 0 }); cc.Prefab = class {}; cc.game = { setFrameRate() {} }; cc.assetManager.getBundle = name => state.bundles.get(name); cc.assetManager.loadBundle = (name, options, done) => { state.homeBundleLoads.push({ name, priority: options.priority }); const finish = (error = null) => { if (error) return done(error); let bundle = state.bundles.get(name); if (!bundle) { bundle = { name, deps: state.bundleDependencies[name] || [], load: (path, type, loaded) => { assert.equal(type, cc.Prefab); state.homePrefabLoads.push({ path, done: loaded, priority: options.priority }); } }; state.bundles.set(name, bundle); } done(null, bundle); }; if (state.delayedBundles.has(name)) state.pendingBundles.push({ name, finish }); else finish(); }; cc.assetManager.loadAny = (request, options, done) => { assert.ok(state.bundles.get(request.bundle), 'loadBundle must finish before loading its Prefab'); assert.equal(request.type, cc.Prefab); state.homePrefabLoads.push({ path: request.path, done, priority: options.priority }); }; cc.fx.GameTool.predownloadGameplayPopupSubpackages = () => { state.events.push('disk'); return state.downloads.promise; }; cc.fx.GameTool.getWechatGameVersion = () => '正式版'; cc.fx.GameTool.shushu_Track = () => {}; cc.warn = (...args) => state.warnings.push(args); cc.error = (...args) => state.warnings.push(args); const globals = { cc, BlockAssetManager, console: { log() {}, error() {} }, LQCollideSystem: {}, initProvinceLocator: () => state.automatic.push('province'), wx: { getLaunchOptionsSync: () => ({ query: state.launchQuery || {} }), getSystemInfoSync: () => ({ platform: 'android' }) }, MiniGameSdk: { API: { showToast: text => state.toasts.push(text) } }, NumberToImage: { numberToImageNodes5: () => state.automatic.push('coin-images') }, setTimeout: (done, delay) => state.timers.push({ done, delay }), Utils: { getShopDouble: callback => { state.shopResponse = callback; } } }; const Home = loadClass('assets/Script/JiaZai.ts', [ 'homeGameplayWarmupPending', 'homeAutomaticTasks', 'homeAutomaticFlushScheduled', 'homePopupOpenRequest', 'isHomeRuntimeAlive', 'queueHomeAutomaticTask', 'flushHomeAutomaticTasks', 'startHomeGameplayWarmup', 'preloadHomePopupPrefabs', 'loadHomePopupPrefab', 'ensureHomePopupPrefab', 'onLoad', 'isHomeBundleReady', 'loadHomeBundleWithDependencies', 'openShop', 'openMonthlyCard', 'openRewardWindow', 'openPassCheck', 'getShareInfo', 'getOrder', 'buyMonthCard', 'openStarter_pack', 'startGame', 'loadJungleTreasurePrefab', 'showJungleTreasure', 'loadJungleOverPrefab', 'showJungleOver', ], globals); const node = { getChildByName: () => node, addChild() {} }; const home = Object.assign(new Home(), { node, scheduleOnce: (done, delay) => state.homeFrames.push({ done, delay }), unschedule: done => { state.homeFrames = state.homeFrames.filter(item => item.done !== done); }, isCareerDisabledByMemoryWarning: () => false, vibrateButtonClick() {}, deferHomePopupWhileTransfer: () => false, }); for (const name of ['ensureLoadingOnTop', 'registerCareerMemoryWarning', 'updateWucaiTransferVisibility', 'applyStartUI2', 'refreshStaminaDisplay', 'closeLoad', 'checkShare', 'closeAvatar', 'setShareInfo', 'checkTasks', 'specialLevelShow', 'endlessLevelShow', 'passCheckBuyState', 'checkAndSetPlayerPassLevel', 'initializeJungleTreasureEntryFromLogin', 'setStarterPackHomeButtonVisible', 'checkStarter_pack', 'getSRank', 'popUpPassCheck', 'onGames', 'getOrder', 'monthH']) { home[name] = () => state.automatic.push(name); } return { ...context, Home, home, globals, finishBundle(name, error = null) { const index = state.pendingBundles.findIndex(item => item.name === name); assert.ok(index >= 0, `pending dependency: ${name}`); state.pendingBundles.splice(index, 1)[0].finish(error); }, frame() { const index = state.homeFrames.findIndex(item => item.delay < 1); assert.ok(index >= 0, 'one queued frame must exist'); state.homeFrames.splice(index, 1)[0].done(); }, async ready() { state.downloads.resolve(true); state.blocks.resolve(true); for (const done of state.preloads) done(null); await flush(); }, }; } for (const cached of [false, true]) { test(`Home startup cached=${cached}: gameplay preparation precedes optional Prefabs and Career`, async () => { const f = homeFixture(); if (cached) { const request = f.home.loadHomePopupPrefab('shop', 'prefab/shop', 'cachedShopPrefab', -10); f.state.homePrefabLoads.shift().done(null, {}); await request; f.state.homeBundleLoads = []; } f.home.onLoad(); assert.equal(f.state.homeBundleLoads.length, 0); assert.equal(f.state.automatic.includes('getSRank'), false); assert.equal(f.state.automatic.includes('closeAvatar'), false); f.frame(); assert.ok(f.state.events.includes('disk')); assert.equal(f.state.preloads.length, 1); f.state.downloads.resolve(true); f.state.preloads[0](null); await flush(); assert.equal(f.state.homePrefabLoads.length, 0, 'block preparation is still pending'); f.state.blocks.resolve(true); await flush(); while (f.state.homeFrames.some(item => item.delay < 1)) f.frame(); assert.ok(f.state.homePrefabLoads.some(item => item.path === 'prefab/Share')); assert.equal(f.state.homePrefabLoads.some(item => item.path === 'prefab/shop'), !cached); assert.ok(f.state.automatic.includes('getSRank')); }); } test('Home automatic tasks wait through entry, resume on failure, and stop on scene departure', async () => { const f = homeFixture(); f.home.queueHomeAutomaticTask('one', () => f.state.automatic.push('one')); f.home.queueHomeAutomaticTask('two', () => f.state.automatic.push('two')); f.home.startHomeGameplayWarmup(); const entry = f.Config.beginGameplayEntryPreparation(); await f.ready(); f.frame(); assert.deepEqual(f.state.automatic, []); entry.release(); f.frame(); assert.deepEqual(f.state.automatic, ['one']); f.state.scene = { name: 'GameScene' }; f.home.node.destroyed = true; f.frame(); assert.deepEqual(f.state.automatic, ['one']); }); for (const failure of ['rejection', 'timeout']) { test(`Home warmup ${failure} does not leave automatic features waiting forever`, async () => { const f = homeFixture(); f.home.queueHomeAutomaticTask('home', () => f.state.automatic.push('home')); f.home.startHomeGameplayWarmup(); if (failure === 'timeout') f.state.homeFrames.find(item => item.delay === 15).done(); else { f.state.blocks.reject(new Error('offline')); await f.ready(); } f.frame(); assert.deepEqual(f.state.automatic, ['home']); assert.equal(f.Config.isGameplayEntryPreparing(), false); }); } test('only unstarted automatic tasks are deferred; an in-flight Prefab still completes and caches after Home destruction', async () => { const f = homeFixture(); const request = f.home.loadHomePopupPrefab('shop', 'prefab/shop', 'cachedShopPrefab', -10); f.home.node.destroyed = true; f.state.scene = { name: 'GameScene' }; const prefab = {}; f.state.homePrefabLoads[0].done(null, prefab); assert.equal(await request, prefab); assert.equal(f.Home.cachedShopPrefab, prefab); }); test('a cached shop does not skip the three other missing shop Prefabs', async () => { const f = homeFixture(); f.home.loadHomePopupPrefab('shop', 'prefab/shop', 'cachedShopPrefab', -10); f.state.homePrefabLoads.shift().done(null, {}); await flush(); f.home.homeGameplayWarmupPending = false; f.home.preloadHomePopupPrefabs(); while (f.state.homeFrames.some(item => item.delay < 1)) f.frame(); const requests = f.state.homePrefabLoads.map(item => item.path); assert.equal(requests.includes('prefab/shop'), false); for (const name of ['RewardWindow', 'monthlyCard', 'passCheck']) assert.ok(requests.includes('prefab/' + name)); for (const request of f.state.homePrefabLoads) { const paid = ['newbieGift', 'monthlyCard', 'passCheck'].some(name => request.path === 'prefab/' + name); assert.equal(request.priority, request.path === 'prefab/RewardWindow' ? 2 : paid ? 1 : -10); } }); for (const method of ['openShop', 'openMonthlyCard', 'openRewardWindow', 'openPassCheck']) { test(`${method}: a user request loads immediately during warmup, then opens only after its Prefab is ready`, async () => { const f = homeFixture(); f.home.queueHomeAutomaticTask('background', () => assert.fail('automatic task started too early')); f.home[method] = () => f.state.opened.push(method); f.Home.prototype[method].call(f.home); assert.equal(f.state.homePrefabLoads.length, 1); assert.equal(f.state.homePrefabLoads[0].priority, 5); assert.deepEqual(f.state.opened, []); f.state.homePrefabLoads[0].done(null, {}); await flush(); assert.deepEqual(f.state.opened, [method]); assert.equal(f.home.homeGameplayWarmupPending, true); }); } test('late manual popup completion cannot open over entry or touch its Loading, but remains cached', async () => { const f = homeFixture(); f.home.ensureHomePopupPrefab('shop', 'prefab/shop', 'cachedShopPrefab', () => assert.fail('late popup opened')); const entry = f.Config.beginGameplayEntryPreparation(); f.state.loading = true; f.state.homePrefabLoads[0].done(null, {}); await flush(); assert.ok(f.Home.cachedShopPrefab); assert.equal(f.state.loading, true); entry.release(); }); test('failed manual popup load can be retried and never instantiates a missing asset', async () => { const f = homeFixture(); const open = () => f.state.opened.push('shop'); f.home.ensureHomePopupPrefab('shop', 'prefab/shop', 'cachedShopPrefab', open); f.state.homePrefabLoads[0].done(new Error('offline')); await flush(); assert.equal(f.state.opened.length, 0); f.home.ensureHomePopupPrefab('shop', 'prefab/shop', 'cachedShopPrefab', open); f.state.homePrefabLoads[1].done(null, {}); await flush(); assert.deepEqual(f.state.opened, ['shop']); }); test('a bundle released before Prefab completion is never installed into the Home cache', async () => { const f = homeFixture(); const request = f.home.loadHomePopupPrefab('shop', 'prefab/shop', 'cachedShopPrefab', 5); f.state.bundles.delete('shop'); f.state.homePrefabLoads[0].done(null, {}); await assert.rejects(request, /预制体已失效/); assert.equal(f.Home.cachedShopPrefab, undefined); }); test('share entry selects the actual level immediately but defers the automatic invitation window', () => { const f = homeFixture(); f.cc.sys.platform = 'wechat'; f.Config.GM_INFO.uid = 'self'; f.state.launchQuery = { level: '51', uid: 'friend' }; f.home.openShare = () => f.state.opened.push('share'); f.home.getShareInfo(); assert.equal(f.Config.GM_INFO.otherLevel, 51); assert.equal(f.Config.GM_INFO.otherUid, 'friend'); assert.equal(f.state.opened.length, 0); f.home.homeGameplayWarmupPending = false; f.home.flushHomeAutomaticTasks(); f.frame(); assert.deepEqual(f.state.opened, ['share']); }); test('newer user popup requests supersede old UI callbacks without losing either Prefab cache', async () => { const f = homeFixture(); f.home.ensureHomePopupPrefab('shop', 'prefab/shop', 'cachedShopPrefab', () => f.state.opened.push('shop')); f.home.ensureHomePopupPrefab('shop', 'prefab/monthlyCard', 'cachedMonthlyCardPrefab', () => f.state.opened.push('card')); f.state.homePrefabLoads[0].done(null, {}); f.state.homePrefabLoads[1].done(null, {}); await flush(); assert.deepEqual(f.state.opened, ['card']); assert.ok(f.Home.cachedShopPrefab && f.Home.cachedMonthlyCardPrefab); }); test('Home level warmup uses the same priority-aware JSON API as manual entry', async () => { const { state, Config } = fixture(); const request = Config.loadLevelFromBundle(51); assert.equal(state.jsonCallbacks.length, 0); state.bundleCallbacks[0](null, {}); const json = { BLOCK_INFO: [[]] }; state.jsonCallbacks[0](null, { json }); assert.equal(await request, json); }); for (const high of [false, true]) { test(`Home tier high=${high}: RewardWindow loads first and order recovery waits for its success`, async () => { const f = homeFixture(high); f.home.onLoad(); f.frame(); assert.equal(f.state.automatic.includes('getOrder'), false, 'phase one still has precedence'); await f.ready(); f.state.automatic = []; f.frame(); assert.deepEqual(f.state.automatic, []); assert.equal(f.state.homePrefabLoads.length, 1); assert.equal(f.state.homePrefabLoads[0].path, 'prefab/RewardWindow'); assert.equal(f.state.homePrefabLoads[0].priority, 2); while (f.state.homeFrames.some(item => item.delay < 1)) f.frame(); const paths = f.state.homePrefabLoads.map(item => item.path); assert.deepEqual(paths.slice(0, 5), ['RewardWindow', 'newbieGift', 'monthlyCard', 'passCheck', 'shop'].map(n => 'prefab/' + n)); assert.equal(f.state.automatic.includes('getOrder'), false, 'no recovery while reward images are pending'); f.state.homePrefabLoads.find(item => item.path === 'prefab/RewardWindow').done(null, {}); await flush(); while (f.state.homeFrames.some(item => item.delay < 1)) f.frame(); assert.equal(f.state.automatic.filter(name => name === 'getOrder').length, 1); assert.ok(f.state.automatic.indexOf('checkStarter_pack') < f.state.automatic.indexOf('closeAvatar')); }); } test('RewardWindow failure leaves recovery unstarted and the pending orders untouched; a later preload can retry', async () => { const f = homeFixture(); const pending = [{ itemid: 'gold_1', outTradeNo: 'pending-order' }]; f.Config.GM_INFO.allOutTradeNo = pending; f.home.homeGameplayWarmupPending = false; f.home.preloadHomePopupPrefabs(); f.frame(); f.state.homePrefabLoads[0].done(new Error('reward images offline'), null); await flush(); while (f.state.homeFrames.some(item => item.delay < 1)) f.frame(); assert.equal(f.state.automatic.includes('getOrder'), false); assert.equal(f.Config.GM_INFO.allOutTradeNo, pending); f.home.preloadHomePopupPrefabs(); f.frame(); const rewards = f.state.homePrefabLoads.filter(item => item.path === 'prefab/RewardWindow'); assert.equal(rewards.length, 2); rewards[1].done(null, {}); await flush(); f.frame(); assert.equal(f.state.automatic.filter(name => name === 'getOrder').length, 1); }); for (const departed of [false, true]) { test(`late RewardWindow success does not start recovery over game entry, departed=${departed}`, async () => { const f = homeFixture(); f.home.homeGameplayWarmupPending = false; f.home.preloadHomePopupPrefabs(); f.frame(); f.Config.isGameplayEntryPreparing = () => true; if (departed) { f.home.node.destroyed = true; f.state.scene = { name: 'GameScene' }; } const prefab = {}; f.state.homePrefabLoads[0].done(null, prefab); await flush(); f.frame(); assert.equal(f.Home.cachedRewardPrefab, prefab); assert.equal(f.state.automatic.includes('getOrder'), false); if (!departed) { f.Config.isGameplayEntryPreparing = () => false; f.frame(); assert.equal(f.state.automatic.filter(name => name === 'getOrder').length, 1); assert.equal(f.state.automatic[0], 'getOrder', 'recovery still has priority over unstarted gift tasks'); } }); } test('Home priorities reorder only unstarted tasks and keep equal priorities in insertion order', () => { const f = homeFixture(); for (const [name, priority] of [['normal', 0], ['paid-a', 1], ['order', 2], ['paid-b', 1]]) { f.home.queueHomeAutomaticTask(name, () => f.state.automatic.push(name), priority); } f.home.homeGameplayWarmupPending = false; f.home.flushHomeAutomaticTasks(); while (f.state.homeFrames.some(item => item.delay < 1)) f.frame(); assert.deepEqual(f.state.automatic, ['order', 'paid-a', 'paid-b', 'normal']); }); test('Home Prefab waits for nested bundle dependencies and forwards request priority', async () => { const f = homeFixture(); f.state.bundleDependencies = { action_bundle: ['shop'], shop: ['UI'] }; f.state.delayedBundles.add('UI'); const request = f.home.loadHomePopupPrefab('action_bundle', 'prefab/newbieGift', 'cachedActionPrefab', 5); assert.deepEqual(f.state.homeBundleLoads.map(item => item.name), ['action_bundle', 'shop', 'UI']); assert.ok(f.state.homeBundleLoads.every(item => item.priority === 5)); assert.equal(f.state.homePrefabLoads.length, 0, 'registered root does not mean its dependencies are ready'); f.finishBundle('UI'); assert.equal(f.state.homePrefabLoads.length, 1); const prefab = {}; f.state.homePrefabLoads[0].done(null, prefab); assert.equal(await request, prefab); }); test('failed Home dependency prevents Prefab parsing; the next request can retry', async () => { const f = homeFixture(); f.state.bundleDependencies = { shop: ['UI'] }; f.state.delayedBundles.add('UI'); const request = f.home.loadHomePopupPrefab('shop', 'prefab/RewardWindow', 'cachedRewardPrefab', 1); const failed = assert.rejects(request, /dependency offline/); f.finishBundle('UI', new Error('dependency offline')); await failed; assert.equal(f.state.homePrefabLoads.length, 0); assert.equal(f.Home.cachedRewardPrefab, undefined); const retry = f.home.loadHomePopupPrefab('shop', 'prefab/RewardWindow', 'cachedRewardPrefab', 5); f.finishBundle('UI'); const prefab = {}; f.state.homePrefabLoads[0].done(null, prefab); assert.equal(await retry, prefab); }); test('Home cache fast path cannot bypass a missing dependency', async () => { const f = homeFixture(); f.state.bundles.set('shop', { name: 'shop', deps: ['UI'] }); f.Home.cachedShopPrefab = {}; f.state.delayedBundles.add('UI'); const opened = f.home.ensureHomePopupPrefab('shop', 'prefab/shop', 'cachedShopPrefab', () => f.state.opened.push('shop')); assert.equal(opened, false); assert.deepEqual(f.state.opened, []); assert.equal(f.state.homePrefabLoads.length, 0); f.finishBundle('UI'); f.state.homePrefabLoads[0].done(null, {}); await flush(); assert.deepEqual(f.state.opened, ['shop']); }); test('Home dependency removed during Prefab loading cannot leave a usable-looking cache', async () => { const f = homeFixture(); f.state.bundleDependencies = { shop: ['UI'] }; const request = f.home.loadHomePopupPrefab('shop', 'prefab/shop', 'cachedShopPrefab', 1); const failed = assert.rejects(request, /预制体已失效/); f.state.bundles.delete('UI'); f.state.homePrefabLoads[0].done(null, {}); await failed; assert.equal(f.Home.cachedShopPrefab, undefined); }); test('Home dependency traversal handles repeated and cyclic bundle references', async () => { const f = homeFixture(); f.state.bundleDependencies = { shop: ['action_bundle'], action_bundle: ['shop'] }; const request = f.home.loadHomePopupPrefab('shop', 'prefab/shop', 'cachedShopPrefab', 1); assert.deepEqual(f.state.homeBundleLoads.map(item => item.name), ['shop', 'action_bundle']); f.state.homePrefabLoads[0].done(null, {}); await request; }); for (const stage of ['dependency', 'prefab']) { test(`newbie gift callback at ${stage} cannot create nodes after Home destruction`, () => { const f = homeFixture(); f.globals.starterPackRemaining = () => 3600; f.state.bundleDependencies = { action_bundle: ['shop'] }; f.state.delayedBundles.add('shop'); f.cc.instantiate = () => assert.fail('late gift created a node'); // Exercise the popup body after queue dispatch; the fixture does not load HomePopupQueue. f.Home.prototype.openStarter_pack.call(f.home, undefined, undefined, true); if (stage === 'prefab') f.finishBundle('shop'); f.home.node.destroyed = true; f.state.scene = { name: 'GameScene' }; if (stage === 'dependency') { f.finishBundle('shop'); assert.equal(f.state.homePrefabLoads.length, 0); } else { const prefab = {}; f.state.homePrefabLoads[0].done(null, prefab); assert.equal(f.Home.cachedActionPrefab, prefab, 'completed asset can still be cached'); } }); } for (const stage of ['not-started', 'shop-request', 'first-order']) { test(`startGame preserves pending orders while recovery is ${stage}`, () => { const f = homeFixture(); f.cc.sys.platform = 'wechat'; const orders = [{ itemid: 'gold_1', outTradeNo: 'order-a', goodsPrice: 600 }, { itemid: 'gold_2', outTradeNo: 'order-b', goodsPrice: 3600 }]; f.Config.GM_INFO.allOutTradeNo = orders; f.Config.GM_INFO.hp = 1; f.cc.fx.AudioManager = { _instance: { playEffect() {} } }; f.cc.fx.GameTool.getUserPowerTime = () => false; f.globals.wx.offShow = f.globals.wx.offHide = () => {}; f.home.node.getComponent = () => ({ _touch: true, setTouch() {} }); f.home.playGameEntryTransition = () => {}; let entries = 0; f.Config.LEVEL_INFO_init = () => { entries++; f.Config.isGameplayEntryPreparing = () => true; }; const requests = [], grants = [], callbacks = []; f.globals.Utils.setPayInfo = (done, order) => { requests.push(order); callbacks.push(done); }; f.globals.MiniGameSdk.API.yinli_Pay = () => {}; f.cc.fx.GameTool.shopBuy = id => grants.push(id); f.home.openLoad = f.home.closeLoad = f.home.updateCoin = () => {}; if (stage !== 'not-started') f.Home.prototype.getOrder.call(f.home); if (stage === 'first-order') f.state.shopResponse({ code: 1, data: { shopDouble: {} } }); f.home.startGame(); assert.equal(entries, 1); assert.equal(f.Config.GM_INFO.allOutTradeNo, orders, 'entering gameplay must not discard the recovery list'); assert.equal(f.Config.GM_INFO.allOutTradeNo.length, 2); f.home.node.destroyed = true; f.state.scene = { name: 'GameScene' }; if (stage === 'not-started') { assert.deepEqual(requests, []); return; } if (stage === 'shop-request') f.state.shopResponse({ code: 1, data: { shopDouble: {} } }); while (callbacks.length) callbacks.shift()({ code: 1 }); assert.deepEqual(requests, ['order-a', 'order-b']); assert.deepEqual(grants, ['gold_1', 'gold_2']); assert.equal(f.Config.GM_INFO.allOutTradeNo.length, 0); }); } for (const departed of [false, true]) { test(`order recovery callback preserves fulfillment without touching entry/old Home UI, departed=${departed}`, () => { const f = homeFixture(); f.cc.sys.platform = 'wechat'; f.Config.GM_INFO.allOutTradeNo = [{ itemid: 'gold_1', outTradeNo: 'paid-order', goodsPrice: 600 }]; let payDone, uiCalls = 0; const grants = []; f.globals.Utils.setPayInfo = done => { payDone = done; }; f.globals.MiniGameSdk.API.yinli_Pay = () => {}; f.cc.fx.GameTool.shopBuy = (id, compensate) => grants.push({ id, compensate }); f.home.openLoad = f.home.closeLoad = f.home.updateCoin = () => uiCalls++; f.Home.prototype.getOrder.call(f.home); f.state.shopResponse({ code: 1, data: { shopDouble: {} } }); assert.equal(uiCalls, 1); if (departed) { f.home.node.destroyed = true; f.state.scene = { name: 'GameScene' }; } else f.Config.isGameplayEntryPreparing = () => true; payDone({ code: 1 }); assert.deepEqual(grants, [{ id: 'gold_1', compensate: true }]); assert.equal(uiCalls, 1, 'late payment must not close entry Loading or update old coin nodes'); assert.equal(f.state.automatic.includes('coin-images'), false); assert.equal(f.Config.GM_INFO.allOutTradeNo.length, 0); }); } test('monthly-card fulfillment does not dereference a missing Canvas during scene replacement', async () => { const f = homeFixture(); let complete, grants = 0; f.globals.Utils.setMonthlyCard = (type, done) => { complete = done; }; f.cc.fx.GameTool.shopBuy = () => grants++; f.cc.fx.GameTool.getMonthlyCardValidityDays = () => Promise.resolve({ days: 30, time: Date.now() }); f.cc.fx.GameTool.setUserHealth = () => {}; f.cc.fx.StorageMessage = { setStorage() {} }; f.Home.prototype.buyMonthCard.call(f.home, 'month_Card', 'month-order'); f.cc.find = () => null; f.state.scene = { name: 'GameScene' }; f.home.node.destroyed = true; complete({ code: 1 }); await flush(); assert.equal(grants, 1); assert.equal(f.state.automatic.includes('closeLoad'), false); }); for (const departed of [false, true]) { for (const stage of ['dependency', 'prefab']) { for (const failed of [false, true]) { test(`jungle late ${stage} callback cannot touch Home/entry UI, departed=${departed}, failed=${failed}`, () => { const f = homeFixture(); f.home.jungleTreasureServerData = {}; f.state.bundleDependencies = { jungle_treasure: ['UI'] }; f.state.delayedBundles.add('UI'); f.home.showJungleTreasure = () => f.state.opened.push('jungle'); f.home.loadJungleTreasurePrefab(true); if (stage === 'prefab') f.finishBundle('UI'); if (departed) { f.home.node.destroyed = true; f.state.scene = { name: 'GameScene' }; } else f.Config.isGameplayEntryPreparing = () => true; const error = failed ? new Error('offline') : null; if (stage === 'dependency') { f.finishBundle('UI', error); assert.equal(f.state.homePrefabLoads.length, 0); } else { const prefab = failed ? null : {}; f.state.homePrefabLoads[0].done(error, prefab); if (!failed) assert.equal(f.Home.cachedJungleTreasurePrefab, prefab); } assert.equal(f.home.jungleTreasureLoading, false); assert.equal(f.home.jungleTreasureOpenAfterLoad, false); assert.deepEqual(f.state.opened, []); assert.equal(f.state.automatic.includes('closeLoad'), false); }); } } } test('cached jungle settlement bundle still waits for its missing dependencies', () => { const f = homeFixture(); f.state.bundles.set('jungle_treasure', { deps: ['UI'], load: (path, type, done) => { f.state.homePrefabLoads.push({ path, done }); } }); f.Home.cachedJungleOverPrefab = {}; f.state.delayedBundles.add('UI'); f.home.showJungleOver = () => f.state.opened.push('settlement'); f.home.loadJungleOverPrefab(); assert.equal(f.state.homePrefabLoads.length, 0); assert.deepEqual(f.state.opened, []); f.finishBundle('UI'); assert.equal(f.state.homePrefabLoads[0].path, 'prefab/jungleOver'); f.state.homePrefabLoads[0].done(null, {}); assert.deepEqual(f.state.opened, ['settlement']); });