diff --git a/tests/unit/viewport-canvas.test.tsx b/tests/unit/viewport-canvas.test.tsx
index 0f1f6747..8b6cff22 100644
--- a/tests/unit/viewport-canvas.test.tsx
+++ b/tests/unit/viewport-canvas.test.tsx
@@ -1,4 +1,4 @@
-import { act, render, screen, waitFor } from "@testing-library/react";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -120,6 +120,7 @@ describe("ViewportCanvas", () => {
});
afterEach(() => {
+ vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -805,6 +806,260 @@ describe("ViewportCanvas", () => {
).toHaveTextContent("terrain · paint · layer 3");
});
+ it("shows the viewport time transport only on the active viewport panel", () => {
+ const sceneDocument = createEmptySceneDocument();
+ const commonProps = {
+ world: sceneDocument.world,
+ sceneDocument,
+ editorSimulationController: new EditorSimulationController(),
+ projectAssets: sceneDocument.assets,
+ loadedModelAssets: {},
+ loadedImageAssets: {},
+ whiteboxSelectionMode: "object" as const,
+ whiteboxSnapEnabled: true,
+ whiteboxSnapStep: 1,
+ viewportGridVisible: true,
+ selection: { kind: "none" } as EditorSelection,
+ activeSelectionId: null,
+ terrainBrushState: null,
+ toolMode: "select" as const,
+ toolPreview: { kind: "none" } as ViewportToolPreview,
+ transformSession: createInactiveTransformSession(),
+ cameraState: createDefaultViewportPanelCameraState(),
+ viewMode: "perspective" as const,
+ displayMode: "normal" as const,
+ layoutMode: "quad" as const,
+ focusRequestId: 0,
+ focusSelection: { kind: "none" } as EditorSelection,
+ onSelectionChange: vi.fn(),
+ onTerrainBrushCommit: vi.fn(() => true),
+ onCommitCreation: vi.fn(() => true),
+ onCameraStateChange: vi.fn(),
+ onToolPreviewChange: vi.fn(),
+ onTransformSessionChange: vi.fn(),
+ onTransformCommit: vi.fn(),
+ onTransformCancel: vi.fn(),
+ onPlayEditorSimulation: vi.fn(),
+ onPauseEditorSimulation: vi.fn(),
+ onStepEditorSimulation: vi.fn()
+ };
+
+ const { rerender } = render(
+
+ );
+
+ expect(screen.getByTestId("viewport-time-transport-topLeft")).toBeVisible();
+
+ rerender(
+
+ );
+
+ expect(
+ screen.queryByTestId("viewport-time-transport-topRight")
+ ).not.toBeInTheDocument();
+ });
+
+ it("routes viewport time transport play and pause to editor simulation handlers", () => {
+ const sceneDocument = createEmptySceneDocument();
+ const onPlayEditorSimulation = vi.fn();
+ const onPauseEditorSimulation = vi.fn();
+
+ const renderCanvas = (editorSimulationPlaying: boolean) => (
+ true)}
+ onCommitCreation={vi.fn(() => true)}
+ onCameraStateChange={vi.fn()}
+ onToolPreviewChange={vi.fn()}
+ onTransformSessionChange={vi.fn()}
+ onTransformCommit={vi.fn()}
+ onTransformCancel={vi.fn()}
+ onPlayEditorSimulation={onPlayEditorSimulation}
+ onPauseEditorSimulation={onPauseEditorSimulation}
+ onStepEditorSimulation={vi.fn()}
+ />
+ );
+
+ const { rerender } = render(renderCanvas(false));
+ const playToggle = screen.getByTestId("viewport-time-play-toggle-topLeft");
+
+ expect(playToggle).toHaveTextContent(">");
+ fireEvent.click(playToggle);
+ expect(onPlayEditorSimulation).toHaveBeenCalledTimes(1);
+ expect(onPauseEditorSimulation).not.toHaveBeenCalled();
+
+ rerender(renderCanvas(true));
+ const pauseToggle = screen.getByTestId("viewport-time-play-toggle-topLeft");
+
+ expect(pauseToggle).toHaveTextContent("II");
+ fireEvent.click(pauseToggle);
+ expect(onPauseEditorSimulation).toHaveBeenCalledTimes(1);
+ });
+
+ it("steps viewport time once on click and repeatedly while held", () => {
+ vi.useFakeTimers();
+
+ const sceneDocument = createEmptySceneDocument();
+ const onStepEditorSimulation = vi.fn();
+
+ render(
+ true)}
+ onCommitCreation={vi.fn(() => true)}
+ onCameraStateChange={vi.fn()}
+ onToolPreviewChange={vi.fn()}
+ onTransformSessionChange={vi.fn()}
+ onTransformCommit={vi.fn()}
+ onTransformCancel={vi.fn()}
+ onPlayEditorSimulation={vi.fn()}
+ onPauseEditorSimulation={vi.fn()}
+ onStepEditorSimulation={onStepEditorSimulation}
+ />
+ );
+
+ fireEvent.click(screen.getByTestId("viewport-time-rewind-topLeft"));
+ expect(onStepEditorSimulation).toHaveBeenLastCalledWith(-0.25);
+ expect(onStepEditorSimulation).toHaveBeenCalledTimes(1);
+
+ const fastForward = screen.getByTestId("viewport-time-forward-topLeft");
+ fireEvent.pointerDown(fastForward, { button: 0, pointerId: 1 });
+ expect(onStepEditorSimulation).toHaveBeenLastCalledWith(0.25);
+ expect(onStepEditorSimulation).toHaveBeenCalledTimes(2);
+
+ act(() => {
+ vi.advanceTimersByTime(124);
+ });
+ expect(onStepEditorSimulation).toHaveBeenCalledTimes(2);
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(onStepEditorSimulation).toHaveBeenCalledTimes(3);
+
+ fireEvent.pointerUp(fastForward, { pointerId: 1 });
+ act(() => {
+ vi.advanceTimersByTime(250);
+ });
+ expect(onStepEditorSimulation).toHaveBeenCalledTimes(3);
+ });
+
+ it("stops viewport time repeat stepping on pointer cancel and blur", () => {
+ vi.useFakeTimers();
+
+ const sceneDocument = createEmptySceneDocument();
+ const onStepEditorSimulation = vi.fn();
+
+ render(
+ true)}
+ onCommitCreation={vi.fn(() => true)}
+ onCameraStateChange={vi.fn()}
+ onToolPreviewChange={vi.fn()}
+ onTransformSessionChange={vi.fn()}
+ onTransformCommit={vi.fn()}
+ onTransformCancel={vi.fn()}
+ onPlayEditorSimulation={vi.fn()}
+ onPauseEditorSimulation={vi.fn()}
+ onStepEditorSimulation={onStepEditorSimulation}
+ />
+ );
+
+ const rewind = screen.getByTestId("viewport-time-rewind-topLeft");
+ fireEvent.pointerDown(rewind, { button: 0, pointerId: 2 });
+ fireEvent.pointerCancel(rewind, { pointerId: 2 });
+ act(() => {
+ vi.advanceTimersByTime(250);
+ });
+ expect(onStepEditorSimulation).toHaveBeenCalledTimes(1);
+
+ const fastForward = screen.getByTestId("viewport-time-forward-topLeft");
+ fireEvent.pointerDown(fastForward, { button: 0, pointerId: 3 });
+ fireEvent.blur(fastForward);
+ act(() => {
+ vi.advanceTimersByTime(250);
+ });
+ expect(onStepEditorSimulation).toHaveBeenCalledTimes(2);
+ });
+
it("does not refocus the viewport when the scene document changes without a new focus request", async () => {
const baseSceneDocument = createEmptySceneDocument();
const focusedTerrain = createTerrain({