feat(presentation): wire platform scenes and UI commands

This commit is contained in:
2026-09-05 12:47:15 +08:00
parent 8b7efa7de0
commit d29a659541
4 changed files with 860 additions and 16 deletions
@@ -261,3 +261,106 @@ test('does not swallow an onFault callback error', () => {
renderer.request();
assert.equal(callbacks.length, 1);
});
test('registration reentering flush cancels the superseded handle without blocking the next frame', () => {
const callbacks: (() => void)[] = [];
let cancelled = 0;
let rendered = 0;
const renderer = new FrameRenderer({ request(callback) {
callbacks.push(callback);
if (callbacks.length === 1) renderer.flush();
return () => { cancelled += 1; };
} }, () => { rendered += 1; }, error => { throw error; });
renderer.request();
assert.equal(rendered, 1);
assert.equal(cancelled, 1);
callbacks[0](); assert.equal(rendered, 1);
renderer.request();
assert.equal(callbacks.length, 2);
callbacks[1](); assert.equal(rendered, 2);
});
test('registration reentering dispose immediately cancels its returned handle once', () => {
const callbacks: (() => void)[] = [];
let cancelled = 0;
let rendered = 0;
const renderer = new FrameRenderer({ request(callback) {
callbacks.push(callback); renderer.dispose();
return () => { cancelled += 1; };
} }, () => { rendered += 1; }, error => { throw error; });
renderer.request(); renderer.dispose();
assert.equal(cancelled, 1);
callbacks[0]();
assert.equal(rendered, 0);
renderer.request(); assert.equal(callbacks.length, 1);
});
test('a reentrant request during registration coalesces with the acquiring frame', () => {
const callbacks: (() => void)[] = [];
let rendered = 0;
const renderer = new FrameRenderer({ request(callback) {
callbacks.push(callback);
if (callbacks.length === 1) renderer.request();
return () => {};
} }, () => { rendered += 1; }, error => { throw error; });
renderer.request();
assert.equal(callbacks.length, 1);
callbacks[0](); assert.equal(rendered, 1);
});
test('registration failure releases the reservation and invalidates its captured callback', () => {
const failure = new Error('request failed');
const callbacks: (() => void)[] = [];
let rendered = 0;
const renderer = new FrameRenderer({ request(callback) {
callbacks.push(callback);
if (callbacks.length === 1) throw failure;
return () => {};
} }, () => { rendered += 1; }, error => { throw error; });
assert.throws(() => renderer.request(), error => error === failure);
renderer.request(); assert.equal(callbacks.length, 2);
callbacks[0](); assert.equal(rendered, 0);
callbacks[1](); assert.equal(rendered, 1);
});
test('superseded registration preserves the successor scheduled by its synchronous flush', () => {
const callbacks: (() => void)[] = [];
const cancelled: number[] = [];
let rendered = 0;
const renderer = new FrameRenderer({ request(callback) {
const index = callbacks.length;
callbacks.push(callback);
if (index === 0) renderer.flush();
return () => { cancelled.push(index); };
} }, () => {
rendered += 1;
if (rendered === 1) renderer.request();
}, error => { throw error; });
renderer.request();
assert.deepEqual(cancelled, [0]);
assert.equal(callbacks.length, 2);
callbacks[0](); assert.equal(rendered, 1);
renderer.flush();
assert.deepEqual(cancelled, [0, 1]);
assert.equal(rendered, 2);
callbacks[1](); assert.equal(rendered, 2);
});
test('superseded handle cancellation errors propagate without reviving stale callbacks', () => {
const failure = new Error('late handle cancellation');
const callbacks: (() => void)[] = [];
let rendered = 0;
const renderer = new FrameRenderer({ request(callback) {
callbacks.push(callback); renderer.dispose();
return () => { throw failure; };
} }, () => { rendered += 1; }, error => { throw error; });
assert.throws(() => renderer.request(), error => error === failure);
callbacks[0](); renderer.dispose(); renderer.request();
assert.equal(rendered, 0);
assert.equal(callbacks.length, 1);
});
@@ -0,0 +1,513 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PlatformUiController } from '../../YouleNexus/assets/framework/presentation/platform-ui-controller.ts';
import type { FrameScheduler, OverlayModel, PageModel, PlatformViewPort, UiRuntime } from '../../YouleNexus/assets/framework/presentation/ui-contracts.ts';
import type { PlatformState } from '../../YouleNexus/assets/framework/platform/stores/platform-types.ts';
import type { ServerDenial } from '../../YouleNexus/assets/framework/platform/runtime-session.ts';
const account = { openid: 'o1', nickname: 'name', avatar: '', sex: 0,
province: '', city: '', unionid: 'u1' };
function loggedInState(): PlatformState {
return {
app: { phase: 'logged-in' },
players: { selfPlayerId: 7, entities: {
7: { playerid: 7, nickname: 'Alice', avatar: 'avatar', sex: 0, bean: 10,
onstate: 0, isprepare: 0 },
} },
room: { kind: 'inside', roomcode: ' R ', selfSeat: 0,
seatPlayerIds: [7, null], readySeats: [], offlineSeats: [], roomtype: [],
stage: 0, needprepare: 1, infinite: 0 },
};
}
function errorLeaves(error: unknown): unknown[] {
if (error instanceof Error && 'errors' in error && Array.isArray(error.errors)) {
return error.errors.flatMap(errorLeaves);
}
return [error];
}
function fixture(options: {
view?: Partial<PlatformViewPort>;
scheduler?: FrameScheduler;
onFault?: (error: unknown) => void;
subscribe?: UiRuntime['subscribeState'];
} = {}) {
let state = loggedInState();
let ready = true;
let listener: ((state: PlatformState) => void) | null = null;
let listenerError: ((error: unknown) => void) | null = null;
let unsubscribeCount = 0;
const events: string[] = [];
const models: PageModel[] = [];
const overlays: OverlayModel[] = [];
const commands: unknown[] = [];
const faults: unknown[] = [];
const frames: { callback: () => void; cancelled: boolean }[] = [];
const runtime: UiRuntime = {
get state() { events.push('read'); return state; },
get ready() { return ready; },
subscribeState: options.subscribe ?? ((next, error) => {
events.push('subscribe'); listener = next; listenerError = error;
return () => { unsubscribeCount += 1; };
}),
login: value => { commands.push(value); },
joinRoom: value => { commands.push(value); },
prepare: () => { commands.push('prepare'); },
exitRoom: () => { commands.push('exit'); },
};
const controller = new PlatformUiController({
render: model => { models.push(model); events.push(`render:${model.page}`); options.view?.render?.(model); },
activate: page => { events.push(`activate:${page}`); options.view?.activate?.(page); },
showOverlay: model => { overlays.push(model); events.push(`overlay:${model.kind}`); options.view?.showOverlay?.(model); },
}, options.scheduler ?? { request: callback => {
const frame = { callback, cancelled: false }; frames.push(frame);
return () => { frame.cancelled = true; };
} }, options.onFault ?? (error => { faults.push(error); }));
return {
controller, runtime, events, models, overlays, commands, faults, frames,
setState(next: PlatformState) { state = next; },
setReady(value: boolean) { ready = value; },
emit() {
assert.ok(listener);
try { listener(state); } catch (error) {
assert.ok(listenerError); listenerError(error);
}
},
emitError(error: unknown) { assert.ok(listenerError); listenerError(error); },
get unsubscribes() { return unsubscribeCount; },
};
}
test('login forwards the exact account only after the runtime opens login', () => {
const sent: unknown[] = [];
const runtime: UiRuntime = {
state: { app: { phase: 'connected' },
players: { selfPlayerId: null, entities: {} }, room: { kind: 'outside' } },
ready: true, subscribeState: () => () => {},
login: account => { sent.push(account); }, joinRoom: () => {},
prepare: () => {}, exitRoom: () => {},
};
const events: string[] = [];
const controller = new PlatformUiController({
render: model => { events.push(`render:${model.page}`); },
activate: page => { events.push(`activate:${page}`); }, showOverlay: () => {},
}, { request: () => () => {} }, error => { throw error; });
controller.connect(runtime);
const account = { openid: 'o1', nickname: 'name', avatar: '', sex: 0,
province: '', city: '', unionid: 'u1' };
assert.throws(() => controller.login(account), /login/);
controller.showLogin();
controller.login(account);
assert.equal(sent[0], account);
assert.deepEqual(events.slice(-2), ['render:login', 'activate:login']);
controller.dispose();
assert.throws(() => controller.login(account), /disposed/);
});
test('connect subscribes before reading and waits for ScenePort to choose a page', () => {
const f = fixture();
assert.throws(() => f.controller.showLogin(), /connected/);
f.controller.connect(f.runtime);
assert.deepEqual(f.events, ['subscribe', 'read']);
f.emit();
assert.equal(f.models.length, 0);
assert.equal(f.frames.length, 0);
assert.throws(() => f.controller.connect(f.runtime), /connect/);
f.controller.showLoading();
assert.deepEqual(f.models, [{ page: 'loading' }]);
});
test('login uses ready alone and forwards runtime command failures unchanged', () => {
const f = fixture();
f.controller.connect(f.runtime);
f.controller.showLogin();
f.setReady(false);
assert.throws(() => f.controller.login(account), /ready/);
assert.equal(f.commands.length, 0);
f.setReady(true);
f.setState({ ...loggedInState(), app: { phase: 'reconnecting' } });
f.controller.login(account);
assert.equal(f.commands[0], account);
const failure = new Error('Wire rejects login');
f.runtime.login = () => { throw failure; };
assert.throws(() => f.controller.login(account), error => error === failure);
});
test('joinRoom forwards the complete command identity only on the lobby page', () => {
const f = fixture();
const command = { roomcode: ' 123 X ', ip: 'ip', location: 'location' };
f.controller.connect(f.runtime);
assert.throws(() => f.controller.joinRoom(command), /lobby/);
f.controller.showLobby();
f.controller.joinRoom(command);
assert.equal(f.commands[0], command);
});
test('prepare and exit consult the latest room capabilities before the next frame', () => {
const f = fixture();
f.controller.connect(f.runtime);
assert.throws(() => f.controller.prepare(), /room/);
assert.throws(() => f.controller.exitRoom(), /room/);
f.controller.showRoom();
f.controller.prepare();
f.controller.exitRoom();
assert.deepEqual(f.commands, ['prepare', 'exit']);
const state = loggedInState();
assert.equal(state.room.kind, 'inside');
f.setState({ ...state, app: { phase: 'reconnecting' } });
assert.throws(() => f.controller.prepare(), /prepare/);
assert.throws(() => f.controller.exitRoom(), /exit/);
f.setState({ ...state, room: { ...state.room, stage: 1 } });
assert.throws(() => f.controller.prepare(), /prepare/);
assert.throws(() => f.controller.exitRoom(), /exit/);
f.setState({ ...state, room: { ...state.room, stage: 1, infinite: 1 } });
f.controller.exitRoom();
assert.deepEqual(f.commands, ['prepare', 'exit', 'exit']);
});
test('state notifications merge into one frame reading the latest runtime snapshot', () => {
const f = fixture();
f.controller.connect(f.runtime);
f.controller.showLobby();
f.emit(); f.emit();
assert.equal(f.frames.length, 1);
const state = loggedInState();
f.setState({ ...state, players: { ...state.players, entities: {
7: { ...state.players.entities[7], nickname: 'Latest' },
} } });
f.frames[0].callback();
assert.equal(f.models.length, 2);
assert.equal(f.models[1].page, 'lobby');
assert.equal(f.models[1].self.nickname, 'Latest');
assert.deepEqual(f.events.filter(value => value.startsWith('activate')), ['activate:lobby']);
});
test('showRoom cancels the prior frame and completes render then activation synchronously', () => {
const f = fixture();
f.controller.connect(f.runtime);
f.controller.showLobby(); f.emit();
f.events.length = 0;
f.controller.showRoom();
f.events.push('restore');
assert.equal(f.frames[0].cancelled, true);
assert.deepEqual(f.events.filter(value => value !== 'read'), ['render:room', 'activate:room', 'restore']);
f.frames[0].callback();
assert.equal(f.models.length, 2);
});
test('denial retains the full outcome and data identities and permits a successful retry', () => {
const f = fixture();
const denial: ServerDenial = { rpc: 'self_join_room', state: 9, data: { state: 9, detail: { reason: 'full' } } };
const command = { roomcode: ' room ', ip: 'ip', location: { lat: 1 } };
f.controller.connect(f.runtime); f.controller.showLobby();
f.controller.showServerDenial(denial);
const overlay = f.overlays[f.overlays.length - 1];
assert.equal(overlay?.kind, 'denial');
assert.equal(overlay.outcome, denial);
assert.equal(overlay.outcome.data, denial.data);
f.controller.joinRoom(command);
assert.equal(f.commands[0], command);
f.controller.showRoom();
assert.deepEqual(f.overlays[f.overlays.length - 1], { kind: 'none' });
});
test('dismissDenial clears only denial and a successful login clears its denial', () => {
const f = fixture();
f.controller.connect(f.runtime); f.controller.showLogin();
const denial: ServerDenial = { rpc: 'player_login', state: 2, data: { state: 2 } };
f.controller.showServerDenial(denial); f.controller.dismissDenial();
assert.deepEqual(f.overlays[f.overlays.length - 1], { kind: 'none' });
f.controller.showReconnect(); f.controller.dismissDenial();
assert.deepEqual(f.overlays[f.overlays.length - 1], { kind: 'reconnect' });
f.controller.showServerDenial(denial); f.controller.showLobby();
assert.deepEqual(f.overlays[f.overlays.length - 1], { kind: 'none' });
});
for (const phase of ['connected', 'logged-in'] as const) {
test(`${phase} clears reconnect without navigating or removing denial`, () => {
const f = fixture();
f.controller.connect(f.runtime); f.controller.showLogin();
f.setState({ ...loggedInState(), app: { phase: 'reconnecting' } });
f.controller.showReconnect();
f.setState({ ...loggedInState(), app: { phase } }); f.emit();
assert.deepEqual(f.overlays[f.overlays.length - 1], { kind: 'none' });
assert.deepEqual(f.events.filter(value => value.startsWith('activate')), ['activate:login']);
const denial: ServerDenial = { rpc: 'player_login', state: 2, data: { state: 2 } };
f.controller.showServerDenial(denial); f.emit();
assert.equal(f.overlays[f.overlays.length - 1]?.kind, 'denial');
});
}
test('kick terminates commands and late updates while the first fatal may replace it', () => {
const f = fixture();
f.controller.connect(f.runtime); f.controller.showLobby(); f.emit();
const data = { reason: 'another login' };
f.controller.showKicked(data);
assert.deepEqual(f.overlays[f.overlays.length - 1], { kind: 'kicked', data });
assert.equal(f.unsubscribes, 1);
assert.equal(f.frames[0].cancelled, true);
const count = f.events.length;
f.emit(); f.frames[0].callback(); f.controller.showLogin(); f.controller.showReconnect();
f.controller.showServerDenial({ rpc: 'player_login', state: 1, data: {} });
assert.equal(f.events.length, count);
for (const command of [() => f.controller.login(account),
() => f.controller.joinRoom({ roomcode: 'x', ip: '', location: null }),
() => f.controller.prepare(), () => f.controller.exitRoom(), () => f.controller.dismissDenial()]) {
assert.throws(command, /terminal/);
}
const error = new Error('fatal');
f.controller.showFatal(error);
assert.deepEqual(f.overlays[f.overlays.length - 1], { kind: 'fatal', error });
const terminalCount = f.events.length;
f.controller.showFatal(new Error('late')); f.controller.showKicked({});
f.emit(); f.frames[0].callback();
assert.equal(f.events.length, terminalCount);
});
for (const phase of ['render', 'activate', 'showOverlay'] as const) {
test(`Presenter disposal during ${phase} interrupts showRoom before caller restore`, () => {
let armed = false;
const f = fixture({ view: { [phase]: () => { if (armed) f.controller.dispose(); } } });
f.controller.connect(f.runtime); f.controller.showLobby();
f.controller.showReconnect(); armed = true;
let restored = false;
assert.throws(() => { f.controller.showRoom(); restored = true; }, /interrupted|disposed/);
assert.equal(restored, false);
if (phase === 'render') assert.equal(f.events.includes('activate:room'), false);
const count = f.events.length;
f.controller.showFatal(new Error('late')); f.controller.showRoom(); f.emit();
assert.equal(f.events.length, count);
assert.equal(f.unsubscribes, 1);
});
}
test('synchronous render, activation, and overlay failures propagate unchanged', () => {
for (const phase of ['render', 'activate', 'showOverlay'] as const) {
const failure = new Error(phase);
const f = fixture({ view: { [phase]: () => { throw failure; } } });
f.controller.connect(f.runtime);
assert.throws(() => phase === 'showOverlay' ? f.controller.showReconnect() : f.controller.showRoom(),
error => error === failure);
assert.equal(f.faults.length, 0);
if (phase === 'render') assert.equal(f.events.includes('activate:room'), false);
}
});
test('frame and subscription faults stop updates but still permit the first fatal overlay', () => {
for (const origin of ['frame', 'subscription'] as const) {
let fail = false;
const failure = new Error(origin);
const f = fixture({ view: { render: () => { if (fail) throw failure; } } });
f.controller.connect(f.runtime); f.controller.showLobby(); f.emit();
fail = true;
if (origin === 'frame') f.frames[0].callback(); else f.emitError(failure);
assert.deepEqual(f.faults, [failure]);
assert.equal(f.unsubscribes, 1);
const count = f.models.length;
f.emit(); f.frames[0].callback();
assert.equal(f.models.length, count);
f.controller.showFatal(failure);
assert.deepEqual(f.overlays[f.overlays.length - 1], { kind: 'fatal', error: failure });
}
});
test('dispose attempts both cancellations once even when frame and subscription cleanup throw', () => {
const cancelError = new Error('cancel frame');
const unsubscribeError = new Error('unsubscribe');
const cleanups: string[] = [];
const f = fixture({
scheduler: { request: () => () => { cleanups.push('frame'); f.controller.dispose(); throw cancelError; } },
});
const subscribe = f.runtime.subscribeState;
f.runtime.subscribeState = (listener, onError) => {
const cancel = subscribe(listener, onError);
return () => { cancel(); cleanups.push('subscription'); f.controller.dispose(); throw unsubscribeError; };
};
f.controller.connect(f.runtime); f.controller.showLobby();
f.emit();
assert.throws(() => f.controller.dispose(), error => {
assert.deepEqual(errorLeaves(error), [cancelError, unsubscribeError]); return true;
});
assert.equal(f.unsubscribes, 1);
f.controller.dispose();
assert.deepEqual(cleanups, ['frame', 'subscription']);
});
test('terminal cleanup failures still attempt the fatal overlay and preserve every error', () => {
const unsubscribeError = new Error('unsubscribe');
const overlayError = new Error('overlay');
const original = new Error('fatal cause');
const f = fixture({ subscribe: () => () => { throw unsubscribeError; },
view: { showOverlay: () => { throw overlayError; } } });
f.controller.connect(f.runtime); f.controller.showLobby();
assert.throws(() => f.controller.showFatal(original), error => {
assert.deepEqual(errorLeaves(error), [unsubscribeError, overlayError]); return true;
});
assert.deepEqual(f.overlays[f.overlays.length - 1], { kind: 'fatal', error: original });
});
test('fatal during kick cleanup cannot be overwritten by the outer kick', () => {
const fatal = new Error('cleanup fatal');
const f = fixture({ subscribe: () => () => { f.controller.showFatal(fatal); } });
f.controller.connect(f.runtime); f.controller.showLobby();
f.controller.showKicked({ reason: 'kick' });
assert.deepEqual(f.overlays, [{ kind: 'fatal', error: fatal }]);
});
test('onFault receives original and cleanup errors and its own failure remains observable', () => {
const renderError = new Error('render');
const cleanupError = new Error('cleanup');
const callbackError = new Error('onFault');
let frame: (() => void) | undefined;
let armed = false;
const delivered: unknown[] = [];
const f = fixture({
scheduler: { request: callback => { frame = callback; return () => {}; } },
view: { render: () => { if (armed) throw renderError; } },
onFault: error => { delivered.push(error); f.controller.dispose(); throw callbackError; },
});
const subscribe = f.runtime.subscribeState;
f.runtime.subscribeState = (listener, onError) => {
const cancel = subscribe(listener, onError);
return () => { cancel(); throw cleanupError; };
};
f.controller.connect(f.runtime); f.controller.showLobby();
f.emit(); assert.ok(frame); armed = true;
assert.throws(() => frame!(), error => {
assert.deepEqual(errorLeaves(error), [renderError, cleanupError, callbackError]); return true;
});
assert.equal(delivered.length, 1);
assert.deepEqual(errorLeaves(delivered[0]), [renderError, cleanupError]);
});
test('a render that disposes and then throws still delivers its original frame fault', () => {
const failure = new Error('render after disposal');
let armed = false;
const f = fixture({ view: { render: () => { if (armed) { f.controller.dispose(); throw failure; } } } });
f.controller.connect(f.runtime); f.controller.showLobby(); f.emit(); armed = true;
f.frames[0].callback();
assert.deepEqual(f.faults, [failure]);
});
test('subscription registration disposal releases the returned handle and interrupts connect', () => {
let cancellations = 0;
const f = fixture({ subscribe: () => {
f.controller.dispose(); return () => { cancellations += 1; };
} });
assert.throws(() => f.controller.connect(f.runtime), /interrupted|disposed/);
assert.equal(cancellations, 1);
assert.equal(f.events.includes('read'), false);
});
test('a throwing initial state read releases the subscription and forbids later commands', () => {
const failure = new Error('initial snapshot');
const f = fixture();
Object.defineProperty(f.runtime, 'state', { get: () => { throw failure; } });
assert.throws(() => f.controller.connect(f.runtime), error => error === failure);
assert.equal(f.unsubscribes, 1);
assert.throws(() => f.controller.login(account), /terminal|disposed/);
});
test('scheduler registration disposal cancels the newly returned frame handle once', () => {
let cancellations = 0;
const f = fixture({ scheduler: { request: () => {
f.controller.dispose(); return () => { cancellations += 1; };
} } });
f.controller.connect(f.runtime); f.controller.showLobby(); f.emit();
assert.equal(cancellations, 1);
assert.equal(f.unsubscribes, 1);
f.controller.dispose(); assert.equal(cancellations, 1);
});
test('nested ScenePort navigation prevents activation of the superseded page', () => {
let armed = false;
const f = fixture({ view: { render: model => {
if (armed && model.page === 'room') f.controller.showLogin();
} } });
f.controller.connect(f.runtime); f.controller.showLobby(); armed = true;
assert.throws(() => f.controller.showRoom(), /interrupted/);
assert.equal(f.events.includes('activate:room'), false);
assert.equal(f.events[f.events.length - 1], 'activate:login');
});
test('scheduler request errors use the required subscription error channel', () => {
const failure = new Error('scheduler request');
const f = fixture({ scheduler: { request: () => { throw failure; } } });
f.controller.connect(f.runtime); f.controller.showLobby(); f.emit();
assert.deepEqual(f.faults, [failure]);
assert.equal(f.unsubscribes, 1);
});
test('subscription overlay disposal prevents a subsequent frame request', () => {
let armed = false;
const f = fixture({ view: { showOverlay: () => { if (armed) f.controller.dispose(); } } });
f.controller.connect(f.runtime); f.controller.showLogin();
f.controller.showReconnect(); armed = true; f.emit();
assert.equal(f.frames.length, 0);
assert.equal(f.unsubscribes, 1);
});
test('a synchronous frame cancellation error prevents room activation and caller restore', () => {
const failure = new Error('cancel pending frame');
const f = fixture({ scheduler: { request: () => () => { throw failure; } } });
f.controller.connect(f.runtime); f.controller.showLobby(); f.emit();
let restored = false;
assert.throws(() => { f.controller.showRoom(); restored = true; }, error => error === failure);
assert.equal(restored, false);
assert.equal(f.events.includes('activate:room'), false);
assert.deepEqual(f.faults, []);
});
test('Presenter disposal cancels UI work while Runtime lifecycle stays with the outer owner', () => {
const f = fixture();
let stopped = false;
const runtime = Object.assign(f.runtime, {
start: () => { throw new Error('Presenter must not start Runtime'); },
stop: () => { stopped = true; },
});
f.controller.connect(runtime); f.controller.showLobby(); f.emit();
f.controller.dispose();
assert.equal(stopped, false);
runtime.stop();
const count = f.models.length;
f.frames[0].callback(); f.emit();
assert.equal(f.models.length, count);
assert.equal(stopped, true);
});
test('terminal events during room render interrupt the transition before activation', () => {
for (const kind of ['kicked', 'fatal'] as const) {
let armed = false;
const failure = new Error('fatal');
const f = fixture({ view: { render: () => {
if (armed) {
if (kind === 'kicked') f.controller.showKicked({}); else f.controller.showFatal(failure);
}
} } });
f.controller.connect(f.runtime); f.controller.showLobby(); armed = true;
assert.throws(() => f.controller.showRoom(), /interrupted/);
assert.equal(f.events.includes('activate:room'), false);
assert.equal(f.overlays[f.overlays.length - 1].kind, kind);
}
});
test('scheduler registration may switch pages without blocking subsequent UI frames', () => {
const callbacks: (() => void)[] = [];
let cancellations = 0;
const f = fixture({ scheduler: { request: callback => {
callbacks.push(callback);
if (callbacks.length === 1) f.controller.showLogin();
return () => { cancellations += 1; };
} } });
f.controller.connect(f.runtime); f.controller.showLobby(); f.emit();
assert.equal(cancellations, 1);
assert.deepEqual(f.events.filter(value => value.startsWith('activate')), ['activate:lobby', 'activate:login']);
callbacks[0]();
assert.equal(f.models.length, 2);
f.setReady(false); f.emit();
assert.equal(callbacks.length, 2);
callbacks[1]();
assert.deepEqual(f.models[f.models.length - 1], { page: 'login', canLogin: false });
});