Files
hyperframes/packages/studio/src/hooks/useGsapSelectionHandlers.test.tsx
T
Miguel Angel Simon Sierra 8470b88aa1 fix(studio): settle boundary retimes, delete every keyframed tween, tighten the test locks
Review follow-ups on the expanded keyframe lanes.

Writer:
- `onMoveKeyframe`'s flat-tween boundary branch answered `true` the moment it
  dispatched update-meta, so a rejected write left the diamond parked at its drop
  position. `observeGsapMutation` now resolves to whether the mutation landed and
  the boundary branch returns it, matching the other branches.
- "Delete All Keyframes" cleared only the first keyframed tween on the layer, so
  a layer with position AND opacity keyframes kept half of them. It now walks
  every keyframed tween, serially, through the clicked element's selection.
- The post-convert lookup in `commitFlatViaKeyframes` matched by target selector,
  which picks an arbitrary tween when a target carries several. Match by id first.

Interaction and a11y:
- A rejected retime whose commit settled after a newer drag reverted the
  selection to its own source keyframe, undoing a retime the user could see. The
  revert now only runs while it is still the lane's latest gesture.
- Diamonds key on the authored identity instead of index plus rendered clip-%, so
  a neighbour's retime no longer remounts the button mid-drag.
- The disclosure caret gets `aria-controls` on an always-mounted lanes container,
  and both it and the property-group toggle grow to the 24x24 WCAG 2.2 minimum.
- `LayerDisclosureRow` takes the same adaptive `columnWidth` as its sibling lane
  rows instead of hardcoding LABEL_COL_W over the canvas.

Test locks:
- The timeline callbacks harness resolves a DISTINCT selection per element, so
  the clicked-element writes are actually pinned; three assertions that passed
  either way now name the clicked element's selection.
- New: null-selection aborts every mutation, delete-all covers both tweens, a
  rejected boundary retime reports `false`, and a stale revert leaves selection.
- The playhead-percentage assertion checks 25, not `expect.any(Number)` (which
  also accepts NaN); ease segments assert their label ORDER, not just that the
  three curves differ; the collapsed-diamond callback asserts the whole target.
- Dropped a duplicate `selection override` describe left by a rebase.
2026-07-28 01:19:05 +02:00

183 lines
6.7 KiB
TypeScript

