Skip to content

Commit 10040e9

Browse files
vanceingallsclaude
andcommitted
test(core): recast-vs-acorn differential suite for GSAP writer ops (+ fixes)
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>
1 parent fcc7b31 commit 10040e9

2 files changed

Lines changed: 796 additions & 20 deletions

File tree

packages/core/src/parsers/gsapWriterAcorn.ts

Lines changed: 150 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,96 @@ function upsertProp(ms: MagicString, objNode: any, key: string, value: unknown):
142142
}
143143
}
144144

145+
/**
146+
* Vars keys that are NOT editable transform/style props: builtins
147+
* (duration/ease/delay), dropped callbacks, and extras (stagger/yoyo/repeat/…).
148+
* The exact union of recast's BUILTIN_VAR_KEYS + DROPPED_VAR_KEYS + EXTRAS_KEYS,
149+
* so both writers classify vars keys identically. (Distinct from the keyframe-
150+
* conversion NON_EDITABLE_VAR_KEYS below, which intentionally omits `ease`
151+
* because that path re-emits ease separately.)
152+
*/
153+
const NON_EDITABLE_PROP_KEYS = new Set([
154+
"duration",
155+
"ease",
156+
"delay",
157+
"onComplete",
158+
"onStart",
159+
"onUpdate",
160+
"onRepeat",
161+
"stagger",
162+
"yoyo",
163+
"repeat",
164+
"repeatDelay",
165+
"snap",
166+
"overwrite",
167+
"immediateRender",
168+
]);
169+
170+
/**
171+
* Editable transform/style key test: anything NOT a builtin, dropped callback, or
172+
* extras key. Mirrors recast's isEditablePropertyKey so both writers classify
173+
* vars keys identically.
174+
*/
175+
function isEditableVarKey(key: string): boolean {
176+
return !NON_EDITABLE_PROP_KEYS.has(key);
177+
}
178+
179+
/**
180+
* Collect verbatim `key: value` entries to PRESERVE from a vars/keyframe
181+
* ObjectExpression: every property whose key `drop` does not reject, sliced from
182+
* source — except keys present in `overrides`, whose value is replaced. Returns
183+
* the entries plus the set of keys it kept, so callers can append new keys.
184+
*/
185+
function preservedEntries(
186+
objNode: any,
187+
source: string,
188+
drop: (key: string) => boolean,
189+
overrides: Record<string, unknown>,
190+
): { entries: string[]; keys: Set<string> } {
191+
const entries: string[] = [];
192+
const keys = new Set<string>();
193+
for (const prop of objNode.properties ?? []) {
194+
if (!isObjectProperty(prop)) continue;
195+
const key = propKeyName(prop);
196+
if (typeof key !== "string" || drop(key)) continue;
197+
keys.add(key);
198+
const code =
199+
key in overrides
200+
? valueToCode(overrides[key])
201+
: source.slice(prop.value.start, prop.value.end);
202+
entries.push(`${safeKey(key)}: ${code}`);
203+
}
204+
return { entries, keys };
205+
}
206+
207+
/**
208+
* Replace the editable-property keys on a vars ObjectExpression with exactly
209+
* `newProps`, leaving non-editable keys (duration/ease/stagger/callbacks/…)
210+
* untouched unless overridden in `nonEditableOverrides`. Mirrors recast's
211+
* reconcileEditableProperties: editable keys absent from `newProps` are DROPPED,
212+
* not merged. Rebuilt in a single ms.overwrite so the splice can never overlap a
213+
* sibling edit — non-editable updates that also target this node (duration/ease/
214+
* extras) are folded into the same rebuild rather than spliced separately.
215+
*/
216+
function reconcileEditableProps(
217+
ms: MagicString,
218+
objNode: any,
219+
source: string,
220+
newProps: Record<string, number | string>,
221+
nonEditableOverrides?: Record<string, unknown>,
222+
): void {
223+
if (objNode?.type !== "ObjectExpression") return;
224+
const overrides = nonEditableOverrides ?? {};
225+
const { entries, keys } = preservedEntries(objNode, source, isEditableVarKey, overrides);
226+
for (const [key, value] of Object.entries(overrides)) {
227+
if (!keys.has(key)) entries.push(`${safeKey(key)}: ${valueToCode(value)}`);
228+
}
229+
for (const [key, value] of Object.entries(newProps)) {
230+
entries.push(`${safeKey(key)}: ${valueToCode(value)}`);
231+
}
232+
ms.overwrite(objNode.start, objNode.end, `{ ${entries.join(", ")} }`);
233+
}
234+
145235
// ── Insertion helpers ─────────────────────────────────────────────────────────
146236

