Files
MatchMaster/tools/test-career-low-memory.cjs

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