import { act, fireEvent, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { toolcraftCanvasRotationLockedTarget } from "../../schema/runtime-targets"; import { createToolcraftSettingsPayload, applyToolcraftSettingsPayload, } from "../app-shell/settings-transfer"; import { DEFAULT_TOOLCRAFT_ORIENTATION_POSE } from "./orientation-gizmo-math"; import { mockOrientationGizmoBounds, renderOrientationGizmoRuntime, } from "./orientation-gizmo.test-support"; const pointer = { button: 0, clientX: 59.5, clientY: 35, pointerId: 1 }; function drag(surface: Element): void { fireEvent.pointerDown(surface, pointer); fireEvent.pointerMove(surface, { ...pointer, clientX: 40, clientY: 50 }); fireEvent.pointerUp(surface, pointer); } describe("Toolcraft rotation lock", () => { it("uses the Setup switch to lock drag, snap and reset, dims the complete gizmo by 1.5, and unlocks without changing the pose", async () => { const store = renderOrientationGizmoRuntime({ defaultValue: { position: [2, 1, 5], up: [0, 1, 0] }, }); const gizmo = await screen.findByTestId("toolcraft-orientation-gizmo"); vi.useFakeTimers(); mockOrientationGizmoBounds(gizmo); const toggle = screen.getByRole("switch", { name: "Lock rotation" }); drag(gizmo); const pose = store.getState().values["view.orbit"]; const canvas = store.getState().canvas; fireEvent.click(toggle); expect(store.getState().values[toolcraftCanvasRotationLockedTarget]).toBe( true, ); expect(gizmo.getAttribute("aria-disabled")).toBe("true"); expect(Number(gizmo.parentElement?.style.opacity)).toBeCloseTo(1 / 1.5, 12); expect( gizmo.parentElement?.querySelector( '[data-slot="toolcraft-orientation-gizmo-backing"]', ), ).toBeTruthy(); expect(store.getState().canvas).toEqual(canvas); const historyLength = store.getState().history.undo.length; drag(gizmo); fireEvent.pointerDown(gizmo, pointer); fireEvent.pointerUp(gizmo, pointer); fireEvent.doubleClick(gizmo, pointer); act(() => vi.advanceTimersByTime(500)); expect(store.getState().values["view.orbit"]).toEqual(pose); expect(store.getState().history.undo).toHaveLength(historyLength); expect(gizmo.getAttribute("data-hovered-axis")).toBe(""); fireEvent.click(toggle); expect(gizmo.getAttribute("aria-disabled")).toBe("false"); expect(Number(gizmo.parentElement?.style.opacity)).toBe(1); expect(store.getState().values["view.orbit"]).toEqual(pose); drag(gizmo); expect(store.getState().values["view.orbit"]).not.toEqual(pose); }); it("lets Space pan locked model hits and restores orbit on unlock", () => { vi.useFakeTimers(); const store = renderOrientationGizmoRuntime(); const surface = screen.getByTestId("model-surface"); const toggle = screen.getByRole("switch", { name: "Lock rotation" }); fireEvent.click(toggle); fireEvent.keyDown(document, { key: " ", code: "Space" }); drag(surface); fireEvent.keyUp(document, { key: " ", code: "Space" }); act(() => vi.advanceTimersByTime(50)); expect(store.getState().canvas.offset).toEqual({ x: -19.5, y: 15 }); expect(store.getState().values["view.orbit"]).toEqual( DEFAULT_TOOLCRAFT_ORIENTATION_POSE, ); fireEvent.click(toggle); drag(surface); expect(store.getState().values["view.orbit"]).not.toEqual( DEFAULT_TOOLCRAFT_ORIENTATION_POSE, ); }); it.each(["gizmo-drag", "gizmo-snap", "model-drag"])( "cancels an active %s synchronously, including queued frames and capture", async (kind) => { const store = renderOrientationGizmoRuntime(); const surface = await screen.findByTestId( kind === "model-drag" ? "model-surface" : "toolcraft-orientation-gizmo", ); vi.useFakeTimers(); mockOrientationGizmoBounds(surface); const release = vi.fn(); Object.defineProperty(surface, "hasPointerCapture", { configurable: true, value: () => true, }); Object.defineProperty(surface, "releasePointerCapture", { configurable: true, value: release, }); fireEvent.pointerDown(surface, pointer); if (kind === "gizmo-snap") { fireEvent.pointerUp(surface, pointer); act(() => vi.advanceTimersByTime(32)); } else { fireEvent.pointerMove(surface, { ...pointer, clientX: 30, clientY: 45, }); } const pose = store.getState().values["view.orbit"]; act(() => { store.dispatch({ type: "controls.setValue", target: toolcraftCanvasRotationLockedTarget, value: true, }); // Before React rerenders, a queued write is already prohibited. vi.advanceTimersByTime(500); }); fireEvent.pointerMove(surface, { ...pointer, clientX: 15, clientY: 60 }); fireEvent.pointerUp(surface, pointer); expect(store.getState().values["view.orbit"]).toEqual(pose); expect(release).toHaveBeenCalledWith(1); act(() => store.dispatch({ type: "controls.setValue", target: toolcraftCanvasRotationLockedTarget, value: false, }), ); act(() => vi.advanceTimersByTime(500)); expect(store.getState().values["view.orbit"]).toEqual(pose); }, ); it("round-trips settings, resets and undoes through canonical runtime values without blocking explicit pose commands", () => { const store = renderOrientationGizmoRuntime(); fireEvent.click(screen.getByRole("switch", { name: "Lock rotation" })); const payload = createToolcraftSettingsPayload(store.getState()); expect(payload.values[toolcraftCanvasRotationLockedTarget]).toBe(true); act(() => store.dispatch({ type: "controls.reset" })); expect(store.getState().values[toolcraftCanvasRotationLockedTarget]).toBe( false, ); act(() => store.dispatch({ type: "history.undo" })); expect(store.getState().values[toolcraftCanvasRotationLockedTarget]).toBe( true, ); act(() => store.dispatch({ type: "controls.reset" })); act(() => applyToolcraftSettingsPayload( { dispatch: store.dispatch, state: store.getState() }, payload, [], ), ); expect(store.getState().values[toolcraftCanvasRotationLockedTarget]).toBe( true, ); const pose = { position: [5, 1, 2], up: [0, 1, 0] }; act(() => store.dispatch({ type: "controls.setValue", target: "view.orbit", value: pose, }), ); expect(store.getState().values["view.orbit"]).toEqual(pose); }); });