diff --git a/tests/e2e/transform-foundation.e2e.ts b/tests/e2e/transform-foundation.e2e.ts new file mode 100644 index 00000000..bdfa856d --- /dev/null +++ b/tests/e2e/transform-foundation.e2e.ts @@ -0,0 +1,228 @@ +import { expect, test } 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: Parameters[0]["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: Parameters[0]["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: Parameters[0]["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 page.getByTestId("viewport-layout-quad").click(); + await clickViewport(page, "topRight"); + + const selectedSnapshot = await getEditorStoreSnapshot(page); + expect(selectedSnapshot.selection).toEqual({ + kind: "brushes", + ids: [fixtures.brush.id] + }); + + 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.getByRole("button", { name: fixtures.playerStart.name as string }).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.getByRole("button", { name: fixtures.playerStart.name as string }).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.getByRole("button", { name: fixtures.modelInstance.name as string }).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); +});