docs: design and plan platform UI wiring core
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
# 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.
|
||||
|
||||
**Goal:** 完成启动、登录、大厅、进房的无引擎 UI 接线核心,并用真实 PlatformRuntime 回放验证,不修改 Cocos 序列化资源。
|
||||
|
||||
**Architecture:** Runtime 保持唯一状态源;presentation 投影 ViewModel、转发命令并实现同步 ScenePort。实际节点绑定留给单独批准的 3B 编辑器批次;3A 使用记录型 ViewPort 验证。
|
||||
|
||||
**Tech Stack:** 现有 TypeScript、node:test、tsx;不新增依赖,不导入 cc。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-05-platform-ui-wiring-design.md`,并继承 `2026-09-04-framework-subgame-zero-coupling-migration-design.md`。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 服务器包、route/rpc、roomtype 与 deskinfo 全部沿用已验证 contracts。不得为 UI 新增字段。
|
||||
- 远程配置与原生读取/WVJB 不在本批修改。
|
||||
- 任何 `.scene/.prefab/.anim/.meta` 变更均使 3A 验收失败。
|
||||
- 3A 测试夹具只留在 framework-tests,不导入 assets,不声明生产 GameEntry。
|
||||
- 不扩充 SDK 出口,不依赖旧 PlatformSession/RoomRPCBus,不引入下游兜底。
|
||||
- 每个 Task 独立执行 RED → GREEN → 规格/实现审查 → 代码质量审查 → 修复复验 → scoped commit。审查不通过不得推进下一 Task。
|
||||
- 执行使用新的隔离 worktree;先按 using-git-worktrees 核实,不重建已删除的旧工作树来冒充继续旧任务。本文件不授权编辑器资源写入。
|
||||
|
||||
## 执行环境与文件地图
|
||||
|
||||
所有代码路径在本计划中相对 `cocoscreator_projects/`;文档路径相对仓库根。所有命令在 `cocoscreator_projects` 执行,git 路径也据此指定。
|
||||
|
||||
先完整阅读仓库 AGENTS.md、上述两个设计及本计划。基线为 `8eae325`,执行时检查分支状态并记录新的 base SHA;保留无关修改。不需要重新执行已完成的 decoupled-runtime Tasks 1–11。
|
||||
|
||||
新增 `YouleNexus/assets/framework/presentation/`:`ui-contracts.ts` 管端口和模型;`page-model.ts` 管纯投影;`frame-renderer.ts` 管合帧与取消;`platform-ui-controller.ts` 管 ScenePort 和交互。不建第二个 Store 或 barrel SDK 出口。
|
||||
|
||||
修改 `platform/runtime.ts` 只增加只读观察及其终止清理;测试放在 `framework-tests/presentation/`,真实运行时联合回放追加到已有 `framework-tests/platform/runtime.test.ts`,复用其中 makeRuntime、RecordingWireClient、ManualClock、makeGameEntry、fixture,不把测试帮助函数搬进产品代码。
|
||||
|
||||
通用验证命令:
|
||||
|
||||
```powershell
|
||||
node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit
|
||||
node scripts/check-import-boundaries.mjs
|
||||
node --test framework-tests/architecture/import-boundaries.test.mjs
|
||||
git diff --check
|
||||
```
|
||||
|
||||
npm 不可解析时使用 `C:\Users\Joywayer\AppData\Local\Author Software\nvm\.nodejs\npm.exe`。tsx 启动的 `uv_os_get_passwd` ENOMEM 属于待核实环境失败,不是有效 RED;申请在允许的环境运行同一只读测试命令,不改依赖、不写个人环境绕过脚本。不要运行含旧资源迁移生成器的全量 npm test:framework。
|
||||
|
||||
## Task 1: 给 Runtime 增加有明确错误通道的只读观察
|
||||
|
||||
**Files:** Modify `YouleNexus/assets/framework/platform/runtime.ts`; Test `framework-tests/platform/runtime.test.ts`。
|
||||
|
||||
**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 下增加实际观察测试:
|
||||
|
||||
```ts
|
||||
test('UI observes canonical commits and unsubscribe is idempotent', async () => {
|
||||
const { runtime, wire } = makeRuntime();
|
||||
const seen: unknown[] = [];
|
||||
const off = runtime.subscribeState(s => seen.push(s), e => { throw e; });
|
||||
assert.equal(seen.length, 0);
|
||||
await runtime.start();
|
||||
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
assert.equal(seen[seen.length - 1], runtime.state);
|
||||
off(); off();
|
||||
const count = seen.length;
|
||||
runtime.login(ACCOUNT);
|
||||
wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
|
||||
} });
|
||||
assert.equal(seen.length, count);
|
||||
runtime.stop();
|
||||
assert.throws(() => runtime.subscribeState(() => {}, () => {}), /stopped/);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] Run `node --import tsx --test framework-tests/platform/runtime.test.ts`,确认因缺 subscribeState 失败。
|
||||
- [ ] 增加每观察者 active 标志、取消集合及终止清理;Store 回调交付提交值,不改为读取可能被重入更新的最新值。核心正常取消结构:
|
||||
|
||||
```ts
|
||||
let active = true;
|
||||
const off = (): void => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
unsubscribeStore();
|
||||
this.stateObservers.delete(off);
|
||||
};
|
||||
const unsubscribeStore = this.store.subscribe(state => {
|
||||
if (!active) return;
|
||||
try { listener(state); }
|
||||
catch (error) {
|
||||
off();
|
||||
try { onError(error); }
|
||||
catch (callbackError) {
|
||||
this.showFatalOnce(this.failureError('UI observer', error, [callbackError]));
|
||||
}
|
||||
}
|
||||
});
|
||||
this.stateObservers.add(off);
|
||||
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`。
|
||||
|
||||
## Task 2: 定义 UI 端口并实现唯一快照投影
|
||||
|
||||
**Files:** Create `YouleNexus/assets/framework/presentation/ui-contracts.ts`, `YouleNexus/assets/framework/presentation/page-model.ts`, `framework-tests/presentation/page-model.test.ts`。
|
||||
|
||||
**Interfaces:** Consumes Task 1 的 PlatformRuntime 只读观察和既有 `PlatformState`、`selectSelfPlayer`、`selectRoomPlayers`。Produces 下列完整端口与 `selectPageModel(page: PageId, state: PlatformState, ready: boolean): PageModel`:
|
||||
|
||||
```ts
|
||||
import type { PlatformRuntime } from '../platform/runtime.ts';
|
||||
import type { ServerDenial } from '../platform/runtime-session.ts';
|
||||
export type UiRuntime = Pick<PlatformRuntime,
|
||||
'state' | 'ready' | 'subscribeState' | 'login' | 'joinRoom' | 'prepare' | 'exitRoom'>;
|
||||
export type PageId = 'loading' | 'login' | 'lobby' | 'room';
|
||||
export interface PlayerModel {
|
||||
readonly playerid: number; readonly nickname: string;
|
||||
readonly avatar: string; readonly bean: number;
|
||||
}
|
||||
export type SeatModel =
|
||||
| { readonly kind: 'empty'; readonly seat: number }
|
||||
| { readonly kind: 'occupied'; readonly seat: number;
|
||||
readonly player: PlayerModel; readonly ready: boolean; readonly offline: boolean };
|
||||
export type PageModel =
|
||||
| { readonly page: 'loading' }
|
||||
| { readonly page: 'login'; readonly canLogin: boolean }
|
||||
| { readonly page: 'lobby'; readonly self: PlayerModel }
|
||||
| { readonly page: 'room'; readonly roomcode: string; readonly selfSeat: number;
|
||||
readonly seats: readonly SeatModel[]; readonly canPrepare: boolean; readonly canExit: boolean };
|
||||
export type OverlayModel =
|
||||
| { readonly kind: 'none' }
|
||||
| { readonly kind: 'reconnect' }
|
||||
| { readonly kind: 'denial'; readonly outcome: ServerDenial }
|
||||
| { readonly kind: 'kicked'; readonly data: unknown }
|
||||
| { readonly kind: 'fatal'; readonly error: Error };
|
||||
export interface FrameScheduler { request(callback: () => void): () => void; }
|
||||
export interface PlatformViewPort {
|
||||
render(model: PageModel): void;
|
||||
activate(page: PageId): void;
|
||||
showOverlay(model: OverlayModel): void;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] 编写投影 RED;测试文件按相对路径 `../../YouleNexus/assets/framework/...` 导入类型/函数,使用 node:test 和 strict assert:
|
||||
|
||||
```ts
|
||||
test('empty seats keep authoritative positions and count', () => {
|
||||
for (const count of [2, 4, 10]) {
|
||||
const state: PlatformState = {
|
||||
app: { phase: 'logged-in' },
|
||||
players: { selfPlayerId: 7, entities: {
|
||||
7: { playerid: 7, nickname: 'self', avatar: '', bean: 0, sex: 0,
|
||||
isprepare: 0, onstate: 0 },
|
||||
} },
|
||||
room: { kind: 'inside', roomcode: '000123', selfSeat: 0,
|
||||
seatPlayerIds: [7, ...Array<null>(count - 1).fill(null)],
|
||||
readySeats: [], offlineSeats: [], roomtype: [], stage: 0,
|
||||
needprepare: 1, infinite: 0 },
|
||||
};
|
||||
const model = selectPageModel('room', state, true);
|
||||
assert.equal(model.page, 'room');
|
||||
if (model.page !== 'room') throw new Error('expected room');
|
||||
assert.equal(model.roomcode, '000123');
|
||||
assert.equal(model.seats.length, count);
|
||||
assert.deepEqual(model.seats[1], { kind: 'empty', seat: 1 });
|
||||
assert.equal(model.canPrepare, true);
|
||||
assert.equal(model.canExit, true);
|
||||
assert.ok(Object.isFrozen(model));
|
||||
assert.ok(Object.isFrozen(model.seats));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] 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。核心投影:
|
||||
|
||||
```ts
|
||||
const playerModel = (player: PlatformPlayer): PlayerModel => Object.freeze({
|
||||
playerid: player.playerid, nickname: player.nickname,
|
||||
avatar: player.avatar, bean: player.bean,
|
||||
});
|
||||
const canExit = state.app.phase === 'logged-in'
|
||||
&& (room.stage === 0 || room.infinite === 1);
|
||||
```
|
||||
|
||||
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`。
|
||||
|
||||
## Task 3: 实现可取消、抗重入的合帧渲染
|
||||
|
||||
**Files:** Create `YouleNexus/assets/framework/presentation/frame-renderer.ts`, `framework-tests/presentation/frame-renderer.test.ts`。
|
||||
|
||||
**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):
|
||||
|
||||
```ts
|
||||
test('coalesces frames and invalidates callbacks even if scheduler delivers late', () => {
|
||||
const callbacks: (() => void)[] = [];
|
||||
let cancelled = 0;
|
||||
let rendered = 0;
|
||||
const renderer = new FrameRenderer({
|
||||
request(cb) { callbacks.push(cb); return () => { cancelled++; }; },
|
||||
}, () => { rendered++; }, e => { throw e; });
|
||||
renderer.request(); renderer.request();
|
||||
assert.equal(callbacks.length, 1);
|
||||
renderer.flush();
|
||||
assert.equal(rendered, 1);
|
||||
callbacks[0]!();
|
||||
assert.equal(rendered, 1);
|
||||
renderer.request();
|
||||
renderer.dispose(); renderer.dispose();
|
||||
callbacks[1]!();
|
||||
assert.equal(rendered, 1);
|
||||
assert.equal(cancelled, 2);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] 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 抛错,迟到任务仍无效。核心失效顺序:
|
||||
|
||||
```ts
|
||||
const cancel = this.pending;
|
||||
this.pending = null;
|
||||
this.generation += 1;
|
||||
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`。
|
||||
|
||||
## Task 4: 实现 ScenePort Presenter 和原样命令入口
|
||||
|
||||
**Files:** Create `YouleNexus/assets/framework/presentation/platform-ui-controller.ts`, `framework-tests/presentation/platform-ui-controller.test.ts`。
|
||||
|
||||
**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 也留在本测试文件:
|
||||
|
||||
```ts
|
||||
test('login forwards the exact account only after the runtime opens login', () => {
|
||||
const sent: unknown[] = [];
|
||||
const runtime: UiRuntime = {
|
||||
state: { app: { phase: 'connected' },
|
||||
players: { selfPlayerId: null, entities: {} }, room: { kind: 'outside' } },
|
||||
ready: true, subscribeState: () => () => {},
|
||||
login: account => { sent.push(account); }, joinRoom: () => {},
|
||||
prepare: () => {}, exitRoom: () => {},
|
||||
};
|
||||
const events: string[] = [];
|
||||
const controller = new PlatformUiController({
|
||||
render: model => { events.push(`render:${model.page}`); },
|
||||
activate: page => { events.push(`activate:${page}`); }, showOverlay: () => {},
|
||||
}, { request: () => () => {} }, error => { throw error; });
|
||||
controller.connect(runtime);
|
||||
const account = { openid: 'o1', nickname: 'name', avatar: '', sex: 0,
|
||||
province: '', city: '', unionid: 'u1' };
|
||||
assert.throws(() => controller.login(account), /login/);
|
||||
controller.showLogin();
|
||||
controller.login(account);
|
||||
assert.equal(sent[0], account);
|
||||
assert.deepEqual(events.slice(-2), ['render:login', 'activate:login']);
|
||||
controller.dispose();
|
||||
assert.throws(() => controller.login(account), /disposed/);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] Run `node --import tsx --test framework-tests/presentation/platform-ui-controller.test.ts`,缺类为 RED。
|
||||
- [ ] 实现一次 connect;订阅之后读取 runtime.state。尚未收到 ScenePort 页面请求时不画业务页。切页保存 page、flush 最新模型、检查未被 render 重入终止后 activate;不调用 director/loadScene。核心模型读取:
|
||||
|
||||
```ts
|
||||
private renderCurrent(): void {
|
||||
if (this.page === null) return;
|
||||
const runtime = this.requireRuntime();
|
||||
this.view.render(selectPageModel(this.page, runtime.state, runtime.ready));
|
||||
}
|
||||
```
|
||||
|
||||
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`。
|
||||
|
||||
## Task 5: 用真实 Runtime 验证整条接线并锁定边界
|
||||
|
||||
**Files:** Modify `framework-tests/platform/runtime.test.ts`; Create `framework-tests/architecture/presentation-boundaries.test.mjs`; Modify 本计划仅勾选已验证任务并记录结果。
|
||||
|
||||
**Interfaces:** Consumes `PlatformUiController`、真实 `PlatformRuntime` 及 runtime.test.ts 现有 helpers。Produces 无新增生产 API;只读集成回放与静态边界门禁。
|
||||
|
||||
- [ ] 将下面集成 RED 加入 runtime.test.ts,导入 controller;使用现有 ACCOUNT、DEVICE、runtimeConfig、RecordingWireClient、ManualClock、fixture、makeGameEntry:
|
||||
|
||||
```ts
|
||||
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);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] 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 断言先覆盖禁止真实值导入:
|
||||
|
||||
```js
|
||||
import ts from 'typescript';
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
function forbiddenSpecifier(specifier) {
|
||||
return specifier === 'cc' || /(?:^|\/)(?:net|protocol)(?:\/|$)/.test(specifier)
|
||||
|| /(?:platform-session|room-rpc-bus)(?:\.|$)/.test(specifier);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
```
|
||||
|
||||
完成 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 测试:
|
||||
|
||||
```powershell
|
||||
$uiCoreTests = @(rg --files framework-tests -g '*.test.ts')
|
||||
if ($uiCoreTests.Count -eq 0) { throw 'No TypeScript tests selected' }
|
||||
node --import tsx --test --test-concurrency=1 @uiCoreTests
|
||||
node --test framework-tests/architecture/import-boundaries.test.mjs framework-tests/architecture/presentation-boundaries.test.mjs
|
||||
node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit
|
||||
node scripts/check-import-boundaries.mjs
|
||||
git diff --check
|
||||
git diff --name-only HEAD
|
||||
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`。
|
||||
|
||||
## 完成与后续门槛
|
||||
|
||||
报告实际测试命令、数量、退出码、base/head SHA 与资源零变更检查;不沿用过去的 408/408 数字当本批结果。3A 完成可表述为“UI 接线核心已通过无引擎回放”。
|
||||
|
||||
不自动进入 3B。进入实际 Cocos 接管前,须另行确认允许修改的序列化文件、真实 GameEntry/目标宿主/账号来源,再通过 MCP 核实控件绑定并制定编辑器实施任务。主题扩展与真实子游戏继续各自独立计划。
|
||||
Reference in New Issue
Block a user