Files
hyperframes/packages/studio/src/hooks/gsapResizeGeometrySweep.test.ts
T
Miguel Ángel a850e97f3d fix(studio): resize an element whose scale is an instant hold (#3092)
* fix(studio): stop a resize writing size into the tween that carries scale

Resizing a scale-driven element failed with "animation not found", and the
element could not be saved again at all.

The tween resolved for the resize's group is, for such an element, the one
carrying `scale`. When it is an instant hold the code handed it straight to the
size commit, which wrote `width` and `height` into it. One tween now spanned two
property groups, so the parser classified it as neither — it lost its group
suffix, and its id with it. Every later edit looked for a scale tween and a size
tween, found a tween with no group at all, and had nothing it could address.

Size goes to a size hold of its own now; the scale hold is left alone. Where the
damage has already happened it is repairable: splitting the mixed tween into
property groups gives back a `scale` tween and a `size` tween.

* fix(studio): let the resize say whether it settled the drop point

Resizing an element whose scale is an instant hold saved the new size and then
snapped the element back to its authored position, every drag.

Whether the caller persists the drag offset was inferred from the element's
tweens: a scale-group tween meant "the resize settles its own position, hold
the offset back". That is true of the scale route, which commits a scale and
then measures where centre-scaling put the box. It is not true of an element
whose scale is an instant hold — that has a scale-group tween and still
commits width/height. So the offset was withheld, nobody wrote it, and the
position tween re-asserted the authored value a frame later.

The outcome carries the answer now. A resize that moved the element says so;
everything else leaves the anchor to the drag, which is what already handles it.

* test(studio): sweep every animated shape a resize can be handed

Both faults on this branch were found one composition at a time, which is a
bad way to find the third.

Drives the real intercept across the cross-product of what an element's tweens
can look like — scale absent, an instant hold, a real tween, longhands; size
absent, a hold, a tween; position absent, a static hold, a tween; plus the 3D
and rotation set a card carries and a tween that already spans two groups —
and holds all 108 to the two rules that were broken: never address an
animation the source does not have, and never leave a tween spanning two
property groups.

The server stand-in answers the way the real one does, rejecting an id it
cannot find, and applies what it is told, so a run that corrupts the animation
list is caught by the next mutation in the same run.

* fix(studio): decide a uniform resize in pixels, not in scale

A free corner drag whose two axes happened to land within 0.01 of each other
was committed as one `scale` value for both, and gave back a box shorter than
the one dropped — 326x213 became 326x211.

The threshold was a fixed amount of scale. That is invisible on a 40px box and
two pixels of height on a 408px one, and the question was never about scale: it
is only ever whether using one value for both axes would move an edge. So it
asks that, in pixels, against the axis the collapse would distort.

Found by a geometry sweep added alongside: 120 runs over the routes a resize
can take, six rotations from none to 180 degrees, and four drops from near-zero
to an aspect flip, each checking the committed scale or size reproduces the
RENDERED box the user dropped — and, where the resize reports it owns the drag
offset, that the box lands on the drop point too. Six runs failed before this
change, all of them the near-uniform shrink, at every rotation including none.
Rotation was the suspect and turned out to be innocent.

* refactor(studio): split the resize sweeps into named steps for the audit gate

* test(studio): pin which tween a resize edits when the element has several

A composition animates the same property more than once — a scale-in early, a
scale-out late — and the one the user means is the one under the playhead.
Editing the wrong one changes a moment they are not looking at and leaves the
moment they are looking at unchanged, which reads as "the resize did nothing".

Six playheads across two scale tweens, including both sides of the midpoint
between them and a time past the end of both. Verified against a stubbed
selection that always takes the first tween: three of the six fail.

* fix(studio): only claim the drop point on the route that settles it

Review caught the inverse of the fault above it. The three returns that report
`ownsDragOffset` hardcoded `true`, and they are reached by the size-tween route
too — a real, non-hold size tween with no scale group. That route never
captures the element, so the finalize step no-ops, nothing writes the position,
and the caller withholds an offset it would otherwise have forwarded. The
release frame looks right because the live DOM was already settled; the
persisted state reverts on the next seek.

Fixed the same way the fault above it was: the finalize step reports whether it
settled the drop point rather than the caller assuming from where it was
called. It answers false when it is not the scale route, false when it cannot
measure, and TRUE when the box is already on the point with nothing to write —
forwarding an offset on top of that would move it off.

The geometry sweep accepted this silently, and the reviewer said why: its live
pose starts at the drop, which is where the gesture leaves it, so a route that
moves nothing trivially "lands" there. Each route now declares whether it
settles the drop point and the sweep holds it to that, which fails on 24 of the
120 runs with the old hardcoded `true`.
2026-08-07 14:29:05 -07:00

290 lines
11 KiB
TypeScript

// @vitest-environment happy-dom
/**
* What the resize COMMITS, checked as geometry rather than as structure.
*
* The structural sweep beside this one proves a resize never addresses a
* missing animation and never leaves a tween spanning two property groups.
* Neither says the box ends up the size the user dragged it to, which is the
* thing they are actually looking at.
*
* The invariant is split by who owns the drop point, because the two halves are
* genuinely different jobs:
*
* - The committed size or scale must reproduce the RENDERED box the user
* dropped, whatever rotation is on the element. This is the resize's job in
* every route.
* - When the resize reports `ownsDragOffset`, the box must also land on the
* drop POINT, because it has taken responsibility for the position. When it
* does not, position is the drag's job and is not asserted here.
*
* Rotation is the reason this exists. The committed scale is worked out from
* the element's CSS box, and a rotated element's rendered box is not its CSS
* box — so the two are only equal if the drafted size is in CSS-box terms all
* the way through. A sweep across rotations is what tells us it is.
*/
import { afterEach, expect, it, vi } from "vitest";
import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
afterEach(() => {
vi.restoreAllMocks();
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
document.body.innerHTML = "";
});
const LAYOUT = { left: 120, top: 520 };
interface Pose {
box: { w: number; h: number };
pos: { x: number; y: number };
scale: { x: number; y: number };
}
/** The AABB a browser reports for `translate() rotate() scale()` about the centre. */
function renderRect(pose: Pose, rotationDeg: number) {
const rad = (rotationDeg * Math.PI) / 180;
const [cos, sin] = [Math.abs(Math.cos(rad)), Math.abs(Math.sin(rad))];
const [sw, sh] = [pose.box.w * pose.scale.x, pose.box.h * pose.scale.y];
const w = sw * cos + sh * sin;
const h = sw * sin + sh * cos;
const cx = LAYOUT.left + pose.box.w / 2 + pose.pos.x;
const cy = LAYOUT.top + pose.box.h / 2 + pose.pos.y;
return { x: cx - w / 2, y: cy - h / 2, w, h };
}
type Props = Record<string, number>;
function tween(id: string, properties: Props, duration: number): GsapAnimation {
return {
id,
targetSelector: "#el",
propertyGroup: classifyTweenPropertyGroup(properties),
method: "to",
properties,
position: 0,
resolvedStart: 0,
duration,
...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : {}),
} as unknown as GsapAnimation;
}
interface Case {
name: string;
/** The element's untransformed CSS box. */
box: { w: number; h: number };
/** Where it sat, and at what scale, before the gesture. */
base: { x: number; y: number };
liveScale: { x: number; y: number };
rotation: number;
/** The box the user dragged to, and where the draft put it. */
drop: { w: number; h: number; x: number; y: number };
animations: () => GsapAnimation[];
/** Whether this route takes responsibility for where the box lands. */
settles: boolean;
}
const ROTATIONS = [0, -8, 45, -47, 90, 180];
/**
* The routes a resize can take, and whether each SETTLES the drop point.
*
* Only a committed scale moves the box: it renders about the element centre
* rather than the dragged corner, so the route measures the difference and
* writes the position. Every other route commits width and height and moves
* nothing, which leaves the anchor to the drag. An element whose scale is an
* instant hold has a scale tween and still commits size, so it belongs with
* the size routes here however it looks from the animation list.
*/
const ROUTES = {
"scale tween": { animations: () => [tween("#el-scale", { scale: 1 }, 2)], settles: true },
"scale longhands": {
animations: () => [tween("#el-scale", { scaleX: 1, scaleY: 1 }, 2)],
settles: true,
},
"scale instant hold": { animations: () => [tween("#el-scale", { scale: 1 }, 0)], settles: false },
"size tween": {
animations: () => [tween("#el-size", { width: 630, height: 408 }, 2)],
settles: false,
},
"size instant hold": {
animations: () => [tween("#el-size", { width: 630, height: 408 }, 0)],
settles: false,
},
} as const;
function buildCases(): Case[] {
const cases: Case[] = [];
for (const [routeName, route] of Object.entries(ROUTES)) {
for (const rotation of ROTATIONS) {
for (const [dropName, drop] of Object.entries({
shrink: { w: 326, h: 213, x: 60, y: 40 },
grow: { w: 980, h: 640, x: -120, y: -90 },
"near zero": { w: 12, h: 8, x: 200, y: 160 },
"aspect flip": { w: 900, h: 90, x: 10, y: 10 },
})) {
cases.push({
name: `${routeName} / rotation ${rotation} / ${dropName}`,
box: { w: 630, h: 408 },
base: { x: 40, y: 25 },
liveScale: { x: 1, y: 1 },
rotation,
drop,
animations: route.animations,
settles: route.settles,
});
}
}
}
return cases;
}
const CASES = buildCases();
/** The scale and size the run committed, read at the playhead. */
function committed(calls: unknown[][]) {
let scale: { x: number; y: number } | null = null;
let size: { w: number; h: number } | null = null;
const take = (source: Props | undefined) => {
if (!source) return;
const sx = source.scaleX ?? source.scale;
const sy = source.scaleY ?? source.scale;
if (sx != null && sy != null) scale = { x: sx, y: sy };
if (source.width != null && source.height != null) {
size = { w: source.width, h: source.height };
}
};
for (const call of calls) {
const mutation = call[1] as {
properties?: Props;
percentage?: number;
keyframes?: Array<{ percentage: number; properties: Props }>;
};
if (mutation.keyframes) {
for (const frame of mutation.keyframes) if (frame.percentage === 0) take(frame.properties);
continue;
}
if (mutation.percentage != null && mutation.percentage !== 0) continue;
take(mutation.properties);
}
return { scale, size };
}
/** The element as the gesture leaves it: drafted box, base pose, live pose. */
function mountCase(testCase: Case, live: Pose) {
const el = document.createElement("div");
el.id = "el";
el.setAttribute("data-hf-studio-original-box-width", String(testCase.box.w));
el.setAttribute("data-hf-studio-original-box-height", String(testCase.box.h));
el.setAttribute("data-hf-drag-gsap-base-x", String(testCase.base.x));
el.setAttribute("data-hf-drag-gsap-base-y", String(testCase.base.y));
el.setAttribute("data-hf-studio-box-size", "true");
el.style.width = `${testCase.drop.w}px`;
el.style.height = `${testCase.drop.h}px`;
document.body.append(el);
el.getBoundingClientRect = () => {
const w = Number.parseFloat(el.style.width) || live.box.w;
const h = Number.parseFloat(el.style.height) || live.box.h;
const rect = renderRect({ ...live, box: { w, h } }, testCase.rotation);
return { ...rect, width: rect.w, height: rect.h } as unknown as DOMRect;
};
const gsap = {
set: (_target: Element, vars: Props) => {
if (vars.x != null) live.pos.x = vars.x;
if (vars.y != null) live.pos.y = vars.y;
if (vars.scaleX != null) live.scale.x = vars.scaleX;
if (vars.scaleY != null) live.scale.y = vars.scaleY;
},
getProperty: (_target: Element, prop: string) =>
({
scaleX: live.scale.x,
scaleY: live.scale.y,
x: live.pos.x,
y: live.pos.y,
rotation: testCase.rotation,
})[prop] ?? 0,
};
Object.assign(window, { gsap });
const iframe = {
contentWindow: { gsap, __timelines: { main: { getChildren: () => [] } } },
contentDocument: document,
} as unknown as HTMLIFrameElement;
return { el, iframe };
}
/** One run: mount the case, drive the intercept, hand the result to the judge. */
async function runCase(testCase: Case): Promise<string[]> {
document.body.innerHTML = "";
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
const live: Pose = {
box: { ...testCase.box },
pos: { x: testCase.drop.x, y: testCase.drop.y },
scale: { ...testCase.liveScale },
};
const { el, iframe } = mountCase(testCase, live);
const dropped = el.getBoundingClientRect();
const animations = testCase.animations();
const commitMutation = vi.fn();
const outcome = await tryGsapResizeIntercept(
{ id: "el", selector: "#el", element: el } as DomEditSelection,
{ width: testCase.drop.w, height: testCase.drop.h },
animations,
iframe,
commitMutation as never,
async () => animations,
);
const { scale, size } = committed(commitMutation.mock.calls);
const settled = renderRect(
{ box: size ?? testCase.box, pos: { ...live.pos }, scale: scale ?? testCase.liveScale },
testCase.rotation,
);
const owns = outcome.status === "persisted" && outcome.ownsDragOffset === true;
return judge(testCase, dropped, settled, owns);
}
/**
* What the run got wrong, if anything. 1px: position rounds to whole pixels and
* scale keeps three decimals.
*/
function judge(
testCase: Case,
dropped: DOMRect,
settled: { x: number; y: number; w: number; h: number },
owns: boolean,
): string[] {
const off = (a: number, b: number) => Math.abs(a - b) > 1;
if (off(settled.w, dropped.width) || off(settled.h, dropped.height)) {
return [
`${testCase.name} — box ${settled.w.toFixed(1)}x${settled.h.toFixed(1)}` +
`, dropped ${dropped.width.toFixed(1)}x${dropped.height.toFixed(1)}`,
];
}
// Claiming the drop point is only honest for the routes that settle it. The
// size routes commit width and height and move nothing, so the drag still owns
// the anchor — and a run that claims otherwise makes the caller withhold an
// offset nobody writes. The position check below cannot see that on its own:
// the fixture's live pose starts at the drop, which is where the gesture
// leaves it, so a size route trivially "lands" there.
if (owns !== testCase.settles) {
return [`${testCase.name} — ownsDragOffset ${owns}, expected ${testCase.settles}`];
}
if (owns && (off(settled.x, dropped.x) || off(settled.y, dropped.y))) {
return [
`${testCase.name} — landed ${settled.x.toFixed(1)},${settled.y.toFixed(1)}` +
`, dropped ${dropped.x.toFixed(1)},${dropped.y.toFixed(1)}`,
];
}
return [];
}
it(`sweeps ${CASES.length} rotations and drops for the box the user dropped`, async () => {
const failures: string[] = [];
for (const testCase of CASES) failures.push(...(await runCase(testCase)));
expect(failures).toEqual([]);
});