MatchMaster/tools/test-block-assets.cjs
COMPUTER\EDY 4a352390b8 更新
2026-08-27 19:31:20 +08:00

1382 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
});