From ec67fe6e6deed1e713c6580cc6b8465c96d322a1 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 13:12:35 +0800 Subject: [PATCH] test(presentation): verify runtime UI wiring and boundaries --- .../presentation-boundaries.test.mjs | 245 ++++++++++++++++ .../framework-tests/platform/runtime.test.ts | 276 ++++++++++++++++++ .../plans/2026-09-05-platform-ui-wiring.md | 64 ++-- 3 files changed, 557 insertions(+), 28 deletions(-) create mode 100644 cocoscreator_projects/framework-tests/architecture/presentation-boundaries.test.mjs diff --git a/cocoscreator_projects/framework-tests/architecture/presentation-boundaries.test.mjs b/cocoscreator_projects/framework-tests/architecture/presentation-boundaries.test.mjs new file mode 100644 index 0000000..2d493d3 --- /dev/null +++ b/cocoscreator_projects/framework-tests/architecture/presentation-boundaries.test.mjs @@ -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: '', 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, []); +}); diff --git a/cocoscreator_projects/framework-tests/platform/runtime.test.ts b/cocoscreator_projects/framework-tests/platform/runtime.test.ts index cfc56dc..a39c23d 100644 --- a/cocoscreator_projects/framework-tests/platform/runtime.test.ts +++ b/cocoscreator_projects/framework-tests/platform/runtime.test.ts @@ -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 { 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).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(); + 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(); +}); diff --git a/docs/superpowers/plans/2026-09-05-platform-ui-wiring.md b/docs/superpowers/plans/2026-09-05-platform-ui-wiring.md index d22395f..5973dd3 100644 --- a/docs/superpowers/plans/2026-09-05-platform-ui-wiring.md +++ b/docs/superpowers/plans/2026-09-05-platform-ui-wiring.md @@ -1,6 +1,6 @@ # 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 序列化资源。 @@ -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。 -- [ ] 在 runtime.test.ts 现有 helpers 下增加实际观察测试: +- [x] 在 runtime.test.ts 现有 helpers 下增加实际观察测试: ```ts 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 失败。 -- [ ] 增加每观察者 active 标志、取消集合及终止清理;Store 回调交付提交值,不改为读取可能被重入更新的最新值。核心正常取消结构: +- [x] Run `node --import tsx --test framework-tests/platform/runtime.test.ts`,确认因缺 subscribeState 失败。 +- [x] 增加每观察者 active 标志、取消集合及终止清理;Store 回调交付提交值,不改为读取可能被重入更新的最新值。核心正常取消结构: ```ts 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 在外部清理回调前使观察者失效。不得将原始错误改成默认数据。 -- [ ] 追加 RED/GREEN:监听器抛错原对象只交付一次、其他监听继续;onError 抛错保留两错误;stop/fatal 后迟到 Store 回调无效;监听中 stop/取消其他监听/再订阅不会复活。测试使用现有 runtime helpers 和 Store 行为,不用私有字段强转写状态。 -- [ ] 重跑上述测试和通用验证。规格审查核对原引用、初值与取消语义,质量审查核对异常/重入清理。提交只含本 Task 两个文件,message `feat(platform): expose lifecycle-safe UI observation`。 +- [x] 追加 RED/GREEN:监听器抛错原对象只交付一次、其他监听继续;onError 抛错保留两错误;stop/fatal 后迟到 Store 回调无效;监听中 stop/取消其他监听/再订阅不会复活。测试使用现有 runtime helpers 和 Store 行为,不用私有字段强转写状态。 +- [x] 重跑上述测试和通用验证。规格审查核对原引用、初值与取消语义,质量审查核对异常/重入清理。提交只含本 Task 两个文件,message `feat(platform): expose lifecycle-safe UI observation`。 ## 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 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。 -- [ ] 实现 switch(page),所有输出及嵌套模型 freeze;不直接泄露 PlatformPlayer 扩展字段。empty 来源仅允许 null。room occupied 的 onstate/isprepare 缺失抛带 seat/player 路径错误;selfSeat 必须指向 self。核心投影: +- [x] Run `node --import tsx --test framework-tests/presentation/page-model.test.ts`,缺模块/函数为预期 RED。 +- [x] 实现 switch(page),所有输出及嵌套模型 freeze;不直接泄露 PlatformPlayer 扩展字段。empty 来源仅允许 null。room occupied 的 onstate/isprepare 缺失抛带 seat/player 路径错误;selfSeat 必须指向 self。核心投影: ```ts 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 节,不扩展服务器规则。 -- [ ] 追加逐项 RED/GREEN:lobby 缺 self、occupied 缺实体、selfSeat 不匹配必报错;bean=0/空 avatar 不变;已有准备/离线状态;战斗普通房禁退出、infinite 房可退出;reconnecting 禁命令;输入 roomtype 完全不解析。 -- [ ] 重跑本测试和通用验证;规格审查查空位及服务器原值,质量审查查冻结/判别联合/职责。scoped commit 三文件,message `feat(presentation): project immutable public page models`。 +- [x] 追加逐项 RED/GREEN:lobby 缺 self、occupied 缺实体、selfSeat 不匹配必报错;bean=0/空 avatar 不变;已有准备/离线状态;战斗普通房禁退出、infinite 房可退出;reconnecting 禁命令;输入 roomtype 完全不解析。 +- [x] 重跑本测试和通用验证;规格审查查空位及服务器原值,质量审查查冻结/判别联合/职责。scoped commit 三文件,message `feat(presentation): project immutable public page models`。 ## 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`。 -- [ ] 写入可直接运行的 RED(导入 FrameRenderer、test、assert): +- [x] 写入可直接运行的 RED(导入 FrameRenderer、test、assert): ```ts 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。 -- [ ] 实现 pending cancel、generation、disposed。flush 先失效旧 token、取消旧任务再同步 render;同步异常直接抛出。request 的异步回调先释放 pending,再 try render,失败 dispose 并调用 onFault 原错误。dispose 先置终止位再调用外部 cancel;即使 cancel 抛错,迟到任务仍无效。核心失效顺序: +- [x] Run `node --import tsx --test framework-tests/presentation/frame-renderer.test.ts`,确认缺类 RED。 +- [x] 实现 pending cancel、generation、disposed。flush 先失效旧 token、取消旧任务再同步 render;同步异常直接抛出。request 的异步回调先释放 pending,再 try render,失败 dispose 并调用 onFault 原错误。dispose 先置终止位再调用外部 cancel;即使 cancel 抛错,迟到任务仍无效。核心失效顺序: ```ts const cancel = this.pending; @@ -227,8 +227,8 @@ if (cancel !== null) cancel(); ``` pending 初始为 null,generation 初始为 0。request/dispose 终止后的请求不排队;flush 终止后不渲染。取消错误和原 render 错误同时存在时用包含原对象的显式聚合错误,不吞掉任何一个。 -- [ ] 追加 RED/GREEN:render 内 request 只排下一帧;render 内 dispose 后不得重排;取消抛错仍失效;render 异步抛错只报一次;flush 同步抛错不吞。测试实现手动 scheduler,不用真实计时。 -- [ ] 重跑本测试与通用验证;规格审查查同帧最新/首帧,质量审查查 token 与回调重入。scoped commit 两文件,message `feat(presentation): add cancellable frame renderer`。 +- [x] 追加 RED/GREEN:render 内 request 只排下一帧;render 内 dispose 后不得重排;取消抛错仍失效;render 异步抛错只报一次;flush 同步抛错不吞。测试实现手动 scheduler,不用真实计时。 +- [x] 重跑本测试与通用验证;规格审查查同帧最新/首帧,质量审查查 token 与回调重入。scoped commit 两文件,message `feat(presentation): add cancellable frame renderer`。 ## 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 导入既有定义。 -- [ ] 先写纯端口 RED,测试 helper 也留在本测试文件: +- [x] 先写纯端口 RED,测试 helper 也留在本测试文件: ```ts 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。 -- [ ] 实现一次 connect;订阅之后读取 runtime.state。尚未收到 ScenePort 页面请求时不画业务页。切页保存 page、flush 最新模型、检查未被 render 重入终止后 activate;不调用 director/loadScene。核心模型读取: +- [x] Run `node --import tsx --test framework-tests/presentation/platform-ui-controller.test.ts`,缺类为 RED。 +- [x] 实现一次 connect;订阅之后读取 runtime.state。尚未收到 ScenePort 页面请求时不画业务页。切页保存 page、flush 最新模型、检查未被 render 重入终止后 activate;不调用 director/loadScene。核心模型读取: ```ts 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 均尝试执行。 -- [ ] 实现命令 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 传播。 -- [ ] 重跑三个 presentation 测试和通用验证;规格审查查导航唯一来源/拒绝重试,质量审查查 controller 未引入第二状态源、循环清理。scoped commit 两文件,message `feat(presentation): wire platform scenes and UI commands`。 +- [x] 实现命令 guards,直接 `runtime.login(account)`/`runtime.joinRoom(command)`,不 spread 或加工业务输入。prepare/exit 使用最新 page model 决定 UI 可用性。恢复/denial/kick/fatal 规则严格按设计第 6 节:terminal 禁命令但仍允许第一次 fatal 画 overlay,fatal 优先于 kicked。 +- [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 传播。 +- [x] 重跑三个 presentation 测试和通用验证;规格审查查导航唯一来源/拒绝重试,质量审查查 controller 未引入第二状态源、循环清理。scoped commit 两文件,message `feat(presentation): wire platform scenes and UI commands`。 ## Task 5: 用真实 Runtime 验证整条接线并锁定边界 @@ -288,7 +288,7 @@ page 初始 null;requireRuntime 对未 connect/已 dispose 显式抛错。用 **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 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 并不得提交。 -- [ ] 追加真实 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] Run `node --import tsx --test framework-tests/platform/runtime.test.ts`。联合测试若初次已绿,不制造伪失败;通过以下定向负例验证测试能抓回归:临时在 controller 中把 showRoom 首帧排到 scheduler,确认该用例因缺 room render/activate 失败,然后恢复正确代码。该受控变异只涉及本批 TS 并不得提交。 +- [x] 追加真实 Runtime 回放:非零登录、非零进房保持原 Store root/原 data,重试能成功;断线重连 overlay 可恢复;kick/fatal 无后续 UI 命令;延迟 loadResources 时不得由 controller 登录;view 回调销毁 Runtime/Presenter 不得 restore;stop 后旧 scheduler 回调无效。夹具数据仅在测试中具备明确来源,不改服务器 fixture 文件来让错误行为通过。 +- [x] 编写 architecture 测试,用 TypeScript AST 遍历 presentation 的 import/export/call expression,type-only 合同引用允许,值导入 protocol/net/cc 和动态导入同样禁止。扫描使用 `readFileSync/readdirSync`,不启动 Creator、不写资源。基础 RED 断言先覆盖禁止真实值导入: ```js 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 被添加到子游戏例外白名单。不要用单个字符串搜索冒充完整边界检查。 -- [ ] 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 $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。 -- [ ] 规格审查把设计第 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 接线核心已通过无引擎回放”。 不自动进入 3B。进入实际 Cocos 接管前,须另行确认允许修改的序列化文件、真实 GameEntry/目标宿主/账号来源,再通过 MCP 核实控件绑定并制定编辑器实施任务。主题扩展与真实子游戏继续各自独立计划。