// @vitest-environment jsdom import { renderHook, act } from "@testing-library/react"; import { observable, ObservableHint } from "@legendapp/state"; import type { OpaqueObject } from "@legendapp/state"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { useRef$ } from "@usels/core"; import { useMouseInElement } from "."; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const wrapEl = (el: Element) => observable | null>(ObservableHint.opaque(el)); function createDiv(rect: Partial = {}) { const div = document.createElement("div"); const full: DOMRect = { left: 0, top: 0, right: 200, bottom: 100, width: 200, height: 100, x: 0, y: 0, toJSON: () => ({}), ...rect, }; vi.spyOn(div, "getClientRects").mockReturnValue([full] as unknown as DOMRectList); return div; } // --------------------------------------------------------------------------- // Mocks: ResizeObserver, MutationObserver // --------------------------------------------------------------------------- class ResizeObserverMock { static instances: ResizeObserverMock[] = []; callback: ResizeObserverCallback; observed: Element[] = []; disconnected = false; constructor(cb: ResizeObserverCallback) { this.callback = cb; ResizeObserverMock.instances.push(this); } observe(el: Element) { this.observed.push(el); } unobserve(el: Element) { this.observed = this.observed.filter((e) => e !== el); } disconnect() { this.disconnected = true; this.observed = []; } } class MutationObserverMock { static instances: MutationObserverMock[] = []; callback: MutationCallback; disconnected = false; constructor(cb: MutationCallback) { this.callback = cb; MutationObserverMock.instances.push(this); } observe(_el: Element, _opts?: MutationObserverInit) {} disconnect() { this.disconnected = true; } takeRecords(): MutationRecord[] { return []; } } beforeEach(() => { ResizeObserverMock.instances = []; MutationObserverMock.instances = []; vi.stubGlobal("ResizeObserver", ResizeObserverMock); vi.stubGlobal("MutationObserver", MutationObserverMock); Object.defineProperty(window, "scrollX", { value: 0, configurable: true }); Object.defineProperty(window, "scrollY", { value: 0, configurable: true }); }); afterEach(() => { vi.unstubAllGlobals(); }); // --------------------------------------------------------------------------- // useMouseInElement — edge cases // --------------------------------------------------------------------------- describe("useMouseInElement() — edge cases", () => { it("stop() called after element removal does not throw", () => { const { result } = renderHook(() => { const el$ = useRef$(); const mouse = useMouseInElement(el$); return { el$, mouse }; }); const div = createDiv(); act(() => result.current.el$(div)); // Remove the element act(() => result.current.el$(null)); // stop() after element removal must not throw expect(() => { act(() => result.current.mouse.stop()); }).not.toThrow(); }); it("handleOutside=false does not register mouseleave listener on document", () => { const docAddSpy = vi.spyOn(document, "addEventListener"); const div = createDiv(); renderHook(() => useMouseInElement(wrapEl(div) as any, { handleOutside: false })); // document mouseleave IS still registered (it handles isOutside reset regardless of handleOutside) // The handleOutside option only controls whether elementX/Y updates when mouse is outside, // not whether the mouseleave listener itself is registered. // Verify the hook does not throw and renders correctly with handleOutside=false. // (The mouseleave listener on document is always registered per the hook's implementation.) const mouseleaveListeners = docAddSpy.mock.calls.filter(([event]) => event === "mouseleave"); expect(mouseleaveListeners.length).toBeGreaterThanOrEqual(1); }); it("handleOutside=false — elementX/Y do not update when mouse moves outside element", () => { const div = createDiv({ left: 0, top: 0, right: 200, bottom: 100, width: 200, height: 100, }); const { result } = renderHook(() => useMouseInElement(wrapEl(div) as any, { handleOutside: false }) ); // Move inside first act(() => { window.dispatchEvent( new MouseEvent("mousemove", { clientX: 50, clientY: 40, bubbles: true }) ); }); expect(result.current.elementX$.get()).toBe(50); expect(result.current.elementY$.get()).toBe(40); expect(result.current.isOutside$.get()).toBe(false); // Move outside — with handleOutside=false, elementX/Y must NOT update act(() => { window.dispatchEvent( new MouseEvent("mousemove", { clientX: 500, clientY: 500, bubbles: true }) ); }); expect(result.current.isOutside$.get()).toBe(true); // elementX/Y frozen at last inside values expect(result.current.elementX$.get()).toBe(50); expect(result.current.elementY$.get()).toBe(40); }); });