refactor(studio): double-cast test fixtures and split three dense functions

CONTRIBUTING.md allows `as unknown as T` with a justification, not a bare
`as T`; the gsapShared fixtures only carry the fields under test.

The fallow complexity gate flagged three functions on this branch. Each is
split at its natural seam rather than suppressed: the auto-expand scan moves
out of the effect, the four repeated attribute guards in
nodeMatchesManifestClip collapse into one table-driven check, and the
segment-% interpolation moves out of onPathDown.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 00:40:46 +02:00
parent b8ff8bf0f3
commit e317f1fbe3
4 changed files with 55 additions and 34 deletions
@@ -45,6 +45,19 @@ type DragState = {
ref: MotionNodeRef;
};
/**
* Tween-% for a stop inserted at fraction `t` along the segment between two
* nodes. null when either end is not a keyframe (arc waypoints carry no %).
*/
function interpolatedKeyframePct(
a: MotionNodeRef | undefined,
b: MotionNodeRef | undefined,
t: number,
): number | null {
if (a?.type !== "keyframe" || b?.type !== "keyframe") return null;
return Math.round((a.pct + (b.pct - a.pct) * t) * 1000) / 1000;
}
const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
// Click-vs-drag cutoff in SCREEN pixels. Below this the pointer-up is a click
// (select the keyframe); at or above it the gesture commits a move. Screen-space
@@ -402,13 +415,10 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
void commitAddWaypoint(animId, np.segIndex + 1, x, y, commitMutation);
} else {
// Linear keyframe path: interpolate the new stop's tween-% from the two
// keyframes bounding the clicked segment (np.t = fraction along it), then
// insert it. Lands ON the current line, so the dot doesn't jump — drag it
// after to bend the path.
const a = abs[np.segIndex]?.ref;
const b = abs[np.segIndex + 1]?.ref;
if (a?.type !== "keyframe" || b?.type !== "keyframe") return;
const pct = Math.round((a.pct + (b.pct - a.pct) * np.t) * 1000) / 1000;
// keyframes bounding the clicked segment, then insert it. Lands ON the
// current line, so the dot doesn't jump — drag it after to bend the path.
const pct = interpolatedKeyframePct(abs[np.segIndex]?.ref, abs[np.segIndex + 1]?.ref, np.t);
if (pct === null) return;
e.stopPropagation();
void commitAddKeyframe(animId, pct, x, y, commitMutation);
}
+7 -5
View File
@@ -11,17 +11,19 @@ import {
toClipPercentage,
} from "./gsapShared";
// Fixtures carry only the fields the function under test reads; the double-cast
// is the documented way to stand in for the full runtime shape (CONTRIBUTING.md).
const tween = (duration: number | undefined) => ({ duration }) as unknown as GsapAnimation;
describe("resolveEditableTweenDuration", () => {
const selection = { dataAttributes: { duration: "16.26" } } as DomEditSelection;
const selection = { dataAttributes: { duration: "16.26" } } as unknown as DomEditSelection;
it("uses the owning clip duration when the tween omits an outer duration", () => {
expect(resolveEditableTweenDuration({ duration: undefined } as GsapAnimation, selection)).toBe(
16.26,
);
expect(resolveEditableTweenDuration(tween(undefined), selection)).toBe(16.26);
});
it("keeps an explicitly-authored tween duration", () => {
expect(resolveEditableTweenDuration({ duration: 4 } as GsapAnimation, selection)).toBe(4);
expect(resolveEditableTweenDuration(tween(4), selection)).toBe(4);
});
});
@@ -11,6 +11,24 @@ import { animationContributesLane } from "./TimelinePropertyLanes";
* — tracked per-clip so a later user collapse sticks and never bounces back open
* (and clips added later still auto-expand).
*/
/**
* Prunes clips that left the source, then returns the ones that newly contribute
* a lane. The prune matters because the set is otherwise append-only: a clip
* deleted and reinserted under the same id (undo, paste) would be remembered as
* already-expanded and never auto-expand again.
*/
function freshLaneClips(gsapAnimations: Map<string, GsapAnimation[]>, clips: Set<string>) {
for (const key of clips) {
if (!gsapAnimations.has(key)) clips.delete(key);
}
const fresh: string[] = [];
for (const [key, animations] of gsapAnimations) {
if (clips.has(key)) continue;
if (animations.some(animationContributesLane)) fresh.push(key);
}
return fresh;
}
export function useAutoExpandKeyframedClips(gsapAnimations: Map<string, GsapAnimation[]>): void {
const expandClips = usePlayerStore((s) => s.expandClips);
const projectId = useStudioShellContextOptional()?.projectId ?? null;
@@ -24,17 +42,7 @@ export function useAutoExpandKeyframedClips(gsapAnimations: Map<string, GsapAnim
} else {
seen.current.source = gsapAnimations;
}
// Drop clips that are no longer in the source at all. Without this the set
// is append-only, so a clip deleted and reinserted under the same id (undo,
// paste) is remembered as already-expanded and never auto-expands again.
for (const key of seen.current.clips) {
if (!gsapAnimations.has(key)) seen.current.clips.delete(key);
}
const fresh: string[] = [];
for (const [key, animations] of gsapAnimations) {
if (seen.current.clips.has(key)) continue;
if (animations.some(animationContributesLane)) fresh.push(key);
}
const fresh = freshLaneClips(gsapAnimations, seen.current.clips);
if (fresh.length === 0) return;
for (const key of fresh) seen.current.clips.add(key);
expandClips(fresh);
@@ -380,20 +380,21 @@ function numbersNearlyEqual(a: number, b: number): boolean {
return Math.abs(a - b) < 0.001;
}
const MANIFEST_CLIP_ATTRS: ReadonlyArray<[string, (clip: ClipManifestClip) => number]> = [
["data-start", (clip) => clip.start],
["data-duration", (clip) => clip.duration],
["data-track-index", (clip) => clip.track],
];
function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
const tagName = clip.tagName?.toLowerCase();
if (tagName && node.tagName.toLowerCase() !== tagName) return false;
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
if (Number.isFinite(start) && !numbersNearlyEqual(start, clip.start)) return false;
const duration = Number.parseFloat(node.getAttribute("data-duration") ?? "");
if (Number.isFinite(duration) && !numbersNearlyEqual(duration, clip.duration)) return false;
const track = Number.parseInt(node.getAttribute("data-track-index") ?? "", 10);
if (Number.isFinite(track) && track !== clip.track) return false;
return true;
// An attribute only constrains the match when it parses to a finite number:
// missing or garbled reads as "unknown", not "mismatch".
return MANIFEST_CLIP_ATTRS.every(([attr, expected]) => {
const actual = Number.parseFloat(node.getAttribute(attr) ?? "");
return !Number.isFinite(actual) || numbersNearlyEqual(actual, expected(clip));
});
}
function findTimelineDomNode(doc: Document, id: string): Element | null {