381 lines
18 KiB
JavaScript
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);
|
|
});
|