diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index f04057d..1c60e8d 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1584,9 +1584,11 @@ P0 基线/治理 | P0-02 运行时版本基线 | `IN PROGRESS` | `config/runtime-baseline.json` 已建立;Three.js 锁定为当前 npm 最新 `0.185.1`,其他内核版本待锁定 | | P0-03 兼容矩阵 | `DONE` | `config/compatibility-matrix.json` 覆盖 13 个工作台和命令状态 | | P1-01 Facade 最小合同 | `DONE` | `src/facade/types.ts` 定义 App/Gui/Command/Selection/Task/Viewport 合同 | +| P1-02 命令状态和选择前置 | `DONE` | MockFacade 根据工作台、选择对象和命令清单返回 enabled/disabled 原因 | +| P1-03 请求上下文、事件和诊断 | `DONE` | 命令事件携带 API 版本、请求 ID、文档 ID/版本和工作台;禁用命令生成结构化诊断 | | P1-05 MockFacadeAdapter | `DONE` | `src/facade/mockFacade.ts`,React 工作区已通过事件投影工作台、选择、文档树和通知 | | P1-06 Facade-only 依赖守卫 | `DONE` | `scripts/check-facade-boundary.mjs`,禁止 UI 绕过入口导入 Three.js/SQLite/OPFS/Worker | | P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 | -| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、事件、任务生命周期和禁用命令;E2E/黄金几何待补齐 | +| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、事件、任务生命周期、禁用命令和请求上下文;E2E/黄金几何待补齐 | 本迭代验证命令:`npm run check:facade-boundary`、`npm run test:facade`、`npm run build`。构建产物将 Three.js 拆为独立 chunk,避免把全部渲染库重复打入应用主 chunk。下一迭代继续完成 P0-01/P0-02 的精确锁定和 P1-02/P1-03 的命令状态、事件及诊断协议。 diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 7faf3f1..a2f3266 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -3,9 +3,11 @@ import type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, + Diagnostic, ExecuteCommandInput, FacadeEvent, FacadeListener, + FacadeRequestContext, FacadeState, ModelTreeItem, TaskSnapshot, @@ -27,41 +29,49 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => ({ id: 'doc-pump-housing', label, version: 18, dirty: true, readOnly: false, units: 'mm', tree: initialTree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), }) -const commandState = (commandId: string, activeWorkbench: WorkbenchId): CommandState => { +const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area']) + +const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string): CommandState => { const known = Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId))) if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' } if (commandId === 'pad' && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: 'Switch to Part Design to use Pad.' } + if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' } return { id: commandId, status: 'enabled' } } export function createMockFacade(): BitBybitWebCadFacade { - let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), task: null, lastNotice: '' } + let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), task: null, lastNotice: '', diagnostics: [] } const listeners = new Set() let requestSequence = 0 const emit = (event: FacadeEvent) => listeners.forEach((listener) => listener(event)) const emitState = () => emit({ type: 'state.changed', state: getState() }) - const getState = () => ({ ...state, document: { ...state.document, tree: state.document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })) }, task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null }) + const getState = () => ({ ...state, diagnostics: state.diagnostics.map((diagnostic) => ({ ...diagnostic })), document: { ...state.document, tree: state.document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })) }, task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null }) const notify = (message: string) => { state = { ...state, lastNotice: message }; emit({ type: 'notice', message }); emitState() } const setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) } const select = (objectId: string) => { state = { ...state, selectedObjectId: objectId }; emitState() } const beginTask = (commandId: string, draft: Record = {}) => { const task: TaskSnapshot = { id: `task-${++requestSequence}`, commandId, title: workbenchDefinitions[state.activeWorkbench].taskTitle, status: 'preview', draft }; state = { ...state, task }; emitState(); return task } const execute = ({ commandId, payload }: ExecuteCommandInput) => { const requestId = `req-${++requestSequence}` - const status = commandState(commandId, state.activeWorkbench) - if (status.status === 'disabled') { emit({ type: 'command.failed', commandId, requestId, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId } - emit({ type: 'command.started', commandId, requestId }) + const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench } + const status = commandState(commandId, state.activeWorkbench, state.selectedObjectId) + if (status.status === 'disabled') { + const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code: 'COMMAND_DISABLED', message: status.reason || 'Command is disabled', objectId: state.selectedObjectId || undefined, requestId } + state = { ...state, diagnostics: [...state.diagnostics, diagnostic] } + emit({ type: 'diagnostic.added', diagnostic, context }); emit({ type: 'command.failed', commandId, context, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId + } + emit({ type: 'command.started', commandId, context }) if (commandId === 'new-document') state = { ...state, document: createDocument('Untitled document') } else if (commandId === 'save') state = { ...state, document: { ...state.document, dirty: false, version: state.document.version + 1 } } else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId) else if (commandId === 'create-body') beginTask('create-body') else if (commandId === 'create-sketch') beginTask('create-sketch') - emit({ type: 'command.completed', commandId, requestId }); emitState(); return requestId + emit({ type: 'command.completed', commandId, context }); emitState(); return requestId } const facade: BitBybitWebCadFacade = { app: { document: { getActive: () => getState().document, create: (label) => { state = { ...state, document: createDocument(label), selectedObjectId: '' }; emitState(); return getState().document }, markDirty: () => { state = { ...state, document: { ...state.document, dirty: true } }; emitState() } } }, - gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } }, + gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } }, selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') }, task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'completed' } }; emitState() }, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } }, viewport: { createAdapter: () => new ThreeViewportAdapter() }, diff --git a/src/facade/types.ts b/src/facade/types.ts index faa7e06..da0129a 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -25,6 +25,23 @@ export type CommandState = { reason?: string } +export type FacadeRequestContext = { + apiVersion: '0.1' + requestId: string + documentId: string + documentVersion: number + workbench: WorkbenchId +} + +export type Diagnostic = { + id: string + severity: 'info' | 'warning' | 'error' + code: string + message: string + objectId?: string + requestId?: string +} + export type TaskSnapshot = { id: string commandId: string @@ -40,12 +57,14 @@ export type FacadeState = { document: DocumentSnapshot task: TaskSnapshot | null lastNotice: string + diagnostics: Diagnostic[] } export type FacadeEvent = | { type: 'state.changed'; state: FacadeState } | { type: 'notice'; message: string } - | { type: 'command.started' | 'command.completed' | 'command.failed'; commandId: string; requestId: string; message?: string } + | { type: 'command.started' | 'command.completed' | 'command.failed'; commandId: string; context: FacadeRequestContext; message?: string } + | { type: 'diagnostic.added'; diagnostic: Diagnostic; context: FacadeRequestContext } export type FacadeListener = (event: FacadeEvent) => void export type Unsubscribe = () => void diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 38086d1..c7a362b 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -39,9 +39,23 @@ test('disabled commands return a reason instead of mutating the document', () => const before = facade.getState().document.version const state = facade.gui.command.getState('pad') assert.equal(state.status, 'enabled') + facade.selection.clear() facade.gui.workbench.setActive('Sketcher') const disabled = facade.gui.command.getState('pad') assert.equal(disabled.status, 'disabled') - assert.match(disabled.reason || '', /Part Design/) + assert.match(disabled.reason || '', /Part Design|compatible object/) + facade.gui.command.execute({ commandId: 'pad' }) + assert.equal(facade.getState().diagnostics.at(-1)?.code, 'COMMAND_DISABLED') assert.equal(facade.getState().document.version, before) }) + +test('command events carry a document-scoped request context', () => { + const facade = createMockFacade() + const events: string[] = [] + let requestId = '' + facade.subscribe((event) => { events.push(event.type); if (event.type === 'command.started') requestId = event.context.requestId }) + facade.gui.command.execute({ commandId: 'create-sketch' }) + assert.ok(events.includes('command.started')) + assert.ok(events.includes('command.completed')) + assert.match(requestId, /^req-/) +})