// @vitest-environment happy-dom import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { FlatMotionSection, FlatTimingRow } from "./propertyPanelFlatMotionSection"; import type { DomEditSelection } from "./domEditing"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; afterEach(() => { document.body.innerHTML = ""; }); function baseElement(overrides: Partial = {}): DomEditSelection { return { element: document.createElement("div"), id: "hero", selector: "#hero", label: "Hero", tagName: "div", sourceFile: "index.html", compositionPath: "index.html", isCompositionHost: false, isInsideLockedComposition: false, boundingBox: { x: 0, y: 0, width: 100, height: 100 }, textContent: "", dataAttributes: { start: "8", duration: "4" }, inlineStyles: {}, computedStyles: {}, textFields: [], capabilities: { canSelect: true, canEditStyles: true, canCrop: true, canMove: true, canResize: true, canApplyManualOffset: true, canApplyManualSize: true, canApplyManualRotation: true, }, ...overrides, } as DomEditSelection; } function renderInto(node: React.ReactElement) { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render(node); }); return { host, root }; } describe("FlatTimingRow", () => { it("renders Start, End, and Duration from the element's data attributes", () => { const { host, root } = renderInto( , ); expect(host.textContent).toContain("Start"); expect(host.textContent).toContain("End"); expect(host.textContent).toContain("Duration"); // Values render inside s (CommitField), not as text nodes, so they // don't show up in textContent — assert on the rendered input values, // in the same Start/End/Duration order the row is built in. const inputs = host.querySelectorAll("input"); expect(inputs[0]?.value).toBe("8.00s"); expect(inputs[1]?.value).toBe("12.00s"); expect(inputs[2]?.value).toBe("4.00s"); act(() => root.unmount()); }); it("shows the inferred note when duration is derived from animations, not authored", () => { const onSetAttribute = vi.fn(); const element = baseElement({ dataAttributes: { start: "0", duration: "0" } }); const { host, root } = renderInto( , ); expect(host.textContent).toContain("Inferred"); act(() => root.unmount()); }); it("commits a Start edit through onSetAttribute", () => { const onSetAttribute = vi.fn(); const { host, root } = renderInto( , ); const startInput = host.querySelectorAll("input")[0]; if (!startInput) throw new Error("expected a Start input"); const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; act(() => { setter.call(startInput, "10s"); startInput.dispatchEvent(new Event("input", { bubbles: true })); startInput.dispatchEvent(new Event("focusout", { bubbles: true })); }); expect(onSetAttribute).toHaveBeenCalledWith("start", "10.00"); act(() => root.unmount()); }); it("pins an inferred range through ONE atomic onSetAttributes call when provided, instead of two sequential onSetAttribute calls", async () => { const onSetAttribute = vi.fn(); const onSetAttributes = vi.fn().mockResolvedValue(undefined); const element = baseElement({ dataAttributes: { start: "0", duration: "0" } }); const { host, root } = renderInto( , ); // Range is inferred (start=2, duration=3) — editing Start alone must pin // the WHOLE range (both attrs), not just data-start. const startInput = host.querySelectorAll("input")[0]; if (!startInput) throw new Error("expected a Start input"); const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; await act(async () => { setter.call(startInput, "5s"); startInput.dispatchEvent(new Event("input", { bubbles: true })); startInput.dispatchEvent(new Event("focusout", { bubbles: true })); await Promise.resolve(); }); expect(onSetAttributes).toHaveBeenCalledTimes(1); expect(onSetAttributes).toHaveBeenCalledWith(element, { start: "5.00", duration: "3.00" }); expect(onSetAttribute).not.toHaveBeenCalled(); act(() => root.unmount()); }); it("falls back to two sequential onSetAttribute calls to pin an inferred range when onSetAttributes is not provided", async () => { const onSetAttribute = vi.fn().mockResolvedValue(undefined); const element = baseElement({ dataAttributes: { start: "0", duration: "0" } }); const { host, root } = renderInto( , ); const startInput = host.querySelectorAll("input")[0]; if (!startInput) throw new Error("expected a Start input"); const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; await act(async () => { setter.call(startInput, "5s"); startInput.dispatchEvent(new Event("input", { bubbles: true })); startInput.dispatchEvent(new Event("focusout", { bubbles: true })); await Promise.resolve(); }); expect(onSetAttribute).toHaveBeenNthCalledWith(1, "start", "5.00"); expect(onSetAttribute).toHaveBeenNthCalledWith(2, "duration", "3.00"); act(() => root.unmount()); }); }); describe("FlatMotionSection", () => { it("renders Timing when showTiming is true and the effect list when showEffects is true", () => { const { host, root } = renderInto( , ); expect(host.textContent).toContain("Start"); expect(host.textContent).toContain("power2.out"); act(() => root.unmount()); }); it("omits Timing entirely when showTiming is false", () => { const { host, root } = renderInto( , ); expect(host.textContent).not.toContain("Start"); act(() => root.unmount()); }); it("omits the effect list entirely when showEffects is false", () => { const { host, root } = renderInto( , ); expect(host.textContent).not.toContain("power2.out"); act(() => root.unmount()); }); it("opens the add-method menu on '+ Add effect' and calls onAddAnimation with the chosen method", () => { const onAddAnimation = vi.fn(); const { host, root } = renderInto( , ); const buttons = () => Array.from(host.querySelectorAll("button")); const addTrigger = buttons().find((b) => b.textContent === "+ Add effect"); if (!addTrigger) throw new Error("expected an '+ Add effect' trigger button"); act(() => { addTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); const animateButton = buttons().find((b) => b.textContent === "Animate"); if (!animateButton) throw new Error("expected an 'Animate' method button"); act(() => { animateButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(onAddAnimation).toHaveBeenCalledWith("to"); // The menu closes back to the trigger after a selection. expect(buttons().some((b) => b.textContent === "+ Add effect")).toBe(true); act(() => root.unmount()); }); });