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, 8); 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 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, 6); 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); });