From 2dfa68822ce88f0b20873890373f8bd8c6faf0d6 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Fri, 3 Apr 2026 02:22:51 +0200 Subject: [PATCH] Add unit test for transform foundation and remove unused e2e test --- tests/e2e/transform-foundation.e2e.ts | 229 ---------- .../transform-foundation.integration.test.tsx | 421 ++++++++++++++++++ 2 files changed, 421 insertions(+), 229 deletions(-) delete mode 100644 tests/e2e/transform-foundation.e2e.ts create mode 100644 tests/unit/transform-foundation.integration.test.tsx diff --git a/tests/e2e/transform-foundation.e2e.ts b/tests/e2e/transform-foundation.e2e.ts deleted file mode 100644 index 2f5bc4c6..00000000 --- a/tests/e2e/transform-foundation.e2e.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { expect, test, type Page } from "@playwright/test"; - -import { createModelInstance } from "../../src/assets/model-instances"; -import { createProjectAssetStorageKey, type ModelAssetRecord } 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"; -import { - clickViewport, - getEditorStoreSnapshot, - getViewportCanvas, - replaceSceneDocument -} from "./viewport-test-helpers"; - -const modelAsset = { - id: "asset-model-transform-e2e", - kind: "model", - sourceName: "transform-fixture.glb", - mimeType: "model/gltf-binary", - storageKey: createProjectAssetStorageKey("asset-model-transform-e2e"), - byteLength: 72, - 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; - -async function seedTransformScene(page: Page) { - 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: 4, - y: 0, - z: -4 - }, - yawDegrees: 0 - }); - const modelInstance = createModelInstance({ - id: "model-instance-transform-main", - assetId: modelAsset.id, - name: "Model Transform Fixture", - position: { - x: -4, - y: 0, - z: 4 - } - }); - - await replaceSceneDocument(page, { - ...createEmptySceneDocument({ name: "Transform Foundation Fixture" }), - brushes: { - [brush.id]: brush - }, - assets: { - [modelAsset.id]: modelAsset - }, - entities: { - [playerStart.id]: playerStart - }, - modelInstances: { - [modelInstance.id]: modelInstance - } - }); - - return { - brush, - playerStart, - modelInstance - }; -} - -async function getViewportCenter(page: Page, panelId: "topLeft" | "topRight" | "bottomLeft" | "bottomRight") { - const viewportCanvas = getViewportCanvas(page, panelId); - const bounds = await viewportCanvas.boundingBox(); - - if (bounds === null) { - throw new Error(`Viewport ${panelId} is not visible.`); - } - - return { - x: bounds.x + bounds.width * 0.5, - y: bounds.y + bounds.height * 0.5 - }; -} - -async function commitKeyboardMove( - page: Page, - panelId: "topLeft" | "topRight" | "bottomLeft" | "bottomRight", - delta: { x: number; y: number }, - axisConstraint?: "x" | "y" | "z" -) { - const center = await getViewportCenter(page, panelId); - - await page.mouse.move(center.x, center.y); - await page.keyboard.press("g"); - - if (axisConstraint !== undefined) { - await page.keyboard.press(axisConstraint); - } - - await page.mouse.move(center.x + delta.x, center.y + delta.y); - await page.mouse.click(center.x + delta.x, center.y + delta.y); -} - -test("viewport picking defaults to whole-brush selection and axis-constrained brush move commits through the shared transform session", async ({ page }) => { - await page.goto("/"); - await page.evaluate((storageKey) => { - window.localStorage.removeItem(storageKey); - }, "webeditor3d.scene-document-draft"); - await page.reload(); - - const fixtures = await seedTransformScene(page); - - await clickViewport(page, "topLeft"); - - const pickedSnapshot = await getEditorStoreSnapshot(page); - expect(pickedSnapshot.selection).toEqual({ - kind: "brushes", - ids: [fixtures.brush.id] - }); - - await page.getByTestId("viewport-layout-quad").click(); - - await commitKeyboardMove(page, "topRight", { x: 160, y: 0 }, "x"); - - const movedSnapshot = await getEditorStoreSnapshot(page); - expect(movedSnapshot.viewportTransientState.transformSession).toEqual({ - kind: "none" - }); - expect(movedSnapshot.document.brushes[fixtures.brush.id].center.y).toBe(fixtures.brush.center.y); - expect(movedSnapshot.document.brushes[fixtures.brush.id].center.z).toBe(fixtures.brush.center.z); - expect(movedSnapshot.document.brushes[fixtures.brush.id].center.x).not.toBe(fixtures.brush.center.x); -}); - -test("keyboard move commits an entity translation through the shared transform controller", async ({ page }) => { - await page.goto("/"); - await page.evaluate((storageKey) => { - window.localStorage.removeItem(storageKey); - }, "webeditor3d.scene-document-draft"); - await page.reload(); - - const fixtures = await seedTransformScene(page); - - await page.getByTestId(`outliner-entity-${fixtures.playerStart.id}`).click(); - await commitKeyboardMove(page, "topLeft", { x: 100, y: -60 }); - - const snapshot = await getEditorStoreSnapshot(page); - expect(snapshot.viewportTransientState.transformSession).toEqual({ - kind: "none" - }); - expect(snapshot.document.entities[fixtures.playerStart.id].position).not.toEqual(fixtures.playerStart.position); -}); - -test("escape cancels an active entity transform session without committing preview changes", async ({ page }) => { - await page.goto("/"); - await page.evaluate((storageKey) => { - window.localStorage.removeItem(storageKey); - }, "webeditor3d.scene-document-draft"); - await page.reload(); - - const fixtures = await seedTransformScene(page); - - await page.getByTestId(`outliner-entity-${fixtures.playerStart.id}`).click(); - - const center = await getViewportCenter(page, "topLeft"); - await page.mouse.move(center.x, center.y); - await page.keyboard.press("g"); - await page.mouse.move(center.x + 140, center.y + 40); - await page.keyboard.press("Escape"); - - const snapshot = await getEditorStoreSnapshot(page); - expect(snapshot.viewportTransientState.transformSession).toEqual({ - kind: "none" - }); - expect(snapshot.document.entities[fixtures.playerStart.id].position).toEqual(fixtures.playerStart.position); -}); - -test("keyboard move commits a model instance translation through the shared transform controller", async ({ page }) => { - await page.goto("/"); - await page.evaluate((storageKey) => { - window.localStorage.removeItem(storageKey); - }, "webeditor3d.scene-document-draft"); - await page.reload(); - - const fixtures = await seedTransformScene(page); - - await page.getByTestId(`outliner-model-instance-${fixtures.modelInstance.id}`).click(); - await commitKeyboardMove(page, "topLeft", { x: -120, y: 80 }); - - const snapshot = await getEditorStoreSnapshot(page); - expect(snapshot.viewportTransientState.transformSession).toEqual({ - kind: "none" - }); - expect(snapshot.document.modelInstances[fixtures.modelInstance.id].position).not.toEqual(fixtures.modelInstance.position); -}); diff --git a/tests/unit/transform-foundation.integration.test.tsx b/tests/unit/transform-foundation.integration.test.tsx new file mode 100644 index 00000000..1a8c117f --- /dev/null +++ b/tests/unit/transform-foundation.integration.test.tsx @@ -0,0 +1,421 @@ +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; + 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(); + 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() + }; +} + +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 + } + } + }; + + 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("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 + } + }); + }); +});