test(presentation): verify runtime UI wiring and boundaries

This commit is contained in:
2026-09-05 13:12:35 +08:00
parent d29a659541
commit ec67fe6e6d
3 changed files with 557 additions and 28 deletions
@@ -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'; } from '../../YouleNexus/assets/framework/platform/runtime.ts';
import type { ScenePort } from '../../YouleNexus/assets/framework/platform/scene-port.ts'; import type { ScenePort } from '../../YouleNexus/assets/framework/platform/scene-port.ts';
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.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> { function fixture(name: string): Record<string, unknown> {
return JSON.parse( return JSON.parse(
@@ -1970,3 +1971,278 @@ test('kick completes close, invalidate, clear, kicked scene, and wire stop despi
PlatformStore.prototype.subscribe = originalStoreSubscribe; 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();
});
@@ -1,6 +1,6 @@
# Platform UI Wiring Core Implementation Plan # Platform UI Wiring Core Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** 完成启动、登录、大厅、进房的无引擎 UI 接线核心,并用真实 PlatformRuntime 回放验证,不修改 Cocos 序列化资源。 **Goal:** 完成启动、登录、大厅、进房的无引擎 UI 接线核心,并用真实 PlatformRuntime 回放验证,不修改 Cocos 序列化资源。
@@ -47,7 +47,7 @@ npm 不可解析时使用 `C:\Users\Joywayer\AppData\Local\Author Software\nvm\.
**Interfaces:** Consumes `PlatformStore.subscribe(listener: (state: PlatformState, previous: PlatformState) => void): () => void` 和 Runtime 既有清理路径。Produces `PlatformRuntime.subscribeState(listener: (state: PlatformState) => void, onError: (error: unknown) => void): () => void`,不立即通知,初值仍取 state。 **Interfaces:** Consumes `PlatformStore.subscribe(listener: (state: PlatformState, previous: PlatformState) => void): () => void` 和 Runtime 既有清理路径。Produces `PlatformRuntime.subscribeState(listener: (state: PlatformState) => void, onError: (error: unknown) => void): () => void`,不立即通知,初值仍取 state。
- [ ] 在 runtime.test.ts 现有 helpers 下增加实际观察测试: - [x] 在 runtime.test.ts 现有 helpers 下增加实际观察测试:
```ts ```ts
test('UI observes canonical commits and unsubscribe is idempotent', async () => { test('UI observes canonical commits and unsubscribe is idempotent', async () => {
@@ -70,8 +70,8 @@ test('UI observes canonical commits and unsubscribe is idempotent', async () =>
}); });
``` ```
- [ ] Run `node --import tsx --test framework-tests/platform/runtime.test.ts`,确认因缺 subscribeState 失败。 - [x] Run `node --import tsx --test framework-tests/platform/runtime.test.ts`,确认因缺 subscribeState 失败。
- [ ] 增加每观察者 active 标志、取消集合及终止清理;Store 回调交付提交值,不改为读取可能被重入更新的最新值。核心正常取消结构: - [x] 增加每观察者 active 标志、取消集合及终止清理;Store 回调交付提交值,不改为读取可能被重入更新的最新值。核心正常取消结构:
```ts ```ts
let active = true; let active = true;
@@ -97,8 +97,8 @@ return off;
``` ```
`stateObservers` 是 Runtime 新增 `Set<() => void>`;现有 failureError 签名为 `(operation: string, primary: unknown, cleanupErrors: readonly unknown[]): Error`。入口拒绝 `kicked`、`fatal`、`stopped`,错误携带当前生命周期;不新增生命周期。stop 与 fatal 在外部清理回调前使观察者失效。不得将原始错误改成默认数据。 `stateObservers` 是 Runtime 新增 `Set<() => void>`;现有 failureError 签名为 `(operation: string, primary: unknown, cleanupErrors: readonly unknown[]): Error`。入口拒绝 `kicked`、`fatal`、`stopped`,错误携带当前生命周期;不新增生命周期。stop 与 fatal 在外部清理回调前使观察者失效。不得将原始错误改成默认数据。
- [ ] 追加 RED/GREEN:监听器抛错原对象只交付一次、其他监听继续;onError 抛错保留两错误;stop/fatal 后迟到 Store 回调无效;监听中 stop/取消其他监听/再订阅不会复活。测试使用现有 runtime helpers 和 Store 行为,不用私有字段强转写状态。 - [x] 追加 RED/GREEN:监听器抛错原对象只交付一次、其他监听继续;onError 抛错保留两错误;stop/fatal 后迟到 Store 回调无效;监听中 stop/取消其他监听/再订阅不会复活。测试使用现有 runtime helpers 和 Store 行为,不用私有字段强转写状态。
- [ ] 重跑上述测试和通用验证。规格审查核对原引用、初值与取消语义,质量审查核对异常/重入清理。提交只含本 Task 两个文件,message `feat(platform): expose lifecycle-safe UI observation`。 - [x] 重跑上述测试和通用验证。规格审查核对原引用、初值与取消语义,质量审查核对异常/重入清理。提交只含本 Task 两个文件,message `feat(platform): expose lifecycle-safe UI observation`。
## Task 2: 定义 UI 端口并实现唯一快照投影 ## Task 2: 定义 UI 端口并实现唯一快照投影
@@ -140,7 +140,7 @@ export interface PlatformViewPort {
} }
``` ```
- [ ] 编写投影 RED;测试文件按相对路径 `../../YouleNexus/assets/framework/...` 导入类型/函数,使用 node:test 和 strict assert: - [x] 编写投影 RED;测试文件按相对路径 `../../YouleNexus/assets/framework/...` 导入类型/函数,使用 node:test 和 strict assert:
```ts ```ts
test('empty seats keep authoritative positions and count', () => { test('empty seats keep authoritative positions and count', () => {
@@ -170,8 +170,8 @@ test('empty seats keep authoritative positions and count', () => {
}); });
``` ```
- [ ] Run `node --import tsx --test framework-tests/presentation/page-model.test.ts`,缺模块/函数为预期 RED。 - [x] Run `node --import tsx --test framework-tests/presentation/page-model.test.ts`,缺模块/函数为预期 RED。
- [ ] 实现 switch(page),所有输出及嵌套模型 freeze;不直接泄露 PlatformPlayer 扩展字段。empty 来源仅允许 null。room occupied 的 onstate/isprepare 缺失抛带 seat/player 路径错误;selfSeat 必须指向 self。核心投影: - [x] 实现 switch(page),所有输出及嵌套模型 freeze;不直接泄露 PlatformPlayer 扩展字段。empty 来源仅允许 null。room occupied 的 onstate/isprepare 缺失抛带 seat/player 路径错误;selfSeat 必须指向 self。核心投影:
```ts ```ts
const playerModel = (player: PlatformPlayer): PlayerModel => Object.freeze({ const playerModel = (player: PlatformPlayer): PlayerModel => Object.freeze({
@@ -183,8 +183,8 @@ const canExit = state.app.phase === 'logged-in'
``` ```
PlatformPlayer 从既有 platform-types type-only 导入;room 是 inside 判别收窄后的 state.room。完整 canPrepare 条件按设计第 5 节,不扩展服务器规则。 PlatformPlayer 从既有 platform-types type-only 导入;room 是 inside 判别收窄后的 state.room。完整 canPrepare 条件按设计第 5 节,不扩展服务器规则。
- [ ] 追加逐项 RED/GREEN:lobby 缺 self、occupied 缺实体、selfSeat 不匹配必报错;bean=0/空 avatar 不变;已有准备/离线状态;战斗普通房禁退出、infinite 房可退出;reconnecting 禁命令;输入 roomtype 完全不解析。 - [x] 追加逐项 RED/GREEN:lobby 缺 self、occupied 缺实体、selfSeat 不匹配必报错;bean=0/空 avatar 不变;已有准备/离线状态;战斗普通房禁退出、infinite 房可退出;reconnecting 禁命令;输入 roomtype 完全不解析。
- [ ] 重跑本测试和通用验证;规格审查查空位及服务器原值,质量审查查冻结/判别联合/职责。scoped commit 三文件,message `feat(presentation): project immutable public page models`。 - [x] 重跑本测试和通用验证;规格审查查空位及服务器原值,质量审查查冻结/判别联合/职责。scoped commit 三文件,message `feat(presentation): project immutable public page models`。
## Task 3: 实现可取消、抗重入的合帧渲染 ## Task 3: 实现可取消、抗重入的合帧渲染
@@ -192,7 +192,7 @@ PlatformPlayer 从既有 platform-types type-only 导入;room 是 inside 判
**Interfaces:** Consumes Task 2 `FrameScheduler`。Produces `class FrameRenderer`,constructor `(scheduler: FrameScheduler, render: () => void, onFault: (error: unknown) => void)`;方法 `request(): void`, `flush(): void`, `dispose(): void`。 **Interfaces:** Consumes Task 2 `FrameScheduler`。Produces `class FrameRenderer`,constructor `(scheduler: FrameScheduler, render: () => void, onFault: (error: unknown) => void)`;方法 `request(): void`, `flush(): void`, `dispose(): void`。
- [ ] 写入可直接运行的 RED(导入 FrameRenderer、test、assert): - [x] 写入可直接运行的 RED(导入 FrameRenderer、test、assert):
```ts ```ts
test('coalesces frames and invalidates callbacks even if scheduler delivers late', () => { test('coalesces frames and invalidates callbacks even if scheduler delivers late', () => {
@@ -216,8 +216,8 @@ test('coalesces frames and invalidates callbacks even if scheduler delivers late
}); });
``` ```
- [ ] Run `node --import tsx --test framework-tests/presentation/frame-renderer.test.ts`,确认缺类 RED。 - [x] Run `node --import tsx --test framework-tests/presentation/frame-renderer.test.ts`,确认缺类 RED。
- [ ] 实现 pending cancel、generation、disposed。flush 先失效旧 token、取消旧任务再同步 render;同步异常直接抛出。request 的异步回调先释放 pending,再 try render,失败 dispose 并调用 onFault 原错误。dispose 先置终止位再调用外部 cancel;即使 cancel 抛错,迟到任务仍无效。核心失效顺序: - [x] 实现 pending cancel、generation、disposed。flush 先失效旧 token、取消旧任务再同步 render;同步异常直接抛出。request 的异步回调先释放 pending,再 try render,失败 dispose 并调用 onFault 原错误。dispose 先置终止位再调用外部 cancel;即使 cancel 抛错,迟到任务仍无效。核心失效顺序:
```ts ```ts
const cancel = this.pending; const cancel = this.pending;
@@ -227,8 +227,8 @@ if (cancel !== null) cancel();
``` ```
pending 初始为 null,generation 初始为 0。request/dispose 终止后的请求不排队;flush 终止后不渲染。取消错误和原 render 错误同时存在时用包含原对象的显式聚合错误,不吞掉任何一个。 pending 初始为 null,generation 初始为 0。request/dispose 终止后的请求不排队;flush 终止后不渲染。取消错误和原 render 错误同时存在时用包含原对象的显式聚合错误,不吞掉任何一个。
- [ ] 追加 RED/GREEN:render 内 request 只排下一帧;render 内 dispose 后不得重排;取消抛错仍失效;render 异步抛错只报一次;flush 同步抛错不吞。测试实现手动 scheduler,不用真实计时。 - [x] 追加 RED/GREEN:render 内 request 只排下一帧;render 内 dispose 后不得重排;取消抛错仍失效;render 异步抛错只报一次;flush 同步抛错不吞。测试实现手动 scheduler,不用真实计时。
- [ ] 重跑本测试与通用验证;规格审查查同帧最新/首帧,质量审查查 token 与回调重入。scoped commit 两文件,message `feat(presentation): add cancellable frame renderer`。 - [x] 重跑本测试与通用验证;规格审查查同帧最新/首帧,质量审查查 token 与回调重入。scoped commit 两文件,message `feat(presentation): add cancellable frame renderer`。
## Task 4: 实现 ScenePort Presenter 和原样命令入口 ## Task 4: 实现 ScenePort Presenter 和原样命令入口
@@ -236,7 +236,7 @@ pending 初始为 null,generation 初始为 0。request/dispose 终止后的
**Interfaces:** Consumes Tasks 1–3。Produces `class PlatformUiController implements ScenePort`;constructor `(view: PlatformViewPort, scheduler: FrameScheduler, onFault: (error: unknown) => void)`;`connect(runtime: UiRuntime): void`;全部既有 ScenePort 方法;`login(account: LoginAccountIdentity): void`, `joinRoom(command: JoinRoomCommand): void`, `prepare(): void`, `exitRoom(): void`, `dismissDenial(): void`, `dispose(): void`。账号/命令/ScenePort 类型均 type-only 导入既有定义。 **Interfaces:** Consumes Tasks 1–3。Produces `class PlatformUiController implements ScenePort`;constructor `(view: PlatformViewPort, scheduler: FrameScheduler, onFault: (error: unknown) => void)`;`connect(runtime: UiRuntime): void`;全部既有 ScenePort 方法;`login(account: LoginAccountIdentity): void`, `joinRoom(command: JoinRoomCommand): void`, `prepare(): void`, `exitRoom(): void`, `dismissDenial(): void`, `dispose(): void`。账号/命令/ScenePort 类型均 type-only 导入既有定义。
- [ ] 先写纯端口 RED,测试 helper 也留在本测试文件: - [x] 先写纯端口 RED,测试 helper 也留在本测试文件:
```ts ```ts
test('login forwards the exact account only after the runtime opens login', () => { test('login forwards the exact account only after the runtime opens login', () => {
@@ -266,8 +266,8 @@ test('login forwards the exact account only after the runtime opens login', () =
}); });
``` ```
- [ ] Run `node --import tsx --test framework-tests/presentation/platform-ui-controller.test.ts`,缺类为 RED。 - [x] Run `node --import tsx --test framework-tests/presentation/platform-ui-controller.test.ts`,缺类为 RED。
- [ ] 实现一次 connect;订阅之后读取 runtime.state。尚未收到 ScenePort 页面请求时不画业务页。切页保存 page、flush 最新模型、检查未被 render 重入终止后 activate;不调用 director/loadScene。核心模型读取: - [x] 实现一次 connect;订阅之后读取 runtime.state。尚未收到 ScenePort 页面请求时不画业务页。切页保存 page、flush 最新模型、检查未被 render 重入终止后 activate;不调用 director/loadScene。核心模型读取:
```ts ```ts
private renderCurrent(): void { private renderCurrent(): void {
@@ -278,9 +278,9 @@ private renderCurrent(): void {
``` ```
page 初始 null;requireRuntime 对未 connect/已 dispose 显式抛错。用 FrameRenderer 承担合帧,controller 保留 terminal/disposed 生命周期及当前 OverlayModel,不保留玩家/房间镜像。每个外部 View 调用后检查终止位再继续。connect 重复拒绝;dispose 幂等,取消 frame 和 subscription 均尝试执行。 page 初始 null;requireRuntime 对未 connect/已 dispose 显式抛错。用 FrameRenderer 承担合帧,controller 保留 terminal/disposed 生命周期及当前 OverlayModel,不保留玩家/房间镜像。每个外部 View 调用后检查终止位再继续。connect 重复拒绝;dispose 幂等,取消 frame 和 subscription 均尝试执行。
- [ ] 实现命令 guards,直接 `runtime.login(account)`/`runtime.joinRoom(command)`,不 spread 或加工业务输入。prepare/exit 使用最新 page model 决定 UI 可用性。恢复/denial/kick/fatal 规则严格按设计第 6 节:terminal 禁命令但仍允许第一次 fatal 画 overlay,fatal 优先于 kicked。 - [x] 实现命令 guards,直接 `runtime.login(account)`/`runtime.joinRoom(command)`,不 spread 或加工业务输入。prepare/exit 使用最新 page model 决定 UI 可用性。恢复/denial/kick/fatal 规则严格按设计第 6 节:terminal 禁命令但仍允许第一次 fatal 画 overlay,fatal 优先于 kicked。
- [ ] 每个行为先加 RED 再实现:join 原引用;ready=false 禁登录;每帧最新 state;showRoom 同步 render→activate;denial 保留 data 身份且重试成功;connected/logged-in 清 reconnect 但不清 denial;kick/fatal 后迟到回调不绘制;render/activate/showOverlay 中 dispose 不再调用后续 View;订阅/frame 错误调用 onFault,同步错误向 Runtime 传播。 - [x] 每个行为先加 RED 再实现:join 原引用;ready=false 禁登录;每帧最新 state;showRoom 同步 render→activate;denial 保留 data 身份且重试成功;connected/logged-in 清 reconnect 但不清 denial;kick/fatal 后迟到回调不绘制;render/activate/showOverlay 中 dispose 不再调用后续 View;订阅/frame 错误调用 onFault,同步错误向 Runtime 传播。
- [ ] 重跑三个 presentation 测试和通用验证;规格审查查导航唯一来源/拒绝重试,质量审查查 controller 未引入第二状态源、循环清理。scoped commit 两文件,message `feat(presentation): wire platform scenes and UI commands`。 - [x] 重跑三个 presentation 测试和通用验证;规格审查查导航唯一来源/拒绝重试,质量审查查 controller 未引入第二状态源、循环清理。scoped commit 两文件,message `feat(presentation): wire platform scenes and UI commands`。
## Task 5: 用真实 Runtime 验证整条接线并锁定边界 ## Task 5: 用真实 Runtime 验证整条接线并锁定边界
@@ -288,7 +288,7 @@ page 初始 null;requireRuntime 对未 connect/已 dispose 显式抛错。用
**Interfaces:** Consumes `PlatformUiController`、真实 `PlatformRuntime` 及 runtime.test.ts 现有 helpers。Produces 无新增生产 API;只读集成回放与静态边界门禁。 **Interfaces:** Consumes `PlatformUiController`、真实 `PlatformRuntime` 及 runtime.test.ts 现有 helpers。Produces 无新增生产 API;只读集成回放与静态边界门禁。
- [ ] 将下面集成 RED 加入 runtime.test.ts,导入 controller;使用现有 ACCOUNT、DEVICE、runtimeConfig、RecordingWireClient、ManualClock、fixture、makeGameEntry: - [x] 将下面集成 RED 加入 runtime.test.ts,导入 controller;使用现有 ACCOUNT、DEVICE、runtimeConfig、RecordingWireClient、ManualClock、fixture、makeGameEntry:
```ts ```ts
test('real runtime renders the room before restoring canonical deskinfo', async () => { test('real runtime renders the room before restoring canonical deskinfo', async () => {
@@ -329,9 +329,9 @@ test('real runtime renders the room before restoring canonical deskinfo', async
}); });
``` ```
- [ ] Run `node --import tsx --test framework-tests/platform/runtime.test.ts`。联合测试若初次已绿,不制造伪失败;通过以下定向负例验证测试能抓回归:临时在 controller 中把 showRoom 首帧排到 scheduler,确认该用例因缺 room render/activate 失败,然后恢复正确代码。该受控变异只涉及本批 TS 并不得提交。 - [x] Run `node --import tsx --test framework-tests/platform/runtime.test.ts`。联合测试若初次已绿,不制造伪失败;通过以下定向负例验证测试能抓回归:临时在 controller 中把 showRoom 首帧排到 scheduler,确认该用例因缺 room render/activate 失败,然后恢复正确代码。该受控变异只涉及本批 TS 并不得提交。
- [ ] 追加真实 Runtime 回放:非零登录、非零进房保持原 Store root/原 data,重试能成功;断线重连 overlay 可恢复;kick/fatal 无后续 UI 命令;延迟 loadResources 时不得由 controller 登录;view 回调销毁 Runtime/Presenter 不得 restore;stop 后旧 scheduler 回调无效。夹具数据仅在测试中具备明确来源,不改服务器 fixture 文件来让错误行为通过。 - [x] 追加真实 Runtime 回放:非零登录、非零进房保持原 Store root/原 data,重试能成功;断线重连 overlay 可恢复;kick/fatal 无后续 UI 命令;延迟 loadResources 时不得由 controller 登录;view 回调销毁 Runtime/Presenter 不得 restore;stop 后旧 scheduler 回调无效。夹具数据仅在测试中具备明确来源,不改服务器 fixture 文件来让错误行为通过。
- [ ] 编写 architecture 测试,用 TypeScript AST 遍历 presentation 的 import/export/call expression,type-only 合同引用允许,值导入 protocol/net/cc 和动态导入同样禁止。扫描使用 `readFileSync/readdirSync`,不启动 Creator、不写资源。基础 RED 断言先覆盖禁止真实值导入: - [x] 编写 architecture 测试,用 TypeScript AST 遍历 presentation 的 import/export/call expression,type-only 合同引用允许,值导入 protocol/net/cc 和动态导入同样禁止。扫描使用 `readFileSync/readdirSync`,不启动 Creator、不写资源。基础 RED 断言先覆盖禁止真实值导入:
```js ```js
import ts from 'typescript'; import ts from 'typescript';
@@ -351,7 +351,7 @@ test('presentation boundary recognizes forbidden production imports', () => {
``` ```
完成 AST 取值与 type-only 判别后,增加内联正反样本(普通/混合 type import、export from、import()、require()),再扫描真实目录;同文件核对 SDK index 仍仅导出 contracts。禁止整个 presentation 被添加到子游戏例外白名单。不要用单个字符串搜索冒充完整边界检查。 完成 AST 取值与 type-only 判别后,增加内联正反样本(普通/混合 type import、export from、import()、require()),再扫描真实目录;同文件核对 SDK index 仍仅导出 contracts。禁止整个 presentation 被添加到子游戏例外白名单。不要用单个字符串搜索冒充完整边界检查。
- [ ] Run `node --test framework-tests/architecture/presentation-boundaries.test.mjs`;暂时注入禁止 import 到新增 presentation 文件验证真实扫描失败,立即恢复;GREEN 后运行所有纯 TS 测试: - [x] Run `node --test framework-tests/architecture/presentation-boundaries.test.mjs`;暂时注入禁止 import 到新增 presentation 文件验证真实扫描失败,立即恢复;GREEN 后运行所有纯 TS 测试:
```powershell ```powershell
$uiCoreTests = @(rg --files framework-tests -g '*.test.ts') $uiCoreTests = @(rg --files framework-tests -g '*.test.ts')
@@ -366,10 +366,18 @@ git status --short
``` ```
运行前检查所选 .test.ts 没有生成资源副作用;不混入 legacy-layer-migration 的 .mjs 生成器。核对从执行 base SHA 到 HEAD 的 committed diff 以及工作区/untracked,确认没有 `.scene/.prefab/.anim/.meta`;不仅检查最后一个 commit。 运行前检查所选 .test.ts 没有生成资源副作用;不混入 legacy-layer-migration 的 .mjs 生成器。核对从执行 base SHA 到 HEAD 的 committed diff 以及工作区/untracked,确认没有 `.scene/.prefab/.anim/.meta`;不仅检查最后一个 commit。
- [ ] 规格审查把设计第 8 节每项映射到测试;质量审查核对 UI 生命周期重入和静态检查漏口。只提交本 Task 测试和计划记录,message `test(presentation): verify runtime UI wiring and boundaries`。 - [x] 规格审查把设计第 8 节每项映射到测试;质量审查核对 UI 生命周期重入和静态检查漏口。只提交本 Task 测试和计划记录,message `test(presentation): verify runtime UI wiring and boundaries`。
## 完成与后续门槛 ## 完成与后续门槛
### 3A 执行验证记录(2026-09-05)
- 执行分支 `codex/platform-ui-wiring-core`;实际 base `2d7655a5d7d2e56733d9622eac6d6e0fcb36254b`,Tasks 1–4 已经逐项规格与质量审查并提交,Task 4 head `d29a659541bcb0dcfe9037a6122ff03a25b9404f`。
- Task 5 实现后的独立验证:显式枚举 `.test.ts` 的回归 499/499;两个指定架构测试文件合计 49/49;上述命令退出码均为 0。`tsc --noEmit`、`check-import-boundaries.mjs`、`git diff --check` 通过。旧 EventBus/reactive/StoreBinder 异常路径测试有预期错误日志;新增测试输出干净。
- 真实 Runtime 63/63,新增回放验证原始 deskinfo 恢复顺序、非零拒绝原引用及重试、重连、终止、资源门槛及重入销毁。异步 showRoom 定向变异和真实禁止 import 变异均使对应测试失败,已立即恢复。
- 验证前扫描纯 TS 测试的资源写入风险;未运行生成器混入的全量 runner。分支 base 到当前提交及未提交/untracked 范围没有 `.scene/.prefab/.anim/.meta` 差异。执行早期误运行旧 runner 产生的两份 out prefab 变更已获用户明确授权、精确恢复并核验。
- UI 接线核心仅为 3A 无引擎回放;不是实际 Cocos 接管。Task 5 已完成规格与质量审查:SDK 本地导出漏检及精确声明数量问题经过两轮 RED/GREEN 修复和范围复审,全部关闭。最终整分支审查尚待完成;最终提交可从本记录所在分支 HEAD 查询。
报告实际测试命令、数量、退出码、base/head SHA 与资源零变更检查;不沿用过去的 408/408 数字当本批结果。3A 完成可表述为“UI 接线核心已通过无引擎回放”。 报告实际测试命令、数量、退出码、base/head SHA 与资源零变更检查;不沿用过去的 408/408 数字当本批结果。3A 完成可表述为“UI 接线核心已通过无引擎回放”。
不自动进入 3B。进入实际 Cocos 接管前,须另行确认允许修改的序列化文件、真实 GameEntry/目标宿主/账号来源,再通过 MCP 核实控件绑定并制定编辑器实施任务。主题扩展与真实子游戏继续各自独立计划。 不自动进入 3B。进入实际 Cocos 接管前,须另行确认允许修改的序列化文件、真实 GameEntry/目标宿主/账号来源,再通过 MCP 核实控件绑定并制定编辑器实施任务。主题扩展与真实子游戏继续各自独立计划。