const test = require('node:test'), assert = require('node:assert/strict'); const { loadMembers, loadModule } = require('./new-player-ab-test-support.cjs'); const file = 'assets/Script/JiaZai.ts'; const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); }; for (const sceneName of ['HomeScene', 'GameScene']) { test(`level read failure deduplicates modal, invalidates entry and confirms Home from ${sceneName}`, () => { const modals = [], launches = [], scene = { name: sceneName }, transition = { active: true }; let closed = 0, released = 0, touch = false; const home = { isShow: false, closeLoad() { closed++; } }; const canvas = { getComponent: () => home, getChildByName: () => transition }; const cc = { warn() {}, find: path => path === 'Canvas' ? canvas : { getComponent: () => ({ setTouch: value => touch = value }) }, director: { getScene: () => scene, loadScene(name, done) { launches.push(name); scene.name = name; done(); return true; } } }; const Config = loadMembers('assets/Script/module/Config/GameConfig.ts', ['showLevelReadError', 'levelReadModalOpen', 'levelLoadRequest', 'sceneLoadRequest', 'homeWarmupRequest'], { cc, wx: { showModal: options => modals.push(options) } }); Config.GM_INFO = { level: 300, hp: 5 }; Config.cancelBlockLoading = () => closed++; Config.takeEntryDownloadRelease = () => () => released++; Config.showBlockLoadError = () => assert.fail('native modal expected'); Config.showLevelReadError(Error('offline')); Config.showLevelReadError(Error('offline')); assert.equal(modals.length, 1); assert.equal(launches.length, 0); assert.equal(Config.levelLoadRequest, 1); assert.equal(Config.sceneLoadRequest, 1); assert.equal(released, 1); assert.equal(modals[0].content, '关卡读取失败,请连接网络!'); assert.equal(modals[0].showCancel, false); assert.equal(modals[0].confirmText, '确定'); modals[0].success({ confirm: true }); modals[0].success({ confirm: true }); assert.equal(scene.name, 'HomeScene'); assert.equal(Config.levelReadModalOpen, false); assert.deepEqual(Config.GM_INFO, { level: 300, hp: 5 }); if (sceneName === 'HomeScene') { assert.equal(touch, true); assert.equal(transition.active, false); assert.equal(home.isShow, true); } else assert.deepEqual(launches, ['HomeScene']); Config.showLevelReadError(Error('still offline')); assert.equal(modals.length, 2); }); } function entryFixture() { let resolve, calls = 0, launches = 0; const ready = new Promise(yes => resolve = yes), scene = { name: 'HomeScene' }; const cc = { isValid: n => !!n && !n.destroyed, warn() {}, director: { getScene: () => scene }, fx: { GameConfig: { GM_INFO: { level: 2040, hp: 5, otherLevel: 0 } }, GameTool: { getWechatGameVersion: () => '正式版', getUserPowerTime: () => true, maxLevel: () => true }, AudioManager: { _instance: { playEffect() {} } }, } }; const Home = loadMembers(file, ['startGame', 'cancelLevelStart', 'cancelLevelStartOnTouch'], { cc, MiniGameSdk: { API: { showToast() {} } }, LevelResources: { allReady: () => false, ensureAll: () => { calls++; return ready; } }, }); const home = new Home(); Object.assign(home, { node: { name: 'HomeScene' }, enabled: true, custom: { string: '' }, endlessStartIntent: 0, waitingForLevels: false, preparingWucaiTransfer: false, hasPendingWucaiEntry: () => false, vibrateButtonClick() {} }); const click = () => Home.prototype.startGame.call(home); home.startGame = () => launches++; return { home, click, resolve, scene, get calls() { return calls; }, get launches() { return launches; } }; } test('infinite start deduplicates clicks and enters exactly once after complete pool', async () => { const f = entryFixture(); f.click(); f.click(); assert.equal(f.calls, 1); assert.equal(f.launches, 0); f.resolve(); await flush(); assert.equal(f.launches, 1); }); test('another Home action, destruction or scene departure cancels automatic entry without cancelling preparation', async () => { for (const cancel of [f => f.home.cancelLevelStartOnTouch({ target: { name: 'shop', parent: f.home.node } }), f => f.home.node.destroyed = true, f => f.scene.name = 'GameScene']) { const f = entryFixture(); f.click(); cancel(f); f.resolve(); await flush(); assert.equal(f.launches, 0); } }); test('a repeated touch on Start does not cancel its pending intent', async () => { const f = entryFixture(); f.click(); f.home.cancelLevelStartOnTouch({ target: { name: 'label', parent: { name: 'startBtn', parent: f.home.node } } }); f.resolve(); await flush(); assert.equal(f.launches, 1); }); test('dynamic maxLevel preserves completed progress and clamps only the random candidate range', () => { const cc = { fx: { GameConfig: { GM_INFO: { level: 2060 } } } }; const Tool = loadMembers('assets/Script/module/Tool/GameTool.ts', ['maxLevel', 'getPurelyRandom'], { cc }); const tool = new Tool(); tool.MAX_NORMAL_LEVEL = 2040; assert.equal(tool.maxLevel(), true); assert.equal(cc.fx.GameConfig.GM_INFO.level, 2060); for (let n = 0; n < 100; n++) assert(tool.getPurelyRandom() <= 2010); tool.MAX_NORMAL_LEVEL = 2080; assert.equal(tool.maxLevel(), false); assert.equal(cc.fx.GameConfig.GM_INFO.level + 1, 2061); }); test('adapter applies login policy once, leaves preview local, and routes help to A', async () => { const calls = [], info = { version: '1.0.96', level: 59, otherLevel: 0 }; const runtime = { beginLogin: data => { calls.push(data); return { maxLevel: 2060 }; }, limits: () => ({ levelGroup: 'B' }), load: async (id, group) => { calls.push([id, group]); return { data: { id, group } }; } }; const cc = { sys: { platform: 'wechat', WECHAT_GAME: 'wechat' }, fx: { GameTool: {}, GameConfig: { GM_INFO: info } } }; const window = { __levelResources: runtime }; const Adapter = loadModule('assets/Script/module/Config/LevelResources.ts', {}, { window, cc }).default; Adapter.beginLogin({ maxLevel: 2060, bLevelCount: 80, levelGroup: 'B' }, info.version); assert.equal(cc.fx.GameTool.MAX_NORMAL_LEVEL, 2060); assert.equal((await Adapter.load(60, true, () => assert.fail('local fallback'))).group, 'B'); assert.equal((await Adapter.load(60, false, () => assert.fail('local fallback'))).group, 'A'); delete window.__levelResources; cc.sys.platform = 'web'; assert.equal(await Adapter.load(3000, true, async () => 'local preview'), 'local preview'); }); function scenePerfFixture(enabled = true) { let time = 0; const rows = [], calls = []; const perf = require('../packages/level-resources/perf').create({ enabled, now: () => time, output: line => rows.push(JSON.parse(line.slice('[LevelPerf] '.length))) }); const oldPrototype = { destroy() { assert.equal(this.name, 'HomeScene'); calls.push('destroy'); time += 5; return 9; } }; const old = Object.assign(Object.create(oldPrototype), { name: 'HomeScene' }); const scenePrototype = { _load() { assert.equal(this.name, 'GameScene'); calls.push('initialize'); time += 3; }, _activate() { calls.push('onLoad/onEnable'); time += 11; } }; const scene = Object.assign(Object.create(scenePrototype), { name: 'GameScene' }); const releaseManager = { _autoRelease() { calls.push('release'); time += 4; } }; const object = { _deferredDestroy() { calls.push('onDestroy'); time += 7; } }; const director = { getScene: () => old, runSceneImmediate(incoming, before, launched) { const target = incoming.scene || incoming; target._load(); time += 2; releaseManager._autoRelease(); assert.equal(old.destroy(), 9); object._deferredDestroy(); if (before) before(); target._activate(); if (launched) launched(null, target); return 42; } }; const cc = { director, assetManager: { _releaseManager: releaseManager }, Object: object }; const Adapter = loadModule('assets/Script/module/Config/LevelResources.ts', {}, { cc, window: { __levelPerf: perf } }).default; const originals = { run: director.runSceneImmediate, release: releaseManager._autoRelease, destroy: object._deferredDestroy }; const restored = () => { assert.equal(director.runSceneImmediate, originals.run); assert.equal(releaseManager._autoRelease, originals.release); assert.equal(object._deferredDestroy, originals.destroy); assert.equal(Object.hasOwn(scene, '_load'), false); assert.equal(Object.hasOwn(scene, '_activate'), false); assert.equal(Object.hasOwn(old, 'destroy'), false); }; return { Adapter, cc, perf, director, scene, rows, calls, restored, advance: ms => time += ms }; } test('scene resource diagnostics follow tagged dependencies, preserve callbacks and exclude unrelated loads', () => { const f = scenePerfFixture(), manager = f.cc.assetManager, callbacks = {}, options = { preset: 'scene' }; let tagged, callbackCount = 0, progressCount = 0; manager.loadAny = function (request, opts, progress) { assert.equal(this, manager); tagged = opts; progress(1, 2, {}); return 17; }; manager.packManager = { load(item, opts, callback) { assert.equal(this, manager.packManager); callbacks.fetch = callback; return 18; } }; manager.downloader = { download(id, url, type, opts, callback) { callbacks.download = callback; return 19; } }; manager.parser = { parse(id, file, type, opts, callback) { callbacks.parse = callback; return 20; } }; manager.pipeline = { async(task) { callbacks.dependencies = () => task.onComplete.call(task, null); return 21; } }; const originals = [manager.loadAny, manager.packManager.load, manager.downloader.download, manager.parser.parse, manager.pipeline.async]; const stop = f.Adapter.traceSceneLoad('GameScene', { requestId: 91 }); assert.equal(manager.loadAny({ scene: 'GameScene' }, options, () => progressCount++, () => {}), 17); assert.equal(progressCount, 1); assert.equal(options.__levelSceneTrace__, undefined, 'caller options stay untouched'); const item = { id: 'image@native', uuid: 'image', url: 'subpackages/main/image.png?secret=hidden', ext: '.png', info: { path: 'game/image' }, config: { name: 'main' } }; const done = function (error, value) { assert.equal(this, callbacks); assert.equal(error, null); assert.equal(value, 42); callbackCount++; }; assert.equal(manager.packManager.load(item, { ...tagged }, done), 18); assert.equal(manager.downloader.download(item.id, item.url, '.png', { ...tagged }, done), 19); f.advance(300); callbacks.download.call(callbacks, null, 42); callbacks.fetch.call(callbacks, null, 42); assert.equal(manager.parser.parse(item.id, {}, '.png', { ...tagged }, done), 20); f.advance(50); callbacks.parse.call(callbacks, null, 42); const task = { input: [{ uuid: 'dep' }], options: { ...tagged }, onComplete(error) { assert.equal(this, task); assert.equal(error, null); callbackCount++; } }; const complete = task.onComplete; assert.equal(manager.pipeline.async(task), 21); f.advance(25); callbacks.dependencies(); assert.equal(task.onComplete, complete); manager.parser.parse('unrelated', {}, 'import', {}, () => callbackCount++); callbacks.parse(); assert.equal(callbackCount, 5); stop({ status: 'ok' }); f.restored(); assert.deepEqual([manager.loadAny, manager.packManager.load, manager.downloader.download, manager.parser.parse, manager.pipeline.async], originals); const summary = f.rows.find(row => row.event === 'summary' && row.scene); assert.equal(summary.metrics['scene.resource.fetch'].maxMs, 300); assert.equal(summary.metrics['scene.resource.download'].maxMs, 300); assert.equal(summary.metrics['scene.resource.parse'].count, 1); assert.equal(summary.metrics['scene.resource.parse'].maxMs, 50); assert.equal(summary.metrics['scene.resource.dependencies'].maxMs, 25); assert.equal(summary.scene['scene.resource.parse'][0].path, 'game/image'); assert(!JSON.stringify(f.rows).includes('secret')); assert(!f.rows.some(row => row.event === 'begin' && row.stage.startsWith('scene.resource.'))); }); test('scene resource diagnostics close pending spans on failure and preserve late callbacks', () => { const f = scenePerfFixture(), manager = f.cc.assetManager; let tagged, callback, calls = 0; manager.loadAny = (_request, options) => { tagged = options; }; manager.parser = { parse(_id, _file, _type, _options, done) { callback = done; } }; const original = manager.parser.parse, stop = f.Adapter.traceSceneLoad('GameScene'); manager.loadAny({ scene: 'GameScene' }, {}, null, () => {}); manager.parser.parse('slow', {}, 'import', tagged, () => calls++); f.advance(3000); f.perf.flush(); const pending = f.rows.find(row => row.event === 'summary').pending.find(row => row.stage === 'scene.resource.parse'); assert.equal(pending.resource, 'slow'); assert.equal(pending.elapsedMs, 3000); stop({ status: 'error' }); assert.equal(manager.parser.parse, original); callback(null, {}); assert.equal(calls, 1); f.perf.flush(); assert.equal(f.rows.filter(row => row.event === 'summary').at(-1).pendingCount, 0); }); test('scene diagnostics split resource waiting and engine phases without changing callbacks or order', () => { const f = scenePerfFixture(), done = f.Adapter.traceSceneLoad('GameScene', { requestId: 7 }); f.advance(100); let callbackCount = 0; assert.equal(f.director.runSceneImmediate({ scene: f.scene }, () => f.calls.push('before'), (error, scene) => { assert.equal(error, null); assert.equal(scene, f.scene); callbackCount++; done({ status: 'ok' }); }), 42); done(); f.restored(); assert.equal(callbackCount, 1); assert.deepEqual(f.calls, ['initialize', 'release', 'destroy', 'onDestroy', 'before', 'onLoad/onEnable']); const ends = f.rows.filter(row => row.event === 'end'); const expected = { resourceLoad: 100, 'initialize.sync': 3, 'releaseAssets.sync': 4, 'destroyOldScene.sync': 5, 'deferredDestroy.sync': 7, 'activate.sync': 11, 'switch.sync': 32 }; assert.equal(ends.length, Object.keys(expected).length); for (const [part, ms] of Object.entries(expected)) { const row = ends.find(row => row.stage === 'scene.GameScene.' + part); assert.equal(row.ms, ms); assert.equal(row.requestId, undefined); // request identity is in its paired begin. assert.equal(row.status, 'ok'); } }); test('scene diagnostics restore hooks on load rejection and thrown lifecycle, and disabled logging installs nothing', () => { const rejected = scenePerfFixture(), stop = rejected.Adapter.traceSceneLoad('GameScene'); stop({ status: 'rejected' }); rejected.restored(); assert(rejected.rows.some(row => row.event === 'end' && row.status === 'rejected')); const f = scenePerfFixture(); const sentinel = Error('lifecycle failed'); f.scene._activate = () => { throw sentinel; }; const originalActivate = f.scene._activate; const finish = f.Adapter.traceSceneLoad('GameScene'); assert.throws(() => f.director.runSceneImmediate(f.scene), error => error === sentinel); finish({ status: 'error' }); assert.equal(f.scene._activate, originalActivate); delete f.scene._activate; f.restored(); assert(f.rows.some(row => row.stage === 'scene.GameScene.activate.sync' && row.status === 'error')); const disabled = scenePerfFixture(false); disabled.Adapter.traceSceneLoad('GameScene')(); disabled.restored(); assert.equal(disabled.rows.length, 0); });