feat(ui): restore Logo countdown and gated skip
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as config from '../../YouleNexus/assets/framework/config/remote-config.ts';
|
||||
import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts';
|
||||
|
||||
test('launchWaitTime follows legacy default, hall own field and truthy hierarchy precedence', () => {
|
||||
const resolve = (config as any).resolveLaunchWaitTime;
|
||||
assert.equal(typeof resolve, 'function');
|
||||
for (const [raw, expected] of [
|
||||
[{}, 10], [{ hall_config: { launchWaitTime: 0 } }, 0],
|
||||
[{ hall_config: { launchWaitTime: 4000 }, launchWaitTime: 0 }, 4000],
|
||||
[{ hall_config: { launchWaitTime: 4000 }, launchWaitTime: 2500 }, 2500],
|
||||
[{ launchWaitTime: 2500, agentlist: [{ agentid: BUILD_IDENTITY.agentid, launchWaitTime: 3200 }] }, 3200],
|
||||
] as const) assert.equal(resolve(config.createRemoteConfig(raw, BUILD_IDENTITY)), expected);
|
||||
for (const value of [-1, Infinity, NaN, '5000', false, null, undefined]) {
|
||||
assert.throws(() => resolve(config.createRemoteConfig({ hall_config: { launchWaitTime: value } }, BUILD_IDENTITY)), /launchWaitTime/);
|
||||
}
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import type { PageModel, OverlayModel, PageId } from '../../YouleNexus/assets/framework/presentation/ui-contracts.ts';
|
||||
import type { ResolveRuntimeConfigOptions } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
|
||||
import type { ResolveRuntimeConfigOptions, RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
@@ -31,7 +31,9 @@ class Socket {
|
||||
}
|
||||
async function setup(overrides: { config?: Partial<ResolveRuntimeConfigOptions>; render?: (model: PageModel) => void;
|
||||
cancelFrame?: () => void; showOverlay?: (model: OverlayModel) => void;
|
||||
record?: (event: { kind: string; data: unknown }) => void; onBusy?: (busy: boolean) => void } = {}) {
|
||||
record?: (event: { kind: string; data: unknown }) => void; onBusy?: (busy: boolean) => void;
|
||||
onConfigResolved?: (config: RuntimeConfig) => void; onResourcesReady?: () => void;
|
||||
onSocketReady?: (ready: boolean) => void; onStopped?: () => void } = {}) {
|
||||
const api = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-host.ts')
|
||||
.catch(error => assert.fail(`Local login host implementation must exist: ${String(error)}`));
|
||||
const resources = deferred(), minimum = deferred();
|
||||
@@ -40,6 +42,8 @@ async function setup(overrides: { config?: Partial<ResolveRuntimeConfigOptions>;
|
||||
const values = new Map<string, string>(), frames = new Set<() => void>();
|
||||
let fetches = 0;
|
||||
const host = api.createLocalLoginHost({
|
||||
onConfigResolved: overrides.onConfigResolved, onResourcesReady: overrides.onResourcesReady,
|
||||
onSocketReady: overrides.onSocketReady, onStopped: overrides.onStopped,
|
||||
configOptions: { hostKind: 'h5', win: {}, search: '?profile=local&version=731',
|
||||
fetcher: { async fetch() { fetches++; return { player_server_tcp: 'remote.example:3088' }; } }, ...overrides.config },
|
||||
storage: { getItem: key => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); } },
|
||||
@@ -57,6 +61,57 @@ async function setup(overrides: { config?: Partial<ResolveRuntimeConfigOptions>;
|
||||
return { host, resources, minimum, sockets, pages, overlays, activations, failures, busy, evidence, values, frames, draw, ready, fetches: () => fetches };
|
||||
}
|
||||
|
||||
test('host exposes resolved config and independent socket/resources readiness before display, and stops late events', async () => {
|
||||
const configs: RuntimeConfig[] = [], socket: boolean[] = []; let resources = 0, stopped = 0;
|
||||
const h = await setup({ onConfigResolved: config => configs.push(config), onResourcesReady: () => resources++,
|
||||
onSocketReady: ready => socket.push(ready), onStopped: () => stopped++ });
|
||||
const starting = h.host.start(); await flush();
|
||||
assert.equal(configs.length, 1); assert.equal(configs[0].identity.version, 731);
|
||||
assert.equal(configs[0].remoteConfig.getValue('player_server_tcp'), 'remote.example:3088');
|
||||
h.sockets[0].open(); await flush(); assert.equal(socket[socket.length - 1], true);
|
||||
assert.equal(h.host.getSnapshot().ready, false);
|
||||
h.resources.resolve(); await flush(); assert.equal(resources, 1);
|
||||
assert.equal(h.host.getSnapshot().ready, false);
|
||||
h.minimum.resolve(); await starting; h.host.stop(); assert.equal(stopped, 1);
|
||||
const events = socket.length; h.sockets[0].open(); await flush(); assert.equal(socket.length, events);
|
||||
});
|
||||
|
||||
test('host stop cancels presentation and ignores a resource completion still pending', async () => {
|
||||
let resources = 0, stopped = 0;
|
||||
const h = await setup({ onResourcesReady: () => resources++, onStopped: () => stopped++ });
|
||||
const starting = h.host.start(); const rejection = assert.rejects(starting, /stop|terminal/);
|
||||
await flush(); h.host.stop(); h.resources.resolve(); h.minimum.resolve();
|
||||
await rejection; assert.equal(resources, 0); assert.equal(stopped, 1);
|
||||
});
|
||||
|
||||
for (const order of [
|
||||
['resources', 'socket', 'minimum'], ['resources', 'minimum', 'socket'],
|
||||
['socket', 'resources', 'minimum'], ['socket', 'minimum', 'resources'],
|
||||
['minimum', 'resources', 'socket'], ['minimum', 'socket', 'resources'],
|
||||
] as const) test(`all startup gates remain independent in ${order.join(' -> ')} order`, async t => {
|
||||
const h = await setup(); t.after(() => h.host.stop());
|
||||
const starting = h.host.start(); await flush();
|
||||
for (let index = 0; index < order.length; index++) {
|
||||
const gate = order[index];
|
||||
if (gate === 'socket') h.sockets[0].open(); else h[gate].resolve();
|
||||
await flush();
|
||||
assert.equal(h.host.getSnapshot().ready, index === order.length - 1);
|
||||
assert.equal(h.activations.includes('login'), index === order.length - 1);
|
||||
}
|
||||
await starting; h.draw(); assert.equal(h.activations.includes('login'), true);
|
||||
});
|
||||
|
||||
test('pending config cannot be bypassed by previously completed resource/display tasks', async t => {
|
||||
const config = deferred(); let resolved = 0;
|
||||
const h = await setup({ config: { fetcher: { async fetch() { await config.promise; return {}; } } },
|
||||
onConfigResolved: () => resolved++ });
|
||||
t.after(() => h.host.stop()); const starting = h.host.start();
|
||||
h.resources.resolve(); h.minimum.resolve(); await flush();
|
||||
assert.equal(resolved, 0); assert.equal(h.sockets.length, 0); assert.equal(h.host.getSnapshot().ready, false);
|
||||
config.resolve(); await flush(); assert.equal(resolved, 1); assert.equal(h.host.getSnapshot().ready, false);
|
||||
h.sockets[0].open(); await starting; h.draw(); assert.equal(h.host.getSnapshot().ready, true);
|
||||
});
|
||||
|
||||
for (const missing of ['resources', 'minimum', 'socket'] as const) test(`login waits for ${missing}, and start composes only once`, async t => {
|
||||
const h = await setup(); t.after(() => h.host.stop());
|
||||
const starting = h.host.start(); const repeated = h.host.start();
|
||||
|
||||
@@ -4,18 +4,35 @@ import { readFileSync, existsSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { resolveLaunchWaitTime } from '../../YouleNexus/assets/framework/config/remote-config.ts';
|
||||
|
||||
class Sprite {}
|
||||
class UITransform {}
|
||||
class Vec3 { constructor(public x = 0, public y = 0, public z = 0) {} }
|
||||
class Widget { enabled = true; updateAlignment() {} }
|
||||
class UITransform {
|
||||
width = 100; height = 30; anchorX = 0.5; anchorY = 0.5;
|
||||
convertToWorldSpaceAR(value: Vec3) { return new Vec3(value.x + 250, value.y + 70, value.z); }
|
||||
convertToNodeSpaceAR(value: Vec3) { return new Vec3(value.x - 20, value.y - 10, value.z); }
|
||||
}
|
||||
class ProgressBar { progress = 0; }
|
||||
class Label { string = ''; }
|
||||
class Label {
|
||||
static Overflow = { NONE: 0 }; overflow = 1;
|
||||
string = ''; updateRenderData() {}
|
||||
}
|
||||
class Node extends EventEmitter {
|
||||
static EventType = { TOUCH_END: 'touch-end' };
|
||||
active = true;
|
||||
children: Node[] = [];
|
||||
parent: Node | null = null;
|
||||
position = new Vec3();
|
||||
setPosition(position: Vec3) { this.position = position; }
|
||||
components = new Map<unknown, unknown>();
|
||||
constructor(public name = '') { super(); }
|
||||
getComponent(type: unknown) { return this.components.get(type) ?? null; }
|
||||
getComponentsInChildren(type: unknown): any[] {
|
||||
const own = this.getComponent(type);
|
||||
return [...(own ? [own] : []), ...this.children.flatMap(child => child.getComponentsInChildren(type))];
|
||||
}
|
||||
getChildByPath(path: string): Node | null {
|
||||
const [head, ...tail] = path.split('/');
|
||||
const child = this.children.find(node => node.name === head);
|
||||
@@ -23,6 +40,79 @@ class Node extends EventEmitter {
|
||||
}
|
||||
}
|
||||
const director = new EventEmitter();
|
||||
function countdownFixture() {
|
||||
const { createLogoCountdown } = load();
|
||||
assert.equal(typeof createLogoCountdown, 'function');
|
||||
let now = 0; let id = 0;
|
||||
const timers = new Map<number, { at: number; callback: () => void }>();
|
||||
const states: { visible: boolean; text: string }[] = [];
|
||||
const gate = createLogoCountdown({ now: () => now,
|
||||
schedule: (callback: () => void, ms: number) => {
|
||||
const key = ++id; timers.set(key, { at: now + ms, callback }); return () => timers.delete(key);
|
||||
}, render: (state: { visible: boolean; text: string }) => states.push(state) });
|
||||
const advance = (ms: number) => {
|
||||
const end = now + ms;
|
||||
while (true) {
|
||||
const next = [...timers].filter(([, timer]) => timer.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next) break;
|
||||
now = next[1].at; timers.delete(next[0]); next[1].callback();
|
||||
}
|
||||
now = end;
|
||||
};
|
||||
return { gate, timers, states, advance, last: () => states[states.length - 1]! };
|
||||
}
|
||||
|
||||
test('Logo countdown ticks legacy milliseconds and skip requires config, resource and socket readiness', async () => {
|
||||
const f = countdownFixture(); let complete = false; void f.gate.promise.then(() => { complete = true; });
|
||||
assert.equal(f.last().visible, false);
|
||||
f.gate.skip(); f.gate.start(2500);
|
||||
assert.equal(f.last().text, '倒计时3s');
|
||||
f.advance(1000); assert.equal(f.last().text, '倒计时2s');
|
||||
f.gate.resourcesReady(); assert.equal(f.last().text, '倒计时2s 跳过');
|
||||
f.gate.skip(); await Promise.resolve(); assert.equal(complete, false);
|
||||
f.gate.socketReady(true); await Promise.resolve(); assert.equal(complete, false, 'premature click must not queue');
|
||||
f.gate.skip(); await f.gate.promise;
|
||||
assert.equal(f.last().visible, false); assert.equal(f.timers.size, 0);
|
||||
});
|
||||
|
||||
test('Logo exact deadline is independent from resources and socket; terminal cancellation ignores late events', async () => {
|
||||
const f = countdownFixture(); f.gate.start(10); f.advance(9);
|
||||
assert.equal(f.last().visible, true); f.advance(1); await f.gate.promise;
|
||||
assert.equal(f.last().visible, false); assert.equal(f.timers.size, 0);
|
||||
const cancelled = countdownFixture(); cancelled.gate.start(5000);
|
||||
const rejection = assert.rejects(cancelled.gate.promise, /cancelled/);
|
||||
cancelled.gate.cancel(); await rejection;
|
||||
const count = cancelled.states.length;
|
||||
cancelled.gate.start(9999); cancelled.gate.resourcesReady(); cancelled.gate.socketReady(true); cancelled.gate.skip(); cancelled.advance(10000);
|
||||
assert.equal(cancelled.states.length, count); assert.equal(cancelled.timers.size, 0);
|
||||
});
|
||||
|
||||
test('Logo render failure at completion rejects the gate and clears all timers', async () => {
|
||||
const { createLogoCountdown } = load(); let now = 0; let failRender = false;
|
||||
const callbacks = new Set<() => void>();
|
||||
const gate = createLogoCountdown({ now: () => now,
|
||||
schedule: (callback: () => void) => { callbacks.add(callback); return () => callbacks.delete(callback); },
|
||||
render: () => { if (failRender) throw new Error('label rendering failed'); } });
|
||||
gate.start(2500); failRender = true; now = 2500;
|
||||
const rejection = assert.rejects(gate.promise, /label rendering failed/);
|
||||
assert.doesNotThrow(() => { const callback = [...callbacks][0]; callbacks.delete(callback); callback(); });
|
||||
await rejection; assert.equal(callbacks.size, 0);
|
||||
});
|
||||
|
||||
test('Logo scheduling and tick render failures settle pending display promises', async () => {
|
||||
const { createLogoCountdown } = load();
|
||||
const failed = createLogoCountdown({ now: () => 0, schedule: () => { throw new Error('timer failed'); }, render() {} });
|
||||
const scheduling = assert.rejects(failed.promise, /timer failed/);
|
||||
assert.doesNotThrow(() => failed.start(2500)); await scheduling;
|
||||
let failRender = false; const callbacks = new Set<() => void>();
|
||||
const gate = createLogoCountdown({ now: () => 0,
|
||||
schedule: (callback: () => void) => { callbacks.add(callback); return () => callbacks.delete(callback); },
|
||||
render: () => { if (failRender) throw new Error('tick rendering failed'); } });
|
||||
gate.start(2500); failRender = true;
|
||||
const rendering = assert.rejects(gate.promise, /tick rendering failed/);
|
||||
assert.doesNotThrow(() => { const callback = [...callbacks][1]; callbacks.delete(callback); callback(); });
|
||||
await rendering; assert.equal(callbacks.size, 0);
|
||||
});
|
||||
const source = new URL('../../YouleNexus/assets/scripts/local-platform-login/local-login-view.ts', import.meta.url);
|
||||
function load() {
|
||||
assert.ok(existsSync(source), 'Cocos local login view implementation must exist');
|
||||
@@ -31,10 +121,26 @@ function load() {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText, { exports, require: (name: string) => {
|
||||
assert.equal(name, 'cc');
|
||||
return { Node, Sprite, UITransform, Label, director, Director: { EVENT_AFTER_DRAW: 'draw' } };
|
||||
return { Node, Sprite, UITransform, Label, Widget, Vec3, director, Director: { EVENT_AFTER_DRAW: 'draw' } };
|
||||
}, Error });
|
||||
return exports as any;
|
||||
}
|
||||
|
||||
test('Logo native binding measures label around button center and releases its touch listener', () => {
|
||||
const { CocosLogoCountdownView } = load(); assert.equal(typeof CocosLogoCountdownView, 'function');
|
||||
const button = new Node('Spirit3103'), text = new Node('Spirit3104'), parent = new Node('group');
|
||||
text.parent = parent;
|
||||
for (const node of [button, text, parent]) node.components.set(UITransform, new UITransform());
|
||||
const label = new Label(); text.components.set(Label, label);
|
||||
const widget = new Widget(); text.components.set(Widget, widget);
|
||||
let clicks = 0; const view = new CocosLogoCountdownView(button, text, () => clicks++);
|
||||
assert.equal(button.active, false); assert.equal(text.active, false);
|
||||
view.render({ visible: true, text: '倒计时3s 跳过' });
|
||||
assert.equal(label.string, '倒计时3s 跳过'); assert.equal(label.overflow, Label.Overflow.NONE);
|
||||
assert.equal(widget.enabled, false); assert.equal(text.position.x, 230); assert.equal(text.position.y, 60);
|
||||
button.emit('touch-end'); assert.equal(clicks, 1);
|
||||
view.dispose(); assert.equal(button.listenerCount('touch-end'), 0); assert.equal(button.active, false);
|
||||
});
|
||||
function fixture() {
|
||||
const refs = { loadingRoot: new Node(), loginRoot: new Node(), resultRoot: new Node(),
|
||||
statusOverlay: new Node(), statusLabel: new Label(), resultLabel: new Label() };
|
||||
@@ -155,7 +261,8 @@ test('component rejects non-debug/native before creating host or writing browser
|
||||
_decorator: { ccclass: decorator, property: () => () => {} } };
|
||||
if (name.endsWith('local-login-host.ts')) return { createLocalLoginHost: () => { hosts++; } };
|
||||
if (name.endsWith('local-login-view.ts')) return load();
|
||||
if (name.endsWith('remote-config-fetcher.ts')) return { HttpConfigFetcher: class {} };
|
||||
if (name.endsWith('remote-config.ts')) return { resolveLaunchWaitTime };
|
||||
if (name.endsWith('remote-config-fetcher.ts')) return { HttpConfigFetcher: class {} };
|
||||
throw new Error(`Unexpected dependency ${name}`);
|
||||
}, Error });
|
||||
const component = new exports.LocalPlatformLogin();
|
||||
@@ -175,6 +282,7 @@ test('component stop cancels pending draw and timer work', async () => {
|
||||
_decorator: { ccclass: () => (target: unknown) => target, property: () => () => {} } };
|
||||
if (name.endsWith('local-login-host.ts')) return {};
|
||||
if (name.endsWith('local-login-view.ts')) return load();
|
||||
if (name.endsWith('remote-config.ts')) return { resolveLaunchWaitTime };
|
||||
if (name.endsWith('remote-config-fetcher.ts')) return {};
|
||||
throw new Error(`Unexpected dependency ${name}`);
|
||||
}, Error, performance: { now: () => 123 }, window: {
|
||||
@@ -203,6 +311,7 @@ test('minimum display ignores an early timer wake and cancellation clears its re
|
||||
hostOptions = options; return { start: async () => {}, stop() {}, getSnapshot: () => ({}) };
|
||||
} };
|
||||
if (name.endsWith('local-login-view.ts')) return load();
|
||||
if (name.endsWith('remote-config.ts')) return { resolveLaunchWaitTime };
|
||||
if (name.endsWith('remote-config-fetcher.ts')) return { HttpConfigFetcher: class {} };
|
||||
throw new Error(`Unexpected dependency ${name}`);
|
||||
}, Error, performance: { now: () => clock }, window: {
|
||||
@@ -210,15 +319,18 @@ test('minimum display ignores an early timer wake and cancellation clears its re
|
||||
setTimeout: (callback: () => void) => { timers.set(++timerId, callback); return timerId; },
|
||||
clearTimeout: (id: number) => timers.delete(id),
|
||||
} });
|
||||
async function started() {
|
||||
async function started(emitDraw = true) {
|
||||
const component = new exports.LocalPlatformLogin(); const f = fixture();
|
||||
Object.assign(component, f.refs, { stopButton: new Node(), loadingPrefab: {}, loginPrefab: {} });
|
||||
const logo = new Node('Layer1_Logo'); const group = new Node('group-1');
|
||||
const bar = new Node('Spirit254'); bar.components.set(ProgressBar, new ProgressBar());
|
||||
group.children.push(bar, new Node('Spirit3103'), new Node('Spirit3104'));
|
||||
const button = new Node('Spirit3103'), text = new Node('Spirit3104');
|
||||
for (const node of [button, text, group]) node.components.set(UITransform, new UITransform());
|
||||
const label = new Label() as Label & { node: Node }; label.node = text; text.components.set(Label, label); text.parent = group;
|
||||
group.children.push(bar, button, text);
|
||||
logo.children.push(group, new Node('group-32')); component.loadingRoot.children.push(logo);
|
||||
component.loadingRoot.getComponentsInChildren = () => [];
|
||||
await component.start(); director.emit('draw'); await Promise.resolve();
|
||||
await component.start(); if (emitDraw) director.emit('draw'); await Promise.resolve();
|
||||
return component;
|
||||
}
|
||||
const drain = async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); };
|
||||
@@ -227,6 +339,7 @@ test('minimum display ignores an early timer wake and cancellation clears its re
|
||||
timers.delete(id); callback();
|
||||
}
|
||||
const component = await started(); let completed = false;
|
||||
hostOptions.onConfigResolved({ identity: { version: 731 }, remoteConfig: { getValue: (name: string) => name === 'launchWaitTime' ? 500 : null } });
|
||||
const minimum = hostOptions.waitForMinimumDisplay().then(() => { completed = true; });
|
||||
await drain(); wake(599.8); await drain();
|
||||
assert.equal(completed, false, '499.8 ms elapsed must not open minimum display gate');
|
||||
@@ -236,9 +349,16 @@ test('minimum display ignores an early timer wake and cancellation clears its re
|
||||
component.stop();
|
||||
|
||||
clock = 1000; const cancelled = await started();
|
||||
hostOptions.onConfigResolved({ identity: { version: 731 }, remoteConfig: { getValue: (name: string) => name === 'launchWaitTime' ? 500 : null } });
|
||||
const cancelledMinimum = hostOptions.waitForMinimumDisplay();
|
||||
const rejection = assert.rejects(cancelledMinimum, /cancelled/);
|
||||
await drain(); wake(1499.8); await drain();
|
||||
assert.equal(timers.size, 1); cancelled.stop(); await rejection;
|
||||
assert.equal(timers.size, 0); assert.equal(cancelled.getDiagnostics().pendingCount, 0);
|
||||
|
||||
const undrawn = await started(false); let zeroComplete = false;
|
||||
hostOptions.onConfigResolved({ identity: { version: 731 }, remoteConfig: { getValue: (name: string) => name === 'hall_config' ? { launchWaitTime: 0 } : null } });
|
||||
const zeroMinimum = hostOptions.waitForMinimumDisplay().then(() => { zeroComplete = true; });
|
||||
await drain(); assert.equal(zeroComplete, false, 'zero display deadline cannot bypass first native frame');
|
||||
director.emit('draw'); await zeroMinimum; assert.equal(zeroComplete, true); undrawn.stop();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user