Files
MatchMaster/tools/test-cloud-rise-presentation.cjs
2026-11-02 13:11:34 +08:00

228 lines
15 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 clone = value => value == null ? value : JSON.parse(JSON.stringify(value));
const compiled = new Map();
function fixture(number = 1, status = 'playing', fault = '') {
const storage = new Map(), tasks = [], timers = new Map();
const calls = { loads: 0, opens: 0, destroys: 0, results: 0, releases: 0, warnings: 0, motions: 0, prepared: [] };
let runtime, view, close, continueMotion, mode, timerId = 0;
const stage = { stage: number, status, success_num: status === 'won' ? 5 : 1, target: 5,
pool: 10000, reward: 100, rewardSaved: true, survivors: 99, opponents: [] };
const run = { id: 'run', periodId: 'period', status: status === 'won' ? number === 3 ? 'completed' : 'waiting'
: status === 'lost' ? 'failed' : 'playing', stage: number, expiresAt: Date.now() + 86400000, stages: [stage] };
const snapshot = { run, serverNow: Date.now(), available: false,
period: { enabled: true, periodId: 'period', startsAt: Date.now() - 1000, endsAt: Date.now() + 86400000 } };
const item = { key: `run:${number}:1:${status}`, before: { ...stage, status: 'playing', success_num: 0 }, after: clone(stage) };
storage.set('cloudRise:1001', { snapshot, queue: [], attempt: null, seenResults: [], eliminations: [item] });
const cc = { _decorator: { ccclass: c => c }, Component: class {},
isValid: node => !!node && !node.destroyed, warn() { calls.warnings++; },
director: { once(_event, done) { done(); } }, Director: { EVENT_AFTER_DRAW: 'after-draw' },
fx: { GameConfig: { GM_INFO: { userId: 1001, coin: 100, level: 99 } },
StorageMessage: { getStorage: key => clone(storage.get(key)), setStorage: (key, value) => storage.set(key, clone(value)) } } };
function load(file, imports) {
if (!compiled.has(file)) compiled.set(file, ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017, experimentalDecorators: true }
}).outputText);
const exports = {};
vm.runInNewContext(compiled.get(file), { exports, cc, console,
setTimeout(fn) { const id = ++timerId; timers.set(id, fn); return id; }, clearTimeout(id) { timers.delete(id); },
require: name => { assert(name in imports, name); return imports[name]; } });
return exports.default;
}
const bridge = load('assets/Script/module/Config/CloudRiseService.ts', {
'../Pay/Utils': { default: {} }, './ActivityBundleLoader': require('./activity-loader-test-support.cjs')(cc)
});
bridge.preloadActivity = () => {};
const schedule = { active: () => snapshot.period, hasSchedule: () => true, now: () => Date.now(), cloudRiseReminder: () => null };
const View = load('assets/Script/CloudRiseView.ts', {
'./module/Config/CloudRiseService': { default: bridge }, './module/Config/ActivityScheduleService': { default: schedule },
'./Sdk/MiniGameSdk': { MiniGameSdk: { API: { showToast() {} } } },
'../home_popup_queue/HomePopupQueue': { default: { get: () => ({ canCollect: () => true, canPresent: () => true,
markShown() {}, enqueue(key, priority, show) { tasks.push({ key, show }); } }) } }
});
function boot() {
runtime = load('assets/cloud_rise_home/scripts/CloudRiseRuntime.ts', {
'../../Script/module/Config/CloudRiseService': { default: bridge }, '../../Script/module/Pay/Utils': { default: {} }
});
runtime.sync = async () => true;
view = new View(); view.node = {}; view.entry = {};
view.host = { addChild() {}, getComponent: () => null, getChildByName: () => ({ getComponent: () => view }) };
return view;
}
const fail = phase => { if (fault === phase) throw Error('injected ' + phase); };
bridge.pagePrefab = async () => {
calls.loads++; assert(storage.get('cloudRise:1001').attemptedEliminations?.includes(item.key), 'claim must be durable before loading');
fail('resource'); if (fault === 'timeout') return new Promise(() => {}); return {};
};
bridge.pageFragment = async () => { fail('fragment'); return {}; };
bridge.retainResources = () => () => calls.releases++;
cc.instantiate = () => {
const node = { active: false, destroy() { this.destroyed = true; calls.destroys++; }, getComponent: () => panel };
const panel = {
preparePage: async (value, stageNumber) => { calls.prepared.push({ mode: value, stage: stageNumber }); fail('prepare'); },
initialize(value, inGame, callback) { fail('initialize'); mode = value; close = callback; node.active = true; calls.opens++; },
playElimination: async item => { calls.motions++; calls.lastMotion = item; fail('animation'); return fault !== 'incomplete'; },
beginSettlement(value, callback) { mode = value; close = callback; },
waitForProgressContinue: () => { fail('continue'); return new Promise(resolve => { continueMotion = () => resolve(true); }); },
openResult() { fail('result'); mode = 'result'; calls.results++; },
acknowledge() { if (mode === 'result') runtime.acknowledgeResult(); }
};
return node;
};
boot();
return { cc, calls, bridge, View, storage, tasks, timers, item, boot,
get runtime() { return runtime; }, get view() { return view; }, setFault(value) { fault = value; },
close: () => close(), continueMotion: () => continueMotion(), mode: () => mode };
}
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
for (const number of [1, 2, 3]) for (const fault of ['resource', 'fragment', 'prepare', 'initialize', 'animation', 'incomplete', 'continue', 'timeout']) {
test(`stage ${number}: ${fault} consumes one animation attempt across home refresh and restart`, async () => {
const f = fixture(number, 'playing', fault);
f.view.refresh(); assert.equal(f.tasks.length, 1);
let done = false; f.tasks.shift().show(() => { done = true; }); await flush();
if (fault === 'timeout') { for (const fn of f.timers.values()) fn(); await flush(); }
assert(done); assert.equal(f.runtime.elimination(), null); assert.equal(f.view.overlay, null); assert.equal(f.view.busy, false);
for (let i = 0; i < 100; i++) f.view.refresh();
assert.equal(f.tasks.length, 0); assert.equal(f.calls.loads, 1); assert.equal(f.calls.releases, 1);
f.boot(); f.view.refresh(); assert.equal(f.tasks.length, 0);
assert.equal(f.runtime.beginEliminationPresentation(f.item.key), false);
});
}
for (const status of ['won', 'lost']) for (const number of [1, 2, 3]) {
test(`stage ${number} ${status}: failure preserves manual result and does not block the next level`, async () => {
const f = fixture(number, status, 'animation');
assert.equal(await f.View.continueFromNextLevel(f.view.host), true);
assert(f.runtime.unseenResult()); assert(f.runtime.manualResultPending()); assert(!f.bridge.allowsAutomaticResults());
for (let i = 0; i < 20; i++) { await f.bridge.sync(); f.view.refresh(); }
assert.equal(f.tasks.length, 0); assert(f.view.entry.active);
assert.equal(f.calls.loads, 1); assert.equal(f.calls.results, 0); assert.equal(f.cc.fx.GameConfig.GM_INFO.coin, 100);
f.boot(); f.view.refresh(); assert(f.view.entry.active); assert.equal(f.tasks.length, 0);
f.setFault(''); f.runtime.sync = async () => false;
await f.view.open(); assert.equal(f.mode(), 'result'); assert(f.view.overlay);
f.close(); assert(!f.runtime.unseenResult()); assert(!f.runtime.manualResultPending());
});
}
test('successful ordinary animation remains open until confirmation and a new record is still playable', async () => {
const f = fixture(); const task = f.view.showElimination(); await flush();
assert(f.view.overlay); assert(f.view.busy); assert.equal(f.runtime.elimination(), null);
f.continueMotion(); assert.equal(await task, true); assert.equal(f.view.overlay, null);
const saved = f.storage.get('cloudRise:1001'); saved.eliminations.push({ ...f.item, key: 'run:1:2' });
f.boot(); assert.equal(f.runtime.elimination().key, 'run:1:2'); assert(f.runtime.beginEliminationPresentation('run:1:2'));
});
test('successful terminal animation opens the result and acknowledges only on confirmation', async () => {
const f = fixture(1, 'won'); const task = f.view.showElimination(); await flush();
assert.equal(f.calls.results, 1); assert(f.runtime.unseenResult());
f.close(); assert.equal(await task, true); assert(!f.runtime.unseenResult());
});
test('result transition failure does not reopen a fallback result automatically', async () => {
const f = fixture(3, 'won', 'result');
assert.equal(await f.View.continueFromNextLevel(f.view.host), true);
f.view.refresh(); assert.equal(f.tasks.length, 0); assert(f.runtime.unseenResult()); assert(f.view.entry.active);
});
test('destroying the view during a pending resource request leaves the attempt consumed', async () => {
const f = fixture(1, 'won'); let release;
f.bridge.pagePrefab = () => new Promise(resolve => { release = resolve; });
const task = f.view.showElimination(); await flush(); f.view.node.destroyed = true; release({});
assert.equal(await task, false); assert.equal(f.calls.opens, 0);
f.boot(); f.view.refresh(); assert.equal(f.tasks.length, 0); assert(f.runtime.manualResultPending());
});
test('an already queued automatic result is cancelled when it becomes manual-only', async () => {
const f = fixture(3, 'won'); f.runtime.acknowledgeElimination(f.item.key);
await f.bridge.sync(); f.view.refresh(); assert.equal(f.tasks.length, 1);
assert.equal(f.tasks[0].key, 'cloudRiseResult');
f.runtime.deferResultPresentation(); let done = false;
f.tasks.shift().show(() => { done = true; }); await flush();
assert(done); assert.equal(f.calls.loads, 0); assert.equal(f.view.overlay, null); assert(f.runtime.unseenResult());
});
function backlog(f) {
const saved = f.storage.get('cloudRise:1001'), current = saved.snapshot.run.stages[0];
current.success_num = 5; current.target = 7;
const oldStage = Array.from({ length: 5 }, (_, i) => ({ key: `run:1:${i + 1}`,
before: { ...current, stage: 1, success_num: i, status: 'playing' },
after: { ...current, stage: 1, success_num: i + 1, status: i === 4 ? 'won' : 'playing' } }));
const recent = Array.from({ length: 4 }, (_, i) => ({ key: `run:2:${i + 2}`,
before: { ...current, success_num: i + 1 }, after: { ...current, success_num: i + 2 } }));
saved.eliminations = [...oldStage, f.item, ...recent];
f.boot(); return saved.eliminations.map(item => item.key);
}
for (const fault of ['', 'resource', 'fragment', 'prepare', 'initialize', 'animation', 'incomplete', 'continue', 'timeout']) {
test(`ten queued records produce one attempt, including ${fault || 'successful playback'}`, async () => {
const f = fixture(2, 'playing', fault), keys = backlog(f);
assert.equal(keys.length, 10); assert.equal(f.runtime.elimination().after.stage, 2);
const task = f.View.playElimination(f.view.host); await flush();
if (fault === 'timeout') { for (const fn of f.timers.values()) fn(); await flush(); }
if (!fault) { assert.equal(f.calls.lastMotion.after.success_num, 5); f.continueMotion(); }
await task;
const saved = f.storage.get('cloudRise:1001');
assert.equal(saved.eliminations.length, 0); assert(keys.every(key => saved.attemptedEliminations.includes(key)));
assert.equal(f.calls.loads, 1); assert(f.calls.opens <= 1); assert(f.calls.motions <= 1);
for (let i = 0; i < 20; i++) f.view.refresh(); assert.equal(f.tasks.length, 0);
f.boot(); f.view.refresh(); assert.equal(f.tasks.length, 0);
});
}
test('old-stage backlog displays the new stage statically without replaying old avatars', async () => {
const f = fixture(2); const saved = f.storage.get('cloudRise:1001');
saved.snapshot.run.stages[0].success_num = 0;
saved.eliminations[0].after.stage = 1; saved.eliminations[0].before.stage = 1;
f.boot(); assert(f.runtime.elimination().skipMotion);
const task = f.view.showElimination(); await flush();
assert.equal(f.calls.motions, 0); assert.equal(f.calls.opens, 1);
assert(f.calls.prepared.every(call => call.stage === 2));
f.continueMotion(); assert(await task); assert.equal(f.runtime.elimination(), null);
});
for (const status of ['won', 'lost']) test(`backlog ending in ${status} shows just the latest result`, async () => {
const f = fixture(3, status), saved = f.storage.get('cloudRise:1001');
saved.eliminations.unshift(...Array.from({ length: 9 }, (_, i) => ({ ...clone(f.item), key: `old-run:${i}:1` })));
f.boot(); const task = f.View.playElimination(f.view.host); await flush();
assert.equal(f.calls.opens, 1); assert.equal(f.calls.motions, 1); assert.equal(f.calls.results, 1);
f.close(); await task; assert.equal(f.runtime.elimination(), null); assert(!f.runtime.unseenResult());
});
test('manual opening consumes all pending animations and does not auto-replay after closing', async () => {
const f = fixture(2); backlog(f);
await f.view.open(); assert.equal(f.calls.opens, 1); assert.equal(f.calls.motions, 0);
f.close(); f.view.refresh(); assert.equal(f.tasks.length, 0); assert.equal(f.runtime.elimination(), null);
});
async function lateWin(f) {
const data = clone(f.runtime.data());
data.run.status = 'waiting'; Object.assign(data.run.stages[0], { status: 'won', success_num: 5 });
await f.runtime.accept(data);
}
test('a late terminal response stays in the current overlay instead of opening another popup', async () => {
const f = fixture(); const task = f.View.playElimination(f.view.host); await flush();
await lateWin(f); assert.equal(f.runtime.elimination(), null); assert(f.runtime.manualResultPending());
f.continueMotion(); await flush(); assert.equal(f.calls.opens, 1); assert.equal(f.calls.results, 1);
f.close(); await task; f.view.refresh(); assert.equal(f.tasks.length, 0); assert(!f.runtime.unseenResult());
});
test('a late terminal response after failure is manual-only across restart', async () => {
const f = fixture(1, 'playing', 'animation'); await f.View.playElimination(f.view.host);
await lateWin(f); f.view.refresh(); assert.equal(f.tasks.length, 0); assert(f.runtime.manualResultPending());
f.boot(); f.view.refresh(); assert.equal(f.tasks.length, 0); assert(f.view.entry.active);
assert.equal(f.calls.loads, 1);
});
test('state received during loading replaces stale motion before the first visible frame', async () => {
const f = fixture(); let release;
f.bridge.pagePrefab = () => new Promise(resolve => { release = resolve; });
const task = f.view.showElimination(); await flush(); await lateWin(f); release({}); await flush();
assert.equal(f.calls.opens, 1); assert.equal(f.calls.motions, 0); assert.equal(f.calls.results, 1);
f.close(); assert(await task);
});