// @vitest-environment happy-dom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type Params = Parameters<typeof useGsapSelectionHandlers>[0];
type Handlers = ReturnType<typeof useGsapSelectionHandlers>;
function makeSelection(): DomEditSelection {
return {
id: "box",
hfId: "hf-box",
selector: "#box",
sourceFile: "index.html",
element: document.createElement("div"),
} as unknown as DomEditSelection;
}
function makeParams(overrides: Partial<Params> = {}): Params {
const resolved = () => vi.fn().mockResolvedValue(undefined);
return {
domEditSelection: makeSelection(),
updateGsapProperty: vi.fn(),
updateGsapMeta: resolved(),
deleteGsapAnimation: resolved(),
deleteAllForSelector: resolved(),
addGsapAnimation: resolved(),
addGsapProperty: resolved(),
removeGsapProperty: resolved(),
updateGsapFromProperty: resolved(),
addGsapFromProperty: resolved(),
removeGsapFromProperty: resolved(),
addKeyframe: vi.fn(),
addKeyframeBatch: resolved(),
removeKeyframe: vi.fn(),
moveKeyframe: vi.fn().mockResolvedValue(true),
resizeKeyframedTween: vi.fn().mockResolvedValue(true),
convertToKeyframes: resolved(),
removeAllKeyframes: resolved(),
handleDomManualEditsReset: vi.fn(),
selectedGsapAnimations: [],
showToast: vi.fn(),
...overrides,
};
}
function renderHandlers(params: Params): { handlers: () => Handlers; unmount: () => void } {
let current: Handlers | undefined;
function Probe() {
current = useGsapSelectionHandlers(params);
return null;
}
const root = createRoot(document.createElement("div"));
act(() => root.render(<Probe />));
return {
handlers: () => {
if (!current) throw new Error("Hook did not render");
return current;
},
unmount: () => act(() => root.unmount()),
};
}
async function flushRejection(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
describe("useGsapSelectionHandlers save failures", () => {
it("surfaces a rejected animation metadata save", async () => {
const error = new Error("write failed");
const showToast = vi.fn();
const rendered = renderHandlers(
makeParams({ updateGsapMeta: vi.fn().mockRejectedValue(error), showToast }),
);
// Braces, not a bare arrow: the handler returns its settlement promise now,
// and returning a thenable from act() turns it into an un-awaited async act.
act(() => {
void rendered.handlers().handleGsapUpdateMeta("anim-1", { duration: 2 });
});
await flushRejection();
expect(showToast).toHaveBeenCalledWith("Couldn't save animation: write failed", "error");
rendered.unmount();
});
it("surfaces a rejected non-debounced property save", async () => {
const error = new Error("write failed");
const showToast = vi.fn();
const rendered = renderHandlers(
makeParams({ addGsapProperty: vi.fn().mockRejectedValue(error), showToast }),
);
act(() => rendered.handlers().handleGsapAddProperty("anim-1", "opacity"));
await flushRejection();
expect(showToast).toHaveBeenCalledWith("Couldn't save animation: write failed", "error");
rendered.unmount();
});
it("does not duplicate a toast already emitted by the mutation request", async () => {
const error = Object.assign(new Error("write failed"), { alreadyToasted: true });
const showToast = vi.fn();
const rendered = renderHandlers(
makeParams({ addGsapAnimation: vi.fn().mockRejectedValue(error), showToast }),
);
act(() => rendered.handlers().handleGsapAddAnimation("to"));
await flushRejection();
expect(showToast).not.toHaveBeenCalled();
rendered.unmount();
});
});
describe("useGsapSelectionHandlers selection override", () => {
it("aborts on an explicit null override instead of writing to the current selection", () => {
const removeKeyframe = vi.fn();
const rendered = renderHandlers(makeParams({ removeKeyframe }));
// Explicit null: the caller resolved a selection for its own element and
// found none, so the write must not land on the selected element.
rendered.handlers().handleGsapRemoveKeyframe("anim-1", 50, undefined, null);
expect(removeKeyframe).not.toHaveBeenCalled();
// Omitted override: falls back to the current selection as before.
rendered.handlers().handleGsapRemoveKeyframe("anim-1", 50);
expect(removeKeyframe).toHaveBeenCalledOnce();
rendered.unmount();
});
it("computes the playhead percentage from the passed animation, not the selection's", () => {
const moveKeyframe = vi.fn();
const selection = makeSelection();
// The passed tween runs 2s→6s, so the playhead at 3s is 25% into IT. Without
// the animation the handler falls back to the selection's own element window
// (0s→1s here), which reads the same playhead as 100%. Asserting the exact
// 25 is what separates the two; `expect.any(Number)` even accepts the NaN a
// missing window would produce.
const animation = {
id: "anim-1",
position: 2,
resolvedStart: 2,
duration: 4,
keyframes: { keyframes: [] },
} as unknown as GsapAnimation;
usePlayerStore.setState({ currentTime: 3 });
const rendered = renderHandlers(makeParams({ moveKeyframe, selectedGsapAnimations: [] }));
rendered.handlers().handleGsapMoveKeyframeToPlayhead("anim-1", 50, selection, animation);
expect(moveKeyframe).toHaveBeenCalledWith(selection, "anim-1", 50, 25);
rendered.unmount();
});
});
describe("useGsapSelectionHandlers retime settlement", () => {
it("returns false without a selection and forwards the mutation result with one", async () => {
const moveKeyframe = vi.fn().mockResolvedValue(true);
const withoutSelection = renderHandlers(makeParams({ domEditSelection: null, moveKeyframe }));
await expect(
withoutSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75),
).resolves.toBe(false);
expect(moveKeyframe).not.toHaveBeenCalled();
withoutSelection.unmount();
const withSelection = renderHandlers(makeParams({ moveKeyframe }));
await expect(withSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75)).resolves.toBe(
true,
);
expect(moveKeyframe).toHaveBeenCalledOnce();
withSelection.unmount();
});
});