@@ -577,6 +577,158 @@ class SentryFrameMetricsCollectorTest {
577577 assertEquals(0 , collector.getProperty<Set <Window >>(" trackedWindows" ).size)
578578 }
579579
580+ @Test
581+ fun `getFramesDelay returns -1 when not available` () {
582+ val buildInfo =
583+ mock<BuildInfoProvider > { whenever(it.sdkInfoVersion).thenReturn(Build .VERSION_CODES .M ) }
584+ val collector = fixture.getSut(context, buildInfo)
585+
586+ val result = collector.getFramesDelay(0 , TimeUnit .SECONDS .toNanos(1 ))
587+ assertEquals(- 1.0 , result.delaySeconds)
588+ assertEquals(0 , result.framesContributingToDelayCount)
589+ }
590+
591+ @Test
592+ fun `getFramesDelay returns -1 for invalid time range` () {
593+ val collector = fixture.getSut(context)
594+
595+ val result = collector.getFramesDelay(2000 , 1000 )
596+ assertEquals(- 1.0 , result.delaySeconds)
597+ assertEquals(0 , result.framesContributingToDelayCount)
598+ }
599+
600+ @Test
601+ fun `getFramesDelay returns zero delay when no slow frames recorded` () {
602+ val buildInfo =
603+ mock<BuildInfoProvider > { whenever(it.sdkInfoVersion).thenReturn(Build .VERSION_CODES .O ) }
604+ val collector = fixture.getSut(context, buildInfo)
605+ Shadows .shadowOf(Looper .getMainLooper()).idle()
606+ val listener =
607+ collector.getProperty<Window .OnFrameMetricsAvailableListener >(" frameMetricsAvailableListener" )
608+
609+ collector.startCollection(mock())
610+
611+ // emit a fast frame (21ns cpu time — well under 16ms budget)
612+ listener.onFrameMetricsAvailable(createMockWindow(), createMockFrameMetrics(), 0 )
613+
614+ // choreographer is at end of range so no pending delay
615+ val choreographer = collector.getProperty<Choreographer >(" choreographer" )
616+ choreographer.injectForField(" mLastFrameTimeNanos" , TimeUnit .SECONDS .toNanos(1 ))
617+
618+ val result = collector.getFramesDelay(0 , TimeUnit .SECONDS .toNanos(1 ))
619+ assertEquals(0.0 , result.delaySeconds)
620+ assertEquals(0 , result.framesContributingToDelayCount)
621+ }
622+
623+ @Test
624+ fun `getFramesDelay calculates delay from slow frames` () {
625+ val buildInfo =
626+ mock<BuildInfoProvider > { whenever(it.sdkInfoVersion).thenReturn(Build .VERSION_CODES .O ) }
627+ val collector = fixture.getSut(context, buildInfo)
628+ val listener =
629+ collector.getProperty<Window .OnFrameMetricsAvailableListener >(" frameMetricsAvailableListener" )
630+
631+ collector.startCollection(mock())
632+
633+ // emit a slow frame (~100ms extra = ~116ms total, well over 16ms budget)
634+ listener.onFrameMetricsAvailable(
635+ createMockWindow(),
636+ createMockFrameMetrics(extraCpuDurationNanos = TimeUnit .MILLISECONDS .toNanos(100 )),
637+ 0 ,
638+ )
639+
640+ // emit a frozen frame (~1000ms extra = ~1016ms total, well over 700ms)
641+ listener.onFrameMetricsAvailable(
642+ createMockWindow(),
643+ createMockFrameMetrics(extraCpuDurationNanos = TimeUnit .MILLISECONDS .toNanos(1000 )),
644+ 0 ,
645+ )
646+
647+ // choreographer is at end of range so no pending delay
648+ Shadows .shadowOf(Looper .getMainLooper()).idle()
649+ val choreographer = collector.getProperty<Choreographer >(" choreographer" )
650+ choreographer.injectForField(" mLastFrameTimeNanos" , TimeUnit .SECONDS .toNanos(5 ))
651+
652+ val result = collector.getFramesDelay(0 , TimeUnit .SECONDS .toNanos(5 ))
653+ assertTrue(result.delaySeconds > 0 )
654+ assertEquals(2 , result.framesContributingToDelayCount)
655+ }
656+
657+ @Test
658+ fun `getFramesDelay handles partial frame overlap` () {
659+ val buildInfo =
660+ mock<BuildInfoProvider > { whenever(it.sdkInfoVersion).thenReturn(Build .VERSION_CODES .O ) }
661+ val collector = fixture.getSut(context, buildInfo)
662+ val listener =
663+ collector.getProperty<Window .OnFrameMetricsAvailableListener >(" frameMetricsAvailableListener" )
664+
665+ collector.startCollection(mock())
666+
667+ // emit a frozen frame (~1s)
668+ listener.onFrameMetricsAvailable(
669+ createMockWindow(),
670+ createMockFrameMetrics(extraCpuDurationNanos = TimeUnit .SECONDS .toNanos(1 )),
671+ 0 ,
672+ )
673+
674+ // choreographer is at end of range
675+ Shadows .shadowOf(Looper .getMainLooper()).idle()
676+ val choreographer = collector.getProperty<Choreographer >(" choreographer" )
677+ choreographer.injectForField(" mLastFrameTimeNanos" , TimeUnit .SECONDS .toNanos(5 ))
678+
679+ // The frame's delay interval is roughly [~16ms, ~1000ms].
680+ // Query from 500ms so the range clips the delay interval in half.
681+ val queryStart = TimeUnit .MILLISECONDS .toNanos(500 )
682+ val queryEnd = TimeUnit .SECONDS .toNanos(5 )
683+
684+ val fullResult = collector.getFramesDelay(0 , queryEnd)
685+ val partialResult = collector.getFramesDelay(queryStart, queryEnd)
686+
687+ // partial overlap should yield less delay than the full range
688+ assertTrue(partialResult.delaySeconds > 0 )
689+ assertTrue(partialResult.delaySeconds < fullResult.delaySeconds)
690+ assertEquals(1 , partialResult.framesContributingToDelayCount)
691+ }
692+
693+ @Test
694+ fun `old frames are automatically pruned` () {
695+ val buildInfo =
696+ mock<BuildInfoProvider > { whenever(it.sdkInfoVersion).thenReturn(Build .VERSION_CODES .O ) }
697+ val collector = fixture.getSut(context, buildInfo)
698+ Shadows .shadowOf(Looper .getMainLooper()).idle()
699+ val listener =
700+ collector.getProperty<Window .OnFrameMetricsAvailableListener >(" frameMetricsAvailableListener" )
701+ val choreographer = collector.getProperty<Choreographer >(" choreographer" )
702+
703+ collector.startCollection(mock())
704+
705+ val t0 = TimeUnit .MINUTES .toNanos(10 ) // start at a realistic base time
706+
707+ // emit a slow frame at t0
708+ val frameMetrics1 =
709+ createMockFrameMetrics(extraCpuDurationNanos = TimeUnit .MILLISECONDS .toNanos(100 ))
710+ whenever(frameMetrics1.getMetric(FrameMetrics .INTENDED_VSYNC_TIMESTAMP )).thenReturn(t0)
711+ listener.onFrameMetricsAvailable(createMockWindow(), frameMetrics1, 0 )
712+
713+ choreographer.injectForField(" mLastFrameTimeNanos" , t0 + TimeUnit .SECONDS .toNanos(1 ))
714+
715+ // verify frame exists
716+ val resultBefore = collector.getFramesDelay(t0, t0 + TimeUnit .SECONDS .toNanos(1 ))
717+ assertEquals(1 , resultBefore.framesContributingToDelayCount)
718+
719+ // emit another slow frame >5 minutes later to trigger auto-pruning
720+ val t1 = t0 + TimeUnit .MINUTES .toNanos(6 )
721+ val frameMetrics2 =
722+ createMockFrameMetrics(extraCpuDurationNanos = TimeUnit .MILLISECONDS .toNanos(100 ))
723+ whenever(frameMetrics2.getMetric(FrameMetrics .INTENDED_VSYNC_TIMESTAMP )).thenReturn(t1)
724+ listener.onFrameMetricsAvailable(createMockWindow(), frameMetrics2, 0 )
725+
726+ // the first frame should have been pruned (>5min old)
727+ choreographer.injectForField(" mLastFrameTimeNanos" , t1 + TimeUnit .SECONDS .toNanos(1 ))
728+ val resultAfter = collector.getFramesDelay(t0, t0 + TimeUnit .SECONDS .toNanos(1 ))
729+ assertEquals(0 , resultAfter.framesContributingToDelayCount)
730+ }
731+
580732 private fun createMockWindow (refreshRate : Float = 60F): Window {
581733 val mockWindow = mock<Window >()
582734 val mockDisplay = mock<Display >()
0 commit comments