import { jsx as _jsx } from "react/jsx-runtime"; 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 } from "../../src/assets/project-assets"; 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 = []; class MockViewportHost { panelId = null; setPanelId = vi.fn((panelId) => { 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: [] } }; 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(_jsx(App, { store: store })); 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) { const transformSession = store.getState().viewportTransientState.transformSession; if (transformSession.kind !== "active") { throw new Error("Expected an active transform session."); } return transformSession; } function emitTransformPreview(viewportHost, transformSession) { const handler = viewportHost.setTransformSessionChangeHandler.mock.calls.at(-1)?.[0]; if (handler === undefined) { throw new Error("Transform session change handler was not registered."); } act(() => { handler(transformSession); }); } function commitTransform(viewportHost, transformSession) { const handler = viewportHost.setTransformCommitHandler.mock.calls.at(-1)?.[0]; 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(() => ({})); }); 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", center: { x: 6, y: brush.center.y, z: brush.center.z }, rotationDegrees: { ...brush.rotationDegrees }, size: { ...brush.size } } }; 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", center: { ...brush.center }, rotationDegrees: { x: 0, y: 37.5, z: 12.5 }, size: { ...brush.size } } }; 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", center: { ...brush.center }, rotationDegrees: { x: 0, y: 37.5, z: 12.5 }, size: { x: 3.5, y: 2.5, z: 4.5 } } }; 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", position: { x: 8, y: 0, z: -4 }, rotation: { kind: "yaw", 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", 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 } }); }); });