test(presentation): verify runtime UI wiring and boundaries
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
import {
|
||||
FRAMEWORK_INTERNAL,
|
||||
SDK_ALLOWED,
|
||||
scanImportBoundaries,
|
||||
} from '../../scripts/lib/import-boundaries.mjs';
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const presentationRoot = join(
|
||||
projectRoot,
|
||||
'YouleNexus',
|
||||
'assets',
|
||||
'framework',
|
||||
'presentation',
|
||||
);
|
||||
|
||||
function forbiddenSpecifier(specifier) {
|
||||
return specifier === 'cc' || specifier.startsWith('cc/')
|
||||
|| /(?:^|\/)(?:net|protocol)(?:\/|$)/.test(specifier)
|
||||
|| /(?:platform-session|room-rpc-bus)(?:\.|$)/.test(specifier)
|
||||
|| /(?:^|\/)platform\/session(?:\.|$)/.test(specifier);
|
||||
}
|
||||
|
||||
function permitsTypeOnlyContract(specifier) {
|
||||
return /(?:^|\/)protocol\/contracts(?:\/|$)/.test(specifier);
|
||||
}
|
||||
|
||||
function staticSpecifier(node) {
|
||||
return node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)
|
||||
? node.moduleSpecifier.text
|
||||
: null;
|
||||
}
|
||||
|
||||
function importIsTypeOnly(node) {
|
||||
const clause = node.importClause;
|
||||
if (!clause) return false;
|
||||
if (clause.isTypeOnly) return true;
|
||||
return clause.name === undefined
|
||||
&& clause.namedBindings !== undefined
|
||||
&& ts.isNamedImports(clause.namedBindings)
|
||||
&& clause.namedBindings.elements.length > 0
|
||||
&& clause.namedBindings.elements.every(element => element.isTypeOnly);
|
||||
}
|
||||
|
||||
function exportIsTypeOnly(node) {
|
||||
if (node.isTypeOnly) return true;
|
||||
return node.exportClause !== undefined
|
||||
&& ts.isNamedExports(node.exportClause)
|
||||
&& node.exportClause.elements.length > 0
|
||||
&& node.exportClause.elements.every(element => element.isTypeOnly);
|
||||
}
|
||||
|
||||
function externalImportEqualsSpecifier(node) {
|
||||
if (!ts.isExternalModuleReference(node.moduleReference)
|
||||
|| !node.moduleReference.expression
|
||||
|| !ts.isStringLiteralLike(node.moduleReference.expression)) return null;
|
||||
return node.moduleReference.expression.text;
|
||||
}
|
||||
|
||||
function literalCallSpecifier(node) {
|
||||
if (node.arguments.length !== 1 || !ts.isStringLiteralLike(node.arguments[0])) return null;
|
||||
return node.arguments[0].text;
|
||||
}
|
||||
|
||||
function presentationViolations(source, file = 'presentation-inline.ts') {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
file,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
const violations = [];
|
||||
const inspect = (specifier, typeOnly, kind) => {
|
||||
if (!forbiddenSpecifier(specifier)) return;
|
||||
if (typeOnly && permitsTypeOnlyContract(specifier)) return;
|
||||
violations.push({ file, specifier, kind });
|
||||
};
|
||||
const visit = (node) => {
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
const specifier = staticSpecifier(node);
|
||||
if (specifier !== null) inspect(specifier, importIsTypeOnly(node), 'import');
|
||||
} else if (ts.isExportDeclaration(node)) {
|
||||
const specifier = staticSpecifier(node);
|
||||
if (specifier !== null) inspect(specifier, exportIsTypeOnly(node), 'export');
|
||||
} else if (ts.isImportEqualsDeclaration(node)) {
|
||||
const specifier = externalImportEqualsSpecifier(node);
|
||||
if (specifier !== null) inspect(specifier, false, 'import-equals');
|
||||
} else if (ts.isImportTypeNode(node)
|
||||
&& ts.isLiteralTypeNode(node.argument)
|
||||
&& ts.isStringLiteralLike(node.argument.literal)) {
|
||||
inspect(node.argument.literal.text, true, 'import-type');
|
||||
} else if (ts.isCallExpression(node)
|
||||
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|
||||
|| (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) {
|
||||
const specifier = literalCallSpecifier(node);
|
||||
if (specifier !== null) {
|
||||
inspect(specifier, false, 'call');
|
||||
} else {
|
||||
violations.push({ file, specifier: '<dynamic>', kind: 'call' });
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
ts.forEachChild(sourceFile, visit);
|
||||
return violations;
|
||||
}
|
||||
|
||||
function typescriptFiles(directory) {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
return entry.isDirectory() ? typescriptFiles(path) : path.endsWith('.ts') ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function sdkBarrelViolations(source, file = 'sdk-index-inline.ts') {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
file,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
const violations = sourceFile.statements.flatMap((statement) => {
|
||||
const allowed = ts.isExportDeclaration(statement)
|
||||
&& !statement.isTypeOnly
|
||||
&& statement.exportClause === undefined
|
||||
&& staticSpecifier(statement) === './contracts/index.ts'
|
||||
&& statement.attributes === undefined;
|
||||
return allowed ? [] : [{
|
||||
file,
|
||||
kind: ts.SyntaxKind[statement.kind],
|
||||
source: statement.getText(sourceFile),
|
||||
}];
|
||||
});
|
||||
if (violations.length === 0 && sourceFile.statements.length !== 1) {
|
||||
violations.push({
|
||||
file,
|
||||
kind: 'ContractsExportCardinality',
|
||||
source: String(sourceFile.statements.length),
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
test('presentation boundary recognizes forbidden production imports', () => {
|
||||
assert.equal(forbiddenSpecifier('../net/wire-client.ts'), true);
|
||||
assert.equal(forbiddenSpecifier('../protocol/contracts/index.ts'), true);
|
||||
assert.equal(forbiddenSpecifier('../platform/runtime.ts'), false);
|
||||
});
|
||||
|
||||
test('presentation AST distinguishes pure type contracts from runtime dependencies', () => {
|
||||
const samples = [
|
||||
["import type { LoginAccountIdentity } from '../protocol/contracts/index.ts';", 0],
|
||||
["import { type LoginAccountIdentity } from '../protocol/contracts/index.ts';", 0],
|
||||
["type Login = import('../protocol/contracts/index.ts').LoginAccountIdentity;", 0],
|
||||
["export type { LoginAccountIdentity } from '../protocol/contracts/index.ts';", 0],
|
||||
["export { type LoginAccountIdentity } from '../protocol/contracts/index.ts';", 0],
|
||||
["import { LoginAccountIdentity } from '../protocol/contracts/index.ts';", 1],
|
||||
["import { type LoginAccountIdentity, buildLoginRequest } from '../protocol/contracts/index.ts';", 1],
|
||||
["export { type LoginAccountIdentity, buildLoginRequest } from '../protocol/contracts/index.ts';", 1],
|
||||
["export * from '../protocol/contracts/index.ts';", 1],
|
||||
["void import('../protocol/contracts/index.ts');", 1],
|
||||
["void import(target);", 1],
|
||||
["const contracts = require('../protocol/contracts/index.ts');", 1],
|
||||
["const contracts = require(target);", 1],
|
||||
["import protocol = require('../protocol/contracts/index.ts');", 1],
|
||||
["import type { WireClient } from '../net/wire-client.ts';", 1],
|
||||
["import type { Node } from 'cc';", 1],
|
||||
["import { Node } from 'cc';", 1],
|
||||
["import { PlatformSession } from '../platform/platform-session.ts';", 1],
|
||||
["import type { PlatformSession } from '../platform/session.ts';", 1],
|
||||
["const bus = require('../platform/room-rpc-bus.ts');", 1],
|
||||
["import { parse } from '@framework/protocol';", 1],
|
||||
["export * from '@framework/net';", 1],
|
||||
["import type { ScenePort } from '../platform/scene-port.ts';", 0],
|
||||
];
|
||||
|
||||
for (const [source, expected] of samples) {
|
||||
assert.equal(presentationViolations(source).length, expected, source);
|
||||
}
|
||||
});
|
||||
|
||||
test('every real presentation source satisfies the value-dependency boundary', () => {
|
||||
const violations = typescriptFiles(presentationRoot).flatMap(file => (
|
||||
presentationViolations(readFileSync(file, 'utf8'), file)
|
||||
));
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
test('SDK public barrel exports contracts only', () => {
|
||||
const sdkIndex = join(projectRoot, 'YouleNexus', 'assets', 'framework', 'sdk', 'index.ts');
|
||||
assert.deepEqual(sdkBarrelViolations(readFileSync(sdkIndex, 'utf8'), sdkIndex), []);
|
||||
});
|
||||
|
||||
test('SDK contracts-only gate rejects a local exported declaration', () => {
|
||||
const source = 'export const unexpectedSdkApi = 1;';
|
||||
assert.equal(sdkBarrelViolations(source).length, 1);
|
||||
});
|
||||
|
||||
test('SDK contracts-only gate requires exactly one contracts re-export', () => {
|
||||
assert.ok(sdkBarrelViolations('').length > 0);
|
||||
assert.ok(sdkBarrelViolations([
|
||||
"export * from './contracts/index.ts';",
|
||||
"export * from './contracts/index.ts';",
|
||||
].join('\n')).length > 0);
|
||||
});
|
||||
|
||||
test('SDK contracts-only gate rejects alternate exports and import-type leaks', () => {
|
||||
const forbiddenSources = [
|
||||
'const privateHelper = 1;',
|
||||
'export function unexpectedSdkApi() {}',
|
||||
'export default class UnexpectedSdkApi {}',
|
||||
'export interface UnexpectedSdkApi {}',
|
||||
'export type UnexpectedSdkApi = string;',
|
||||
"export { GameEntry } from './contracts/index.ts';",
|
||||
"export type { GameEntry } from './contracts/index.ts';",
|
||||
"export * as contracts from './contracts/index.ts';",
|
||||
"export * from '../presentation/ui-contracts.ts';",
|
||||
"type Leaked = import('../presentation/ui-contracts.ts').PageModel; export type { Leaked };",
|
||||
"import type { PageModel } from '../presentation/ui-contracts.ts'; export type { PageModel };",
|
||||
"export * from './contracts/index.ts'; export const unexpectedSdkApi = 1;",
|
||||
];
|
||||
|
||||
assert.deepEqual(sdkBarrelViolations("export * from './contracts/index.ts';"), []);
|
||||
for (const source of forbiddenSources) {
|
||||
assert.ok(sdkBarrelViolations(source).length > 0, source);
|
||||
}
|
||||
});
|
||||
|
||||
test('general architecture scanner keeps presentation internal to framework and out of SDK', () => {
|
||||
assert.equal(FRAMEWORK_INTERNAL.test('framework/presentation/platform-ui-controller.ts'), true);
|
||||
assert.equal(SDK_ALLOWED.test('framework/presentation/platform-ui-controller.ts'), false);
|
||||
const violations = scanImportBoundaries({
|
||||
frameworkDir: join(projectRoot, 'YouleNexus', 'assets', 'framework'),
|
||||
gamesDir: join(projectRoot, 'games'),
|
||||
tsconfigPath: join(projectRoot, 'tsconfig.framework.json'),
|
||||
});
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../YouleNexus/assets/framework/platform/runtime.ts';
|
||||
import type { ScenePort } from '../../YouleNexus/assets/framework/platform/scene-port.ts';
|
||||
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
||||
import { PlatformUiController } from '../../YouleNexus/assets/framework/presentation/platform-ui-controller.ts';
|
||||
|
||||
function fixture(name: string): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
@@ -1970,3 +1971,278 @@ test('kick completes close, invalidate, clear, kicked scene, and wire stop despi
|
||||
PlatformStore.prototype.subscribe = originalStoreSubscribe;
|
||||
}
|
||||
});
|
||||
|
||||
test('real runtime renders the room before restoring canonical deskinfo', async () => {
|
||||
const events: string[] = [];
|
||||
const wire = new RecordingWireClient();
|
||||
const controller = new PlatformUiController({
|
||||
render: model => { events.push(`render:${model.page}`); },
|
||||
activate: page => { events.push(`activate:${page}`); },
|
||||
showOverlay: model => { events.push(`overlay:${model.kind}`); },
|
||||
}, { request: () => () => {} }, error => { throw error; });
|
||||
const deskinfo = { fixture: 'opaque' };
|
||||
const entry = makeGameEntry({ createModule: () => ({
|
||||
attach() { events.push('attach'); },
|
||||
handlePlatformEvent() {}, handleGameMessage() {}, dispose() {},
|
||||
restore(value) { assert.equal(value, deskinfo); events.push('restore'); },
|
||||
}) });
|
||||
const runtime = new PlatformRuntime({
|
||||
gameEntry: entry, resolveRuntimeConfig: async () => runtimeConfig(),
|
||||
createWireClient: () => wire, scene: controller,
|
||||
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
|
||||
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
|
||||
});
|
||||
controller.connect(runtime);
|
||||
await runtime.start();
|
||||
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
controller.login(ACCOUNT);
|
||||
wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login',
|
||||
data: fixture('player-login-success.json') } });
|
||||
controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
|
||||
wire.emit({ type: 'message', message: { route: 'agent', rpc: 'self_join_room',
|
||||
data: { ...fixture('self-join-room.json'), deskinfo } } });
|
||||
assert.ok(events.includes('render:lobby'));
|
||||
assert.ok(events.indexOf('attach') < events.indexOf('render:room'));
|
||||
assert.ok(events.indexOf('render:room') < events.indexOf('activate:room'));
|
||||
assert.ok(events.indexOf('activate:room') < events.indexOf('restore'));
|
||||
controller.dispose(); runtime.stop();
|
||||
assert.equal(wire.stopCalls, 1);
|
||||
});
|
||||
|
||||
test('real runtime keeps canonical roots and raw denials while login and join retries succeed', async () => {
|
||||
const wire = new RecordingWireClient();
|
||||
const overlays: unknown[] = [];
|
||||
const controller = new PlatformUiController({
|
||||
render: () => {}, activate: () => {},
|
||||
showOverlay: model => { overlays.push(model); },
|
||||
}, { request: () => () => {} }, error => { throw error; });
|
||||
const runtime = new PlatformRuntime({
|
||||
gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(),
|
||||
createWireClient: () => wire, scene: controller,
|
||||
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
|
||||
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
|
||||
});
|
||||
controller.connect(runtime);
|
||||
await runtime.start();
|
||||
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
|
||||
const preLoginRoot = runtime.state;
|
||||
controller.login(ACCOUNT);
|
||||
const loginDenial = { state: 7, showerror: 1, error: 'Login denied' };
|
||||
wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'player_login', data: loginDenial,
|
||||
} });
|
||||
assert.equal(runtime.state, preLoginRoot);
|
||||
const loginOverlay = overlays[overlays.length - 1] as { kind: string; outcome: { data: unknown } };
|
||||
assert.equal(loginOverlay.kind, 'denial');
|
||||
assert.equal(loginOverlay.outcome.data, loginDenial);
|
||||
|
||||
const retryAccount = { ...ACCOUNT, openid: 'retry-account' };
|
||||
controller.login(retryAccount);
|
||||
assert.equal((wire.sent[wire.sent.length - 1]!.data as Record<string, unknown>).openid, 'retry-account');
|
||||
wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
|
||||
} });
|
||||
const lobbyRoot = runtime.state;
|
||||
|
||||
const firstJoin = { roomcode: '999999', location: null, ip: '127.0.0.1' };
|
||||
controller.joinRoom(firstJoin);
|
||||
const joinDenial = { state: 99, roomcode: '999999', showerror: 1, error: 'No room' };
|
||||
wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'self_join_room', data: joinDenial,
|
||||
} });
|
||||
assert.equal(runtime.state, lobbyRoot);
|
||||
const joinOverlay = overlays[overlays.length - 1] as { kind: string; outcome: { data: unknown } };
|
||||
assert.equal(joinOverlay.kind, 'denial');
|
||||
assert.equal(joinOverlay.outcome.data, joinDenial);
|
||||
|
||||
controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
|
||||
wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'self_join_room', data: fixture('self-join-room.json'),
|
||||
} });
|
||||
assert.equal(runtime.state.room.kind, 'inside');
|
||||
assert.equal((overlays[overlays.length - 1] as { kind: string }).kind, 'none');
|
||||
controller.dispose(); runtime.stop();
|
||||
});
|
||||
|
||||
test('real runtime restores reconnect overlay and silences UI after kick or fatal', async () => {
|
||||
for (const terminal of ['kick', 'fatal'] as const) {
|
||||
const wire = new RecordingWireClient();
|
||||
const events: string[] = [];
|
||||
const controller = new PlatformUiController({
|
||||
render: model => { events.push(`render:${model.page}`); },
|
||||
activate: page => { events.push(`activate:${page}`); },
|
||||
showOverlay: model => { events.push(`overlay:${model.kind}`); },
|
||||
}, { request: () => () => {} }, error => { throw error; });
|
||||
const runtime = new PlatformRuntime({
|
||||
gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(),
|
||||
createWireClient: () => wire, scene: controller,
|
||||
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
|
||||
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
|
||||
});
|
||||
controller.connect(runtime);
|
||||
await runtime.start();
|
||||
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
controller.login(ACCOUNT);
|
||||
wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
|
||||
} });
|
||||
|
||||
wire.emit({ type: 'slow' });
|
||||
assert.equal(events[events.length - 1], 'overlay:reconnect');
|
||||
const activations = events.filter(event => event.startsWith('activate:')).length;
|
||||
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
assert.equal(events[events.length - 1], 'overlay:none');
|
||||
assert.equal(events.filter(event => event.startsWith('activate:')).length, activations);
|
||||
|
||||
if (terminal === 'kick') {
|
||||
wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'kick_server', data: { code: 1, msg: 'terminal' },
|
||||
} });
|
||||
assert.equal(events[events.length - 1], 'overlay:kicked');
|
||||
} else {
|
||||
wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'player_login', data: { state: 0 },
|
||||
} });
|
||||
assert.equal(events[events.length - 1], 'overlay:fatal');
|
||||
}
|
||||
const terminalEvents = [...events];
|
||||
assert.throws(() => controller.login(ACCOUNT), /terminal/);
|
||||
wire.emit({ type: 'slow' });
|
||||
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
assert.deepEqual(events, terminalEvents);
|
||||
assert.equal(wire.stopCalls, 1);
|
||||
controller.dispose(); runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('real runtime alone governs login readiness while resources are pending', async () => {
|
||||
const resourcesReady = deferred<void>();
|
||||
const wire = new RecordingWireClient();
|
||||
const controller = new PlatformUiController({
|
||||
render: () => {}, activate: () => {}, showOverlay: () => {},
|
||||
}, { request: () => () => {} }, error => { throw error; });
|
||||
const runtime = new PlatformRuntime({
|
||||
gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(),
|
||||
createWireClient: () => wire, scene: controller,
|
||||
loadResources: () => resourcesReady.promise, waitForMinimumDisplay: async () => {},
|
||||
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
|
||||
});
|
||||
controller.connect(runtime);
|
||||
const starting = runtime.start();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(wire.lifecycle, ['subscribe', 'start']);
|
||||
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
assert.equal(runtime.ready, false);
|
||||
assert.throws(() => controller.login(ACCOUNT), /login page/);
|
||||
assert.equal(wire.sent.length, 0);
|
||||
|
||||
resourcesReady.resolve(undefined);
|
||||
await starting;
|
||||
assert.equal(runtime.ready, true);
|
||||
controller.login(ACCOUNT);
|
||||
assert.equal(wire.sent.length, 1);
|
||||
controller.dispose(); runtime.stop();
|
||||
});
|
||||
|
||||
for (const phase of ['render', 'activate'] as const) {
|
||||
test(`real room ${phase} disposal interrupts restore and terminates the runtime`, async () => {
|
||||
const wire = new RecordingWireClient();
|
||||
let armed = false;
|
||||
let restores = 0;
|
||||
let controller!: PlatformUiController;
|
||||
controller = new PlatformUiController({
|
||||
render: model => {
|
||||
if (armed && phase === 'render' && model.page === 'room') controller.dispose();
|
||||
},
|
||||
activate: page => {
|
||||
if (armed && phase === 'activate' && page === 'room') controller.dispose();
|
||||
},
|
||||
showOverlay: () => {},
|
||||
}, { request: () => () => {} }, error => { throw error; });
|
||||
const runtime = new PlatformRuntime({
|
||||
gameEntry: makeGameEntry({ createModule: () => ({
|
||||
attach() {}, handlePlatformEvent() {}, handleGameMessage() {}, dispose() {},
|
||||
restore() { restores += 1; },
|
||||
}) }),
|
||||
resolveRuntimeConfig: async () => runtimeConfig(),
|
||||
createWireClient: () => wire, scene: controller,
|
||||
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
|
||||
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
|
||||
});
|
||||
controller.connect(runtime);
|
||||
await runtime.start();
|
||||
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
controller.login(ACCOUNT);
|
||||
wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
|
||||
} });
|
||||
armed = true;
|
||||
controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
|
||||
wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'self_join_room', data: fixture('self-join-room.json'),
|
||||
} });
|
||||
|
||||
assert.equal(restores, 0);
|
||||
assert.equal(wire.stopCalls, 1);
|
||||
assert.throws(() => runtime.login(ACCOUNT), error => {
|
||||
assert.ok(error instanceof Error);
|
||||
const errors = (error as Error & { readonly errors: readonly unknown[] }).errors;
|
||||
assert.match(String(errors[0]), /lifecycle interrupted/);
|
||||
return true;
|
||||
});
|
||||
runtime.stop();
|
||||
});
|
||||
}
|
||||
|
||||
test('application overlay teardown disposes Presenter, stops Runtime, and invalidates a late frame', async () => {
|
||||
const wire = new RecordingWireClient();
|
||||
const frames: Array<{ callback: () => void; cancelled: boolean }> = [];
|
||||
const renders: string[] = [];
|
||||
let armed = false;
|
||||
let runtime!: PlatformRuntime;
|
||||
let controller!: PlatformUiController;
|
||||
controller = new PlatformUiController({
|
||||
render: model => { renders.push(model.page); },
|
||||
activate: () => {},
|
||||
showOverlay: model => {
|
||||
if (armed && model.kind === 'reconnect') {
|
||||
controller.dispose();
|
||||
runtime.stop();
|
||||
}
|
||||
},
|
||||
}, { request: callback => {
|
||||
const frame = { callback, cancelled: false };
|
||||
frames.push(frame);
|
||||
return () => { frame.cancelled = true; };
|
||||
} }, error => { throw error; });
|
||||
runtime = new PlatformRuntime({
|
||||
gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(),
|
||||
createWireClient: () => wire, scene: controller,
|
||||
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
|
||||
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
|
||||
});
|
||||
controller.connect(runtime);
|
||||
await runtime.start();
|
||||
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
controller.login(ACCOUNT);
|
||||
wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
|
||||
} });
|
||||
armed = true;
|
||||
const framesBeforeStop = frames.length;
|
||||
|
||||
wire.emit({ type: 'slow' });
|
||||
|
||||
assert.equal(frames.length, framesBeforeStop + 1);
|
||||
const lateFrame = frames[frames.length - 1]!;
|
||||
assert.equal(lateFrame.cancelled, true);
|
||||
assert.equal(wire.stopCalls, 1);
|
||||
const rendered = [...renders];
|
||||
lateFrame.callback();
|
||||
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
assert.deepEqual(renders, rendered);
|
||||
assert.throws(() => runtime.login(ACCOUNT), /stopped/);
|
||||
runtime.stop();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user