import { act, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createTransformSession, createInactiveTransformSession, type ActiveTransformSession, type TransformSessionState } from "../../src/core/transform-session"; import type { EditorSelection } from "../../src/core/selection"; import type { ArmedTerrainBrushState } from "../../src/core/terrain-brush"; import { createBoxBrush } from "../../src/document/brushes"; import { createEmptySceneDocument } from "../../src/document/scene-document"; import { createTerrain } from "../../src/document/terrains"; import { EditorSimulationController } from "../../src/runtime-three/editor-simulation-controller"; import { ViewportCanvas } from "../../src/viewport-three/ViewportCanvas"; import { createDefaultViewportPanelCameraState, type ViewportPanelCameraState } from "../../src/viewport-three/viewport-layout"; import type { CreationViewportToolPreview, ViewportToolPreview } from "../../src/viewport-three/viewport-transient-state"; const { MockViewportHost, viewportHostInstances } = vi.hoisted(() => { const viewportHostInstances: Array<{ mount: ReturnType; dispose: ReturnType; setRenderEnabled: ReturnType; updateWorld: ReturnType; updateSimulation: ReturnType; updateSimulationFrame: ReturnType; updateAssets: ReturnType; updateDocument: ReturnType; updateSelection: ReturnType; setViewMode: ReturnType; setDisplayMode: ReturnType; setCameraState: ReturnType; setBrushSelectionChangeHandler: ReturnType; setCameraStateChangeHandler: ReturnType; setCreationPreviewChangeHandler: ReturnType; setCreationCommitHandler: ReturnType; setTransformSessionChangeHandler: ReturnType; setTransformPreviewChangeHandler: ReturnType; setTransformCommitHandler: ReturnType; setTransformCancelHandler: ReturnType; setTerrainBrushCommitHandler: ReturnType; setWhiteboxHoverLabelChangeHandler: ReturnType; setWhiteboxSelectionMode: ReturnType; setWhiteboxSnapSettings: ReturnType; setGridVisible: ReturnType; setToolMode: ReturnType; setTerrainBrushState: ReturnType; setCreationPreview: ReturnType; setTransformSession: ReturnType; setPanelId: ReturnType; focusSelection: ReturnType; }> = []; class MockViewportHost { mount = vi.fn(); dispose = vi.fn(); setRenderEnabled = vi.fn(); updateWorld = vi.fn(); updateSimulation = vi.fn(); updateSimulationFrame = vi.fn(); updateAssets = vi.fn(); updateDocument = vi.fn(); updateSelection = 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(); setTransformPreviewChangeHandler = vi.fn(); setTransformCommitHandler = vi.fn(); setTransformCancelHandler = vi.fn(); setTerrainBrushCommitHandler = vi.fn(); setWhiteboxHoverLabelChangeHandler = vi.fn(); setWhiteboxSelectionMode = vi.fn(); setWhiteboxSnapSettings = vi.fn(); setGridVisible = vi.fn(); setToolMode = vi.fn(); setTerrainBrushState = vi.fn(); setCreationPreview = vi.fn(); setTransformSession = vi.fn(); setPanelId = vi.fn(); focusSelection = vi.fn(); constructor() { viewportHostInstances.push(this); } } return { MockViewportHost, viewportHostInstances }; }); vi.mock("../../src/viewport-three/viewport-host", () => ({ ViewportHost: MockViewportHost })); describe("ViewportCanvas", () => { beforeEach(() => { viewportHostInstances.length = 0; vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation( () => ({}) as never ); }); afterEach(() => { vi.restoreAllMocks(); }); it("wires the creation commit handler into the viewport host", async () => { const sceneDocument = createEmptySceneDocument(); const cameraState = createDefaultViewportPanelCameraState(); const toolPreview: CreationViewportToolPreview = { kind: "create", sourcePanelId: "topLeft", target: { kind: "box-brush" }, center: null }; const onCommitCreation = vi.fn(() => true); const onCameraStateChange = vi.fn( (_cameraState: ViewportPanelCameraState) => undefined ); const onToolPreviewChange = vi.fn( (_toolPreview: ViewportToolPreview) => undefined ); const onTransformSessionChange = vi.fn( (_transformSession: TransformSessionState) => undefined ); const onTransformCommit = vi.fn( (_transformSession: ActiveTransformSession) => undefined ); const onTransformCancel = vi.fn(() => undefined); const onSelectionChange = vi.fn(); render( true)} onCommitCreation={onCommitCreation} onCameraStateChange={onCameraStateChange} onToolPreviewChange={onToolPreviewChange} onTransformSessionChange={onTransformSessionChange} onTransformCommit={onTransformCommit} onTransformCancel={onTransformCancel} /> ); await waitFor(() => { expect(viewportHostInstances).toHaveLength(1); expect( viewportHostInstances[0].setCreationCommitHandler ).toHaveBeenCalledTimes(1); }); const registeredHandler = viewportHostInstances[0].setCreationCommitHandler .mock.calls[0][0] as ( toolPreview: CreationViewportToolPreview ) => boolean; expect(registeredHandler(toolPreview)).toBe(true); expect(onCommitCreation).toHaveBeenCalledWith(toolPreview); }); it("applies and subscribes to persisted camera state through the viewport host", async () => { const sceneDocument = createEmptySceneDocument(); const cameraState = createDefaultViewportPanelCameraState(); const onCameraStateChange = vi.fn( (_cameraState: ViewportPanelCameraState) => undefined ); render( true)} onCommitCreation={vi.fn(() => true)} onCameraStateChange={onCameraStateChange} onToolPreviewChange={vi.fn()} onTransformSessionChange={vi.fn()} onTransformCommit={vi.fn()} onTransformCancel={vi.fn()} /> ); await waitFor(() => { expect(viewportHostInstances).toHaveLength(1); expect(viewportHostInstances[0].setCameraState).toHaveBeenCalledWith( cameraState ); expect( viewportHostInstances[0].setCameraStateChangeHandler ).toHaveBeenCalledTimes(1); }); }); it("syncs selection without resyncing the viewport document", async () => { const brush = createBoxBrush({ id: "selection-sync-brush" }); const sceneDocument = { ...createEmptySceneDocument(), brushes: { [brush.id]: brush } }; const cameraState = createDefaultViewportPanelCameraState(); const renderCanvas = ( selection: EditorSelection, activeSelectionId: string | null ) => ( true)} onCommitCreation={vi.fn(() => true)} onCameraStateChange={vi.fn()} onToolPreviewChange={vi.fn()} onTransformSessionChange={vi.fn()} onTransformCommit={vi.fn()} onTransformCancel={vi.fn()} /> ); const { rerender } = render(renderCanvas({ kind: "none" }, null)); await waitFor(() => { expect(viewportHostInstances).toHaveLength(1); expect(viewportHostInstances[0].updateDocument).toHaveBeenCalledWith( sceneDocument ); }); const viewportHost = viewportHostInstances[0]; expect(viewportHost.updateDocument.mock.calls[0]).toHaveLength(1); viewportHost.updateDocument.mockClear(); viewportHost.updateSelection.mockClear(); const selectedBrush: EditorSelection = { kind: "brushes", ids: [brush.id] }; rerender(renderCanvas(selectedBrush, brush.id)); await waitFor(() => { expect(viewportHost.updateSelection).toHaveBeenCalledWith( selectedBrush, brush.id ); }); expect(viewportHost.updateDocument).not.toHaveBeenCalled(); }); it("pushes editor simulation scene state into the viewport host", async () => { const sceneDocument = createEmptySceneDocument(); const editorSimulationController = new EditorSimulationController(); editorSimulationController.updateInputs({ document: sceneDocument, loadedModelAssets: {} }); const editorSimulationFrame = editorSimulationController.getFrameSnapshot(); render( true)} onCommitCreation={vi.fn(() => true)} onCameraStateChange={vi.fn()} onToolPreviewChange={vi.fn()} onTransformSessionChange={vi.fn()} onTransformCommit={vi.fn()} onTransformCancel={vi.fn()} /> ); await waitFor(() => { expect(viewportHostInstances).toHaveLength(1); expect(viewportHostInstances[0].updateSimulation).toHaveBeenCalledWith( editorSimulationFrame.runtimeScene, editorSimulationFrame.clock, { sceneVersion: editorSimulationFrame.sceneVersion, frameVersion: editorSimulationFrame.frameVersion } ); }); }); it("routes ordinary editor simulation frames through the incremental host update", async () => { const sceneDocument = createEmptySceneDocument(); sceneDocument.time.dayLengthMinutes = 24; const editorSimulationController = new EditorSimulationController({ requestAnimationFrame: () => 1, cancelAnimationFrame: () => undefined }); editorSimulationController.updateInputs({ document: sceneDocument, loadedModelAssets: {} }); render( true)} onCommitCreation={vi.fn(() => true)} onCameraStateChange={vi.fn()} onToolPreviewChange={vi.fn()} onTransformSessionChange={vi.fn()} onTransformCommit={vi.fn()} onTransformCancel={vi.fn()} /> ); await waitFor(() => { expect(viewportHostInstances).toHaveLength(1); }); const viewportHost = viewportHostInstances[0]; viewportHost.updateSimulation.mockClear(); viewportHost.updateSimulationFrame.mockClear(); act(() => { editorSimulationController.play(); editorSimulationController.advance(0.25); }); expect(viewportHost.updateSimulation).not.toHaveBeenCalled(); expect(viewportHost.updateSimulationFrame).toHaveBeenCalled(); }); it("routes editor simulation scene-version changes through the structural host update", async () => { const sceneDocument = createEmptySceneDocument(); const editorSimulationController = new EditorSimulationController(); editorSimulationController.updateInputs({ document: sceneDocument, loadedModelAssets: {} }); render( true)} onCommitCreation={vi.fn(() => true)} onCameraStateChange={vi.fn()} onToolPreviewChange={vi.fn()} onTransformSessionChange={vi.fn()} onTransformCommit={vi.fn()} onTransformCancel={vi.fn()} /> ); await waitFor(() => { expect(viewportHostInstances).toHaveLength(1); }); const viewportHost = viewportHostInstances[0]; viewportHost.updateSimulation.mockClear(); viewportHost.updateSimulationFrame.mockClear(); const nextSceneDocument = { ...sceneDocument, name: "Structural Simulation Scene" }; act(() => { editorSimulationController.updateInputs({ document: nextSceneDocument, loadedModelAssets: {} }); }); expect(viewportHost.updateSimulation).toHaveBeenCalledTimes(1); expect(viewportHost.updateSimulationFrame).not.toHaveBeenCalled(); }); it("shows the surface snap translate overlay when the active transform enables it", () => { const sceneDocument = createEmptySceneDocument(); const brush = createBoxBrush({ id: "overlay-brush", center: { x: 0, y: 1, z: 0 } }); render( true)} onCommitCreation={vi.fn(() => true)} onCameraStateChange={vi.fn()} onToolPreviewChange={vi.fn()} onTransformSessionChange={vi.fn()} onTransformCommit={vi.fn()} onTransformCancel={vi.fn()} /> ); expect(screen.getByTestId("viewport-transform-preview-topLeft")).toHaveTextContent( "translate · surface snap · Local X" ); }); it("shows the terrain brush overlay and pushes brush state into the viewport host", async () => { const sceneDocument = createEmptySceneDocument(); const terrainBrushState: ArmedTerrainBrushState = { terrainId: "terrain-selected", tool: "smooth", radius: 2.5, strength: 0.4, falloff: 0.7 }; render( true)} onCommitCreation={vi.fn(() => true)} onCameraStateChange={vi.fn()} onToolPreviewChange={vi.fn()} onTransformSessionChange={vi.fn()} onTransformCommit={vi.fn()} onTransformCancel={vi.fn()} /> ); await waitFor(() => { expect(viewportHostInstances).toHaveLength(1); expect(viewportHostInstances[0].setTerrainBrushState).toHaveBeenCalledWith( terrainBrushState ); expect( viewportHostInstances[0].setTerrainBrushCommitHandler ).toHaveBeenCalledTimes(1); }); expect( screen.getByTestId("viewport-terrain-brush-preview-topLeft") ).toHaveTextContent("terrain · smooth"); }); it("shows the active terrain paint layer in the viewport overlay", () => { const sceneDocument = createEmptySceneDocument(); const terrainBrushState: ArmedTerrainBrushState = { terrainId: "terrain-selected", tool: "paint", layerIndex: 2, radius: 2.5, strength: 0.4, falloff: 0.7 }; render( true)} onCommitCreation={vi.fn(() => true)} onCameraStateChange={vi.fn()} onToolPreviewChange={vi.fn()} onTransformSessionChange={vi.fn()} onTransformCommit={vi.fn()} onTransformCancel={vi.fn()} /> ); expect( screen.getByTestId("viewport-terrain-brush-preview-topLeft") ).toHaveTextContent("terrain · paint · layer 3"); }); it("does not refocus the viewport when the scene document changes without a new focus request", async () => { const baseSceneDocument = createEmptySceneDocument(); const focusedTerrain = createTerrain({ id: "terrain-focused" }); const focusedSceneDocument = { ...baseSceneDocument, terrains: { [focusedTerrain.id]: focusedTerrain } }; const updatedSceneDocument = { ...focusedSceneDocument, terrains: { [focusedTerrain.id]: createTerrain({ ...focusedTerrain, heights: focusedTerrain.heights.map((height, index) => index === 0 ? height + 1 : height ) }) } }; const cameraState = createDefaultViewportPanelCameraState(); const onCameraStateChange = vi.fn( (_cameraState: ViewportPanelCameraState) => undefined ); const onToolPreviewChange = vi.fn( (_toolPreview: ViewportToolPreview) => undefined ); const onTransformSessionChange = vi.fn( (_transformSession: TransformSessionState) => undefined ); const onTransformCommit = vi.fn( (_transformSession: ActiveTransformSession) => undefined ); const onTransformCancel = vi.fn(() => undefined); const onSelectionChange = vi.fn(); const { rerender } = render( true)} onCommitCreation={vi.fn(() => true)} onCameraStateChange={onCameraStateChange} onToolPreviewChange={onToolPreviewChange} onTransformSessionChange={onTransformSessionChange} onTransformCommit={onTransformCommit} onTransformCancel={onTransformCancel} /> ); await waitFor(() => { expect(viewportHostInstances).toHaveLength(1); expect(viewportHostInstances[0].focusSelection).toHaveBeenCalledTimes(1); }); rerender( true)} onCommitCreation={vi.fn(() => true)} onCameraStateChange={onCameraStateChange} onToolPreviewChange={onToolPreviewChange} onTransformSessionChange={onTransformSessionChange} onTransformCommit={onTransformCommit} onTransformCancel={onTransformCancel} /> ); expect(viewportHostInstances[0].focusSelection).toHaveBeenCalledTimes(1); }); });