mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
test(core): recast-vs-acorn differential suite for GSAP writer ops (+ fixes) (#1533)
Add gsapWriterParity.corpus.test.ts: a reusable recast-vs-acorn differential harness (runParity/modelOf, exported for the WS-3 op-PR workflow) plus a broadened corpus (3 real registry scripts + 10 synthetic) covering to/from/fromTo, multi-tween, keyframes, labels, numeric/label-relative/symbolic positions, stagger/repeat/yoyo extras, and sub-composition selectors. Extends true differential coverage to the five previously standalone-only acorn ops (update/add/removeAnimation, update/removeKeyframe) and adds correctness tests for the acorn-only label ops. Fix three acorn-writer divergences the suite surfaced: - updateAnimationInScript now REPLACES the editable property set (and fromTo from-vars) instead of merging, matching recast's reconcileEditableProperties; non-editable keys (duration/ease/stagger/…) are preserved. - removeKeyframeFromScript now collapses keyframes back to a flat tween when fewer than two keyframes remain, matching recast's collapseKeyframesToFlat. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
fcc7b314f0
commit
c040e4973a
@@ -142,6 +142,96 @@ function upsertProp(ms: MagicString, objNode: any, key: string, value: unknown):
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vars keys that are NOT editable transform/style props: builtins
|
||||
* (duration/ease/delay), dropped callbacks, and extras (stagger/yoyo/repeat/…).
|
||||
* The exact union of recast's BUILTIN_VAR_KEYS + DROPPED_VAR_KEYS + EXTRAS_KEYS,
|
||||
* so both writers classify vars keys identically. (Distinct from the keyframe-
|
||||
* conversion NON_EDITABLE_VAR_KEYS below, which intentionally omits `ease`
|
||||
* because that path re-emits ease separately.)
|
||||
*/
|
||||
const NON_EDITABLE_PROP_KEYS = new Set([
|
||||
"duration",
|
||||
"ease",
|
||||
"delay",
|
||||
"onComplete",
|
||||
"onStart",
|
||||
"onUpdate",
|
||||
"onRepeat",
|
||||
"stagger",
|
||||
"yoyo",
|
||||
"repeat",
|
||||
"repeatDelay",
|
||||
"snap",
|
||||
"overwrite",
|
||||
"immediateRender",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Editable transform/style key test: anything NOT a builtin, dropped callback, or
|
||||
* extras key. Mirrors recast's isEditablePropertyKey so both writers classify
|
||||
* vars keys identically.
|
||||
*/
|
||||
function isEditableVarKey(key: string): boolean {
|
||||
return !NON_EDITABLE_PROP_KEYS.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect verbatim `key: value` entries to PRESERVE from a vars/keyframe
|
||||
* ObjectExpression: every property whose key `drop` does not reject, sliced from
|
||||
* source — except keys present in `overrides`, whose value is replaced. Returns
|
||||
* the entries plus the set of keys it kept, so callers can append new keys.
|
||||
*/
|
||||
function preservedEntries(
|
||||
objNode: any,
|
||||
source: string,
|
||||
drop: (key: string) => boolean,
|
||||
overrides: Record<string, unknown>,
|
||||
): { entries: string[]; keys: Set<string> } {
|
||||
const entries: string[] = [];
|
||||
const keys = new Set<string>();
|
||||
for (const prop of objNode.properties ?? []) {
|
||||
if (!isObjectProperty(prop)) continue;
|
||||
const key = propKeyName(prop);
|
||||
if (typeof key !== "string" || drop(key)) continue;
|
||||
keys.add(key);
|
||||
const code =
|
||||
key in overrides
|
||||
? valueToCode(overrides[key])
|
||||
: source.slice(prop.value.start, prop.value.end);
|
||||
entries.push(`${safeKey(key)}: ${code}`);
|
||||
}
|
||||
return { entries, keys };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the editable-property keys on a vars ObjectExpression with exactly
|
||||
* `newProps`, leaving non-editable keys (duration/ease/stagger/callbacks/…)
|
||||
* untouched unless overridden in `nonEditableOverrides`. Mirrors recast's
|
||||
* reconcileEditableProperties: editable keys absent from `newProps` are DROPPED,
|
||||
* not merged. Rebuilt in a single ms.overwrite so the splice can never overlap a
|
||||
* sibling edit — non-editable updates that also target this node (duration/ease/
|
||||
* extras) are folded into the same rebuild rather than spliced separately.
|
||||
*/
|
||||
function reconcileEditableProps(
|
||||
ms: MagicString,
|
||||
objNode: any,
|
||||
source: string,
|
||||
newProps: Record<string, number | string>,
|
||||
nonEditableOverrides?: Record<string, unknown>,
|
||||
): void {
|
||||
if (objNode?.type !== "ObjectExpression") return;
|
||||
const overrides = nonEditableOverrides ?? {};
|
||||
const { entries, keys } = preservedEntries(objNode, source, isEditableVarKey, overrides);
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (!keys.has(key)) entries.push(`${safeKey(key)}: ${valueToCode(value)}`);
|
||||
}
|
||||
for (const [key, value] of Object.entries(newProps)) {
|
||||
entries.push(`${safeKey(key)}: ${valueToCode(value)}`);
|
||||
}
|
||||
ms.overwrite(objNode.start, objNode.end, `{ ${entries.join(", ")} }`);
|
||||
}
|
||||
|
||||
// ── Insertion helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Traverse callee.object chain to check if a call ultimately roots at timelineVar. */
|
||||
@@ -184,24 +274,35 @@ export function updateAnimationInScript(
|
||||
const ms = new MagicString(script);
|
||||
const { call }: { call: TweenCallInfo } = target;
|
||||
|
||||
if (updates.duration !== undefined) {
|
||||
upsertProp(ms, call.varsArg, "duration", updates.duration);
|
||||
}
|
||||
|
||||
if (updates.ease !== undefined) {
|
||||
upsertProp(ms, call.varsArg, "ease", updates.ease);
|
||||
}
|
||||
|
||||
// When `properties` is present we REPLACE the editable set (recast parity:
|
||||
// editable keys absent from the update are dropped). Fold any concurrent
|
||||
// non-editable updates (duration/ease/extras) into the single varsArg rebuild
|
||||
// so their splices can't overlap the rebuild's overwrite of the whole node.
|
||||
if (updates.properties) {
|
||||
for (const [key, value] of Object.entries(updates.properties)) {
|
||||
upsertProp(ms, call.varsArg, key, value);
|
||||
const overrides: Record<string, unknown> = {};
|
||||
if (updates.duration !== undefined) overrides.duration = updates.duration;
|
||||
if (updates.ease !== undefined) overrides.ease = updates.ease;
|
||||
if (updates.extras) Object.assign(overrides, updates.extras);
|
||||
reconcileEditableProps(ms, call.varsArg, script, updates.properties, overrides);
|
||||
} else {
|
||||
if (updates.duration !== undefined) {
|
||||
upsertProp(ms, call.varsArg, "duration", updates.duration);
|
||||
}
|
||||
if (updates.ease !== undefined) {
|
||||
upsertProp(ms, call.varsArg, "ease", updates.ease);
|
||||
}
|
||||
if (updates.extras) {
|
||||
for (const [key, value] of Object.entries(updates.extras)) {
|
||||
upsertProp(ms, call.varsArg, key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.fromProperties && call.method === "fromTo" && call.fromArg) {
|
||||
for (const [key, value] of Object.entries(updates.fromProperties)) {
|
||||
upsertProp(ms, call.fromArg, key, value);
|
||||
}
|
||||
// fromTo's from-vars carry only editable props — REPLACE them too (recast
|
||||
// parity). fromArg is a distinct node from varsArg, so this rebuild never
|
||||
// overlaps the varsArg edits above.
|
||||
reconcileEditableProps(ms, call.fromArg, script, updates.fromProperties);
|
||||
}
|
||||
|
||||
if (updates.position !== undefined) {
|
||||
@@ -214,12 +315,6 @@ export function updateAnimationInScript(
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.extras) {
|
||||
for (const [key, value] of Object.entries(updates.extras)) {
|
||||
upsertProp(ms, call.varsArg, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
@@ -715,6 +810,29 @@ function insertNewKeyframe(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a vars ObjectExpression that has just dropped below two keyframes,
|
||||
* collapsing `keyframes: {…}` back to a flat tween. Mirrors recast's
|
||||
* collapseKeyframesToFlat: drop the `keyframes` + `easeEach` keys, preserve every
|
||||
* other vars key verbatim, and splice the remaining keyframe's properties (minus
|
||||
* its per-keyframe `ease`) in as flat vars keys. Single ms.overwrite of the whole
|
||||
* vars node so the splice can't overlap the keyframe removal.
|
||||
*/
|
||||
function collapseKeyframesToFlat(
|
||||
ms: MagicString,
|
||||
varsNode: any,
|
||||
source: string,
|
||||
remainingRecord: Record<string, number | string>,
|
||||
): void {
|
||||
if (varsNode?.type !== "ObjectExpression") return;
|
||||
const dropKeyframeKeys = (key: string) => key === "keyframes" || key === "easeEach";
|
||||
const { entries } = preservedEntries(varsNode, source, dropKeyframeKeys, {});
|
||||
for (const [k, v] of Object.entries(remainingRecord)) {
|
||||
if (k !== "ease") entries.push(`${safeKey(k)}: ${valueToCode(v)}`);
|
||||
}
|
||||
ms.overwrite(varsNode.start, varsNode.end, `{ ${entries.join(", ")} }`);
|
||||
}
|
||||
|
||||
export function removeKeyframeFromScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
@@ -732,8 +850,20 @@ export function removeKeyframeFromScript(
|
||||
const match = findKfPropByPct(kfNode, percentage);
|
||||
if (!match) return script;
|
||||
|
||||
const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p));
|
||||
const ms = new MagicString(script);
|
||||
|
||||
// If removing this keyframe leaves fewer than two, collapse the keyframes
|
||||
// object back to a flat tween (recast parity) instead of leaving a lone
|
||||
// keyframe. We rebuild the whole vars node, so we never also splice the kf
|
||||
// node — the two edits would overlap.
|
||||
const remaining = percentagePropsOf(kfNode).filter((p) => p !== match.prop);
|
||||
if (remaining.length < 2) {
|
||||
const record = remaining.length === 1 ? valueNodeToRecord(remaining[0]!.value, script) : {};
|
||||
collapseKeyframesToFlat(ms, target.call.varsArg, script, record);
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p));
|
||||
removeProp(ms, match.prop, allProps);
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
/**
|
||||
* Recast-vs-acorn GSAP-writer differential suite (WS-3.F cutover gate).
|
||||
*
|
||||
* The SDK's browser-safe acorn writer (gsapWriterAcorn.ts) must produce output
|
||||
* equivalent to the server's recast writer (gsapParser.ts) for every write op,
|
||||
* so that making the acorn writer authoritative (retiring recast) is behavior-
|
||||
* preserving. The two formatters differ (recast pretty-prints, acorn splices),
|
||||
* so we never compare bytes — we apply each op via BOTH writers, parse both
|
||||
* outputs with the shared `parseGsapScriptAcorn`, and assert the resulting
|
||||
* animation models match structurally.
|
||||
*
|
||||
* Until this suite, only `addKeyframeToScript` had a true differential test
|
||||
* (gsapWriterParity.acorn.test.ts). This file extends parity coverage to the
|
||||
* five previously standalone-only ops:
|
||||
* updateAnimationInScript, addAnimationToScript, removeAnimationFromScript,
|
||||
* updateKeyframeInScript, removeKeyframeFromScript.
|
||||
* Plus correctness tests for the acorn-only label ops (addLabelToScript /
|
||||
* removeLabelFromScript), which have no recast oracle to diff against.
|
||||
*
|
||||
* The harness (`runParity`, `modelOf`) is exported so the follow-up WS-3 op-PR
|
||||
* workflow can reuse it to gate each cut-over op.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
updateAnimationInScript as updateAnimAcorn,
|
||||
addAnimationToScript as addAnimAcorn,
|
||||
removeAnimationFromScript as removeAnimAcorn,
|
||||
updateKeyframeInScript as updateKfAcorn,
|
||||
removeKeyframeFromScript as removeKfAcorn,
|
||||
addLabelToScript,
|
||||
removeLabelFromScript,
|
||||
} from "./gsapWriterAcorn.js";
|
||||
import {
|
||||
updateAnimationInScript as updateAnimRecast,
|
||||
addAnimationToScript as addAnimRecast,
|
||||
removeAnimationFromScript as removeAnimRecast,
|
||||
updateKeyframeInScript as updateKfRecast,
|
||||
removeKeyframeFromScript as removeKfRecast,
|
||||
parseGsapScript,
|
||||
} from "./gsapParser.js";
|
||||
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
|
||||
import type { GsapAnimation } from "./gsapSerialize.js";
|
||||
|
||||
// ── Reusable differential harness (exported for the WS-3 op-PR workflow) ───────
|
||||
|
||||
/**
|
||||
* Fields that are incidental metadata — derived per-parse rather than authored —
|
||||
* and so must be excluded from a structural comparison: stable ids are content-
|
||||
* derived (and recast's addAnimation id is a `Date.now()` placeholder), and the
|
||||
* rest are computed analysis (resolved start, group classification, provenance).
|
||||
*/
|
||||
const IGNORED_FIELDS = new Set<keyof GsapAnimation | string>([
|
||||
"id",
|
||||
"resolvedStart",
|
||||
"implicitPosition",
|
||||
"propertyGroup",
|
||||
"provenance",
|
||||
"hasUnresolvedKeyframes",
|
||||
"hasUnresolvedSelector",
|
||||
]);
|
||||
|
||||
type NormalizedAnimation = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Parse a GSAP script and reduce each animation to its authored shape: target,
|
||||
* method, position, properties, fromProperties, duration, ease, extras,
|
||||
* keyframes — dropping per-parse metadata. Both writers' outputs go through this
|
||||
* SAME parser, so any model difference is a genuine writer divergence, not a
|
||||
* parser artifact.
|
||||
*/
|
||||
export function modelOf(script: string): NormalizedAnimation[] {
|
||||
return parseGsapScriptAcorn(script).animations.map((anim) => {
|
||||
const out: NormalizedAnimation = {};
|
||||
for (const [key, value] of Object.entries(anim)) {
|
||||
if (IGNORED_FIELDS.has(key) || value === undefined) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an op via BOTH writers and assert the parsed animation models match.
|
||||
* `recast`/`acorn` each receive the original script and must return the rewritten
|
||||
* script. Returns the recast-written script so callers can chain ops.
|
||||
*/
|
||||
export function runParity(
|
||||
script: string,
|
||||
recast: (s: string) => string,
|
||||
acorn: (s: string) => string,
|
||||
): string {
|
||||
const recastOut = recast(script);
|
||||
const acornOut = acorn(script);
|
||||
expect(modelOf(acornOut), "acorn model must equal recast model").toEqual(modelOf(recastOut));
|
||||
return recastOut;
|
||||
}
|
||||
|
||||
/** The id of the i-th animation in a script (recast parser — the op oracle). */
|
||||
function idAt(script: string, index = 0): string {
|
||||
const id = parseGsapScript(script).animations[index]?.id;
|
||||
if (!id) throw new Error(`no animation at index ${index} in fixture`);
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── Corpus ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Real registry scripts (literal-tween portions extracted verbatim from
|
||||
// registry/blocks and registry/components, trimmed to the editable tweens) plus
|
||||
// synthetic scripts covering breadth the single-op test lacks: to/from/fromTo,
|
||||
// multi-tween, keyframes, labels, numeric + label-relative + symbolic positions,
|
||||
// stagger/repeat/yoyo extras, and sub-composition selectors.
|
||||
|
||||
// REAL — registry/blocks/macos-notification: two plain .to() tweens, numeric pos.
|
||||
const REAL_MACOS = `\
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#notification", { x: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2);
|
||||
tl.to("#notification", { x: 420, opacity: 0, duration: 0.3, ease: "power3.in" }, 4.2);
|
||||
window.__timelines["macos-notification"] = tl;`;
|
||||
|
||||
// REAL — registry/blocks/flowchart: multi .to() with strokeDashoffset + back ease.
|
||||
const REAL_FLOWCHART = `\
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#node-root", { scale: 1, duration: 0.4, ease: "back.out(2)" }, 0.2);
|
||||
tl.to("#node-yes", { scale: 1, duration: 0.3, ease: "back.out(2)" }, 0.5);
|
||||
tl.to("#path-1-L", { strokeDashoffset: 0, duration: 0.6, ease: "power2.inOut" }, 0.8);
|
||||
window.__timelines["flowchart"] = tl;`;
|
||||
|
||||
// REAL — registry/components/caption-kinetic-slam: .fromTo() entrances + .to() exit.
|
||||
const REAL_CAPTION = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#kt-w-0", { y: -120, opacity: 0 }, { y: 0, opacity: 1, duration: 0.22, ease: "back.out(1.7)" }, 0.5);
|
||||
tl.fromTo("#kt-w-1", { x: -300, opacity: 0 }, { x: 0, opacity: 1, duration: 0.2, ease: "expo.out" }, 1.2);
|
||||
tl.to("#kt-w-0", { opacity: 0, duration: 0.1, ease: "power2.in" }, 2.5);
|
||||
window.__timelines["caption-kinetic-slam"] = tl;`;
|
||||
|
||||
// SYNTH — single plain to().
|
||||
const SYN_SINGLE = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { opacity: 1, x: 100, duration: 0.5, ease: "power3.out" }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — to + from + fromTo, mixed positions (numeric, symbolic "<", numeric).
|
||||
const SYN_MIXED_METHODS = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.from("#title", { opacity: 0, y: 30, duration: 0.6, ease: "power2.out" }, 0);
|
||||
tl.to("#title", { opacity: 0, duration: 0.4 }, 1.2);
|
||||
tl.fromTo(".card", { scale: 0.8, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.5 }, "<");
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — extras: stagger / repeat / yoyo / repeatDelay must survive every op.
|
||||
const SYN_EXTRAS = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to(".dot", { y: -20, duration: 0.3, stagger: 0.08, repeat: 2, yoyo: true, repeatDelay: 0.1 }, 0);
|
||||
tl.from("#panel", { opacity: 0, duration: 0.5, ease: "sine.out" }, 0.4);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — labels + label-relative positions ("intro", "intro+=0.3").
|
||||
const SYN_LABELED = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.addLabel("intro", 0);
|
||||
tl.to("#a", { opacity: 1, duration: 0.5 }, "intro");
|
||||
tl.to("#b", { opacity: 1, duration: 0.5 }, "intro+=0.3");
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — sub-composition / nested selectors (scoped descendant + attribute).
|
||||
const SYN_NESTED = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#scene-2 .headline", { y: 0, opacity: 1, duration: 0.5 }, 0);
|
||||
tl.from('[data-hf-id="scene-2"] .sub', { opacity: 0, duration: 0.4 }, 0.3);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — percentage keyframes (3 kfs).
|
||||
const SYN_KF3 = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — percentage keyframes (2 kfs) — removal collapses to flat.
|
||||
const SYN_KF2 = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 0, x: 0 }, "100%": { opacity: 1, x: 40 } }, duration: 0.5, delay: 0.1 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — keyframes carrying per-keyframe ease + easeEach.
|
||||
const SYN_KF_EASE = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { y: 0 }, "50%": { y: 30, ease: "power2.in" }, "100%": { y: 0 }, easeEach: "power1.inOut" }, duration: 0.8 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — chained tweens (tl.from(...).from(...)) for chain-link removal.
|
||||
const SYN_CHAIN = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.from(".a", { opacity: 0, duration: 0.5 }, 0)
|
||||
.from(".b", { opacity: 0, duration: 0.3 }, 0.5)
|
||||
.to(".a", { x: 10, duration: 0.2 }, 1);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// ── 1. updateAnimationInScript ──────────────────────────────────────────────────
|
||||
|
||||
describe("parity — updateAnimationInScript", () => {
|
||||
it("updates duration + ease on a real two-tween block (macos)", () => {
|
||||
const id = idAt(REAL_MACOS, 1);
|
||||
const u = { duration: 0.9, ease: "power1.in" };
|
||||
runParity(
|
||||
REAL_MACOS,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates duration on a real .to() with a back.out ease (flowchart)", () => {
|
||||
const id = idAt(REAL_FLOWCHART, 0);
|
||||
const u = { duration: 0.7 };
|
||||
runParity(
|
||||
REAL_FLOWCHART,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("REPLACES the editable property set (props absent from update are dropped)", () => {
|
||||
const id = idAt(SYN_MIXED_METHODS, 0);
|
||||
const u = { properties: { x: 50, rotation: 10 } };
|
||||
runParity(
|
||||
SYN_MIXED_METHODS,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("REPLACES fromTo from-vars (props absent from update are dropped)", () => {
|
||||
const id = idAt(SYN_MIXED_METHODS, 2);
|
||||
const u = { fromProperties: { scale: 0.5 } };
|
||||
runParity(
|
||||
SYN_MIXED_METHODS,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces props while preserving extras (stagger/repeat/yoyo)", () => {
|
||||
const id = idAt(SYN_EXTRAS, 0);
|
||||
const u = { properties: { y: -40 } };
|
||||
runParity(
|
||||
SYN_EXTRAS,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates props + duration + ease together in one op", () => {
|
||||
const id = idAt(SYN_SINGLE, 0);
|
||||
const u = { properties: { x: 9 }, duration: 1.1, ease: "sine.in" };
|
||||
runParity(
|
||||
SYN_SINGLE,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes numeric position to a label-relative string position", () => {
|
||||
const id = idAt(SYN_MIXED_METHODS, 0);
|
||||
const u = { position: "intro+=0.5" };
|
||||
runParity(
|
||||
SYN_MIXED_METHODS,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes a symbolic '<' position to a numeric position", () => {
|
||||
const id = idAt(SYN_MIXED_METHODS, 2);
|
||||
const u = { position: 3 };
|
||||
runParity(
|
||||
SYN_MIXED_METHODS,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates a tween whose position is a label name (labeled fixture)", () => {
|
||||
const id = idAt(SYN_LABELED, 1);
|
||||
const u = { duration: 0.9 };
|
||||
runParity(
|
||||
SYN_LABELED,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("inserts ease when absent on a nested-selector tween", () => {
|
||||
const id = idAt(SYN_NESTED, 0);
|
||||
const u = { ease: "power3.out" };
|
||||
runParity(
|
||||
SYN_NESTED,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. addAnimationToScript ──────────────────────────────────────────────────────
|
||||
|
||||
describe("parity — addAnimationToScript", () => {
|
||||
const add =
|
||||
(animation: Omit<GsapAnimation, "id">) =>
|
||||
(writer: (s: string, a: Omit<GsapAnimation, "id">) => { script: string }) =>
|
||||
(s: string) =>
|
||||
writer(s, animation).script;
|
||||
|
||||
it("appends a plain to() after the last real tween (macos)", () => {
|
||||
const anim: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: "#new",
|
||||
method: "to",
|
||||
position: 5,
|
||||
duration: 0.3,
|
||||
properties: { x: 100 },
|
||||
ease: "sine.in",
|
||||
};
|
||||
const build = add(anim);
|
||||
runParity(REAL_MACOS, build(addAnimRecast), build(addAnimAcorn));
|
||||
});
|
||||
|
||||
it("appends a fromTo() with extras (repeat/yoyo)", () => {
|
||||
const anim: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: "#x",
|
||||
method: "fromTo",
|
||||
position: 2,
|
||||
duration: 0.4,
|
||||
properties: { opacity: 1 },
|
||||
fromProperties: { opacity: 0 },
|
||||
extras: { repeat: 2, yoyo: true },
|
||||
};
|
||||
const build = add(anim);
|
||||
runParity(SYN_MIXED_METHODS, build(addAnimRecast), build(addAnimAcorn));
|
||||
});
|
||||
|
||||
it("appends a from() with a symbolic '<' position", () => {
|
||||
const anim: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: ".card",
|
||||
method: "from",
|
||||
position: "<",
|
||||
duration: 0.5,
|
||||
properties: { y: 20, opacity: 0 },
|
||||
};
|
||||
const build = add(anim);
|
||||
runParity(SYN_EXTRAS, build(addAnimRecast), build(addAnimAcorn));
|
||||
});
|
||||
|
||||
it("appends a tween with a label-relative position", () => {
|
||||
const anim: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: "#c",
|
||||
method: "to",
|
||||
position: "intro+=0.6",
|
||||
duration: 0.4,
|
||||
properties: { opacity: 1 },
|
||||
};
|
||||
const build = add(anim);
|
||||
runParity(SYN_LABELED, build(addAnimRecast), build(addAnimAcorn));
|
||||
});
|
||||
|
||||
it("appends a tween onto a chained-tween timeline", () => {
|
||||
const anim: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: ".c",
|
||||
method: "to",
|
||||
position: 1.5,
|
||||
duration: 0.3,
|
||||
properties: { scale: 1.2 },
|
||||
};
|
||||
const build = add(anim);
|
||||
runParity(SYN_CHAIN, build(addAnimRecast), build(addAnimAcorn));
|
||||
});
|
||||
|
||||
it("appends with a nested sub-composition selector", () => {
|
||||
const anim: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: "#scene-2 .footer",
|
||||
method: "to",
|
||||
position: 0.8,
|
||||
duration: 0.3,
|
||||
properties: { opacity: 1 },
|
||||
};
|
||||
const build = add(anim);
|
||||
runParity(SYN_NESTED, build(addAnimRecast), build(addAnimAcorn));
|
||||
});
|
||||
|
||||
it("inserts after the timeline decl when the script has no tweens", () => {
|
||||
const empty = `var tl = gsap.timeline({ paused: true });\nwindow.__timelines["t"] = tl;`;
|
||||
const anim: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: "#hero",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 0.5,
|
||||
properties: { opacity: 1 },
|
||||
};
|
||||
const build = add(anim);
|
||||
runParity(empty, build(addAnimRecast), build(addAnimAcorn));
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. removeAnimationFromScript ─────────────────────────────────────────────────
|
||||
|
||||
describe("parity — removeAnimationFromScript", () => {
|
||||
it("removes a standalone tween statement (real macos, first tween)", () => {
|
||||
const id = idAt(REAL_MACOS, 0);
|
||||
runParity(
|
||||
REAL_MACOS,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the middle standalone tween of a 3-tween block (flowchart)", () => {
|
||||
const id = idAt(REAL_FLOWCHART, 1);
|
||||
runParity(
|
||||
REAL_FLOWCHART,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the only tween (timeline left empty)", () => {
|
||||
const id = idAt(SYN_SINGLE, 0);
|
||||
runParity(
|
||||
SYN_SINGLE,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a fromTo tween (real caption block)", () => {
|
||||
const id = idAt(REAL_CAPTION, 1);
|
||||
runParity(
|
||||
REAL_CAPTION,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the inner-most chain link", () => {
|
||||
const id = idAt(SYN_CHAIN, 0);
|
||||
runParity(
|
||||
SYN_CHAIN,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the outer-most chain link", () => {
|
||||
const id = idAt(SYN_CHAIN, 2);
|
||||
runParity(
|
||||
SYN_CHAIN,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a labeled-position tween (label statement preserved)", () => {
|
||||
const id = idAt(SYN_LABELED, 0);
|
||||
runParity(
|
||||
SYN_LABELED,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a tween carrying extras", () => {
|
||||
const id = idAt(SYN_EXTRAS, 0);
|
||||
runParity(
|
||||
SYN_EXTRAS,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 4. updateKeyframeInScript ────────────────────────────────────────────────────
|
||||
|
||||
describe("parity — updateKeyframeInScript", () => {
|
||||
it("replaces the 50% keyframe value", () => {
|
||||
const id = idAt(SYN_KF3, 0);
|
||||
runParity(
|
||||
SYN_KF3,
|
||||
(s) => updateKfRecast(s, id, 50, { opacity: 0.5 }),
|
||||
(s) => updateKfAcorn(s, id, 50, { opacity: 0.5 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces a keyframe value AND sets a per-keyframe ease", () => {
|
||||
const id = idAt(SYN_KF3, 0);
|
||||
runParity(
|
||||
SYN_KF3,
|
||||
(s) => updateKfRecast(s, id, 50, { opacity: 0.4 }, "power2.in"),
|
||||
(s) => updateKfAcorn(s, id, 50, { opacity: 0.4 }, "power2.in"),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces the endpoint (100%) keyframe value", () => {
|
||||
const id = idAt(SYN_KF3, 0);
|
||||
runParity(
|
||||
SYN_KF3,
|
||||
(s) => updateKfRecast(s, id, 100, { opacity: 0.9 }),
|
||||
(s) => updateKfAcorn(s, id, 100, { opacity: 0.9 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces a keyframe that carried a per-keyframe ease (ease dropped when omitted)", () => {
|
||||
const id = idAt(SYN_KF_EASE, 0);
|
||||
runParity(
|
||||
SYN_KF_EASE,
|
||||
(s) => updateKfRecast(s, id, 50, { y: 25 }),
|
||||
(s) => updateKfAcorn(s, id, 50, { y: 25 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces with multiple props on a keyframes-with-easeEach fixture", () => {
|
||||
const id = idAt(SYN_KF_EASE, 0);
|
||||
runParity(
|
||||
SYN_KF_EASE,
|
||||
(s) => updateKfRecast(s, id, 100, { y: 5, opacity: 1 }),
|
||||
(s) => updateKfAcorn(s, id, 100, { y: 5, opacity: 1 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 5. removeKeyframeFromScript ──────────────────────────────────────────────────
|
||||
|
||||
describe("parity — removeKeyframeFromScript", () => {
|
||||
it("removes the interior keyframe, leaving ≥2 (no collapse)", () => {
|
||||
const id = idAt(SYN_KF3, 0);
|
||||
runParity(
|
||||
SYN_KF3,
|
||||
(s) => removeKfRecast(s, id, 50),
|
||||
(s) => removeKfAcorn(s, id, 50),
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses to a flat tween when removal leaves a single keyframe", () => {
|
||||
const id = idAt(SYN_KF2, 0);
|
||||
runParity(
|
||||
SYN_KF2,
|
||||
(s) => removeKfRecast(s, id, 0),
|
||||
(s) => removeKfAcorn(s, id, 0),
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses to flat preserving the remaining keyframe's props (remove 100%)", () => {
|
||||
const id = idAt(SYN_KF2, 0);
|
||||
runParity(
|
||||
SYN_KF2,
|
||||
(s) => removeKfRecast(s, id, 100),
|
||||
(s) => removeKfAcorn(s, id, 100),
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses a keyframes-with-easeEach fixture (drops easeEach + per-kf ease)", () => {
|
||||
// SYN_KF_EASE has 3 kfs; remove two so only one remains → collapse.
|
||||
const id1 = idAt(SYN_KF_EASE, 0);
|
||||
const afterFirst = runParity(
|
||||
SYN_KF_EASE,
|
||||
(s) => removeKfRecast(s, id1, 50),
|
||||
(s) => removeKfAcorn(s, id1, 50),
|
||||
);
|
||||
const id2 = idAt(afterFirst, 0);
|
||||
runParity(
|
||||
afterFirst,
|
||||
(s) => removeKfRecast(s, id2, 0),
|
||||
(s) => removeKfAcorn(s, id2, 0),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 6. Label correctness (acorn-only ops — no recast oracle to diff) ─────────────
|
||||
//
|
||||
// addLabelToScript / removeLabelFromScript exist only on the acorn writer, so we
|
||||
// cannot parity-test them. Instead we verify the source-level contract directly.
|
||||
|
||||
describe("correctness — addLabelToScript / removeLabelFromScript", () => {
|
||||
function labelCallCount(script: string, name: string): number {
|
||||
const re = new RegExp(`\\.addLabel\\(\\s*"${name}"`, "g");
|
||||
return (script.match(re) ?? []).length;
|
||||
}
|
||||
|
||||
it("adds an addLabel(name, pos) call after the last located tween", () => {
|
||||
const out = addLabelToScript(SYN_SINGLE, "mid", 1.5);
|
||||
expect(out).toContain('tl.addLabel("mid", 1.5);');
|
||||
// The label sits after the hero tween and before the postamble.
|
||||
expect(out.indexOf('addLabel("mid"')).toBeGreaterThan(out.indexOf('tl.to("#hero"'));
|
||||
expect(out.indexOf('addLabel("mid"')).toBeLessThan(out.indexOf("window.__timelines"));
|
||||
});
|
||||
|
||||
it("adds a label to an empty (tween-less) timeline after the declaration", () => {
|
||||
const empty = `var tl = gsap.timeline({ paused: true });\nwindow.__timelines["t"] = tl;`;
|
||||
const out = addLabelToScript(empty, "start", 0.8);
|
||||
expect(out).toContain('tl.addLabel("start", 0.8);');
|
||||
expect(out.indexOf('addLabel("start"')).toBeGreaterThan(out.indexOf("gsap.timeline"));
|
||||
});
|
||||
|
||||
it("removes a previously-added label (round-trip back to original)", () => {
|
||||
const empty = `var tl = gsap.timeline({ paused: true });\nwindow.__timelines["t"] = tl;`;
|
||||
const added = addLabelToScript(empty, "start", 0.8);
|
||||
const removed = removeLabelFromScript(added, "start");
|
||||
expect(removed).toBe(empty);
|
||||
});
|
||||
|
||||
it("removes a hand-authored label, leaving the tweens intact", () => {
|
||||
const out = removeLabelFromScript(SYN_LABELED, "intro");
|
||||
expect(labelCallCount(out, "intro")).toBe(0);
|
||||
expect(out).toContain('tl.to("#a"');
|
||||
expect(out).toContain('tl.to("#b"');
|
||||
});
|
||||
|
||||
it("is idempotent: removing an absent label is a no-op", () => {
|
||||
expect(removeLabelFromScript(SYN_SINGLE, "nope")).toBe(SYN_SINGLE);
|
||||
});
|
||||
|
||||
it("adding the same label twice yields two addLabel calls (no dedup contract)", () => {
|
||||
const once = addLabelToScript(SYN_SINGLE, "mid", 1.0);
|
||||
const twice = addLabelToScript(once, "mid", 2.0);
|
||||
expect(labelCallCount(twice, "mid")).toBe(2);
|
||||
});
|
||||
|
||||
it("removeLabel deletes ALL matching addLabel calls for the name", () => {
|
||||
const once = addLabelToScript(SYN_SINGLE, "mid", 1.0);
|
||||
const twice = addLabelToScript(once, "mid", 2.0);
|
||||
const cleared = removeLabelFromScript(twice, "mid");
|
||||
expect(labelCallCount(cleared, "mid")).toBe(0);
|
||||
});
|
||||
|
||||
it("the added label is observable by the parser when a tween references it", () => {
|
||||
// Tween at numeric 0; add a label at 1.0; a follow-up tween positioned at the
|
||||
// label parses without error and is located alongside the others.
|
||||
const withLabel = addLabelToScript(SYN_SINGLE, "beat", 1.0);
|
||||
const parsed = parseGsapScriptAcorn(withLabel);
|
||||
expect(parsed.animations.length).toBe(1);
|
||||
expect(withLabel).toContain('tl.addLabel("beat", 1);');
|
||||
});
|
||||
|
||||
it("returns the script unchanged when there is no timeline to anchor to", () => {
|
||||
const noTl = `console.log("no timeline here");`;
|
||||
expect(addLabelToScript(noTl, "x", 1)).toBe(noTl);
|
||||
expect(removeLabelFromScript(noTl, "x")).toBe(noTl);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user