1382 lines
70 KiB
JavaScript
1382 lines
70 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 imagePaths = fs.readdirSync(path.join(root, 'assets/Block')).filter(n => n.endsWith('.png')).map(n => n.slice(0, -4)).sort();
|
||
const sorted = values => [...new Set(values)].sort();
|
||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||
|
||
async function waitFor(check, message) {
|
||
for (let i = 0; i < 1000 && !check(); i++) await tick();
|
||
assert.ok(check(), message);
|
||
}
|
||
|
||
function releaseHeldDownloads(state) {
|
||
state.hold.clear();
|
||
for (const [key, finish] of [...state.deferred]) if (key.startsWith('download:')) finish();
|
||
}
|
||
|
||
function fireTimer(state, delay) {
|
||
const timers = [...state.timers.entries()].filter(([, timer]) => timer.delay === delay);
|
||
assert.equal(timers.length, 1, `expected one ${delay}ms timer`);
|
||
const [id, timer] = timers[0];
|
||
state.timers.delete(id);
|
||
timer.cb();
|
||
}
|
||
|
||
function fixture(previous) {
|
||
const state = {
|
||
loads: [], jsonLoads: [], downloads: 0, releases: [], scenes: [], errors: [], loadingEvents: [], mapsInitialized: 0,
|
||
bundleLoads: [], requests: [], logs: [], consoleLogs: [], gameListeners: [], timers: new Map(), requestAborts: 0,
|
||
fileRequests: [], activeDownloads: new Set(), maxActiveDownloads: 0,
|
||
directRequests: [], directories: new Set(['http://usr']), cacheWrites: 0,
|
||
manifest: { statusCode: 200, data: { version: '1.0.0' } }, manifestError: null,
|
||
textures: new Map(), mkdirCalls: [], mkdirError: null, offline: false,
|
||
fail: new Set(), badJson: new Set(), deferred: new Map(), hold: new Set(), disk: new Map(), caches: new Map(), temps: new Map(),
|
||
nativeFiles: new Map(), storage: new Map(), assets: new Map(), save: true, scene: { name: 'HomeScene' }
|
||
};
|
||
if (previous) {
|
||
for (const key of ['disk', 'caches', 'storage', 'downloads', 'directories']) state[key] = previous[key];
|
||
}
|
||
const cache = {
|
||
getCache: url => state.caches.get(url) || '',
|
||
getTemp: url => state.temps.get(url) || '',
|
||
tempFiles: { remove: url => state.temps.delete(url) },
|
||
removeCache: url => state.caches.delete(url),
|
||
cacheFile(url, temp) {
|
||
state.cacheWrites++;
|
||
if (!state.save || !state.disk.has(temp)) return;
|
||
const local = 'saved/' + url;
|
||
state.disk.set(local, state.disk.get(temp));
|
||
state.caches.set(url, local);
|
||
}
|
||
};
|
||
const stats = filename => {
|
||
if (state.directories.has(filename)) {
|
||
return { size: 0, isFile: () => false, isDirectory: () => true };
|
||
}
|
||
if (!state.disk.has(filename)) throw new Error('ENOENT');
|
||
return { size: 12, isFile: () => true, isDirectory: () => false };
|
||
};
|
||
function requestManifest(options) {
|
||
state.requests.push(options);
|
||
const finish = () => state.manifestError || state.offline
|
||
? options.fail(state.manifestError || new Error('offline')) : options.success(state.manifest);
|
||
if (state.hold.has('version')) state.deferred.set('version', finish);
|
||
else queueMicrotask(finish);
|
||
return { abort() { state.requestAborts++; } };
|
||
}
|
||
const fsManager = {
|
||
statSync: stats,
|
||
readFileSync: file => state.disk.get(file),
|
||
mkdirSync(directory) {
|
||
state.mkdirCalls.push(directory);
|
||
if (state.mkdirError) throw state.mkdirError;
|
||
if (state.disk.has(directory)) throw new Error('mkdirSync:fail file already exists ' + directory);
|
||
if (state.directories.has(directory)) throw new Error('mkdirSync:fail file already exists ' + directory);
|
||
state.directories.add(directory);
|
||
},
|
||
unlinkSync(file) {
|
||
if (!state.disk.delete(file)) throw new Error('unlinkSync:fail no such file ' + file);
|
||
},
|
||
renameSync(source, target) {
|
||
if (!state.disk.has(source)) throw new Error('renameSync:fail no such file ' + source);
|
||
state.disk.set(target, state.disk.get(source));
|
||
state.disk.delete(source);
|
||
},
|
||
copyFile(options) {
|
||
queueMicrotask(() => {
|
||
if (!state.save || !state.disk.has(options.srcPath)) return options.fail(new Error('copyFile:fail'));
|
||
state.disk.set(options.destPath, state.disk.get(options.srcPath));
|
||
options.success();
|
||
});
|
||
}
|
||
};
|
||
const remoteName = remote => {
|
||
const match = String(remote).match(/([^/]+)\.png(?:\?|$)/);
|
||
return match ? match[1] : '';
|
||
};
|
||
const wx = {
|
||
env: { USER_DATA_PATH: 'http://usr' },
|
||
getFileSystemManager: () => fsManager,
|
||
request: requestManifest,
|
||
downloadFile(options) {
|
||
const name = remoteName(options.url);
|
||
state.directRequests.push({ name, url: options.url, filePath: options.filePath });
|
||
state.fileRequests.push(name);
|
||
state.activeDownloads.add(name);
|
||
state.maxActiveDownloads = Math.max(state.maxActiveDownloads, state.activeDownloads.size);
|
||
const key = 'download:' + name;
|
||
const finish = () => {
|
||
state.deferred.delete(key);
|
||
state.activeDownloads.delete(name);
|
||
if (state.offline || state.fail.has(name) || !state.save) {
|
||
options.fail(new Error(state.save ? 'network error' : 'storage full'));
|
||
return;
|
||
}
|
||
state.downloads++;
|
||
state.disk.set(options.filePath, 'valid');
|
||
options.success({ statusCode: 200, filePath: options.filePath });
|
||
};
|
||
if (state.hold.has(key)) state.deferred.set(key, finish);
|
||
else queueMicrotask(finish);
|
||
return { abort() {} };
|
||
}
|
||
};
|
||
const tt = {};
|
||
let timerId = 0;
|
||
class Texture2D {
|
||
constructor(name, url) {
|
||
this.name = name; this.url = url; this.packable = true; this.refs = 0;
|
||
const meta = readJSON('assets/Block/' + name + '.png.meta');
|
||
this.width = meta.width; this.height = meta.height;
|
||
}
|
||
addRef() { this.refs++; }
|
||
decRef() {
|
||
assert.ok(this.refs > 0, 'reference must be owned before releasing');
|
||
if (--this.refs === 0) {
|
||
this.destroyed = true;
|
||
state.releases.push(this.name);
|
||
state.textures.delete(this.url);
|
||
}
|
||
}
|
||
}
|
||
class SpriteFrame {
|
||
constructor(texture, rect, rotated, offset, originalSize) {
|
||
this.texture = texture; this.rect = rect; this.rotated = rotated;
|
||
this.offset = offset; this.originalSize = originalSize;
|
||
state.assets.set(texture.name, this);
|
||
}
|
||
getTexture() { return this.texture; }
|
||
destroy() {
|
||
assert.ok(!this.destroyed, 'dynamic frame must be destroyed only once');
|
||
this.destroyed = true;
|
||
state.assets.delete(this.name);
|
||
}
|
||
}
|
||
function createScene(name) {
|
||
const scene = { name, loading: false };
|
||
scene.controller = {
|
||
openLoad() { scene.loading = true; state.loadingEvents.push(name + ':open'); },
|
||
closeLoad() { scene.loading = false; state.loadingEvents.push(name + ':close'); }
|
||
};
|
||
scene.canvas = { getComponent: type => type === (name === 'HomeScene' ? 'JiaZai' : 'SceneManager') ? scene.controller : null };
|
||
return scene;
|
||
}
|
||
state.scene = createScene('HomeScene');
|
||
const cc = {
|
||
SpriteFrame, Texture2D, JsonAsset: class {}, Component: class {},
|
||
rect: (x, y, width, height) => ({ x, y, width, height }),
|
||
v2: (x, y) => ({ x, y }), size: (width, height) => ({ width, height }),
|
||
find: () => state.scene.canvas || null,
|
||
isValid: object => !!object && !object.destroyed,
|
||
_decorator: { ccclass: value => typeof value === 'function' ? value : type => type, property: () => {} },
|
||
debug: { DebugMode: { INFO: 1, ERROR: 3 } },
|
||
game: { config: { debugMode: 1 }, EVENT_SHOW: 'show',
|
||
on: (event, callback, target) => state.gameListeners.push({ event, callback, target }) },
|
||
log: (...args) => state.logs.push(args), warn() {}, error() {}, fx: { GameConfig: {
|
||
BLOCK_IMAGE_BASE_URL: 'https://cdn.test/remote/Block/', BLOCK_IMAGE_VERSION: '1.0.0'
|
||
} },
|
||
sys: { localStorage: { getItem: key => state.storage.get(key), setItem: (key, value) => state.storage.set(key, value) } },
|
||
director: {
|
||
getScene: () => state.scene,
|
||
preloadScene: (name, cb) => queueMicrotask(() => cb(null)),
|
||
loadScene: name => {
|
||
state.scenes.push(name);
|
||
if (state.scene.controller) state.scene.controller.destroyed = true;
|
||
state.scene = createScene(name);
|
||
}
|
||
}
|
||
};
|
||
const customBundle = {
|
||
getInfoWithPath(name) { return fs.existsSync(path.join(root, 'assets/custom', name + '.json')) ? { path: name } : null; },
|
||
load(name, type, cb) {
|
||
state.jsonLoads.push(name);
|
||
const finish = () => state.fail.has(name) ? cb(new Error('JSON download failed'))
|
||
: cb(null, { json: state.badJson.has(name) ? {} : readJSON('assets/custom/' + name + '.json') });
|
||
if (state.hold.has(name)) state.deferred.set(name, finish);
|
||
else queueMicrotask(finish);
|
||
}
|
||
};
|
||
const url = (name, version = '1.0.0') => `https://cdn.test/remote/Block/${name}.png?v=${version}`;
|
||
const userFile = (name, version = '1.0.0') => `http://usr/Block/${version}/${name}.png`;
|
||
const imageName = remote => remoteName(remote);
|
||
const download = (remote, reload) => {
|
||
let file = !reload && (state.nativeFiles.get(remote + '@native') || cache.getCache(remote) || cache.getTemp(remote));
|
||
if (file && !state.disk.has(file)) throw new Error('missing local file');
|
||
if (!file) {
|
||
if (state.offline || state.fail.has(imageName(remote))) throw new Error('network error');
|
||
state.downloads++;
|
||
file = 'temp/' + state.downloads;
|
||
state.disk.set(file, 'valid');
|
||
state.temps.set(remote, file);
|
||
cache.cacheFile(remote, file);
|
||
}
|
||
state.nativeFiles.set(remote + '@native', file);
|
||
return file;
|
||
};
|
||
const downloading = new Map();
|
||
const requestFile = (remote, reload, cb) => {
|
||
if (downloading.has(remote)) { downloading.get(remote).push(cb); return; }
|
||
const callbacks = [cb];
|
||
downloading.set(remote, callbacks);
|
||
state.fileRequests.push(imageName(remote));
|
||
state.activeDownloads.add(imageName(remote));
|
||
state.maxActiveDownloads = Math.max(state.maxActiveDownloads, state.activeDownloads.size);
|
||
const finish = () => {
|
||
let error, file;
|
||
try { file = download(remote, reload); } catch (err) { error = err; }
|
||
downloading.delete(remote);
|
||
state.activeDownloads.delete(imageName(remote));
|
||
state.deferred.delete(key);
|
||
callbacks.forEach(callback => callback(error, file));
|
||
};
|
||
const key = 'download:' + imageName(remote);
|
||
if (state.hold.has(key)) state.deferred.set(key, finish);
|
||
else finish();
|
||
};
|
||
const requestNative = (remote, options, cb) => requestFile(remote, options.reload, cb);
|
||
cc.assetManager = {
|
||
cacheManager: cache,
|
||
downloader: {
|
||
// Deliberately no bundleVers/remoteServerAddress: raw PNGs do not depend on a build.
|
||
maxRetryCount: 3, maxConcurrency: 8,
|
||
downloadFile(remote, options, progress, cb) {
|
||
return requestManifest({ url: remote, ...options,
|
||
success: response => cb(response.statusCode === 200 ? null : new Error('HTTP error'), response.data),
|
||
fail: error => cb(error)
|
||
});
|
||
}
|
||
},
|
||
_files: { get: key => state.nativeFiles.get(key), remove: key => state.nativeFiles.delete(key) }, _parsed: { remove() {} },
|
||
loadBundle: (name, options, cb) => queueMicrotask(() => {
|
||
state.bundleLoads.push(name);
|
||
assert.notEqual(name, 'Block', 'Block must not use any Cocos build artifacts');
|
||
(cb || options)(null, customBundle);
|
||
}),
|
||
loadRemote(remote, options, cb) {
|
||
const cachedUrl = [...state.caches, ...state.temps].find(([, file]) => file === remote)?.[0];
|
||
const nativeKey = [...state.nativeFiles].find(([, file]) => file === remote)?.[0];
|
||
const name = imageName(cachedUrl || (nativeKey && nativeKey.replace(/@native$/, '')) || remote);
|
||
state.loads.push({ name, url: remote, reload: options.reload });
|
||
const consume = (error, file) => {
|
||
try {
|
||
if (error) throw error;
|
||
if (state.disk.get(file) === 'corrupt') throw new Error('decode error');
|
||
const texture = state.textures.get(remote) || new Texture2D(name, remote);
|
||
state.textures.set(remote, texture);
|
||
cb(null, texture);
|
||
} catch (error) { cb(error); }
|
||
};
|
||
const finish = () => state.disk.has(remote) ? consume(null, remote)
|
||
: requestNative(remote, options, consume);
|
||
const key = options.reload && state.hold.has(name + ':retry') ? name + ':retry' : name;
|
||
if (state.hold.has(key)) state.deferred.set(key, finish);
|
||
else queueMicrotask(finish);
|
||
}
|
||
};
|
||
const modules = new Map();
|
||
function load(file) {
|
||
const absolute = path.resolve(root, file);
|
||
if (modules.has(absolute)) return modules.get(absolute).exports;
|
||
const mod = { exports: {} };
|
||
modules.set(absolute, mod);
|
||
const source = fs.readFileSync(absolute, 'utf8');
|
||
const output = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS, experimentalDecorators: true } }).outputText;
|
||
vm.runInNewContext(output, {
|
||
module: mod, exports: mod.exports, cc,
|
||
wx, tt,
|
||
console: { log: (...args) => { state.consoleLogs.push(args); state.logs.push(args); }, warn() {}, error() {} }, window: {},
|
||
setTimeout: (cb, delay) => {
|
||
const id = ++timerId;
|
||
if (delay < 1000) queueMicrotask(cb);
|
||
else state.timers.set(id, { cb, delay });
|
||
return id;
|
||
},
|
||
clearTimeout: id => state.timers.delete(id),
|
||
require(request) {
|
||
const target = path.resolve(path.dirname(absolute), request) + '.ts';
|
||
if (['BlockTexturePaths.ts', 'BlockAssetManager.ts', 'GameConfig.ts', 'GameTool.ts', 'Map.ts'].includes(path.basename(target))) return load(target);
|
||
return { MiniGameSdk: { API: {} }, default: {} };
|
||
}
|
||
}, { filename: absolute });
|
||
return mod.exports;
|
||
}
|
||
const Manager = load('assets/Script/module/Tool/BlockAssetManager.ts').default;
|
||
const pathsModule = load('assets/Script/module/Tool/BlockTexturePaths.ts');
|
||
const collect = pathsModule.collectBlockTexturePaths;
|
||
function config() {
|
||
const Config = load('assets/Script/module/Config/GameConfig.ts').GameConfig;
|
||
cc.fx.GameConfig = Config;
|
||
Config.BLOCK_IMAGE_BASE_URL = 'https://cdn.test/remote/Block/';
|
||
cc.fx.GameTool = load('assets/Script/module/Tool/GameTool.ts').GameTool;
|
||
Config.GM_INFO = { level: 49, otherLevel: 0, GameplayType: 0 };
|
||
Config.showBlockLoadError = (error, retry) => state.errors.push({ error, retry });
|
||
return Config;
|
||
}
|
||
function map() {
|
||
const MapController = load('assets/Script/Map.ts').default;
|
||
return Object.assign(Object.create(MapController.prototype), {
|
||
blockAssetsReady: false,
|
||
initMap() { state.mapsInitialized++; },
|
||
showEnterNewModeIfNeeded() {}
|
||
});
|
||
}
|
||
return { state, cc, wx, tt, Manager, manager: Manager.instance, collect, pathsModule, config, cache, url, userFile, createScene, map };
|
||
}
|
||
|
||
test('all shipped levels resolve to small images, including hidden/switch/stack/cycle states', () => {
|
||
const { collect, pathsModule } = fixture();
|
||
assert.deepEqual([...pathsModule.allBlockTexturePaths()], imagePaths);
|
||
const files = fs.readdirSync(path.join(root, 'assets/custom/Json')).filter(n => /^level\d+\.json$/.test(n));
|
||
for (const name of files) {
|
||
const data = readJSON('assets/custom/Json/' + name).BLOCK_INFO[0];
|
||
const before = JSON.stringify(data);
|
||
for (const image of collect(data)) assert.ok(imagePaths.includes(image), `${name}: ${image}`);
|
||
assert.equal(JSON.stringify(data), before, 'reading next level must not mutate it');
|
||
}
|
||
assert.deepEqual([...collect([{ block: 2, color: 11, type: 1, stacking: 7, lock: false }])], ['0color2', '11color2', '12color2', '7color2']);
|
||
assert.deepEqual([...collect([{ block: 0, color: 10, type: 16 }])], ['10color0', 'question0']);
|
||
assert.deepEqual([...collect([{ block: 1, color: 9, type: 20, colorArray: '81' }])], ['2color1', '9color1']);
|
||
assert.deepEqual([...collect([{ block: 23, color: 1 }])], []);
|
||
});
|
||
|
||
test('50 -> 51 releases only old exclusive frames; restart reuses both levels', async () => {
|
||
const { manager, collect, state } = fixture();
|
||
const a = level(50).BLOCK_INFO[0], b = level(51).BLOCK_INFO[0], c = level(52).BLOCK_INFO[0];
|
||
await manager.prepareWindow(a, b);
|
||
assert.deepEqual([...manager.getLoadedPaths()], sorted([...collect(a), ...collect(b)]));
|
||
manager.activateScene(a);
|
||
const shared = collect(a).find(name => collect(b).includes(name));
|
||
const original = manager.getFrame(shared);
|
||
await manager.prepareWindow(b, c);
|
||
assert.ok(manager.getLoadedPaths().includes(collect(a)[0]), 'old scene stays visible until destroyed');
|
||
manager.releaseScene();
|
||
manager.activateScene(b);
|
||
assert.deepEqual([...manager.getLoadedPaths()], sorted([...collect(b), ...collect(c)]));
|
||
assert.equal(manager.getFrame(shared), original);
|
||
const count = state.loads.length;
|
||
await manager.prepareWindow(b, c);
|
||
assert.equal(state.loads.length, count);
|
||
for (const frame of state.assets.values()) { assert.equal(frame.texture.refs, 1); assert.equal(frame.texture.packable, false); }
|
||
assert.equal(state.textures.size, state.assets.size, 'no orphaned raw textures after level transitions');
|
||
});
|
||
|
||
test('failed new window rolls back without freeing current/next; retry is possible', async () => {
|
||
const { manager, state } = fixture();
|
||
const old = [{ block: 0, color: 1 }], next = [{ block: 1, color: 2 }];
|
||
await manager.prepareWindow(old, next);
|
||
manager.activateScene(old);
|
||
const original = [...manager.getLoadedPaths()];
|
||
state.fail.add('9color22');
|
||
await assert.rejects(manager.prepareWindow([{ block: 22, color: 9 }], []));
|
||
assert.deepEqual([...manager.getLoadedPaths()], original);
|
||
state.fail.clear();
|
||
assert.equal(await manager.prepareWindow([{ block: 22, color: 9 }], []), true);
|
||
manager.releaseScene();
|
||
assert.deepEqual([...manager.getLoadedPaths()], ['9color22']);
|
||
});
|
||
|
||
test('late completion of a cancelled window cannot pin obsolete textures', async () => {
|
||
const { manager, state } = fixture();
|
||
state.hold.add('1color0');
|
||
const oldRequest = manager.prepareWindow([{ block: 0, color: 1 }], []);
|
||
await tick();
|
||
const latest = manager.prepareWindow([{ block: 1, color: 2 }], []);
|
||
assert.equal(await latest, true);
|
||
state.deferred.get('1color0')();
|
||
assert.equal(await oldRequest, false);
|
||
assert.deepEqual([...manager.getLoadedPaths()], ['2color1']);
|
||
assert.ok(state.releases.includes('1color0'));
|
||
});
|
||
|
||
test('invalid image configuration can retry, and concurrent windows share one texture load', async () => {
|
||
const { manager, state, cc } = fixture();
|
||
const base = cc.fx.GameConfig.BLOCK_IMAGE_BASE_URL;
|
||
cc.fx.GameConfig.BLOCK_IMAGE_BASE_URL = '';
|
||
await assert.rejects(manager.prepareWindow([{ block: 0, color: 1 }], []));
|
||
cc.fx.GameConfig.BLOCK_IMAGE_BASE_URL = base;
|
||
state.hold.add('1color0');
|
||
const first = manager.prepareWindow([{ block: 0, color: 1 }], []);
|
||
await tick();
|
||
const second = manager.prepareWindow([{ block: 0, color: 1 }], []);
|
||
await tick();
|
||
assert.equal(state.loads.length, 1);
|
||
state.deferred.get('1color0')();
|
||
assert.equal(await first, false);
|
||
assert.equal(await second, true);
|
||
assert.equal(manager.getFrame('1color0').getTexture().refs, 1);
|
||
});
|
||
|
||
test('background downloads directly to the versioned user directory without Cocos persistence', async () => {
|
||
const { manager, Manager, state, userFile } = fixture();
|
||
state.disk.set(userFile(imagePaths[0]) + '.tmp', 'interrupted');
|
||
await manager.cacheAllFiles();
|
||
assert.equal(state.loads.length, 0);
|
||
assert.equal(state.assets.size, 0);
|
||
assert.equal(state.downloads, imagePaths.length);
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true);
|
||
assert.equal(state.cacheWrites, 0, 'Block must not enter the Cocos persistent-cache queue');
|
||
assert.equal(state.temps.size, 0);
|
||
assert.ok(imagePaths.every(name => state.disk.has(userFile(name))));
|
||
assert.ok(imagePaths.every(name => !state.disk.has(userFile(name) + '.tmp')));
|
||
assert.ok(state.directRequests.every(request => request.filePath === userFile(request.name) + '.tmp'));
|
||
const restarted = new Manager();
|
||
await restarted.cacheAllFiles();
|
||
assert.equal(state.downloads, imagePaths.length, 'restart should reuse actual local files');
|
||
assert.deepEqual(state.loadingEvents, [], 'background caching must remain silent');
|
||
});
|
||
|
||
test('background startup, downloads and cache hits remain observable in a non-debug WeChat build', async () => {
|
||
const { manager, Manager, state, cc } = fixture();
|
||
cc.game.config.debugMode = cc.debug.DebugMode.ERROR;
|
||
cc.log = cc.warn = () => {}; // Cocos release boot resets these to no-ops.
|
||
state.hold.add('version');
|
||
manager.startBackgroundCache();
|
||
manager.startBackgroundCache();
|
||
await tick();
|
||
assert.equal(state.requests.length, 1);
|
||
assert.equal(state.gameListeners.length, 1);
|
||
assert.equal(state.downloads, 0, 'version check has not completed');
|
||
for (const message of ['[后台缓存] 启动', '[版本检查] 开始', '任务正在运行']) {
|
||
assert.ok(state.consoleLogs.some(args => args[0].includes(message)));
|
||
}
|
||
state.deferred.get('version')();
|
||
await tick();
|
||
assert.equal(manager.backgroundRunning, false);
|
||
assert.equal(state.downloads, imagePaths.length);
|
||
assert.equal(state.loads.length, 0, 'background must still cache files without decoding textures');
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true);
|
||
const lines = state.consoleLogs.map(args => args[0]);
|
||
assert.equal(lines.filter(line => line.includes('[远程下载] 开始')).length, imagePaths.length);
|
||
assert.equal(lines.filter(line => line.includes('[远程下载] 成功')).length, imagePaths.length);
|
||
assert.ok(lines.some(line => line.includes(`有效持久文件=${imagePaths.length}/${imagePaths.length},完整落盘=true`)));
|
||
assert.ok(lines.some(line => line.includes('本轮结束,全部已持久缓存')));
|
||
manager.startBackgroundCache();
|
||
await tick();
|
||
assert.equal(state.downloads, imagePaths.length, 'valid cache must not be downloaded for logging');
|
||
assert.ok(state.consoleLogs.some(args => args[0].includes('全部本地缓存有效,无需下载')));
|
||
assert.equal(cc.game.config.debugMode, cc.debug.DebugMode.ERROR, 'do not enable global game logging');
|
||
assert.deepEqual(state.loadingEvents, []);
|
||
});
|
||
|
||
test('background keeps eight files in flight and replenishes each completed slot without waiting for a batch', async t => {
|
||
const { manager, Manager, state } = fixture();
|
||
manager.paths = manager.paths.slice(0, 18);
|
||
for (const name of manager.paths) state.hold.add('download:' + name);
|
||
t.after(() => releaseHeldDownloads(state));
|
||
const background = manager.cacheAllFiles();
|
||
await tick();
|
||
assert.deepEqual(state.fileRequests, [...manager.paths.slice(0, 8)]);
|
||
assert.equal(state.activeDownloads.size, 8);
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false);
|
||
state.deferred.get('download:' + manager.paths[3])();
|
||
await tick();
|
||
assert.equal(state.fileRequests.length, 9, 'one free worker immediately starts the ninth image');
|
||
assert.equal(state.activeDownloads.size, 8, 'the other seven slow images do not block replenishment');
|
||
assert.ok(state.logs.some(args => args[0].includes('在途=8/8')));
|
||
releaseHeldDownloads(state);
|
||
assert.equal((await background).complete, true);
|
||
assert.equal(state.downloads, 18);
|
||
assert.equal(new Set(state.fileRequests).size, 18);
|
||
assert.equal(state.maxActiveDownloads, 8);
|
||
assert.equal(state.loads.length, 0, 'parallel file caching must not decode textures');
|
||
});
|
||
|
||
test('one failed worker does not stop the pool or schedule a retry before the other workers finish', async t => {
|
||
const { manager, Manager, state } = fixture();
|
||
manager.paths = manager.paths.slice(0, 12);
|
||
for (const name of manager.paths.slice(0, 8)) state.hold.add('download:' + name);
|
||
t.after(() => releaseHeldDownloads(state));
|
||
state.fail.add(manager.paths[0]);
|
||
manager.startBackgroundCache();
|
||
await tick();
|
||
manager.startBackgroundCache();
|
||
assert.equal(state.fileRequests.length, 8, 'starting again must not create another pool');
|
||
state.deferred.get('download:' + manager.paths[0])();
|
||
await tick();
|
||
assert.equal(state.fileRequests.length, 12, 'the failed worker continues processing the remaining queue');
|
||
assert.equal(manager.backgroundRunning, true);
|
||
assert.equal(state.timers.size, 0, 'seven requests are still in flight');
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false);
|
||
releaseHeldDownloads(state);
|
||
await tick();
|
||
assert.equal(manager.backgroundRunning, false);
|
||
assert.equal(state.downloads, 11);
|
||
state.fail.clear();
|
||
fireTimer(state, 10000);
|
||
await tick();
|
||
assert.equal(state.fileRequests.length, 13, 'only the one failed file is retried');
|
||
assert.equal(state.downloads, 12);
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true);
|
||
});
|
||
|
||
test('level entry pauses new background work and the same pool resumes after switching to GameScene', async t => {
|
||
const { manager, Manager, state, config, collect } = fixture();
|
||
const Config = config();
|
||
Config.applyLevelData(level(50), false, 0, 50);
|
||
const needed = sorted([...collect(level(50).BLOCK_INFO[0]), ...collect(level(51).BLOCK_INFO[0])]);
|
||
const foregroundName = collect(level(50).BLOCK_INFO[0])[0];
|
||
const backgroundNames = imagePaths.filter(name => !needed.includes(name)).slice(0, 16);
|
||
const backgroundRequestCount = () => state.fileRequests.filter(name => backgroundNames.includes(name)).length;
|
||
manager.paths = backgroundNames.concat(needed);
|
||
manager.delay = ms => ms === 100 ? tick() : Promise.resolve();
|
||
for (const name of manager.paths.slice(0, 8)) state.hold.add('download:' + name);
|
||
state.hold.add('download:' + foregroundName);
|
||
t.after(() => releaseHeldDownloads(state));
|
||
manager.startBackgroundCache();
|
||
await tick();
|
||
assert.equal(backgroundRequestCount(), 8);
|
||
Config.enterGameSceneWhenReady();
|
||
await tick();
|
||
assert.equal(state.scene.loading, true);
|
||
assert.ok(manager.foregroundLoads > 0);
|
||
for (const name of manager.paths.slice(0, 8)) state.deferred.get('download:' + name)();
|
||
await tick();
|
||
assert.equal(backgroundRequestCount(), 8, 'completed background slots must not be replenished while entering');
|
||
assert.equal(manager.backgroundRunning, true, 'pause must not discard the remaining queue');
|
||
assert.equal(state.logs.filter(args => args[0].includes('暂停派发')).length, 1);
|
||
state.hold.delete('download:' + foregroundName);
|
||
state.deferred.get('download:' + foregroundName)();
|
||
await waitFor(() => state.scenes.length === 1 && !manager.backgroundRunning, 'entry and background caching finish');
|
||
assert.deepEqual(state.scenes, ['GameScene']);
|
||
assert.deepEqual(state.loadingEvents, ['HomeScene:open', 'HomeScene:close']);
|
||
assert.equal(backgroundRequestCount(), 16, 'remaining images download after entering, without another start call');
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true);
|
||
assert.ok(state.logs.some(args => args[0].includes('进关图片处理结束,继续后台缓存')));
|
||
assert.equal(manager.getLoadedPaths().some(name => backgroundNames.includes(name)), false, 'background files stay out of texture memory');
|
||
});
|
||
|
||
test('failed foreground preparation also releases the background pause', async t => {
|
||
const { manager, state } = fixture();
|
||
manager.paths = manager.paths.slice(0, 12).concat('9color22');
|
||
manager.delay = ms => ms === 100 ? tick() : Promise.resolve();
|
||
for (const name of manager.paths.slice(0, 8)) state.hold.add('download:' + name);
|
||
const key = 'download:9color22';
|
||
state.hold.add(key);
|
||
state.fail.add('9color22');
|
||
t.after(() => releaseHeldDownloads(state));
|
||
const background = manager.cacheAllFiles();
|
||
await tick();
|
||
const foreground = assert.rejects(manager.prepareWindow([{ block: 22, color: 9 }], []));
|
||
await tick();
|
||
for (const name of manager.paths.slice(0, 8)) state.deferred.get('download:' + name)();
|
||
await tick();
|
||
assert.equal(state.fileRequests.filter(name => name !== '9color22').length, 8);
|
||
state.hold.delete(key);
|
||
state.deferred.get(key)();
|
||
await foreground;
|
||
state.fail.clear();
|
||
assert.equal((await background).complete, true);
|
||
assert.ok(manager.paths.every(name => state.disk.has(`http://usr/Block/1.0.0/${name}.png`)));
|
||
assert.equal(manager.foregroundLoads, 0);
|
||
});
|
||
|
||
test('Block scheduler gives foreground two extra slots while all eight background requests remain pending', async t => {
|
||
const { manager, state, cc } = fixture();
|
||
manager.delay = ms => ms === 100 ? tick() : Promise.resolve();
|
||
const blocks = [19, 20, 21, 22].map(block => ({ block, color: 9 }));
|
||
const names = blocks.map(block => `9color${block.block}`);
|
||
manager.paths = manager.paths.slice(0, 10).concat(names);
|
||
for (const name of [...manager.paths, ...names]) state.hold.add('download:' + name);
|
||
t.after(() => releaseHeldDownloads(state));
|
||
const background = manager.cacheAllFiles();
|
||
await tick();
|
||
assert.equal(state.activeDownloads.size, 8);
|
||
const foreground = manager.prepareWindow(blocks, []);
|
||
await tick();
|
||
assert.equal(state.activeDownloads.size, 10, 'foreground starts without waiting for any of the eight background requests');
|
||
assert.deepEqual(state.fileRequests.slice(8), names.slice(0, 2));
|
||
for (const name of names.slice(0, 2)) state.deferred.get('download:' + name)();
|
||
await waitFor(() => names.slice(2).every(name => state.deferred.has('download:' + name)), 'queued foreground files start next');
|
||
assert.deepEqual(sorted(state.fileRequests.slice(8)), names);
|
||
assert.equal(state.fileRequests.filter(name => manager.paths.slice(0, 10).includes(name)).length, 8);
|
||
for (const name of names.slice(2)) state.deferred.get('download:' + name)();
|
||
assert.equal(await foreground, true);
|
||
assert.equal(state.downloads, 4, 'all foreground files finish while the background files are still held');
|
||
state.deferred.get('download:' + manager.paths[0])();
|
||
await waitFor(() => state.fileRequests.filter(name => manager.paths.slice(0, 10).includes(name)).length === 9,
|
||
'background replenishment resumes after foreground');
|
||
releaseHeldDownloads(state);
|
||
assert.equal((await background).complete, true);
|
||
assert.equal(state.maxActiveDownloads, 10);
|
||
assert.equal(cc.assetManager.downloader.maxConcurrency, 8, 'global downloader settings remain unchanged');
|
||
});
|
||
|
||
test('background network failures wait ten seconds and retry only missing images without engine retry bursts', async () => {
|
||
const { manager, Manager, state } = fixture();
|
||
state.fail.add('question0');
|
||
manager.startBackgroundCache();
|
||
await tick();
|
||
assert.equal(state.downloads, imagePaths.length - 1);
|
||
assert.equal(state.directRequests.filter(request => request.name === 'question0').length, 1);
|
||
assert.equal(state.timers.size, 1);
|
||
assert.ok(state.logs.some(args => args[0].includes('仍缺少1张图片,10秒后重试')));
|
||
state.fail.clear();
|
||
fireTimer(state, 10000);
|
||
await tick();
|
||
assert.equal(state.downloads, imagePaths.length);
|
||
assert.equal(state.fileRequests.length, imagePaths.length + 1);
|
||
assert.equal(state.directRequests.filter(request => request.name === 'question0').length, 2);
|
||
assert.equal(state.timers.size, 0);
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true);
|
||
assert.equal(state.requests.length, 1, 'background retries do not refetch version metadata');
|
||
});
|
||
|
||
test('legacy Cocos temporary files migrate into the user directory without network requests', async () => {
|
||
const { manager, Manager, state, url, userFile } = fixture();
|
||
manager.paths = manager.paths.slice(0, 12);
|
||
await manager.initialize();
|
||
for (const name of manager.paths) {
|
||
const temp = 'legacy-temp/' + name;
|
||
state.temps.set(url(name), temp);
|
||
state.disk.set(temp, 'valid');
|
||
}
|
||
await manager.cacheAllFiles();
|
||
assert.equal(state.downloads, 0);
|
||
assert.equal(state.directRequests.length, 0);
|
||
assert.equal(state.cacheWrites, 0);
|
||
assert.ok(manager.paths.every(name => state.disk.has(userFile(name))));
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true);
|
||
});
|
||
|
||
test('failed legacy-file migration retries after ten seconds without downloading a duplicate', async () => {
|
||
const { manager, Manager, state, url, userFile } = fixture();
|
||
manager.paths = manager.paths.slice(0, 4);
|
||
await manager.initialize();
|
||
for (const name of manager.paths) {
|
||
const temp = 'legacy-temp/' + name;
|
||
state.temps.set(url(name), temp);
|
||
state.disk.set(temp, 'valid');
|
||
}
|
||
state.save = false;
|
||
manager.startBackgroundCache();
|
||
await tick();
|
||
assert.equal(state.directRequests.length, 0);
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false);
|
||
assert.ok(state.logs.some(args => args[0].includes('4张旧临时文件待迁移,10秒后重试')));
|
||
state.save = true;
|
||
fireTimer(state, 10000);
|
||
await tick();
|
||
assert.ok(manager.paths.every(name => state.disk.has(userFile(name))));
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true);
|
||
assert.equal(state.timers.size, 0);
|
||
});
|
||
|
||
test('Cocos cache-index writes do not affect direct-cache completion', async () => {
|
||
const { manager, Manager, state } = fixture();
|
||
manager.paths = manager.paths.slice(0, 12);
|
||
await manager.cacheAllFiles();
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true);
|
||
assert.equal(state.cacheWrites, 0);
|
||
assert.equal(state.disk.has('gamecaches/cacheList.json'), false);
|
||
});
|
||
|
||
test('storage-full retries direct downloads after ten seconds', async () => {
|
||
const { manager, Manager, state } = fixture();
|
||
manager.paths = manager.paths.slice(0, 12);
|
||
state.save = false;
|
||
manager.startBackgroundCache();
|
||
await tick();
|
||
assert.equal(state.downloads, 0);
|
||
assert.equal(state.directRequests.length, 12);
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false);
|
||
state.save = true;
|
||
fireTimer(state, 10000);
|
||
await tick();
|
||
assert.equal(state.downloads, 12);
|
||
assert.equal(state.directRequests.length, 24);
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true);
|
||
assert.equal(state.timers.size, 0);
|
||
});
|
||
|
||
test('the ten-second background retry does not delay an immediate foreground level entry', async () => {
|
||
const { manager, state, config, collect } = fixture();
|
||
const Config = config();
|
||
const missing = collect(level(50).BLOCK_INFO[0])[0];
|
||
state.fail.add(missing);
|
||
manager.startBackgroundCache();
|
||
await tick();
|
||
assert.equal([...state.timers.values()].some(timer => timer.delay === 10000), true);
|
||
state.fail.clear();
|
||
Config.applyLevelData(level(50), false, 0, 50);
|
||
Config.enterGameSceneWhenReady();
|
||
await tick();
|
||
assert.deepEqual(state.scenes, ['GameScene']);
|
||
assert.deepEqual(state.loadingEvents, ['HomeScene:open', 'HomeScene:close']);
|
||
assert.equal(state.downloads, imagePaths.length);
|
||
const requests = state.fileRequests.length;
|
||
fireTimer(state, 10000);
|
||
await tick();
|
||
assert.equal(state.downloads, imagePaths.length);
|
||
assert.equal(state.fileRequests.length, requests, 'the later retry sees the foreground cache and skips downloading');
|
||
assert.equal(state.timers.size, 0);
|
||
});
|
||
|
||
test('foreground and background create the Block cache directory only when it is missing', async () => {
|
||
const { manager, Manager, state } = fixture();
|
||
const directory = 'http://usr/Block/1.0.0';
|
||
const blocks = [{ block: 0, color: 1 }];
|
||
await Promise.all([manager.prepareWindow(blocks, []), manager.cacheAllFiles()]);
|
||
await manager.prepareWindow(blocks, []);
|
||
assert.deepEqual(state.mkdirCalls, [directory]);
|
||
const restarted = new Manager();
|
||
await restarted.cacheAllFiles();
|
||
assert.equal(state.mkdirCalls.length, 1, 'an existing directory survives manager/session initialization');
|
||
state.directories.delete(directory);
|
||
await manager.prepareWindow(blocks, []);
|
||
assert.equal(state.mkdirCalls.length, 2, 'cleared cache directories must be recreated, not remembered by a flag');
|
||
assert.ok(state.directories.has(directory));
|
||
});
|
||
|
||
test('directory creation failures still surface and may be retried; existing files are never deleted', async () => {
|
||
const { manager, state } = fixture();
|
||
const directory = 'http://usr/Block/1.0.0';
|
||
await manager.initialize();
|
||
const permissionError = new Error('mkdirSync:fail permission denied');
|
||
state.mkdirError = permissionError;
|
||
assert.throws(() => manager.ensureUserCacheFolder(), permissionError);
|
||
assert.equal(state.directories.has(directory), false);
|
||
state.mkdirError = null;
|
||
manager.ensureUserCacheFolder();
|
||
assert.ok(state.directories.has(directory));
|
||
assert.equal(state.mkdirCalls.length, 2);
|
||
state.directories.delete(directory);
|
||
state.disk.set(directory, 'a file occupying the directory path');
|
||
assert.throws(() => manager.ensureUserCacheFolder(), /file already exists/);
|
||
assert.equal(state.disk.get(directory), 'a file occupying the directory path');
|
||
assert.equal(state.directories.has(directory), false, 'a same-name file is not a usable directory');
|
||
});
|
||
|
||
test('a complete disk cache loads only the two-level texture window without downloading', async () => {
|
||
const { manager, state, collect } = fixture();
|
||
await manager.cacheAllFiles();
|
||
const downloads = state.downloads;
|
||
const current = level(50).BLOCK_INFO[0], next = level(51).BLOCK_INFO[0];
|
||
await manager.prepareWindow(current, next);
|
||
assert.equal(state.downloads, downloads);
|
||
assert.deepEqual([...state.assets.keys()].sort(), sorted([...collect(current), ...collect(next)]));
|
||
});
|
||
|
||
test('a complete localStorage marker never substitutes for missing user-directory files', async () => {
|
||
const { manager, Manager, state } = fixture();
|
||
state.storage.set(Manager.CACHE_KEY, JSON.stringify({
|
||
version: '1.0.0', baseUrl: 'https://cdn.test/remote/Block/',
|
||
total: imagePaths.length, cached: imagePaths.length, complete: true
|
||
}));
|
||
await manager.cacheAllFiles();
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true);
|
||
assert.equal(state.downloads, imagePaths.length);
|
||
});
|
||
|
||
test('cache eviction, interrupted download, and semantic version changes revalidate completion', async () => {
|
||
const { manager, Manager, state, userFile } = fixture();
|
||
state.fail.add('question0');
|
||
await manager.cacheAllFiles();
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false);
|
||
assert.ok(state.logs.some(args => args[0].includes('[远程下载] 失败 question0.png')));
|
||
state.fail.clear();
|
||
await manager.cacheAllFiles();
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, true);
|
||
state.disk.delete(userFile('1color0'));
|
||
await manager.cacheAllFiles();
|
||
assert.equal(state.downloads, imagePaths.length + 1);
|
||
const restarted = fixture(state);
|
||
restarted.state.manifest.data.version = '1.0.1';
|
||
await restarted.manager.cacheAllFiles();
|
||
assert.equal(restarted.state.downloads, imagePaths.length * 2 + 1);
|
||
const saved = JSON.parse(restarted.state.storage.get(Manager.CACHE_KEY));
|
||
assert.equal(saved.version, '1.0.1');
|
||
assert.equal(saved.complete, true);
|
||
});
|
||
|
||
test('1.0.0 -> 1.0.1 changes raw PNG cache URLs without any bundle or build hash', async () => {
|
||
const first = fixture();
|
||
await first.manager.cacheAllFiles();
|
||
const { manager, Manager, state, url, userFile } = fixture(first.state);
|
||
state.manifest.data.version = '1.0.1';
|
||
await manager.prepareWindow([{ block: 0, color: 1 }], [{ block: 1, color: 2 }]);
|
||
assert.deepEqual(state.bundleLoads, []);
|
||
assert.equal(state.downloads, imagePaths.length + 2, 'both foreground images need the new version');
|
||
assert.ok(state.loads.every(load => load.url.includes('/Block/1.0.1/')));
|
||
assert.ok(state.directRequests.every(request => request.url.endsWith('.png?v=1.0.1')));
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false);
|
||
assert.ok(state.disk.has(userFile('1color0', '1.0.0')), 'do not delete the previous version before replacement completes');
|
||
await manager.cacheAllFiles();
|
||
const saved = JSON.parse(state.storage.get(Manager.CACHE_KEY));
|
||
assert.equal(saved.version, '1.0.1');
|
||
assert.equal(saved.baseUrl, 'https://cdn.test/remote/Block/');
|
||
assert.equal(saved.complete, true);
|
||
assert.equal(state.downloads, imagePaths.length * 2, 'a total version bump refreshes all files');
|
||
assert.equal(state.assets.size, 2, 'background update must not decode the other images');
|
||
assert.equal(state.textures.size, 2);
|
||
state.manifest.data.version = '1.0.2';
|
||
await manager.cacheAllFiles();
|
||
await manager.prepareWindow([{ block: 0, color: 1 }], []);
|
||
assert.equal(state.requests.length, 1, 'scene changes and repeated scans keep this session version');
|
||
assert.equal(state.downloads, imagePaths.length * 2);
|
||
assert.match(state.requests[0].url, /^https:\/\/cdn\.test\/remote\/Block\/version\.json\?_t=\d+$/);
|
||
assert.equal(state.requests[0].header['Cache-Control'], 'no-cache');
|
||
});
|
||
|
||
test('fully offline restart uses the cached semantic version and images without a bundle config', async () => {
|
||
const first = fixture();
|
||
first.state.manifest.data.version = '1.0.1';
|
||
await first.manager.cacheAllFiles();
|
||
const { manager, state } = fixture(first.state);
|
||
state.offline = true;
|
||
await manager.prepareWindow([{ block: 0, color: 1 }], [{ block: 1, color: 2 }]);
|
||
assert.ok(state.loads.every(load => load.url.includes('/Block/1.0.1/')));
|
||
assert.equal(state.directRequests.length, 0);
|
||
assert.equal(state.downloads, imagePaths.length);
|
||
assert.equal(state.assets.size, 2);
|
||
assert.deepEqual(state.bundleLoads, []);
|
||
});
|
||
|
||
test('incomplete new version resumes after a manifest outage without repeating cached downloads', async () => {
|
||
const first = fixture();
|
||
await first.manager.cacheAllFiles();
|
||
const updated = fixture(first.state);
|
||
updated.state.manifest.data.version = '1.0.1';
|
||
updated.state.fail.add('question0');
|
||
await updated.manager.cacheAllFiles();
|
||
let saved = JSON.parse(updated.state.storage.get(updated.Manager.CACHE_KEY));
|
||
assert.equal(saved.version, '1.0.1');
|
||
assert.equal(saved.complete, false);
|
||
assert.equal(updated.state.downloads, imagePaths.length * 2 - 1);
|
||
const restarted = fixture(updated.state);
|
||
restarted.state.manifestError = new Error('version service unavailable');
|
||
await restarted.manager.cacheAllFiles();
|
||
saved = JSON.parse(restarted.state.storage.get(restarted.Manager.CACHE_KEY));
|
||
assert.equal(saved.version, '1.0.1');
|
||
assert.equal(saved.complete, true);
|
||
assert.equal(restarted.state.downloads, imagePaths.length * 2);
|
||
});
|
||
|
||
test('missing new-version PNG fails entry and retries the new URL instead of using old pixels', async () => {
|
||
const first = fixture();
|
||
await first.manager.cacheAllFiles();
|
||
const { manager, Manager, state } = fixture(first.state);
|
||
state.manifest.data.version = '1.0.1';
|
||
state.fail.add('1color0');
|
||
await assert.rejects(manager.prepareWindow([{ block: 0, color: 1 }], []));
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).version, '1.0.1');
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false);
|
||
assert.equal(state.assets.size, 0);
|
||
state.fail.clear();
|
||
await manager.prepareWindow([{ block: 0, color: 1 }], []);
|
||
assert.ok(state.loads.every(load => load.url.includes('/Block/1.0.1/')));
|
||
assert.ok(state.directRequests.every(request => request.url.endsWith('?v=1.0.1')));
|
||
assert.equal(state.requests.length, 1, 'retry shares the selected semantic version');
|
||
assert.equal(state.downloads, imagePaths.length + 1);
|
||
});
|
||
|
||
test('old build-hash records and version hints for a different CDN cannot select a raw-image version', async () => {
|
||
for (const previous of [
|
||
{ version: 'fbc07', buildVersion: 'fbc07', complete: true },
|
||
{ version: '1.5.0', baseUrl: 'https://another.test/Block/', complete: true }
|
||
]) {
|
||
const { manager, Manager, state } = fixture();
|
||
state.storage.set(Manager.CACHE_KEY, JSON.stringify(previous));
|
||
state.manifestError = new Error('offline');
|
||
await manager.prepareWindow([{ block: 0, color: 1 }], []);
|
||
assert.ok(state.loads.every(load => load.url.includes('/Block/1.0.0/')));
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false);
|
||
}
|
||
});
|
||
|
||
test('missing, malformed or unsafe version metadata falls back without trusting legacy true flags', async () => {
|
||
for (const response of [
|
||
{ statusCode: 404 }, { statusCode: 200, data: '{' }, { statusCode: 200, data: null },
|
||
...['../bad', '1', 'fbc07', '1.0.0?x=1'].map(version => ({ statusCode: 200, data: { version } }))
|
||
]) {
|
||
const { manager, Manager, state } = fixture();
|
||
state.storage.set(Manager.CACHE_KEY, 'true');
|
||
state.manifest = response;
|
||
await manager.prepareWindow([{ block: 0, color: 1 }], []);
|
||
const saved = JSON.parse(state.storage.get(Manager.CACHE_KEY));
|
||
assert.equal(saved.version, '1.0.0');
|
||
assert.equal(saved.complete, false);
|
||
assert.equal(state.downloads, 1);
|
||
}
|
||
});
|
||
|
||
test('version watchdog falls back once and ignores a late response', async () => {
|
||
const { manager, state } = fixture();
|
||
state.manifest.data.version = '1.0.1';
|
||
state.hold.add('version');
|
||
const pending = manager.prepareWindow([{ block: 0, color: 1 }], []);
|
||
await tick();
|
||
assert.equal(state.loads.length, 0);
|
||
[...state.timers.values()].find(timer => timer.delay === 5000).cb();
|
||
assert.equal(await pending, true);
|
||
assert.equal(state.requestAborts, 1);
|
||
state.deferred.get('version')();
|
||
await tick();
|
||
assert.ok(state.loads.every(load => load.url.includes('/Block/1.0.0/')));
|
||
});
|
||
|
||
test('ByteDance and browser version requests work; browsers never run full file pre-caching', async () => {
|
||
const { manager, wx, tt, state } = fixture();
|
||
tt.request = wx.request;
|
||
tt.downloadFile = wx.downloadFile;
|
||
tt.getFileSystemManager = wx.getFileSystemManager;
|
||
tt.env = wx.env;
|
||
delete wx.request;
|
||
delete wx.downloadFile;
|
||
delete wx.getFileSystemManager;
|
||
state.manifest.data = JSON.stringify({ version: '1.0.1' });
|
||
await manager.prepareWindow([{ block: 0, color: 1 }], []);
|
||
assert.ok(state.loads.every(load => load.url.includes('/Block/1.0.1/')));
|
||
assert.ok(state.directRequests.every(request => request.url.endsWith('?v=1.0.1')));
|
||
const browser = fixture();
|
||
delete browser.wx.request;
|
||
delete browser.wx.downloadFile;
|
||
delete browser.wx.getFileSystemManager;
|
||
browser.cc.assetManager.cacheManager = null;
|
||
browser.state.manifest.data.version = '1.0.2';
|
||
browser.manager.startBackgroundCache();
|
||
assert.ok(browser.state.logs.some(args => args[0].includes('[后台缓存] 跳过:用户缓存环境不可用')));
|
||
await browser.manager.prepareWindow([{ block: 0, color: 1 }], []);
|
||
assert.ok(browser.state.loads.every(load => load.url.endsWith('?v=1.0.2')));
|
||
assert.equal(browser.state.requests.length, 1);
|
||
assert.equal(browser.state.directRequests.length, 0);
|
||
assert.equal(browser.state.storage.size, 0);
|
||
});
|
||
|
||
test('raw SpriteFrames preserve existing PNG crop layout and explicitly release their textures', async () => {
|
||
const { manager, state, pathsModule, url } = fixture();
|
||
for (const name of imagePaths) {
|
||
const frame = Object.values(readJSON('assets/Block/' + name + '.png.meta').subMetas)[0];
|
||
const trim = pathsModule.BLOCK_TEXTURE_TRIMS[name];
|
||
if (frame.rawWidth === frame.width && frame.rawHeight === frame.height) assert.equal(trim, undefined);
|
||
else assert.deepEqual([...trim], [frame.trimX, frame.trimY, frame.width, frame.height,
|
||
frame.offsetX, frame.offsetY, frame.rawWidth, frame.rawHeight]);
|
||
}
|
||
await manager.prepareWindow([{ block: 20, color: 4 }], [{ block: 0, color: 1 }]);
|
||
const oldFrame = manager.getFrame('4color20'), texture = oldFrame.getTexture();
|
||
assert.deepEqual(oldFrame.rect, { x: 23, y: 0, width: 244, height: 249 });
|
||
assert.deepEqual(oldFrame.originalSize, { width: 267, height: 249 });
|
||
await manager.prepareWindow([{ block: 1, color: 2 }], []);
|
||
assert.equal(oldFrame.destroyed, true);
|
||
assert.equal(texture.refs, 0);
|
||
assert.equal(state.nativeFiles.has(url('4color20') + '@native'), false);
|
||
assert.equal(state.textures.size, 1);
|
||
});
|
||
|
||
for (const scene of ['HomeScene', 'GameScene']) {
|
||
test(scene + ' opens Loading only for missing images after version selection', async () => {
|
||
const { manager, state, config, createScene, collect } = fixture();
|
||
state.scene = createScene(scene);
|
||
state.manifest.data.version = '1.0.1';
|
||
state.hold.add('version');
|
||
const Config = config(), sourceScene = state.scene;
|
||
const held = collect(level(51).BLOCK_INFO[0])[0];
|
||
state.hold.add(held);
|
||
const startup = manager.initialize();
|
||
Config.applyLevelData(level(50), false, 0, 50);
|
||
Config.enterGameSceneWhenReady();
|
||
await tick();
|
||
assert.equal(sourceScene.loading, false, 'version metadata alone must not show image Loading');
|
||
assert.equal(state.requests.length, 1);
|
||
assert.equal(state.loads.length, 0);
|
||
state.deferred.get('version')();
|
||
await startup;
|
||
await tick();
|
||
assert.equal(sourceScene.loading, true);
|
||
assert.equal(state.scenes.length, 0);
|
||
state.deferred.get(held)();
|
||
await tick();
|
||
assert.equal(sourceScene.loading, false);
|
||
assert.ok(state.loads.every(load => load.url.includes('/Block/1.0.1/')));
|
||
assert.ok(state.directRequests.every(request => request.url.endsWith('?v=1.0.1')));
|
||
assert.ok(state.bundleLoads.every(name => name !== 'Block'));
|
||
assert.deepEqual(state.scenes, ['GameScene']);
|
||
});
|
||
}
|
||
|
||
test('corrupt local image is removed and retried from remote before entering memory', async () => {
|
||
const { manager, state, url, userFile } = fixture();
|
||
state.caches.set(url('1color0'), 'bad.png');
|
||
state.disk.set('bad.png', 'corrupt');
|
||
await manager.prepareWindow([{ block: 0, color: 1 }], []);
|
||
assert.deepEqual(state.loads.map(item => item.reload), [false, false]);
|
||
assert.equal(state.downloads, 1);
|
||
assert.equal(state.directRequests.length, 1);
|
||
assert.ok(state.disk.has(userFile('1color0')));
|
||
assert.equal(manager.getFrame('1color0').getTexture().refs, 1);
|
||
});
|
||
|
||
test('next-level lookup uses file number, never replaces current level data or advances progress', async () => {
|
||
const { config, state } = fixture();
|
||
const Config = config();
|
||
const data = level(50);
|
||
data.LEVEL_INFO[0].id = '1001'; // Several shipped JSON ids differ from their actual file number.
|
||
Config.applyLevelData(data, false, 0, 50);
|
||
await Config.prepareBlockWindow();
|
||
assert.equal(Config.BLOCK_INFO, data.BLOCK_INFO);
|
||
assert.equal(Config.LEVEL_INFO, data.LEVEL_INFO);
|
||
assert.equal(Config.GM_INFO.level, 49);
|
||
assert.deepEqual(state.jsonLoads, ['Json/level51']);
|
||
});
|
||
|
||
test('scene transition waits for both levels; failure exposes retry without advancing progress', async () => {
|
||
const { config, state, collect } = fixture();
|
||
const Config = config();
|
||
Config.applyLevelData(level(50), false, 0, 50);
|
||
const onlyNext = collect(level(51).BLOCK_INFO[0]).find(name => !collect(level(50).BLOCK_INFO[0]).includes(name));
|
||
state.fail.add(onlyNext);
|
||
const sourceScene = state.scene;
|
||
Config.enterGameSceneWhenReady();
|
||
assert.equal(sourceScene.loading, false);
|
||
await tick();
|
||
assert.equal(state.scenes.length, 0);
|
||
assert.equal(state.errors.length, 1);
|
||
assert.equal(sourceScene.loading, false, 'close the spinner before showing retry');
|
||
assert.ok(state.logs.some(args => args[0].includes('[远程加载] 失败 ' + onlyNext + '.png')));
|
||
assert.equal(Config.GM_INFO.level, 49);
|
||
state.fail.clear();
|
||
state.errors[0].retry();
|
||
await tick();
|
||
assert.deepEqual(state.scenes, ['GameScene']);
|
||
assert.equal(sourceScene.loading, false);
|
||
assert.deepEqual(state.loadingEvents, ['HomeScene:open', 'HomeScene:close', 'HomeScene:open', 'HomeScene:close']);
|
||
});
|
||
|
||
for (const name of ['HomeScene', 'GameScene']) {
|
||
test(name + ' opens its own Loading until both levels are ready', async () => {
|
||
const { config, state, collect, createScene } = fixture();
|
||
state.scene = createScene(name);
|
||
const sourceScene = state.scene, Config = config();
|
||
Config.applyLevelData(level(50), false, 0, 50);
|
||
const onlyNext = collect(level(51).BLOCK_INFO[0]).find(image => !collect(level(50).BLOCK_INFO[0]).includes(image));
|
||
state.hold.add(onlyNext);
|
||
Config.enterGameSceneWhenReady();
|
||
assert.equal(sourceScene.loading, false);
|
||
await tick();
|
||
assert.equal(sourceScene.loading, true);
|
||
assert.equal(state.scenes.length, 0);
|
||
state.deferred.get(onlyNext)();
|
||
await tick();
|
||
assert.equal(sourceScene.loading, false);
|
||
assert.deepEqual(state.loadingEvents, [name + ':open', name + ':close']);
|
||
assert.deepEqual(state.scenes, ['GameScene']);
|
||
});
|
||
|
||
test(name + ' uses valid disk cache without Loading, including Map initialization and replay', async () => {
|
||
const { manager, config, state, collect, createScene, map } = fixture();
|
||
await manager.cacheAllFiles();
|
||
state.logs.length = 0;
|
||
state.scene = createScene(name);
|
||
const sourceScene = state.scene, Config = config();
|
||
const current = level(50).BLOCK_INFO[0], next = level(51).BLOCK_INFO[0];
|
||
const wanted = sorted([...collect(current), ...collect(next)]);
|
||
Config.applyLevelData(level(50), false, 0, 50);
|
||
const held = collect(next).find(image => !collect(current).includes(image));
|
||
state.hold.add(held);
|
||
const downloads = state.downloads;
|
||
Config.enterGameSceneWhenReady();
|
||
await tick();
|
||
assert.equal(sourceScene.loading, false);
|
||
assert.equal(state.scenes.length, 0, 'cached files must still finish decoding before entry');
|
||
assert.deepEqual(state.loadingEvents, []);
|
||
state.deferred.get(held)();
|
||
await tick();
|
||
assert.deepEqual(state.scenes, ['GameScene']);
|
||
const cachedLogs = state.logs.map(args => args[0]).filter(line => line.includes('[进关来源]'));
|
||
assert.equal(cachedLogs.length, wanted.length);
|
||
assert.ok(cachedLogs.every(line => line.includes('-> 本地缓存')));
|
||
for (const image of wanted) assert.ok(cachedLogs.some(line => line.includes(image + '.png')));
|
||
await map().prepareBlockAssets();
|
||
assert.equal(state.mapsInitialized, 1);
|
||
assert.deepEqual(state.jsonLoads, ['Json/level51'], 'only next JSON is memoized');
|
||
assert.deepEqual(state.loadingEvents, [], 'Map must not flash Loading after a prepared entry');
|
||
state.logs.length = 0;
|
||
const loads = state.loads.length;
|
||
Config.enterGameSceneWhenReady();
|
||
await tick();
|
||
const memoryLogs = state.logs.map(args => args[0]).filter(line => line.includes('[进关来源]'));
|
||
assert.equal(memoryLogs.length, wanted.length, 'replay must log sources again even with the same BLOCK_INFO');
|
||
assert.ok(memoryLogs.every(line => line.includes('-> 内存复用')));
|
||
assert.ok(state.logs.some(args => args[0].includes('当前关50 / 下一关51')));
|
||
assert.equal(state.loads.length, loads);
|
||
assert.equal(state.downloads, downloads);
|
||
assert.deepEqual(state.loadingEvents, []);
|
||
});
|
||
|
||
test(name + ' opens Loading if a cached image fails decoding and needs a remote retry', async () => {
|
||
const { manager, config, state, collect, createScene, url, userFile } = fixture();
|
||
await manager.cacheAllFiles();
|
||
state.logs.length = 0;
|
||
state.scene = createScene(name);
|
||
const sourceScene = state.scene, Config = config();
|
||
Config.applyLevelData(level(50), false, 0, 50);
|
||
const bad = collect(level(50).BLOCK_INFO[0])[0];
|
||
state.disk.set(userFile(bad), 'corrupt');
|
||
state.nativeFiles.delete(url(bad) + '@native');
|
||
state.hold.add(bad);
|
||
state.hold.add('download:' + bad);
|
||
Config.enterGameSceneWhenReady();
|
||
await tick();
|
||
assert.deepEqual(state.loadingEvents, [], 'file existence alone does not open Loading');
|
||
state.hold.delete(bad);
|
||
state.deferred.get(bad)();
|
||
await waitFor(() => state.deferred.has('download:' + bad), 'remote replacement starts after decode failure');
|
||
assert.equal(sourceScene.loading, true, 'decode failure must notify the entry before retry');
|
||
assert.equal(state.scenes.length, 0);
|
||
state.hold.delete('download:' + bad);
|
||
state.deferred.get('download:' + bad)();
|
||
await tick();
|
||
assert.deepEqual(state.loadingEvents, [name + ':open', name + ':close']);
|
||
assert.deepEqual(state.scenes, ['GameScene']);
|
||
assert.equal(state.downloads, imagePaths.length + 1);
|
||
const lines = state.logs.map(args => args[0]).filter(line => line.includes(bad + '.png'));
|
||
assert.ok(lines.some(line => line.includes('-> 本地缓存')));
|
||
assert.ok(lines.some(line => line.includes('-> 远程下载')));
|
||
assert.ok(lines.some(line => line.includes('[远程加载] 开始') && line.includes('进关重试')));
|
||
assert.ok(lines.some(line => line.includes('[远程加载] 成功')));
|
||
});
|
||
}
|
||
|
||
test('each image source distinguishes memory, disk, temporary cache and actual remote need', async () => {
|
||
const { manager, state, url } = fixture();
|
||
await manager.prepareWindow([{ block: 0, color: 1 }], []);
|
||
state.logs.length = 0;
|
||
state.caches.set(url('2color1'), 'disk.png');
|
||
state.disk.set('disk.png', 'valid');
|
||
state.caches.set(url('3color2'), 'evicted.png');
|
||
state.temps.set(url('3color2'), 'temp.png');
|
||
state.disk.set('temp.png', 'valid');
|
||
state.nativeFiles.set(url('5color4') + '@native', 'native.png');
|
||
state.disk.set('native.png', 'valid');
|
||
let remoteNotifications = 0;
|
||
await manager.prepareWindow([{ block: 0, color: 1 }, { block: 1, color: 2 }, { block: 2, color: 3 }],
|
||
[{ block: 3, color: 4 }, { block: 4, color: 5 }],
|
||
{ currentLevel: 50, nextLevel: 51, onRemoteRequired: () => remoteNotifications++ });
|
||
assert.equal(remoteNotifications, 1);
|
||
assert.equal(state.downloads, 2, 'valid temporary/native files survive a stale persistent index');
|
||
for (const [name, source, whichLevel] of [
|
||
['1color0', '内存复用', '当前关50'], ['2color1', '本地缓存', '当前关50'],
|
||
['3color2', '临时缓存', '当前关50'], ['4color3', '远程下载', '下一关51'], ['5color4', '临时缓存', '下一关51']
|
||
]) assert.ok(state.logs.some(args => args[0].includes(`${whichLevel} ${name}.png -> ${source}`)));
|
||
const starts = state.logs.map(args => args[0]).filter(line => line.includes('[远程加载] 开始'));
|
||
assert.equal(starts.length, 1);
|
||
assert.ok(starts[0].includes(url('4color3')));
|
||
});
|
||
|
||
test('temporary files are usable without Loading even when persistence has not completed', async () => {
|
||
const { manager, state, config, Manager, map, collect, url } = fixture();
|
||
await manager.initialize();
|
||
const wanted = sorted([...collect(level(50).BLOCK_INFO[0]), ...collect(level(51).BLOCK_INFO[0])]);
|
||
for (const name of wanted) {
|
||
const temp = 'legacy-temp/' + name;
|
||
state.temps.set(url(name), temp);
|
||
state.disk.set(temp, 'valid');
|
||
}
|
||
assert.equal(JSON.parse(state.storage.get(Manager.CACHE_KEY)).complete, false);
|
||
state.logs.length = 0;
|
||
const Config = config();
|
||
Config.applyLevelData(level(50), false, 0, 50);
|
||
Config.enterGameSceneWhenReady();
|
||
await waitFor(() => state.scenes.length === 1, 'temporary images finish decoding');
|
||
await map().prepareBlockAssets();
|
||
assert.deepEqual(state.scenes, ['GameScene']);
|
||
assert.deepEqual(state.loadingEvents, []);
|
||
assert.equal(state.downloads, 0);
|
||
const sources = state.logs.map(args => args[0]).filter(line => line.includes('[进关来源]'));
|
||
assert.ok(sources.length > 0 && sources.every(line => line.includes('-> 临时缓存')));
|
||
});
|
||
|
||
test('foreground waiting for a background download shows Loading and shares the file request', async () => {
|
||
const { manager, state, config, collect, url, userFile } = fixture();
|
||
await manager.cacheAllFiles();
|
||
const Config = config(), sourceScene = state.scene;
|
||
Config.applyLevelData(level(50), false, 0, 50);
|
||
const missing = collect(level(50).BLOCK_INFO[0])[0];
|
||
state.disk.delete(userFile(missing));
|
||
state.logs.length = 0;
|
||
state.hold.add('download:' + missing);
|
||
// Use event-loop yields here so a held foreground request cannot starve the background pause.
|
||
manager.delay = tick;
|
||
const background = manager.cacheAllFiles();
|
||
for (let i = 0; i < imagePaths.length * 2 && !state.deferred.has('download:' + missing); i++) await tick();
|
||
assert.equal(state.deferred.has('download:' + missing), true);
|
||
assert.deepEqual(state.loadingEvents, [], 'background downloads alone remain silent');
|
||
Config.enterGameSceneWhenReady();
|
||
await tick();
|
||
assert.equal(sourceScene.loading, true);
|
||
assert.equal(state.scenes.length, 0);
|
||
assert.ok(state.logs.some(args => args[0].includes('[远程加载] 等待后台下载 ' + missing + '.png')));
|
||
state.deferred.get('download:' + missing)();
|
||
await background;
|
||
await tick();
|
||
assert.deepEqual(state.scenes, ['GameScene']);
|
||
assert.deepEqual(state.loadingEvents, ['HomeScene:open', 'HomeScene:close']);
|
||
assert.equal(state.downloads, imagePaths.length + 1, 'only one shared download');
|
||
for (const phase of ['开始', '成功']) {
|
||
assert.ok(state.logs.some(args => args[0].includes(`[远程下载] ${phase} ${missing}.png`) && args[0].includes(url(missing))));
|
||
}
|
||
});
|
||
|
||
test('a superseded entry cannot close the latest entry Loading', async () => {
|
||
const { config, state } = fixture();
|
||
const Config = config(), pending = [], sourceScene = state.scene;
|
||
Config.prepareBlockWindow = onRemoteRequired => {
|
||
onRemoteRequired();
|
||
return new Promise(resolve => pending.push(resolve));
|
||
};
|
||
Config.enterGameSceneWhenReady();
|
||
Config.enterGameSceneWhenReady();
|
||
pending[0](true);
|
||
await tick();
|
||
assert.equal(sourceScene.loading, true);
|
||
assert.equal(state.scenes.length, 0);
|
||
pending[1](true);
|
||
await tick();
|
||
assert.equal(sourceScene.loading, false);
|
||
assert.deepEqual(state.scenes, ['GameScene']);
|
||
});
|
||
|
||
test('late remote notifications cannot open Loading for cancelled entries or a different scene', async () => {
|
||
const { config, state, createScene } = fixture();
|
||
const Config = config(), pending = [];
|
||
Config.prepareBlockWindow = onRemoteRequired => new Promise(resolve => pending.push({ onRemoteRequired, resolve }));
|
||
Config.enterGameSceneWhenReady();
|
||
Config.enterGameSceneWhenReady();
|
||
pending[0].onRemoteRequired();
|
||
assert.deepEqual(state.loadingEvents, []);
|
||
state.scene = createScene('GameScene');
|
||
pending[1].onRemoteRequired();
|
||
assert.deepEqual(state.loadingEvents, [], 'must not find and open the new scene controller');
|
||
pending.forEach(request => request.resolve(true));
|
||
await tick();
|
||
assert.deepEqual(state.scenes, []);
|
||
});
|
||
|
||
test('current JSON retrieval and failure do not show image Loading; remote images on retry do', async () => {
|
||
const { config, state, collect } = fixture();
|
||
const Config = config(), sourceScene = state.scene;
|
||
state.hold.add('Json/level50');
|
||
state.fail.add('Json/level50');
|
||
Config.LEVEL_INFO_init(true, 0, false);
|
||
assert.equal(sourceScene.loading, false);
|
||
await tick();
|
||
state.deferred.get('Json/level50')();
|
||
assert.equal(sourceScene.loading, false);
|
||
assert.equal(state.errors.length, 1);
|
||
assert.equal(state.scenes.length, 0);
|
||
assert.deepEqual(state.loadingEvents, []);
|
||
state.fail.clear();
|
||
state.hold.delete('Json/level50');
|
||
const held = collect(level(50).BLOCK_INFO[0])[0];
|
||
state.hold.add(held);
|
||
state.errors[0].retry();
|
||
assert.equal(sourceScene.loading, false);
|
||
await tick();
|
||
assert.equal(sourceScene.loading, true);
|
||
state.deferred.get(held)();
|
||
await tick();
|
||
assert.equal(sourceScene.loading, false);
|
||
assert.deepEqual(state.scenes, ['GameScene']);
|
||
assert.equal(Config.GM_INFO.level, 49);
|
||
});
|
||
|
||
test('leaving while current JSON is pending does not enter a stale level or close the new scene Loading', async () => {
|
||
const { config, state, createScene } = fixture();
|
||
const Config = config();
|
||
state.hold.add('Json/level50');
|
||
Config.LEVEL_INFO_init(true, 0, false);
|
||
await tick();
|
||
state.scene = createScene('GameScene');
|
||
state.scene.controller.openLoad();
|
||
state.deferred.get('Json/level50')();
|
||
await tick();
|
||
assert.equal(state.scene.loading, true);
|
||
assert.equal(state.scenes.length, 0);
|
||
});
|
||
|
||
test('GameScene fallback closes Loading on image failure and reopens it for retry', async () => {
|
||
const { config, state, collect, createScene, map } = fixture();
|
||
state.scene = createScene('GameScene');
|
||
const Config = config(), controller = map();
|
||
Config.applyLevelData(level(50), false, 0, 50);
|
||
state.fail.add(collect(level(50).BLOCK_INFO[0])[0]);
|
||
const first = controller.prepareBlockAssets();
|
||
assert.equal(state.scene.loading, false);
|
||
await first;
|
||
assert.equal(state.scene.loading, false);
|
||
assert.equal(state.mapsInitialized, 0);
|
||
assert.equal(state.errors.length, 1);
|
||
state.fail.clear();
|
||
const retry = state.errors[0].retry();
|
||
await retry;
|
||
assert.equal(state.scene.loading, false);
|
||
assert.equal(state.mapsInitialized, 1);
|
||
assert.equal(controller.blockAssetsReady, true);
|
||
assert.deepEqual(state.loadingEvents, ['GameScene:open', 'GameScene:close', 'GameScene:open', 'GameScene:close']);
|
||
});
|
||
|
||
test('malformed next-level JSON rejects with a retry instead of leaving the load pending', async () => {
|
||
const { config, state } = fixture();
|
||
const Config = config();
|
||
Config.applyLevelData(level(50), false, 0, 50);
|
||
state.badJson.add('Json/level51');
|
||
Config.enterGameSceneWhenReady();
|
||
await tick();
|
||
assert.equal(state.scenes.length, 0);
|
||
assert.equal(state.errors.length, 1);
|
||
state.badJson.clear();
|
||
state.errors[0].retry();
|
||
await tick();
|
||
assert.deepEqual(state.scenes, ['GameScene']);
|
||
});
|
||
|
||
test('an old scene-entry request cannot change scenes after the player has left', async () => {
|
||
const { config, state, collect } = fixture();
|
||
const Config = config();
|
||
Config.applyLevelData(level(50), false, 0, 50);
|
||
const held = collect(level(50).BLOCK_INFO[0])[0];
|
||
state.hold.add(held);
|
||
Config.enterGameSceneWhenReady();
|
||
await tick();
|
||
state.scene = { name: 'AnotherScene' };
|
||
state.deferred.get(held)();
|
||
await tick();
|
||
assert.equal(state.scenes.length, 0);
|
||
assert.equal(state.errors.length, 0);
|
||
});
|
||
|
||
test('returning to a level after its request was cancelled can prepare it again', async () => {
|
||
const { config, state, collect } = fixture();
|
||
const Config = config(), original = level(50);
|
||
Config.applyLevelData(original, false, 0, 50);
|
||
const held = collect(original.BLOCK_INFO[0])[0];
|
||
state.hold.add(held);
|
||
const first = Config.prepareBlockWindow();
|
||
await tick();
|
||
Config.applyLevelData(level(53), false, 0, 53);
|
||
state.deferred.get(held)();
|
||
assert.equal(await first, false);
|
||
Config.applyLevelData(original, false, 0, 50);
|
||
assert.equal(await Config.prepareBlockWindow(), true);
|
||
});
|
||
|
||
test('endless preload and actual next level consume the same random selection', async () => {
|
||
const { config, cc, state } = fixture();
|
||
const Config = config();
|
||
Config.GM_INFO.level = 1920;
|
||
Config.GM_INFO.GameplayType = 1;
|
||
cc.fx.GameTool.getPurelyRandom = () => 52;
|
||
Config.applyLevelData(level(50), false, 0, 50);
|
||
await Config.prepareBlockWindow();
|
||
assert.deepEqual(state.jsonLoads, ['Json/level52']);
|
||
assert.equal(cc.fx.GameTool.getNextLevel(), 52);
|
||
assert.equal(Config.nextEndlessLevel, 0);
|
||
});
|