@@ -285,4 +285,145 @@ describe('Tween System', () => {
285285 )
286286 } )
287287 } )
288+
289+ // Regression coverage for the "retarget mid-travel" false-positive bug: replacing a tween
290+ // (Tween.createOrReplace / setMove et al.) while a previous one is still running, or right
291+ // after it arrived, must never be reported as completed for the *new* tween.
292+ describe ( 'mid-flight retargeting must never be reported as completed' , ( ) => {
293+ it ( 'does not report completed when a tween is replaced while still TS_ACTIVE, even when read in the same frame as the replacement' , async ( ) => {
294+ const testEngine = Engine ( )
295+ const testTweenSystem = createTweenSystem ( testEngine )
296+ const testTween = components . Tween ( testEngine )
297+ const testTweenState = components . TweenState ( testEngine )
298+ const testEntity = testEngine . addEntity ( )
299+ const testMockTween = mockTweenEngine ( testEngine , testTween )
300+
301+ await testMockTween ( testEntity , testTween . Mode . Move ( { start : Vector3 . create ( ) , end : Vector3 . create ( 1 , 1 , 1 ) } ) )
302+ await testEngine . update ( 1 ) // let the cache pick up the first tween
303+
304+ testTweenState . createOrReplace ( testEntity , { state : TweenStateStatus . TS_ACTIVE , currentTime : 0.5 } )
305+ await testEngine . update ( 1 ) // now travelling, mid-flight
306+
307+ let resultOnRetargetFrame : boolean | undefined
308+ testEngine . addSystem ( ( ) => {
309+ if ( resultOnRetargetFrame === undefined ) {
310+ // Replace the tween and read tweenCompleted() in the very same tick, before the
311+ // internal cache-maintenance system (registered at -Infinity) gets a chance to run.
312+ testTween . createOrReplace ( testEntity , {
313+ duration : 1000 ,
314+ easingFunction : EasingFunction . EF_EASEBACK ,
315+ mode : testTween . Mode . Move ( { start : Vector3 . create ( 1 , 1 , 1 ) , end : Vector3 . create ( 2 , 2 , 2 ) } )
316+ } )
317+ resultOnRetargetFrame = testTweenSystem . tweenCompleted ( testEntity )
318+ }
319+ } )
320+
321+ await testEngine . update ( 1 )
322+ expect ( resultOnRetargetFrame ) . toBe ( false )
323+
324+ // And it must stay false on the following frames too, while genuinely mid-flight
325+ expect ( testTweenSystem . tweenCompleted ( testEntity ) ) . toBe ( false )
326+ await testEngine . update ( 1 )
327+ expect ( testTweenSystem . tweenCompleted ( testEntity ) ) . toBe ( false )
328+ } )
329+
330+ it ( 'does not report completed for a tween retargeted right after an arrival (stale TS_COMPLETED), but does report once the new tween genuinely completes' , async ( ) => {
331+ const testEngine = Engine ( )
332+ const testTweenSystem = createTweenSystem ( testEngine )
333+ const testTween = components . Tween ( testEngine )
334+ const testTweenState = components . TweenState ( testEngine )
335+ const testEntity = testEngine . addEntity ( )
336+ const testMockTween = mockTweenEngine ( testEngine , testTween )
337+ const testMockTweenStatus = mockTweenStatusEngine ( testEngine , testTweenState )
338+ const testCompleted = jest . fn ( )
339+ testEngine . addSystem ( ( ) => {
340+ if ( testTweenSystem . tweenCompleted ( testEntity ) ) testCompleted ( )
341+ } )
342+
343+ // First tween genuinely completes
344+ await testMockTween ( testEntity )
345+ await testMockTweenStatus ( testEntity )
346+ expect ( testCompleted ) . toBeCalledTimes ( 1 )
347+
348+ // Retarget while TweenState is still (stale) TS_COMPLETED from the tween we just replaced -
349+ // the renderer's PUT acknowledging the new tween hasn't round-tripped back yet.
350+ testCompleted . mockClear ( )
351+ await testMockTween ( testEntity , testTween . Mode . Move ( { start : Vector3 . create ( ) , end : Vector3 . create ( 5 , 5 , 5 ) } ) )
352+ await testEngine . update ( 1 )
353+ expect ( testCompleted ) . toBeCalledTimes ( 0 )
354+
355+ // Stays uncompleted across several frames of stale TS_COMPLETED (the round trip is "at
356+ // least 2 frames, variable" - a single-frame guard is not enough)
357+ await testEngine . update ( 1 )
358+ expect ( testCompleted ) . toBeCalledTimes ( 0 )
359+ await testEngine . update ( 1 )
360+ expect ( testCompleted ) . toBeCalledTimes ( 0 )
361+
362+ // Renderer catches up and reports the new tween as active
363+ testTweenState . createOrReplace ( testEntity , { state : TweenStateStatus . TS_ACTIVE , currentTime : 0.1 } )
364+ await testEngine . update ( 1 )
365+ expect ( testCompleted ) . toBeCalledTimes ( 0 )
366+
367+ // The new tween genuinely completes
368+ testTweenState . createOrReplace ( testEntity , { state : TweenStateStatus . TS_COMPLETED , currentTime : 1 } )
369+ await testEngine . update ( 1 )
370+ expect ( testCompleted ) . toBeCalledTimes ( 1 )
371+
372+ // And is not reported again on subsequent frames
373+ testCompleted . mockClear ( )
374+ await testEngine . update ( 1 )
375+ expect ( testCompleted ) . toBeCalledTimes ( 0 )
376+ } )
377+
378+ it ( 'does not double-report a sequence-advanced step while the renderer still echoes the previous step TS_COMPLETED' , async ( ) => {
379+ const testEngine = Engine ( )
380+ const testTweenSystem = createTweenSystem ( testEngine )
381+ const testTween = components . Tween ( testEngine )
382+ const testTweenState = components . TweenState ( testEngine )
383+ const testTweenSequence = components . TweenSequence ( testEngine )
384+ const testEntity = testEngine . addEntity ( )
385+ const testMockTween = mockTweenEngine ( testEngine , testTween )
386+ const testCompleted = jest . fn ( )
387+ testEngine . addSystem ( ( ) => {
388+ if ( testTweenSystem . tweenCompleted ( testEntity ) ) testCompleted ( )
389+ } )
390+
391+ const moveTween = await testMockTween (
392+ testEntity ,
393+ testTween . Mode . Move ( { start : Vector3 . create ( ) , end : Vector3 . create ( 1 , 1 , 1 ) } )
394+ )
395+ const rotateTween = {
396+ ...moveTween ,
397+ mode : testTween . Mode . Rotate ( { start : Quaternion . Zero ( ) , end : Quaternion . Identity ( ) } )
398+ }
399+ testTweenSequence . createOrReplace ( testEntity , { sequence : [ rotateTween ] , loop : TweenLoop . TL_RESTART } )
400+
401+ // Settle the cache on the move tween
402+ await testEngine . update ( 1 )
403+ await testEngine . update ( 1 )
404+
405+ // Move tween genuinely completes -> sequence system advances to the rotate tween this same frame
406+ testTweenState . createOrReplace ( testEntity , { state : TweenStateStatus . TS_COMPLETED , currentTime : 1 } )
407+ await testEngine . update ( 1 )
408+ expect ( testCompleted ) . toBeCalledTimes ( 1 )
409+ expect ( testTween . get ( testEntity ) . mode ) . toMatchCloseTo ( rotateTween . mode )
410+
411+ // TweenState is still (stale) TS_COMPLETED, echoing the move tween that just finished. The
412+ // renderer hasn't acknowledged the rotate tween yet - it must not be reported as completed.
413+ testCompleted . mockClear ( )
414+ await testEngine . update ( 1 )
415+ expect ( testCompleted ) . toBeCalledTimes ( 0 )
416+ await testEngine . update ( 1 )
417+ expect ( testCompleted ) . toBeCalledTimes ( 0 )
418+
419+ // Renderer catches up: rotate tween is active, then genuinely completes
420+ testTweenState . createOrReplace ( testEntity , { state : TweenStateStatus . TS_ACTIVE , currentTime : 0.2 } )
421+ await testEngine . update ( 1 )
422+ expect ( testCompleted ) . toBeCalledTimes ( 0 )
423+
424+ testTweenState . createOrReplace ( testEntity , { state : TweenStateStatus . TS_COMPLETED , currentTime : 1 } )
425+ await testEngine . update ( 1 )
426+ expect ( testCompleted ) . toBeCalledTimes ( 1 )
427+ } )
428+ } )
288429} )
0 commit comments