const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const ts = require('typescript'); const prefab = JSON.parse(fs.readFileSync('assets/shop/prefab/shop0917.prefab', 'utf8')); const root = prefab[1]; const child = (node, name) => node._children.map(r => prefab[r.__id__]).find(n => n._name === name); const component = (node, name) => node._components.map(r => prefab[r.__id__]).find(c => c.__type__ === name); const subtree = node => [node, ...node._children.flatMap(r => subtree(prefab[r.__id__]))]; const textureFrames = new Map(); function scan(dir) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const file = path.join(dir, entry.name); assert(/^[\x00-\x7f]+$/.test(file), 'asset paths must use English names: ' + file); if (entry.isDirectory()) scan(file); else if (file.endsWith('.png.meta')) { const meta = JSON.parse(fs.readFileSync(file, 'utf8')); for (const frame of Object.values(meta.subMetas)) { assert(!textureFrames.has(frame.uuid), 'duplicate frame UUID: ' + file); textureFrames.set(frame.uuid, frame); } } } } scan('assets/shop/redesign0917/texture'); test('all prefab sprites use native TRIMMED sizes on separate visual nodes', () => { for (const sprite of prefab.filter(c => c.__type__ === 'cc.Sprite')) { assert.equal(sprite._sizeMode, 1); const visual = prefab[sprite.node.__id__]; assert.equal(visual._name, 'SpriteVisual'); assert.equal(visual._children.length, 0); assert(!component(visual, 'cc.Widget'), 'layout widgets must not resize the trimmed image'); if (!sprite._spriteFrame) continue; // Remote player avatar is assigned at runtime. const frame = textureFrames.get(sprite._spriteFrame.__uuid__) || (sprite._spriteFrame.__uuid__ === 'a23235d1-15db-4b95-8439-a2e005bfff91' && { width: 2, height: 2 }); assert(frame, 'unresolved sprite frame'); assert.equal(visual._contentSize.width, frame.width); assert.equal(visual._contentSize.height, frame.height); const layout = prefab[visual._parent.__id__]; assert(Math.abs(visual._contentSize.width * visual._trs.array[7] - layout._contentSize.width) < .001); assert(Math.abs(visual._contentSize.height * visual._trs.array[8] - layout._contentSize.height) < .001); } }); test('0917 keeps the fixed header outside the product ScrollView and keeps delivery lookup compatibility', () => { assert.equal(root._name, 'shop'); // GameTool/monthlyCard find Canvas/shop. const scroll = child(root, 'ProductsScrollView'); const view = child(scroll, 'view'); const content = child(view, 'content'); assert(child(root, 'Top')); assert(!subtree(content).some(node => node._name === 'Top' || node._name === 'Carousel')); assert.equal(component(scroll, 'cc.Widget')._top, 1005); assert.equal(component(scroll, 'cc.ScrollView').vertical, true); assert.equal(component(scroll, 'cc.ScrollView').horizontal, false); assert.equal(component(scroll, 'cc.ScrollView').content.__id__, prefab.indexOf(content)); assert(component(view, 'cc.Mask')); assert(content._contentSize.height > scroll._contentSize.height); assert.equal(content._children.map(r => prefab[r.__id__]).filter(n => n._name.startsWith('Gift_')).length, 3); assert.equal(content._children.map(r => prefab[r.__id__]).filter(n => n._name.startsWith('gold_')).length, 6); }); test('storefront text and digits use isolated sprite resources; references and prices resolve', () => { const storefront = [child(root, 'Top'), child(root, 'ProductsScrollView')].flatMap(subtree); const products = []; for (const node of storefront) { assert(!component(node, 'cc.Label'), node._name + ' must use supplied image text'); for (const r of node._components) { const c = prefab[r.__id__]; if (c.__type__ === 'cc.Sprite' && c._spriteFrame) { assert(textureFrames.has(c._spriteFrame.__uuid__), 'external storefront image: ' + node._name); } for (const eventRef of c.clickEvents || []) { const event = prefab[eventRef.__id__]; assert.equal(event.target.__id__, 1); if (event.handler === 'buyProduct') products.push(event.customEventData); } } } assert.deepEqual(products, ['unlimited_health_bundle_10', 'unlimited_health_bundle_20', 'unlimited_health_bundle_30', 'gold_1', 'gold_2', 'gold_3', 'gold_4', 'gold_5', 'gold_6']); function references(value) { if (!value || typeof value !== 'object') return; if ('__id__' in value) assert(prefab[value.__id__], 'broken serialized node/component reference'); else Object.values(value).forEach(references); } references(prefab); for (const file of ['assets/Script/JiaZai.ts', 'assets/Script/SceneManager.ts']) { const source = fs.readFileSync(file, 'utf8'); assert(source.includes('prefab/shop0917')); assert(!/["']prefab\/shop["']/.test(source)); } assert.notEqual(JSON.parse(fs.readFileSync('assets/shop/prefab/shop.prefab.meta')).uuid, JSON.parse(fs.readFileSync('assets/shop/prefab/shop0917.prefab.meta')).uuid); }); function fixture() { class Sprite { constructor(node) { this.node = node; this._sizeMode = 1; } set spriteFrame(frame) { this._frame = frame; this.applySize(); } get spriteFrame() { return this._frame; } set sizeMode(mode) { this._sizeMode = mode; this.applySize(); } get sizeMode() { return this._sizeMode; } applySize() { if (this._frame && this._sizeMode === 1) { const rect = this._frame.getRect(); this.node.setContentSize(rect.width, rect.height); } } } Sprite.SizeMode = { TRIMMED: 1 }; class Node { constructor(name) { this.name = name; this.children = []; this.active = true; this.width = 0; this.height = 0; this.scaleX = this.scaleY = 1; } addChild(node) { this.children.push(node); node.parent = this; } removeAllChildren() { this.children = []; } destroy() { this.destroyed = true; } addComponent() { return this.sprite = new Sprite(this); } getComponent() { return this.sprite || null; } getComponentInChildren() { return this.sprite || this.children.map(n => n.getComponentInChildren()).find(Boolean); } setContentSize(width, height) { Object.assign(this, { width, height }); } setPosition(x, y) { Object.assign(this, { x, y }); } setScale(scaleX, scaleY) { Object.assign(this, { scaleX, scaleY }); } } class Component { constructor() { this.scheduled = new Map(); this.nextFrame = []; } schedule(callback, interval) { this.scheduled.set(callback, interval); } unschedule(callback) { this.scheduled.delete(callback); } scheduleOnce(callback) { this.nextFrame.push(callback); } } const cc = { Node, Component, Sprite, SpriteFrame: class {}, ScrollView: class {}, v2: (x, y) => ({ x, y }), _decorator: { ccclass: x => x, property: () => () => {} } }; const exports = {}; let starterRemaining = 3600; vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/shop/script/Shop0917View.ts', 'utf8'), { compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS, experimentalDecorators: true } }).outputText, { cc, exports, require: () => ({ starterPackRemaining: () => starterRemaining }) }); const view = new exports.default(); const saved = prefab.find(c => c.countFrames); const frame = value => ({ uuid: value.__uuid__, getRect: () => { const meta = textureFrames.get(value.__uuid__); return { width: meta.width, height: meta.height }; } }); for (const key of ['countFrames', 'healthFrames', 'dayFrames', 'timeFrames']) view[key] = saved[key].map(frame); for (const key of ['colonFrame', 'monthOpen', 'monthRenew', 'dotNormal', 'dotSelected']) view[key] = frame(saved[key]); for (const key of ['coin', 'health', 'monthDays', 'monthPrivilege', 'monthRemaining']) view[key] = new Node(key); for (const key of ['banners', 'dots', 'firstPurchase']) view[key] = saved[key].map((_, i) => new Node(key + i)); for (const dot of view.dots) { const visual = new Node('SpriteVisual'); dot.addChild(visual); visual.addComponent(); } const monthLayout = new Node('Open'); monthLayout.setContentSize(314, 110); const monthVisual = new Node('SpriteVisual'); monthLayout.addChild(monthVisual); view.monthButton = monthVisual.addComponent(); const buttons = [{ interactable: true }, { interactable: true }, { interactable: true }]; view.carousel = { convertToNodeSpaceAR: point => ({ x: point.x * 2, y: point.y * 2 }), getComponentsInChildren: () => buttons }; const touch = (x, id = 0) => ({ getID: () => id, getLocation: () => ({ x, y: 100 }) }); const nextFrame = () => { const callbacks = view.nextFrame.splice(0); callbacks.forEach(callback => callback()); }; return { view, Node, buttons, touch, nextFrame, disableStarter() { starterRemaining = 0; } }; } test('lucky starter offer and Jungle offer use matching artwork and entry actions', () => { const saved = prefab.find(c => c.countFrames); const frameId = name => Object.values(JSON.parse(fs.readFileSync( 'assets/shop/redesign0917/texture/' + name + '.png.meta', 'utf8')).subMetas)[0].uuid; for (const [index, image, handler, script] of [ [0, 'lucky_banner', 'openStarter', 'Shop0917View'], [2, 'starter_banner', 'openJungleTreasure', 'shop'] ]) { const banner = prefab[saved.banners[index].__id__]; assert.equal(component(child(banner, 'SpriteVisual'), 'cc.Sprite')._spriteFrame.__uuid__, frameId(image)); const action = component(child(banner, 'Open'), 'cc.Button').clickEvents[0]; assert.equal(prefab[action.__id__].handler, handler); assert.equal(prefab[action.__id__].component, script); } const { view } = fixture(); view.refreshBanners(false); assert.deepEqual(Array.from(view.banners, n => n.active), [true, true, false], 'an active starter offer without Jungle shows only lucky artwork and monthly card'); }); test('dynamic image numbers preserve every digit, fit HUD width and avoid rebuilding unchanged values', () => { const { view, Node } = fixture(); view.setCoin(123456789); assert.equal(view.coin.children.length, 9); const digits = view.coin.children; assert(digits[8].x + digits[8].width * digits[8].scaleX / 2 <= 95.01); assert(digits.every(node => node.sprite.sizeMode === 1)); view.setCoin(123456789); assert.equal(view.coin.children, digits); view.setCoin(0); assert.equal(view.coin.children.length, 1); assert(digits.every(node => node.destroyed)); const timer = new Node('time'); view.setTime(timer, '12:34:56'); assert.equal(timer.children.length, 8); assert.equal(timer.children[2].sprite.spriteFrame, view.colonFrame); assert.equal(timer.children[5].sprite.spriteFrame, view.colonFrame); }); test('swapping trimmed frames and resizing layouts preserves the displayed size', () => { const { view, Node } = fixture(); const layout = new Node('avatar'); layout.setContentSize(96, 96); const visual = new Node('SpriteVisual'); layout.addChild(visual); const sprite = visual.addComponent(); for (const [width, height] of [[128, 128], [640, 480]]) { view.fitSprite(layout, { getRect: () => ({ width, height }) }); assert.equal(sprite.sizeMode, 1); assert.equal(visual.width, width); assert.equal(visual.height, height); assert.equal(visual.width * visual.scaleX, 96); assert.equal(visual.height * visual.scaleY, 96); } layout.setContentSize(1080, 1920); view.fitSprite(layout); assert.equal(visual.width * visual.scaleX, 1080); assert.equal(visual.height * visual.scaleY, 1920); }); test('purchased offers and unavailable activities disappear, month status switches image assets', () => { const { view, disableStarter } = fixture(); view.refreshFirstPurchase({ gold_1: true, gold_2: false }); assert.deepEqual(Array.from(view.firstPurchase, n => n.active), [false, true, false, false, false, false]); view.refreshMonth(true, 12); assert.equal(view.monthButton.spriteFrame, view.monthRenew); assert.equal(view.monthDays.children.length, 2); assert.equal(view.monthRemaining.active, true); view.refreshMonth(false, 0); assert.equal(view.monthButton.spriteFrame, view.monthOpen); assert.equal(view.monthPrivilege.active, true); assert.equal(view.monthRemaining.active, false); view.refreshBanners(true); view.selectBanner(null, '2'); view.update(1); disableStarter(); view.refreshBanners(false); assert.equal(view.selectedBanner, 1); assert(view.dots.every(n => !n.active)); assert.equal(view.banners[1].x, 5); }); test('carousel follows the finger in local coordinates before release, then smoothly settles', () => { const { view, touch, buttons, nextFrame } = fixture(); view.refreshBanners(true); view.onTouchStart(touch(300)); view.onTouchMove(touch(250)); assert.deepEqual(Array.from(view.banners, n => n.x), [-995, -95, 805]); assert.equal(view.selectedBanner, 1); assert(buttons.every(button => !button.interactable)); view.onTouchEnd(touch(250)); assert.equal(view.banners[2].x, 805, 'release must not teleport the incoming page'); assert(buttons.every(button => !button.interactable), 'button remains disabled throughout touch-end dispatch'); nextFrame(); assert(buttons.every(button => button.interactable)); view.update(.08); assert(view.banners[2].x > 5 && view.banners[2].x < 805); view.update(1); assert.equal(view.selectedBanner, 2); assert.equal(view.banners[2].x, 5); }); test('short drag eases back and a second finger cannot hijack the gesture', () => { const { view, touch } = fixture(); view.refreshBanners(true); view.onTouchStart(touch(200, 1)); view.onTouchMove(touch(180, 1)); assert.equal(view.banners[1].x, -35); view.onTouchStart(touch(0, 2)); view.onTouchMove(touch(-200, 2)); view.onTouchEnd(touch(-200, 2)); assert.equal(view.banners[1].x, -35); assert(view.touching); view.onTouchEnd(touch(180, 1)); view.update(.04); assert(view.banners[1].x > -35 && view.banners[1].x < 5); view.update(1); assert.equal(view.selectedBanner, 1); assert.equal(view.banners[1].x, 5); }); test('three-page loop stays continuous across last/first pages in both directions', () => { const { view, touch } = fixture(); view.refreshBanners(true); view.selectBanner(null, '2'); view.update(1); view.onTouchStart(touch(300)); view.onTouchMove(touch(200)); assert.equal(view.banners[0].x, 705); // Cocos sends CANCEL when a swipe ends outside the button it started on. view.onTouchCancel(touch(200)); view.update(.08); assert(view.banners[0].x < 705 && view.banners[0].x > 5); view.update(1); assert.equal(view.selectedBanner, 0); view.onTouchStart(touch(200)); view.onTouchMove(touch(300)); assert.equal(view.banners[2].x, -695); view.onTouchEnd(touch(300)); view.update(1); assert.equal(view.selectedBanner, 2); assert.equal(view.banners[2].x, 5); }); test('two-page edges resist overscroll and a single banner does not drift', () => { const { view, touch, disableStarter } = fixture(); disableStarter(); view.refreshBanners(true); view.onTouchStart(touch(200)); view.onTouchMove(touch(300)); assert.equal(view.banners[1].x, 55, '200 local pixels at the edge receive 25% resistance'); view.onTouchEnd(touch(300)); view.update(1); assert.equal(view.selectedBanner, 1); view.onTouchStart(touch(300)); view.onTouchMove(touch(200)); view.onTouchEnd(touch(200)); view.update(1); assert.equal(view.selectedBanner, 2); view.refreshBanners(false); view.onTouchStart(touch(300)); view.onTouchMove(touch(100)); assert.equal(view.banners[1].x, 5); view.onTouchEnd(touch(100)); view.update(1); assert.equal(view.selectedBanner, 1); }); test('auto advance and dot selection animate, and touching interrupts animation without a jump', () => { const { view, touch } = fixture(); view.refreshBanners(true); view.advanceBanner(); assert.equal(view.banners[1].x, 5); view.update(.08); const movingX = view.banners[1].x; assert(movingX < 5 && movingX > -895); view.onTouchStart(touch(200)); assert.equal(view.banners[1].x, movingX); view.update(.2); assert.equal(view.banners[1].x, movingX, 'automatic animation pauses while held'); view.onTouchMove(touch(210)); assert.equal(view.banners[1].x, movingX + 20); view.onTouchEnd(touch(210)); assert.equal(view.scheduled.get(view.advanceBanner), 5, 'manual interaction restarts the auto timer'); view.update(1); const from = view.banners[0].x; view.selectBanner(null, '0'); assert.equal(view.banners[0].x, from); view.update(.08); assert(Math.abs(view.banners[0].x - 5) < Math.abs(from - 5)); view.update(1); assert.equal(view.selectedBanner, 0); }); test('closing mid-drag clears motion and a delayed touch-end cannot unlock a new drag', () => { const { view, touch, buttons, nextFrame } = fixture(); view.refreshBanners(true); view.onTouchStart(touch(300)); view.onTouchMove(touch(200)); view.onTouchEnd(touch(200)); view.onTouchStart(touch(200)); view.onTouchMove(touch(180)); nextFrame(); assert(buttons.every(button => !button.interactable)); view.onDisable(); assert(buttons.every(button => button.interactable)); assert.equal(view.touching, false); assert.equal(view.snapping, false); assert.equal(view.touchId, null); assert.equal(view.banners[1].x, 5); assert.equal(view.scheduled.has(view.advanceBanner), false); });