147237
/** Traverse callee.object chain to check if a call ultimately roots at timelineVar. */
@@ -184,24 +274,35 @@ export function updateAnimationInScript(
184274
const ms = new MagicString(script);
185275
const { call }: { call: TweenCallInfo } = target;
186276

187-
if (updates.duration !== undefined) {
188-
upsertProp(ms, call.varsArg, "duration", updates.duration);
189-
}
190-
191-
if (updates.ease !== undefined) {
192-
upsertProp(ms, call.varsArg, "ease", updates.ease);
193-
}
194-
277+
// When `properties` is present we REPLACE the editable set (recast parity:
278+
// editable keys absent from the update are dropped). Fold any concurrent
279+
// non-editable updates (duration/ease/extras) into the single varsArg rebuild
280+
// so their splices can't overlap the rebuild's overwrite of the whole node.
195281
if (updates.properties) {
196-
for (const [key, value] of Object.entries(updates.properties)) {
197-
upsertProp(ms, call.varsArg, key, value);
282+
const overrides: Record<string, unknown> = {};
283+
if (updates.duration !== undefined) overrides.duration = updates.duration;
284+
if (updates.ease !== undefined) overrides.ease = updates.ease;
285+
if (updates.extras) Object.assign(overrides, updates.extras);
286+
reconcileEditableProps(ms, call.varsArg, script, updates.properties, overrides);
287+
} else {
288+
if (updates.duration !== undefined) {
289+
upsertProp(ms, call.varsArg, "duration", updates.duration);
290+
}
291+
if (updates.ease !== undefined) {
292+
upsertProp(ms, call.varsArg, "ease", updates.ease);
293+
}
294+
if (updates.extras) {
295+
for (const [key, value] of Object.entries(updates.extras)) {
296+
upsertProp(ms, call.varsArg, key, value);
297+
}
198298
}
199299
}
200300

201301
if (updates.fromProperties && call.method === "fromTo" && call.fromArg) {
202-
for (const [key, value] of Object.entries(updates.fromProperties)) {
203-
upsertProp(ms, call.fromArg, key, value);
204-
}
302+
// fromTo's from-vars carry only editable props — REPLACE them too (recast
303+
// parity). fromArg is a distinct node from varsArg, so this rebuild never
304+
// overlaps the varsArg edits above.
305+
reconcileEditableProps(ms, call.fromArg, script, updates.fromProperties);
205306
}
206307

207308
if (updates.position !== undefined) {
@@ -214,12 +315,6 @@ export function updateAnimationInScript(
214315
}
215316
}
216317

217-
if (updates.extras) {
218-
for (const [key, value] of Object.entries(updates.extras)) {
219-
upsertProp(ms, call.varsArg, key, value);
220-
}
221-
}
222-
223318
return ms.toString();
224319
}
225320

@@ -715,6 +810,29 @@ function insertNewKeyframe(
715810
}
716811
}
717812

813+
/**
814+
* Rebuild a vars ObjectExpression that has just dropped below two keyframes,
815+
* collapsing `keyframes: {…}` back to a flat tween. Mirrors recast's
816+
* collapseKeyframesToFlat: drop the `keyframes` + `easeEach` keys, preserve every
817+
* other vars key verbatim, and splice the remaining keyframe's properties (minus
818+
* its per-keyframe `ease`) in as flat vars keys. Single ms.overwrite of the whole
819+
* vars node so the splice can't overlap the keyframe removal.
820+
*/
821+
function collapseKeyframesToFlat(
822+
ms: MagicString,
823+
varsNode: any,
824+
source: string,
825+
remainingRecord: Record<string, number | string>,
826+
): void {
827+
if (varsNode?.type !== "ObjectExpression") return;
828+
const dropKeyframeKeys = (key: string) => key === "keyframes" || key === "easeEach";
829+
const { entries } = preservedEntries(varsNode, source, dropKeyframeKeys, {});
830+
for (const [k, v] of Object.entries(remainingRecord)) {
831+
if (k !== "ease") entries.push(`${safeKey(k)}: ${valueToCode(v)}`);
832+
}
833+
ms.overwrite(varsNode.start, varsNode.end, `{ ${entries.join(", ")} }`);
834+
}
835+
718836
export function removeKeyframeFromScript(
719837
script: string,
720838
animationId: string,
@@ -732,8 +850,20 @@ export function removeKeyframeFromScript(
732850
const match = findKfPropByPct(kfNode, percentage);
733851
if (!match) return script;
734852

735-
const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p));
736853
const ms = new MagicString(script);
854+
855+
// If removing this keyframe leaves fewer than two, collapse the keyframes
856+
// object back to a flat tween (recast parity) instead of leaving a lone
857+
// keyframe. We rebuild the whole vars node, so we never also splice the kf
858+
// node — the two edits would overlap.
859+
const remaining = percentagePropsOf(kfNode).filter((p) => p !== match.prop);
860+
if (remaining.length < 2) {
861+
const record = remaining.length === 1 ? valueNodeToRecord(remaining[0]!.value, script) : {};
862+
collapseKeyframesToFlat(ms, target.call.varsArg, script, record);
863+
return ms.toString();
864+
}
865+
866+
const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p));
737867
removeProp(ms, match.prop, allProps);
738868
return ms.toString();
739869
}

0 commit comments

Comments
 (0)