MatchMaster/tools/test-shop0917.cjs
2026-09-22 10:17:58 +08:00

381 lines
18 KiB
JavaScript

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);
});