@@ -3,6 +3,8 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
33import type { DomEditSelection } from "../components/editor/domEditingTypes" ;
44import {
55 commitGsapPositionFromDrag ,
6+ commitStaticGsapPosition ,
7+ commitStaticGsapRotation ,
68 parkPlayheadOnKeyframe ,
79 type GsapDragCommitCallbacks ,
810} from "./gsapDragCommit" ;
@@ -235,6 +237,142 @@ describe("commitGsapPositionFromDrag — from() tween dragged outside its range"
235237 } ) ;
236238} ) ;
237239
240+ // Captures the OPTIONS each commit carries (not just the mutation) so we can
241+ // assert which value-only commits attach the `instantPatch` fast path.
242+ type RecordedCommit = { mutation : Record < string , unknown > ; options : Record < string , unknown > } ;
243+ function optionRecordingCallbacks ( ) : {
244+ commits : RecordedCommit [ ] ;
245+ callbacks : GsapDragCommitCallbacks ;
246+ } {
247+ const commits : RecordedCommit [ ] = [ ] ;
248+ return {
249+ commits,
250+ callbacks : {
251+ commitMutation : async ( _sel , mutation , options ) => {
252+ commits . push ( { mutation, options : options as Record < string , unknown > } ) ;
253+ } ,
254+ fetchAnimations : async ( ) => [ convertedTween ( ) ] ,
255+ } ,
256+ } ;
257+ }
258+
259+ const existingPositionSet = ( ) : GsapAnimation =>
260+ ( {
261+ id : "#puck-a-set" ,
262+ targetSelector : "#puck-a" ,
263+ method : "set" ,
264+ properties : { x : 10 , y : 20 } ,
265+ } ) as unknown as GsapAnimation ;
266+
267+ const existingRotationSet = ( ) : GsapAnimation =>
268+ ( {
269+ id : "#puck-a-rot-set" ,
270+ targetSelector : "#puck-a" ,
271+ method : "set" ,
272+ properties : { rotation : 15 } ,
273+ } ) as unknown as GsapAnimation ;
274+
275+ describe ( "commitStaticGsapPosition — instantPatch (value-only set)" , ( ) => {
276+ beforeEach ( ( ) => usePlayerStore . setState ( { currentTime : 0 , activeKeyframePct : null } ) ) ;
277+
278+ it ( "attaches instantPatch {kind:set, props:{x,y}} to the FINAL coalesced commit only" , async ( ) => {
279+ const { commits, callbacks } = optionRecordingCallbacks ( ) ;
280+
281+ await commitStaticGsapPosition (
282+ selection ( ) ,
283+ { x : - 50 , y : 30 } , // studioOffset → newX/newY off a zero base
284+ { x : 0 , y : 0 } ,
285+ "#puck-a" ,
286+ existingPositionSet ( ) ,
287+ callbacks ,
288+ ) ;
289+
290+ expect ( commits ) . toHaveLength ( 2 ) ;
291+ // First (x) commit is the intermediate skipReload one — NO instantPatch.
292+ expect ( commits [ 0 ] . options . skipReload ) . toBe ( true ) ;
293+ expect ( commits [ 0 ] . options . instantPatch ) . toBeUndefined ( ) ;
294+ // Final (y) commit triggers the reload and carries the full {x,y} patch.
295+ expect ( commits [ 1 ] . options . softReload ) . toBe ( true ) ;
296+ expect ( commits [ 1 ] . options . instantPatch ) . toEqual ( {
297+ selector : "#puck-a" ,
298+ change : { kind : "set" , props : { x : - 50 , y : 30 } } ,
299+ } ) ;
300+ } ) ;
301+
302+ it ( "does NOT attach instantPatch when ADDING a new set (structural — new tween)" , async ( ) => {
303+ const { commits, callbacks } = optionRecordingCallbacks ( ) ;
304+
305+ await commitStaticGsapPosition (
306+ selection ( ) ,
307+ { x : - 50 , y : 30 } ,
308+ { x : 0 , y : 0 } ,
309+ "#puck-a" ,
310+ null , // no existing set → `add` a new tween
311+ callbacks ,
312+ ) ;
313+
314+ expect ( commits ) . toHaveLength ( 1 ) ;
315+ expect ( commits [ 0 ] . mutation . type ) . toBe ( "add" ) ;
316+ expect ( commits [ 0 ] . options . instantPatch ) . toBeUndefined ( ) ;
317+ } ) ;
318+ } ) ;
319+
320+ describe ( "commitStaticGsapRotation — instantPatch (value-only set)" , ( ) => {
321+ beforeEach ( ( ) => usePlayerStore . setState ( { currentTime : 0 , activeKeyframePct : null } ) ) ;
322+
323+ it ( "attaches instantPatch {kind:set, props:{rotation}} when updating an existing rotation set" , async ( ) => {
324+ const { commits, callbacks } = optionRecordingCallbacks ( ) ;
325+
326+ await commitStaticGsapRotation ( selection ( ) , 42 , "#puck-a" , existingRotationSet ( ) , callbacks ) ;
327+
328+ expect ( commits ) . toHaveLength ( 1 ) ;
329+ expect ( commits [ 0 ] . mutation . type ) . toBe ( "update-property" ) ;
330+ expect ( commits [ 0 ] . options . instantPatch ) . toEqual ( {
331+ selector : "#puck-a" ,
332+ change : { kind : "set" , props : { rotation : 42 } } ,
333+ } ) ;
334+ } ) ;
335+
336+ it ( "does NOT attach instantPatch when ADDING a new rotation set (structural)" , async ( ) => {
337+ const { commits, callbacks } = optionRecordingCallbacks ( ) ;
338+
339+ await commitStaticGsapRotation ( selection ( ) , 42 , "#puck-a" , null , callbacks ) ;
340+
341+ expect ( commits ) . toHaveLength ( 1 ) ;
342+ expect ( commits [ 0 ] . mutation . type ) . toBe ( "add" ) ;
343+ expect ( commits [ 0 ] . options . instantPatch ) . toBeUndefined ( ) ;
344+ } ) ;
345+ } ) ;
346+
347+ describe ( "commitGsapPositionFromDrag — keyframe/structural commits omit instantPatch" , ( ) => {
348+ beforeEach ( ( ) => usePlayerStore . setState ( { currentTime : 0 , activeKeyframePct : null } ) ) ;
349+
350+ it ( "a structural keyframe drag (convert-to-keyframes → add-keyframe) sets no instantPatch" , async ( ) => {
351+ usePlayerStore . setState ( { currentTime : 2 } ) ; // inside [1.2, 3.4] → convert + add-keyframe
352+ const { commits, callbacks } = optionRecordingCallbacks ( ) ;
353+
354+ await commitGsapPositionFromDrag (
355+ selection ( ) ,
356+ flatTween ( ) ,
357+ { x : - 100 , y : 0 } ,
358+ { x : 0 , y : 0 } ,
359+ null ,
360+ "#puck-a" ,
361+ callbacks ,
362+ ) ;
363+
364+ // The keyframe path is structural here (convert + add-keyframe) and must rely
365+ // on the soft reload — none of its commits opt into the instant patch.
366+ expect ( commits . length ) . toBeGreaterThan ( 0 ) ;
367+ for ( const c of commits ) {
368+ expect ( c . options . instantPatch ) . toBeUndefined ( ) ;
369+ }
370+ const types = commits . map ( ( c ) => c . mutation . type ) ;
371+ expect ( types ) . toContain ( "convert-to-keyframes" ) ;
372+ expect ( types ) . toContain ( "add-keyframe" ) ;
373+ } ) ;
374+ } ) ;
375+
238376describe ( "parkPlayheadOnKeyframe" , ( ) => {
239377 beforeEach ( ( ) => usePlayerStore . setState ( { requestedSeekTime : null } ) ) ;
240378
0 commit comments