Files
hyperframes/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx
T
Vance IngallsandClaude Sonnet 5 5fd84c395b feat(studio,core): a volume and a living meter on the group row (#3290)
* feat(studio,core): a volume and a living meter on the group row

B7: the group bus strip — droppable, and deliberately minimal per the
casual-user design constraints (groups doc §5): a volume slider, a level
bar that moves with the sound, and the words "Too loud" when it clips. No
dB numbers, no peak-hold readout, no routing row.

Transport (core): groupInput() now routes each group through input -> [FX
chain or dry passthrough] -> output -> master, with one AnalyserNode per
group tapped off `output` (post-FX, so the meter reads what the bus
actually outputs) — fftSize 256, level not spectrum. groupLevel(groupId)
returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer
(no per-frame allocation), or null when the group is idle/unknown. The
runtime posts group-levels messages only while playing, piggybacking the
existing message channel rather than adding a new poll loop.

Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's
shape) fed by useTimelinePlayer's message handler via
parseGroupLevelsMessage; useGroupLevel throttles re-renders to ~33ms.
TimelineGroupBusStrip renders in the group row's own `∿` lane area
(STRIP_H, already sized in B2's row-height pipeline) — drag writes live
via onSetAudioGroupAttributeLive, release commits one undo entry via
onSetAudioGroupAttributeQuiet (packages/studio/src/hooks/
timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to
stay under the 600-line cap; mirrors FxParamRow's live/commit split).
"Too loud" holds for ~2s after the last clipped block, tracked in the
component, not the transport. volumeByGroup mirrors labelByGroup in
useTimelineTrackDerivations.ts so the strip's slider round-trips the
group's own data-volume.

Fixed two pre-existing group-routing tests in webAudioTransport.test.ts
that hardcoded gain-node creation order/count — B7 inserts an extra
`output` gain node between the group's input and master (for the meter to
tap), which shifted node indices the tests asserted on directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(studio,core): keep useTimelinePlayer under the size cap and the level buffer non-shared

Two CI gates, both from this branch's own additions.

`File size check`: `useTimelinePlayer.ts` sat at 599 lines on main and the
group-levels branch pushed it to 605 (cap 600). Extracted the `window.message`
router — which already carried a `fallow-ignore-next-line complexity` admitting
it had outgrown its home — into `previewMessageRouter.ts`, with the fixture
lease, sender check and protocol accept-gate collapsed into one
`acceptedPreviewMessage` so the listener is a flat dispatch and the suppression
is retired rather than moved. Same branches, same refs, no behaviour change;
the file lands at 561.

`Test: runtime contract`: `levelBuf: Float32Array` resolves to
`Float32Array<ArrayBufferLike>` under `tsconfig.runtime.json`, and
`getFloatTimeDomainData` will not take a possibly-shared buffer (TS2345).
Pinned the field to `Float32Array<ArrayBuffer>`, which is what
`new Float32Array(analyser.fftSize)` already produces.

Also drops `EditorShell.selectionSync.test.tsx`'s `vi.mock("./StudioFeedbackBar")`
— main deleted that component in favour of `feedback/StudioFeedbackCard`, and
touching this file for the group prop put the dangling path in fallow's scope.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:13:42 -07:00

769 lines
24 KiB
TypeScript

// @vitest-environment happy-dom
import React, { act } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../../player";
import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
import { usePlayerStore } from "../../player/store/playerStore";
import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
installReactActEnvironment();
const mocks = vi.hoisted(() => ({
actions: {
handleGsapRemoveKeyframe: vi.fn(),
handleGsapMoveKeyframeToPlayhead: vi.fn(),
handleGsapMoveKeyframe: vi.fn().mockResolvedValue(true),
handleGsapResizeKeyframedTween: vi.fn().mockResolvedValue(true),
handleGsapUpdateMeta: vi.fn().mockResolvedValue(true),
handleGsapAddKeyframe: vi.fn(),
handleGsapAddKeyframeBatch: vi.fn().mockResolvedValue(undefined),
handleGsapConvertToKeyframes: vi.fn(),
handleGsapRemoveAllKeyframes: vi.fn().mockResolvedValue(true),
handleGsapDeleteAnimation: vi.fn(),
buildDomSelectionForTimelineElement: vi.fn(),
},
selection: { id: "box", selector: "#box", sourceFile: "index.html" },
animations: Array<GsapAnimation>(),
}));
vi.mock("../../contexts/StudioContext", () => ({
useStudioShellContext: () => ({ projectId: "project", activeCompPath: "index.html" }),
}));
vi.mock("../../contexts/DomEditContext", () => ({
useDomEditActionsContext: () => mocks.actions,
useDomEditSelectionContext: () => ({
domEditSelection: mocks.selection,
selectedGsapAnimations: mocks.animations,
}),
}));
import { useTimelineEditCallbacks } from "./useTimelineEditCallbacks";
const element: TimelineElement = {
id: "box",
key: "index.html#box",
domId: "box",
tag: "div",
start: 0,
duration: 1,
track: 0,
sourceFile: "index.html",
};
const flatAnimation: GsapAnimation = {
id: "box-to-0-position",
targetSelector: "#box",
method: "to",
position: 0,
resolvedStart: 0,
duration: 1,
properties: { x: 420 },
propertyGroup: "position",
};
const otherFlatAnimation: GsapAnimation = {
...flatAnimation,
id: "circle-to-0-position",
targetSelector: "#circle",
};
const otherKeyframedAnimation: GsapAnimation = {
...otherFlatAnimation,
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 420 } },
],
},
};
function authoredInteriorAnimation(): GsapAnimation {
return {
...flatAnimation,
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 210 } },
{ percentage: 100, properties: { x: 420 } },
],
},
};
}
function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => void } {
let callbacks: TimelineEditCallbacks | null = null;
function Harness() {
callbacks = useTimelineEditCallbacks({
handleTimelineElementMove: vi.fn(),
handleTimelineElementsMove: vi.fn(),
handleTimelineElementResize: vi.fn(),
handleTimelineGroupResize: vi.fn(),
handleToggleTrackHidden: vi.fn(),
setAudioGroupAttribute: { setLive: vi.fn(), setQuiet: vi.fn() },
handleBlockedTimelineEdit: vi.fn(),
handleTimelineElementSplit: vi.fn(),
handleRazorSplit: vi.fn(),
handleRazorSplitAll: vi.fn(),
});
return null;
}
const root = mountReactHarness(<Harness />);
if (!callbacks) throw new Error("timeline callbacks did not initialize");
return { callbacks, unmount: () => act(() => root.unmount()) };
}
// One selection PER element, so a callback that resolves the selection for the
// wrong element gets a visibly different object. A single mockResolvedValue
// hands every element the same selection, which passes just as happily when the
// write is committed through whatever happens to be selected.
function selectionForElement(el: TimelineElement): {
id: string;
selector: string;
sourceFile: string;
} {
if (el.id === "box") return mocks.selection;
return { id: el.id, selector: `#${el.id}`, sourceFile: el.sourceFile ?? "index.html" };
}
function arrangeClickedCircle(): {
circle: TimelineElement;
selection: { id: string; selector: string; sourceFile: string };
} {
const elementKey = "scenes/main.html#circle";
const circle: TimelineElement = {
...element,
id: "circle",
key: elementKey,
domId: "circle",
sourceFile: "scenes/main.html",
};
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([[elementKey, [otherKeyframedAnimation]]]),
});
return { circle, selection: selectionForElement(circle) };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.animations = [flatAnimation];
mocks.actions.buildDomSelectionForTimelineElement.mockImplementation((el: TimelineElement) =>
Promise.resolve(selectionForElement(el)),
);
usePlayerStore.setState({
currentTime: 0.5,
elements: [element],
domClipChildren: [],
keyframeCache: new Map(),
gsapAnimations: new Map([["box", [flatAnimation]]]),
});
});
afterEach(() => {
usePlayerStore.setState({
elements: [],
domClipChildren: [],
keyframeCache: new Map(),
gsapAnimations: new Map(),
});
});
describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
it("adds an interior point through the add-keyframe persist boundary", async () => {
const view = renderCallbacks();
await act(async () => {
await view.callbacks.onTogglePropertyGroupKeyframe?.(element, {
animationId: flatAnimation.id,
propertyGroup: "position",
tweenPercentage: 50,
properties: { x: 210 },
remove: false,
});
});
expect(mocks.actions.handleGsapAddKeyframeBatch).toHaveBeenCalledWith(
flatAnimation.id,
50,
{ x: 210 },
undefined,
mocks.selection,
);
expect(mocks.actions.handleGsapConvertToKeyframes).not.toHaveBeenCalled();
view.unmount();
});
it("retimes a flat tween's boundary through update-meta, not the keyframe writer", async () => {
const view = renderCallbacks();
await expect(
view.callbacks.onMoveKeyframe?.(
"box",
{
percentage: 0,
propertyGroup: "position",
tweenPercentage: 0,
animationId: flatAnimation.id,
},
25,
),
).resolves.toBe(true);
// The start boundary moved to 0.25s; the end stays put, so the window is 0.75s.
expect(mocks.actions.handleGsapUpdateMeta).toHaveBeenCalledWith(
flatAnimation.id,
{ position: 0.25, duration: 0.75 },
mocks.selection,
);
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
// resize-keyframed-tween would convert the flat tween to keyframes form.
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
view.unmount();
});
it("reports an unsettled flat-boundary retime as uncommitted", async () => {
mocks.actions.handleGsapUpdateMeta.mockResolvedValueOnce(false);
const view = renderCallbacks();
// The diamond snaps back on `false`. Answering `true` the moment update-meta
// was dispatched left a rejected boundary drag rendered at its drop position.
await expect(
view.callbacks.onMoveKeyframe?.(
"box",
{
percentage: 0,
propertyGroup: "position",
tweenPercentage: 0,
animationId: flatAnimation.id,
},
25,
),
).resolves.toBe(false);
view.unmount();
});
it("refuses a non-selected element flat boundary instead of deleting the tween", async () => {
const circle: TimelineElement = {
...element,
id: "circle",
key: "scenes/main.html#circle",
domId: "circle",
};
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([["scenes/main.html#circle", [otherFlatAnimation]]]),
});
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
percentage: 0,
propertyGroup: "position",
tweenPercentage: 0,
animationId: otherFlatAnimation.id,
});
await Promise.resolve();
await Promise.resolve();
});
// Persisted through the CLICKED element's own selection, not the current one,
// and as a remove-keyframe the writer can refuse — never a whole-tween delete.
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
otherFlatAnimation.id,
0,
undefined,
selectionForElement(circle),
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
it("removes a non-selected element authored endpoint through the clicked element's selection", async () => {
const circle: TimelineElement = {
...element,
id: "circle",
key: "scenes/main.html#circle",
domId: "circle",
};
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([["index.html#circle", [otherKeyframedAnimation]]]),
});
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
percentage: 100,
propertyGroup: "position",
tweenPercentage: 100,
animationId: otherKeyframedAnimation.id,
});
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
otherKeyframedAnimation.id,
100,
undefined,
selectionForElement(circle),
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
it("deletes all keyframes through the clicked non-selected element's identity", async () => {
const circle: TimelineElement = {
...element,
id: "circle",
key: "scenes/main.html#circle",
domId: "circle",
sourceFile: "scenes/main.html",
};
const circleSelection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" };
const scaleAnimation: GsapAnimation = {
...otherKeyframedAnimation,
id: "circle-to-0-scale",
properties: {},
propertyGroup: "scale",
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { scale: 1 } },
{ percentage: 100, properties: { scale: 2 } },
],
},
};
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([
["scenes/main.html#circle", [otherKeyframedAnimation, scaleAnimation]],
]),
});
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(circleSelection);
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteAllKeyframes?.(circle, scaleAnimation.id);
await Promise.resolve();
});
expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith(
scaleAnimation.id,
circleSelection,
);
view.unmount();
});
it("deletes all keyframes on every keyframed tween of the layer, not just the first", async () => {
const opacityAnimation: GsapAnimation = {
...otherKeyframedAnimation,
id: "circle-to-0-visual",
propertyGroup: "visual",
};
const { circle } = arrangeClickedCircle();
usePlayerStore.setState({
gsapAnimations: new Map([
["scenes/main.html#circle", [otherKeyframedAnimation, opacityAnimation]],
]),
});
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteAllKeyframes?.(circle);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.actions.handleGsapRemoveAllKeyframes.mock.calls.map((call) => call[0])).toEqual([
otherKeyframedAnimation.id,
opacityAnimation.id,
]);
view.unmount();
});
it("does not delete a different lane when an explicit animation identity is stale", async () => {
const { circle } = arrangeClickedCircle();
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteAllKeyframes?.(circle, "missing-animation-id");
await Promise.resolve();
});
expect(mocks.actions.handleGsapRemoveAllKeyframes).not.toHaveBeenCalled();
view.unmount();
});
it("aborts every mutation when the clicked element resolves no selection", async () => {
const { circle } = arrangeClickedCircle();
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(null);
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteAllKeyframes?.(circle);
view.callbacks.onMoveKeyframeToPlayhead?.(circle, {
percentage: 100,
propertyGroup: "position",
tweenPercentage: 100,
animationId: otherKeyframedAnimation.id,
});
view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
percentage: 100,
propertyGroup: "position",
tweenPercentage: 100,
animationId: otherKeyframedAnimation.id,
});
await Promise.resolve();
await Promise.resolve();
});
// No selection for the clicked element means there is nothing safe to write
// to: falling back to the current selection would edit a different file.
expect(mocks.actions.handleGsapRemoveAllKeyframes).not.toHaveBeenCalled();
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).not.toHaveBeenCalled();
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
view.unmount();
});
it("does not delete a different keyframe when its explicit animation identity is stale", () => {
const view = renderCallbacks();
act(() => {
view.callbacks.onDeleteKeyframe?.("box", {
percentage: 100,
propertyGroup: "position",
tweenPercentage: 100,
animationId: "missing-animation-id",
});
});
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
view.unmount();
});
it("does not move a different keyframe when its explicit animation identity is stale", async () => {
const view = renderCallbacks();
await act(async () => {
view.callbacks.onMoveKeyframeToPlayhead?.(element, {
percentage: 100,
propertyGroup: "position",
tweenPercentage: 100,
animationId: "missing-animation-id",
});
await Promise.resolve();
});
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).not.toHaveBeenCalled();
view.unmount();
});
it("moves a keyframe to the playhead through the clicked non-selected element's identity", async () => {
const { circle, selection } = arrangeClickedCircle();
const view = renderCallbacks();
await act(async () => {
view.callbacks.onMoveKeyframeToPlayhead?.(circle, {
percentage: 100,
propertyGroup: "position",
tweenPercentage: 100,
animationId: otherKeyframedAnimation.id,
});
await Promise.resolve();
});
// The retime target, the selection it commits through, and the animation the
// playhead percentage is computed against all come from the CLICKED element.
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).toHaveBeenCalledWith(
otherKeyframedAnimation.id,
100,
selection,
otherKeyframedAnimation,
);
view.unmount();
});
it("keeps a selected-element flat boundary on the remove-keyframe path", () => {
const view = renderCallbacks();
act(() => {
view.callbacks.onDeleteKeyframe?.("box", {
percentage: 0,
propertyGroup: "position",
tweenPercentage: 0,
animationId: flatAnimation.id,
});
});
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
flatAnimation.id,
0,
undefined,
undefined,
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
it("routes the flat lane-header remove toggle through the refusable remove path", async () => {
const view = renderCallbacks();
await act(async () => {
await view.callbacks.onTogglePropertyGroupKeyframe?.(element, {
animationId: flatAnimation.id,
propertyGroup: "position",
tweenPercentage: 100,
properties: { x: 420 },
remove: true,
});
});
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
flatAnimation.id,
100,
undefined,
mocks.selection,
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
// The lane-header toggle fires on whichever element owns the lane, which need
// not be the selected one. It must still commit through that element's own
// selection, and it must never escalate a flat tween to a whole-tween delete.
it("removes a non-selected element's flat tween through that element's own selection", async () => {
const circle: TimelineElement = {
...element,
id: "circle",
key: "scenes/main.html#circle",
domId: "circle",
};
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([["scenes/main.html#circle", [otherFlatAnimation]]]),
});
const view = renderCallbacks();
await act(async () => {
await view.callbacks.onTogglePropertyGroupKeyframe?.(circle, {
animationId: otherFlatAnimation.id,
propertyGroup: "position",
tweenPercentage: 0,
properties: { x: 0 },
remove: true,
});
});
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
otherFlatAnimation.id,
0,
undefined,
selectionForElement(circle),
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
it("keeps authored interior deletion on the per-keyframe path", () => {
mocks.animations = [authoredInteriorAnimation()];
usePlayerStore.setState({ gsapAnimations: new Map([["box", mocks.animations]]) });
const view = renderCallbacks();
act(() => {
view.callbacks.onDeleteKeyframe?.("box", {
percentage: 50,
propertyGroup: "position",
tweenPercentage: 50,
animationId: flatAnimation.id,
});
});
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
flatAnimation.id,
50,
undefined,
undefined,
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
it("keeps an authored interior drag on the per-keyframe move path", async () => {
const authored = authoredInteriorAnimation();
mocks.animations = [authored];
usePlayerStore.setState({ gsapAnimations: new Map([["box", [authored]]]) });
const view = renderCallbacks();
await expect(
view.callbacks.onMoveKeyframe?.(
"box",
{
percentage: 50,
propertyGroup: "position",
tweenPercentage: 50,
animationId: authored.id,
},
75,
),
).resolves.toBe(true);
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
authored.id,
50,
75,
mocks.selection,
);
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
view.unmount();
});
// A drag starts on whatever diamond the pointer is over, which need not be the
// selected element. Resolving against the selection would retime the selected
// element's tween and commit it through the selected element's file.
it("retimes a non-selected element's keyframe through that element's own selection", async () => {
const circle: TimelineElement = {
...element,
id: "circle",
key: "scenes/main.html#circle",
domId: "circle",
sourceFile: "scenes/main.html",
};
const circleSelection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" };
const circleAnimation = { ...authoredInteriorAnimation(), id: "circle-to-0-position" };
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([["scenes/main.html#circle", [circleAnimation]]]),
});
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(circleSelection);
const view = renderCallbacks();
await act(async () => {
await view.callbacks.onMoveKeyframe?.(
"scenes/main.html#circle",
{
percentage: 50,
propertyGroup: "position",
tweenPercentage: 50,
animationId: circleAnimation.id,
},
75,
);
});
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
circleAnimation.id,
50,
75,
circleSelection,
);
view.unmount();
});
// The diamond's rapid-second-retime path reports the PENDING clip-% (where the
// first drag put the keyframe), which the keyframe cache has not caught up to.
// TimelineClipDiamonds' own test mocks onMoveKeyframe, so only this one proves
// the real callback resolves that stale-cache position off the identity fields
// instead of failing the lookup.
it("retimes from a pending position the keyframe cache has not caught up to", async () => {
const authored = authoredInteriorAnimation();
mocks.animations = [authored];
usePlayerStore.setState({
elements: [element],
gsapAnimations: new Map([["index.html#box", [authored]]]),
// Still the pre-drag positions: 75% is not in here.
keyframeCache: new Map([
[
"index.html#box",
{
format: "percentage" as const,
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 210 } },
{ percentage: 100, properties: { x: 420 } },
],
},
],
]),
});
const view = renderCallbacks();
await expect(
view.callbacks.onMoveKeyframe?.(
"index.html#box",
{
percentage: 75,
propertyGroup: "position",
tweenPercentage: 50,
animationId: authored.id,
},
85,
),
).resolves.toBe(true);
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
authored.id,
50,
85,
mocks.selection,
);
// Control: the same drag WITHOUT the identity fields falls back to the cache
// lookup, finds nothing at 75%, and cannot retime.
mocks.actions.handleGsapMoveKeyframe.mockClear();
await expect(
view.callbacks.onMoveKeyframe?.("index.html#box", { percentage: 75 }, 85),
).resolves.toBe(false);
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
view.unmount();
});
it("uses the clip timing basis when retiming a duration-less tween", async () => {
const durationless = {
...authoredInteriorAnimation(),
position: 3.2,
resolvedStart: 3.2,
duration: undefined,
};
const wideElement = { ...element, start: 10.94, duration: 16.26 };
mocks.animations = [durationless];
usePlayerStore.setState({
elements: [wideElement],
gsapAnimations: new Map([["box", [durationless]]]),
});
const view = renderCallbacks();
await expect(
view.callbacks.onMoveKeyframe?.(
"box",
{
percentage: 19.1,
propertyGroup: "position",
tweenPercentage: 50,
animationId: durationless.id,
},
40,
),
).resolves.toBe(true);
// The whole point of the clip basis: the drop lands at 10.94 + 0.40 * 16.26 =
// 17.444s, and the duration-less tween borrows the clip's 16.26s window from
// its 3.2s start, so 17.444 - 3.2 over 16.26 is 87.601%. Any other basis (a
// zero-length tween, or the clip's own 0-100 %) produces a different number.
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
durationless.id,
50,
expect.closeTo(87.601, 3),
mocks.selection,
);
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
view.unmount();
});
});