diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 65158a157..658a8b40a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -4760,7 +4760,12 @@ export default function ProjectDevelopmentView({ passive: false, }); return () => manager.removeEventListener('wheel', handleResourceBookWheel); - }, [handleResourceBookWheel, mode]); + }, [ + handleResourceBookWheel, + mode, + // UI 编辑器会卸载整个资源 manager;返回时 ref 指向新节点,必须重新绑定原生 wheel。 + uiEditorRoute, + ]); const handleResourceBookMainPointerDown = useCallback( (event: ReactPointerEvent) => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 3c6da89f6..71af32e10 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -1227,6 +1227,32 @@ async function openMainProject(projectPath: string) { ).not.toBeNull(); } +export function installResizeObserverStub() { + let observerCount = 0; + let observerDisconnected = false; + class TestResizeObserver { + constructor(readonly callback: ResizeObserverCallback) { + observerCount += 1; + } + + observe() {} + + unobserve() {} + + disconnect() { + observerDisconnected = true; + } + } + Object.defineProperty(window, 'ResizeObserver', { + configurable: true, + value: TestResizeObserver, + }); + return { + observerCount: () => observerCount, + observerDisconnected: () => observerDisconnected, + }; +} + beforeEach(() => { resetLlmModelCatalogCacheForTest(); vi.spyOn(clientApi, 'loadClientLlmModels').mockResolvedValue({ diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 4ef60c65b..41f54cc3d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -34,6 +34,7 @@ import { GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, type GameCreationAgentRunTrace, getResourceSelectButton, + installResizeObserverStub, it, mockRoleAgentReply, openResourceFilterPanel, @@ -4740,25 +4741,7 @@ export function registerProjectWorkbenchFoundationTests() { manuallyPlaced: false, }, ]; - let observerCount = 0; - let observerDisconnected = false; - class TestResizeObserver { - constructor(readonly callback: ResizeObserverCallback) { - observerCount += 1; - } - - observe() {} - - unobserve() {} - - disconnect() { - observerDisconnected = true; - } - } - Object.defineProperty(window, 'ResizeObserver', { - configurable: true, - value: TestResizeObserver, - }); + const resizeObserver = installResizeObserverStub(); const animationFrame = vi.spyOn(window, 'requestAnimationFrame'); const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; @@ -4850,7 +4833,7 @@ export function registerProjectWorkbenchFoundationTests() { /^url\(#.+-asset-reference-arrow\)$/u, ); }); - expect(observerCount).toBe(1); + expect(resizeObserver.observerCount()).toBe(1); expect( overlay .querySelector('[data-testid="resource-dependency-overlay-scene"]') @@ -4897,7 +4880,7 @@ export function registerProjectWorkbenchFoundationTests() { ), ); rendered.unmount(); - expect(observerDisconnected).toBe(true); + expect(resizeObserver.observerDisconnected()).toBe(true); expect(removeViewportListener).toHaveBeenCalledWith( 'scroll', expect.any(Function), @@ -5948,6 +5931,144 @@ export function registerProjectWorkbenchFoundationTests() { ); }); + it('restores resource canvas panning after returning from the UI editor', async () => { + installResizeObserverStub(); + const manifest = createGameCreationAppManifest( + 'workbench-ui-editor-return-pan', + 'UI 编辑器返回平移测试', + ); + manifest.assets = [ + { + id: 'ui-design-resource', + kind: 'UI', + mediaType: 'application/json', + localPath: 'assets/ui-design.json', + source: { kind: 'generated' }, + }, + ]; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: manifest.projectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: manifest.projectId, + mode: args?.mode, + revision: 1, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + if (command === 'load_ui_design_state') { + return { + revision: 0, + state: { + ui_trees: [], + ui_design_images: {}, + sprite_assets: {}, + font_assets: {}, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-ui-editor-return-pan', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + await openResourceBookCategory('UI 交互'); + const canvas = await screen.findByRole('region', { name: 'UI 交互' }); + const scene = document.querySelector( + '[data-resource-book-view="child"] .game-resource-book-scene', + ); + + const wheel = (target: HTMLElement, deltaY: number) => { + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY, + clientX: 120, + clientY: 100, + }); + act(() => target.dispatchEvent(event)); + expect(event.defaultPrevented).toBe(true); + }; + wheel(scene ?? canvas, 120); + const world = canvas.querySelector('[data-resource-viewport]'); + expect(world?.getAttribute('data-resource-viewport')).toMatch( + /^(?!48,48,1)/u, + ); + + fireEvent.click( + screen.getByRole('button', { + name: '选中资源:UI 交互 ui-design.json', + }), + ); + fireEvent.click(await screen.findByRole('button', { name: 'UI 编辑器' })); + await screen.findByRole('button', { name: '返回资源' }); + fireEvent.click(screen.getByRole('button', { name: '返回资源' })); + await waitFor(() => + expect(screen.queryByRole('button', { name: '返回资源' })).toBeNull(), + ); + + const restoredCanvas = await screen.findByRole('region', { + name: 'UI 交互', + }); + const restoredWorld = restoredCanvas.querySelector( + '[data-resource-viewport]', + ); + const beforeReturn = world?.getAttribute('data-resource-viewport'); + const restoredBeforePan = restoredWorld?.getAttribute( + 'data-resource-viewport', + ); + expect(restoredBeforePan).toBe(beforeReturn); + + const restoredScene = document.querySelector( + '[data-resource-book-view="child"] .game-resource-book-scene', + ); + const restoredWheelEvent = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY: 120, + clientX: 120, + clientY: 100, + }); + act(() => + (restoredScene ?? restoredCanvas).dispatchEvent(restoredWheelEvent), + ); + expect(restoredWheelEvent.defaultPrevented).toBe(true); + expect(restoredWorld?.getAttribute('data-resource-viewport')).not.toBe( + restoredBeforePan, + ); + }); + it('clears the resource preview cache and cancels the old preview scope on project switch', async () => { // 60d8b8fbb 删掉了从未被写入的 `resourcePreviewVersionByResourceId` 死接线,原先钉 // 那条 prop 与它的清空语句的断言随之取消;用例真正要守的意图不变——项目切换必须把 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 4dede4235..3192d7b07 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -7,6 +7,14 @@ - **处理(现行口径)**:① 渲染 `ProjectDevelopmentView` 的桩统一登记 `list_local_project_asset_generations`(返回**数组**,空账本 `[]`;Rust 侧返回 `Vec`,不是 `{ tasks: [] }`);② `unexpectedCommands` 这类门禁**不要放宽**,只登记合法命令;③ 生产代码对 IPC 返回值做形状防御(`Array.isArray` 归一化),IPC 拒绝走既有提示路径,不产生 unhandled rejection(`resourceCanvasAssetGenerationTaskModel.ts` / `resourceCanvasAssetGenerationQueue.ts` / `index.tsx` 的恢复 effect)。 - **易错点**:① 桩返回**非数组**时用例可能"看着绿"但同时报 unhandled rejection(实测:把 `[]` 误写成 `{ tasks: [] }` 就是 8 passed + 7 unhandled error),所以判"绿"必须同时看 unhandled 计数;② 新增挂载期 IPC 后要一次性 grep 所有 `ProjectDevelopmentView` 的桩,而不是等 CI 逐个炸;③ 提示条类断言(如「无读时提示」)会把「桩抛错」翻译成「多了一条提示」,排查时先看 unhandled,再看断言。 - **关联**:`apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx`、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts`。 +## 2026-09-14 UI 编辑器返回后资源画布滚轮平移失效 + +- **现象**:资源管理打开 UI 编辑器再返回后,资源画布滚轮平移/缩放不再响应;返回前同一手势正常。 +- **原因**:资源画布的非 passive `wheel` 监听绑定在 `resourceBookManagerRef` 当前 DOM 上,但 effect 只依赖 `handleResourceBookWheel` 与 `mode`。UI 编辑器切换会卸载旧 manager 并挂载新 manager,依赖不变导致新节点没有重新绑定监听。 +- **处理**:将 `uiEditorRoute` 纳入 wheel effect 依赖,使进入/退出 UI 编辑器时先清理旧节点监听,再给返回后的新 manager 绑定同一处理器。 +- **验证**:`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts -t "restores resource canvas panning"`;回归用例覆盖打开栏目、wheel 平移、进入 UI 编辑器、返回并再次 wheel 平移。 +- **关联**:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`。 + ## 2026-09-14 AGC 壳 Rust 套件按「一片一 job」拆分,且分片必须自校验覆盖 - **现象**:`AI game creator shell Rust tests` 一直是客户端 CI 的关键路径。run 2097 实测 15 分 27 秒,其中 `apps/ai-game-creator-shell/src-tauri` 的 bin target 单测(2466 条)一条 `cargo test -- --test-threads=1` 串行占 507 秒。