// Run with TYPESCRIPT_PATH pointing to typescript/lib/typescript.js if it is not installed locally. 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(process.env.TYPESCRIPT_PATH || 'typescript'); const root = path.resolve(__dirname, '..'); const readJSON = file => JSON.parse(fs.readFileSync(path.join(root, file), 'utf8').replace(/^\uFEFF/, '')); const level = n => readJSON(`assets/custom/Json/level${n}.json`); const atlasPaths = ['BlockBtnUI', 'door', 'down']; const imagePaths = fs.readdirSync(path.join(root, 'assets/Block')).filter(n => n.endsWith('.png')) .map(n => n.slice(0, -4)).sort(); const filePaths = imagePaths.concat(atlasPaths.map(name => name + '.plist')).sort(); const sorted = values => [...new Set(values)].sort(); const tick = () => new Promise(resolve => setImmediate(resolve)); // Parse the real XML independently with Python's standard library, so stale Cocos // .meta files cannot make the runtime layout tests pass against the wrong atlas. const plistFixtures = JSON.parse(require('node:child_process').execFileSync(process.env.PYTHON || 'python', [ '-c', 'import json,plistlib,sys,pathlib; print(json.dumps({pathlib.Path(p).stem:plistlib.loads(pathlib.Path(p).read_bytes()) for p in sys.argv[1:]}))', ...atlasPaths.map(name => path.join(root, 'assets/Block', name + '.plist')) ], { encoding: 'utf8' })); const atlasPlistFixture = name => JSON.parse(JSON.stringify(plistFixtures[name])); async function waitFor(check, message) { for (let i = 0; i < 1000 && !check(); i++) await tick(); assert.ok(check(), message); } function releaseHeldDownloads(state) { state.hold.clear(); for (const [key, finish] of [...state.deferred]) if (key.startsWith('download:')) finish(); } function fireTimer(state, delay) { const timers = [...state.timers.entries()].filter(([, timer]) => timer.delay === delay); assert.equal(timers.length, 1, `expected one ${delay}ms timer`); const [id, timer] = timers[0]; state.timers.delete(id); timer.cb(); } function fixture(previous) { const state = { loads: [], plistLoads: [], jsonLoads: [], downloads: 0, releases: [], scenes: [], errors: [], loadingEvents: [], mapsInitialized: 0, bundleLoads: [], requests: [], logs: [], consoleLogs: [], gameListeners: [], sceneListeners: [], timers: new Map(), requestAborts: 0, fileRequests: [], activeDownloads: new Set(), maxActiveDownloads: 0, directRequests: [], directories: new Set(['http://usr']), cacheWrites: 0, manifest: { statusCode: 200, data: { version: '1.0.0' } }, manifestError: null, plists: new Map(atlasPaths.map(name => [name, atlasPlistFixture(name)])), textures: new Map(), mkdirCalls: [], mkdirError: null, offline: false, fail: new Set(), badJson: new Set(), deferred: new Map(), hold: new Set(), disk: new Map(), caches: new Map(), temps: new Map(), nativeFiles: new Map(), storage: new Map(), assets: new Map(), save: true, scene: { name: 'HomeScene' } }; if (previous) { for (const key of ['disk', 'caches', 'storage', 'downloads', 'directories']) state[key] = previous[key]; } const cache = { getCache: url => state.caches.get(url) || '', getTemp: url => state.temps.get(url) || '', tempFiles: { remove: url => state.temps.delete(url) }, removeCache: url => state.caches.delete(url), cacheFile(url, temp) { state.cacheWrites++; if (!state.save || !state.disk.has(temp)) return; const local = 'saved/' + url; state.disk.set(local, state.disk.get(temp)); state.caches.set(url, local); } }; const stats = filename => { if (state.directories.has(filename)) { return { size: 0, isFile: () => false, isDirectory: () => true }; } if (!state.disk.has(filename)) throw new Error('ENOENT'); return { size: 12, isFile: () => true, isDirectory: () => false }; }; function requestManifest(options) { state.requests.push(options); const finish = () => state.manifestError || state.offline ? options.fail(state.manifestError || new Error('offline')) : options.success(state.manifest); if (state.hold.has('version')) state.deferred.set('version', finish); else queueMicrotask(finish); return { abort() { state.requestAborts++; } }; } const fsManager = { statSync: stats, readFileSync: file => state.disk.get(file), mkdirSync(directory) { state.mkdirCalls.push(directory); if (state.mkdirError) throw state.mkdirError; if (state.disk.has(directory)) throw new Error('mkdirSync:fail file already exists ' + directory); if (state.directories.has(directory)) throw new Error('mkdirSync:fail file already exists ' + directory); state.directories.add(directory); }, unlinkSync(file) { if (!state.disk.delete(file)) throw new Error('unlinkSync:fail no such file ' + file); }, renameSync(source, target) { if (!state.disk.has(source)) throw new Error('renameSync:fail no such file ' + source); state.disk.set(target, state.disk.get(source)); state.disk.delete(source); }, copyFile(options) { queueMicrotask(() => { if (!state.save || !state.disk.has(options.srcPath)) return options.fail(new Error('copyFile:fail')); state.disk.set(options.destPath, state.disk.get(options.srcPath)); options.success(); }); } }; const remoteName = remote => { const match = String(remote).match(/([^/]+)\.(png|plist)(?:\?|$)/); return match ? match[1] + (match[2] === 'plist' ? '.plist' : '') : ''; }; const wx = { env: { USER_DATA_PATH: 'http://usr' }, getFileSystemManager: () => fsManager, request: requestManifest, downloadFile(options) { const name = remoteName(options.url); state.directRequests.push({ name, url: options.url, filePath: options.filePath }); state.fileRequests.push(name); state.activeDownloads.add(name); state.maxActiveDownloads = Math.max(state.maxActiveDownloads, state.activeDownloads.size); const key = 'download:' + name; const finish = () => { state.deferred.delete(key); state.activeDownloads.delete(name); if (state.offline || state.fail.has(name) || !state.save) { options.fail(new Error(state.save ? 'network error' : 'storage full')); return; } state.downloads++; state.disk.set(options.filePath, name.endsWith('.plist') ? JSON.stringify(state.plists.get(name.slice(0, -6))) : 'valid'); options.success({ statusCode: 200, filePath: options.filePath }); }; if (state.hold.has(key)) state.deferred.set(key, finish); else queueMicrotask(finish); return { abort() {} }; } }; const tt = {}; let timerId = 0; class Texture2D { constructor(name, url) { this.name = name; this.url = url; this.packable = true; this.refs = 0; const png = fs.readFileSync(path.join(root, 'assets/Block', name + '.png')); this.width = png.readUInt32BE(16); this.height = png.readUInt32BE(20); } addRef() { this.refs++; } decRef() { assert.ok(this.refs > 0, 'reference must be owned before releasing'); if (--this.refs === 0) { this.destroyed = true; state.releases.push(this.name); state.textures.delete(this.url); } } } class SpriteFrame { constructor(texture, rect, rotated, offset, originalSize) { this.texture = texture; this.rect = rect; this.rotated = rotated; this.offset = offset; this.originalSize = originalSize; this.insetTop = this.insetBottom = this.insetLeft = this.insetRight = 0; state.assets.set(texture.name, this); } getTexture() { return this.texture; } destroy() { assert.ok(!this.destroyed, 'dynamic frame must be destroyed only once'); this.destroyed = true; state.assets.delete(this.name); } } function createScene(name) { const scene = { name, loading: false }; scene.controller = { openLoad() { scene.loading = true; state.loadingEvents.push(name + ':open'); }, closeLoad() { scene.loading = false; state.loadingEvents.push(name + ':close'); } }; scene.canvas = { getComponent: type => type === (name === 'HomeScene' ? 'JiaZai' : 'SceneManager') ? scene.controller : null }; return scene; } state.scene = createScene('HomeScene'); const cc = { SpriteFrame, Texture2D, JsonAsset: class {}, Component: class {}, Director: { EVENT_AFTER_SCENE_LAUNCH: 'after-scene-launch' }, rect: (x, y, width, height) => ({ x, y, width, height }), v2: (x, y) => ({ x, y }), size: (width, height) => ({ width, height }), find: () => state.scene.canvas || null, isValid: object => !!object && !object.destroyed, _decorator: { ccclass: value => typeof value === 'function' ? value : type => type, property: () => {} }, debug: { DebugMode: { INFO: 1, ERROR: 3 } }, game: { config: { debugMode: 1 }, EVENT_SHOW: 'show', on: (event, callback, target) => state.gameListeners.push({ event, callback, target }), off: (event, callback, target) => { state.gameListeners = state.gameListeners.filter(item => item.event !== event || item.callback !== callback || item.target !== target); }, emit: (event, payload) => state.gameListeners.filter(item => item.event === event) .forEach(({ callback, target }) => callback.call(target, payload)) }, log: (...args) => state.logs.push(args), warn() {}, error() {}, fx: { GameConfig: { BLOCK_IMAGE_BASE_URL: 'https://cdn.test/remote/Block/', BLOCK_IMAGE_VERSION: '1.0.0' } }, sys: { OS_IOS: 'iOS', OS_ANDROID: 'Android', localStorage: { getItem: key => state.storage.get(key), setItem: (key, value) => state.storage.set(key, value) } }, director: { on: (event, callback, target) => state.sceneListeners.push({ event, callback, target }), off: (event, callback, target) => { state.sceneListeners = state.sceneListeners.filter(item => item.event !== event || item.callback !== callback || item.target !== target); }, getScene: () => state.scene, preloadScene: (name, cb) => queueMicrotask(() => cb(null)), loadScene: name => { state.scenes.push(name); if (state.scene.controller) state.scene.controller.destroyed = true; state.scene = createScene(name); state.sceneListeners.forEach(({ callback, target }) => callback.call(target)); } } }; const customBundle = { getInfoWithPath(name) { return fs.existsSync(path.join(root, 'assets/custom', name + '.json')) ? { path: name } : null; }, load(name, type, cb) { state.jsonLoads.push(name); const finish = () => state.fail.has(name) ? cb(new Error('JSON download failed')) : cb(null, { json: state.badJson.has(name) ? {} : readJSON('assets/custom/' + name + '.json') }); if (state.hold.has(name)) state.deferred.set(name, finish); else queueMicrotask(finish); } }; const url = (name, version = '1.0.0') => `https://cdn.test/remote/Block/${name.endsWith('.plist') ? name : name + '.png'}?v=${version}`; const userFile = (name, version = '1.0.0') => `http://usr/Block/${version}/${name.endsWith('.plist') ? name : name + '.png'}`; const imageName = remote => remoteName(remote); const download = (remote, reload) => { let file = !reload && (state.nativeFiles.get(remote + '@native') || cache.getCache(remote) || cache.getTemp(remote)); if (file && !state.disk.has(file)) throw new Error('missing local file'); if (!file) { if (state.offline || state.fail.has(imageName(remote))) throw new Error('network error'); state.downloads++; file = 'temp/' + state.downloads; state.disk.set(file, imageName(remote).endsWith('.plist') ? JSON.stringify(state.plists.get(imageName(remote).slice(0, -6))) : 'valid'); state.temps.set(remote, file); cache.cacheFile(remote, file); } state.nativeFiles.set(remote + '@native', file); return file; }; const downloading = new Map(); const requestFile = (remote, reload, cb) => { if (downloading.has(remote)) { downloading.get(remote).push(cb); return; } const callbacks = [cb]; downloading.set(remote, callbacks); state.fileRequests.push(imageName(remote)); state.activeDownloads.add(imageName(remote)); state.maxActiveDownloads = Math.max(state.maxActiveDownloads, state.activeDownloads.size); const finish = () => { let error, file; try { file = download(remote, reload); } catch (err) { error = err; } downloading.delete(remote); state.activeDownloads.delete(imageName(remote)); state.deferred.delete(key); callbacks.forEach(callback => callback(error, file)); }; const key = 'download:' + imageName(remote); if (state.hold.has(key)) state.deferred.set(key, finish); else finish(); }; const requestNative = (remote, options, cb) => requestFile(remote, options.reload, cb); cc.assetManager = { getBundle: () => null, releaseAsset(asset) { asset.destroyed = true; if (asset instanceof Texture2D) state.textures.delete(asset.url); }, cacheManager: cache, downloader: { // Deliberately no bundleVers/remoteServerAddress: raw PNGs do not depend on a build. maxRetryCount: 3, maxConcurrency: 8, downloadFile(remote, options, progress, cb) { return requestManifest({ url: remote, ...options, success: response => cb(response.statusCode === 200 ? null : new Error('HTTP error'), response.data), fail: error => cb(error) }); } }, _files: { get: key => state.nativeFiles.get(key), remove: key => state.nativeFiles.delete(key) }, _parsed: { remove() {} }, loadBundle: (name, options, cb) => queueMicrotask(() => { state.bundleLoads.push(name); assert.notEqual(name, 'Block', 'Block must not use any Cocos build artifacts'); (cb || options)(null, customBundle); }), loadRemote(remote, options, cb) { const cachedUrl = [...state.caches, ...state.temps].find(([, file]) => file === remote)?.[0]; const nativeKey = [...state.nativeFiles].find(([, file]) => file === remote)?.[0]; const name = imageName(cachedUrl || (nativeKey && nativeKey.replace(/@native$/, '')) || remote); (name.endsWith('.plist') ? state.plistLoads : state.loads).push({ name, url: remote, reload: options.reload }); const consume = (error, file) => { try { if (error) throw error; if (state.disk.get(file) === 'corrupt') throw new Error('decode error'); if (name.endsWith('.plist')) { cb(null, { _nativeAsset: JSON.parse(state.disk.get(file)), name }); return; } const texture = state.textures.get(remote) || new Texture2D(name, remote); state.textures.set(remote, texture); cb(null, texture); } catch (error) { cb(error); } }; const finish = () => state.disk.has(remote) ? consume(null, remote) : requestNative(remote, options, consume); const key = options.reload && state.hold.has(name + ':retry') ? name + ':retry' : name; if (state.hold.has(key)) state.deferred.set(key, finish); else queueMicrotask(finish); } }; const modules = new Map(); function load(file) { const absolute = path.resolve(root, file); if (modules.has(absolute)) return modules.get(absolute).exports; const mod = { exports: {} }; modules.set(absolute, mod); const source = fs.readFileSync(absolute, 'utf8'); const output = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS, experimentalDecorators: true } }).outputText; vm.runInNewContext(output, { module: mod, exports: mod.exports, cc, wx, tt, console: { log: (...args) => { state.consoleLogs.push(args); state.logs.push(args); }, warn() {}, error() {} }, window: {}, setTimeout: (cb, delay) => { const id = ++timerId; if (delay < 1000) queueMicrotask(cb); else state.timers.set(id, { cb, delay }); return id; }, clearTimeout: id => state.timers.delete(id), require(request) { const target = path.resolve(path.dirname(absolute), request) + '.ts'; if (['BlockTexturePaths.ts', 'GameplayAtlasFrames.ts', 'BlockAssetManager.ts', 'GameConfig.ts', 'GameTool.ts', 'Map.ts'].includes(path.basename(target))) return load(target); return { MiniGameSdk: { API: {} }, default: {} }; } }, { filename: absolute }); return mod.exports; } const Manager = load('assets/Script/module/Tool/BlockAssetManager.ts').default; const pathsModule = load('assets/Script/module/Tool/BlockTexturePaths.ts'); const collect = pathsModule.collectBlockTexturePaths; function config() { const Config = load('assets/Script/module/Config/GameConfig.ts').GameConfig; cc.fx.GameConfig = Config; Config.BLOCK_IMAGE_BASE_URL = 'https://cdn.test/remote/Block/'; cc.fx.GameTool = load('assets/Script/module/Tool/GameTool.ts').GameTool; Config.GM_INFO = { level: 49, otherLevel: 0, GameplayType: 0 }; Config.showBlockLoadError = (error, retry) => state.errors.push({ error, retry }); return Config; } function map() { const MapController = load('assets/Script/Map.ts').default; return Object.assign(Object.create(MapController.prototype), { blockAssetsReady: false, initMap() { state.mapsInitialized++; }, showEnterNewModeIfNeeded() {} }); } return { state, cc, wx, tt, Manager, manager: Manager.instance, collect, pathsModule, config, cache, url, userFile, createScene, map, load }; } test('home warmup cannot cancel entry when its level JSON arrives late', async () => { const { config, cc, state } = fixture(); const Config = config(); cc.fx.GameTool.shouldPreloadGameplayPopupInstances = () => true; cc.fx.GameTool.areGameplayPopupSubpackagesDownloaded = () => true; cc.fx.GameTool.ensureGameplayPopupSubpackagesDownloaded = () => Promise.resolve(true); cc.fx.GameTool.warmupHomeGameplayPopupAssets = () => Promise.resolve(true); state.scene.controller.playGameEntryTransition = force => assert.equal(force, true); Config.applyLevelData(level(50), false, 0, 50); state.hold.add('Json/level50'); state.hold.add('door'); const warmup = Config.warmupHomeBlockAssets(); await waitFor(() => state.deferred.has('Json/level50'), 'home level JSON is pending'); Config.enterGameSceneWhenReady(); await waitFor(() => state.deferred.has('door'), 'entry atlas decode is pending'); state.deferred.get('Json/level50')(); await tick(); state.deferred.get('door')(); await warmup; await waitFor(() => [...state.timers.values()].some(timer => timer.delay === 1000), 'first entry transition is ready'); assert.deepEqual(state.scenes, [], 'scene launch waits for the transition'); fireTimer(state, 1000); await waitFor(() => state.scenes.length > 0, 'formal entry must still launch GameScene'); assert.deepEqual(state.scenes, ['GameScene']); assert.equal(state.errors.length, 0); }); test('home warmup preserves a prepared entry until transition and resumes after returning home', async () => { const { manager, cc } = fixture(); const current = [{ block: 0, color: 1 }]; const other = [{ block: 1, color: 2 }]; assert.equal(await manager.prepareWindow(current), true); const frame = manager.getFrame('1color0'); assert.equal(await manager.prepareWindow(other, { homeWarmup: true }), false); assert.equal(manager.getFrame('1color0'), frame, 'prepared entry texture must remain valid'); cc.director.loadScene('GameScene'); manager.activateScene(current); manager.releaseScene(); cc.director.loadScene('HomeScene'); assert.equal(await manager.prepareWindow(other, { homeWarmup: true }), true); assert.ok(manager.getFrame('2color1')); }); test('home warmup yields to a later formal entry and shares pending atlas loads', async () => { const { manager, state, cc } = fixture(); state.hold.add('door'); const warmup = manager.prepareWindow([{ block: 0, color: 1 }], { homeWarmup: true, extraPaths: atlasPaths }); await waitFor(() => state.deferred.has('door'), 'warmup atlas decode is pending'); const current = [{ block: 1, color: 2 }]; const entry = manager.prepareWindow(current, { extraPaths: atlasPaths }); state.deferred.get('door')(); assert.equal(await warmup, false); assert.equal(await entry, true); cc.director.loadScene('GameScene'); manager.activateScene(current, atlasPaths); assert.ok(manager.getFrame('2color1')); assert.equal(state.plistLoads.filter(load => load.name === 'door.plist').length, 1); }); test('remote atlases preserve every imported frame and release shared textures once', async () => { const { manager, state, cc } = fixture(); await manager.prepareWindow([], { extraPaths: atlasPaths }); assert.equal(state.downloads, 6); manager.activateScene([], atlasPaths); const children = []; for (const atlas of atlasPaths) { const plist = atlasPlistFixture(atlas); const numbers = value => value.match(/-?\d+(?:\.\d+)?/g).map(Number); for (const [filename, data] of Object.entries(plist.frames)) { const name = filename.replace(/\.png$/, ''); const frame = manager.getAtlasFrame(atlas, name); children.push(frame); assert.equal(frame, manager.getAtlasFrame(atlas, name)); assert.equal(frame.getTexture(), manager.getFrame(atlas).getTexture()); const [x, y, width, height] = numbers(data.frame); assert.deepEqual({ ...frame.rect }, { x, y, width, height }); assert.equal(frame.rotated, data.rotated); assert.deepEqual([frame.offset.x, frame.offset.y], numbers(data.offset)); assert.deepEqual([frame.originalSize.width, frame.originalSize.height], numbers(data.sourceSize)); assert.deepEqual([frame.insetTop, frame.insetBottom, frame.insetLeft, frame.insetRight], atlas === 'door' && name === 'turn_bg' ? [0, 0, 30, 30] : [0, 0, 0, 0]); } assert.equal(manager.getFrame(atlas).getTexture().refs, 1); } manager.releaseScene(); assert.ok(children.every(frame => !frame.destroyed), 'GameScene destruction keeps common frames'); cc.director.loadScene('HomeScene'); assert.ok(children.every(frame => frame.destroyed)); assert.deepEqual(state.releases.sort(), atlasPaths.slice().sort()); assert.deepEqual([...manager.getLoadedPaths()], []); }); test('remote atlases gate gameplay, retry failure, and reuse cache offline', async () => { const { manager, state, userFile, config, map } = fixture(); const Config = config(); Config.applyLevelData(level(50), false, 0, 50); let loading = 0; state.fail.add('door'); await assert.rejects(Config.prepareBlockWindow(() => loading++)); assert.ok(loading > 0); state.fail.clear(); assert.equal(await Config.prepareBlockWindow(), true); assert.ok(atlasPaths.every(name => state.disk.has(userFile(name)))); await map().prepareBlockAssets(); assert.equal(state.mapsInitialized, 1); const before = state.downloads; manager.releaseScene(); state.offline = true; assert.equal(await Config.prepareBlockWindow(() => assert.fail('cache should not show loading')), true); assert.equal(state.downloads, before); assert.ok(atlasPaths.every(name => manager.getFrame(name))); }); test('remote atlases finish loading before any block texture enters memory', async () => { const { manager, state } = fixture(); state.hold.add('door'); const ready = manager.prepareWindow([{ block: 0, color: 1 }], { extraPaths: atlasPaths }); await waitFor(() => state.deferred.has('door'), 'door decode is pending'); assert.deepEqual(state.loads.map(load => load.name).sort(), atlasPaths.slice().sort()); assert.ok(!state.fileRequests.includes('1color0')); state.deferred.get('door')(); assert.equal(await ready, true); assert.equal(state.loads[state.loads.length - 1].name, '1color0'); }); test('remote atlases apply the 5000 MB boundary only to iOS', async () => { const { manager, state, cc, wx, config } = fixture(); config(); const gameTool = cc.fx.GameTool; const cases = [ ['ios', 4096, false], ['ios', 4999, false], ['ios', 5000, true], ['ios', 8192, true], ['ios', undefined, false], ['ios', 0, false], ['ios', 'invalid', false], ['ios', Infinity, false], ['android', 2048, true], ['android', undefined, true], ['android', 8192, true], ['devtools', 8192, false] ]; for (const [platform, memorySize, retain] of cases) { wx.getDeviceInfo = () => ({ platform, memorySize }); assert.equal(gameTool.shouldRetainGameplayAtlasesOnHome(), retain, `${platform}/${memorySize}`); await manager.prepareWindow([], { extraPaths: atlasPaths }); cc.director.loadScene('GameScene'); manager.activateScene([], atlasPaths); const original = manager.getFrame('door'); manager.releaseScene(); cc.director.loadScene('HomeScene'); assert.equal(!original.destroyed, retain, `HomeScene: ${platform}/${memorySize}`); if (retain) { const loads = state.loads.length; await manager.prepareWindow([], { extraPaths: atlasPaths }); assert.equal(state.loads.length, loads); assert.equal(manager.getFrame('door'), original); } } wx.getDeviceInfo = () => ({ memorySize: 8192 }); cc.sys.os = cc.sys.OS_ANDROID; assert.equal(gameTool.shouldRetainGameplayAtlasesOnHome(), true); cc.sys.os = cc.sys.OS_IOS; assert.equal(gameTool.shouldRetainGameplayAtlasesOnHome(), true); wx.getDeviceInfo = () => { throw new Error('device info unavailable'); }; assert.equal(gameTool.shouldRetainGameplayAtlasesOnHome(), false); }); test('remote atlases low-memory iOS warnings preserve active and pending gameplay, then release at home', async () => { const { manager, cc, wx, config } = fixture(); config(); const gameTool = cc.fx.GameTool; cc.sys.WECHAT_GAME = cc.sys.platform = 'wechat'; wx.getDeviceInfo = () => ({ platform: 'ios', memorySize: 4096 }); await manager.prepareWindow([], { extraPaths: atlasPaths }); cc.director.loadScene('GameScene'); manager.activateScene([], atlasPaths); const frame = manager.getFrame('door'); gameTool.handleMemoryWarning(); assert.equal(gameTool.isLowMemoryMode(), true); assert.ok(!frame.destroyed); manager.releaseScene(); cc.director.loadScene('HomeScene'); assert.ok(frame.destroyed); gameTool._lowMemoryMode = false; await manager.prepareWindow([], { extraPaths: atlasPaths }); const pending = manager.getFrame('door'); gameTool.handleMemoryWarning(); assert.equal(gameTool.isLowMemoryMode(), true); assert.equal(gameTool._memoryWarningCount, 2); assert.ok(!pending.destroyed, 'warning preserves textures prepared for the next game'); }); test('Android and high-memory iOS warnings leave state, resources and GC untouched', async () => { const { manager, state, cc, wx, config } = fixture(); config(); cc.sys.WECHAT_GAME = cc.sys.platform = 'wechat'; const gameTool = cc.fx.GameTool; let events = 0, cleanups = 0, gcCalls = 0; cc.game.on(gameTool.MEMORY_WARNING_EVENT, () => events++); gameTool.releaseHomeGameplayPopupWarmup = () => cleanups++; wx.triggerGC = () => gcCalls++; for (const [platform, memorySize] of [ ['android', 2048], ['android', 8192], ['android', undefined], ['ios', 5000], ['ios', 8192] ]) { wx.getDeviceInfo = () => ({ platform, memorySize }); await manager.prepareWindow([], { extraPaths: atlasPaths }); cc.director.loadScene('HomeScene'); const frame = manager.getFrame('door'); const timers = state.timers.size; gameTool.handleMemoryWarning({ level: 10 }); gameTool.handleMemoryWarning({ level: 15 }); assert.equal(gameTool.isLowMemoryMode(), false, `${platform}/${memorySize}`); assert.equal(gameTool._memoryWarningCount, 0); assert.equal(events, 0); assert.equal(cleanups, 0); assert.equal(gcCalls, 0); assert.equal(state.timers.size, timers); assert.ok(!frame.destroyed); cc.director.loadScene('HomeScene'); assert.equal(manager.getFrame('door'), frame); } }); test('remote atlases stay identical across GameScene replay and next level, then release on HomeScene', async () => { const { manager, state, cc, userFile } = fixture(); const first = [{ block: 0, color: 1 }], next = [{ block: 1, color: 2 }]; const options = { extraPaths: atlasPaths }; await manager.prepareWindow(first, options); cc.director.loadScene('GameScene'); manager.activateScene(first, atlasPaths); const original = atlasPaths.map(name => manager.getFrame(name)); const child = manager.getAtlasFrame('door', 'wall1'); // 重玩先销毁旧场景,再准备新场景。 manager.releaseScene(); cc.director.loadScene('GameScene'); await manager.prepareWindow(first, options); manager.activateScene(first, atlasPaths); // 下一关先准备,随后销毁旧场景。 await manager.prepareWindow(next, options); manager.releaseScene(); cc.director.loadScene('GameScene'); manager.activateScene(next, atlasPaths); atlasPaths.forEach((name, index) => assert.equal(manager.getFrame(name), original[index])); assert.equal(manager.getAtlasFrame('door', 'wall1'), child); assert.equal(state.loads.filter(load => atlasPaths.includes(load.name)).length, 3); assert.ok(!state.releases.some(name => atlasPaths.includes(name))); manager.releaseScene(); cc.director.loadScene('HomeScene'); assert.ok(child.destroyed); assert.ok(original.every(frame => frame.destroyed)); assert.deepEqual([...manager.getLoadedPaths()], []); assert.ok(atlasPaths.every(name => state.disk.has(userFile(name))), 'disk cache survives returning home'); const downloads = state.downloads; await manager.prepareWindow(next, options); assert.equal(state.downloads, downloads); atlasPaths.forEach((name, index) => assert.notEqual(manager.getFrame(name), original[index])); assert.equal(state.sceneListeners.length, 1); }); test('remote atlases returning home cancels in-flight entry and frees late textures', async () => { const { manager, state, cc } = fixture(); state.hold.add('door'); const ready = manager.prepareWindow([{ block: 0, color: 1 }], { extraPaths: atlasPaths }); await waitFor(() => state.deferred.has('door'), 'door decode is pending'); cc.director.loadScene('HomeScene'); assert.deepEqual([...manager.getLoadedPaths()], []); state.deferred.get('door')(); assert.equal(await ready, false); assert.deepEqual([...manager.getLoadedPaths()], []); assert.ok(!state.fileRequests.includes('1color0')); assert.deepEqual(state.releases.sort(), atlasPaths.slice().sort()); }); test('remote atlases background cache completes the three PNGs before downloading blocks', async () => { const { manager, state } = fixture(); state.hold.add('download:door'); manager.startBackgroundCache(); await waitFor(() => state.deferred.has('download:door'), 'background door download is pending'); await tick(); assert.deepEqual(state.fileRequests.slice().sort(), atlasPaths.concat(atlasPaths.map(name => name + '.plist')).sort()); assert.equal(state.loads.length, 0, 'background caching must not decode textures'); state.hold.clear(); state.deferred.get('download:door')(); await waitFor(() => !manager.backgroundRunning, 'background cache completes'); assert.deepEqual(state.fileRequests.slice(0, 6).sort(), atlasPaths.concat(atlasPaths.map(name => name + '.plist')).sort()); assert.equal(state.downloads, filePaths.length); assert.equal(state.loads.length, 0); }); test('remote atlases use new-version plist coordinates and new frame names without compiled layout data', async () => { const first = fixture(); await first.manager.prepareWindow([], { extraPaths: ['door'] }); const old = first.manager.getAtlasFrame('door', 'wall1'); const updated = fixture(first.state); updated.state.manifest.data.version = '1.0.1'; const plist = updated.state.plists.get('door'); plist.frames['wall1.png'] = { frame: '{{10,20},{50,30}}', offset: '{2,-3}', sourceSize: '{60,40}', rotated: true }; plist.frames['remote_only.png'] = { frame: '{{100,200},{20,40}}', offset: '{0,0}', sourceSize: '{20,40}', rotated: false }; await updated.manager.prepareWindow([], { extraPaths: ['door'] }); const frame = updated.manager.getAtlasFrame('door', 'wall1'); assert.notDeepEqual({ ...frame.rect }, { ...old.rect }); assert.deepEqual({ ...frame.rect }, { x: 10, y: 20, width: 50, height: 30 }); assert.equal(frame.rotated, true); assert.deepEqual({ ...frame.offset }, { x: 2, y: -3 }); assert.ok(updated.manager.getAtlasFrame('door', 'remote_only')); assert.deepEqual(updated.state.directRequests.map(request => request.url), [updated.url('door.plist', '1.0.1'), updated.url('door', '1.0.1')]); assert.ok(updated.state.disk.has(updated.userFile('door.plist', '1.0.1'))); const offline = fixture(updated.state); offline.state.offline = true; const downloads = offline.state.downloads; await offline.manager.prepareWindow([], { extraPaths: ['door'] }); assert.equal(offline.state.downloads, downloads); assert.deepEqual({ ...offline.manager.getAtlasFrame('door', 'wall1').rect }, { ...frame.rect }); }); test('remote atlases missing or malformed plist prevents PNG/block loading and exposes retry', async () => { const { manager, state } = fixture(); const blocks = [{ block: 0, color: 1 }], options = { extraPaths: ['door'] }; state.fail.add('door.plist'); await assert.rejects(manager.prepareWindow(blocks, options)); assert.equal(state.loads.length, 0); state.fail.clear(); const original = state.plists.get('door'); state.plists.set('door', { metadata: {}, frames: {} }); await assert.rejects(manager.prepareWindow(blocks, options), /plist/); assert.equal(state.loads.length, 0); // A corrected server file replaces the bad cached descriptor on retry. state.plists.set('door', original); assert.equal(await manager.prepareWindow(blocks, options), true); assert.ok(manager.getAtlasFrame('door', 'wall1')); }); test('remote atlases reject mismatched PNG dimensions and invalid plist frame bounds', async () => { const { manager, state, load } = fixture(); state.plists.get('door').metadata.size = '{1024,4096}'; await assert.rejects(manager.prepareWindow([], { extraPaths: ['door'] }), /PNG/); assert.deepEqual([...manager.getLoadedPaths()], []); assert.equal(state.textures.size, 0, 'mismatched decoded textures must be released'); const { parseGameplayAtlas } = load('assets/Script/module/Tool/GameplayAtlasFrames.ts'); const invalid = atlasPlistFixture('door'); invalid.frames['wall1.png'].frame = '{{10000,10000},{50,30}}'; assert.throws(() => parseGameplayAtlas(invalid, 'door'), /plist/); }); test('remote atlases a corrupt local plist is removed and downloaded again', async () => { const { manager, state, userFile } = fixture(); state.disk.set(userFile('door.plist'), 'not XML or parsed plist'); let loading = 0; await manager.prepareWindow([], { extraPaths: ['door'], onRemoteRequired: () => loading++ }); assert.ok(loading > 0); assert.equal(state.plistLoads.length, 2); assert.equal(state.directRequests.filter(request => request.name === 'door.plist').length, 1); assert.ok(manager.getAtlasFrame('door', 'wall1')); }); test('remote atlases replace all prefab dependencies and restore default sprites without overriding state', async () => { const { manager, load } = fixture(); await manager.prepareWindow([], { extraPaths: atlasPaths }); const Binding = load('assets/Script/module/Tool/RemoteAtlasBindings.ts').default; const bindingUuid = readJSON('assets/Script/module/Tool/RemoteAtlasBindings.ts.meta').uuid; const uuids = new Set(); for (const atlas of atlasPaths) { const meta = readJSON(`assets/Block/${atlas}.plist.meta`); uuids.add(meta.uuid); uuids.add(meta.rawTextureUuid); Object.values(meta.subMetas).forEach(data => uuids.add(data.uuid)); } function scan(directory) { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const file = path.join(directory, entry.name); if (entry.isDirectory()) { scan(file); continue; } if (!/\.(prefab|fire|anim)$/.test(entry.name)) continue; const text = fs.readFileSync(file, 'utf8'); for (const uuid of uuids) assert.ok(!text.includes(uuid), `${file} still references ${uuid}`); if (!entry.name.endsWith('.prefab')) continue; const data = JSON.parse(text); for (const component of data.filter(item => item.frameKeys && item.sprites)) { assert.ok(component.__type__ === bindingUuid || component.__type__ === compressUuid(bindingUuid)); assert.equal(component.sprites.length, component.frameKeys.length); assert.ok(data[component.node.__id__]._components.some(ref => data[ref.__id__] === component)); const binding = new Binding(); binding.frameKeys = component.frameKeys; binding.sprites = component.sprites.map(ref => { assert.equal(data[ref.__id__].__type__, 'cc.Sprite'); assert.equal(data[ref.__id__]._spriteFrame, null); return { spriteFrame: null }; }); binding.onLoad(); binding.sprites.forEach((sprite, index) => { const [atlas, name] = component.frameKeys[index].split('/'); assert.equal(sprite.spriteFrame, manager.getAtlasFrame(atlas, name)); }); const override = {}; binding.sprites[0].spriteFrame = override; binding.onLoad(); assert.equal(binding.sprites[0].spriteFrame, override); } } } scan(path.join(root, 'assets')); }); function compressUuid(uuid) { const hex = uuid.replace(/-/g, ''); const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; let value = hex.slice(0, 5); for (let i = 5; i < hex.length; i += 3) { const part = parseInt(hex.slice(i, i + 3), 16); value += alphabet[part >> 6] + alphabet[part & 63]; } return value; } test('all shipped levels resolve to small images, including hidden/switch/stack/cycle states', () => { const { collect, pathsModule } = fixture(); assert.deepEqual([...pathsModule.allBlockTexturePaths()], imagePaths); const files = fs.readdirSync(path.join(root, 'assets/custom/Json')).filter(n => /^level\d+\.json$/.test(n)); for (const name of files) { const data = readJSON('assets/custom/Json/' + name).BLOCK_INFO[0]; const before = JSON.stringify(data); for (const image of collect(data)) assert.ok(imagePaths.includes(image), `${name}: ${image}`); assert.equal(JSON.stringify(data), before, 'reading next level must not mutate it'); } assert.deepEqual([...collect([{ block: 2, color: 11, type: 1, stacking: 7, lock: false }])], ['0color2', '11color2', '12color2', '7color2']); assert.deepEqual([...collect([{ block: 0, color: 10, type: 16 }])], ['10color0', 'question0']); assert.deepEqual([...collect([{ block: 1, color: 9, type: 20, colorArray: '81' }])], ['2color1', '9color1']); assert.deepEqual([...collect([{ block: 23, color: 1 }])], []); }); test('50 -> 51 releases only old exclusive frames; restart reuses both levels', async () => { const { manager, collect, state } = fixture(); const a = level(50).BLOCK_INFO[0], b = level(51).BLOCK_INFO[0], c = level(52).BLOCK_INFO[0]; await manager.prepareWindow(a, b); assert.deepEqual([...manager.getLoadedPaths()], sorted([...collect(a), ...collect(b)])); manager.activateScene(a); const shared = collect(a).find(name => collect(b).includes(name)); const original = manager.getFrame(shared); await manager.prepareWindow(b, c); assert.ok(manager.getLoadedPaths().includes(collect(a)[0]), 'old scene stays visible until destroyed'); manager.releaseScene(); manager.activateScene(b); assert.deepEqual([...manager.getLoadedPaths()], sorted([...collect(b), ...collect(c)])); assert.equal(manager.getFrame(shared), original); const count = state.loads.length; await manager.prepareWindow(b, c); assert.equal(state.loads.length, count); for (const frame of state.assets.values()) { assert.equal(frame.texture.refs, 1); assert.equal(frame.texture.packable, false); } assert.equal(state.textures.size, state.assets.size, 'no orphaned raw textures after level transitions'); }); test('failed new window rolls back without freeing current/next; retry is possible', async () => { const { manager, state } = fixture(); const old = [{ block: 0, color: 1 }], next = [{ block: 1, color: 2 }]; await manager.prepareWindow(old, next); manager.activateScene(old); const original = [...manager.getLoadedPaths()]; state.fail.add('9color22'); await assert.rejects(manager.prepareWindow([{ block: 22, color: 9 }], [])); assert.deepEqual([...manager.getLoadedPaths()], original); state.fail.clear(); assert.equal(await manager.prepareWindow([{ block: 22, color: 9 }], []), true); manager.releaseScene(); assert.deepEqual([...manager.getLoadedPaths()], ['9color22']); }); test('late completion of a cancelled window cannot pin obsolete textures', async () => { const { manager, state } = fixture(); state.hold.add('1color0'); const oldRequest = manager.prepareWindow([{ block: 0, color: 1 }], []); await tick(); const latest = manager.prepareWindow([{ block: 1, color: 2 }], []); assert.equal(await latest, true); state.deferred.get('1color0')(); assert.equal(await oldRequest, false); assert.deepEqual([...manager.getLoadedPaths()], ['2color1']); assert.ok(state.releases.includes('1color0')); }); test('invalid image configuration can retry, and concurrent windows share one texture load', async () => { const { manager, state, cc } = fixture(); const base = cc.fx.GameConfig.BLOCK_IMAGE_BASE_URL; cc.fx.GameConfig.BLOCK_IMAGE_BASE_URL = ''; await assert.rejects(manager.prepareWindow([{ block: 0, color: 1 }], [])); cc.fx.GameConfig.BLOCK_IMAGE_BASE_URL = base; state.hold.add('1color0'); const first = manager.prepareWindow([{ block: 0, color: 1 }], []); await tick(); const second = manager.prepareWindow([{ block: 0, color: 1 }], []); await tick(); assert.equal(state.loads.length, 1); state.deferred.get('1color0')(); assert.equal(await first, false); assert.equal(await second, true); assert.equal(manager.getFrame('1color0').getTexture().refs, 1); }); test('background downloads directly to the versioned user directory without Cocos persistence', async () => { const { manager, Manager, state, userFile } = fixture(); state.disk.set(userFile(imagePaths[0]) + '.tmp', 'interrupted'); await manager.cacheAllFiles(); assert.equal(state.loads.length, 0); assert.equal(state.assets.size, 0); assert.equal(state.downloads, filePaths.length); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true); assert.equal(state.cacheWrites, 0, 'Block must not enter the Cocos persistent-cache queue'); assert.equal(state.temps.size, 0); assert.ok(imagePaths.every(name => state.disk.has(userFile(name)))); assert.ok(imagePaths.every(name => !state.disk.has(userFile(name) + '.tmp'))); assert.ok(state.directRequests.every(request => request.filePath === userFile(request.name) + '.tmp')); const restarted = new Manager(); await restarted.cacheAllFiles(); assert.equal(state.downloads, filePaths.length, 'restart should reuse actual local files'); assert.deepEqual(state.loadingEvents, [], 'background caching must remain silent'); }); test('background startup, downloads and cache hits remain observable in a non-debug WeChat build', async () => { const { manager, Manager, state, cc } = fixture(); cc.game.config.debugMode = cc.debug.DebugMode.ERROR; cc.log = cc.warn = () => {}; // Cocos release boot resets these to no-ops. state.hold.add('version'); manager.startBackgroundCache(); manager.startBackgroundCache(); await tick(); assert.equal(state.requests.length, 1); assert.equal(state.gameListeners.length, 1); assert.equal(state.downloads, 0, 'version check has not completed'); for (const message of ['[后台缓存] 启动', '[版本检查] 开始', '任务正在运行']) { assert.ok(state.consoleLogs.some(args => args[0].includes(message))); } state.deferred.get('version')(); await tick(); assert.equal(manager.backgroundRunning, false); assert.equal(state.downloads, filePaths.length); assert.equal(state.loads.length, 0, 'background must still cache files without decoding textures'); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true); const lines = state.consoleLogs.map(args => args[0]); assert.equal(lines.filter(line => line.includes('[远程下载] 开始')).length, filePaths.length); assert.equal(lines.filter(line => line.includes('[远程下载] 成功')).length, filePaths.length); assert.ok(lines.some(line => line.includes(`有效持久文件=${filePaths.length}/${filePaths.length},完整落盘=true`))); assert.ok(lines.some(line => line.includes('本轮结束,全部已持久缓存'))); manager.startBackgroundCache(); await tick(); assert.equal(state.downloads, filePaths.length, 'valid cache must not be downloaded for logging'); assert.ok(state.consoleLogs.some(args => args[0].includes('全部本地缓存有效,无需下载'))); assert.equal(cc.game.config.debugMode, cc.debug.DebugMode.ERROR, 'do not enable global game logging'); assert.deepEqual(state.loadingEvents, []); }); test('background keeps eight files in flight and replenishes each completed slot without waiting for a batch', async t => { const { manager, Manager, state } = fixture(); manager.paths = manager.paths.slice(0, 18); for (const name of manager.paths) state.hold.add('download:' + name); t.after(() => releaseHeldDownloads(state)); const background = manager.cacheAllFiles(); await tick(); assert.deepEqual(state.fileRequests, [...manager.paths.slice(0, 8)]); assert.equal(state.activeDownloads.size, 8); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false); state.deferred.get('download:' + manager.paths[3])(); await tick(); assert.equal(state.fileRequests.length, 9, 'one free worker immediately starts the ninth image'); assert.equal(state.activeDownloads.size, 8, 'the other seven slow images do not block replenishment'); assert.ok(state.logs.some(args => args[0].includes('在途=8/8'))); releaseHeldDownloads(state); assert.equal((await background).complete, true); assert.equal(state.downloads, 18); assert.equal(new Set(state.fileRequests).size, 18); assert.equal(state.maxActiveDownloads, 8); assert.equal(state.loads.length, 0, 'parallel file caching must not decode textures'); }); test('one failed worker does not stop the pool or schedule a retry before the other workers finish', async t => { const { manager, Manager, state } = fixture(); manager.paths = manager.paths.slice(0, 12); for (const name of manager.paths.slice(0, 8)) state.hold.add('download:' + name); t.after(() => releaseHeldDownloads(state)); state.fail.add(manager.paths[0]); manager.startBackgroundCache(); await tick(); manager.startBackgroundCache(); assert.equal(state.fileRequests.length, 8, 'starting again must not create another pool'); state.deferred.get('download:' + manager.paths[0])(); await tick(); assert.equal(state.fileRequests.length, 12, 'the failed worker continues processing the remaining queue'); assert.equal(manager.backgroundRunning, true); assert.equal(state.timers.size, 0, 'seven requests are still in flight'); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false); releaseHeldDownloads(state); await tick(); assert.equal(manager.backgroundRunning, false); assert.equal(state.downloads, 11); state.fail.clear(); fireTimer(state, 10000); await tick(); assert.equal(state.fileRequests.length, 13, 'only the one failed file is retried'); assert.equal(state.downloads, 12); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true); }); test('level entry pauses new background work and the same pool resumes after switching to GameScene', async t => { const { manager, Manager, state, config, collect } = fixture(); const Config = config(); Config.applyLevelData(level(50), false, 0, 50); const needed = sorted([...collect(level(50).BLOCK_INFO[0]), ...collect(level(51).BLOCK_INFO[0])]); const foregroundName = collect(level(50).BLOCK_INFO[0])[0]; const backgroundNames = imagePaths.filter(name => !needed.includes(name)).slice(0, 16); const backgroundRequestCount = () => state.fileRequests.filter(name => backgroundNames.includes(name)).length; manager.paths = backgroundNames.concat(needed); manager.delay = ms => ms === 100 ? tick() : Promise.resolve(); for (const name of manager.paths.slice(0, 8)) state.hold.add('download:' + name); state.hold.add('download:' + foregroundName); t.after(() => releaseHeldDownloads(state)); manager.startBackgroundCache(); await tick(); assert.equal(backgroundRequestCount(), 8); Config.enterGameSceneWhenReady(); await tick(); assert.equal(state.scene.loading, true); assert.ok(manager.foregroundLoads > 0); for (const name of manager.paths.slice(0, 8)) state.deferred.get('download:' + name)(); await tick(); assert.equal(backgroundRequestCount(), 8, 'completed background slots must not be replenished while entering'); assert.equal(manager.backgroundRunning, true, 'pause must not discard the remaining queue'); assert.equal(state.logs.filter(args => args[0].includes('暂停派发')).length, 1); state.hold.delete('download:' + foregroundName); state.deferred.get('download:' + foregroundName)(); await waitFor(() => state.scenes.length === 1 && !manager.backgroundRunning, 'entry and background caching finish'); assert.deepEqual(state.scenes, ['GameScene']); assert.deepEqual(state.loadingEvents, ['HomeScene:open', 'HomeScene:close']); assert.equal(backgroundRequestCount(), 16, 'remaining images download after entering, without another start call'); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true); assert.ok(state.logs.some(args => args[0].includes('进关图片处理结束,继续后台缓存'))); assert.equal(manager.getLoadedPaths().some(name => backgroundNames.includes(name)), false, 'background files stay out of texture memory'); }); test('failed foreground preparation also releases the background pause', async t => { const { manager, state } = fixture(); manager.paths = manager.paths.slice(0, 12).concat('9color22'); manager.delay = ms => ms === 100 ? tick() : Promise.resolve(); for (const name of manager.paths.slice(0, 8)) state.hold.add('download:' + name); const key = 'download:9color22'; state.hold.add(key); state.fail.add('9color22'); t.after(() => releaseHeldDownloads(state)); const background = manager.cacheAllFiles(); await tick(); const foreground = assert.rejects(manager.prepareWindow([{ block: 22, color: 9 }], [])); await tick(); for (const name of manager.paths.slice(0, 8)) state.deferred.get('download:' + name)(); await tick(); assert.equal(state.fileRequests.filter(name => name !== '9color22').length, 8); state.hold.delete(key); state.deferred.get(key)(); await foreground; state.fail.clear(); assert.equal((await background).complete, true); assert.ok(manager.paths.every(name => state.disk.has(`http://usr/Block/1.0.0/${name}.png`))); assert.equal(manager.foregroundLoads, 0); }); test('foreground rolling pool fills a free slot without waiting for the slowest image of a batch', async t => { const { manager, state } = fixture(); const names = manager.paths.filter(name => /^9color[0-5]$/.test(name)); const blocks = names.map(name => { const [, color, block] = name.match(/^(\d+)color(\d+)$/); return { color: +color, block: +block }; }); for (const name of names) state.hold.add('download:' + name); t.after(() => releaseHeldDownloads(state)); const ready = manager.prepareWindow(blocks, {}); await waitFor(() => state.fileRequests.length === 4, 'first four foreground downloads start'); state.deferred.get('download:' + names[1])(); await waitFor(() => state.fileRequests.includes(names[4]), 'a fifth image starts before the first slow image finishes'); assert.equal(state.activeDownloads.size, 4); assert.ok(state.activeDownloads.has(names[0])); releaseHeldDownloads(state); assert.equal(await ready, true); }); test('foreground rolling pool stops issuing work on failure and drains pending loads before cleanup', async () => { const { manager } = fixture(); const names = manager.paths.filter(name => /^9color[0-5]$/.test(name)); const blocks = names.map(name => { const [, color, block] = name.match(/^(\d+)color(\d+)$/); return { color: +color, block: +block }; }); const pending = []; manager.loadFrame = name => new Promise((resolve, reject) => pending.push({ name, resolve, reject })); const result = assert.rejects(manager.prepareWindow(blocks, {}), /broken/); await waitFor(() => pending.length === 4, 'four workers start'); pending[0].reject(new Error('broken')); await tick(); assert.equal(manager.foregroundLoads, 1, 'pause remains held while other image jobs are still running'); pending.slice(1).forEach(job => job.resolve()); await result; assert.equal(pending.length, 4); assert.equal(manager.foregroundLoads, 0); }); test('entry hold keeps bulk workers paused after foreground decoding until map handoff releases it', async t => { const { manager, state } = fixture(); manager.paths = manager.paths.slice(0, 12).concat('9color22'); manager.delay = ms => ms === 100 ? tick() : Promise.resolve(); const release = manager.holdBackgroundDownloads(); t.after(release); const background = manager.cacheAllFiles(); await tick(); assert.equal(state.fileRequests.length, 0); assert.equal(await manager.prepareWindow([{ block: 22, color: 9 }], {}), true); await tick(); assert.deepEqual(state.fileRequests, ['9color22'], 'bulk downloads cannot resume at the end of prepareWindow'); assert.equal(manager.foregroundLoads, 0); release(); assert.equal((await background).complete, true); assert.equal(manager.entryDownloadHolds, 0); assert.ok(manager.paths.every(name => state.disk.has(`http://usr/Block/1.0.0/${name}.png`))); }); test('Block scheduler gives foreground two extra slots while all eight background requests remain pending', async t => { const { manager, state, cc } = fixture(); manager.delay = ms => ms === 100 ? tick() : Promise.resolve(); const blocks = [19, 20, 21, 22].map(block => ({ block, color: 9 })); const names = blocks.map(block => `9color${block.block}`); manager.paths = manager.paths.slice(0, 10).concat(names); for (const name of [...manager.paths, ...names]) state.hold.add('download:' + name); t.after(() => releaseHeldDownloads(state)); const background = manager.cacheAllFiles(); await tick(); assert.equal(state.activeDownloads.size, 8); const foreground = manager.prepareWindow(blocks, []); await tick(); assert.equal(state.activeDownloads.size, 10, 'foreground starts without waiting for any of the eight background requests'); assert.deepEqual(state.fileRequests.slice(8), names.slice(0, 2)); for (const name of names.slice(0, 2)) state.deferred.get('download:' + name)(); await waitFor(() => names.slice(2).every(name => state.deferred.has('download:' + name)), 'queued foreground files start next'); assert.deepEqual(sorted(state.fileRequests.slice(8)), names); assert.equal(state.fileRequests.filter(name => manager.paths.slice(0, 10).includes(name)).length, 8); for (const name of names.slice(2)) state.deferred.get('download:' + name)(); assert.equal(await foreground, true); assert.equal(state.downloads, 4, 'all foreground files finish while the background files are still held'); state.deferred.get('download:' + manager.paths[0])(); await waitFor(() => state.fileRequests.filter(name => manager.paths.slice(0, 10).includes(name)).length === 9, 'background replenishment resumes after foreground'); releaseHeldDownloads(state); assert.equal((await background).complete, true); assert.equal(state.maxActiveDownloads, 10); assert.equal(cc.assetManager.downloader.maxConcurrency, 8, 'global downloader settings remain unchanged'); }); test('background network failures wait ten seconds and retry only missing images without engine retry bursts', async () => { const { manager, Manager, state } = fixture(); state.fail.add('question0'); manager.startBackgroundCache(); await tick(); assert.equal(state.downloads, filePaths.length - 1); assert.equal(state.directRequests.filter(request => request.name === 'question0').length, 1); assert.equal(state.timers.size, 1); assert.ok(state.logs.some(args => args[0].includes('仍缺少1张图片,10秒后重试'))); state.fail.clear(); fireTimer(state, 10000); await tick(); assert.equal(state.downloads, filePaths.length); assert.equal(state.fileRequests.length, filePaths.length + 1); assert.equal(state.directRequests.filter(request => request.name === 'question0').length, 2); assert.equal(state.timers.size, 0); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true); assert.equal(state.requests.length, 1, 'background retries do not refetch version metadata'); }); test('legacy Cocos temporary files migrate into the user directory without network requests', async () => { const { manager, Manager, state, url, userFile } = fixture(); manager.paths = manager.paths.slice(0, 12); await manager.initialize(); for (const name of manager.paths) { const temp = 'legacy-temp/' + name; state.temps.set(url(name), temp); state.disk.set(temp, 'valid'); } await manager.cacheAllFiles(); assert.equal(state.downloads, 0); assert.equal(state.directRequests.length, 0); assert.equal(state.cacheWrites, 0); assert.ok(manager.paths.every(name => state.disk.has(userFile(name)))); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true); }); test('failed legacy-file migration retries after ten seconds without downloading a duplicate', async () => { const { manager, Manager, state, url, userFile } = fixture(); manager.paths = manager.paths.slice(0, 4); await manager.initialize(); for (const name of manager.paths) { const temp = 'legacy-temp/' + name; state.temps.set(url(name), temp); state.disk.set(temp, 'valid'); } state.save = false; manager.startBackgroundCache(); await tick(); assert.equal(state.directRequests.length, 0); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false); assert.ok(state.logs.some(args => args[0].includes('4张旧临时文件待迁移,10秒后重试'))); state.save = true; fireTimer(state, 10000); await tick(); assert.ok(manager.paths.every(name => state.disk.has(userFile(name)))); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true); assert.equal(state.timers.size, 0); }); test('Cocos cache-index writes do not affect direct-cache completion', async () => { const { manager, Manager, state } = fixture(); manager.paths = manager.paths.slice(0, 12); await manager.cacheAllFiles(); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true); assert.equal(state.cacheWrites, 0); assert.equal(state.disk.has('gamecaches/cacheList.json'), false); }); test('storage-full retries direct downloads after ten seconds', async () => { const { manager, Manager, state } = fixture(); manager.paths = manager.paths.slice(0, 12); state.save = false; manager.startBackgroundCache(); await tick(); assert.equal(state.downloads, 0); assert.equal(state.directRequests.length, 12); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false); state.save = true; fireTimer(state, 10000); await tick(); assert.equal(state.downloads, 12); assert.equal(state.directRequests.length, 24); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true); assert.equal(state.timers.size, 0); }); test('the ten-second background retry does not delay an immediate foreground level entry', async () => { const { manager, state, config, collect } = fixture(); const Config = config(); const missing = collect(level(50).BLOCK_INFO[0])[0]; state.fail.add(missing); manager.startBackgroundCache(); await tick(); assert.equal([...state.timers.values()].some(timer => timer.delay === 10000), true); state.fail.clear(); Config.applyLevelData(level(50), false, 0, 50); Config.enterGameSceneWhenReady(); await tick(); assert.deepEqual(state.scenes, ['GameScene']); assert.deepEqual(state.loadingEvents, ['HomeScene:open', 'HomeScene:close']); assert.equal(state.downloads, filePaths.length); const requests = state.fileRequests.length; fireTimer(state, 10000); await tick(); assert.equal(state.downloads, filePaths.length); assert.equal(state.fileRequests.length, requests, 'the later retry sees the foreground cache and skips downloading'); assert.equal(state.timers.size, 0); }); test('foreground and background create the Block cache directory only when it is missing', async () => { const { manager, Manager, state } = fixture(); const directory = 'http://usr/Block/1.0.0'; const blocks = [{ block: 0, color: 1 }]; await Promise.all([manager.prepareWindow(blocks, []), manager.cacheAllFiles()]); await manager.prepareWindow(blocks, []); assert.deepEqual(state.mkdirCalls, [directory]); const restarted = new Manager(); await restarted.cacheAllFiles(); assert.equal(state.mkdirCalls.length, 1, 'an existing directory survives manager/session initialization'); state.directories.delete(directory); await manager.prepareWindow(blocks, []); assert.equal(state.mkdirCalls.length, 2, 'cleared cache directories must be recreated, not remembered by a flag'); assert.ok(state.directories.has(directory)); }); test('directory creation failures still surface and may be retried; existing files are never deleted', async () => { const { manager, state } = fixture(); const directory = 'http://usr/Block/1.0.0'; await manager.initialize(); const permissionError = new Error('mkdirSync:fail permission denied'); state.mkdirError = permissionError; assert.throws(() => manager.ensureUserCacheFolder(), permissionError); assert.equal(state.directories.has(directory), false); state.mkdirError = null; manager.ensureUserCacheFolder(); assert.ok(state.directories.has(directory)); assert.equal(state.mkdirCalls.length, 2); state.directories.delete(directory); state.disk.set(directory, 'a file occupying the directory path'); assert.throws(() => manager.ensureUserCacheFolder(), /file already exists/); assert.equal(state.disk.get(directory), 'a file occupying the directory path'); assert.equal(state.directories.has(directory), false, 'a same-name file is not a usable directory'); }); test('a complete disk cache loads only the two-level texture window without downloading', async () => { const { manager, state, collect } = fixture(); await manager.cacheAllFiles(); const downloads = state.downloads; const current = level(50).BLOCK_INFO[0], next = level(51).BLOCK_INFO[0]; await manager.prepareWindow(current, next); assert.equal(state.downloads, downloads); assert.deepEqual([...state.assets.keys()].sort(), sorted([...collect(current), ...collect(next)])); }); test('a complete localStorage marker never substitutes for missing user-directory files', async () => { const { manager, Manager, state } = fixture(); state.storage.set(Manager.CACHE_KEY, JSON.stringify({ version: '1.0.0', baseUrl: 'https://cdn.test/remote/Block/', total: filePaths.length, cached: filePaths.length, complete: true })); await manager.cacheAllFiles(); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true); assert.equal(state.downloads, filePaths.length); }); test('cache eviction, interrupted download, and semantic version changes revalidate completion', async () => { const { manager, Manager, state, userFile } = fixture(); state.fail.add('question0'); await manager.cacheAllFiles(); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false); assert.ok(state.logs.some(args => args[0].includes('[远程下载] 失败 question0.png'))); state.fail.clear(); await manager.cacheAllFiles(); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true); state.disk.delete(userFile('1color0')); await manager.cacheAllFiles(); assert.equal(state.downloads, filePaths.length + 1); const restarted = fixture(state); restarted.state.manifest.data.version = '1.0.1'; await restarted.manager.cacheAllFiles(); assert.equal(restarted.state.downloads, filePaths.length * 2 + 1); const saved = JSON.parse(restarted.state.storage.get(Manager.CACHE_KEY)); assert.equal(saved.version, '1.0.1'); assert.equal(saved.complete, true); }); test('1.0.0 -> 1.0.1 changes raw PNG cache URLs without any bundle or build hash', async () => { const first = fixture(); await first.manager.cacheAllFiles(); const { manager, Manager, state, url, userFile } = fixture(first.state); state.manifest.data.version = '1.0.1'; await manager.prepareWindow([{ block: 0, color: 1 }], [{ block: 1, color: 2 }]); assert.deepEqual(state.bundleLoads, []); assert.equal(state.downloads, filePaths.length + 2, 'both foreground images need the new version'); assert.ok(state.loads.every(load => load.url.includes('/Block/1.0.1/'))); assert.ok(state.directRequests.every(request => request.url.endsWith('.png?v=1.0.1'))); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false); assert.ok(state.disk.has(userFile('1color0', '1.0.0')), 'do not delete the previous version before replacement completes'); await manager.cacheAllFiles(); const saved = JSON.parse(state.storage.get(Manager.CACHE_KEY)); assert.equal(saved.version, '1.0.1'); assert.equal(saved.baseUrl, 'https://cdn.test/remote/Block/'); assert.equal(saved.complete, true); assert.equal(state.downloads, filePaths.length * 2, 'a total version bump refreshes all files'); assert.equal(state.assets.size, 2, 'background update must not decode the other images'); assert.equal(state.textures.size, 2); state.manifest.data.version = '1.0.2'; await manager.cacheAllFiles(); await manager.prepareWindow([{ block: 0, color: 1 }], []); assert.equal(state.requests.length, 1, 'scene changes and repeated scans keep this session version'); assert.equal(state.downloads, filePaths.length * 2); assert.match(state.requests[0].url, /^https:\/\/cdn\.test\/remote\/Block\/version\.json\?_t=\d+$/); assert.equal(state.requests[0].header['Cache-Control'], 'no-cache'); }); test('fully offline restart uses the cached semantic version and images without a bundle config', async () => { const first = fixture(); first.state.manifest.data.version = '1.0.1'; await first.manager.cacheAllFiles(); const { manager, state } = fixture(first.state); state.offline = true; await manager.prepareWindow([{ block: 0, color: 1 }], [{ block: 1, color: 2 }]); assert.ok(state.loads.every(load => load.url.includes('/Block/1.0.1/'))); assert.equal(state.directRequests.length, 0); assert.equal(state.downloads, filePaths.length); assert.equal(state.assets.size, 2); assert.deepEqual(state.bundleLoads, []); }); test('incomplete new version resumes after a manifest outage without repeating cached downloads', async () => { const first = fixture(); await first.manager.cacheAllFiles(); const updated = fixture(first.state); updated.state.manifest.data.version = '1.0.1'; updated.state.fail.add('question0'); await updated.manager.cacheAllFiles(); let saved = JSON.parse(updated.state.storage.get(updated.Manager.CACHE_KEY)); assert.equal(saved.version, '1.0.1'); assert.equal(saved.complete, false); assert.equal(updated.state.downloads, filePaths.length * 2 - 1); const restarted = fixture(updated.state); restarted.state.manifestError = new Error('version service unavailable'); await restarted.manager.cacheAllFiles(); saved = JSON.parse(restarted.state.storage.get(restarted.Manager.CACHE_KEY)); assert.equal(saved.version, '1.0.1'); assert.equal(saved.complete, true); assert.equal(restarted.state.downloads, filePaths.length * 2); }); test('missing new-version PNG fails entry and retries the new URL instead of using old pixels', async () => { const first = fixture(); await first.manager.cacheAllFiles(); const { manager, Manager, state } = fixture(first.state); state.manifest.data.version = '1.0.1'; state.fail.add('1color0'); await assert.rejects(manager.prepareWindow([{ block: 0, color: 1 }], [])); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).version, '1.0.1'); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false); assert.equal(state.assets.size, 0); state.fail.clear(); await manager.prepareWindow([{ block: 0, color: 1 }], []); assert.ok(state.loads.every(load => load.url.includes('/Block/1.0.1/'))); assert.ok(state.directRequests.every(request => request.url.endsWith('?v=1.0.1'))); assert.equal(state.requests.length, 1, 'retry shares the selected semantic version'); assert.equal(state.downloads, filePaths.length + 1); }); test('old build-hash records and version hints for a different CDN cannot select a raw-image version', async () => { for (const previous of [ { version: 'fbc07', buildVersion: 'fbc07', complete: true }, { version: '1.5.0', baseUrl: 'https://another.test/Block/', complete: true } ]) { const { manager, Manager, state } = fixture(); state.storage.set(Manager.CACHE_KEY, JSON.stringify(previous)); state.manifestError = new Error('offline'); await manager.prepareWindow([{ block: 0, color: 1 }], []); assert.ok(state.loads.every(load => load.url.includes('/Block/1.0.0/'))); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false); } }); test('missing, malformed or unsafe version metadata falls back without trusting legacy true flags', async () => { for (const response of [ { statusCode: 404 }, { statusCode: 200, data: '{' }, { statusCode: 200, data: null }, ...['../bad', '1', 'fbc07', '1.0.0?x=1'].map(version => ({ statusCode: 200, data: { version } })) ]) { const { manager, Manager, state } = fixture(); state.storage.set(Manager.CACHE_KEY, 'true'); state.manifest = response; await manager.prepareWindow([{ block: 0, color: 1 }], []); const saved = JSON.parse(state.storage.get(Manager.CACHE_KEY)); assert.equal(saved.version, '1.0.0'); assert.equal(saved.complete, false); assert.equal(state.downloads, 1); } }); test('version watchdog falls back once and ignores a late response', async () => { const { manager, state } = fixture(); state.manifest.data.version = '1.0.1'; state.hold.add('version'); const pending = manager.prepareWindow([{ block: 0, color: 1 }], []); await tick(); assert.equal(state.loads.length, 0); [...state.timers.values()].find(timer => timer.delay === 5000).cb(); assert.equal(await pending, true); assert.equal(state.requestAborts, 1); state.deferred.get('version')(); await tick(); assert.ok(state.loads.every(load => load.url.includes('/Block/1.0.0/'))); }); test('ByteDance and browser version requests work; browsers never run full file pre-caching', async () => { const { manager, wx, tt, state } = fixture(); tt.request = wx.request; tt.downloadFile = wx.downloadFile; tt.getFileSystemManager = wx.getFileSystemManager; tt.env = wx.env; delete wx.request; delete wx.downloadFile; delete wx.getFileSystemManager; state.manifest.data = JSON.stringify({ version: '1.0.1' }); await manager.prepareWindow([{ block: 0, color: 1 }], []); assert.ok(state.loads.every(load => load.url.includes('/Block/1.0.1/'))); assert.ok(state.directRequests.every(request => request.url.endsWith('?v=1.0.1'))); const browser = fixture(); delete browser.wx.request; delete browser.wx.downloadFile; delete browser.wx.getFileSystemManager; browser.cc.assetManager.cacheManager = null; browser.state.manifest.data.version = '1.0.2'; browser.manager.startBackgroundCache(); assert.ok(browser.state.logs.some(args => args[0].includes('[后台缓存] 跳过:用户缓存环境不可用'))); await browser.manager.prepareWindow([{ block: 0, color: 1 }], []); assert.ok(browser.state.loads.every(load => load.url.endsWith('?v=1.0.2'))); assert.equal(browser.state.requests.length, 1); assert.equal(browser.state.directRequests.length, 0); assert.equal(browser.state.storage.size, 0); }); test('raw SpriteFrames preserve existing PNG crop layout and explicitly release their textures', async () => { const { manager, state, pathsModule, url } = fixture(); for (const name of imagePaths.filter(name => !atlasPaths.includes(name))) { const frame = Object.values(readJSON('assets/Block/' + name + '.png.meta').subMetas)[0]; const trim = pathsModule.BLOCK_TEXTURE_TRIMS[name]; if (frame.rawWidth === frame.width && frame.rawHeight === frame.height) assert.equal(trim, undefined); else assert.deepEqual([...trim], [frame.trimX, frame.trimY, frame.width, frame.height, frame.offsetX, frame.offsetY, frame.rawWidth, frame.rawHeight]); } await manager.prepareWindow([{ block: 20, color: 4 }], [{ block: 0, color: 1 }]); const oldFrame = manager.getFrame('4color20'), texture = oldFrame.getTexture(); assert.deepEqual(oldFrame.rect, { x: 23, y: 0, width: 244, height: 249 }); assert.deepEqual(oldFrame.originalSize, { width: 267, height: 249 }); await manager.prepareWindow([{ block: 1, color: 2 }], []); assert.equal(oldFrame.destroyed, true); assert.equal(texture.refs, 0); assert.equal(state.nativeFiles.has(url('4color20') + '@native'), false); assert.equal(state.textures.size, 1); }); for (const scene of ['HomeScene', 'GameScene']) { test(scene + ' opens Loading only for missing images after version selection', async () => { const { manager, state, config, createScene, collect } = fixture(); state.scene = createScene(scene); state.manifest.data.version = '1.0.1'; state.hold.add('version'); const Config = config(), sourceScene = state.scene; const held = collect(level(51).BLOCK_INFO[0])[0]; state.hold.add(held); const startup = manager.initialize(); Config.applyLevelData(level(50), false, 0, 50); Config.enterGameSceneWhenReady(); await tick(); assert.equal(sourceScene.loading, false, 'version metadata alone must not show image Loading'); assert.equal(state.requests.length, 1); assert.equal(state.loads.length, 0); state.deferred.get('version')(); await startup; await tick(); assert.equal(sourceScene.loading, true); assert.equal(state.scenes.length, 0); state.deferred.get(held)(); await tick(); assert.equal(sourceScene.loading, false); assert.ok(state.loads.every(load => load.url.includes('/Block/1.0.1/'))); assert.ok(state.directRequests.every(request => request.url.endsWith('?v=1.0.1'))); assert.ok(state.bundleLoads.every(name => name !== 'Block')); assert.deepEqual(state.scenes, ['GameScene']); }); } test('corrupt local image is removed and retried from remote before entering memory', async () => { const { manager, state, url, userFile } = fixture(); state.caches.set(url('1color0'), 'bad.png'); state.disk.set('bad.png', 'corrupt'); await manager.prepareWindow([{ block: 0, color: 1 }], []); assert.deepEqual(state.loads.map(item => item.reload), [false, false]); assert.equal(state.downloads, 1); assert.equal(state.directRequests.length, 1); assert.ok(state.disk.has(userFile('1color0'))); assert.equal(manager.getFrame('1color0').getTexture().refs, 1); }); test('next-level lookup uses file number, never replaces current level data or advances progress', async () => { const { config, state } = fixture(); const Config = config(); const data = level(50); data.LEVEL_INFO[0].id = '1001'; // Several shipped JSON ids differ from their actual file number. Config.applyLevelData(data, false, 0, 50); await Config.prepareBlockWindow(); assert.equal(Config.BLOCK_INFO, data.BLOCK_INFO); assert.equal(Config.LEVEL_INFO, data.LEVEL_INFO); assert.equal(Config.GM_INFO.level, 49); assert.deepEqual(state.jsonLoads, ['Json/level51']); }); test('scene transition waits for both levels; failure exposes retry without advancing progress', async () => { const { config, state, collect } = fixture(); const Config = config(); Config.applyLevelData(level(50), false, 0, 50); const onlyNext = collect(level(51).BLOCK_INFO[0]).find(name => !collect(level(50).BLOCK_INFO[0]).includes(name)); state.fail.add(onlyNext); const sourceScene = state.scene; Config.enterGameSceneWhenReady(); assert.equal(sourceScene.loading, false); await tick(); assert.equal(state.scenes.length, 0); assert.equal(state.errors.length, 1); assert.equal(sourceScene.loading, false, 'close the spinner before showing retry'); assert.ok(state.logs.some(args => args[0].includes('[远程加载] 失败 ' + onlyNext + '.png'))); assert.equal(Config.GM_INFO.level, 49); state.fail.clear(); state.errors[0].retry(); await tick(); assert.deepEqual(state.scenes, ['GameScene']); assert.equal(sourceScene.loading, false); assert.deepEqual(state.loadingEvents, ['HomeScene:open', 'HomeScene:close', 'HomeScene:open', 'HomeScene:close']); }); for (const name of ['HomeScene', 'GameScene']) { test(name + ' opens its own Loading until both levels are ready', async () => { const { config, state, collect, createScene } = fixture(); state.scene = createScene(name); const sourceScene = state.scene, Config = config(); Config.applyLevelData(level(50), false, 0, 50); const onlyNext = collect(level(51).BLOCK_INFO[0]).find(image => !collect(level(50).BLOCK_INFO[0]).includes(image)); state.hold.add(onlyNext); Config.enterGameSceneWhenReady(); assert.equal(sourceScene.loading, false); await tick(); assert.equal(sourceScene.loading, true); assert.equal(state.scenes.length, 0); state.deferred.get(onlyNext)(); await tick(); assert.equal(sourceScene.loading, false); assert.deepEqual(state.loadingEvents, [name + ':open', name + ':close']); assert.deepEqual(state.scenes, ['GameScene']); }); test(name + ' uses valid disk cache without Loading, including Map initialization and replay', async () => { const { manager, config, state, collect, createScene, map } = fixture(); await manager.cacheAllFiles(); state.logs.length = 0; state.scene = createScene(name); const sourceScene = state.scene, Config = config(); const current = level(50).BLOCK_INFO[0], next = level(51).BLOCK_INFO[0]; const wanted = sorted([...collect(current), ...collect(next)]); Config.applyLevelData(level(50), false, 0, 50); const held = collect(next).find(image => !collect(current).includes(image)); state.hold.add(held); const downloads = state.downloads; Config.enterGameSceneWhenReady(); await tick(); assert.equal(sourceScene.loading, false); assert.equal(state.scenes.length, 0, 'cached files must still finish decoding before entry'); assert.deepEqual(state.loadingEvents, []); state.deferred.get(held)(); await tick(); assert.deepEqual(state.scenes, ['GameScene']); const cachedLogs = state.logs.map(args => args[0]).filter(line => line.includes('[进关来源]')); assert.equal(cachedLogs.length, wanted.length); assert.ok(cachedLogs.every(line => line.includes('-> 本地缓存'))); for (const image of wanted) assert.ok(cachedLogs.some(line => line.includes(image + '.png'))); await map().prepareBlockAssets(); assert.equal(state.mapsInitialized, 1); assert.deepEqual(state.jsonLoads, ['Json/level51'], 'only next JSON is memoized'); assert.deepEqual(state.loadingEvents, [], 'Map must not flash Loading after a prepared entry'); state.logs.length = 0; const loads = state.loads.length; Config.enterGameSceneWhenReady(); await tick(); const memoryLogs = state.logs.map(args => args[0]).filter(line => line.includes('[进关来源]')); assert.equal(memoryLogs.length, wanted.length, 'replay must log sources again even with the same BLOCK_INFO'); assert.ok(memoryLogs.every(line => line.includes('-> 内存复用'))); assert.ok(state.logs.some(args => args[0].includes('当前关50 / 下一关51'))); assert.equal(state.loads.length, loads); assert.equal(state.downloads, downloads); assert.deepEqual(state.loadingEvents, []); }); test(name + ' opens Loading if a cached image fails decoding and needs a remote retry', async () => { const { manager, config, state, collect, createScene, url, userFile } = fixture(); await manager.cacheAllFiles(); state.logs.length = 0; state.scene = createScene(name); const sourceScene = state.scene, Config = config(); Config.applyLevelData(level(50), false, 0, 50); const bad = collect(level(50).BLOCK_INFO[0])[0]; state.disk.set(userFile(bad), 'corrupt'); state.nativeFiles.delete(url(bad) + '@native'); state.hold.add(bad); state.hold.add('download:' + bad); Config.enterGameSceneWhenReady(); await tick(); assert.deepEqual(state.loadingEvents, [], 'file existence alone does not open Loading'); state.hold.delete(bad); state.deferred.get(bad)(); await waitFor(() => state.deferred.has('download:' + bad), 'remote replacement starts after decode failure'); assert.equal(sourceScene.loading, true, 'decode failure must notify the entry before retry'); assert.equal(state.scenes.length, 0); state.hold.delete('download:' + bad); state.deferred.get('download:' + bad)(); await tick(); assert.deepEqual(state.loadingEvents, [name + ':open', name + ':close']); assert.deepEqual(state.scenes, ['GameScene']); assert.equal(state.downloads, filePaths.length + 1); const lines = state.logs.map(args => args[0]).filter(line => line.includes(bad + '.png')); assert.ok(lines.some(line => line.includes('-> 本地缓存'))); assert.ok(lines.some(line => line.includes('-> 远程下载'))); assert.ok(lines.some(line => line.includes('[远程加载] 开始') && line.includes('进关重试'))); assert.ok(lines.some(line => line.includes('[远程加载] 成功'))); }); } test('each image source distinguishes memory, disk, temporary cache and actual remote need', async () => { const { manager, state, url } = fixture(); await manager.prepareWindow([{ block: 0, color: 1 }], []); state.logs.length = 0; state.caches.set(url('2color1'), 'disk.png'); state.disk.set('disk.png', 'valid'); state.caches.set(url('3color2'), 'evicted.png'); state.temps.set(url('3color2'), 'temp.png'); state.disk.set('temp.png', 'valid'); state.nativeFiles.set(url('5color4') + '@native', 'native.png'); state.disk.set('native.png', 'valid'); let remoteNotifications = 0; await manager.prepareWindow([{ block: 0, color: 1 }, { block: 1, color: 2 }, { block: 2, color: 3 }], [{ block: 3, color: 4 }, { block: 4, color: 5 }], { currentLevel: 50, nextLevel: 51, onRemoteRequired: () => remoteNotifications++ }); assert.equal(remoteNotifications, 1); assert.equal(state.downloads, 2, 'valid temporary/native files survive a stale persistent index'); for (const [name, source, whichLevel] of [ ['1color0', '内存复用', '当前关50'], ['2color1', '本地缓存', '当前关50'], ['3color2', '临时缓存', '当前关50'], ['4color3', '远程下载', '下一关51'], ['5color4', '临时缓存', '下一关51'] ]) assert.ok(state.logs.some(args => args[0].includes(`${whichLevel} ${name}.png -> ${source}`))); const starts = state.logs.map(args => args[0]).filter(line => line.includes('[远程加载] 开始')); assert.equal(starts.length, 1); assert.ok(starts[0].includes(url('4color3'))); }); test('temporary files are usable without Loading even when persistence has not completed', async () => { const { manager, state, config, Manager, map, collect, url } = fixture(); await manager.initialize(); const wanted = sorted([...collect(level(50).BLOCK_INFO[0]), ...collect(level(51).BLOCK_INFO[0])]); for (const name of wanted) { const temp = 'legacy-temp/' + name; state.temps.set(url(name), temp); state.disk.set(temp, 'valid'); } assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false); state.logs.length = 0; const Config = config(); Config.applyLevelData(level(50), false, 0, 50); Config.enterGameSceneWhenReady(); await waitFor(() => state.scenes.length === 1, 'temporary images finish decoding'); await map().prepareBlockAssets(); assert.deepEqual(state.scenes, ['GameScene']); assert.deepEqual(state.loadingEvents, []); assert.equal(state.downloads, 0); const sources = state.logs.map(args => args[0]).filter(line => line.includes('[进关来源]')); assert.ok(sources.length > 0 && sources.every(line => line.includes('-> 临时缓存'))); }); test('foreground waiting for a background download shows Loading and shares the file request', async () => { const { manager, state, config, collect, url, userFile } = fixture(); await manager.cacheAllFiles(); const Config = config(), sourceScene = state.scene; Config.applyLevelData(level(50), false, 0, 50); const missing = collect(level(50).BLOCK_INFO[0])[0]; state.disk.delete(userFile(missing)); state.logs.length = 0; state.hold.add('download:' + missing); // Use event-loop yields here so a held foreground request cannot starve the background pause. manager.delay = tick; const background = manager.cacheAllFiles(); for (let i = 0; i < filePaths.length * 2 && !state.deferred.has('download:' + missing); i++) await tick(); assert.equal(state.deferred.has('download:' + missing), true); assert.deepEqual(state.loadingEvents, [], 'background downloads alone remain silent'); Config.enterGameSceneWhenReady(); await tick(); assert.equal(sourceScene.loading, true); assert.equal(state.scenes.length, 0); assert.ok(state.logs.some(args => args[0].includes('[远程加载] 等待后台下载 ' + missing + '.png'))); state.deferred.get('download:' + missing)(); await background; await tick(); assert.deepEqual(state.scenes, ['GameScene']); assert.deepEqual(state.loadingEvents, ['HomeScene:open', 'HomeScene:close']); assert.equal(state.downloads, filePaths.length + 1, 'only one shared download'); for (const phase of ['开始', '成功']) { assert.ok(state.logs.some(args => args[0].includes(`[远程下载] ${phase} ${missing}.png`) && args[0].includes(url(missing)))); } }); test('a superseded entry cannot close the latest entry Loading', async () => { const { config, state } = fixture(); const Config = config(), pending = [], sourceScene = state.scene; Config.prepareBlockWindow = onRemoteRequired => { onRemoteRequired(); return new Promise(resolve => pending.push(resolve)); }; Config.enterGameSceneWhenReady(); Config.enterGameSceneWhenReady(); pending[0](true); await tick(); assert.equal(sourceScene.loading, true); assert.equal(state.scenes.length, 0); pending[1](true); await tick(); assert.equal(sourceScene.loading, false); assert.deepEqual(state.scenes, ['GameScene']); }); test('late remote notifications cannot open Loading for cancelled entries or a different scene', async () => { const { config, state, createScene } = fixture(); const Config = config(), pending = []; Config.prepareBlockWindow = onRemoteRequired => new Promise(resolve => pending.push({ onRemoteRequired, resolve })); Config.enterGameSceneWhenReady(); Config.enterGameSceneWhenReady(); pending[0].onRemoteRequired(); assert.deepEqual(state.loadingEvents, []); state.scene = createScene('GameScene'); pending[1].onRemoteRequired(); assert.deepEqual(state.loadingEvents, [], 'must not find and open the new scene controller'); pending.forEach(request => request.resolve(true)); await tick(); assert.deepEqual(state.scenes, []); }); test('current JSON retrieval and failure do not show image Loading; remote images on retry do', async () => { const { config, state, collect } = fixture(); const Config = config(), sourceScene = state.scene; state.hold.add('Json/level50'); state.fail.add('Json/level50'); Config.LEVEL_INFO_init(true, 0, false); assert.equal(sourceScene.loading, false); await tick(); state.deferred.get('Json/level50')(); assert.equal(sourceScene.loading, false); assert.equal(state.errors.length, 1); assert.equal(state.scenes.length, 0); assert.deepEqual(state.loadingEvents, []); state.fail.clear(); state.hold.delete('Json/level50'); const held = collect(level(50).BLOCK_INFO[0])[0]; state.hold.add(held); state.errors[0].retry(); assert.equal(sourceScene.loading, false); await tick(); assert.equal(sourceScene.loading, true); state.deferred.get(held)(); await tick(); assert.equal(sourceScene.loading, false); assert.deepEqual(state.scenes, ['GameScene']); assert.equal(Config.GM_INFO.level, 49); }); test('leaving while current JSON is pending does not enter a stale level or close the new scene Loading', async () => { const { config, state, createScene } = fixture(); const Config = config(); state.hold.add('Json/level50'); Config.LEVEL_INFO_init(true, 0, false); await tick(); state.scene = createScene('GameScene'); state.scene.controller.openLoad(); state.deferred.get('Json/level50')(); await tick(); assert.equal(state.scene.loading, true); assert.equal(state.scenes.length, 0); }); test('GameScene fallback closes Loading on image failure and reopens it for retry', async () => { const { config, state, collect, createScene, map } = fixture(); state.scene = createScene('GameScene'); const Config = config(), controller = map(); Config.applyLevelData(level(50), false, 0, 50); state.fail.add(collect(level(50).BLOCK_INFO[0])[0]); const first = controller.prepareBlockAssets(); assert.equal(state.scene.loading, false); await first; assert.equal(state.scene.loading, false); assert.equal(state.mapsInitialized, 0); assert.equal(state.errors.length, 1); state.fail.clear(); const retry = state.errors[0].retry(); await retry; assert.equal(state.scene.loading, false); assert.equal(state.mapsInitialized, 1); assert.equal(controller.blockAssetsReady, true); assert.deepEqual(state.loadingEvents, ['GameScene:open', 'GameScene:close', 'GameScene:open', 'GameScene:close']); }); test('malformed next-level JSON rejects with a retry instead of leaving the load pending', async () => { const { config, state } = fixture(); const Config = config(); Config.applyLevelData(level(50), false, 0, 50); state.badJson.add('Json/level51'); Config.enterGameSceneWhenReady(); await tick(); assert.equal(state.scenes.length, 0); assert.equal(state.errors.length, 1); state.badJson.clear(); state.errors[0].retry(); await tick(); assert.deepEqual(state.scenes, ['GameScene']); }); test('an old scene-entry request cannot change scenes after the player has left', async () => { const { config, state, collect } = fixture(); const Config = config(); Config.applyLevelData(level(50), false, 0, 50); const held = collect(level(50).BLOCK_INFO[0])[0]; state.hold.add(held); Config.enterGameSceneWhenReady(); await tick(); state.scene = { name: 'AnotherScene' }; state.deferred.get(held)(); await tick(); assert.equal(state.scenes.length, 0); assert.equal(state.errors.length, 0); }); test('returning to a level after its request was cancelled can prepare it again', async () => { const { config, state, collect } = fixture(); const Config = config(), original = level(50); Config.applyLevelData(original, false, 0, 50); const held = collect(original.BLOCK_INFO[0])[0]; state.hold.add(held); const first = Config.prepareBlockWindow(); await tick(); Config.applyLevelData(level(53), false, 0, 53); state.deferred.get(held)(); assert.equal(await first, false); Config.applyLevelData(original, false, 0, 50); assert.equal(await Config.prepareBlockWindow(), true); }); test('endless preload and actual next level consume the same random selection', async () => { const { config, cc, state } = fixture(); const Config = config(); Config.GM_INFO.level = 1920; Config.GM_INFO.GameplayType = 1; cc.fx.GameTool.getPurelyRandom = () => 52; Config.applyLevelData(level(50), false, 0, 50); await Config.prepareBlockWindow(); assert.deepEqual(state.jsonLoads, ['Json/level52']); assert.equal(cc.fx.GameTool.getNextLevel(), 52); assert.equal(Config.nextEndlessLevel, 0); });