163 lines
10 KiB
JavaScript
163 lines
10 KiB
JavaScript
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 source = ts.transpileModule(fs.readFileSync('assets/cloud_rise_home/scripts/CloudRisePanel.ts', 'utf8'), {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017, experimentalDecorators: true }
|
|
}).outputText;
|
|
|
|
function fixture(options = {}) {
|
|
const state = { width: 393, height: 852, bottom: 92, dpr: 3, viewportY: 0, cameraY: 0, ...options };
|
|
function emitter() {
|
|
const listeners = [];
|
|
return {
|
|
on(event, fn, target) { listeners.push({ event, fn, target }); },
|
|
once(event, fn, target) { listeners.push({ event, fn, target, once: true }); },
|
|
off(event, fn, target) { for (let i = listeners.length - 1; i >= 0; i--) if (listeners[i].event === event && listeners[i].fn === fn && listeners[i].target === target) listeners.splice(i, 1); },
|
|
emit(event) { for (const l of listeners.slice()) if (l.event === event) { if (l.once) this.off(event, l.fn, l.target); l.fn.call(l.target); } },
|
|
count: () => listeners.length
|
|
};
|
|
}
|
|
class Node {
|
|
constructor(parent = null) { this.parent = parent; this.children = []; if (parent) parent.children.push(this); this.x = this.y = 0; this.scale = 1; this.active = true; this.width = 1080; this.height = 2200; this.anchorY = 0.5; }
|
|
get scaleY() { return this.scale; }
|
|
setContentSize(size) { this.width = size.width; this.height = size.height; }
|
|
convertToWorldSpaceAR(p) { const q = { x: this.x + p.x * this.scale, y: this.y + p.y * this.scale }; return this.parent ? this.parent.convertToWorldSpaceAR(q) : q; }
|
|
convertToNodeSpaceAR(p) { const q = this.parent ? this.parent.convertToNodeSpaceAR(p) : p; return { x: (q.x - this.x) / this.scale, y: (q.y - this.y) / this.scale }; }
|
|
getComponent(type) { return type === cc.Button ? { zoomScale: 1.03 } : null; }
|
|
getComponentsInChildren() { return []; }
|
|
on() {} off() {}
|
|
}
|
|
const director = emitter(), game = Object.assign(emitter(), { EVENT_SHOW: 'show', canvas: {} });
|
|
const engineScale = () => state.width * state.dpr / 1080;
|
|
const view = Object.assign(emitter(), {
|
|
getVisibleSize: () => ({ width: 1080, height: state.height * 1080 / state.width }),
|
|
getFrameSize: () => ({ width: state.width, height: state.height }),
|
|
getViewportRect: () => ({ x: 0, y: state.viewportY }), getScaleY: engineScale
|
|
});
|
|
Object.defineProperty(game.canvas, 'height', { get: () => state.height * state.dpr });
|
|
const camera = { getWorldToScreenPoint: p => ({ x: p.x, y: p.y - state.cameraY }), getScreenToWorldPoint: p => ({ x: p.x, y: p.y + state.cameraY }) };
|
|
const cc = { _decorator: { ccclass: c => c, property: () => () => {} }, Component: class { schedule() {} }, Node,
|
|
Label: class {}, Sprite: class {}, SpriteFrame: class {}, String, Widget: class {}, Button: class {},
|
|
Camera: { findCamera: () => camera }, v2: (x, y) => ({ x, y }), view, game, director,
|
|
Director: { EVENT_BEFORE_DRAW: 'draw' }, Tween: { stopAllByTarget() {} }, isValid: n => !!n && !n.destroyed };
|
|
let reads = 0;
|
|
const wx = {
|
|
getWindowInfo: () => ({ windowWidth: state.width, windowHeight: state.height, safeArea: { top: 59 } }),
|
|
getMenuButtonBoundingClientRect() { reads++; if (state.error) throw Error('not ready'); return state.bottom ? { left: state.width - 100, right: state.width - 8, top: state.bottom - 32, bottom: state.bottom } : {}; }
|
|
};
|
|
const exports = {};
|
|
vm.runInNewContext(source, { exports, cc, wx, console, require: () => ({ default: { subscribe: () => () => {} } }) });
|
|
const parent = new Node(); parent.x = 540; parent.y = view.getVisibleSize().height / 2;
|
|
const panel = new exports.default(); panel.node = new Node(parent); panel.node.active = false;
|
|
panel.background = new Node(panel.node); panel.design = new Node(panel.node);
|
|
const button = new Node(panel.design); button.x = 395; button.y = 920; button.width = 103; button.height = 112;
|
|
panel.buttons = [button];
|
|
panel.bindButtons = () => {}; panel.warmMatching = () => {};
|
|
panel.render = () => panel.applyClosePosition();
|
|
let openings = 0;
|
|
panel.openPopup = () => { openings++; assert(panel.node.active); };
|
|
function top(zoom = 1.03) {
|
|
const world = button.convertToWorldSpaceAR({ x: 0, y: button.height * (1 - button.anchorY) * zoom });
|
|
return state.height - (camera.getWorldToScreenPoint(world).y * engineScale() + state.viewportY) / state.dpr;
|
|
}
|
|
return { panel, button, state, cc, wx, parent, reads: () => reads, openings: () => openings, top,
|
|
draw: () => director.emit('draw'), init: (mode = 'progress') => panel.initialize(mode, false, () => {}, () => {}) };
|
|
}
|
|
|
|
for (const device of [{ width: 393, height: 852, bottom: 92, dpr: 3 }, { width: 400, height: 890, bottom: 88, dpr: 2.75 }, { width: 375, height: 667, bottom: 64, dpr: 2 }]) {
|
|
test(`first visible frame clears capsule and pressed bounds on ${device.width}x${device.height}`, () => {
|
|
const f = fixture(device); f.init(); assert.equal(f.panel.node.active, false);
|
|
f.draw(); assert.equal(f.panel.node.active, true);
|
|
assert(f.top() >= device.bottom + 10 - 1e-8, `top=${f.top()}`);
|
|
const y = f.button.y, reads = f.reads(); f.state.bottom += 20;
|
|
for (let i = 0; i < 20; i++) { f.panel.render(); f.draw(); }
|
|
assert.equal(f.button.y, y); assert.equal(f.reads(), reads, 'ordinary refresh never repositions from late API data');
|
|
});
|
|
}
|
|
|
|
test('viewport offset, camera translation, nested scale and noncentral anchor are converted before reveal', () => {
|
|
const f = fixture({ viewportY: -85, cameraY: 43 });
|
|
f.parent.scale = 0.85; f.button.anchorY = 0.2;
|
|
f.init(); f.draw(); assert(f.top() >= 102 - 1e-8);
|
|
});
|
|
|
|
test('overview position remains safe for the complete backOut and pressed-button envelope', () => {
|
|
const f = fixture({ bottom: 220 }); f.init('overview');
|
|
assert.equal(f.openings(), 0); f.draw(); assert.equal(f.openings(), 1);
|
|
const finalScale = f.panel.popupScale;
|
|
for (let i = 0; i <= 100; i++) {
|
|
const t = i / 100 - 1, backOut = t * t * (2.70158 * t + 1.70158) + 1;
|
|
f.panel.design.scale = finalScale * (1 / 7 + backOut);
|
|
assert(f.top() >= 230 - 1e-8);
|
|
}
|
|
f.panel.design.scale = finalScale;
|
|
});
|
|
|
|
test('invalid capsule retries while invisible, then succeeds without a visible correction', () => {
|
|
const f = fixture({ bottom: 0 }); f.init(); f.draw();
|
|
assert.equal(f.panel.node.active, false);
|
|
f.state.bottom = 92; f.draw(); assert.equal(f.panel.node.active, true); assert(f.top() >= 102 - 1e-8);
|
|
});
|
|
|
|
test('persistent API errors reveal a stable conservative fallback after one retry', () => {
|
|
const f = fixture({ error: true }); f.init(); f.draw(); f.draw();
|
|
assert.equal(f.panel.node.active, true); assert(f.top() >= 59 + 48 + 10 - 1e-8);
|
|
const y = f.button.y; f.state.error = false; f.panel.render(); f.draw(); assert.equal(f.button.y, y);
|
|
});
|
|
|
|
test('resume uses matching cache; resize invalidates it and relayouts hidden without accumulating offsets', () => {
|
|
const f = fixture(); f.init(); f.draw(); const y = f.button.y;
|
|
f.state.error = true; f.cc.game.emit('show'); assert.equal(f.panel.node.active, false); f.draw();
|
|
assert.equal(f.panel.node.active, true); assert.equal(f.button.y, y);
|
|
f.state.height = 800; f.cc.view.emit('canvas-resize'); f.draw(); assert.equal(f.panel.node.active, false);
|
|
f.draw(); assert.equal(f.panel.node.active, true); assert(f.top() >= 117 - 1e-8);
|
|
f.state.error = false; f.state.height = 852; f.cc.view.emit('canvas-resize'); f.draw(); assert.equal(f.button.y, y);
|
|
});
|
|
|
|
test('settlement close and later page refreshes share the same safe position', () => {
|
|
const f = fixture(); f.init(); f.draw(); const y = f.button.y;
|
|
f.panel.mode = 'elimination'; f.panel.eliminationSkippable = true; f.panel.updateEliminationClose();
|
|
assert.equal(f.button.y, y); assert.equal(f.button.active, true);
|
|
f.panel.mode = 'overview'; f.panel.render(); f.panel.mode = 'progress'; f.panel.render(); assert.equal(f.button.y, y);
|
|
});
|
|
|
|
test('destroying a hidden pending page removes draw, resize and resume callbacks', () => {
|
|
const f = fixture(); f.init(); f.panel.onDestroy();
|
|
assert.equal(f.cc.director.count(), 0); assert.equal(f.cc.view.count(), 0); assert.equal(f.cc.game.count(), 0);
|
|
f.draw(); assert.equal(f.panel.node.active, false);
|
|
});
|
|
|
|
test('temporary hiding for relayout preserves guide and cloud animation state', () => {
|
|
const f = fixture(); f.init(); f.draw();
|
|
f.panel.cloudFloatTime = 2;
|
|
let hidden = 0; f.panel.showGuide = () => hidden++;
|
|
f.cc.game.emit('show'); f.panel.onDisable();
|
|
assert.equal(f.panel.cloudFloatTime, 2); assert.equal(hidden, 0);
|
|
});
|
|
|
|
test('opening the activity attaches an inactive root throughout asynchronous fragment loading', async () => {
|
|
let release, attached;
|
|
const panel = {
|
|
preparePage: () => new Promise(resolve => { release = resolve; }),
|
|
initialize() { assert.equal(attached.active, false); attached.initialized = true; }
|
|
};
|
|
const service = { sync: async () => true, dismissExpiryPresentation() {}, visible: () => true,
|
|
lockedUntilLevel: () => 0, retainResources: () => () => {}, pagePrefab: async () => ({}),
|
|
unseenResult: () => false, elimination: () => null,
|
|
run: () => ({ status: 'playing' }), stage: () => ({ stage: 1 }), pageFragment: async () => ({}) };
|
|
const cc = { _decorator: { ccclass: c => c }, Component: class {}, isValid: () => true,
|
|
instantiate: () => ({ active: true, getComponent: () => panel }), warn() {} };
|
|
const exports = {};
|
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/Script/CloudRiseView.ts', 'utf8'), {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017, experimentalDecorators: true }
|
|
}).outputText, { exports, cc, console, require: name => name.endsWith('CloudRiseService') ? { default: service }
|
|
: { default: {}, MiniGameSdk: { API: { showToast(message) { assert.fail(message); } } } } });
|
|
const view = new exports.default(); view.node = {}; view.host = { addChild(node) { assert.equal(node.active, false); attached = node; } };
|
|
const task = view.open('progress');
|
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
assert(attached); assert.equal(attached.active, false); assert.equal(attached.initialized, undefined);
|
|
release(); await task; assert.equal(attached.initialized, true);
|
|
});
|