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
@@ -11,7 +11,7 @@ class FrameRendererFault extends Error {
} }
export class FrameRenderer { export class FrameRenderer {
private pending: (() => void) | null = null; private pending: { cancel: (() => void) | null } | null = null;
private generation = 0; private generation = 0;
private disposed = false; private disposed = false;
@@ -25,28 +25,45 @@ export class FrameRenderer {
if (this.disposed || this.pending !== null) return; if (this.disposed || this.pending !== null) return;
const generation = this.generation; const generation = this.generation;
this.pending = this.scheduler.request(() => { // Reserve ownership before registration, which may reenter flush or dispose.
if (this.disposed || generation !== this.generation) return; const pending: { cancel: (() => void) | null } = { cancel: null };
this.pending = null; this.pending = pending;
this.generation += 1; let cancel: () => void;
try { try {
this.render(); cancel = this.scheduler.request(() => {
} catch (error) { if (this.disposed || generation !== this.generation) return;
let fault: unknown = error; this.pending = null;
this.generation += 1;
try { try {
this.dispose(); this.render();
} catch (cancelError) { } catch (error) {
fault = new FrameRendererFault([error, cancelError]); let fault: unknown = error;
try {
this.dispose();
} catch (cancelError) {
fault = new FrameRendererFault([error, cancelError]);
}
this.onFault(fault);
} }
this.onFault(fault); });
} catch (error) {
if (this.pending === pending) {
this.pending = null;
this.generation += 1;
} }
}); throw error;
}
if (this.disposed || generation !== this.generation) {
cancel();
return;
}
pending.cancel = cancel;
} }
public flush(): void { public flush(): void {
if (this.disposed) return; if (this.disposed) return;
const cancel = this.pending; const cancel = this.pending === null ? null : this.pending.cancel;
this.pending = null; this.pending = null;
this.generation += 1; this.generation += 1;
const generation = this.generation; const generation = this.generation;
@@ -59,7 +76,7 @@ export class FrameRenderer {
if (this.disposed) return; if (this.disposed) return;
this.disposed = true; this.disposed = true;
const cancel = this.pending; const cancel = this.pending === null ? null : this.pending.cancel;
this.pending = null; this.pending = null;
this.generation += 1; this.generation += 1;
if (cancel !== null) cancel(); if (cancel !== null) cancel();
@@ -0,0 +1,211 @@
import type { LoginAccountIdentity } from '../protocol/contracts/index.ts';
import type { JoinRoomCommand } from '../platform/commands.ts';
import type { ScenePort } from '../platform/scene-port.ts';
import type { ServerDenial } from '../platform/runtime-session.ts';
import type { PlatformState } from '../platform/stores/platform-types.ts';
import type { FrameScheduler, OverlayModel, PageId, PlatformViewPort, UiRuntime } from './ui-contracts.ts';
import { selectPageModel } from './page-model.ts';
import { FrameRenderer } from './frame-renderer.ts';
class PlatformUiFault extends Error {
readonly errors: readonly unknown[];
constructor(errors: readonly unknown[]) {
super('PlatformUiController operation and cleanup failed');
this.name = 'PlatformUiFault';
this.errors = Object.freeze([...errors]);
}
}
function collectedFault(errors: readonly unknown[]): unknown {
return errors.length === 1 ? errors[0] : new PlatformUiFault(errors);
}
function throwCollected(errors: readonly unknown[]): void {
if (errors.length > 0) throw collectedFault(errors);
}
export class PlatformUiController implements ScenePort {
private runtime: UiRuntime | null = null;
private disposed = false;
private terminal = false;
private faultReported = false;
private transition = 0;
private overlay: OverlayModel = { kind: 'none' };
private page: PageId | null = null;
private unsubscribe: (() => void) | null = null;
private readonly renderer: FrameRenderer;
constructor(
private readonly view: PlatformViewPort,
scheduler: FrameScheduler,
private readonly onFault: (error: unknown) => void,
) {
this.renderer = new FrameRenderer(scheduler, () => this.renderCurrent(), error => this.handleFault(error));
}
connect(runtime: UiRuntime): void {
if (this.disposed) throw new Error('PlatformUiController is disposed');
if (this.runtime !== null) throw new Error('PlatformUiController already connected');
this.runtime = runtime;
try {
this.unsubscribe = runtime.subscribeState(state => this.stateChanged(state), error => this.handleFault(error));
this.requireTransition(this.transition);
this.stateChanged(runtime.state);
this.requireTransition(this.transition);
} catch (error) {
this.terminal = true;
throw collectedFault([error, ...this.stopUpdates()]);
}
}
showLoading(): void { this.showPage('loading'); }
showLogin(): void { this.showPage('login'); }
showLobby(): void { this.showPage('lobby'); }
showRoom(): void { this.showPage('room'); }
showReconnect(): void {
if (this.stopped) return;
this.requireRuntime();
this.setOverlay({ kind: 'reconnect' });
}
showServerDenial(outcome: ServerDenial): void {
if (this.stopped) return;
this.requireRuntime();
this.setOverlay({ kind: 'denial', outcome });
}
showKicked(data: unknown): void {
if (this.stopped) return;
this.showTerminal({ kind: 'kicked', data });
}
showFatal(error: Error): void {
if (this.disposed || this.overlay.kind === 'fatal') return;
this.showTerminal({ kind: 'fatal', error });
}
dismissDenial(): void {
this.requireRuntime();
if (this.overlay.kind === 'denial') this.setOverlay({ kind: 'none' });
}
login(account: LoginAccountIdentity): void {
const runtime = this.requireRuntime();
if (this.page !== 'login') throw new Error('login page is not open');
if (!runtime.ready) throw new Error('Runtime is not ready for login');
runtime.login(account);
}
joinRoom(command: JoinRoomCommand): void {
const runtime = this.requireRuntime();
if (this.page !== 'lobby') throw new Error('joinRoom requires the lobby page');
runtime.joinRoom(command);
}
prepare(): void {
const runtime = this.requireRuntime();
if (this.page !== 'room') throw new Error('prepare requires the room page');
const model = selectPageModel('room', runtime.state, runtime.ready);
if (model.page !== 'room' || !model.canPrepare) throw new Error('Cannot prepare');
runtime.prepare();
}
exitRoom(): void {
const runtime = this.requireRuntime();
if (this.page !== 'room') throw new Error('exit requires the room page');
const model = selectPageModel('room', runtime.state, runtime.ready);
if (model.page !== 'room' || !model.canExit) throw new Error('Cannot exit');
runtime.exitRoom();
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
throwCollected(this.stopUpdates());
}
private stopUpdates(): unknown[] {
const errors: unknown[] = [];
const unsubscribe = this.unsubscribe;
this.unsubscribe = null;
try { this.renderer.dispose(); } catch (error) { errors.push(error); }
if (unsubscribe !== null) {
try { unsubscribe(); } catch (error) { errors.push(error); }
}
return errors;
}
private showPage(page: PageId): void {
if (this.stopped) return;
this.requireRuntime();
const transition = ++this.transition;
this.page = page;
this.renderer.flush();
this.requireTransition(transition);
this.view.activate(page);
this.requireTransition(transition);
if (this.overlay.kind !== 'none') {
this.setOverlay({ kind: 'none' });
this.requireTransition(transition);
}
}
private renderCurrent(): void {
if (this.stopped || this.page === null) return;
const runtime = this.requireRuntime();
this.view.render(selectPageModel(this.page, runtime.state, runtime.ready));
}
private requireRuntime(): UiRuntime {
if (this.disposed) throw new Error('PlatformUiController is disposed');
if (this.terminal) throw new Error('PlatformUiController is terminal');
if (this.runtime === null) throw new Error('PlatformUiController is not connected');
return this.runtime;
}
private get stopped(): boolean { return this.disposed || this.terminal; }
private requireTransition(transition: number): void {
if (this.stopped || transition !== this.transition) {
throw new Error('PlatformUiController lifecycle interrupted page transition');
}
}
private setOverlay(model: OverlayModel): void {
this.overlay = model;
this.view.showOverlay(model);
}
private showTerminal(model: Extract<OverlayModel, { kind: 'fatal' | 'kicked' }>): void {
this.terminal = true;
this.overlay = model;
const errors = this.stopUpdates();
// Cleanup can synchronously dispose the Presenter or promote kick to fatal.
if (!this.disposed && this.overlay === model) {
try { this.view.showOverlay(model); } catch (error) { errors.push(error); }
}
throwCollected(errors);
}
private stateChanged(state: PlatformState): void {
if (this.stopped) return;
if (this.overlay.kind === 'reconnect'
&& (state.app.phase === 'connected' || state.app.phase === 'logged-in')) {
this.setOverlay({ kind: 'none' });
if (this.stopped) return;
}
if (this.page !== null) this.renderer.request();
}
private handleFault(error: unknown): void {
if (this.faultReported) return;
this.faultReported = true;
this.terminal = true;
const fault = collectedFault([error, ...this.stopUpdates()]);
try { this.onFault(fault); } catch (callbackError) {
throw collectedFault([fault, callbackError]);
}
}
}
@@ -261,3 +261,106 @@ test('does not swallow an onFault callback error', () => {
renderer.request(); renderer.request();
assert.equal(callbacks.length, 1); 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 });
});