From d29a659541bcb0dcfe9037a6122ff03a25b9404f Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 12:47:15 +0800 Subject: [PATCH] feat(presentation): wire platform scenes and UI commands --- .../framework/presentation/frame-renderer.ts | 49 +- .../presentation/platform-ui-controller.ts | 211 +++++++ .../presentation/frame-renderer.test.ts | 103 ++++ .../platform-ui-controller.test.ts | 513 ++++++++++++++++++ 4 files changed, 860 insertions(+), 16 deletions(-) create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/presentation/platform-ui-controller.ts create mode 100644 cocoscreator_projects/framework-tests/presentation/platform-ui-controller.test.ts diff --git a/cocoscreator_projects/YouleNexus/assets/framework/presentation/frame-renderer.ts b/cocoscreator_projects/YouleNexus/assets/framework/presentation/frame-renderer.ts index 406b318..7c5a0ca 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/presentation/frame-renderer.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/presentation/frame-renderer.ts @@ -11,7 +11,7 @@ class FrameRendererFault extends Error { } export class FrameRenderer { - private pending: (() => void) | null = null; + private pending: { cancel: (() => void) | null } | null = null; private generation = 0; private disposed = false; @@ -25,28 +25,45 @@ export class FrameRenderer { if (this.disposed || this.pending !== null) return; const generation = this.generation; - this.pending = this.scheduler.request(() => { - if (this.disposed || generation !== this.generation) return; - this.pending = null; - this.generation += 1; - try { - this.render(); - } catch (error) { - let fault: unknown = error; + // Reserve ownership before registration, which may reenter flush or dispose. + const pending: { cancel: (() => void) | null } = { cancel: null }; + this.pending = pending; + let cancel: () => void; + try { + cancel = this.scheduler.request(() => { + if (this.disposed || generation !== this.generation) return; + this.pending = null; + this.generation += 1; try { - this.dispose(); - } catch (cancelError) { - fault = new FrameRendererFault([error, cancelError]); + this.render(); + } catch (error) { + 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 { if (this.disposed) return; - const cancel = this.pending; + const cancel = this.pending === null ? null : this.pending.cancel; this.pending = null; this.generation += 1; const generation = this.generation; @@ -59,7 +76,7 @@ export class FrameRenderer { if (this.disposed) return; this.disposed = true; - const cancel = this.pending; + const cancel = this.pending === null ? null : this.pending.cancel; this.pending = null; this.generation += 1; if (cancel !== null) cancel(); diff --git a/cocoscreator_projects/YouleNexus/assets/framework/presentation/platform-ui-controller.ts b/cocoscreator_projects/YouleNexus/assets/framework/presentation/platform-ui-controller.ts new file mode 100644 index 0000000..94d19f9 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/presentation/platform-ui-controller.ts @@ -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): 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]); + } + } +} diff --git a/cocoscreator_projects/framework-tests/presentation/frame-renderer.test.ts b/cocoscreator_projects/framework-tests/presentation/frame-renderer.test.ts index f7980f6..839d762 100644 --- a/cocoscreator_projects/framework-tests/presentation/frame-renderer.test.ts +++ b/cocoscreator_projects/framework-tests/presentation/frame-renderer.test.ts @@ -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); +}); diff --git a/cocoscreator_projects/framework-tests/presentation/platform-ui-controller.test.ts b/cocoscreator_projects/framework-tests/presentation/platform-ui-controller.test.ts new file mode 100644 index 0000000..2ab5c30 --- /dev/null +++ b/cocoscreator_projects/framework-tests/presentation/platform-ui-controller.test.ts @@ -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; + 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 }); +});