@@ -18,6 +18,7 @@ import {
1818 materializeKeyframesInScript as materializeRecast ,
1919 splitIntoPropertyGroups as splitGroupsRecast ,
2020 splitAnimationsInScript as splitAnimsRecast ,
21+ updateAnimationInScript as updateAnimRecast ,
2122 setArcPathInScript as setArcRecast ,
2223 updateArcSegmentInScript as updateArcSegmentRecast ,
2324 removeArcPathFromScript as removeArcRecast ,
@@ -55,6 +56,7 @@ import {
5556 resizeKeyframedTweenInScript as resizeKeyframedTweenAcorn ,
5657 addAnimationWithKeyframesToScript as addWithKfAcorn ,
5758 removeAnimationFromScript as removeAnimAcorn ,
59+ updateAnimationInScript as updateAnimAcorn ,
5860 shiftPositionsInScript as shiftAcorn ,
5961 scalePositionsInScript as scaleAcorn ,
6062 retimeClipTweensInScript ,
@@ -570,6 +572,150 @@ describe("parity: splitAnimationsInScript (recast vs acorn)", () => {
570572 } ) ;
571573} ) ;
572574
575+ describe ( "splitAnimationsInScript keeps keyframes the parser cannot read" , ( ) => {
576+ const STEP_SHAPES = [
577+ [ "a step with a flag" , "" , "[{ x: 0 }, { x: 100, runBackwards: true }]" ] ,
578+ [ "a spread step" , "const base = { x: 0 };" , "[{ ...base }, { x: 100 }]" ] ,
579+ [ "a named step" , "const last = { x: 100 };" , "[{ x: 0 }, last]" ] ,
580+ [ "keyframes from a call" , "" , "steps()" ] ,
581+ ] as const ;
582+ const opts = { originalId : "a" , newId : "a-2" , elementStart : 0 , elementDuration : 4 } ;
583+ const writers = [
584+ [ "recast" , splitAnimsRecast ] ,
585+ [ "acorn" , splitAnimsAcorn ] ,
586+ ] as const ;
587+
588+ for ( const [ writer , split ] of writers ) {
589+ for ( const [ shape , decl , keyframes ] of STEP_SHAPES ) {
590+ const script = `const tl = gsap.timeline({ paused: true });
591+ ${ decl }
592+ tl.to("#a", { duration: 2, keyframes: ${ keyframes } }, 0);` ;
593+
594+ it ( `${ writer } : leaves ${ shape } spanning the split as authored` , ( ) => {
595+ expect ( parseGsapScriptAcorn ( script ) . animations [ 0 ] ! . hasUnresolvedKeyframes ) . toBe ( true ) ;
596+ const result = split ( script , { ...opts , splitTime : 1 } ) ;
597+ expect ( result . script ) . toContain ( `tl.to("#a", { duration: 2, keyframes: ${ keyframes } }, 0)` ) ;
598+ expect ( result . skippedSelectors ) . toContain ( "#a (keyframes spanning split)" ) ;
599+ } ) ;
600+
601+ it ( `${ writer } : moves ${ shape } after the split whole` , ( ) => {
602+ const result = split ( script , { ...opts , splitTime : 0 } ) ;
603+ expect ( result . script ) . toContain (
604+ `tl.to("#a-2", { duration: 2, keyframes: ${ keyframes } }, 0)` ,
605+ ) ;
606+ } ) ;
607+ }
608+ }
609+ } ) ;
610+
611+ describe ( "writers leave keyframes they cannot read as authored" , ( ) => {
612+ const UNREADABLE = [
613+ [ "a step with a flag" , "[{ x: 0 }, { x: 100, runBackwards: true }]" ] ,
614+ [ "keyframes from a call" , "steps()" ] ,
615+ ] as const ;
616+ const opts = { originalId : "a" , newId : "a-2" , elementStart : 0 , elementDuration : 4 } ;
617+
618+ for ( const [ shape , keyframes ] of UNREADABLE ) {
619+ const script = `const tl = gsap.timeline({ paused: true });
620+ tl.to("#a", { opacity: 1, x: 5, duration: 1, keyframes: ${ keyframes } }, 0);` ;
621+ const id = ( ) => acornId ( script ) ;
622+
623+ for ( const [ writer , split ] of [
624+ [ "recast" , splitAnimsRecast ] ,
625+ [ "acorn" , splitAnimsAcorn ] ,
626+ ] as const ) {
627+ if ( writer === "recast" && keyframes !== "steps()" ) continue ;
628+ it ( `${ writer } : a split after ${ shape } reports that the new clip's start is unknown` , ( ) => {
629+ const result = split ( `${ script } \ntl.to("#a", { duration: 2, x: 200 }, 1);` , {
630+ ...opts ,
631+ splitTime : 2 ,
632+ } ) ;
633+ expect ( result . skippedSelectors ) . toContain ( "#a (unreadable keyframes before split)" ) ;
634+ } ) ;
635+ }
636+
637+ it . each ( [
638+ [ "recast convert to keyframes" , ( ) => convertRecast ( script , id ( ) ) ] ,
639+ [ "acorn convert to keyframes" , ( ) => convertAcorn ( script , id ( ) ) ] ,
640+ [ "recast split into property groups" , ( ) => splitGroupsRecast ( script , id ( ) ) . script ] ,
641+ [ "acorn split into property groups" , ( ) => splitGroupsAcorn ( script , id ( ) ) . script ] ,
642+ [ "acorn add keyframe" , ( ) => addKeyframeAcorn ( script , id ( ) , 50 , { x : 10 } ) ] ,
643+ [ "acorn update keyframe" , ( ) => updateKeyframeAcorn ( script , id ( ) , 100 , { x : 10 } ) ] ,
644+ [ "acorn remove keyframe" , ( ) => removeKeyframeAcorn ( script , id ( ) , 100 ) ] ,
645+ [ "acorn move keyframe" , ( ) => moveKeyframeAcorn ( script , id ( ) , 100 , 80 ) ] ,
646+ [ "acorn resize keyframed tween" , ( ) => resizeKeyframedTweenAcorn ( script , id ( ) , 0 , 2 , [ ] ) ] ,
647+ [
648+ "acorn property edit" ,
649+ ( ) => updateAnimAcorn ( script , id ( ) , { properties : { opacity : 0.5 } } ) ,
650+ ] ,
651+ ] ) ( `%s leaves ${ shape } unchanged` , ( _name , write ) => {
652+ expect ( parseGsapScriptAcorn ( script ) . animations [ 0 ] ! . hasUnresolvedKeyframes ) . toBe ( true ) ;
653+ expect ( write ( ) ) . toBe ( script ) ;
654+ } ) ;
655+ }
656+
657+ it . each ( [ "{ x: v }" , "{ [v]: { x: 1 } }" , "{ ...o }" ] ) (
658+ "acorn add and resize leave object keyframes %s unchanged" ,
659+ ( keyframes ) => {
660+ const script = `const tl = gsap.timeline({ paused: true });
661+ tl.to("#a", { duration: 1, keyframes: ${ keyframes } }, 0);` ;
662+ expect ( parseGsapScriptAcorn ( script ) . animations [ 0 ] ! . hasUnresolvedKeyframes ) . toBe ( true ) ;
663+ expect ( addKeyframeAcorn ( script , acornId ( script ) , 50 , { x : 5 } ) ) . toBe ( script ) ;
664+ expect ( resizeKeyframedTweenAcorn ( script , acornId ( script ) , 0 , 2 , [ ] ) ) . toBe ( script ) ;
665+ } ,
666+ ) ;
667+
668+ it . each ( [
669+ [ "recast" , splitAnimsRecast ] ,
670+ [ "acorn" , splitAnimsAcorn ] ,
671+ ] as const ) ( "%s: a split across unreadable keyframes writes no start value" , ( _writer , split ) => {
672+ const script = `const tl = gsap.timeline({ paused: true });
673+ tl.to("#a", { opacity: 0.5, duration: 2, keyframes: steps() }, 0);` ;
674+ const result = split ( script , { ...opts , splitTime : 1 } ) ;
675+ expect ( result . script ) . not . toContain ( '"#a-2"' ) ;
676+ expect ( result . skippedSelectors ) . toContain ( "#a (keyframes spanning split)" ) ;
677+ } ) ;
678+
679+ it ( "treats keyframes with a computed percentage key as unreadable" , ( ) => {
680+ const script = `const tl = gsap.timeline({ paused: true });
681+ tl.to("#a", { duration: 1, keyframes: { [k]: { x: 10 }, "100%": { x: 50 } } }, 0);` ;
682+ const id = acornId ( script ) ;
683+ expect ( parseGsapScriptAcorn ( script ) . animations [ 0 ] ! . hasUnresolvedKeyframes ) . toBe ( true ) ;
684+ expect ( updateAnimAcorn ( script , id , { properties : { opacity : 0.5 } } ) ) . toBe ( script ) ;
685+ expect ( removeKeyframeAcorn ( script , id , 100 ) ) . toBe ( script ) ;
686+ } ) ;
687+
688+ it . each ( [
689+ [ "a named step" , '{ "0%": { x: 0 }, "100%": last }' ] ,
690+ [ "a spread inside a step" , '{ "0%": { x: 0 }, "100%": { ...base, x: 10 } }' ] ,
691+ [ "a numeric key" , '{ "0%": { x: 0 }, [50]: { x: 5 }, "100%": { x: 10 } }' ] ,
692+ [ "a template key in an array step" , "[{ x: 0 }, { x: 10, [`y`]: 5 }]" ] ,
693+ [ "a variable key in an array step" , "[{ x: 0 }, { x: 10, [k]: 5 }]" ] ,
694+ [ "a getter in a step" , '{ "0%": { x: 0 }, "100%": { get x() { return 10; } } }' ] ,
695+ [ "a method in an array step" , "[{ x: 0 }, { x: 10, y() {} }]" ] ,
696+ [ "an array channel beside % steps" , '{ "0%": { x: 0 }, "100%": { x: 10 }, y: [0, 5] }' ] ,
697+ [ "an unresolved array value" , "{ x: [0, 10, v] }" ] ,
698+ [ "a spread in an array channel" , "{ x: [0, ...rest] }" ] ,
699+ [ "a hole in an array channel" , "{ x: [0, , 10] }" ] ,
700+ ] ) (
701+ "treats keyframes with %s as unreadable, so drags and deletes keep them" ,
702+ ( _shape , keyframes ) => {
703+ const script = `const tl = gsap.timeline({ paused: true });
704+ tl.to("#a", { duration: 2, keyframes: ${ keyframes } }, 0);` ;
705+ const id = acornId ( script ) ;
706+ expect ( parseGsapScriptAcorn ( script ) . animations [ 0 ] ! . hasUnresolvedKeyframes ) . toBe ( true ) ;
707+ expect ( moveKeyframeAcorn ( script , id , 100 , 80 ) ) . toBe ( script ) ;
708+ expect ( removeKeyframeAcorn ( script , id , 0 ) ) . toBe ( script ) ;
709+ } ,
710+ ) ;
711+
712+ it ( "recast property edit leaves keyframes from a call unchanged" , ( ) => {
713+ const script = `const tl = gsap.timeline({ paused: true });
714+ tl.to("#a", { x: 5, duration: 1, keyframes: steps() }, 0);` ;
715+ expect ( updateAnimRecast ( script , acornId ( script ) , { properties : { x : 9 } } ) ) . toBe ( script ) ;
716+ } ) ;
717+ } ) ;
718+
573719// ─── arc path parity ──────────────────────────────────────────────────────────
574720
575721const ARC_FLAT_SCRIPT = `
0 commit comments