204 lines
11 KiB
JavaScript
204 lines
11 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const vm = require('node:vm');
|
|
const ts = require('typescript');
|
|
|
|
function methods(file, names, globals) {
|
|
const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true);
|
|
const cls = source.statements.find(ts.isClassDeclaration);
|
|
const code = names.map(name => cls.members.find(m => m.name && m.name.getText(source) === name).getText(source)).join('\n');
|
|
return vm.runInNewContext(ts.transpileModule(`class Subject { ${code} }; Subject`, {
|
|
compilerOptions: { target: ts.ScriptTarget.ES2017 }
|
|
}).outputText, globals);
|
|
}
|
|
|
|
function avatarFixture() {
|
|
const requests = [];
|
|
const scene = { children: [], getComponent: () => null };
|
|
class Sprite {}
|
|
class Skeleton {}
|
|
class SpriteFrame {
|
|
constructor(texture) { this.texture = texture; }
|
|
getTexture() { return this.texture; }
|
|
destroy() { this.destroyed = true; }
|
|
}
|
|
const cc = { v2: (x, y) => ({ x, y }), Sprite, SpriteFrame, isValid: n => !!n && !n.destroyed,
|
|
fx: { GameTool: { getActionName: value => 'action' + value.padStart(2, '0') } },
|
|
director: { getScene: () => scene },
|
|
resources: { get: () => null, load: (url, type, done) => requests.push({ url, done, spine: true }) },
|
|
assetManager: { assets: new Map(), loadRemote: (url, options, done) => requests.push({ url, options, done }) }
|
|
};
|
|
const exports = {};
|
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/career/script/CareerAvatarLoader.ts', 'utf8'), {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 }
|
|
}).outputText, { exports, cc, sp: { Skeleton, SkeletonData: class {} } });
|
|
const loader = new exports.default();
|
|
function avatar(y = 100) {
|
|
const sprite = { spriteFrame: null };
|
|
const skeleton = { skeletonData: null, clearTracks() {}, setAnimation() {} };
|
|
const icon = { active: true, getComponent: () => sprite };
|
|
const animated = { active: false, getComponent: () => skeleton };
|
|
const node = { y, children: [], activeInHierarchy: true, getComponent: () => null };
|
|
const mask = { getChildByName: n => n === 'icon' ? icon : animated,
|
|
getBoundingBoxToWorld: () => ({ yMin: node.y, yMax: node.y + 60 }) };
|
|
node.getChildByName = () => mask;
|
|
scene.children.push(node);
|
|
return { node, sprite, skeleton };
|
|
}
|
|
function asset() {
|
|
return { refCount: 0, releases: [], addRef() { this.refCount++; },
|
|
decRef(auto) { this.refCount--; this.releases.push(auto); } };
|
|
}
|
|
const view = { height: 1920, anchorY: 0, convertToWorldSpaceAR: p => p };
|
|
return { loader, cc, scene, requests, avatar, asset, update: () => loader.update(view) };
|
|
}
|
|
|
|
test('only near-visible avatars load, identical URLs coalesce, disk cache is explicit', () => {
|
|
const f = avatarFixture();
|
|
const a = f.avatar(), b = f.avatar(), off = f.avatar(3000);
|
|
f.loader.bind(a.node, 'https://avatar/a'); f.loader.bind(b.node, 'https://avatar/a');
|
|
f.loader.bind(off.node, 'https://avatar/off'); f.update();
|
|
assert.equal(f.requests.length, 1);
|
|
assert.equal(f.requests[0].options.cacheEnabled, true);
|
|
const asset = f.asset(); f.requests[0].done(null, asset); f.update();
|
|
assert.equal(a.sprite.spriteFrame.getTexture(), asset);
|
|
assert.equal(b.sprite.spriteFrame.getTexture(), asset);
|
|
assert.equal(asset.refCount, 1);
|
|
});
|
|
|
|
test('a recycled avatar cannot receive the previous player late result', () => {
|
|
const f = avatarFixture(), a = f.avatar();
|
|
f.loader.bind(a.node, 'https://avatar/old'); f.update();
|
|
f.loader.bind(a.node, 'https://avatar/new'); f.update();
|
|
const old = f.asset(), next = f.asset();
|
|
f.requests[0].done(null, old); f.update();
|
|
assert.equal(a.sprite.spriteFrame, null);
|
|
f.requests[1].done(null, next); f.update();
|
|
assert.equal(a.sprite.spriteFrame.getTexture(), next);
|
|
});
|
|
|
|
test('image and Spine concurrency are limited independently and offscreen Spine pauses', () => {
|
|
const f = avatarFixture();
|
|
for (let i = 0; i < 10; i++) f.loader.bind(f.avatar().node, 'https://avatar/' + i);
|
|
const animated = [];
|
|
for (let i = 0; i < 5; i++) { const a = f.avatar(); animated.push(a); f.loader.bind(a.node, String(i)); }
|
|
f.update();
|
|
assert.equal(f.requests.filter(r => !r.spine).length, 4);
|
|
assert.equal(f.requests.filter(r => r.spine).length, 2);
|
|
f.requests.find(r => r.spine).done(null, f.asset()); f.update();
|
|
animated[0].node.y = 2000; f.update();
|
|
assert.equal(animated[0].skeleton.paused, true);
|
|
animated[0].node.y = 100; f.update();
|
|
assert.equal(animated[0].skeleton.paused, false);
|
|
});
|
|
|
|
test('scrolling through many avatars keeps at most 12 unused image entries', () => {
|
|
const f = avatarFixture(), a = f.avatar();
|
|
for (let i = 0; i < 40; i++) {
|
|
f.loader.bind(a.node, 'https://avatar/' + i); f.update();
|
|
f.requests.at(-1).done(null, f.asset()); f.update();
|
|
}
|
|
assert.equal(f.loader.entries.size, 13); // current + 12 idle
|
|
});
|
|
|
|
test('disposal waits for unfinished requests, clears bindings and releases owned assets', async () => {
|
|
const f = avatarFixture(), a = f.avatar();
|
|
f.loader.bind(a.node, 'https://avatar/a'); f.update();
|
|
let completed = false;
|
|
const closing = f.loader.dispose().then(() => { completed = true; });
|
|
await Promise.resolve(); assert.equal(completed, false);
|
|
const asset = f.asset(); f.requests[0].done(null, asset); await closing;
|
|
assert.equal(a.sprite.spriteFrame, null);
|
|
assert.equal(asset.refCount, 0);
|
|
assert.deepEqual(asset.releases, [true]);
|
|
f.update(); assert.equal(f.requests.length, 1);
|
|
});
|
|
|
|
test('an asset cached before career is never force-released by career', async () => {
|
|
const f = avatarFixture(), a = f.avatar(), asset = f.asset();
|
|
f.cc.assetManager.assets.set('https://avatar/shared', asset);
|
|
f.loader.bind(a.node, 'https://avatar/shared'); f.update();
|
|
f.requests[0].done(null, asset); f.update(); await f.loader.dispose();
|
|
assert.deepEqual(asset.releases, [false]);
|
|
});
|
|
|
|
test('shared live scene bindings are preserved even when career originally loaded the texture', async () => {
|
|
const f = avatarFixture(), a = f.avatar(), asset = f.asset();
|
|
f.loader.bind(a.node, 'https://avatar/shared'); f.update();
|
|
f.requests[0].done(null, asset); f.update();
|
|
f.scene.children.push({ children: [], getComponent: type => type === f.cc.Sprite ? {
|
|
spriteFrame: { getTexture: () => asset }
|
|
} : null });
|
|
await f.loader.dispose();
|
|
assert.deepEqual(asset.releases, [false]);
|
|
});
|
|
|
|
test('failed in-flight image requests do not block disposal', async () => {
|
|
const f = avatarFixture(), a = f.avatar();
|
|
f.loader.bind(a.node, 'https://avatar/fail'); f.update();
|
|
const closing = f.loader.dispose();
|
|
f.requests[0].done(new Error('download failed'), null);
|
|
await closing;
|
|
assert.equal(f.loader.active.image, 0);
|
|
});
|
|
|
|
test('normal devices keep the existing six-city capacity; low-memory capacity is three', () => {
|
|
const Subject = methods('assets/career/script/CareerList2.ts', ['countListParam'], { ListType: { Vertical: 2 } });
|
|
for (const lowMemoryIOS of [false, true]) {
|
|
const list = Object.assign(new Subject(), { lowMemoryIOS, type: 2, itemDataList: Array(35),
|
|
content: { parent: { width: 1080 } }, scrollView: { node: { height: 1920 } },
|
|
itemHeight: 940, spaceY: 50, padding_top: -100, padding_buttom: 750 });
|
|
list.countListParam();
|
|
assert.equal(list.spawnCount, lowMemoryIOS ? 3 : 6);
|
|
}
|
|
});
|
|
|
|
test('low-memory release clears assets without removing the Bundle', () => {
|
|
let released = 0, removed = 0;
|
|
const bundle = { releaseAll() { released++; } };
|
|
const cc = { fx: { GameTool: { isLowMemoryIOSDevice: () => true } },
|
|
assetManager: { getBundle: () => bundle, removeBundle: () => removed++ } };
|
|
const Subject = methods('assets/Script/JiaZai.ts', ['releaseCareerBundle'], { cc });
|
|
new Subject().releaseCareerBundle();
|
|
assert.equal(released, 1); assert.equal(removed, 0);
|
|
});
|
|
|
|
test('low-memory window covers direct jumps and never creates more than three city instances', () => {
|
|
let depth = 0, created = 0;
|
|
const view = { height: 1920, anchorY: 1, convertToWorldSpaceAR: v => v };
|
|
const content = { parent: view, convertToNodeSpaceAR: () => ({ y: -depth }) };
|
|
const cc = { v2: (x, y) => ({ x, y }), instantiate() {
|
|
created++;
|
|
const item = { itemIndex: -1, clearLazyAvatars() {}, dataChanged() {} };
|
|
return { width: 962, getComponent: () => item, setPosition(x, y) { this.y = y; }, removeFromParent() {}, destroy() {} };
|
|
} };
|
|
const Subject = methods('assets/career/script/CareerList2.ts', ['updateLowMemoryRows', 'getItem'], { cc, CareerItem2: class {} });
|
|
const list = Object.assign(new Subject(), { content, itemHeight: 940, spaceY: 50, padding_top: -100, padding_left: 57,
|
|
firstRender: { height: 1550 }, itemDataList: Array.from({ length: 35 }, (_, i) => ({ city: i })),
|
|
itemList: [], itemPool: [], avatars: {} });
|
|
for (depth of [0, 1700, 28000, 9500, 33000, 0, ...Array.from({ length: 340 }, (_, i) => i * 100)]) {
|
|
list.updateLowMemoryRows();
|
|
assert.ok(created <= 3);
|
|
assert.ok(list.itemList.length + list.itemPool.length <= 3);
|
|
const visible = list.itemDataList.map((_, i) => i).filter(i => 1500 + i * 990 <= depth + 1920 && 2440 + i * 990 >= depth);
|
|
for (const i of visible) assert.ok(list.itemList.some(n => n.getComponent().itemIndex === i), `missing city ${i} at ${depth}`);
|
|
}
|
|
});
|
|
|
|
test('entry cleanup waits for node destruction, bundle loads and avatar callbacks, preserving Bundle', async () => {
|
|
const ticks = []; let destroyed = false, finishAvatars, releases = 0;
|
|
const avatarIdle = new Promise(resolve => { finishAvatars = resolve; });
|
|
const rank = { getChildByName: () => ({ getComponent: () => ({ disposeAvatarResources: () => avatarIdle }) }), destroy() {} };
|
|
const cc = { fx: { GameTool: { isLowMemoryIOSDevice: () => true } }, isValid: n => n === rank ? !destroyed : true };
|
|
const Subject = methods('assets/Script/JiaZai.ts', ['prepareCareerForSceneExit'], { cc, setTimeout: fn => ticks.push(fn) });
|
|
const home = Object.assign(new Subject(), { RankNode: rank, careerPendingLoads: 1, careerLoadWaiters: [],
|
|
_sRankRequestId: 0, careerLoadGeneration: 0, stopCareerTweens() {}, releaseCareerBundle() { releases++; } });
|
|
const pending = home.prepareCareerForSceneExit();
|
|
assert.equal(home.prepareCareerForSceneExit(), pending);
|
|
home.careerLoadWaiters.shift()(); await Promise.resolve(); assert.equal(releases, 0);
|
|
destroyed = true; ticks.shift()(); await Promise.resolve(); assert.equal(releases, 0);
|
|
finishAvatars(); await pending; assert.equal(releases, 1);
|
|
assert.equal(home.RankNode, null);
|
|
});
|