diff --git a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx index 3fdd46a61..25026bd69 100644 --- a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx @@ -248,4 +248,43 @@ describe('PreviewWorkspace quick zoom', () => { }), ).toBe(true); }); + it('进入时按全部树的联合边界适配,选中节点不重新适配', () => { + const secondRoot: UiNode = { + ...structuredClone(root), + id: 'root-2', + offset: { min: [1600, 0], max: [2800, 800] }, + }; + const twoTrees = [ + { src_ui_design: 'page-1', root }, + { src_ui_design: 'page-2', root: secondRoot }, + ]; + // 第二棵树的界面图必须也在投影里,否则它不会进入联合边界(`allBounds` 只收集尺寸有效的树)。 + const twoTreeCanvas = { + uiTrees: twoTrees, + images: { 'page-1': activeImage, 'page-2': activeImage }, + previewUrls: { + 'page-1': 'data:image/png;base64,', + 'page-2': 'data:image/png;base64,', + }, + }; + + const single = render(); + const singleScale = logicalViewportScale(single.container); + single.unmount(); + + const joint = render( + , + ); + const jointScale = logicalViewportScale(joint.container); + // 联合边界(两棵树横跨 1600 单位)明显比单棵树宽:首次进入必须适配全部树。 + expect(jointScale).toBeLessThan(singleScale); + + // 选中另一棵树的节点只改选中态:不允许顺带重新适配(那会把用户当前视口顶掉)。 + joint.rerender( + , + ); + expect(logicalViewportScale(joint.container)).toBe(jointScale); + }); }); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index aa31788bc..fc7ebc651 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -1179,4 +1179,22 @@ describe('UiEditorPage', () => { expect(component.checked).toBe(true); expect(origin.checked).toBe(true); }); + it('预览里选择节点不改变左侧选中的界面图', async () => { + const hook = await renderLoadedSession( + stateWithPages(['page-1', 'page-2']), + ); + + // 左侧图片选择维护 activeImageId。 + act(() => hook.result.current.input.selectDesignImage('page-2')); + expect(hook.result.current.canvas.activeImageId).toBe('page-2'); + + // 预览里选中另一棵树的节点:只改节点选中,不能反过来改掉左侧选中的界面图。 + act(() => hook.result.current.canvas.selectNode('page-1-root')); + expect(hook.result.current.canvas.selectedNodeId).toBe('page-1-root'); + expect(hook.result.current.canvas.activeImageId).toBe('page-2'); + + // Inspector 的部件切换同样不改左侧选中的界面图。 + act(() => hook.result.current.input.selectSprite('panel')); + expect(hook.result.current.canvas.activeImageId).toBe('page-2'); + }); }); diff --git a/docs/project-memory/plans/【里程碑】UI编辑器多树预览与独立显示开关-2026-09-14.md b/docs/project-memory/plans/【里程碑】UI编辑器多树预览与独立显示开关-2026-09-14.md index 9a0ba313d..39c752c00 100644 --- a/docs/project-memory/plans/【里程碑】UI编辑器多树预览与独立显示开关-2026-09-14.md +++ b/docs/project-memory/plans/【里程碑】UI编辑器多树预览与独立显示开关-2026-09-14.md @@ -37,8 +37,8 @@ UI 编辑器预览同时展示全部界面树。每棵树使用 root.offset 的 - [ ] 原图与组件随 root 一起移动,拖动结束进入 undo/redo 并可保存恢复。 - [x] root 不显示 resize handles,子节点 resize 不回归。 - [x] 三个开关默认 showFrame=true、showOriginImage=true、showComponent=false,且互相独立。 -- [ ] 预览选择不修改 activeImageId;左侧图片选择和 Inspector 既有切换保持。 -- [ ] 仅首次进入和手动适配触发联合 fit。 +- [x] 预览选择不修改 activeImageId;左侧图片选择和 Inspector 既有切换保持。 +- [x] 仅首次进入和手动适配触发联合 fit。 ## 证据要求 @@ -52,10 +52,10 @@ UI 编辑器预览同时展示全部界面树。每棵树使用 root.offset 的 - 三个独立开关在 `view/ui-editor/index.tsx` 里渲染:`显示框线` / `显示原图` / `显示组件`。 - 定向用例:`npx vitest run tests/uiEditorPreview.test.tsx tests/previewWorkspaceZoom.test.tsx tests/previewRightPanGesture.test.ts tests/uiEditorPage.test.ts --root apps/ai-game-creator-shell` → **59 passed / 0 failed**。 - 本轮补齐的验收项(第 1 条):新增 `tests/uiEditorState.test.ts` 的「新树默认落在所有现有树右侧并与最小 top 对齐」——直接跑 `createTree` 入口:第一棵落在 `[0,0]`,第二棵落在「第一棵右边界 + `UI_TREE_PADDING`」且 top 与现有最小 top 对齐;把第二棵整体下移后,第三棵仍然按**最小** top(而不是最后一棵的 top)对齐,横向取所有树最右边界。变异验证:把 `deriveTreeOffset` 的 `minY` 改成取最后一棵树的 top 后,只有这条用例变红(1 failed / 17 passed),改回后恢复绿;本轮四套 UI 编辑器用例合计 **72 passed**。 -- 仍未覆盖的 3 条验收项(都不是运行时项,缺的是自动化用例,已按条列清): +- 仍未覆盖的 1 条验收项(不是运行时项,缺的是自动化用例): - 「原图与组件随 root 一起移动,拖动结束进入 undo/redo 并可保存恢复」:现有 `uiEditorState.test.ts` 只覆盖 `set-tree-offset` 命令本身,没有断言预览渲染跟随 root 位移与拖动落点进 undo/redo、保存后能读回。 - 本轮补齐的验收项(第 3 条):在同一条 `shows final-preview node frames and resize handles only when requested` 里补上 root 断言——选中 root 时一个 `/调整控制点$/` 都不出现,紧接着的对照是选中 child 时恰好 8 个。变异验证:把 handle 渲染条件里的 `!isRoot &&` 去掉后只有这一处断言变红(1 failed / 10 passed,且证明 `selectedNodeId=root` 确实进入了选中态、断言不是空转),改回后恢复绿。 - 本轮补齐的验收项(第 4 条):`tests/uiEditorPage.test.ts` 新增「默认显示框线与原图、隐藏组件,且三个开关互相独立」——渲染真实 `UiEditorPage` 后按标签取三个复选框,断言初始值 `显示框线=true` / `显示原图=true` / `显示组件=false`,再分别关框线、开组件,确认互不影响。变异验证:把 `view/ui-editor/index.tsx` 的 `showFrame` 初值改成 `false` 后只有这条用例变红(1 failed / 35 passed),改回后四套用例合计 **73 passed**。 - - 「预览选择不修改 activeImageId;左侧图片选择和 Inspector 既有切换保持」:`activeImageId` 在页面 hook(`useUiEditorPage.ts`)里,左侧图片列表只用 CSS class(`border-orange-300 bg-orange-50`)标记选中,没有 aria 属性——断言应当走 hook 暴露的 activeImageId,而不是 DOM class。 - - 「仅首次进入和手动适配触发联合 fit」:手动适配一半由 `previewWorkspaceZoom.test.tsx` 的 `refits the preview when the zoom percentage button is clicked` 覆盖,首次进入只触发一次联合 fit 没有断言。 +- 本轮补齐的验收项(第 5 条):`tests/uiEditorPage.test.ts` 新增「预览里选择节点不改变左侧选中的界面图」——先用 `input.selectDesignImage('page-2')` 建立 `activeImageId`,再 `canvas.selectNode('page-1-root')`,断言 `selectedNodeId` 变了而 `activeImageId` 仍是 `page-2`;随后切到 Inspector 部件(`input.selectSprite('panel')`)同样不改。断言走 hook 暴露的 `canvas.activeImageId`,不依赖左侧列表的 CSS class(那里没有 aria 属性)。变异验证:在 `selectNode` 里塞一句 `setActiveImageId('page-1')`(模拟「按选中节点同步左侧图片」的误改)后,只有这条用例变红(1 failed / 36 passed)。 +- 本轮补齐的验收项(第 6 条):`tests/previewWorkspaceZoom.test.tsx` 新增「进入时按全部树的联合边界适配,选中节点不重新适配」——两棵树分别落在 x=0 与 x=1600(两棵界面图都必须在投影里,否则 `allBounds` 会跳过它),断言联合适配后的逻辑缩放**小于**单棵树的适配值(证明首次进入用的是全部树的联合边界),再用 `selectedNodeId` 重新渲染一次并断言缩放不变(选中不该顶掉用户视口);手动适配那一半继续由既有 `refits the preview when the zoom percentage button is clicked` 覆盖。变异验证:把 `allBounds` 的过滤结果接上 `slice(0, 1)`(只适配第一棵树)后,只有这条用例的第一处断言变红(1 failed / 8 passed)。 - 仍待补:真机手感与多树布局的目视验收(本机只到定向用例级)。 \ No newline at end of file