// @vitest-environment happy-dom import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { DomEditSelection } from "../components/editor/domEditing"; import { usePlayerStore } from "../player"; import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness"; import { usePreviewInteraction } from "./usePreviewInteraction"; installReactActEnvironment(); function createPreviewIframe(playerPause?: () => void): HTMLIFrameElement { const iframe = document.createElement("iframe"); if (playerPause) { Object.defineProperty(iframe, "contentWindow", { configurable: true, value: { __player: { getTime: () => 3.25, pause: playerPause, }, }, }); } return iframe; } interface HarnessArgs { previewIframe: HTMLIFrameElement | null; resolveDomSelectionFromPreviewPoint: ( clientX: number, clientY: number, options?: { activeGroupElement?: HTMLElement | null }, ) => Promise; resolveAllDomSelectionsFromPreviewPoint?: ( clientX: number, clientY: number, ) => Promise; applyDomSelection: ( selection: DomEditSelection | null, options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean }, ) => void; setActiveGroupElement?: (el: HTMLElement | null) => void; mouseDownOptions?: { preferClipAncestor?: boolean; hoverSelection?: DomEditSelection | null; }; } function renderHarness(args: HarnessArgs): { canvas: HTMLDivElement; cleanup: () => void; } { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); function Harness() { const interaction = usePreviewInteraction({ captionEditMode: false, compositionLoading: false, previewIframeRef: { current: args.previewIframe }, showToast: vi.fn(), applyDomSelection: args.applyDomSelection, resolveDomSelectionFromPreviewPoint: args.resolveDomSelectionFromPreviewPoint, resolveAllDomSelectionsFromPreviewPoint: args.resolveAllDomSelectionsFromPreviewPoint ?? vi.fn(async () => []), updateDomEditHoverSelection: vi.fn(), setActiveGroupElement: args.setActiveGroupElement ?? vi.fn(), }); return React.createElement("div", { id: "canvas", onMouseDown: (event: React.MouseEvent) => { void interaction.handlePreviewCanvasMouseDown(event, args.mouseDownOptions); }, }); } act(() => { root.render(React.createElement(Harness)); }); const canvas = host.querySelector("#canvas"); if (!(canvas instanceof HTMLDivElement)) throw new Error("Expected canvas div"); return { canvas, cleanup: () => { act(() => root.unmount()); host.remove(); }, }; } async function dispatchMouseDown(canvas: HTMLDivElement, init: MouseEventInit): Promise { await act(async () => { canvas.dispatchEvent( new MouseEvent("mousedown", { bubbles: true, cancelable: true, clientX: 50, clientY: 60, ...init, }), ); await Promise.resolve(); await Promise.resolve(); }); } describe("usePreviewInteraction", () => { beforeEach(() => { vi.restoreAllMocks(); }); it("pauses playback before resolving a click and falls back to the tracked hover selection", async () => { const order: string[] = []; const playerPause = vi.fn(() => { order.push("pause"); }); const element = document.createElement("div"); element.id = "headline"; const hoverSelection = makeSelection("Headline", element); const applyDomSelection = vi.fn(); const resolveDomSelectionFromPreviewPoint = vi.fn(async () => { order.push("resolve"); return null; }); const { canvas, cleanup } = renderHarness({ previewIframe: createPreviewIframe(playerPause), resolveDomSelectionFromPreviewPoint, applyDomSelection, mouseDownOptions: { preferClipAncestor: false, hoverSelection }, }); await dispatchMouseDown(canvas, {}); expect(order[0]).toBe("pause"); expect(applyDomSelection).toHaveBeenCalledWith(hoverSelection); cleanup(); }); it("treats a double-click on a regular element as a plain selection", async () => { const element = document.createElement("div"); element.id = "headline"; const selection = makeSelection("Headline", element); const applyDomSelection = vi.fn(); let resolveCount = 0; const resolveDomSelectionFromPreviewPoint = vi.fn(async () => { resolveCount += 1; return resolveCount === 1 ? selection : null; }); const { canvas, cleanup } = renderHarness({ previewIframe: null, resolveDomSelectionFromPreviewPoint, applyDomSelection, }); await dispatchMouseDown(canvas, { detail: 2 }); expect(applyDomSelection).toHaveBeenCalledWith(selection); expect(applyDomSelection).not.toHaveBeenCalledWith(null, { revealPanel: false }); cleanup(); }); it("preserves group drill-in on double-click", async () => { const group = document.createElement("div"); group.setAttribute("data-hf-group", "hero"); const child = document.createElement("span"); child.id = "headline"; group.append(child); const groupSelection = makeSelection("Hero Group", group); const childSelection = makeSelection("Headline", child); const applyDomSelection = vi.fn(); const setActiveGroupElement = vi.fn(); const resolveDomSelectionFromPreviewPoint = vi.fn( async ( _clientX: number, _clientY: number, options?: { activeGroupElement?: HTMLElement | null }, ) => (options?.activeGroupElement === group ? childSelection : groupSelection), ); const { canvas, cleanup } = renderHarness({ previewIframe: null, resolveDomSelectionFromPreviewPoint, applyDomSelection, setActiveGroupElement, }); await dispatchMouseDown(canvas, { detail: 2 }); expect(setActiveGroupElement).toHaveBeenCalledWith(group); expect(applyDomSelection).toHaveBeenCalledWith(childSelection); cleanup(); }); it("cycles stacked candidates on a rapid second click at the same spot", async () => { const topElement = document.createElement("div"); topElement.id = "top"; const bottomElement = document.createElement("div"); bottomElement.id = "bottom"; const topSelection = makeSelection("Top", topElement); const bottomSelection = makeSelection("Bottom", bottomElement); const applyDomSelection = vi.fn(); const resolveDomSelectionFromPreviewPoint = vi.fn(async () => topSelection); const resolveAllDomSelectionsFromPreviewPoint = vi.fn(async () => [ topSelection, bottomSelection, ]); const { canvas, cleanup } = renderHarness({ previewIframe: null, resolveDomSelectionFromPreviewPoint, resolveAllDomSelectionsFromPreviewPoint, applyDomSelection, }); await dispatchMouseDown(canvas, { detail: 1 }); await dispatchMouseDown(canvas, { detail: 2 }); expect(applyDomSelection).toHaveBeenNthCalledWith(1, topSelection); expect(applyDomSelection).toHaveBeenNthCalledWith(2, bottomSelection); cleanup(); }); it("resumes playback when a click resolves to nothing (dead-zone / deselect)", async () => { usePlayerStore.setState({ isPlaying: true }); const applyDomSelection = vi.fn(); const resolveDomSelectionFromPreviewPoint = vi.fn(async () => null); const { canvas, cleanup } = renderHarness({ previewIframe: createPreviewIframe(vi.fn()), resolveDomSelectionFromPreviewPoint, applyDomSelection, }); await dispatchMouseDown(canvas, {}); expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false }); expect(usePlayerStore.getState().isPlaying).toBe(true); cleanup(); }); it("does not resume playback on deselect when it was already paused", async () => { usePlayerStore.setState({ isPlaying: false }); const applyDomSelection = vi.fn(); const resolveDomSelectionFromPreviewPoint = vi.fn(async () => null); const { canvas, cleanup } = renderHarness({ previewIframe: createPreviewIframe(vi.fn()), resolveDomSelectionFromPreviewPoint, applyDomSelection, }); await dispatchMouseDown(canvas, {}); expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false }); expect(usePlayerStore.getState().isPlaying).toBe(false); cleanup(); }); });