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