import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts'; // 固定测试来源,允许开发者独立切换项目的 local/remote 开关。 const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const}); import { remoteFixture } from '../fixtures/remote-config.ts'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import type { RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; import { createGameHostAdapter, GameSessionDisposedError, } from '../../YouleNexus/assets/framework/platform/game-host-adapter.ts'; import { PlatformCommands } from '../../YouleNexus/assets/framework/platform/commands.ts'; import { RuntimeSession } from '../../YouleNexus/assets/framework/platform/runtime-session.ts'; import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts'; import type { PlatformState, } from '../../YouleNexus/assets/framework/platform/stores/platform-types.ts'; import { parseLoginResponse, parseOtherJoinRoomPayload, parsePlayerPreparePayload, } from '../../YouleNexus/assets/framework/protocol/contracts/index.ts'; import type { GameEntry, GameHost, GameHostCommand, } from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; import { assertGameRoute, GameSessionHost, } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts'; const RUNTIME_CONFIG: RuntimeConfig = Object.freeze({ startupConfig: {...STARTUP_CONFIG,mode:'release' as const,useLocalServer:false}, mode: 'release', isDebugger: false, identity: Object.freeze({ agentid: 17, channelid: 18, gameid: 'game-41', marketid: 19, version: 41, }), servers: Object.freeze(['ws://fixture']), source: 'remote', gameserver: 'https://fixture/config', ...remoteFixture(), }); function fixture(name: string): { data: Record } { return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')); } function roomStore(seatCount = 4, selfSeat = 1, stage = 0): PlatformStore { const source = fixture('player-login-room.json'); const self = (source.data.players as Array | null>)[1]!; const players = Array.from({ length: seatCount }, () => null) as Array | null>; players[selfSeat] = { ...self }; source.data.players = players; source.data.seat = selfSeat; source.data.roomtype = [seatCount, { nested: [1, 2] }]; source.data.isbattle = stage; const store = new PlatformStore(); store.applyLoginSuccess(parseLoginResponse(source.data)); return store; } function entry(route = 'fixture-route'): GameEntry { return { key: 'fixture', gameId: 'game-41', route, resolveSeatCount: (roomtype) => {assert.ok(Array.isArray(roomtype));return roomtype[0] as number;}, createModule: () => ({ attach() {}, handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() {}, }), }; } function makeLease(options: { readonly store?: PlatformStore; readonly gameEntry?: GameEntry; readonly feedback?:import('../../YouleNexus/assets/framework/sdk/contracts/game-capabilities.ts').FeedbackOwnerPort; readonly numericTools?: import('../../YouleNexus/assets/framework/platform/game-host-adapter.ts').NumericToolsPort; } = {}) { const store = options.store ?? roomStore(); const sent: OutboundEnvelope[] = []; const send = { send: (envelope: OutboundEnvelope) => { sent.push(envelope); } }; const commands = new PlatformCommands({ runtimeConfig: RUNTIME_CONFIG, store, send }); const lease = createGameHostAdapter({ gameEntry: options.gameEntry ?? entry(), store, commands, send, numericTools: options.numericTools, feedback:options.feedback, }); return { ...lease, store, sent }; } test('numeric UI capabilities preserve values and revoke callbacks when their room lease ends',()=>{ let input:((value:number|string)=>void)|undefined; let calculator:((values:readonly number[])=>void)|undefined; const results:unknown[]=[];let closes=0;let hides=0; const tools={openNumberInput(request){assert.equal(request.message,'ID');input=request.onConfirm;}, showMultiplier(request){assert.deepEqual(request.values,[0.1,-2]);calculator=request.onConfirm;}, hideMultiplier(){hides++;},closeNumberInput(){},close(){closes++;}} satisfies import('../../YouleNexus/assets/framework/platform/game-host-adapter.ts').NumericToolsPort; const lease=makeLease({numericTools:tools}); lease.host.execute({type:'ui.open-number-input',message:'ID',begin0:true,onConfirm:value=>results.push(value)}); lease.host.execute({type:'ui.show-multiplier',values:[0.1,-2],onConfirm:value=>results.push(value)}); input!('001');calculator!([0.02,-0.4]);assert.deepEqual(results,['001',[0.02,-0.4]]); lease.host.execute({type:'ui.hide-multiplier'});assert.equal(hides,1); lease.invalidate();lease.invalidate();assert.equal(closes,1); input!('old');calculator!([9]);assert.equal(results.length,2); assert.throws(()=>lease.host.execute({type:'ui.hide-multiplier'}),GameSessionDisposedError); }); test('numeric UI replacement and explicit close discard old callbacks within the same room',()=>{ const callbacks:((value:number|string)=>void)[]=[];let closes=0;const results:unknown[]=[]; const lease=makeLease({numericTools:{openNumberInput:request=>{callbacks.push(request.onConfirm);}, closeNumberInput:()=>{closes++;},showMultiplier:()=>{},hideMultiplier:()=>{},close:()=>{}}}); for(let index=0;index<2;index++)lease.host.execute({type:'ui.open-number-input',message:'ID',onConfirm:value=>results.push(value)}); callbacks[0]('stale');callbacks[1]('current');assert.deepEqual(results,['current']); lease.host.execute({type:'ui.close-number-input'});callbacks[1]('closed'); assert.equal(closes,1);assert.deepEqual(results,['current']); }); test('sendGameMessage binds app and GameEntry route, rejects empty rpc, and preserves data identity', () => { const { host, sent } = makeLease(); const data = { card: 7 }; host.sendGameMessage('play-card', data); assert.deepEqual(sent[0], { app: 'youle', route: 'fixture-route', rpc: 'play-card', data: { card: 7 }, }); assert.equal(sent[0]!.data, data); assert.equal(host.sendGameMessage.length, 2, 'Host API 不接受 route'); assert.throws(() => host.sendGameMessage('', {}), /rpc.*non-empty/i); assert.throws(() => host.sendGameMessage(' ', {}), /rpc.*non-empty/i); assert.equal(sent.length, 1); }); test('adapter rejects empty and reserved GameEntry routes before exposing a Host', () => { for (const route of ['', ' ', 'platform', 'agent', 'room']) { assert.throws( () => makeLease({ gameEntry: entry(route) }), route.trim() === '' ? /route.*non-empty/i : /reserved.*route/i, ); } }); test('Task 1 route validator is the shared authority for game-route admission', () => { assert.doesNotThrow(() => assertGameRoute('fixture-route')); assert.throws(() => assertGameRoute(''), /route.*non-empty/i); for (const route of ['platform', 'agent', 'room']) { assert.throws(() => assertGameRoute(route), /reserved.*route/i); } }); test('execute exposes only the GameHost command union and delegates semantic commands', () => { const { host, sent } = makeLease({ store: roomStore(4, 1, 0) }); host.execute({ type: 'room.prepare' }); host.execute({ type: 'room.exit' }); assert.equal(sent[0]!.rpc, 'player_prepare'); assert.equal(sent[1]!.rpc, 'self_exit_room'); assert.throws( () => host.execute({ type: 'room.join' } as unknown as GameHostCommand), /unsupported.*command/i, ); assert.equal(sent.length, 2); }); test('seat mapper binds each 2, 4, and 10 seat session and round-trips every server seat', () => { for (const seatCount of [2, 4, 10]) { const selfSeat = seatCount - 1; const { host } = makeLease({ store: roomStore(seatCount, selfSeat) }); assert.equal(host.seat.toView(selfSeat), 0); for (let serverSeat = 0; serverSeat < seatCount; serverSeat++) { const viewSeat = (serverSeat - selfSeat + seatCount) % seatCount; assert.equal(host.seat.toView(serverSeat), viewSeat); assert.equal(host.seat.toServer(viewSeat), serverSeat); } for (const invalid of [-1, seatCount, 0.5, Number.NaN, Number.POSITIVE_INFINITY, '0']) { assert.throws(() => host.seat.toView(invalid as number), /seat.*range|integer/i); assert.throws(() => host.seat.toServer(invalid as number), /seat.*range|integer/i); } } }); test('adapter validates descriptor seat count against the canonical room session', () => { const store = roomStore(4, 1); for (const seatCount of [0, -1, 2.5, Number.NaN, 2]) { assert.throws( () => makeLease({ store, gameEntry: { ...entry(), resolveSeatCount: () => seatCount }, }), /seat count/i, ); } }); test('getSnapshot exposes only recursively frozen SDK DTOs and the canonical roomtype', () => { const { host, store } = makeLease(); const state = store.getState(); assert.equal(state.room.kind, 'inside'); if (state.room.kind !== 'inside') return; const snapshot = host.getSnapshot(); assert.deepEqual(Object.keys(snapshot).sort(), ['connection', 'room', 'seats', 'self']); assert.equal(snapshot.room.roomtype, state.room.roomtype); assert.equal(Object.isFrozen(snapshot), true); assert.equal(Object.isFrozen(snapshot.connection), true); assert.equal(Object.isFrozen(snapshot.self), true); assert.equal(Object.isFrozen(snapshot.room), true); assert.equal(Object.isFrozen(snapshot.room.roomtype), true); assert.ok(Array.isArray(snapshot.room.roomtype)); assert.equal(Object.isFrozen(snapshot.room.roomtype[1]), true); assert.equal(Object.isFrozen((snapshot.room.roomtype[1] as { nested: unknown[] }).nested), true); assert.equal(Object.isFrozen(snapshot.seats), true); assert.equal(Object.isFrozen(snapshot.seats[0]), true); assert.equal('getState' in snapshot, false); assert.equal('subscribe' in snapshot, false); assert.equal('state' in snapshot, false); }); test('RuntimeSession room replacement keeps the old lease isolated until release and exposes only the fresh lease', () => { const store = new PlatformStore(); const sent: OutboundEnvelope[] = []; const send = { send: (envelope: OutboundEnvelope) => { sent.push(envelope); } }; const commands = new PlatformCommands({ runtimeConfig: RUNTIME_CONFIG, store, send }); const attachedHosts: GameHost[] = []; const staleDisposeResults: unknown[] = []; const gameEntry: GameEntry = { ...entry(), resolveSeatCount: (roomtype) => {assert.ok(Array.isArray(roomtype));return roomtype[0] as number;}, createModule: () => { let host: GameHost | null = null; return { attach(value) { host = value; attachedHosts.push(value); }, handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() { if (host === null) return; for (const call of [ () => host!.getSnapshot(), () => host!.sendGameMessage('stale-play', { room: 'old' }), () => host!.execute({ type: 'room.prepare' }), ]) { try { staleDisposeResults.push(call()); } catch (error) { staleDisposeResults.push(error); } } }, }; }, }; const gameSession = new GameSessionHost(gameEntry); const runtime = new RuntimeSession({ store, gameSession, onServerDenial() { assert.fail('Unexpected server denial'); }, createGameHostLease: () => createGameHostAdapter({ gameEntry, store, commands, send }), }); const login = fixture('player-login-room.json'); login.data.roomtype = [4, { generation: 'A' }]; login.data.isbattle = 0; runtime.handleLogin(login.data); const oldHost = attachedHosts[0]!; const oldRoomcodes: string[] = []; oldHost.subscribe((snapshot) => { oldRoomcodes.push(snapshot.room.roomcode); }); const replacement = fixture('self-join-room.json'); replacement.data.roomcode = '200002'; replacement.data.roomtype = [4, { generation: 'B' }]; replacement.data.deskwar = 0; delete replacement.data.deskinfo; runtime.handleSelfJoin(replacement.data); assert.deepEqual(oldRoomcodes, ['100001']); assert.equal(staleDisposeResults.length, 3); for (const result of staleDisposeResults) { assert.ok(result instanceof GameSessionDisposedError); } assert.deepEqual(sent, []); assert.equal(attachedHosts.length, 2); assert.equal(attachedHosts[1]!.getSnapshot().room.roomcode, '200002'); }); class ObservableStore extends PlatformStore { private readonly observed = new Set<(state: PlatformState, previous: PlatformState) => void>(); override subscribe(listener: (state: PlatformState, previous: PlatformState) => void): () => void { const unsubscribeStore = super.subscribe(listener); this.observed.add(listener); return () => { this.observed.delete(listener); unsubscribeStore(); }; } emitCurrent(): void { const state = this.getState(); for (const listener of [...this.observed]) listener(state, state); } } test('subscribe is immediate, suppresses identical public snapshots, and isolates listener failures', () => { const store = new ObservableStore(); const source = fixture('player-login-room.json'); source.data.isbattle = 0; source.data.roomtype = [4]; store.applyLoginSuccess(parseLoginResponse(source.data)); const { host } = makeLease({ store }); const received: unknown[] = []; host.subscribe((snapshot) => { received.push(snapshot); }); store.emitCurrent(); assert.equal(received.length, 1); const listenerFailure = new Error('listener failed'); const reports: unknown[][] = []; const originalConsoleError = console.error; console.error = (...args: unknown[]) => { reports.push(args); }; try { assert.throws( () => host.subscribe(() => { throw listenerFailure; }), (error) => error === listenerFailure, ); store.playerJoin(parseOtherJoinRoomPayload({ seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b', sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0, })); assert.equal(received.length, 2); assert.equal(reports.length, 0, 'failed initial subscriptions must be removed'); } finally { console.error = originalConsoleError; } }); test('a later listener failure does not block other Host subscribers', () => { const { host, store } = makeLease(); const listenerFailure = new Error('later listener failed'); let firstCalls = 0; let secondCalls = 0; const reports: unknown[][] = []; const originalConsoleError = console.error; console.error = (...args: unknown[]) => { reports.push(args); }; try { host.subscribe(() => { firstCalls++; if (firstCalls > 1) throw listenerFailure; }); host.subscribe(() => { secondCalls++; }); store.playerJoin(parseOtherJoinRoomPayload({ seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b', sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0, })); assert.equal(firstCalls, 2); assert.equal(secondCalls, 2); assert.equal(reports.length, 1); assert.equal(reports[0]!.includes(listenerFailure), true); } finally { console.error = originalConsoleError; } }); class ThrowingUnsubscribeStore extends PlatformStore { unsubscribeCalls = 0; override subscribe(listener: (state: PlatformState, previous: PlatformState) => void): () => void { const unsubscribeStore = super.subscribe(listener); return () => { unsubscribeStore(); this.unsubscribeCalls++; if (this.unsubscribeCalls === 1) throw undefined; throw null; }; } } test('invalidate clears every subscription and reference before reporting all cleanup failures', () => { const store = new ThrowingUnsubscribeStore(); const source = fixture('player-login-room.json'); source.data.isbattle = 0; source.data.roomtype = [4]; store.applyLoginSuccess(parseLoginResponse(source.data)); const { host, invalidate } = makeLease({ store }); let callbacks = 0; const firstUnsubscribe = host.subscribe(() => { callbacks++; }); const secondUnsubscribe = host.subscribe(() => { callbacks++; }); let cleanupError: unknown; try { invalidate(); } catch (error) { cleanupError = error; } assert.equal(store.unsubscribeCalls, 2); assert.deepEqual( (cleanupError as { errors?: readonly unknown[] })?.errors, [undefined, null], ); assert.throws(() => host.getSnapshot(), GameSessionDisposedError); assert.doesNotThrow(() => firstUnsubscribe()); assert.doesNotThrow(() => secondUnsubscribe()); assert.doesNotThrow(() => invalidate()); store.playerJoin(parseOtherJoinRoomPayload({ seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b', sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0, })); assert.equal(callbacks, 2); }); class ReentrantThrowingUnsubscribeStore extends PlatformStore { readonly nestedFailure = { source: 'subscription-B' }; readonly cleanupCalls: string[] = []; secondPublicUnsubscribe: (() => void) | null = null; private subscriptionIndex = 0; override subscribe(listener: (state: PlatformState, previous: PlatformState) => void): () => void { const index = this.subscriptionIndex++; const unsubscribeStore = super.subscribe(listener); if (index === 0) { return () => { this.cleanupCalls.push('A'); this.secondPublicUnsubscribe!(); unsubscribeStore(); throw undefined; }; } return () => { this.cleanupCalls.push('B'); unsubscribeStore(); throw this.nestedFailure; }; } } test('invalidate collects a reentrant public-unsubscribe failure in occurrence order exactly once', () => { const store = new ReentrantThrowingUnsubscribeStore(); const source = fixture('player-login-room.json'); source.data.isbattle = 0; source.data.roomtype = [4]; store.applyLoginSuccess(parseLoginResponse(source.data)); const { host, invalidate } = makeLease({ store }); const firstPublicUnsubscribe = host.subscribe(() => {}); const secondPublicUnsubscribe = host.subscribe(() => {}); store.secondPublicUnsubscribe = secondPublicUnsubscribe; let didThrow = false; let cleanupError: unknown; try { invalidate(); } catch (error) { didThrow = true; cleanupError = error; } assert.equal(didThrow, true); assert.deepEqual(store.cleanupCalls, ['A', 'B']); const errors = (cleanupError as { errors?: readonly unknown[] })?.errors; assert.equal(errors?.[0], store.nestedFailure); assert.equal(errors?.[1], undefined); assert.equal(errors?.length, 2); assert.doesNotThrow(() => firstPublicUnsubscribe()); assert.doesNotThrow(() => secondPublicUnsubscribe()); assert.deepEqual(store.cleanupCalls, ['A', 'B']); assert.doesNotThrow(() => invalidate()); }); test('unsubscribe and invalidate are reentry-safe and prevent callbacks already snapshotted by Store', () => { const { host, invalidate, store } = makeLease(); const calls: string[] = []; let unsubscribeSecond = (): void => undefined; host.subscribe(() => { calls.push('first'); if (calls.length > 2) { unsubscribeSecond(); invalidate(); } }); unsubscribeSecond = host.subscribe(() => { calls.push('second'); }); assert.deepEqual(calls, ['first', 'second']); store.playerJoin(parseOtherJoinRoomPayload({ seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b', sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0, })); assert.deepEqual(calls, ['first', 'second', 'first']); assert.doesNotThrow(() => unsubscribeSecond()); assert.doesNotThrow(() => unsubscribeSecond()); assert.doesNotThrow(() => invalidate()); store.playerReady(parsePlayerPreparePayload({ seat: 2 })); assert.deepEqual(calls, ['first', 'second', 'first']); }); test('invalidate is idempotent, old unsubscribe stays safe, and every stale Host method fails predictably', () => { const { host, invalidate, store, sent } = makeLease(); let callbacks = 0; const unsubscribe = host.subscribe(() => { callbacks++; }); assert.equal(callbacks, 1); invalidate(); invalidate(); const disposed = (call: () => unknown) => assert.throws(call, GameSessionDisposedError); disposed(() => host.getSnapshot()); disposed(() => host.subscribe(() => { callbacks++; })); disposed(() => host.subscribe(null as never)); disposed(() => host.sendGameMessage('play-card', {})); disposed(() => host.execute({ type: 'room.prepare' })); disposed(() => host.seat.toView(0)); disposed(() => host.seat.toServer(0)); assert.doesNotThrow(() => unsubscribe()); assert.doesNotThrow(() => unsubscribe()); store.playerJoin(parseOtherJoinRoomPayload({ seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b', sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0, })); assert.equal(callbacks, 1); assert.equal(sent.length, 0); }); test('game host exposes dissolution application through exact room command',()=>{ const {host,sent}=makeLease({store:roomStore(4,1,1)});host.execute({type:'room.apply-dissolution'}); assert.deepEqual(sent,[{app:'youle',route:'room',rpc:'self_apply_free_room',data:{agentid:17,gameid:'game-41',playerid:430511,roomcode:'100001'}}]); }); test('grouped input results distinguish types, cancellation, overlap and lease cleanup',async()=>{ let input!:Parameters[0]; let multiplier!:Parameters[0]; const tools={openNumberInput(request:typeof input){input=request;},showMultiplier(request:typeof multiplier){multiplier=request;},closeNumberInput(){input?.onCancel?.();},hideMultiplier(){multiplier?.onCancel?.();},close(){throw Error('close failed');}}; const lease=makeLease({numericTools:tools});const caps=lease.host.capabilities!; const number=caps.ui.requestNumber({message:'N'});await assert.rejects(caps.ui.requestDigitText({message:'overlap'}),/pending/); input.onConfirm(12);assert.deepEqual(await number,{kind:'confirmed',value:12}); const text=caps.ui.requestDigitText({message:'ID'});input.onConfirm('001');assert.deepEqual(await text,{kind:'confirmed',value:'001'}); const cancelled=caps.ui.requestNumber({message:'Cancel'});input.onCancel!();assert.deepEqual(await cancelled,{kind:'cancelled',reason:'user'}); const replaced=caps.ui.requestNumber({message:'Replace'});const late=input.onConfirm; lease.host.execute({type:'ui.open-number-input',message:'Legacy',onConfirm(){}});assert.deepEqual(await replaced,{kind:'cancelled',reason:'replaced'});late(99); const pending=caps.ui.requestMultiplier({values:[1,2]});const cleanup:string[]=[]; caps.scope.defer(()=>{cleanup.push('first');});caps.scope.defer(()=>{cleanup.push('second');throw Error('cleanup failed');}); assert.throws(()=>lease.invalidate());assert.deepEqual(cleanup,['second','first']);assert.equal(caps.scope.active,false); assert.deepEqual(await pending,{kind:'cancelled',reason:'scope-ended'});multiplier.onConfirm([9]); assert.throws(()=>caps.messages.send('play',{}),GameSessionDisposedError);assert.throws(()=>caps.scope.defer(()=>{}),GameSessionDisposedError); await assert.rejects(caps.ui.requestNumber({message:'stale'}),GameSessionDisposedError);lease.invalidate(); }); test('typed inputs reject invalid adapter results without leaving a request pending',async()=>{ let confirm!:(value:number|string)=>void;const lease=makeLease({numericTools:{openNumberInput(r){confirm=r.onConfirm;},closeNumberInput(){},showMultiplier(){},hideMultiplier(){},close(){}}}); const first=lease.host.capabilities!.ui.requestNumber({message:'N'});confirm('wrong');await assert.rejects(first,/type/); const next=lease.host.capabilities!.ui.requestNumber({message:'N'});confirm(3);assert.deepEqual(await next,{kind:'confirmed',value:3});lease.invalidate(); }); test('grouped commands bind routing and scope while explicit exit uses central policy',()=>{ const {host,sent}=makeLease({store:roomStore(4,1,1)});const caps=host.capabilities!; assert.equal(caps.scope.active,true);caps.room.requestExit();assert.equal(sent[0].rpc,'self_apply_free_room'); caps.messages.send('card',{value:1});assert.equal(sent[1].route,'fixture-route'); assert.throws(()=>caps.messages.send('card',{value:undefined} as any),/JSON/); }); test('pending input closes its partial view and preserves both opening and cleanup failures',async()=>{ const openError=Error('open failed'),closeError=Error('close failed');let closes=0; const lease=makeLease({numericTools:{openNumberInput(){throw openError;},closeNumberInput(){closes++;throw closeError;},showMultiplier(){},hideMultiplier(){},close(){}}}); await assert.rejects(lease.host.capabilities!.ui.requestNumber({message:'N'}),error=>{ assert.deepEqual((error as Error&{errors:unknown[]}).errors,[openError,closeError]);return true; }); assert.equal(closes,1);lease.invalidate(); }); test('late grouped input confirmation after room replacement resolves scope cancellation',async()=>{ let confirm!:(value:number|string)=>void;const lease=makeLease({numericTools:{openNumberInput(r){confirm=r.onConfirm;},closeNumberInput(){},showMultiplier(){},hideMultiplier(){},close(){}}}); const result=lease.host.capabilities!.ui.requestNumber({message:'N'}); lease.store.clearRoom();confirm(2);assert.deepEqual(await result,{kind:'cancelled',reason:'scope-ended'});lease.invalidate(); }); test('owned UI handles are automatically released and cannot close another source',()=>{ const closed:string[]=[];let count=0; const lease=makeLease({feedback:{beginLoading(){const id='load'+(++count);return {close(){closed.push(id);}};},showBusinessTip(){return {close(){closed.push('tip');}};}}}); const a=lease.host.capabilities!.ui.beginLoading(),b=lease.host.capabilities!.ui.beginLoading(); lease.host.capabilities!.ui.showBusinessTip('操作成功',1000);a.close();a.close();assert.deepEqual(closed,['load1']); lease.invalidate();assert.deepEqual(closed,['load1','tip','load2']);b.close();assert.equal(closed.length,3); assert.throws(()=>lease.host.capabilities!.ui.beginLoading(),GameSessionDisposedError); });