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 setup() { const prefab = JSON.parse(fs.readFileSync('assets/shop/prefab/shop2.prefab', 'utf8')); const sourceActivity = prefab.find(n => n.__type__ === 'cc.Node' && n._name === 'activity'); const sourceProgress = prefab.find(n => n.__type__ === 'cc.Node' && n._name === 'progress'); function node(raw, parent) { const result = { name: raw._name, x: raw._trs.array[0], scaleX: raw._trs.array[7], width: raw._contentSize.width, anchorX: raw._anchorPoint.x, active: raw._active, parent, children: [], on() {}, off() {}, getChildByName(name) { return this.children.find(child => child.name === name) || null; }, setSiblingIndex(index) { const siblings = this.parent.children; siblings.splice(siblings.indexOf(this), 1); siblings.splice(index, 0, this); } }; result.children = raw._children.map(ref => node(prefab[ref.__id__], result)); return result; } const parent = { convertToNodeSpaceAR: p => p }; const activity = node(sourceActivity, parent); const progress = node(sourceProgress, parent); const selects = progress.children.map(dot => dot.getChildByName('select')); const sourceLayout = sourceActivity._components.map(ref => prefab[ref.__id__]) .find(component => component.__type__ === 'cc.Layout'); let layoutUpdates = 0; const layout = { updateLayout() { layoutUpdates++; const children = activity.children.filter(child => child.active); const spacing = sourceLayout['_N$spacingX']; activity.width = children.reduce((width, child) => width + child.width, 0) + spacing * Math.max(0, children.length - 1); let left = -activity.width * activity.anchorX; children.forEach(child => { child.x = left + child.width * child.anchorX; left += child.width + spacing; }); } }; activity.getComponent = () => layout; const cc = { _decorator: { ccclass: value => value }, Component: function() {}, Layout: function() {}, Node: { EventType: { TOUCH_START: 'start', TOUCH_MOVE: 'move', TOUCH_END: 'end', TOUCH_CANCEL: 'cancel' } }, Event: { EventTouch: function(touches, bubbles) { this.bubbles = bubbles; this.getID = () => 1; } }, find: path => { let raw = prefab[prefab[0].data.__id__]; for (const name of path.split('/')) { raw = raw && raw._children.map(ref => prefab[ref.__id__]).find(node => node._name === name); } return raw === sourceActivity ? activity : raw === sourceProgress ? progress : null; }, tween: node => ({ to(duration, props) { this.props = props; return this; }, start() { Object.assign(node, this.props); return this; }, stop() {} }) }; cc.Event.EventTouch.ENDED = 2; const context = { cc, exports: {} }; vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/shop/script/ShopActivityCarousel.ts', 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, experimentalDecorators: true } }).outputText, context); const carousel = new context.exports.default(); carousel.node = { on() {}, off() {} }; carousel.onLoad(); let cancellations = 0; const button = { parent: activity, dispatchEvent(event) { cancellations++; carousel.onTouchCancel(event); } }; function event(x = 0, y = 0, id = 1) { return { target: button, touch: {}, bubbles: true, stopped: false, getEventCode: () => 3, getID: () => id, getTouches: () => [], getStartLocation: () => ({ x: 0, y: 0 }), getLocation: () => ({ x, y, sub: p => ({ x: x - p.x, y: y - p.y }) }), stopPropagation() { this.stopped = true; } }; } function swipe(x, y = 0) { carousel.onTouchStart(event()); const move = event(x, y), end = event(x, y); carousel.onTouchMove(move); carousel.onTouchEnd(end); return { move, end }; } return { carousel, activity, progress, selects, event, swipe, cancellations: () => cancellations, layoutUpdates: () => layoutUpdates }; } function visibleCards(s) { return s.activity.children.filter(node => node.active); } function centerCard(s) { return visibleCards(s).find(node => s.activity.x + node.x * s.activity.scaleX === 0)?.name; } const combinations = [ { names: [], pages: [], selected: -1 }, { names: ['newbieGift'], pages: ['newbieGift'], selected: 0 }, { names: ['jungle'], pages: ['jungle'], selected: 0 }, { names: ['banre'], pages: ['banre'], selected: 0 }, { names: ['newbieGift', 'jungle'], pages: ['newbieGift', 'jungle'], selected: 0 }, { names: ['newbieGift', 'banre'], pages: ['newbieGift', 'banre'], selected: 0 }, { names: ['jungle', 'banre'], pages: ['jungle', 'banre'], selected: 0 }, { names: ['newbieGift', 'jungle', 'banre'], pages: ['jungle', 'newbieGift', 'banre'], selected: 1 } ]; for (const { names, pages, selected } of combinations) { test(`活动可用组合 ${names.join('+') || '无'}:最高优先级居中,分页点数量和位置匹配`, () => { const s = setup(); s.carousel.setEntries(names); const cards = visibleCards(s); assert.deepEqual(cards.map(node => node.name), pages); assert.equal(s.activity.active, names.length > 0); assert.equal(s.progress.active, names.length > 0); assert.equal(centerCard(s), names[0]); assert.deepEqual(s.progress.children.map(dot => dot.active), [0, 1, 2].map(i => i < names.length)); assert.deepEqual(s.progress.children.filter(dot => dot.active).map(dot => dot.x), names.map((_, i) => (i - (names.length - 1) / 2) * 50)); assert.deepEqual(s.selects.map(select => select.active), [0, 1, 2].map(i => i === selected)); for (let i = 1; i < cards.length; i++) { assert.equal(cards[i].x - cards[i - 1].x, (cards[i].width + cards[i - 1].width) / 2 + 40); } if (names.length === 2) { assert.ok(s.activity.x + cards[1].x > 0); s.swipe(20); assert.equal(centerCard(s), names[0], '初始页向右滑不会进入隐藏节点'); s.swipe(-20); assert.equal(centerCard(s), names[1]); assert.deepEqual(s.selects.map(select => select.active), [false, true, false]); s.swipe(-20); assert.equal(centerCard(s), names[1], '末页向左滑不会进入隐藏节点'); s.swipe(20); assert.equal(centerCard(s), names[0]); } }); } test('同一活动集合重复刷新保留选择;显式 reset 恢复优先级入口', () => { const s = setup(); s.swipe(-20); assert.equal(centerCard(s), 'banre'); const updates = s.layoutUpdates(); s.carousel.setEntries(['newbieGift', 'jungle', 'banre']); assert.equal(centerCard(s), 'banre'); assert.equal(s.layoutUpdates(), updates); s.carousel.setEntries(['newbieGift', 'jungle', 'banre'], true); assert.equal(centerCard(s), 'newbieGift'); assert.equal(s.layoutUpdates(), updates + 1); }); test('拖动中移除、清空并恢复入口:不留下隐藏页,也不会被旧手势带走', () => { const s = setup(); s.swipe(-20); s.carousel.onTouchStart(s.event()); s.carousel.onTouchMove(s.event(100)); s.carousel.setEntries(['newbieGift', 'jungle']); s.carousel.onTouchEnd(s.event(100)); assert.equal(centerCard(s), 'newbieGift'); assert.deepEqual(visibleCards(s).map(node => node.name), ['newbieGift', 'jungle']); s.swipe(-20); assert.equal(centerCard(s), 'jungle'); s.carousel.setEntries(['banre']); assert.equal(centerCard(s), 'banre'); s.carousel.setEntries([]); assert.equal(s.activity.x, 0); assert.deepEqual(s.selects.map(select => select.active), [false, false, false]); s.carousel.setEntries(['newbieGift', 'jungle', 'banre']); assert.deepEqual(visibleCards(s).map(node => node.name), ['jungle', 'newbieGift', 'banre']); assert.equal(centerCard(s), 'newbieGift'); assert.deepEqual(s.activity.children.map(node => node.x), [-906, 0, 906]); assert.deepEqual(s.progress.children.map(dot => dot.x), [-50, 0, 50]); }); test('零页和单页不截获横滑、点击或纵向手势', () => { for (const names of [[], ['newbieGift'], ['jungle'], ['banre']]) { const s = setup(); s.carousel.setEntries(names); const x = s.activity.x; for (const [offsetX, offsetY] of [[200, 0], [-200, 0], [0, 0], [20, 200]]) { const { move, end } = s.swipe(offsetX, offsetY); assert.equal(move.stopped, false); assert.equal(end.stopped, false); assert.equal(s.activity.x, x); } assert.equal(s.cancellations(), 0); } }); test('关闭时把拖动位置归位到当前页,重新启用保留选中的可用入口', () => { const s = setup(); s.carousel.setEntries(['jungle', 'banre']); s.swipe(-20); s.carousel.onTouchStart(s.event()); s.carousel.onTouchMove(s.event(100)); s.carousel.onDisable(); assert.equal(centerCard(s), 'banre'); assert.deepEqual(s.selects.map(select => select.active), [false, true, false]); s.carousel.onEnable(); s.swipe(20); assert.equal(centerCard(s), 'jungle'); }); test('从中间开始;右滑左卡居中,左滑恢复,并且只亮一个 select', () => { const s = setup(); assert.equal(s.activity.x, 0); assert.deepEqual(s.selects.map(n => n.active), [false, true, false]); s.swipe(20); assert.equal(s.activity.x, 906); assert.deepEqual(s.selects.map(n => n.active), [true, false, false]); s.swipe(-20); assert.equal(s.activity.x, 0); s.swipe(-200); assert.equal(s.activity.x, -906); assert.deepEqual(s.selects.map(n => n.active), [false, false, true]); }); test('每次最多翻一页,首尾不越界', () => { const s = setup(); s.swipe(4000); s.swipe(4000); assert.equal(s.activity.x, 906); s.swipe(-4000); assert.equal(s.activity.x, 0); s.swipe(-4000); s.swipe(-4000); assert.equal(s.activity.x, -906); }); test('横拖跟手,取消按钮触摸并拦截松手,短拖回原页', () => { const s = setup(); s.carousel.onTouchStart(s.event()); const move = s.event(19); s.carousel.onTouchMove(move); assert.equal(s.activity.x, 19); assert.equal(s.cancellations(), 1); assert.equal(move.stopped, true); const end = s.event(19); s.carousel.onTouchEnd(end); assert.equal(end.stopped, true); assert.equal(s.activity.x, 0); }); test('轻点保留按钮事件;纵向滑动交给外层滚动,不翻页', () => { const s = setup(); s.carousel.onTouchStart(s.event()); const tap = s.event(); s.carousel.onTouchEnd(tap); assert.equal(tap.stopped, false); const { move, end } = s.swipe(20, 200); assert.equal(move.stopped, false); assert.equal(end.stopped, false); assert.equal(s.cancellations(), 0); assert.equal(s.activity.x, 0); }); test('系统取消横拖回原页;忽略 ScrollView 模拟取消和其他手指', () => { const s = setup(); s.carousel.onTouchStart(s.event()); s.carousel.onTouchMove(s.event(200, 0, 2)); assert.equal(s.activity.x, 0); s.carousel.onTouchCancel(Object.assign(s.event(), { simulate: true })); s.carousel.onTouchMove(s.event(200)); assert.equal(s.activity.x, 200); s.carousel.onTouchCancel(s.event(200)); assert.equal(s.activity.x, 0); assert.deepEqual(s.selects.map(n => n.active), [false, true, false]); }); test('滑出原按钮区域后松手:TOUCH_CANCEL 携带 ENDED 仍应翻页', () => { for (const offset of [200, -200]) { const s = setup(); s.carousel.onTouchStart(s.event()); s.carousel.onTouchMove(s.event(offset)); const releaseOutside = s.event(offset); releaseOutside.getEventCode = () => 2; s.carousel.onTouchCancel(releaseOutside); assert.equal(s.activity.x, offset > 0 ? 906 : -906); assert.deepEqual(s.selects.map(n => n.active), offset > 0 ? [true, false, false] : [false, false, true]); assert.equal(releaseOutside.stopped, true); } });