import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "../../src/app/App"; import { createEditorStore } from "../../src/app/editor-store"; import { createModelInstance } from "../../src/assets/model-instances"; import { createProjectAssetStorageKey, type ModelAssetRecord } from "../../src/assets/project-assets"; import type { ActiveTransformSession, TransformSessionState } from "../../src/core/transform-session"; import { createBoxBrush } from "../../src/document/brushes"; import { createEmptySceneDocument } from "../../src/document/scene-document"; import { createPlayerStartEntity } from "../../src/entities/entity-instances"; const { MockViewportHost, viewportHostInstances } = vi.hoisted(() => { const viewportHostInstances: Array<{ panelId: string | null; setPanelId: ReturnType; mount: ReturnType; dispose: ReturnType; updateWorld: ReturnType; updateAssets: ReturnType; updateDocument: ReturnType; setViewMode: ReturnType; setDisplayMode: ReturnType; setCameraState: ReturnType; setBrushSelectionChangeHandler: ReturnType; setCameraStateChangeHandler: ReturnType; setCreationPreviewChangeHandler: ReturnType; setCreationCommitHandler: ReturnType; setTransformSessionChangeHandler: ReturnType; setTransformCommitHandler: ReturnType; setTransformCancelHandler: ReturnType; setWhiteboxHoverLabelChangeHandler: ReturnType; setWhiteboxSelectionMode: ReturnType; setWhiteboxSnapSettings: ReturnType; setToolMode: ReturnType; setCreationPreview: ReturnType; setTransformSession: ReturnType; focusSelection: ReturnType; }> = []; class MockViewportHost { panelId: string | null = null; setPanelId = vi.fn((panelId: string) => { this.panelId = panelId; }); mount = vi.fn(); dispose = vi.fn(); updateWorld = vi.fn(); updateAssets = vi.fn(); updateDocument = vi.fn(); setViewMode = vi.fn(); setDisplayMode = vi.fn(); setCameraState = vi.fn(); setBrushSelectionChangeHandler = vi.fn(); setCameraStateChangeHandler = vi.fn(); setCreationPreviewChangeHandler = vi.fn(); setCreationCommitHandler = vi.fn(); setTransformSessionChangeHandler = vi.fn(); setTransformCommitHandler = vi.fn(); setTransformCancelHandler = vi.fn(); setWhiteboxHoverLabelChangeHandler = vi.fn(); setWhiteboxSelectionMode = vi.fn(); setWhiteboxSnapSettings = vi.fn(); setToolMode = vi.fn(); setCreationPreview = vi.fn(); setTransformSession = vi.fn(); focusSelection = vi.fn(); constructor() { viewportHostInstances.push(this); } } return { MockViewportHost, viewportHostInstances }; }); vi.mock("../../src/viewport-three/viewport-host", () => ({ ViewportHost: MockViewportHost })); vi.mock("../../src/assets/project-asset-storage", () => ({ getBrowserProjectAssetStorageAccess: vi.fn(async () => ({ storage: null, diagnostic: null })) })); const modelAsset = { id: "asset-model-transform-integration", kind: "model", sourceName: "transform-fixture.glb", mimeType: "model/gltf-binary", storageKey: createProjectAssetStorageKey("asset-model-transform-integration"), byteLength: 64, metadata: { kind: "model", format: "glb", sceneName: "Transform Fixture", nodeCount: 1, meshCount: 1, materialNames: [], textureNames: [], animationNames: [], boundingBox: { min: { x: -0.5, y: 0, z: -0.5 }, max: { x: 0.5, y: 1, z: 0.5 }, size: { x: 1, y: 1, z: 1 } }, warnings: [] } } satisfies ModelAssetRecord; function getTopLeftViewportHost() { const viewportHost = viewportHostInstances.find((instance) => instance.panelId === "topLeft"); if (viewportHost === undefined) { throw new Error("Top-left viewport host was not mounted."); } return viewportHost; } async function renderTransformFixtureApp() { const brush = createBoxBrush({ id: "brush-transform-main", name: "Brush Transform Fixture", center: { x: 0, y: 1, z: 0 } }); const playerStart = createPlayerStartEntity({ id: "entity-player-start-transform", name: "Player Start Fixture", position: { x: 2, y: 0, z: -2 }, yawDegrees: 0 }); const modelInstance = createModelInstance({ id: "model-instance-transform-main", assetId: modelAsset.id, name: "Model Transform Fixture", position: { x: -3, y: 0, z: 3 } }); const store = createEditorStore({ initialDocument: { ...createEmptySceneDocument({ name: "Transform Fixture" }), brushes: { [brush.id]: brush }, assets: { [modelAsset.id]: modelAsset }, entities: { [playerStart.id]: playerStart }, modelInstances: { [modelInstance.id]: modelInstance } } }); render(); await waitFor(() => { expect(viewportHostInstances.length).toBeGreaterThan(0); expect(getTopLeftViewportHost().setTransformCommitHandler).toHaveBeenCalled(); }); return { store, brush, playerStart, modelInstance, viewportHost: getTopLeftViewportHost() }; } async function renderQuadTransformFixtureApp() { const fixture = await renderTransformFixtureApp(); act(() => { fixture.store.setViewportLayoutMode("quad"); }); return fixture; } function getLatestTransformSession(store: ReturnType): ActiveTransformSession { const transformSession = store.getState().viewportTransientState.transformSession; if (transformSession.kind !== "active") { throw new Error("Expected an active transform session."); } return transformSession; } function emitTransformPreview( viewportHost: ReturnType, transformSession: ActiveTransformSession ) { const handler = viewportHost.setTransformSessionChangeHandler.mock.calls.at(-1)?.[0] as ((transformSession: TransformSessionState) => void) | undefined; if (handler === undefined) { throw new Error("Transform session change handler was not registered."); } act(() => { handler(transformSession); }); } function commitTransform(viewportHost: ReturnType, transformSession: ActiveTransformSession) { const handler = viewportHost.setTransformCommitHandler.mock.calls.at(-1)?.[0] as ((transformSession: ActiveTransformSession) => void) | undefined; if (handler === undefined) { throw new Error("Transform commit handler was not registered."); } act(() => { handler(transformSession); }); } describe("transform foundation integration", () => { beforeEach(() => { viewportHostInstances.length = 0; vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => ({}) as never); }); afterEach(() => { vi.restoreAllMocks(); }); it("moves a whole brush through keyboard entry, axis constraint, and viewport commit", async () => { const { store, brush, viewportHost } = await renderTransformFixtureApp(); await act(async () => { fireEvent.click(screen.getByRole("button", { name: /^Brush Transform Fixture$/ })); }); fireEvent.keyDown(window, { key: "g", code: "KeyG" }); expect(store.getState().viewportTransientState.transformSession).toMatchObject({ kind: "active", operation: "translate", axisConstraint: null, target: { kind: "brush", brushId: brush.id } }); fireEvent.keyDown(window, { key: "x", code: "KeyX" }); expect(store.getState().viewportTransientState.transformSession).toMatchObject({ kind: "active", axisConstraint: "x" }); const previewSession = { ...getLatestTransformSession(store), preview: { kind: "brush" as const, center: { x: 6, y: brush.center.y, z: brush.center.z }, rotationDegrees: { ...brush.rotationDegrees }, size: { ...brush.size }, geometry: createBoxBrush({ size: brush.size }).geometry } }; emitTransformPreview(viewportHost, previewSession); commitTransform(viewportHost, previewSession); expect(store.getState().viewportTransientState.transformSession).toEqual({ kind: "none" }); expect(store.getState().document.brushes[brush.id].center).toEqual({ x: 6, y: brush.center.y, z: brush.center.z }); }); it("rotates and scales a whole whitebox box through the shared transform controller", async () => { const { store, brush, viewportHost } = await renderTransformFixtureApp(); await act(async () => { fireEvent.click(screen.getByRole("button", { name: /^Brush Transform Fixture$/ })); }); fireEvent.click(screen.getByTestId("transform-rotate-button")); const rotatePreviewSession = { ...getLatestTransformSession(store), preview: { kind: "brush" as const, center: { ...brush.center }, rotationDegrees: { x: 0, y: 37.5, z: 12.5 }, size: { ...brush.size }, geometry: createBoxBrush({ size: brush.size }).geometry } }; emitTransformPreview(viewportHost, rotatePreviewSession); commitTransform(viewportHost, rotatePreviewSession); expect(store.getState().document.brushes[brush.id].rotationDegrees).toEqual({ x: 0, y: 37.5, z: 12.5 }); fireEvent.click(screen.getByTestId("transform-scale-button")); const scalePreviewSession = { ...getLatestTransformSession(store), preview: { kind: "brush" as const, center: { ...brush.center }, rotationDegrees: { x: 0, y: 37.5, z: 12.5 }, size: { x: 3.5, y: 2.5, z: 4.5 }, geometry: createBoxBrush({ size: { x: 3.5, y: 2.5, z: 4.5 } }).geometry } }; emitTransformPreview(viewportHost, scalePreviewSession); commitTransform(viewportHost, scalePreviewSession); expect(store.getState().document.brushes[brush.id]).toMatchObject({ rotationDegrees: { x: 0, y: 37.5, z: 12.5 }, size: { x: 3.5, y: 2.5, z: 4.5 } }); }); it("keeps transform controls coherent across object and component modes", async () => { const { store, brush } = await renderTransformFixtureApp(); await act(async () => { fireEvent.click(screen.getByRole("button", { name: /^Brush Transform Fixture$/ })); }); expect(screen.getByTestId("transform-translate-button")).not.toBeDisabled(); await act(async () => { fireEvent.click(screen.getByTestId("whitebox-selection-mode-face")); }); expect(store.getState().whiteboxSelectionMode).toBe("face"); expect(screen.getByTestId("transform-translate-button")).toBeDisabled(); expect(screen.getByTestId("transform-rotate-button")).toBeDisabled(); expect(screen.getByTestId("transform-scale-button")).toBeDisabled(); act(() => { store.setSelection({ kind: "brushFace", brushId: brush.id, faceId: "posY" }); }); expect(screen.getByTestId("transform-translate-button")).not.toBeDisabled(); expect(screen.getByTestId("transform-rotate-button")).not.toBeDisabled(); expect(screen.getByTestId("transform-scale-button")).not.toBeDisabled(); await act(async () => { fireEvent.click(screen.getByTestId("whitebox-selection-mode-vertex")); }); act(() => { store.setSelection({ kind: "brushVertex", brushId: brush.id, vertexId: "posX_posY_posZ" }); }); expect(screen.getByTestId("transform-translate-button")).not.toBeDisabled(); expect(screen.getByTestId("transform-rotate-button")).toBeDisabled(); expect(screen.getByTestId("transform-scale-button")).toBeDisabled(); await act(async () => { fireEvent.click(screen.getByTestId("whitebox-selection-mode-object")); }); expect(store.getState().whiteboxSelectionMode).toBe("object"); expect(store.getState().selection).toEqual({ kind: "brushes", ids: [brush.id] }); expect(screen.getByTestId("transform-translate-button")).not.toBeDisabled(); }); it("moves an entity through the shared transform controller", async () => { const { store, playerStart, viewportHost } = await renderTransformFixtureApp(); await act(async () => { fireEvent.click(screen.getByRole("button", { name: /^Player Start Fixture$/ })); }); fireEvent.keyDown(window, { key: "g", code: "KeyG" }); const previewSession = { ...getLatestTransformSession(store), preview: { kind: "entity" as const, position: { x: 8, y: 0, z: -4 }, rotation: { kind: "yaw" as const, yawDegrees: playerStart.yawDegrees } } }; emitTransformPreview(viewportHost, previewSession); commitTransform(viewportHost, previewSession); expect(store.getState().document.entities[playerStart.id]).toMatchObject({ position: { x: 8, y: 0, z: -4 } }); }); it("cancels an active transform with Escape without committing preview changes", async () => { const { store, playerStart, viewportHost } = await renderTransformFixtureApp(); await act(async () => { fireEvent.click(screen.getByRole("button", { name: /^Player Start Fixture$/ })); }); fireEvent.keyDown(window, { key: "g", code: "KeyG" }); emitTransformPreview(viewportHost, { ...getLatestTransformSession(store), preview: { kind: "entity", position: { x: 12, y: 0, z: -6 }, rotation: { kind: "yaw", yawDegrees: playerStart.yawDegrees } } }); fireEvent.keyDown(window, { key: "Escape", code: "Escape" }); expect(store.getState().viewportTransientState.transformSession).toEqual({ kind: "none" }); expect(store.getState().document.entities[playerStart.id]).toEqual(playerStart); }); it("moves a model instance through the shared transform controller", async () => { const { store, modelInstance, viewportHost } = await renderTransformFixtureApp(); await act(async () => { fireEvent.click(screen.getByRole("button", { name: /^Model Transform Fixture$/ })); }); fireEvent.keyDown(window, { key: "g", code: "KeyG" }); const previewSession = { ...getLatestTransformSession(store), preview: { kind: "modelInstance" as const, position: { x: -1, y: 0, z: 7 }, rotationDegrees: { ...modelInstance.rotationDegrees }, scale: { ...modelInstance.scale } } }; emitTransformPreview(viewportHost, previewSession); commitTransform(viewportHost, previewSession); expect(store.getState().document.modelInstances[modelInstance.id]).toMatchObject({ position: { x: -1, y: 0, z: 7 } }); }); it("uses the hovered quad viewport as the active transform panel for keyboard entry", async () => { const { store, brush } = await renderQuadTransformFixtureApp(); await act(async () => { fireEvent.click(screen.getByRole("button", { name: /^Brush Transform Fixture$/ })); }); fireEvent.pointerMove(screen.getByTestId("viewport-panel-bottomRight"), { clientX: 24, clientY: 24 }); fireEvent.keyDown(window, { key: "g", code: "KeyG" }); expect(store.getState().activeViewportPanelId).toBe("bottomRight"); expect(store.getState().viewportTransientState.transformSession).toMatchObject({ kind: "active", operation: "translate", sourcePanelId: "bottomRight", target: { kind: "brush", brushId: brush.id } }); }); });