// 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 imagePaths = fs.readdirSync(path.join(root, 'assets/Block')).filter(n => n.endsWith('.png')).map(n => n.slice(0, -4)).sort(); const sorted = values => [...new Set(values)].sort(); const tick = () => new Promise(resolve => setImmediate(resolve)); 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: [], jsonLoads: [], downloads: 0, releases: [], scenes: [], errors: [], loadingEvents: [], mapsInitialized: 0, bundleLoads: [], requests: [], logs: [], consoleLogs: [], gameListeners: [], 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, 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(?:\?|$)/); return match ? match[1] : ''; }; 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, '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 meta = readJSON('assets/Block/' + name + '.png.meta'); this.width = meta.width; this.height = meta.height; } 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; 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 {}, 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 }) }, 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: { localStorage: { getItem: key => state.storage.get(key), setItem: (key, value) => state.storage.set(key, value) } }, director: { 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); } } }; 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}.png?v=${version}`; const userFile = (name, version = '1.0.0') => `http://usr/Block/${version}/${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, '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 = { 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); 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'); 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', '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 }; } 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, imagePaths.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, imagePaths.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, imagePaths.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, imagePaths.length); assert.equal(lines.filter(line => line.includes('[远程下载] 成功')).length, imagePaths.length); assert.ok(lines.some(line => line.includes(`有效持久文件=${imagePaths.length}/${imagePaths.length},完整落盘=true`))); assert.ok(lines.some(line => line.includes('本轮结束,全部已持久缓存'))); manager.startBackgroundCache(); await tick(); assert.equal(state.downloads, imagePaths.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('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, imagePaths.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, imagePaths.length); assert.equal(state.fileRequests.length, imagePaths.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, imagePaths.length); const requests = state.fileRequests.length; fireTimer(state, 10000); await tick(); assert.equal(state.downloads, imagePaths.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: imagePaths.length, cached: imagePaths.length, complete: true })); await manager.cacheAllFiles(); assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true); assert.equal(state.downloads, imagePaths.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, imagePaths.length + 1); const restarted = fixture(state); restarted.state.manifest.data.version = '1.0.1'; await restarted.manager.cacheAllFiles(); assert.equal(restarted.state.downloads, imagePaths.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, imagePaths.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, imagePaths.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, imagePaths.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, imagePaths.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, imagePaths.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, imagePaths.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, imagePaths.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) { 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, imagePaths.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 < imagePaths.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, imagePaths.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); });