import { act, fireEvent, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { DEFAULT_TOOLCRAFT_ORIENTATION_POSE, getToolcraftOrientationPoseFromPointerDelta as rotate, readToolcraftOrientationPose, } from "./orientation-gizmo-math"; import { expectOrientationPoseClose, mockOrientationGizmoBounds, renderOrientationGizmoRuntime, } from "./orientation-gizmo.test-support"; const target = "view.orbit"; const customDefault = { position: [3, 4, 7], up: [0, 0, 1] } as const; const editedPose = { position: [5, 0, 0], up: [0, 1, 0] } as const; async function setup(defaultValue = DEFAULT_TOOLCRAFT_ORIENTATION_POSE) { const store = renderOrientationGizmoRuntime({ defaultValue }); const gizmo = await screen.findByTestId("toolcraft-orientation-gizmo"); mockOrientationGizmoBounds(gizmo); return { store, gizmo, pose: () => readToolcraftOrientationPose(store.getState().values[target]), }; } function start(gizmo: HTMLElement, shiftKey: boolean) { fireEvent.pointerDown(gizmo, { button: 0, clientX: 35, clientY: 20, pointerId: 1, shiftKey, }); } function move(gizmo: HTMLElement, clientX: number, clientY: number, shiftKey: boolean) { fireEvent.pointerMove(gizmo, { clientX, clientY, pointerId: 1, shiftKey }); } function click(gizmo: HTMLElement, detail: number) { const coordinates = { button: 0, clientX: 59.5, clientY: 35 }; fireEvent.pointerDown(gizmo, { ...coordinates, pointerId: 1 }); fireEvent.pointerUp(gizmo, { ...coordinates, pointerId: 1 }); fireEvent.click(gizmo, { ...coordinates, detail }); } describe("Orientation gizmo reset gesture", () => { it.each([ [35, 20], [59.5, 35], ])("resets at %s,%s to the exact schema default", async (clientX, clientY) => { const { store, gizmo } = await setup(customDefault); act(() => store.dispatch({ type: "controls.setValue", target, value: editedPose })); const canvas = store.getState().canvas; fireEvent.doubleClick(gizmo, { button: 0, clientX, clientY }); expect(store.getState().values[target]).toEqual(customDefault); expect(store.getState().values["model.scale"]).toBe(1); expect(store.getState().canvas).toEqual(canvas); act(() => store.dispatch({ type: "history.undo" })); expect(store.getState().values[target]).toEqual(editedPose); act(() => store.dispatch({ type: "history.redo" })); expect(store.getState().values[target]).toEqual(customDefault); }); it("merges the first endpoint snap and double-click reset into one Undo step", async () => { const { store, gizmo } = await setup(customDefault); act(() => store.dispatch({ type: "controls.setValue", target, value: DEFAULT_TOOLCRAFT_ORIENTATION_POSE, }), ); const before = store.getState().values[target]; const historyLength = store.getState().history.undo.length; vi.useFakeTimers(); click(gizmo, 1); act(() => vi.advanceTimersByTime(48)); expect(store.getState().values[target]).not.toEqual(before); click(gizmo, 2); fireEvent.doubleClick(gizmo, { button: 0, clientX: 59.5, clientY: 35, detail: 2 }); expect(store.getState().values[target]).toEqual(customDefault); act(() => vi.advanceTimersByTime(300)); expect(store.getState().values[target]).toEqual(customDefault); expect(store.getState().history.undo).toHaveLength(historyLength + 1); act(() => store.dispatch({ type: "history.undo" })); expect(store.getState().values[target]).toEqual(before); act(() => store.dispatch({ type: "history.redo" })); expect(store.getState().values[target]).toEqual(customDefault); }); it.each([ { clientX: 1, clientY: 1 }, { clientX: 35, clientY: 20, altKey: true }, { clientX: 35, clientY: 20, ctrlKey: true }, { clientX: 35, clientY: 20, metaKey: true }, { clientX: 35, clientY: 20, button: 2 }, ])("does not claim an outside or unsupported double-click: %j", async (event) => { const { store, gizmo } = await setup(); act(() => store.dispatch({ type: "controls.setValue", target, value: editedPose })); fireEvent.doubleClick(gizmo, { button: 0, ...event }); expect(store.getState().values[target]).toEqual(editedPose); }); it("does not absorb an external target edit between the two clicks", async () => { const { store, gizmo } = await setup(customDefault); vi.useFakeTimers(); click(gizmo, 1); act(() => vi.advanceTimersByTime(48)); act(() => store.dispatch({ type: "controls.setValue", target, value: editedPose })); click(gizmo, 2); fireEvent.doubleClick(gizmo, { button: 0, clientX: 59.5, clientY: 35, detail: 2 }); expect(store.getState().values[target]).toEqual(customDefault); act(() => store.dispatch({ type: "history.undo" })); expect(store.getState().values[target]).toEqual(editedPose); act(() => vi.advanceTimersByTime(300)); expect(store.getState().values[target]).toEqual(editedPose); }); it("does not record a reset when already at the default", async () => { const { store, gizmo } = await setup(customDefault); fireEvent.doubleClick(gizmo, { button: 0, clientX: 35, clientY: 20 }); expect(store.getState().values[target]).toEqual(customDefault); expect(store.getState().history.undo).toHaveLength(0); }); }); describe("Orientation gizmo Shift drag", () => { it.each([ [20, 8, 20, 0], [8, 20, 0, 20], [10, 10, 10, 0], ])( "locks delta %s,%s to %s,%s without moving the canvas", async (dx, dy, expectedX, expectedY) => { const { store, gizmo, pose } = await setup(); start(gizmo, true); move(gizmo, 35 + dx, 20 + dy, true); expectOrientationPoseClose( pose(), rotate(DEFAULT_TOOLCRAFT_ORIENTATION_POSE, expectedX, expectedY), ); expect(store.getState().canvas.offset).toEqual({ x: 0, y: 0 }); fireEvent.pointerUp(gizmo, { pointerId: 1 }); expect(store.getState().history.undo).toHaveLength(1); act(() => store.dispatch({ type: "history.undo" })); expect(store.getState().values[target]).toEqual(DEFAULT_TOOLCRAFT_ORIENTATION_POSE); }, ); it("keeps the chosen axis when the pointer changes direction", async () => { const { gizmo, pose } = await setup(); start(gizmo, true); move(gizmo, 45, 24, true); const first = rotate(DEFAULT_TOOLCRAFT_ORIENTATION_POSE, 10, 0); move(gizmo, 46, 65, true); expectOrientationPoseClose(pose(), rotate(first, 1, 0)); }); it("does not dispatch when movement is entirely on the suppressed axis", async () => { const { store, gizmo, pose } = await setup(); start(gizmo, true); move(gizmo, 45, 24, true); expect(pose()).not.toEqual(DEFAULT_TOOLCRAFT_ORIENTATION_POSE); const before = store.getState(); move(gizmo, 45, 50, true); expect(store.getState()).toBe(before); move(gizmo, 45, 52, false); expectOrientationPoseClose( pose(), rotate(readToolcraftOrientationPose(before.values[target]), 0, 2), ); }); it("presses, releases and represses Shift mid-drag without applying discarded motion", async () => { const { gizmo, pose } = await setup(); start(gizmo, false); move(gizmo, 43, 26, false); let expected = rotate(DEFAULT_TOOLCRAFT_ORIENTATION_POSE, 8, 6); move(gizmo, 45, 34, true); expected = rotate(expected, 0, 8); expectOrientationPoseClose(pose(), expected); move(gizmo, 65, 35, true); expected = rotate(expected, 0, 1); expectOrientationPoseClose(pose(), expected); move(gizmo, 67, 38, false); expected = rotate(expected, 2, 3); expectOrientationPoseClose(pose(), expected); move(gizmo, 73, 40, true); expected = rotate(expected, 6, 0); expectOrientationPoseClose(pose(), expected); }); it("does not latch an axis before the drag threshold", async () => { const { store, gizmo, pose } = await setup(); start(gizmo, true); move(gizmo, 37, 20, true); expect(store.getState().history.undo).toHaveLength(0); move(gizmo, 37, 30, true); expectOrientationPoseClose(pose(), rotate(DEFAULT_TOOLCRAFT_ORIENTATION_POSE, 0, 10)); }); it("chooses a new axis after releasing and repressing Shift without pointer movement", async () => { const { gizmo, pose } = await setup(); start(gizmo, true); move(gizmo, 45, 24, true); const first = pose(); fireEvent.keyUp(window, { key: "Shift" }); fireEvent.keyDown(window, { key: "Shift", shiftKey: true }); move(gizmo, 46, 40, true); expectOrientationPoseClose(pose(), rotate(first, 0, 16)); }); it("ends a Shift gesture on capture loss", async () => { const { gizmo, pose } = await setup(); start(gizmo, true); move(gizmo, 45, 24, true); const before = pose(); expect(before).not.toEqual(DEFAULT_TOOLCRAFT_ORIENTATION_POSE); fireEvent.lostPointerCapture(gizmo, { pointerId: 1 }); move(gizmo, 55, 40, true); expect(pose()).toEqual(before); }); });