MatchMaster/tools/test-first-game-entry.cjs

864 lines
48 KiB
JavaScript

// 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 });
require('./new-player-ab-test-support.cjs').withNewPlayerConfig(Config);
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),
scheduleGameplayIntroPredownload: () => state.events.push('intro-idle'),
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.deepEqual(state.events.slice(-2), ['down', 'intro-idle']);
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']);
});