MatchMaster/tools/test-battle-pass-ui.cjs
2026-09-22 17:00:52 +08:00

550 lines
36 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');
const { withNewPlayerConfig } = require('./new-player-ab-test-support.cjs');
const rows = JSON.parse(fs.readFileSync('assets/resources/Json/PASS_CHECK.json', 'utf8')).Sheet1;
const quiet = { log() {}, warn() {}, error() {} };
function load(file, deps = {}, globals = {}) {
const exports = {};
const source = ts.transpileModule(fs.readFileSync(file, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, experimentalDecorators: true } }).outputText;
vm.runInNewContext(source, { exports, require: name => deps[name], console: quiet, Date, setTimeout, clearTimeout, ...globals });
return exports;
}
const model = load('assets/Script/module/Config/BattlePassModel.ts');
class Node {
constructor(name = '') { Object.assign(this, { name, children: [], components: [], events: {}, active: true, x: 0, y: 0, width: 1080, height: 350 }); }
set parent(value) { if (this._parent) this._parent.children = this._parent.children.filter(n => n !== this); this._parent = value; if (value) value.children.push(this); }
get parent() { return this._parent; }
get activeInHierarchy() { return this.active && !this.destroyed && (!this.parent || this.parent.activeInHierarchy); }
get childrenCount() { return this.children.length; }
addChild(node) { node.parent = this; }
getChildByName(name) { return this.children.find(n => n.name === name) || null; }
removeAllChildren() { [...this.children].forEach(n => n.parent = null); }
destroyAllChildren() { [...this.children].forEach(n => n.destroy()); }
setContentSize(width, height) { this.width = width; this.height = height; }
destroy() { this.destroyed = true; this.parent = null; }
setPosition(x, y) { this.x = x; this.y = y; }
setAnchorPoint(x, y) { this.anchorX = x; this.anchorY = y; }
on(name, callback, owner) { (this.events[name] ||= []).push({ callback, owner }); }
once(name, callback, owner) { this.on(name, callback, owner); this.events[name].at(-1).once = true; }
emit(name) { for (const entry of [...(this.events[name] || [])]) { if (entry.once) this.events[name] = this.events[name].filter(e => e !== entry); entry.callback.call(entry.owner); } }
getComponent(type) { return this.components.find(c => typeof type === 'string' ? c.constructor.name.toLowerCase() === type.toLowerCase() : c instanceof type) || null; }
addComponent(Type) { const component = new Type(); component.node = this; this.components.push(component); return component; }
}
function runtime() {
class Component { scheduleOnce() {} }
class Sprite extends Component {}
Sprite.SizeMode = { TRIMMED: 0 };
class Button extends Component {}
class ScrollView extends Component { getMaxScrollOffset() { return { y: this.content.height }; } getScrollOffset() { return { y: 0 }; } }
class NodePool { nodes = []; size() { return this.nodes.length; } get() { return this.nodes.pop(); } put(node) { node.parent = null; this.nodes.push(node); } }
const cc = { Node, Component, Sprite, Button, ScrollView, NodePool, Prefab: class {}, SpriteAtlas: class {}, Integer: Number,
_decorator: { ccclass: v => v, property: () => () => {} }, Enum: v => v, Color: { WHITE: 'white' }, color: (...v) => v.join(','), sys: { isMobile: false },
instantiate: prefab => typeof prefab === 'function' ? prefab() : new Node(prefab.name), isValid: node => !!node && !node.destroyed,
find(path, root) { return path.split('/').reduce((node, name) => node && node.getChildByName(name), root); },
fx: { GameConfig: { PASS_CHECK: rows, GM_INFO: {}, isGameplayEntryPreparing: () => false }, GameTool: { adaptation: () => 1, shushu_Track() {} }, StorageMessage: { getStorage: () => 0, setStorage() {} } },
Tween: { stopAllByTarget() {} }, tween(target) { const chain = { delay: () => chain, to: (_, values) => { Object.assign(target, values); return chain; }, call: fn => { fn(); return chain; }, start() {} }; return chain; } };
withNewPlayerConfig(cc.fx.GameConfig);
const child = (parent, name) => { const node = new Node(name); parent.addChild(node); return node; };
const numbers = { default: { numberToImageNodesPassCheck(value, _w, _h, _prefix, node) { node.number = value; } } };
const Item = load('assets/shop/script/passCheckItem.ts', { '../../Script/NumberToImage': numbers }, { cc }).default;
const Pool = load('assets/Script/module/NodePool/NodePoolMgr.ts', {}, { cc }).default;
const List = load('assets/Script/module/List/scrollviewList.ts', { '../NodePool/NodePoolMgr': { default: Pool } }, { cc }).default;
const Manager = load('assets/shop/script/passCheckMgr.ts', { '../../Script/module/List/scrollviewList': { default: List }, './passCheckItem': { default: Item }, '../../Script/module/Config/BattlePassModel': model, '../../Script/module/Pay/Utils': {} }, { cc }).default;
const itemPrefab = () => {
const node = new Node('passCheckItem');
child(node, 'mask').addComponent(Button);
for (const name of ['leftNode', 'rightNode']) {
const lane = child(node, name);
for (const part of ['get', 'getBtn', 'freeReward', 'addTiem', 'num_1', 'num_x']) child(lane, part).addComponent(Sprite);
lane.getChildByName('num_1').x = 100;
if (name === 'rightNode') child(lane, 'lock');
}
const item = node.addComponent(Item);
item.passCheckItemLvPrefab = () => { const level = new Node('level'); child(level, 'bright_bg').addComponent(Sprite); child(level, 'levelNum'); return level; };
item.uiAtlas = item.texture_atlas = item.passCheckFont = { getSpriteFrame: name => name };
return node;
};
return { cc, Item, Pool, List, Manager, child, itemPrefab };
}
const stageAt = (level, tier) => model.normalizePass({ time: '100', experience: rows.slice(0, level).reduce((sum, row) => sum + row.token, 0), tier, activate: tier > 0 }, rows);
function animationFixture() {
const { cc, Manager } = runtime();
const storage = new Map(), actions = [], stopped = new Set();
cc.fx.StorageMessage = { getStorage: key => storage.get(key), setStorage: (key, value) => storage.set(key, value) };
cc.fx.GameConfig.GM_INFO = { uid: 'animation-player', getItemType: 2 };
cc.fx.GameConfig.PASS_CHECK_OLD = JSON.parse(fs.readFileSync('assets/resources/Json/PASS_CHECK_OLD.json', 'utf8')).Sheet1;
cc.Tween.stopAllByTarget = target => actions.filter(a => a.target === target).forEach(a => stopped.add(a));
cc.tween = target => {
const steps = [];
const chain = { delay: seconds => { steps.push({ seconds }); return chain; },
to: (seconds, values) => { steps.push({ seconds, values }); return chain; },
call: fn => { steps.push({ fn }); return chain; },
start: () => { actions.push({ target, steps }); return chain; } };
return chain;
};
const writeNumber = (value, _w, _h, _prefix, node) => { node.number = value; };
const Panel = load('assets/shop/script/passCheck.ts', {
'../../Script/module/Config/BattlePassModel': model,
'../../Script/NumberToImage': { default: { numberToImageNodesPassCheck: writeNumber, numberToImageNodesShop: writeNumber } },
}, { cc }).default;
const panel = new Panel(), manager = new Manager();
Object.assign(panel, { activateBtn: { node: {} }, currentLv: new Node(), progress_1: new Node(), progress_2: new Node(),
barSpr: { node: {}, fillRange: 0 }, setbarNodePosX() {}, initCountDown() {} });
const content = new Node();
Object.assign(manager, { list: { itemHeight: 350, spacingY: 0, paddingTop: 0, paddingBottom: 0 },
progressBar3: new Node(), progressBar4: new Node() });
manager.progressBar3.parent = manager.progressBar4.parent = content;
// Use the real panel -> manager init arguments, but skip unrelated reward rendering and scrolling.
manager.scrollToLastClaimedReward = () => {};
manager.setupProgressBars = () => manager.updateProgressBars();
const list = manager.list;
Object.assign(list, { init() {}, setData(data) { manager.listData = data; } });
const scroll = new Node('scrollView');
scroll.getComponent = type => type === cc.ScrollView ? { content } : list;
manager.node = new Node('scrollViewNode'); manager.node.addChild(scroll);
panel.node = { getChildByName: () => ({ active: true, getComponent: () => manager }) };
function show(level, partial = 0, options = {}) {
const season = options.season || '2000000000000', tier = options.tier || 0;
cc.fx.GameConfig.GM_INFO.getItemType = options.old ? 1 : 2;
const config = options.old ? cc.fx.GameConfig.PASS_CHECK_OLD : rows;
const stage = { time: season, experience: config.slice(0, level).reduce((sum, row) => sum + row.token, 0) + partial,
free: Array(level).fill(0), passCheck: Array(level).fill(1), tier, activate: tier > 0 };
const input = { 1: options.old ? stage : { time: '0' }, 2: options.old ? { time: '0' } : stage };
const before = JSON.stringify(input);
actions.length = 0; stopped.clear(); panel.onInit(input);
assert.equal(JSON.stringify(input), before, 'animation must not mutate XP or claim data');
return actions;
}
const topFills = () => actions.filter(a => a.target === panel.barSpr && !stopped.has(a))
.flatMap(a => a.steps.filter(s => s.values).map(s => s.values.fillRange));
function finish() { for (const action of actions) if (!stopped.has(action)) for (const step of action.steps) {
if (step.values) Object.assign(action.target, step.values); if (step.fn) step.fn();
} }
return { cc, panel, manager, storage, actions, stopped, show, topFills, finish };
}
test('battle pass animation ignores unscoped history and preserves already claimed tier five', () => {
const f = animationFixture(); f.storage.set('ProgressIndex', 4);
f.show(7);
assert.equal(f.actions.length, 0);
assert.equal(f.manager.progressBar4.height, 6 * 350);
assert.equal(f.panel.stage.free[4], 0);
assert.equal(f.panel.barSpr.fillRange, 0);
assert.equal(f.panel.currentLv.number, 7);
assert.equal(f.storage.get('ProgressIndex'), 4);
});
test('battle pass animation plays only new completed tiers and does not repeat on reopening', () => {
const f = animationFixture(); f.show(4); f.show(7);
assert.deepEqual(f.topFills(), [1, 1, 1]);
assert.equal(f.manager.progressBar4.height, 3 * 350);
f.finish(); assert.equal(f.manager.progressBar4.height, 6 * 350);
assert.equal(f.panel.barSpr.fillRange, 0);
f.show(7); assert.equal(f.actions.length, 0);
});
test('battle pass partial progress animates within a tier without moving the completed-tier bar', () => {
const f = animationFixture(); f.show(5, 1); f.show(5, 2);
assert.equal(f.panel.barSpr.fillRange, 0.25);
assert.deepEqual(f.topFills(), [0.5]);
assert.equal(f.actions.length, 1);
f.finish(); assert.equal(f.panel.barSpr.fillRange, 0.5);
assert.equal(f.panel.progress_1.number, 2); assert.equal(f.panel.progress_2.number, 4);
f.show(5, 2); assert.equal(f.actions.length, 0); assert.equal(f.panel.barSpr.fillRange, 0.5);
});
test('battle pass crosses a tier from its saved fraction and finishes at the new fraction', () => {
const f = animationFixture(); f.show(5, 3); f.show(6, 1);
assert.equal(f.panel.barSpr.fillRange, 0.75);
assert.deepEqual(f.topFills(), [1, 0.25]);
f.finish(); assert.equal(f.panel.barSpr.fillRange, 0.25);
assert.equal(f.manager.progressBar4.height, 5 * 350);
});
test('battle pass displayed level goes 5 -> 6 -> 7 only after each full bar', () => {
const f = animationFixture();
f.show(5, 1);
f.show(7, 1);
assert.equal(f.panel.currentLv.number, 5);
const animation = f.actions.find(action => action.target === f.panel.barSpr);
const levels = [f.panel.currentLv.number];
for (const step of animation.steps) {
if (step.values) {
const before = f.panel.currentLv.number;
Object.assign(animation.target, step.values);
assert.equal(f.panel.currentLv.number, before, 'filling must not advance the number early');
}
if (step.fn) { step.fn(); levels.push(f.panel.currentLv.number); }
}
assert.deepEqual(levels, [5, 6, 7]);
assert.equal(f.panel.barSpr.fillRange, 0.25);
f.show(7, 1); assert.equal(f.actions.length, 0); assert.equal(f.panel.currentLv.number, 7);
});
test('battle pass partial-only animation keeps the displayed level unchanged', () => {
const f = animationFixture(); f.show(5, 1); f.show(5, 2);
assert.equal(f.panel.currentLv.number, 5);
f.finish(); assert.equal(f.panel.currentLv.number, 5);
});
test('battle pass animated level stops at the cap and refresh cancels older number updates', () => {
const f = animationFixture(); f.show(33); f.show(35);
assert.equal(f.panel.currentLv.number, 33);
f.finish(); assert.equal(f.panel.currentLv.number, 35);
f.show(4); f.show(6);
const pending = [...f.actions];
f.panel.onInit({ 1: { time: '0' }, 2: { time: '2000000000000', experience: 13, free: [], passCheck: [] } });
pending.forEach(action => assert.equal(f.stopped.has(action), true));
f.finish(); assert.equal(f.panel.currentLv.number, 6);
});
test('battle pass level never exceeds unlocked tiers or the entitlement cap at any animation step', () => {
for (const tier of [0, 18, 30]) {
const f = animationFixture(), cap = tier === 30 ? rows.length : 35;
for (const level of [5, 7, cap - 1, cap, cap + 1]) {
f.show(Math.min(level, rows.length), 1, { tier });
const unlocked = Math.min(f.panel.stage.unlocked, cap);
const check = () => assert.ok(f.panel.currentLv.number <= unlocked,
`tier ${tier}: displayed ${f.panel.currentLv.number} exceeds unlocked ${unlocked}`);
check();
for (const action of f.actions) for (const step of action.steps) {
if (step.values) Object.assign(action.target, step.values);
if (step.fn) step.fn();
check();
}
assert.equal(f.panel.currentLv.number, unlocked);
}
f.storage.set('PassProgress:animation-player:2000000000000', { completed: cap + 10, fill: 1 });
f.show(6, 1, { tier });
assert.equal(f.actions.length, 0);
assert.equal(f.panel.currentLv.number, 6);
}
const empty = animationFixture(); empty.show(0, 0, { old: true, season: '1900000000000' });
assert.equal(empty.panel.currentLv.number, 0);
});
test('battle pass periods and accounts cannot overwrite each other animation records', () => {
const f = animationFixture(); f.show(5); f.show(4, 0, { old: true, season: '1900000000000' });
assert.equal(f.actions.length, 0);
f.show(7); assert.deepEqual(f.topFills(), [1, 1]);
f.show(4, 0, { old: true, season: '1900000000000' }); assert.equal(f.actions.length, 0);
f.show(7); assert.equal(f.actions.length, 0);
f.show(1, 0, { season: '2100000000000' }); assert.equal(f.actions.length, 0);
f.cc.fx.GameConfig.GM_INFO.uid = 'another-player'; f.show(7); assert.equal(f.actions.length, 0);
});
test('battle pass cap stays full and gaining experience above the cap does not replay', () => {
for (const tier of [0, 18, 30]) {
const f = animationFixture(), cap = tier === 30 ? rows.length : 35;
f.show(cap - 1, 0, { tier }); f.show(cap, 0, { tier });
assert.deepEqual(f.topFills(), [1]); f.finish(); assert.equal(f.panel.barSpr.fillRange, 1);
f.show(cap, 1, { tier }); assert.equal(f.actions.length, 0); assert.equal(f.panel.barSpr.fillRange, 1);
}
});
test('battle pass invalid or ahead-of-server records show the current state without a backward replay', () => {
const f = animationFixture(), key = 'PassProgress:animation-player:2000000000000';
for (const value of [null, 4, { completed: -1, fill: 0 }, { completed: 7, fill: 0 }, { completed: 5, fill: NaN }, { completed: 5, fill: 0.9 }]) {
f.storage.set(key, value); f.show(5, 1);
assert.equal(f.actions.length, 0); assert.equal(f.panel.barSpr.fillRange, 0.25);
}
});
test('battle pass refresh and destroy stop pending progress animations', () => {
const f = animationFixture(); f.show(4); f.show(7);
const previous = [...f.actions];
// Refresh without removing pending tween handles from the test scheduler.
f.panel.onInit({ 1: { time: '0' }, 2: { time: '2000000000000', experience: 16, free: [], passCheck: [] } });
previous.forEach(action => assert.equal(f.stopped.has(action), true));
f.finish(); assert.equal(f.panel.barSpr.fillRange, 0); assert.equal(f.manager.progressBar4.height, 6 * 350);
f.show(8); f.panel.onDestroy(); f.manager.onDestroy();
f.actions.forEach(action => assert.equal(f.stopped.has(action), true));
});
test('battle pass one/two XP increments agree between both bars at every early-tier boundary', () => {
for (const tier of [0, 18, 30]) {
const f = animationFixture();
let previous;
for (let xp = 0; xp <= 100; xp += tier === 30 ? 2 : 1) {
const stage = model.normalizePass({ time: '100', experience: xp }, rows);
const level = stage.unlocked;
const partial = xp - rows.slice(0, level).reduce((sum, row) => sum + row.token, 0);
f.show(level, partial, { tier });
const fills = f.topFills();
if (previous !== undefined) assert.equal(fills.filter(value => value === 1).length, level - previous);
f.finish();
assert.equal(f.manager.progressBar4.height, Math.max(0, level - 1) * 350);
assert.equal(f.panel.barSpr.fillRange, partial / rows[level].token);
previous = level;
}
}
});
test('battle pass zero XP, cost changes and premium extension keep progress display consistent', () => {
const f = animationFixture();
f.show(0, 0, { old: true, season: '1900000000000' });
assert.equal(f.panel.barSpr.fillRange, 0); assert.equal(f.panel.progress_1.number, 0);
f.show(4); f.show(5); f.finish();
assert.equal(f.panel.progress_1.number, 0); assert.equal(f.panel.progress_2.number, 4);
assert.equal(f.panel.currentLv.number, 5);
f.show(35, 0, { tier: 18 }); f.show(35, 0, { tier: 30 });
assert.equal(f.actions.length, 0); assert.equal(f.panel.barSpr.fillRange, 0);
f.show(36, 0, { tier: 30 }); assert.deepEqual(f.topFills(), [1]); f.finish();
assert.equal(f.manager.progressBar4.height, 35 * 350);
});
function listData(count, tier) { return Array.from({ length: count }, (_, i) => ({ id: i, ItemID1: 1001, ItemNum1: 1500, ItemID2: 1001, ItemNum2: 500, free: 1, passCheck: 1, leftAllowed: i < 35 || tier === 30, rightAllowed: i < 35 ? tier >= 18 : tier === 30 })); }
test('free and VIP preview 40 with clickable masks after 35, pooled SVIP rows hide every mask', () => {
const { cc, Item, Pool, List, Manager, child, itemPrefab } = runtime();
const root = new Node('scrollViewNode'), scroll = child(root, 'scrollView');
const panel = new Node('passCheck'); root.parent = panel;
let purchaseOpens = 0;
panel.components.push({ constructor: { name: 'passCheck' }, onActivateBtnClick() { purchaseOpens++; } });
const content = child(child(scroll, 'view'), 'content'); scroll.addComponent(cc.ScrollView).content = content;
const poolNode = child(root, 'pool'); poolNode.addComponent(Pool).onLoad();
Object.assign(scroll.addComponent(List), { nodePoolNode: poolNode, itemPrefab, itemHeight: 350, itemWidth: 1080 });
const bars = ['progress_bar_3', 'progress_bar_4'].map(name => child(scroll, name)); bars.forEach(bar => bar.addComponent(cc.Sprite));
const wujin = child(scroll, 'wujin');
const manager = root.addComponent(Manager);
for (const [tier, level, generated, contentRows] of [[0, 35, 40, 36], [18, 35, 40, 36], [30, 35, 40, 36], [30, 37, 42, 38], [30, 38, 43, 39], [30, rows.length, rows.length, rows.length], [0, 6, 30, 30]]) {
manager.init(listData(generated, tier), level, {}, 100, stageAt(level, tier), {});
assert.equal(content.height, contentRows * 350);
assert.equal(scroll.getComponent(cc.ScrollView).elastic, generated > contentRows);
assert.equal(content.children.filter(n => n.name === 'level').length, generated);
const items = content.children.filter(n => n.name === 'passCheckItem'); assert.equal(items.length, generated);
bars.forEach(bar => assert.equal(bar.parent, content));
assert.equal(wujin.parent, content);
assert.equal(wujin.active, generated > 35);
assert.equal(wujin.zIndex, 3);
assert.equal(wujin.y, -35 * 350);
assert.equal(content.children.filter(n => n.name === 'wujin').length, 1);
items.forEach((node, i) => {
const item = node.getComponent(Item); assert.equal(item.itemIndex, i); assert.equal(item.levelNode.getChildByName('levelNum').number, i + 1);
assert.equal(node.getChildByName('rightNode').getChildByName('getBtn').active, tier === 30 || tier === 18 && i < 35);
assert.equal(node.getChildByName('leftNode').getChildByName('num_1').x, 25);
for (const lane of node.children.filter(n => n.name === 'leftNode' || n.name === 'rightNode')) assert.equal(lane.getChildByName('getBtn').events.click.length, 1);
const mask = node.getChildByName('mask');
assert.equal(mask.active, tier !== 30 && i >= 35);
assert.equal(mask.events.click.length, 1);
const before = purchaseOpens; mask.emit('click');
assert.equal(purchaseOpens, before + (mask.active ? 1 : 0));
});
}
});
test('purchase popup selects 18/30 when free and only 12 upgrade after basic purchase', () => {
const { cc, child } = runtime();
const Buy = load('assets/shop/script/buyActivate.ts', {}, { cc }).default;
for (const [tier, lane, expected] of [[0, 'vip', 'battlepass'], [0, 'svip', 'battlepass_30'], [18, 'vip', null], [18, 'svip', 'battlepass_12']]) {
const root = new Node('buyActivate'), panel = child(child(root, 'activate'), 'activateNode');
for (const name of ['vip', 'svip']) { const btn = child(child(panel, name), 'buyBtn'); btn.addComponent(cc.Button); child(btn, '30yuan'); child(btn, '12yuan'); }
const buy = root.addComponent(Buy); buy.qiutBtn = child(root, 'quit').addComponent(cc.Button);
let product = null; buy.init(value => product = value, '', tier);
assert.equal(panel.getChildByName('vip').opacity, tier === 0 ? 255 : 150);
assert.equal(panel.getChildByName('vip').getChildByName('buyBtn').active, tier === 0);
assert.equal(cc.find('activate/activateNode/svip/buyBtn/30yuan', root).active, tier === 0);
assert.equal(cc.find('activate/activateNode/svip/buyBtn/12yuan', root).active, tier === 18);
cc.find('activate/activateNode/' + lane + '/buyBtn', root).emit('click'); assert.equal(product, expected);
}
});
test('manual rapid clicks queue each permitted lane once and reject level 36 for basic', () => {
const { Manager } = runtime(); const manager = new Manager(); manager.stage = stageAt(40, 18);
manager.claimReward(34, 1001, 100, true); manager.claimReward(34, 1001, 100, true);
manager.claimReward(34, 1001, 100, false); manager.claimReward(35, 1001, 100, true);
clearTimeout(manager.rewardProcessTimeout);
assert.equal(manager.rewardQueue.length, 2);
assert.equal(manager.stage.free[34], 0); assert.equal(manager.stage.passCheck[34], 0); assert.equal(manager.stage.free[35], 1);
});
function homeFixture(queueOverride) {
const { cc, child } = runtime();
const parsed = ts.createSourceFile('JiaZai.ts', fs.readFileSync('assets/Script/JiaZai.ts', 'utf8'), 99, true);
const members = parsed.statements.find(ts.isClassDeclaration).members.filter(n => /^(popUpPassCheck|openPassCheck|openPassCheckIntro|openPassCheckContent)$/.test(n.name?.getText(parsed))).map(n => n.getText(parsed)).join('\n');
const callbacks = [];
const context = { HomePopupQueue: queueOverride || { get: () => ({ enqueue: (key, priority, run) => run(() => {}) }) }, cc, passTier: model.passTier, console: quiet, Utils: { getPassCheckInfo: fn => callbacks.push(fn) }, MiniGameSdk: { API: { showToast() {} } }, setTimeout: () => 1, clearTimeout() {} };
vm.runInNewContext(ts.transpileModule('class JiaZai {' + members + '} globalThis.Home=JiaZai;', { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText, context);
const Home = context.Home; Home.cachedPassIntroPrefab = () => { const node = new Node('intro'); child(node, 'close'); child(node, 'beginBtn'); return node; };
Home.cachedPassCheckPrefab = () => { const node = new Node('content'); node.components.push({ constructor: { name: 'passCheck' }, setInfo() {} }); return node; };
const home = new Home(); Object.assign(home, { node: new Node('home'), ensureHomePopupPrefab: () => true, isHomeBundleReady: () => true, scheduleOnce() {}, isHomeRuntimeAlive: () => true, vibrateButtonClick() {}, openLoad() {}, closeLoad() {} });
const respond = () => callbacks.shift()({ code: 1, data: { passCheck: JSON.stringify({ 1: { time: '0' }, 2: { time: '100' } }) } });
return { home, callbacks, respond, cc };
}
test('intro close only dismisses; begin opens content once and never recursively reopens intro', () => {
const { home, callbacks, respond } = homeFixture(); home.openPassCheckIntro(); home.node.getChildByName('intro').getChildByName('close').emit('click');
assert.equal(callbacks.length, 0); assert.equal(home.node.children.length, 0);
home.openPassCheckIntro(); home.openPassCheck(); assert.equal(callbacks.length, 0, 'manual entry cannot open content behind an active intro');
const begin = home.node.getChildByName('intro').getChildByName('beginBtn'); begin.emit('click'); begin.emit('click');
assert.equal(callbacks.length, 1); respond(); assert.equal(home.node.children.filter(n => n.name === 'content').length, 1); assert.equal(home.node.getChildByName('intro'), null);
});
test('home button enters content directly and overlapping automatic entry cannot create an intro', () => {
const { home, callbacks, respond } = homeFixture();
home.openPassCheck(undefined, 'passBtn');
home.openPassCheck(); home.openPassCheckIntro();
assert.equal(home.node.getChildByName('intro'), null, 'no intro while content read is pending');
assert.equal(callbacks.length, 1); respond(); home.openPassCheck(); home.openPassCheckIntro();
assert.equal(home.node.getChildByName('intro'), null, 'no intro over existing content');
home.openPassCheckContent(); assert.equal(callbacks.length, 0);
assert.equal(home.node.children.filter(n => n.name === 'content').length, 1);
});
test('daily intro is local per account and date, only for free and VIP after level 21', () => {
const { home, cc, callbacks } = homeFixture();
const local = new Map();
cc.fx.StorageMessage = { getStorage: key => local.get(key), setStorage: (key, value) => local.set(key, value) };
Object.assign(cc.fx.GameConfig.GM_INFO, { level: 21, canIos: true, uid: 'player1', popPassCheck: false });
home.deferHomePopupWhileTransfer = () => false;
home.guidePassCheckAnimation = () => {};
const reply = tier => callbacks.shift()({ code: 1, data: { upgradeReady: true, time: Date.now() - 1000,
passCheck: JSON.stringify({ 2: { time: String(Date.now() + 86400000), tier, activate: tier > 0 } }) } });
const close = () => home.passIntroNode.getChildByName('close').emit('click');
home.popUpPassCheck(); reply(0);
assert.equal(home.node.children.filter(n => n.name === 'intro').length, 1);
assert.match(local.get('CACHE_PASS_CHECK:player1'), /^\d{4}-\d{2}-\d{2}$/);
close(); home.popUpPassCheck();
assert.equal(callbacks.length, 0);
assert.equal(home.passIntroNode, null);
local.set('CACHE_PASS_CHECK:player1', '2000-01-01');
home.popUpPassCheck(); reply(18); close();
cc.fx.GameConfig.GM_INFO.uid = 'player2';
home.popUpPassCheck(); reply(30);
assert.equal(home.passIntroNode, null);
assert.equal(local.has('CACHE_PASS_CHECK:player2'), false);
cc.fx.GameConfig.GM_INFO.level = 20;
home.popUpPassCheck(); assert.equal(callbacks.length, 0);
});
test('daily intro does not record failed loading or requests and duplicate replies open once', () => {
const { home, cc, callbacks } = homeFixture();
const local = new Map();
cc.fx.StorageMessage = { getStorage: key => local.get(key), setStorage: (key, value) => local.set(key, value) };
Object.assign(cc.fx.GameConfig.GM_INFO, { level: 21, canIos: true, uid: 'player' });
home.deferHomePopupWhileTransfer = () => false;
const response = { code: 1, data: { upgradeReady: true, time: Date.now() - 1000, passCheck: 'null' } };
home.popUpPassCheck(); callbacks.shift()({ code: 0 });
assert.equal(local.size, 0);
home.isHomeBundleReady = () => false;
home.loadHomePopupPrefab = () => new Promise(() => {});
home.popUpPassCheck(); callbacks.shift()(response);
assert.equal(local.size, 0);
home.isHomeBundleReady = () => true;
home.popUpPassCheck(); home.popUpPassCheck();
callbacks.shift()(response); callbacks.shift()(response);
assert.equal(home.node.children.filter(n => n.name === 'intro').length, 1);
assert.equal(local.size, 1);
});
test('B battle pass waits until clearing 20, then uses the existing daily introduction', () => {
const { home, cc, callbacks } = homeFixture();
Object.assign(cc.fx.GameConfig.GM_INFO, { level: 19, canIos: true, uid: 'new-player',
abTestAssignments: { layer_1: { name: 'new1_50', group: 'B' } } });
home.deferHomePopupWhileTransfer = () => false;
home.popUpPassCheck(); assert.equal(callbacks.length, 0);
cc.fx.GameConfig.GM_INFO.level = 20;
home.popUpPassCheck(); assert.equal(callbacks.length, 1);
callbacks.shift()({ code: 1, data: { upgradeReady: true, time: Date.now() - 1000, passCheck: 'null' } });
assert.equal(home.node.children.filter(n => n.name === 'intro').length, 1);
});
test('cold intro loading survives another home popup request and records only after showing', async () => {
const { home } = homeFixture();
let ready = false, finishLoad, shown = 0;
home.homePopupOpenRequest = 1;
home.isHomeBundleReady = () => ready;
home.ensureHomePopupPrefab = () => { throw new Error('must not share manual popup cancellation'); };
home.loadHomePopupPrefab = () => new Promise(resolve => { finishLoad = () => { ready = true; resolve(); }; });
home.openPassCheckIntro(() => shown++);
assert.equal(shown, 0);
home.homePopupOpenRequest++;
finishLoad(); await Promise.resolve();
assert.equal(shown, 1);
assert.equal(home.node.children.filter(n => n.name === 'intro').length, 1);
});
test('daily intro retries unavailable data then opens for a level 43 free player', () => {
const { home, cc, callbacks } = homeFixture();
Object.assign(cc.fx.GameConfig.GM_INFO, { level: 43, canIos: true, uid: 'cold-start' });
home.deferHomePopupWhileTransfer = () => false;
const scheduled = [];
home.scheduleOnce = (fn, delay) => { assert.equal(delay, 2); scheduled.push(fn); };
home.popUpPassCheck();
callbacks.shift()({ code: 0, msg: '战令配置尚未加载,请稍后重试' });
assert.equal(home.node.children.length, 0);
assert.equal(scheduled.length, 1);
scheduled.shift()();
callbacks.shift()({ code: 1, data: { upgradeReady: true, time: Date.now() - 1000, passCheck: 'null' } });
assert.equal(home.node.children.filter(n => n.name === 'intro').length, 1);
assert.equal(scheduled.length, 0);
});
test('battle pass waits for seven-day popup and blocks later popups until closed', () => {
let queue;
const { home, cc, callbacks } = homeFixture({ get: () => queue });
const Queue = load('assets/home_popup_queue/HomePopupQueue.ts', {}, { cc }).default;
queue = new Queue(); queue.node = home.node; home.node.components.push(home);
const seven = new Node('sevenDayGift'); home.node.addChild(seven); seven.addChild(new Node('activity'));
Object.assign(cc.fx.GameConfig.GM_INFO, { level: 43, canIos: true, uid: 'queue-test' });
home.deferHomePopupWhileTransfer = () => false;
const local = new Map(); cc.fx.StorageMessage = { getStorage: k => local.get(k), setStorage: (k,v) => local.set(k,v) };
home.popUpPassCheck(); callbacks.shift()({ code: 1, data: { upgradeReady: true, time: Date.now()-1000, passCheck: 'null' } });
queue.update(); assert.equal(home.passIntroNode, undefined); assert.equal(local.size, 0);
seven.removeAllChildren(); queue.update(); assert.ok(home.passIntroNode); assert.equal(local.size, 1);
let later = false; queue.enqueue('later', 70, done => { later = true; done(); });
queue.update(); assert.equal(later, false);
home.passIntroNode.getChildByName('close').emit('click'); queue.update(); assert.equal(later, true);
});
test('failed battle-pass loading releases queue completion without recording display', async () => {
const { home, cc } = homeFixture(); let finished = 0, shown = 0;
cc.warn = () => {};
home.isHomeBundleReady = () => false;
home.loadHomePopupPrefab = () => Promise.reject(new Error('offline'));
home.openPassCheckIntro(() => shown++, () => finished++);
await Promise.resolve(); assert.equal(finished, 1); assert.equal(shown, 0);
});
test('battle-pass content loading blocks the next automatic popup', () => {
const { cc } = runtime(); const Queue = load('assets/home_popup_queue/HomePopupQueue.ts', {}, { cc }).default;
const home = { passContentLoading: true }; const queue = new Queue();
queue.node = { getChildByName: () => null, getComponent: () => home };
let ran = false; queue.enqueue('later', 70, done => { ran = true; done(); });
queue.update(); assert.equal(ran, false); home.passContentLoading = false; queue.update(); assert.equal(ran, true);
});
test('daily battle pass preloads before its queue turn without opening or recording', async () => {
let run, resolveLoad, loads = 0, ready = false;
const { home, cc, callbacks } = homeFixture({ get: () => ({ enqueue: (key, priority, task) => { run = task; } }) });
Object.assign(cc.fx.GameConfig.GM_INFO, { level: 43, canIos: true, uid: 'preload-player' });
home.deferHomePopupWhileTransfer = () => false;
const local = new Map(); cc.fx.StorageMessage = { getStorage: k => local.get(k), setStorage: (k,v) => local.set(k,v) };
home.isHomeBundleReady = () => ready;
home.loadHomePopupPrefab = () => { loads++; return new Promise(resolve => { resolveLoad = () => { ready = true; resolve(); }; }); };
home.popUpPassCheck(); callbacks.shift()({ code: 1, data: { upgradeReady: true, time: Date.now()-1000, passCheck: 'null' } });
assert.equal(loads, 1); assert.equal(home.passIntroNode, undefined); assert.equal(local.size, 0);
resolveLoad(); await Promise.resolve();
assert.equal(home.passIntroNode, undefined); assert.equal(local.size, 0);
let finished = false; run(() => finished = true); await Promise.resolve();
assert.ok(home.passIntroNode); assert.equal(local.size, 1); assert.equal(loads, 1); assert.equal(finished, true);
});
test('daily battle pass preload rejection releases its queue turn without recording', async () => {
let run;
const { home, cc, callbacks } = homeFixture({ get: () => ({ enqueue: (key, priority, task) => { run = task; } }) });
cc.warn = () => {};
Object.assign(cc.fx.GameConfig.GM_INFO, { level: 43, canIos: true, uid: 'preload-fail' });
home.deferHomePopupWhileTransfer = () => false;
const local = new Map(); cc.fx.StorageMessage = { getStorage: k => local.get(k), setStorage: (k,v) => local.set(k,v) };
home.isHomeBundleReady = () => false;
home.loadHomePopupPrefab = () => Promise.reject(new Error('offline'));
home.popUpPassCheck(); callbacks.shift()({ code: 1, data: { upgradeReady: true, time: Date.now()-1000, passCheck: 'null' } });
await Promise.resolve(); let finished = false; run(() => finished = true); await Promise.resolve();
assert.equal(finished, true); assert.equal(local.size, 0); assert.equal(home.passIntroNode, undefined);
});