// @vitest-environment jsdom import * as React from "react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { VectorControl } from "./vector-control"; import type { VectorControlProps, VectorControlValue } from "./vector-control-types"; beforeAll(() => { if (!window.PointerEvent) vi.stubGlobal("PointerEvent", class extends MouseEvent { pointerId: number; constructor(type: string, options: PointerEventInit = {}) { super(type, options); this.pointerId = options.pointerId ?? 1; } }); }); afterEach(cleanup); function mount(props: Partial = {}) { const change = vi.fn(); function Harness() { const [value, setValue] = React.useState({ x: "0.1234567", y: "-0.25" }); return { change(next, meta); setValue(next); }} />; } render(); const pad = screen.getByRole("button", { name: "Position X/Y pad" }); vi.spyOn(pad, "getBoundingClientRect").mockReturnValue({ left: 0, top: 0, width: 100, height: 100 } as DOMRect); Object.assign(pad, { setPointerCapture: vi.fn(), hasPointerCapture: () => true, releasePointerCapture: vi.fn() }); const drag = (x: number, y: number, shiftKey = false) => { fireEvent.pointerDown(pad, { button: 0, buttons: 1, pointerId: 1, clientX: 50, clientY: 50, shiftKey }); fireEvent.pointerMove(pad, { button: 0, buttons: 1, pointerId: 1, clientX: x, clientY: y, shiftKey }); fireEvent.pointerUp(pad, { pointerId: 1 }); }; return { change, pad, drag }; } const lock = (axis: string) => fireEvent.click(screen.getByRole("button", { name: `Lock Position ${axis} axis` })); function edit(axis: string, draft: string, key?: string) { fireEvent.click(screen.getByRole("button", { name: `Edit Position ${axis} value` })); const editor = screen.getByRole("textbox", { name: `Position ${axis} value` }); editor.textContent = draft; if (key) fireEvent.keyDown(editor, { key }); fireEvent.blur(editor); } describe("Vector axis locks", () => { it("starts open and preserves exact X through pointer, Shift, numeric input and reset", () => { const { change, drag, pad } = mount(); expect(screen.getByRole("button", { name: "Lock Position X axis" }).getAttribute("aria-pressed")).toBe("false"); lock("X"); expect(change).not.toHaveBeenCalled(); expect(screen.getByRole("button", { name: "Unlock Position X axis" }).getAttribute("aria-pressed")).toBe("true"); expect(screen.queryByRole("button", { name: "Edit Position X value" })).toBeNull(); drag(90, 65, true); expect(change.mock.lastCall?.[0]).toEqual({ x: "0.1234567", y: "0.30" }); edit("Y", "−0,8", "Enter"); expect(change.mock.lastCall?.[0]).toEqual({ x: "0.1234567", y: "-0.80" }); fireEvent.doubleClick(pad); expect(change.mock.lastCall?.[0]).toEqual({ x: "0.1234567", y: "0.00" }); expect(pad.releasePointerCapture).toHaveBeenCalled(); }); it("preserves Y in cartesian mode, blocks both axes and unlocks without a jump", () => { const { change, drag, pad } = mount({ padCoordinateMode: "cartesian", padVariant: "whiteBalance" }); lock("Y"); drag(80, 5, true); expect(change.mock.lastCall?.[0]).toEqual({ x: "0.60", y: "-0.25" }); lock("X"); change.mockClear(); expect((pad as HTMLButtonElement).disabled).toBe(true); drag(0, 100); fireEvent.doubleClick(pad); expect(change).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Unlock Position X axis" })); expect(change).not.toHaveBeenCalled(); drag(90, 0); expect(change.mock.lastCall?.[0]).toEqual({ x: "0.80", y: "-0.25" }); }); it("retains valid coordinates on malformed/cancelled drafts and clamps the existing domain", () => { const { change } = mount(); for (const draft of ["", "NaN", "Infinity", "0.5oops", "0.5,0.7", "0x10"]) edit("X", draft); edit("X", "0.5", "Escape"); expect(change).not.toHaveBeenCalled(); edit("X", "50", "Enter"); expect(change).toHaveBeenCalledTimes(1); expect(change.mock.lastCall?.[0]).toEqual({ x: "1.00", y: "-0.25" }); }); it("ignores pointer movement outside an active gesture and after cancellation", () => { const { change, pad } = mount(); fireEvent.pointerMove(pad, { buttons: 1, clientX: 20, clientY: 20 }); expect(change).not.toHaveBeenCalled(); fireEvent.pointerDown(pad, { buttons: 1, button: 0, clientX: 50, clientY: 50, pointerId: 1 }); change.mockClear(); fireEvent.pointerMove(pad, { buttons: 1, clientX: 20, clientY: 20, pointerId: 2 }); expect(change).not.toHaveBeenCalled(); fireEvent.pointerUp(pad, { pointerId: 2 }); fireEvent.pointerMove(pad, { buttons: 1, clientX: 80, clientY: 60, pointerId: 1 }); expect(change.mock.lastCall?.[0]).toEqual({ x: "0.60", y: "0.20" }); fireEvent.pointerCancel(pad, { pointerId: 1 }); change.mockClear(); fireEvent.pointerMove(pad, { buttons: 1, clientX: 20, clientY: 20, pointerId: 1 }); expect(change).not.toHaveBeenCalled(); }); it("keeps locks independent per control and lets external state remain authoritative", () => { const first = vi.fn(); const value: VectorControlValue = { x: "0.1234567", y: "0.25" }; const view = render(<>); lock("X"); expect(screen.getByRole("button", { name: "Lock Other X axis" }).getAttribute("aria-pressed")).toBe("false"); view.rerender(<>); edit("Y", "-0.5"); expect(first).toHaveBeenCalledWith({ x: "0.6789123", y: "-0.50" }); expect(screen.getByRole("button", { name: "Unlock Position X axis" })).toBeTruthy(); }); });