test(presentation): verify runtime UI wiring and boundaries

This commit is contained in:
2026-09-05 13:12:35 +08:00
parent d29a659541
commit ec67fe6e6d
3 changed files with 557 additions and 28 deletions
@@ -18,6 +18,7 @@ import {
} from '../../YouleNexus/assets/framework/platform/runtime.ts';
import type { ScenePort } from '../../YouleNexus/assets/framework/platform/scene-port.ts';
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
import { PlatformUiController } from '../../YouleNexus/assets/framework/presentation/platform-ui-controller.ts';
function fixture(name: string): Record<string, unknown> {
return JSON.parse(
@@ -1970,3 +1971,278 @@ test('kick completes close, invalidate, clear, kicked scene, and wire stop despi
PlatformStore.prototype.subscribe = originalStoreSubscribe;
}
});
test('real runtime renders the room before restoring canonical deskinfo', async () => {
const events: string[] = [];
const wire = new RecordingWireClient();
const controller = new PlatformUiController({
render: model => { events.push(`render:${model.page}`); },
activate: page => { events.push(`activate:${page}`); },
showOverlay: model => { events.push(`overlay:${model.kind}`); },
}, { request: () => () => {} }, error => { throw error; });
const deskinfo = { fixture: 'opaque' };
const entry = makeGameEntry({ createModule: () => ({
attach() { events.push('attach'); },
handlePlatformEvent() {}, handleGameMessage() {}, dispose() {},
restore(value) { assert.equal(value, deskinfo); events.push('restore'); },
}) });
const runtime = new PlatformRuntime({
gameEntry: entry, resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire, scene: controller,
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
});
controller.connect(runtime);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
controller.login(ACCOUNT);
wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login',
data: fixture('player-login-success.json') } });
controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
wire.emit({ type: 'message', message: { route: 'agent', rpc: 'self_join_room',
data: { ...fixture('self-join-room.json'), deskinfo } } });
assert.ok(events.includes('render:lobby'));
assert.ok(events.indexOf('attach') < events.indexOf('render:room'));
assert.ok(events.indexOf('render:room') < events.indexOf('activate:room'));
assert.ok(events.indexOf('activate:room') < events.indexOf('restore'));
controller.dispose(); runtime.stop();
assert.equal(wire.stopCalls, 1);
});
test('real runtime keeps canonical roots and raw denials while login and join retries succeed', async () => {
const wire = new RecordingWireClient();
const overlays: unknown[] = [];
const controller = new PlatformUiController({
render: () => {}, activate: () => {},
showOverlay: model => { overlays.push(model); },
}, { request: () => () => {} }, error => { throw error; });
const runtime = new PlatformRuntime({
gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire, scene: controller,
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
});
controller.connect(runtime);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
const preLoginRoot = runtime.state;
controller.login(ACCOUNT);
const loginDenial = { state: 7, showerror: 1, error: 'Login denied' };
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: loginDenial,
} });
assert.equal(runtime.state, preLoginRoot);
const loginOverlay = overlays[overlays.length - 1] as { kind: string; outcome: { data: unknown } };
assert.equal(loginOverlay.kind, 'denial');
assert.equal(loginOverlay.outcome.data, loginDenial);
const retryAccount = { ...ACCOUNT, openid: 'retry-account' };
controller.login(retryAccount);
assert.equal((wire.sent[wire.sent.length - 1]!.data as Record<string, unknown>).openid, 'retry-account');
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
const lobbyRoot = runtime.state;
const firstJoin = { roomcode: '999999', location: null, ip: '127.0.0.1' };
controller.joinRoom(firstJoin);
const joinDenial = { state: 99, roomcode: '999999', showerror: 1, error: 'No room' };
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'self_join_room', data: joinDenial,
} });
assert.equal(runtime.state, lobbyRoot);
const joinOverlay = overlays[overlays.length - 1] as { kind: string; outcome: { data: unknown } };
assert.equal(joinOverlay.kind, 'denial');
assert.equal(joinOverlay.outcome.data, joinDenial);
controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'self_join_room', data: fixture('self-join-room.json'),
} });
assert.equal(runtime.state.room.kind, 'inside');
assert.equal((overlays[overlays.length - 1] as { kind: string }).kind, 'none');
controller.dispose(); runtime.stop();
});
test('real runtime restores reconnect overlay and silences UI after kick or fatal', async () => {
for (const terminal of ['kick', 'fatal'] as const) {
const wire = new RecordingWireClient();
const events: string[] = [];
const controller = new PlatformUiController({
render: model => { events.push(`render:${model.page}`); },
activate: page => { events.push(`activate:${page}`); },
showOverlay: model => { events.push(`overlay:${model.kind}`); },
}, { request: () => () => {} }, error => { throw error; });
const runtime = new PlatformRuntime({
gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire, scene: controller,
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
});
controller.connect(runtime);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
controller.login(ACCOUNT);
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
wire.emit({ type: 'slow' });
assert.equal(events[events.length - 1], 'overlay:reconnect');
const activations = events.filter(event => event.startsWith('activate:')).length;
wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(events[events.length - 1], 'overlay:none');
assert.equal(events.filter(event => event.startsWith('activate:')).length, activations);
if (terminal === 'kick') {
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'kick_server', data: { code: 1, msg: 'terminal' },
} });
assert.equal(events[events.length - 1], 'overlay:kicked');
} else {
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: { state: 0 },
} });
assert.equal(events[events.length - 1], 'overlay:fatal');
}
const terminalEvents = [...events];
assert.throws(() => controller.login(ACCOUNT), /terminal/);
wire.emit({ type: 'slow' });
wire.emit({ type: 'open', server: 'ws://agent' });
assert.deepEqual(events, terminalEvents);
assert.equal(wire.stopCalls, 1);
controller.dispose(); runtime.stop();
}
});
test('real runtime alone governs login readiness while resources are pending', async () => {
const resourcesReady = deferred<void>();
const wire = new RecordingWireClient();
const controller = new PlatformUiController({
render: () => {}, activate: () => {}, showOverlay: () => {},
}, { request: () => () => {} }, error => { throw error; });
const runtime = new PlatformRuntime({
gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire, scene: controller,
loadResources: () => resourcesReady.promise, waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
});
controller.connect(runtime);
const starting = runtime.start();
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(wire.lifecycle, ['subscribe', 'start']);
wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(runtime.ready, false);
assert.throws(() => controller.login(ACCOUNT), /login page/);
assert.equal(wire.sent.length, 0);
resourcesReady.resolve(undefined);
await starting;
assert.equal(runtime.ready, true);
controller.login(ACCOUNT);
assert.equal(wire.sent.length, 1);
controller.dispose(); runtime.stop();
});
for (const phase of ['render', 'activate'] as const) {
test(`real room ${phase} disposal interrupts restore and terminates the runtime`, async () => {
const wire = new RecordingWireClient();
let armed = false;
let restores = 0;
let controller!: PlatformUiController;
controller = new PlatformUiController({
render: model => {
if (armed && phase === 'render' && model.page === 'room') controller.dispose();
},
activate: page => {
if (armed && phase === 'activate' && page === 'room') controller.dispose();
},
showOverlay: () => {},
}, { request: () => () => {} }, error => { throw error; });
const runtime = new PlatformRuntime({
gameEntry: makeGameEntry({ createModule: () => ({
attach() {}, handlePlatformEvent() {}, handleGameMessage() {}, dispose() {},
restore() { restores += 1; },
}) }),
resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire, scene: controller,
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
});
controller.connect(runtime);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
controller.login(ACCOUNT);
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
armed = true;
controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'self_join_room', data: fixture('self-join-room.json'),
} });
assert.equal(restores, 0);
assert.equal(wire.stopCalls, 1);
assert.throws(() => runtime.login(ACCOUNT), error => {
assert.ok(error instanceof Error);
const errors = (error as Error & { readonly errors: readonly unknown[] }).errors;
assert.match(String(errors[0]), /lifecycle interrupted/);
return true;
});
runtime.stop();
});
}
test('application overlay teardown disposes Presenter, stops Runtime, and invalidates a late frame', async () => {
const wire = new RecordingWireClient();
const frames: Array<{ callback: () => void; cancelled: boolean }> = [];
const renders: string[] = [];
let armed = false;
let runtime!: PlatformRuntime;
let controller!: PlatformUiController;
controller = new PlatformUiController({
render: model => { renders.push(model.page); },
activate: () => {},
showOverlay: model => {
if (armed && model.kind === 'reconnect') {
controller.dispose();
runtime.stop();
}
},
}, { request: callback => {
const frame = { callback, cancelled: false };
frames.push(frame);
return () => { frame.cancelled = true; };
} }, error => { throw error; });
runtime = new PlatformRuntime({
gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire, scene: controller,
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
});
controller.connect(runtime);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
controller.login(ACCOUNT);
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
armed = true;
const framesBeforeStop = frames.length;
wire.emit({ type: 'slow' });
assert.equal(frames.length, framesBeforeStop + 1);
const lateFrame = frames[frames.length - 1]!;
assert.equal(lateFrame.cancelled, true);
assert.equal(wire.stopCalls, 1);
const rendered = [...renders];
lateFrame.callback();
wire.emit({ type: 'open', server: 'ws://agent' });
assert.deepEqual(renders, rendered);
assert.throws(() => runtime.login(ACCOUNT), /stopped/);
runtime.stop();
});