diff --git a/tests/unit/path-point-editing.integration.test.tsx b/tests/unit/path-point-editing.integration.test.tsx new file mode 100644 index 00000000..74040b98 --- /dev/null +++ b/tests/unit/path-point-editing.integration.test.tsx @@ -0,0 +1,357 @@ +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 type { + ActiveTransformSession, + TransformSessionState +} from "../../src/core/transform-session"; +import { createScenePath } from "../../src/document/paths"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; + +const { MockViewportHost, viewportHostInstances } = vi.hoisted(() => { + const viewportHostInstances: Array<{ + panelId: string | null; + mount: ReturnType; + dispose: ReturnType; + updateWorld: ReturnType; + updateAssets: ReturnType; + updateDocument: ReturnType; + setPanelId: ReturnType; + setRenderEnabled: ReturnType; + setViewMode: ReturnType; + setDisplayMode: ReturnType; + setGridVisible: 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; + mount = vi.fn(); + dispose = vi.fn(); + updateWorld = vi.fn(); + updateAssets = vi.fn(); + updateDocument = vi.fn(); + setPanelId = vi.fn((panelId: string) => { + this.panelId = panelId; + }); + setRenderEnabled = vi.fn(); + setViewMode = vi.fn(); + setDisplayMode = vi.fn(); + setGridVisible = 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 + })) +})); + +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; +} + +function emitSelectionChange( + viewportHost: ReturnType, + selection: { kind: "pathPoint"; pathId: string; pointId: string } +) { + const handler = viewportHost.setBrushSelectionChangeHandler.mock.calls.at( + -1 + )?.[0] as ((selection: unknown) => void) | undefined; + + if (handler === undefined) { + throw new Error("Selection change handler was not registered."); + } + + act(() => { + handler(selection); + }); +} + +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("Path point editing integration", () => { + beforeEach(() => { + viewportHostInstances.length = 0; + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation( + () => ({}) as never + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("moves selected path points and supports Shift+W add, delete, undo, and redo", async () => { + const path = createScenePath({ + id: "path-edit-main", + name: "Patrol Route", + points: [ + { + id: "path-edit-point-a", + position: { + x: -1, + y: 0, + z: 0 + } + }, + { + id: "path-edit-point-b", + position: { + x: 1, + y: 0, + z: 0 + } + } + ] + }); + const store = createEditorStore({ + initialDocument: { + ...createEmptySceneDocument({ name: "Path Editing Fixture" }), + paths: { + [path.id]: path + } + } + }); + + render(); + + await waitFor(() => { + expect(viewportHostInstances.length).toBeGreaterThan(0); + expect( + getTopLeftViewportHost().setTransformCommitHandler + ).toHaveBeenCalled(); + }); + + const viewportHost = getTopLeftViewportHost(); + + emitSelectionChange(viewportHost, { + kind: "pathPoint", + pathId: path.id, + pointId: path.points[1].id + }); + + expect(store.getState().selection).toEqual({ + kind: "pathPoint", + pathId: path.id, + pointId: path.points[1].id + }); + + fireEvent.keyDown(window, { + key: "g", + code: "KeyG" + }); + fireEvent.keyDown(window, { + key: "x", + code: "KeyX" + }); + + const transformSession = + store.getState().viewportTransientState.transformSession; + + if (transformSession.kind !== "active") { + throw new Error("Expected an active transform session."); + } + + expect(transformSession.target).toMatchObject({ + kind: "pathPoint", + pathId: path.id, + pointId: path.points[1].id + }); + expect(transformSession.axisConstraint).toBe("x"); + + const previewSession: ActiveTransformSession = { + ...transformSession, + preview: { + kind: "pathPoint", + position: { + x: 5, + y: 0, + z: 0 + } + } + }; + + emitTransformPreview(viewportHost, previewSession); + commitTransform(viewportHost, previewSession); + + expect( + store.getState().document.paths[path.id]?.points[1]?.position + ).toEqual({ + x: 5, + y: 0, + z: 0 + }); + + fireEvent.keyDown(window, { + key: "W", + code: "KeyW", + shiftKey: true + }); + + await waitFor(() => { + expect(store.getState().document.paths[path.id]?.points).toHaveLength(3); + }); + + const selectionAfterAdd = store.getState().selection; + + expect(selectionAfterAdd.kind).toBe("pathPoint"); + if (selectionAfterAdd.kind !== "pathPoint") { + throw new Error("Expected the appended path point to be selected."); + } + + const appendedPointId = selectionAfterAdd.pointId; + + fireEvent.keyDown(window, { + key: "Delete", + code: "Delete" + }); + + await waitFor(() => { + expect(store.getState().document.paths[path.id]?.points).toHaveLength(2); + }); + + expect( + store.getState().document.paths[path.id]?.points.some( + (point) => point.id === appendedPointId + ) + ).toBe(false); + + fireEvent.keyDown(window, { + key: "z", + code: "KeyZ", + ctrlKey: true + }); + + expect(store.getState().document.paths[path.id]?.points).toHaveLength(3); + + fireEvent.keyDown(window, { + key: "z", + code: "KeyZ", + ctrlKey: true + }); + + expect(store.getState().document.paths[path.id]?.points).toHaveLength(2); + + fireEvent.keyDown(window, { + key: "z", + code: "KeyZ", + ctrlKey: true + }); + + expect( + store.getState().document.paths[path.id]?.points[1]?.position + ).toEqual(path.points[1].position); + + fireEvent.keyDown(window, { + key: "y", + code: "KeyY", + ctrlKey: true + }); + fireEvent.keyDown(window, { + key: "y", + code: "KeyY", + ctrlKey: true + }); + fireEvent.keyDown(window, { + key: "y", + code: "KeyY", + ctrlKey: true + }); + + expect( + store.getState().document.paths[path.id]?.points[1]?.position + ).toEqual({ + x: 5, + y: 0, + z: 0 + }); + expect(store.getState().document.paths[path.id]?.points).toHaveLength(2); + }); +});