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 }); });