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 prefab = JSON.parse(fs.readFileSync('assets/seven_day_gift/prefab/sevenDayGift.prefab')); const source = fs.readFileSync('assets/seven_day_gift/SevenDayGift.ts', 'utf8'); const frameData = new Map(); function scan(dir) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const path = dir + '/' + entry.name; if (entry.isDirectory()) scan(path); else if (path.endsWith('.png.meta')) { const meta = JSON.parse(fs.readFileSync(path)); for (const frame of Object.values(meta.subMetas || {})) frameData.set(frame.uuid, frame); } } } scan('assets/seven_day_gift/texture'); const frame = uuid => ({ uuid, getRect() { return { height: frameData.get(uuid).height }; }, getOriginalSize() { const data = frameData.get(uuid); assert.ok(data, 'SpriteFrame resolves: ' + uuid); return { width: data.rawWidth, height: data.rawHeight }; } }); const rewards = [ [{ type: 'infinite_health', count: 900 }], [{ type: 'hammer', count: 1 }], [{ type: 'infinite_health', count: 1800 }], [{ type: 'freeze', count: 1 }, { type: 'magic_wand', count: 1 }], [{ type: 'coin', count: 600 }], [{ type: 'hammer', count: 1 }, { type: 'freeze', count: 1 }], [{ type: 'infinite_health', count: 3600 }, { type: 'cat_skin', count: 1 }], ]; function fixture() { const cc = { Component: class {}, Animation: 'cc.Animation', Sprite: 'cc.Sprite', Label: 'cc.Label', LabelOutline: 'cc.LabelOutline', SpriteFrame: class {}, Enum: x => x, Color: { WHITE: {} }, color: (...x) => x, _decorator: { ccclass: x => x, property: () => () => {} }, Node: class { constructor() { throw Error('Runtime node creation is forbidden'); } }, warn() {}, tween() { return { to() { return this; }, repeatForever() { return this; }, start() {} }; }, }; const context = { cc, exports: {}, require: () => ({ default: class {}, ActivityPopupAnimationType: { 'Q弹弹出': 1 } }) }; vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { experimentalDecorators: true, target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS, } }).outputText, context); const nodes = new Map(); prefab.forEach((data, id) => { if (data.__type__ !== 'cc.Node') return; const node = { name: data._name, active: data._active, x: data._trs.array[0], y: data._trs.array[1], width: data._contentSize.width, height: data._contentSize.height, children: [], components: {}, get childrenCount() { return this.children.length; }, getChildByName(name) { return this.children.find(child => child.name === name); }, getComponent(type) { return this.components[type]; }, setContentSize(w, h) { this.width = w; this.height = h; }, setPosition(x, y) { this.x = x; this.y = y; }, stopAllActions() {}, }; for (const ref of data._components) { const c = prefab[ref.__id__]; node.components[c.__type__] = { node, string: c._string, fontSize: c._fontSize, spriteFrame: c._spriteFrame && frame(c._spriteFrame.__uuid__) }; if (c.__type__ === 'cc.Animation') { const state = { isPlaying: false }; node.components[c.__type__] = { state, starts: 0, getAnimationState: () => state, play() { this.starts++; state.isPlaying = true; }, stop() { state.isPlaying = false; } }; } } nodes.set(id, node); }); for (const [id, node] of nodes) node.children = prefab[id]._children.map(ref => nodes.get(ref.__id__)); const view = new context.exports.default(); const serialized = prefab.find(x => x.rewardNumberFrames); for (const [key, value] of Object.entries(serialized)) { if (value && value.__uuid__) view[key] = frame(value.__uuid__); } view.rewardNumberFrames = serialized.rewardNumberFrames.map(value => frame(value.__uuid__)); view.node = nodes.get(1); assert.equal(view.cacheView(), true); const info = { canClaim: true, nextRewardDay: 1, rewards: rewards.map((items, i) => ({ day: i + 1, items, claimed: false })) }; return { view, info, nodes }; } test('prefab retains only fixed reward slots and required claim-state nodes', () => { function visit(value) { if (!value || typeof value !== 'object') return; if ('__id__' in value) assert.ok(prefab[value.__id__], 'reference ' + value.__id__); Object.values(value).forEach(visit); } prefab.forEach(visit); const { view } = fixture(); for (const [day, card] of view.cards.entries()) { assert.ok(card.getChildByName('claimedOverlay')); assert.ok(card.getChildByName('claimedBadge')); const container = card.getChildByName('rewardItems'); assert.equal(container.children.length, rewards[day].length); for (const icon of container.children) assert.ok(icon.getChildByName('amount').children.every(n => n.active && /^digit\d+$/.test(n.name))); } assert.ok(prefab.filter(n => n.__type__ === 'cc.Node' && !n._active).every(n => ['claimedOverlay', 'claimedBadge'].includes(n._name))); assert.ok(!prefab.some(n => n.__type__ === 'cc.Node' && /^(layout\d+|amountLabel|close|message|title|subtitle)$/.test(n._name))); const glow = view.cards[6].getChildByName('catGlow'); assert.ok(glow && glow.active); assert.equal(glow.width, 480); const clip = JSON.parse(fs.readFileSync('assets/seven_day_gift/CatRewardGlow.anim')); assert.equal(clip.wrapMode, 2); assert.equal(clip._duration, 6); assert.equal(clip.curveData.props.angle[1].value, 360); assert.deepEqual(fs.readFileSync('assets/seven_day_gift/texture/catAnimation3.png'), fs.readFileSync('assets/gacha_bundle/img/catAnimation3.png')); }); test('default data matches baked icon and amount geometry without creating nodes', () => { const { view, info, nodes } = fixture(); const before = [...nodes.values()].map(n => [n.x, n.y, n.width, n.height, n.active]); view.setData(info, () => {}); const after = [...nodes.values()].map(n => [n.x, n.y, n.width, n.height, n.active]); assert.deepEqual(after, before); }); test('cat glow starts explicitly, survives refresh and stops after claiming', () => { const { view, info } = fixture(); const glow = view.cards[6].getChildByName('catGlow'); const animation = glow.getComponent('cc.Animation'); view.setData(info, () => {}); assert.equal(animation.state.isPlaying, true); view.setData(info, () => {}); assert.equal(animation.starts, 1, 'refresh must not restart the rotation'); info.rewards[6].claimed = true; view.setData(info, () => {}); assert.equal(glow.active, false); assert.equal(animation.state.isPlaying, false); info.rewards[6].claimed = false; view.setData(info, () => {}); assert.equal(animation.state.isPlaying, true); }); test('claim states and quantity updates use existing nodes without text fallback', () => { const { view, info } = fixture(); for (let day = 1; day <= 7; day++) { info.nextRewardDay = day; info.rewards.forEach(reward => reward.claimed = reward.day < day); view.setData(info, () => {}); view.cards.forEach((card, i) => assert.equal(card.getChildByName('claimedBadge').active, i + 1 < day)); } const container = view.cards[0].getChildByName('rewardItems'); const amount = container.children[0].getChildByName('amount'); for (const value of [1.5, 123456789]) { info.rewards[0].items = [{ type: 'coin', amount: value }]; view.setData(info, () => {}); assert.ok(amount.children.every(n => !n.active), 'unsupported quantities do not display a truncated number'); } info.rewards[0].items = [{ type: 'infinite_health', count: 600 }]; view.setData(info, () => {}); assert.ok(amount.children.every(n => n.active), 'existing image digits are restored after invalid data'); info.rewards[0].items = []; view.setData(info, () => {}); assert.equal(view.cards[0].getChildByName('rewardItems').children.some(x => x.active), false); info.rewards[6].claimed = true; view.setData(info, () => {}); assert.equal(view.cards[6].getChildByName('catGlow').active, false); assert.doesNotMatch(source, /new cc\.Node|addComponent\(|createAmount\(/); });