Files
hyperframes/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx
T
Vance Ingalls e820e18092 fix(studio): atomic timing pin, expanded-list Hide All, repeated-host matching, pointercancel revert
Fixes real bugs from two independent re-reviews (#2225 @ 65954c3804,
#2416 @ beaf4ffbf6):

- FlatTimingRow's pinRange committed a pinned start+duration range through
  TWO sequential onSetAttribute calls. Each resolves domEditSelection fresh
  from current hook state, so a selection change between the two awaits
  could misdirect the second write at the newly-selected element instead of
  the one being edited, and a failure of just the second call left the pair
  half-applied (inconsistent inferred/explicit state). Added
  commitDataAttributes/handleDomAttributesCommit (mirroring
  onCommitAnimatedProperties's same-shaped fix for GSAP property batches):
  one PatchOperation[] persist call against an explicit, caller-supplied
  selection — not the "current" one — threaded through as the new optional
  onSetAttributes prop. pinRange uses it when provided, falls back to the
  old sequential behavior otherwise.

- Hide All silently dropped nested sub-composition children: a selection
  inside a sub-comp with no timeline-store entry of its own resolves to a
  virtual `sourceFile#domId` key (the fallback branch exists so the
  expansion hook can later resolve it via clipParentMap), but
  toggleTimelineElementHidden only searched the RAW store list, which never
  contains that key. useTimelineElementVisibilityEditing now resolves
  against useExpandedTimelineElements() instead, matching the track-based
  toggle's existing approach — the expanded list synthesizes a real,
  patchable TimelineElement (matching key/domId/sourceFile) for each visible
  child whenever its host is currently expanded.

- Two composition hosts importing the same sub-composition collapsed to
  the first one: findMatchingTimelineElementId ORed domId/selector/
  compositionSrc matches with equal priority in a single per-element scan,
  so `.find()` could stop at an EARLIER, unrelated host that merely shared
  the compositionSrc, before the scan ever reached the correct domId/
  selector match further down the list. Restructured to try domId, then
  selector, across the WHOLE list first; compositionSrc-only matching is
  now a true last resort for when neither identifies a specific element.

- FlatSlider's native pointercancel handler (a platform-level gesture abort
  — scroll/touch takeover, pen leaving range) manually duplicated the
  pointer-capture release logic instead of calling cancelDrag, so it never
  reverted to the pre-drag value — leaving whatever intermediate position
  the pointer last reached committed, unlike the Escape/right-click paths
  added in the previous round. Now calls cancelDrag directly.

- useColorGradingController's flushPendingPersist read identityKeyRef.current
  fresh at flush time rather than a value snapshotted when the edit was
  scheduled. Defensive fix: added pendingPersistIdentityRef, set alongside
  pendingPersistValueRef in commitColorGrading, read by flushPendingPersist
  instead of the live ref — closes the gap regardless of how unlikely the
  actual race is given the identity-cleanup effect's existing eager-flush
  behavior.

Two prior findings re-verified as already fixed further up this same
Graphite stack (not re-fixed here, per established stack-order handling):
metadata-cache negative-caching (267cdfce1) and cross-file
selectionIdentityKey (6f40e03a1), both landing after #2225's reviewed head.

StudioRightPanel.tsx crossed the 600-line file-size gate after wiring the
new onSetAttributes prop through; extracted the inspector split-pane resize
handlers (previously inlined) into their own useInspectorSplitResize hook.

New regression tests: repeated-composition-host resolution, atomic vs.
fallback pinRange commit paths, pointercancel revert. Full studio suite
still at the known pre-existing 55-failure baseline, zero new regressions.
Typecheck/oxlint/oxfmt clean.
2026-07-14 16:28:33 -07:00

273 lines
9.7 KiB
TypeScript

// @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> = {}): 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(
<FlatTimingRow element={baseElement()} onSetAttribute={vi.fn()} />,
);
expect(host.textContent).toContain("Start");
expect(host.textContent).toContain("End");
expect(host.textContent).toContain("Duration");
// Values render inside <input>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<HTMLInputElement>("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(
<FlatTimingRow
element={element}
animations={[{ position: 2, duration: 3 } as never]}
onSetAttribute={onSetAttribute}
/>,
);
expect(host.textContent).toContain("Inferred");
act(() => root.unmount());
});
it("commits a Start edit through onSetAttribute", () => {
const onSetAttribute = vi.fn();
const { host, root } = renderInto(
<FlatTimingRow element={baseElement()} onSetAttribute={onSetAttribute} />,
);
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(
<FlatTimingRow
element={element}
animations={[{ position: 2, duration: 3 } as never]}
onSetAttribute={onSetAttribute}
onSetAttributes={onSetAttributes}
/>,
);
// 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(
<FlatTimingRow
element={element}
animations={[{ position: 2, duration: 3 } as never]}
onSetAttribute={onSetAttribute}
/>,
);
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(
<FlatMotionSection
element={baseElement()}
animations={[
{
id: "a1",
method: "to",
position: 0.8,
duration: 1.2,
ease: "power2.out",
properties: { opacity: 1 },
} as never,
]}
showTiming
showEffects
onSetAttribute={vi.fn()}
onAddAnimation={vi.fn()}
onUpdateProperty={vi.fn()}
onUpdateMeta={vi.fn()}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
/>,
);
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(
<FlatMotionSection
element={baseElement()}
animations={[]}
showTiming={false}
showEffects
onSetAttribute={vi.fn()}
onAddAnimation={vi.fn()}
onUpdateProperty={vi.fn()}
onUpdateMeta={vi.fn()}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
/>,
);
expect(host.textContent).not.toContain("Start");
act(() => root.unmount());
});
it("omits the effect list entirely when showEffects is false", () => {
const { host, root } = renderInto(
<FlatMotionSection
element={baseElement()}
animations={[
{
id: "a1",
method: "to",
position: 0.8,
duration: 1.2,
ease: "power2.out",
properties: { opacity: 1 },
} as never,
]}
showTiming
showEffects={false}
onSetAttribute={vi.fn()}
onAddAnimation={vi.fn()}
onUpdateProperty={vi.fn()}
onUpdateMeta={vi.fn()}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
/>,
);
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(
<FlatMotionSection
element={baseElement()}
animations={[]}
showTiming
showEffects
onSetAttribute={vi.fn()}
onAddAnimation={onAddAnimation}
onUpdateProperty={vi.fn()}
onUpdateMeta={vi.fn()}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
/>,
);
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());
});
});