diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts index e3e373ccb5..8121c7a22c 100644 --- a/packages/parsers/src/gsapParser.ts +++ b/packages/parsers/src/gsapParser.ts @@ -1969,6 +1969,10 @@ function percentageFromKey(key: string): number { const PCT_TOLERANCE = 2; +// Below this (tween-%) a retime resolves onto its own source keyframe → skip the +// write. Mirrors the drag layer's NOOP_EPSILON so a deliberate 1% retime commits. +const MOVE_NOOP_EPSILON_PCT = 0.05; + function findKeyframePropByPct( kfNode: AstNode, percentage: number, @@ -2315,6 +2319,96 @@ export function removeKeyframeFromScript( return recast.print(loc.parsed.ast).code; } +/** + * Retime a keyframe: move the keyframe at `fromPercentage` to `toPercentage`, + * PRESERVING its properties and per-keyframe ease (the Studio "Move to Playhead" + * gesture). Re-sorts keyframes by percentage. If a keyframe already exists at + * `toPercentage`, it is overwritten by the moved one (no duplicate). No-op when + * the animation/keyframe isn't found, the tween has no object-form keyframes, or + * the move resolves onto the same keyframe. Acorn twin: moveKeyframeInScript. + */ +export function moveKeyframeInScript( + script: string, + animationId: string, + fromPercentage: number, + toPercentage: number, +): string { + const loc = locateAnimationWithFallback(script, animationId); + if (!loc) return script; + const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + if (!kfNode) return script; + + const match = findKeyframePropByPct(kfNode, fromPercentage); + if (!match) return script; + // No-op ONLY for a negligible move (matches the drag's NOOP_EPSILON). The old + // `collision.prop === match.prop` guard dropped EVERY sub-PCT_TOLERANCE (2%) + // retime, because findKeyframePropByPct resolves the destination back onto the + // from-keyframe — so a deliberate 1% drag committed nothing. Acorn twin too. + if (Math.abs(fromPercentage - toPercentage) < MOVE_NOOP_EPSILON_PCT) return script; + // A destination keyframe is only a real collision (overwrite) when it's a + // DIFFERENT keyframe; resolving back onto the from-keyframe is not. + const dest = findKeyframePropByPct(kfNode, toPercentage); + const collision = dest && dest.prop !== match.prop ? dest : null; + + // Reuse each keyframe's value node verbatim (preserves properties + + // per-keyframe ease + _auto). Drop the moved keyframe (and any destination + // keyframe it overwrites), re-key the moved value to toPercentage, then re-sort. + const movedValue = match.prop.value; + const entries: Array<{ pct: number; value: AstNode }> = []; + for (const prop of filterPercentageProps(kfNode)) { + if (prop === match.prop) continue; + if (collision && prop === collision.prop) continue; + const pct = percentageFromKey(propKeyName(prop) ?? ""); + if (Number.isNaN(pct)) continue; + entries.push({ pct, value: prop.value }); + } + entries.push({ pct: toPercentage, value: movedValue }); + entries.sort((a, b) => a.pct - b.pct); + + kfNode.properties = entries.map((e) => { + const p = parseExpr(`{ ${JSON.stringify(`${e.pct}%`)}: {} }`).properties[0]; + p.value = e.value; + return p; + }); + return recast.print(loc.parsed.ast).code; +} + +/** + * Resize a keyframed tween's window (boundary drag-to-retime): set the tween's + * `position` + `duration` and RE-KEY each existing keyframe to its new percentage + * via `pctRemap` (each `{ from, to }` matches an existing keyframe by its current + * tween-% and re-keys it to `to`). + * + * Re-keys the percentage KEY in place, leaving every value node (so `_auto` + + * per-keyframe `ease` survive), the keyframes-object `easeEach`, and the OUTER + * tween `ease` untouched. Acorn twin: resizeKeyframedTweenInScript. + */ +export function resizeKeyframedTweenInScript( + script: string, + animationId: string, + newPosition: number, + newDuration: number, + pctRemap: ReadonlyArray<{ from: number; to: number }>, +): string { + const loc = locateAnimationWithFallback(script, animationId); + if (!loc) return script; + const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + if (!kfNode) return script; + + const seen = new Set(); + for (const { from, to } of pctRemap) { + const match = findKeyframePropByPct(kfNode, from); + if (!match || seen.has(match.prop)) continue; + seen.add(match.prop); + // Replace only the key node; the value node (incl. _auto + per-keyframe ease) + // stays verbatim. easeEach is a sibling non-percentage prop, left untouched. + match.prop.key = parseExpr(`{ ${JSON.stringify(`${to}%`)}: 0 }`).properties[0].key; + } + + applyUpdatesToCall(loc.target.call, { position: newPosition, duration: newDuration }); + return recast.print(loc.parsed.ast).code; +} + /** * Replace the properties (and optionally ease) at an existing keyframe percentage. */ diff --git a/packages/parsers/src/gsapWriter.parity.test.ts b/packages/parsers/src/gsapWriter.parity.test.ts index 780c930666..2a066e7991 100644 --- a/packages/parsers/src/gsapWriter.parity.test.ts +++ b/packages/parsers/src/gsapWriter.parity.test.ts @@ -25,6 +25,8 @@ import { addKeyframeToScript as addKeyframeRecast, updateKeyframeInScript as updateKeyframeRecast, removeKeyframeFromScript as removeKeyframeRecast, + moveKeyframeInScript as moveKeyframeRecast, + resizeKeyframedTweenInScript as resizeKeyframedTweenRecast, addAnimationWithKeyframesToScript as addWithKfRecast, shiftPositionsInScript as shiftRecast, scalePositionsInScript as scaleRecast, @@ -49,6 +51,8 @@ import { addKeyframeToScript as addKeyframeAcorn, updateKeyframeInScript as updateKeyframeAcorn, removeKeyframeFromScript as removeKeyframeAcorn, + moveKeyframeInScript as moveKeyframeAcorn, + resizeKeyframedTweenInScript as resizeKeyframedTweenAcorn, addAnimationWithKeyframesToScript as addWithKfAcorn, removeAnimationFromScript as removeAnimAcorn, shiftPositionsInScript as shiftAcorn, @@ -1117,6 +1121,157 @@ describe("parity: updateKeyframeInScript (recast vs acorn)", () => { }); }); +// ── moveKeyframeInScript (retime: preserve value + ease) ───────────────────── +// "Move to Playhead" retimes a keyframe in time, keeping its properties and +// per-keyframe ease. The moved keyframe must vanish from the source percentage +// and reappear (with identical value + ease) at the destination; a destination +// collision is overwritten, not duplicated. recast and acorn must agree. +const MOVE_KF_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { x: 0 }, "50%": { x: 100, opacity: 0.5, ease: "power2.in" }, "100%": { x: 200 } }, duration: 1 }, 0.2); +`; + +describe("moveKeyframeInScript: retime preserves value + ease (acorn) ", () => { + it("moves a keyframe to a new percentage, keeping properties + ease", () => { + const id = acornId(MOVE_KF_SCRIPT); + const out = moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 75); + const kfs = shapeOf(out).keyframes?.keyframes ?? []; + const pcts = kfs.map((k) => k.percentage); + expect(pcts).toEqual([0, 75, 100]); + const moved = kfs.find((k) => k.percentage === 75)!; + expect(moved.properties).toEqual({ x: 100, opacity: 0.5 }); + expect(moved.ease).toBe("power2.in"); + // The source percentage is gone. + expect(pcts).not.toContain(50); + }); + + it("overwrites the destination keyframe on collision (no duplicate)", () => { + const id = acornId(MOVE_KF_SCRIPT); + const out = moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 100); + const kfs = shapeOf(out).keyframes?.keyframes ?? []; + const pcts = kfs.map((k) => k.percentage); + expect(pcts).toEqual([0, 100]); + const dest = kfs.find((k) => k.percentage === 100)!; + // The moved keyframe's value + ease replaced the old 100% { x: 200 }. + expect(dest.properties).toEqual({ x: 100, opacity: 0.5 }); + expect(dest.ease).toBe("power2.in"); + }); + + it("no-ops only for a negligible move (below the drag epsilon)", () => { + const id = acornId(MOVE_KF_SCRIPT); + // < 0.05% of travel resolves onto its own source keyframe → skip the write. + expect(moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 50.02)).toBe(MOVE_KF_SCRIPT); + expect(moveKeyframeRecast(MOVE_KF_SCRIPT, id, 50, 50.02)).toBe(MOVE_KF_SCRIPT); + }); + + // Regression: a deliberate sub-PCT_TOLERANCE (2%) retime must COMMIT, not get + // swallowed by the old `collision.prop === match.prop` guard (findKfPropByPct + // resolves the 51% destination back onto the 50% from-keyframe). + it("commits a sub-2% retime, keeping value + ease", () => { + const id = acornId(MOVE_KF_SCRIPT); + const out = moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 51); + expect(out).not.toBe(MOVE_KF_SCRIPT); + const kfs = shapeOf(out).keyframes?.keyframes ?? []; + expect(kfs.map((k) => k.percentage)).toEqual([0, 51, 100]); + const moved = kfs.find((k) => k.percentage === 51)!; + expect(moved.properties).toEqual({ x: 100, opacity: 0.5 }); + expect(moved.ease).toBe("power2.in"); + }); + + it("no-ops on unknown id / absent source keyframe (both writers)", () => { + const id = acornId(MOVE_KF_SCRIPT); + expect(moveKeyframeAcorn(MOVE_KF_SCRIPT, "bad-id", 50, 75)).toBe(MOVE_KF_SCRIPT); + expect(moveKeyframeRecast(MOVE_KF_SCRIPT, "bad-id", 50, 75)).toBe(MOVE_KF_SCRIPT); + expect(moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 33, 75)).toBe(MOVE_KF_SCRIPT); + }); +}); + +describe("parity: moveKeyframeInScript (recast vs acorn)", () => { + function expectParity(script: string, from: number, to: number) { + const id = acornId(script); + expect(parseGsapScript(script).animations[0]!.id).toBe(id); + expect(modelOf(moveKeyframeAcorn(script, id, from, to))).toEqual( + modelOf(moveKeyframeRecast(script, id, from, to)), + ); + } + + it("retime to a fresh percentage", () => { + expectParity(MOVE_KF_SCRIPT, 50, 75); + }); + + it("retime earlier, re-sorting keyframes", () => { + expectParity(MOVE_KF_SCRIPT, 50, 10); + }); + + it("retime onto an existing percentage (collision overwrite)", () => { + expectParity(MOVE_KF_SCRIPT, 50, 100); + }); + + it("retime an endpoint inward", () => { + expectParity(MOVE_KF_SCRIPT, 0, 25); + }); + + it("sub-2% retime agrees between writers (regression for the swallow bug)", () => { + expectParity(MOVE_KF_SCRIPT, 50, 51); + }); +}); + +// ── resizeKeyframedTweenInScript (boundary drag: re-key + grow window) ──────── +// Boundary drag-to-retime grows/shifts the tween window and RE-KEYS keyframes in +// place. Unlike replace-with-keyframes (array rebuild), it must preserve author +// intent verbatim: `_auto` endpoint markers, per-keyframe `ease`, the keyframes- +// object `easeEach`, and the OUTER tween `ease`. +const RESIZE_KF_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 0, _auto: 1 }, "50%": { opacity: 0.5, ease: "power2.in" }, "100%": { opacity: 1, _auto: 1 }, easeEach: "power1.inOut" }, duration: 1, ease: "power3.out" }, 0.2); +`; +// Window [0.2, 1.2]; drag the last keyframe (abs 1.2) out to abs 2.2 → [0.2, 2.2]. +// abs 0.2/0.7/2.2 over the new 2.0s window → 0 / 25 / 100. +const RESIZE_REMAP = [ + { from: 0, to: 0 }, + { from: 50, to: 25 }, + { from: 100, to: 100 }, +]; + +describe("resizeKeyframedTweenInScript: preserves author intent (acorn + recast)", () => { + for (const [label, resize] of [ + ["acorn", resizeKeyframedTweenAcorn], + ["recast", resizeKeyframedTweenRecast], + ] as const) { + it(`${label}: re-keys + grows the window, keeping _auto / ease / easeEach / outer ease`, () => { + const id = acornId(RESIZE_KF_SCRIPT); + const out = resize(RESIZE_KF_SCRIPT, id, 0.2, 2, RESIZE_REMAP); + expect(out).not.toBe(RESIZE_KF_SCRIPT); + const shape = shapeOf(out); + expect(shape.duration).toBe(2); + expect(parseGsapScript(out).animations[0]!.position).toBeCloseTo(0.2, 5); + // Outer tween ease + keyframes-object easeEach survive. + expect(shape.ease).toBe("power3.out"); + expect(shape.keyframes?.easeEach).toBe("power1.inOut"); + const kfs = shape.keyframes?.keyframes ?? []; + expect(kfs.map((k) => k.percentage)).toEqual([0, 25, 100]); + // _auto endpoints preserved (parsed back as an _auto property). + expect(kfs.find((k) => k.percentage === 0)!.properties).toEqual({ opacity: 0, _auto: 1 }); + expect(kfs.find((k) => k.percentage === 100)!.properties).toEqual({ opacity: 1, _auto: 1 }); + // Per-keyframe ease on the interior keyframe survives the re-key. + const interior = kfs.find((k) => k.percentage === 25)!; + expect(interior.properties).toEqual({ opacity: 0.5 }); + expect(interior.ease).toBe("power2.in"); + }); + + it(`${label}: no-ops on unknown id`, () => { + expect(resize(RESIZE_KF_SCRIPT, "bad-id", 0.2, 2, RESIZE_REMAP)).toBe(RESIZE_KF_SCRIPT); + }); + } + + it("parity: both writers reparse to the same model", () => { + const id = acornId(RESIZE_KF_SCRIPT); + expect(modelOf(resizeKeyframedTweenAcorn(RESIZE_KF_SCRIPT, id, 0.2, 2, RESIZE_REMAP))).toEqual( + modelOf(resizeKeyframedTweenRecast(RESIZE_KF_SCRIPT, id, 0.2, 2, RESIZE_REMAP)), + ); + }); +}); + // ── addAnimationWithKeyframesToScript parity (recast vs acorn) ─────────────── // WS-3.C add path: both writers insert a new keyframed tl.to() call. The // inserted statement's authored model (selector, keyframes, duration, ease, diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index 1c3f490ef2..a6a9d40bc4 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -667,6 +667,10 @@ const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/; // treated as the same keyframe (merge), not a new insert. const PCT_TOLERANCE = 2; +// Below this (tween-%) a retime resolves onto its own source keyframe → skip the +// write. Mirrors the drag layer's NOOP_EPSILON so a deliberate 1% retime commits. +const MOVE_NOOP_EPSILON_PCT = 0.05; + function percentageFromKey(key: string): number { const m = PERCENTAGE_KEY_RE.exec(key); return m ? Number.parseFloat(m[1] ?? "0") : Number.NaN; @@ -1191,6 +1195,102 @@ export function removeKeyframeFromScript( return ms.toString(); } +/** + * Retime a keyframe: move the keyframe at `fromPercentage` to `toPercentage`, + * PRESERVING its properties and per-keyframe ease (the Studio "Move to Playhead" + * gesture). Re-sorts keyframes by percentage. If a keyframe already exists at + * `toPercentage`, it is overwritten by the moved one (no duplicate). No-op when + * the animation/keyframe isn't found, the tween has no object-form keyframes, or + * the move resolves onto the same keyframe. + */ +export function moveKeyframeInScript( + script: string, + animationId: string, + fromPercentage: number, + toPercentage: number, +): string { + const located = locateWithKeyframes(script, animationId); + if (!located) return script; + const { kfNode } = located; + + const match = findKfPropByPct(kfNode, fromPercentage); + if (!match) return script; + // No-op ONLY for a negligible move (matches the drag's NOOP_EPSILON). The old + // `collision.prop === match.prop` guard dropped EVERY sub-PCT_TOLERANCE (2%) + // retime, because findKfPropByPct resolves the destination back onto the + // from-keyframe — so a deliberate 1% drag committed nothing. + if (Math.abs(fromPercentage - toPercentage) < MOVE_NOOP_EPSILON_PCT) return script; + // A destination keyframe is only a real collision (overwrite) when it's a + // DIFFERENT keyframe; resolving back onto the from-keyframe is not. + const dest = findKfPropByPct(kfNode, toPercentage); + const collision = dest && dest.prop !== match.prop ? dest : null; + + // Rebuild the keyframes object: drop the moved keyframe (and any keyframe at + // the destination it overwrites), re-key the moved record to toPercentage, + // then re-sort. recordToCode round-trips properties + per-keyframe ease + _auto. + const entries: Array<{ pct: number; record: Record }> = []; + for (const prop of percentagePropsOf(kfNode)) { + if (prop === match.prop) continue; + if (collision && prop === collision.prop) continue; + const pct = percentageFromKey(propKeyName(prop) ?? ""); + if (Number.isNaN(pct)) continue; + entries.push({ pct, record: valueNodeToRecord(prop.value, script) }); + } + entries.push({ pct: toPercentage, record: valueNodeToRecord(match.prop.value, script) }); + entries.sort((a, b) => a.pct - b.pct); + + const body = entries + .map((e) => `${JSON.stringify(`${e.pct}%`)}: ${recordToCode(e.record)}`) + .join(", "); + const ms = new MagicString(script); + ms.overwrite(kfNode.start, kfNode.end, `{ ${body} }`); + return ms.toString(); +} + +/** + * Resize a keyframed tween's window (boundary drag-to-retime): set the tween's + * `position` (trailing position arg) + `duration`, and RE-KEY each existing + * keyframe to its new percentage via `pctRemap` (each `{ from, to }` matches an + * existing keyframe by its current tween-% and re-keys it to `to`). + * + * Unlike replace-with-keyframes (which rebuilds the keyframes object from a plain + * array and loses author intent), this re-keys the percentage KEY in place and + * leaves every value node, each keyframe's `_auto` + per-keyframe `ease`, the + * keyframes-object `easeEach`, and the OUTER tween `ease` byte-for-byte verbatim. + * No-op when the animation/keyframes can't be located. + */ +export function resizeKeyframedTweenInScript( + script: string, + animationId: string, + newPosition: number, + newDuration: number, + pctRemap: ReadonlyArray<{ from: number; to: number }>, +): string { + const located = locateWithKeyframes(script, animationId); + if (!located) return script; + const { target, kfNode } = located; + + // Resolve every re-key against the ORIGINAL AST first (offsets stay stable), + // then splice — distinct key nodes, so the overwrites never overlap. A Set + // guards the degenerate case where two remaps resolve to the same key. + const edits: Array<{ keyNode: Node; to: number }> = []; + const seen = new Set(); + for (const { from, to } of pctRemap) { + const match = findKfPropByPct(kfNode, from); + if (!match || seen.has(match.prop.key)) continue; + seen.add(match.prop.key); + edits.push({ keyNode: match.prop.key, to }); + } + + const ms = new MagicString(script); + for (const { keyNode, to } of edits) { + ms.overwrite(keyNode.start, keyNode.end, JSON.stringify(`${to}%`)); + } + overwritePosition(ms, target.call, newPosition); + upsertProp(ms, target.call.varsArg, "duration", newDuration); + return ms.toString(); +} + export function removePropertyFromAnimation( script: string, animationId: string, diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 2e2b58502e..766c15b147 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -450,6 +450,159 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0); expect(fp.opacity).toBe(0); // untouched }); + // Object-form keyframes — exercises the move-keyframe (retime) route. + const KEYFRAME_COMP = ` +
+ +`; + + it("move-keyframe retimes a keyframe, preserving its value + ease", async () => { + const projectDir = createProjectDir(); + writeHtml(projectDir, "kf.html", KEYFRAME_COMP); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const anim = await getFirstAnimation(app, "kf.html"); + + const res = await app.request("http://localhost/projects/demo/gsap-mutations/kf.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "move-keyframe", + animationId: anim.id, + fromPercentage: 50, + toPercentage: 75, + }), + }); + const result = (await res.json()) as { + ok: boolean; + changed: boolean; + parsed: { + animations: Array<{ + keyframes?: { + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>; + }; + }>; + }; + }; + + expect(res.status).toBe(200); + expect(result.ok).toBe(true); + expect(result.changed).toBe(true); + const kfs = result.parsed.animations[0].keyframes?.keyframes ?? []; + expect(kfs.map((k) => k.percentage)).toEqual([0, 75, 100]); + const moved = kfs.find((k) => k.percentage === 75)!; + expect(moved.properties).toEqual({ x: 100, opacity: 0.5 }); + expect(moved.ease).toBe("power2.in"); + }); + + it("move-keyframe rejects non-finite percentages before writing source", async () => { + const projectDir = createProjectDir(); + writeHtml(projectDir, "kf.html", KEYFRAME_COMP); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const anim = await getFirstAnimation(app, "kf.html"); + const before = readFileSync(join(projectDir, "kf.html"), "utf-8"); + const res = await app.request("http://localhost/projects/demo/gsap-mutations/kf.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "move-keyframe", + animationId: anim.id, + fromPercentage: 50, + toPercentage: Number.NaN, + }), + }); + + expect(res.status).toBe(400); + expect(readFileSync(join(projectDir, "kf.html"), "utf-8")).toBe(before); + }); + + it("resize-keyframed-tween grows the window + re-keys, preserving value + ease", async () => { + const projectDir = createProjectDir(); + writeHtml(projectDir, "kf.html", KEYFRAME_COMP); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const anim = await getFirstAnimation(app, "kf.html"); + + // Window [0, 1.5]; drag the last keyframe (abs 1.5) out to abs 3 → [0, 3]. + // abs 0/0.75/3 over the new 3s window → 0 / 25 / 100. + const res = await app.request("http://localhost/projects/demo/gsap-mutations/kf.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "resize-keyframed-tween", + animationId: anim.id, + position: 0, + duration: 3, + pctRemap: [ + { from: 0, to: 0 }, + { from: 50, to: 25 }, + { from: 100, to: 100 }, + ], + }), + }); + const result = (await res.json()) as { + ok: boolean; + changed: boolean; + parsed: { + animations: Array<{ + duration?: number; + keyframes?: { + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>; + }; + }>; + }; + }; + + expect(res.status).toBe(200); + expect(result.ok).toBe(true); + expect(result.changed).toBe(true); + expect(result.parsed.animations[0].duration).toBe(3); + const kfs = result.parsed.animations[0].keyframes?.keyframes ?? []; + expect(kfs.map((k) => k.percentage)).toEqual([0, 25, 100]); + const interior = kfs.find((k) => k.percentage === 25)!; + expect(interior.properties).toEqual({ x: 100, opacity: 0.5 }); + expect(interior.ease).toBe("power2.in"); + }); + + it("resize-keyframed-tween rejects non-finite numbers before writing source", async () => { + const projectDir = createProjectDir(); + writeHtml(projectDir, "kf.html", KEYFRAME_COMP); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const anim = await getFirstAnimation(app, "kf.html"); + const before = readFileSync(join(projectDir, "kf.html"), "utf-8"); + const res = await app.request("http://localhost/projects/demo/gsap-mutations/kf.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "resize-keyframed-tween", + animationId: anim.id, + position: 0, + duration: Number.NaN, + pctRemap: [{ from: 0, to: 0 }], + }), + }); + + expect(res.status).toBe(400); + expect(readFileSync(join(projectDir, "kf.html"), "utf-8")).toBe(before); + }); + it("remove-from-property returns 400 for a non-fromTo animation", async () => { const projectDir = createProjectDir(); const TO_COMP = `