diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h index 9cbb868d799..243682cf508 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h @@ -37,13 +37,16 @@ FOUNDATION_EXTERN NSString *const kFPRSlowFrameCounterName; /** Counter name for total frames. */ FOUNDATION_EXTERN NSString *const kFPRTotalFramesCounterName; -/** Slow frame threshold (for time difference between current and previous frame render time) - * in sec. +/** Legacy slow frame threshold constant (formerly 1/59). + * NOTE: This constant is deprecated and maintained only for test compatibility. + * The actual slow frame detection uses a cached value computed from + * UIScreen.maximumFramesPerSecond (slow threshold = 1000 / maxFPS ms). + * New code should not rely on this value. */ FOUNDATION_EXTERN CFTimeInterval const kFPRSlowFrameThreshold; /** Frozen frame threshold (for time difference between current and previous frame render time) - * in sec. + * in sec. Frozen threshold = 700 ms (>700 ms). */ FOUNDATION_EXTERN CFTimeInterval const kFPRFrozenFrameThreshold; @@ -81,6 +84,12 @@ FOUNDATION_EXTERN CFTimeInterval const kFPRFrozenFrameThreshold; /** The slow frames counter. */ @property(atomic) int_fast64_t slowFramesCount; +/** The previous frame timestamp from the display link. Used to calculate frame duration. */ +@property(nonatomic) CFAbsoluteTime previousTimestamp; + +/** Refreshes the cached maximum FPS and slow frame budget from UIScreen. */ +- (void)fpr_refreshFrameRateCache; + /** Handles the appDidBecomeActive notification. Restores the screen traces that were active before * the app was backgrounded. * diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index 5137776fb5f..bc3470cc15a 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -25,15 +25,30 @@ NSString *const kFPRSlowFrameCounterName = @"_fr_slo"; NSString *const kFPRTotalFramesCounterName = @"_fr_tot"; -// Note: This was previously 60 FPS, but that resulted in 90% + of all frames collected to be -// flagged as slow frames, and so the threshold for iOS is being changed to 59 FPS. +// Note: The slow frame threshold is now dynamically computed based on UIScreen's +// maximumFramesPerSecond to align with device capabilities (ProMotion, tvOS, etc.). +// Slow threshold = 1000 / UIScreen.maximumFramesPerSecond ms (or 1.0 / maxFPS seconds). +// For devices reporting 60 FPS, the threshold is approximately 16.67ms (1/60). +// The threshold is cached and refreshed when the app becomes active. // TODO(b/73498642): Make these configurable. -CFTimeInterval const kFPRSlowFrameThreshold = 1.0 / 59.0; // Anything less than 59 FPS is slow. + +// Legacy constant maintained for test compatibility. The actual slow frame detection +// uses a cached value computed from UIScreen.maximumFramesPerSecond. +CFTimeInterval const kFPRSlowFrameThreshold = 1.0 / 60.0; + CFTimeInterval const kFPRFrozenFrameThreshold = 700.0 / 1000.0; /** Constant that indicates an invalid time. */ CFAbsoluteTime const kFPRInvalidTime = -1.0; +/** Returns the maximum frames per second supported by the device's main screen. + * Falls back to 60 if the value is unavailable or invalid. + */ +static inline NSInteger FPRMaxFPS(void) { + NSInteger maxFPS = [UIScreen mainScreen].maximumFramesPerSecond; + return maxFPS > 0 ? maxFPS : 60; +} + /** Returns the class name without the prefixed module name present in Swift classes * (e.g. MyModule.MyViewController -> MyViewController). */ @@ -71,6 +86,19 @@ } } +@interface FPRScreenTraceTracker () + +// fpr_cachedMaxFPS and fpr_cachedSlowBudget are initialized at startup. +// We update them only on tvOS during appDidBecomeActive because the output +// mode can change with user settings. iOS ProMotion dynamic changes are +// intentionally left for a future follow-up (see TODO in the notification +// handler). +@property(nonatomic) NSInteger fpr_cachedMaxFPS; + +@property(nonatomic) CFTimeInterval fpr_cachedSlowBudget; + +@end + @implementation FPRScreenTraceTracker { /** Instance variable storing the total frames observed so far. */ atomic_int_fast64_t _totalFramesCount; @@ -112,6 +140,7 @@ - (instancetype)init { atomic_store_explicit(&_totalFramesCount, 0, memory_order_relaxed); atomic_store_explicit(&_frozenFramesCount, 0, memory_order_relaxed); atomic_store_explicit(&_slowFramesCount, 0, memory_order_relaxed); + _previousTimestamp = kFPRInvalidTime; _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkStep)]; [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; @@ -126,6 +155,17 @@ - (instancetype)init { selector:@selector(appWillResignActiveNotification:) name:UIApplicationWillResignActiveNotification object:[UIApplication sharedApplication]]; + + // Initialize cached FPS and slow budget + NSInteger __fps = UIScreen.mainScreen.maximumFramesPerSecond ?: 60; +#if TARGET_OS_TV + // tvOS may report 59 for ~60Hz outputs. Normalize to 60. + if (__fps == 59) { + __fps = 60; + } +#endif + self.fpr_cachedMaxFPS = __fps; + self.fpr_cachedSlowBudget = 1.0 / (double)__fps; } return self; } @@ -142,6 +182,20 @@ - (void)dealloc { } - (void)appDidBecomeActiveNotification:(NSNotification *)notification { +#if TARGET_OS_TV + NSInteger __fps = UIScreen.mainScreen.maximumFramesPerSecond ?: 60; + if (__fps == 59) { + __fps = 60; // normalize tvOS 59 -> 60 + } + if (__fps != self.fpr_cachedMaxFPS) { + self.fpr_cachedMaxFPS = __fps; + self.fpr_cachedSlowBudget = 1.0 / (double)__fps; + } +#else + // TODO: Support dynamic ProMotion changes on iOS in a future follow-up. + // For now, do not refresh here to avoid incorrect assumptions about timing. +#endif + // To get the most accurate numbers of total, frozen and slow frames, we need to capture them as // soon as we're notified of an event. int64_t currentTotalFrames = atomic_load_explicit(&_totalFramesCount, memory_order_relaxed); @@ -186,11 +240,11 @@ - (void)appWillResignActiveNotification:(NSNotification *)notification { #pragma mark - Frozen, slow and good frames - (void)displayLinkStep { - static CFAbsoluteTime previousTimestamp = kFPRInvalidTime; CFAbsoluteTime currentTimestamp = self.displayLink.timestamp; - RecordFrameType(currentTimestamp, previousTimestamp, &_slowFramesCount, &_frozenFramesCount, - &_totalFramesCount); - previousTimestamp = currentTimestamp; + CFTimeInterval slowBudget = self.fpr_cachedSlowBudget; + RecordFrameType(currentTimestamp, self.previousTimestamp, slowBudget, &_slowFramesCount, + &_frozenFramesCount, &_totalFramesCount); + self.previousTimestamp = currentTimestamp; } /** This function increments the relevant frame counters based on the current and previous @@ -198,6 +252,7 @@ - (void)displayLinkStep { * * @param currentTimestamp The current timestamp of the displayLink. * @param previousTimestamp The previous timestamp of the displayLink. + * @param slowBudget The cached slow frame budget in seconds (1.0 / maxFPS). * @param slowFramesCounter The value of the slowFramesCount before this function was called. * @param frozenFramesCounter The value of the frozenFramesCount before this function was called. * @param totalFramesCounter The value of the totalFramesCount before this function was called. @@ -205,6 +260,7 @@ - (void)displayLinkStep { FOUNDATION_STATIC_INLINE void RecordFrameType(CFAbsoluteTime currentTimestamp, CFAbsoluteTime previousTimestamp, + CFTimeInterval slowBudget, atomic_int_fast64_t *slowFramesCounter, atomic_int_fast64_t *frozenFramesCounter, atomic_int_fast64_t *totalFramesCounter) { @@ -212,7 +268,7 @@ void RecordFrameType(CFAbsoluteTime currentTimestamp, if (previousTimestamp == kFPRInvalidTime) { return; } - if (frameDuration > kFPRSlowFrameThreshold) { + if (frameDuration > slowBudget) { atomic_fetch_add_explicit(slowFramesCounter, 1, memory_order_relaxed); } if (frameDuration > kFPRFrozenFrameThreshold) { diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 2f5cbf40c61..9308f337f4c 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -68,6 +68,32 @@ @interface FPRTestPageViewController : UIPageViewController @implementation FPRTestPageViewController @end +#pragma mark - Test Swizzling Infrastructure + +/** Associated object key for test FPS override. */ +static const void *kFPRTestMaxFPSKey = &kFPRTestMaxFPSKey; + +/** Original IMP for -[UIScreen maximumFramesPerSecond]. */ +static IMP gOriginal_maximumFramesPerSecond = NULL; + +/** Swizzled implementation of -[UIScreen maximumFramesPerSecond]. */ +static NSInteger FPRSwizzled_maximumFramesPerSecond(id self, SEL _cmd) { + NSNumber *override = objc_getAssociatedObject(self, kFPRTestMaxFPSKey); + if (override) { + return [override integerValue]; + } + + // Call the original implementation if available + if (gOriginal_maximumFramesPerSecond) { + return ((NSInteger (*)(id, SEL))gOriginal_maximumFramesPerSecond)(self, _cmd); + } + + // Fallback to 60 FPS if original implementation is unavailable + return 60; +} + +#pragma mark - Test Class + @interface FPRScreenTraceTrackerTest : FPRTestCase /** The FPRScreenTraceTracker instance that's being used for a given test. */ @@ -80,9 +106,46 @@ @interface FPRScreenTraceTrackerTest : FPRTestCase @implementation FPRScreenTraceTrackerTest ++ (void)setUp { + [super setUp]; + + // Perform one-time swizzle of UIScreen.maximumFramesPerSecond + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + Class screenClass = [UIScreen class]; + SEL originalSelector = @selector(maximumFramesPerSecond); + Method originalMethod = class_getInstanceMethod(screenClass, originalSelector); + + // class_replaceMethod returns the original IMP, which we store for later use + gOriginal_maximumFramesPerSecond = + class_replaceMethod(screenClass, originalSelector, (IMP)FPRSwizzled_maximumFramesPerSecond, + method_getTypeEncoding(originalMethod)); + }); +} + ++ (void)tearDown { + [super tearDown]; + + // Restore original implementation to prevent test pollution + if (gOriginal_maximumFramesPerSecond) { + Class screenClass = [UIScreen class]; + SEL originalSelector = @selector(maximumFramesPerSecond); + Method originalMethod = class_getInstanceMethod(screenClass, originalSelector); + if (originalMethod) { + class_replaceMethod(screenClass, originalSelector, gOriginal_maximumFramesPerSecond, + method_getTypeEncoding(originalMethod)); + gOriginal_maximumFramesPerSecond = NULL; + } + } +} + - (void)setUp { [super setUp]; + // Clear any existing override before test + objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + FIRPerformance *performance = [FIRPerformance sharedInstance]; [performance setDataCollectionEnabled:YES]; self.tracker = [[FPRScreenTraceTracker alloc] init]; @@ -93,12 +156,21 @@ - (void)setUp { - (void)tearDown { [super tearDown]; + objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + FIRPerformance *performance = [FIRPerformance sharedInstance]; [performance setDataCollectionEnabled:NO]; self.tracker = nil; self.dispatchGroupToWaitOn = nil; } +/** Helper method to set the test FPS override for the current test. */ +- (void)setTestMaxFPSOverride:(NSInteger)fps { + objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, @(fps), + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + /** Tests that shared instance returns the same instance. */ - (void)testSingleton { FPRScreenTraceTracker *trackerOne = [FPRScreenTraceTracker sharedInstance]; @@ -603,47 +675,87 @@ - (void)testAppDidBecomeActiveWillNotRestoreTracesOfNilledViewControllers { * slow frame counter of the screen trace tracker is incremented. */ - (void)testSlowFrameIsRecorded { + // Set FPS to 60 to match kFPRSlowFrameThreshold constant (1/60) + [self setTestMaxFPSOverride:60]; + FPRScreenTraceTracker *tracker = [[FPRScreenTraceTracker alloc] init]; + tracker.displayLink.paused = YES; + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + kFPRSlowFrameThreshold + 0.005; // Buffer for float comparison. id displayLinkMock = OCMClassMock([CADisplayLink class]); - [self.tracker.displayLink invalidate]; - self.tracker.displayLink = displayLinkMock; + [tracker.displayLink invalidate]; + tracker.displayLink = displayLinkMock; // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); - [self.tracker displayLinkStep]; - int64_t initialSlowFramesCount = self.tracker.slowFramesCount; + [tracker displayLinkStep]; + int64_t initialSlowFramesCount = tracker.slowFramesCount; OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); - [self.tracker displayLinkStep]; + [tracker displayLinkStep]; - int64_t newSlowFramesCount = self.tracker.slowFramesCount; + int64_t newSlowFramesCount = tracker.slowFramesCount; XCTAssertEqual(newSlowFramesCount, initialSlowFramesCount + 1); } +#if TARGET_OS_TV +- (void)testTvOS59FpsIsTreatedAs60AndSlowFrameIsRecorded { + // Stub tvOS max FPS to 59, which should be normalized to 60. + [self setTestMaxFPSOverride:59]; + + // Create a new tracker so it initializes with the overridden FPS value. + FPRScreenTraceTracker *tracker = [[FPRScreenTraceTracker alloc] init]; + tracker.displayLink.paused = YES; + + CFAbsoluteTime first = 1.0; + // 17ms is slower than 1/60 (~16.67ms) and should count as slow. + CFAbsoluteTime second = first + 0.017; + + id displayLinkMock = OCMClassMock([CADisplayLink class]); + [tracker.displayLink invalidate]; + tracker.displayLink = displayLinkMock; + + // Prime previous timestamp. + OCMExpect([displayLinkMock timestamp]).andReturn(first); + [tracker displayLinkStep]; + int64_t initialSlow = tracker.slowFramesCount; + + // Emit a slow frame at ~17ms. + OCMExpect([displayLinkMock timestamp]).andReturn(second); + [tracker displayLinkStep]; + + XCTAssertEqual(tracker.slowFramesCount, initialSlow + 1); +} +#endif + /** Tests that the slow and frozen frame counter is not incremented in the case of a good frame. */ - (void)testSlowAndFrozenFrameIsNotRecordedInCaseOfGoodFrame { + // Set FPS to 60 to match kFPRSlowFrameThreshold constant (1/60) + [self setTestMaxFPSOverride:60]; + FPRScreenTraceTracker *tracker = [[FPRScreenTraceTracker alloc] init]; + tracker.displayLink.paused = YES; + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + kFPRSlowFrameThreshold - 0.005; // Good frame. id displayLinkMock = OCMClassMock([CADisplayLink class]); - [self.tracker.displayLink invalidate]; - self.tracker.displayLink = displayLinkMock; + [tracker.displayLink invalidate]; + tracker.displayLink = displayLinkMock; // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); - [self.tracker displayLinkStep]; - int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount; - int64_t initialSlowFramesCount = self.tracker.slowFramesCount; + [tracker displayLinkStep]; + int64_t initialFrozenFramesCount = tracker.frozenFramesCount; + int64_t initialSlowFramesCount = tracker.slowFramesCount; OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); - [self.tracker displayLinkStep]; + [tracker displayLinkStep]; - int64_t newSlowFramesCount = self.tracker.slowFramesCount; - int64_t newFrozenFramesCount = self.tracker.frozenFramesCount; + int64_t newSlowFramesCount = tracker.slowFramesCount; + int64_t newFrozenFramesCount = tracker.frozenFramesCount; XCTAssertEqual(newSlowFramesCount, initialSlowFramesCount); XCTAssertEqual(newFrozenFramesCount, initialFrozenFramesCount); @@ -651,23 +763,28 @@ - (void)testSlowAndFrozenFrameIsNotRecordedInCaseOfGoodFrame { /* Tests that the frozen frame counter is not incremented in case of a slow frame. */ - (void)testFrozenFrameIsNotRecordedInCaseOfSlowFrame { + // Set FPS to 60 to match kFPRSlowFrameThreshold constant (1/60) + [self setTestMaxFPSOverride:60]; + FPRScreenTraceTracker *tracker = [[FPRScreenTraceTracker alloc] init]; + tracker.displayLink.paused = YES; + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + kFPRSlowFrameThreshold + 0.005; // Slow frame. id displayLinkMock = OCMClassMock([CADisplayLink class]); - [self.tracker.displayLink invalidate]; - self.tracker.displayLink = displayLinkMock; + [tracker.displayLink invalidate]; + tracker.displayLink = displayLinkMock; // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); - [self.tracker displayLinkStep]; - int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount; + [tracker displayLinkStep]; + int64_t initialFrozenFramesCount = tracker.frozenFramesCount; OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); - [self.tracker displayLinkStep]; + [tracker displayLinkStep]; - int64_t newFrozenFramesCount = self.tracker.frozenFramesCount; + int64_t newFrozenFramesCount = tracker.frozenFramesCount; XCTAssertEqual(newFrozenFramesCount, initialFrozenFramesCount); } @@ -675,6 +792,11 @@ - (void)testFrozenFrameIsNotRecordedInCaseOfSlowFrame { * frames. */ - (void)testTotalFramesAreAlwaysRecorded { + // Set FPS to 60 to match kFPRSlowFrameThreshold constant (1/60) + [self setTestMaxFPSOverride:60]; + FPRScreenTraceTracker *tracker = [[FPRScreenTraceTracker alloc] init]; + tracker.displayLink.paused = YES; + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + kFPRSlowFrameThreshold - 0.005; // Good frame. @@ -684,27 +806,27 @@ - (void)testTotalFramesAreAlwaysRecorded { thirdFrameRenderTimestamp + kFPRFrozenFrameThreshold + 0.005; // Frozen frame. id displayLinkMock = OCMClassMock([CADisplayLink class]); - [self.tracker.displayLink invalidate]; - self.tracker.displayLink = displayLinkMock; + [tracker.displayLink invalidate]; + tracker.displayLink = displayLinkMock; // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); - [self.tracker displayLinkStep]; - int64_t initialTotalFramesCount = self.tracker.totalFramesCount; + [tracker displayLinkStep]; + int64_t initialTotalFramesCount = tracker.totalFramesCount; OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); - [self.tracker displayLinkStep]; - int64_t newTotalFramesCount = self.tracker.totalFramesCount; + [tracker displayLinkStep]; + int64_t newTotalFramesCount = tracker.totalFramesCount; XCTAssertEqual(newTotalFramesCount, initialTotalFramesCount + 1); OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); - [self.tracker displayLinkStep]; - newTotalFramesCount = self.tracker.totalFramesCount; + [tracker displayLinkStep]; + newTotalFramesCount = tracker.totalFramesCount; XCTAssertEqual(newTotalFramesCount, initialTotalFramesCount + 2); OCMExpect([displayLinkMock timestamp]).andReturn(fourthFrameRenderTimestamp); - [self.tracker displayLinkStep]; - newTotalFramesCount = self.tracker.totalFramesCount; + [tracker displayLinkStep]; + newTotalFramesCount = tracker.totalFramesCount; XCTAssertEqual(newTotalFramesCount, initialTotalFramesCount + 3); } @@ -901,4 +1023,102 @@ + (NSString *)expectedTraceNameForViewController:(UIViewController *)viewControl return [@"_st_" stringByAppendingString:NSStringFromClass([viewController class])]; } +#pragma mark - Dynamic FPS Threshold Tests + +/** Structure for table-driven FPS test cases. */ +typedef struct { + NSInteger fps; + CFTimeInterval slowCandidate; // seconds > budget should be slow + CFTimeInterval fastCandidate; // seconds < budget should NOT be slow +} FPRFpsCase; + +/** Helper to run a single FPS test case with the given tracker and expectations. */ +static inline void FPRRunFpsTestCase(FPRScreenTraceTrackerTest *testCase, FPRFpsCase fpsCase) { + [testCase setTestMaxFPSOverride:fpsCase.fps]; + // Create a new tracker so it initializes with the overridden FPS value. + // This works on both iOS and tvOS since initialization always reads maxFPS. + FPRScreenTraceTracker *tracker = [[FPRScreenTraceTracker alloc] init]; + tracker.displayLink.paused = YES; + testCase.tracker = tracker; + + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; + // Frame that should be classified as slow (exceeds budget) + CFAbsoluteTime slowFrameTimestamp = firstFrameRenderTimestamp + fpsCase.slowCandidate; + // Frame that should NOT be classified as slow (within budget) + CFAbsoluteTime fastFrameTimestamp = slowFrameTimestamp + fpsCase.fastCandidate; + // Frame that should be classified as frozen (always 701ms) + CFAbsoluteTime frozenFrameTimestamp = fastFrameTimestamp + 0.701; + // Frame that should NOT be classified as frozen (always 699ms) + CFAbsoluteTime notFrozenFrameTimestamp = frozenFrameTimestamp + 0.699; + + id displayLinkMock = OCMClassMock([CADisplayLink class]); + [tracker.displayLink invalidate]; + tracker.displayLink = displayLinkMock; + + // Process an initial frame to set the starting timestamp for duration calculations. + OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + [tracker displayLinkStep]; + int64_t initialSlowFramesCount = tracker.slowFramesCount; + int64_t initialFrozenFramesCount = tracker.frozenFramesCount; + int64_t initialTotalFramesCount = tracker.totalFramesCount; + + // Test slow frame (should be classified as slow, not frozen) + OCMExpect([displayLinkMock timestamp]).andReturn(slowFrameTimestamp); + [tracker displayLinkStep]; + XCTAssertEqual(tracker.slowFramesCount, initialSlowFramesCount + 1, + @"Frame duration %.3fms should be slow at %ld FPS", fpsCase.slowCandidate * 1000.0, + (long)fpsCase.fps); + XCTAssertEqual(tracker.frozenFramesCount, initialFrozenFramesCount, + @"Frame duration %.3fms should not be frozen", fpsCase.slowCandidate * 1000.0); + XCTAssertEqual(tracker.totalFramesCount, initialTotalFramesCount + 1, + @"Total frames should increment after a slow frame."); + + // Test fast frame (should NOT be classified as slow or frozen) + OCMExpect([displayLinkMock timestamp]).andReturn(fastFrameTimestamp); + [tracker displayLinkStep]; + XCTAssertEqual(tracker.slowFramesCount, initialSlowFramesCount + 1, + @"Frame duration %.3fms should NOT be slow at %ld FPS", + fpsCase.fastCandidate * 1000.0, (long)fpsCase.fps); + XCTAssertEqual(tracker.frozenFramesCount, initialFrozenFramesCount, + @"Frame duration %.3fms should not be frozen", fpsCase.fastCandidate * 1000.0); + XCTAssertEqual(tracker.totalFramesCount, initialTotalFramesCount + 2, + @"Total frames should increment after a fast frame."); + + // Test frozen frame (should be classified as both slow and frozen) + OCMExpect([displayLinkMock timestamp]).andReturn(frozenFrameTimestamp); + [tracker displayLinkStep]; + XCTAssertEqual(tracker.slowFramesCount, initialSlowFramesCount + 2, + @"Frame duration 701ms should be slow at %ld FPS", (long)fpsCase.fps); + XCTAssertEqual(tracker.frozenFramesCount, initialFrozenFramesCount + 1, + @"Frame duration 701ms should be frozen at %ld FPS", (long)fpsCase.fps); + XCTAssertEqual(tracker.totalFramesCount, initialTotalFramesCount + 3, + @"Total frames should increment after a frozen frame."); + + // Test not-frozen frame (should be classified as slow but not frozen) + OCMExpect([displayLinkMock timestamp]).andReturn(notFrozenFrameTimestamp); + [tracker displayLinkStep]; + XCTAssertEqual(tracker.slowFramesCount, initialSlowFramesCount + 3, + @"Frame duration 699ms should be slow at %ld FPS", (long)fpsCase.fps); + XCTAssertEqual(tracker.frozenFramesCount, initialFrozenFramesCount + 1, + @"Frame duration 699ms should NOT be frozen at %ld FPS", (long)fpsCase.fps); + XCTAssertEqual(tracker.totalFramesCount, initialTotalFramesCount + 4, + @"Total frames should increment after a not-frozen frame."); +} + +// FPS threshold adaptation tests +- (void)testSlowThresholdAdaptsTo50FPS { + FPRFpsCase testCase = (FPRFpsCase){50, 0.021, 0.019}; // 50 FPS: budget 20ms (1/50) + FPRRunFpsTestCase(self, testCase); +} + +- (void)testSlowThresholdAdaptsTo60FPS { + FPRFpsCase testCase = (FPRFpsCase){60, 0.017, 0.016}; // 60 FPS: budget ~16.67ms (1/60) + FPRRunFpsTestCase(self, testCase); +} + +- (void)testSlowThresholdAdaptsTo120FPS { + FPRFpsCase testCase = (FPRFpsCase){120, 0.009, 0.008}; // 120 FPS: budget ~8.33ms (1/120) + FPRRunFpsTestCase(self, testCase); +} + @end