From 248ee2ac635193a0816816a371d3149053a42395 Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Wed, 19 Nov 2025 17:35:02 -0600 Subject: [PATCH 1/5] Use UIScreen.maximumFramesPerSecond for dynamic slow frame threshold --- .../FPRScreenTraceTracker+Private.h | 14 ++ .../AppActivity/FPRScreenTraceTracker.m | 90 ++++++- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 236 +++++++++++++++++- 3 files changed, 336 insertions(+), 4 deletions(-) diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h index 9cbb868d799..3965417ee00 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h @@ -112,6 +112,20 @@ FOUNDATION_EXTERN CFTimeInterval const kFPRFrozenFrameThreshold; */ - (void)viewControllerDidDisappear:(id)viewController; +#if TARGET_OS_TV +/** Handles the UIScreenModeDidChangeNotification. Recomputes the cached slow budget when the screen + * mode changes on tvOS. + * + * @param notification The NSNotification object. + */ +- (void)screenModeDidChangeNotification:(NSNotification *)notification; +#endif + +/** Updates the cached maxFPS and slowBudget from UIScreen.maximumFramesPerSecond. + * This method must be called on the main thread. + */ +- (void)updateCachedSlowBudget; + @end NS_ASSUME_NONNULL_END diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index 5137776fb5f..b4af3dcc17d 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -28,9 +28,14 @@ // 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. // TODO(b/73498642): Make these configurable. +// This constant is kept for backward compatibility but is no longer used directly. +// The actual threshold is computed dynamically from UIScreen.maximumFramesPerSecond. CFTimeInterval const kFPRSlowFrameThreshold = 1.0 / 59.0; // Anything less than 59 FPS is slow. CFTimeInterval const kFPRFrozenFrameThreshold = 700.0 / 1000.0; +/** Epsilon value to avoid floating point comparison issues (e.g., 59.94 vs 60). */ +static const CFTimeInterval kFPRSlowFrameEpsilon = 0.001; + /** Constant that indicates an invalid time. */ CFAbsoluteTime const kFPRInvalidTime = -1.0; @@ -80,6 +85,14 @@ @implementation FPRScreenTraceTracker { /** Instance variable storing the frozen frames observed so far. */ atomic_int_fast64_t _frozenFramesCount; + + /** Cached maximum frames per second from UIScreen. */ + NSInteger _cachedMaxFPS; + + /** Cached slow frame budget computed from maxFPS. Initialized to the old constant value + * for backward compatibility until updateCachedSlowBudget is called. + */ + CFTimeInterval _cachedSlowBudget; } @dynamic totalFramesCount; @@ -112,6 +125,24 @@ - (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); + + // Initialize cached values with defaults. These will be updated by updateCachedSlowBudget, + // but having defaults ensures reasonable behavior if initialization is delayed or fails. + _cachedMaxFPS = 60; // Default to 60 FPS. + _cachedSlowBudget = 1.0 / 60.0; // Default to 60 FPS budget. + + // Initialize cached maxFPS and slowBudget on main thread. + // UIScreen.maximumFramesPerSecond reflects device capability and can be up to 120 on ProMotion. + // TODO: Support ProMotion devices that dynamically adjust refresh rate based on content. + // Use synchronous dispatch to ensure values are set before first frame is recorded. + if ([NSThread isMainThread]) { + [self updateCachedSlowBudget]; + } else { + dispatch_sync(dispatch_get_main_queue(), ^{ + [self updateCachedSlowBudget]; + }); + } + _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkStep)]; [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; @@ -126,6 +157,15 @@ - (instancetype)init { selector:@selector(appWillResignActiveNotification:) name:UIApplicationWillResignActiveNotification object:[UIApplication sharedApplication]]; + +#if TARGET_OS_TV + // On tvOS, the refresh rate can change when the user switches display modes or connects to + // different displays. Listen for mode changes to recompute the slow budget. + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(screenModeDidChangeNotification:) + name:UIScreenModeDidChangeNotification + object:nil]; +#endif } return self; } @@ -139,6 +179,11 @@ - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationWillResignActiveNotification object:[UIApplication sharedApplication]]; +#if TARGET_OS_TV + [[NSNotificationCenter defaultCenter] removeObserver:self + name:UIScreenModeDidChangeNotification + object:nil]; +#endif } - (void)appDidBecomeActiveNotification:(NSNotification *)notification { @@ -183,13 +228,49 @@ - (void)appWillResignActiveNotification:(NSNotification *)notification { }); } +#if TARGET_OS_TV +/** Handles the UIScreenModeDidChangeNotification. Recomputes the cached slow budget when the screen + * mode changes on tvOS. + * + * @param notification The NSNotification object. + */ +- (void)screenModeDidChangeNotification:(NSNotification *)notification { + dispatch_async(dispatch_get_main_queue(), ^{ + [self updateCachedSlowBudget]; + }); +} +#endif + +/** Updates the cached maxFPS and slowBudget from UIScreen.maximumFramesPerSecond. + * This method must be called on the main thread. + */ +- (void)updateCachedSlowBudget { + NSAssert([NSThread isMainThread], @"updateCachedSlowBudget must be called on main thread"); + UIScreen *mainScreen = [UIScreen mainScreen]; + if (mainScreen) { + _cachedMaxFPS = mainScreen.maximumFramesPerSecond; + if (_cachedMaxFPS > 0) { + _cachedSlowBudget = 1.0 / _cachedMaxFPS; + } else { + // Fallback to 60 FPS if maximumFramesPerSecond is unavailable or invalid. + _cachedMaxFPS = 60; + _cachedSlowBudget = 1.0 / 60.0; + } + } else { + // Fallback if mainScreen is nil. + _cachedMaxFPS = 60; + _cachedSlowBudget = 1.0 / 60.0; + } +} + #pragma mark - Frozen, slow and good frames - (void)displayLinkStep { static CFAbsoluteTime previousTimestamp = kFPRInvalidTime; CFAbsoluteTime currentTimestamp = self.displayLink.timestamp; + // Use the cached slow budget computed from UIScreen.maximumFramesPerSecond. RecordFrameType(currentTimestamp, previousTimestamp, &_slowFramesCount, &_frozenFramesCount, - &_totalFramesCount); + &_totalFramesCount, _cachedSlowBudget); previousTimestamp = currentTimestamp; } @@ -207,12 +288,15 @@ void RecordFrameType(CFAbsoluteTime currentTimestamp, CFAbsoluteTime previousTimestamp, atomic_int_fast64_t *slowFramesCounter, atomic_int_fast64_t *frozenFramesCounter, - atomic_int_fast64_t *totalFramesCounter) { + atomic_int_fast64_t *totalFramesCounter, + CFTimeInterval slowBudget) { CFTimeInterval frameDuration = currentTimestamp - previousTimestamp; if (previousTimestamp == kFPRInvalidTime) { return; } - if (frameDuration > kFPRSlowFrameThreshold) { + // Use cached slowBudget with epsilon to avoid floating point comparison issues + // (e.g., 59.94 vs 60 Hz displays). + if (frameDuration > slowBudget + kFPRSlowFrameEpsilon) { 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..e23d0ab8fa8 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -626,8 +626,11 @@ - (void)testSlowFrameIsRecorded { /** Tests that the slow and frozen frame counter is not incremented in the case of a good frame. */ - (void)testSlowAndFrozenFrameIsNotRecordedInCaseOfGoodFrame { CFAbsoluteTime firstFrameRenderTimestamp = 1.0; + // Use a frame duration that's clearly below any reasonable threshold (even for 120 FPS devices). + // For 120 FPS: threshold = 1/120 = 0.008333, with epsilon = 0.001, so slow if > 0.009333. + // Using 0.005 ensures it's a good frame on all devices. CFAbsoluteTime secondFrameRenderTimestamp = - firstFrameRenderTimestamp + kFPRSlowFrameThreshold - 0.005; // Good frame. + firstFrameRenderTimestamp + 0.005; // Good frame (5ms, well below any threshold). id displayLinkMock = OCMClassMock([CADisplayLink class]); [self.tracker.displayLink invalidate]; @@ -895,6 +898,237 @@ - (void)testScreenTracesAreCreatedForContainerViewControllerSubclasses { XCTAssertEqual(self.tracker.activeScreenTraces.count, 4); } +#pragma mark - Dynamic FPS Tests + +#if TARGET_OS_TV +/** Tests that slow frames are correctly detected with a custom maxFPS value on tvOS. + * This test stubs UIScreen.maximumFramesPerSecond to 50 FPS and verifies that frames + * at ~21ms (slow) and ~19ms (not slow) are correctly classified. + */ +- (void)testSlowFrameIsRecordedWithCustomMaxFPSOnTvOS { + // Swizzle UIScreen.maximumFramesPerSecond to return 50 FPS. + // At 50 FPS, slow budget = 1.0/50 = 0.02 seconds = 20ms. + UIScreen *mainScreen = [UIScreen mainScreen]; + NSInteger originalMaxFPS = mainScreen.maximumFramesPerSecond; + + // Use method swizzling to stub maximumFramesPerSecond. + Method originalMethod = class_getInstanceMethod([UIScreen class], @selector(maximumFramesPerSecond)); + IMP originalIMP = method_getImplementation(originalMethod); + + NSInteger (^stubBlock)(id) = ^NSInteger(id self) { + return 50; // Return 50 FPS for testing. + }; + IMP stubIMP = imp_implementationWithBlock(stubBlock); + method_setImplementation(originalMethod, stubIMP); + + @try { + // Create a new tracker instance to pick up the stubbed maxFPS value. + FPRScreenTraceTracker *testTracker = [[FPRScreenTraceTracker alloc] init]; + testTracker.displayLink.paused = YES; + + // Update cached budget with stubbed value. Tests run on main thread, so call directly. + if ([NSThread isMainThread]) { + [testTracker updateCachedSlowBudget]; + } else { + dispatch_sync(dispatch_get_main_queue(), ^{ + [testTracker updateCachedSlowBudget]; + }); + } + + // At 50 FPS, slow budget = 20ms. With epsilon (0.001), frames > 20.001ms are slow. + // Test with 21ms frame (should be slow). + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; + CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + 0.021; // 21ms, slow + + id displayLinkMock = OCMClassMock([CADisplayLink class]); + [testTracker.displayLink invalidate]; + testTracker.displayLink = displayLinkMock; + + OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + [testTracker displayLinkStep]; + int64_t initialSlowFramesCount = testTracker.slowFramesCount; + + OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t newSlowFramesCount = testTracker.slowFramesCount; + XCTAssertEqual(newSlowFramesCount, initialSlowFramesCount + 1, + @"Frame at 21ms should be marked as slow at 50 FPS (20ms threshold)"); + + // Test with 19ms frame (should NOT be slow). + CFAbsoluteTime thirdFrameRenderTimestamp = secondFrameRenderTimestamp + 0.019; // 19ms, not slow + OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t finalSlowFramesCount = testTracker.slowFramesCount; + XCTAssertEqual(finalSlowFramesCount, newSlowFramesCount, + @"Frame at 19ms should NOT be marked as slow at 50 FPS (20ms threshold)"); + } @finally { + // Restore original implementation. + method_setImplementation(originalMethod, originalIMP); + } +} +#endif + +/** Tests that the epsilon value correctly handles edge cases around 59.94 vs 60 Hz displays. + * Frames right at the threshold should not be miscounted due to floating point precision. + */ +- (void)testSlowFrameEpsilonHandlesBoundaryCases { + // Swizzle UIScreen.maximumFramesPerSecond to return 60 FPS. + Method originalMethod = class_getInstanceMethod([UIScreen class], @selector(maximumFramesPerSecond)); + IMP originalIMP = method_getImplementation(originalMethod); + + NSInteger (^stubBlock)(id) = ^NSInteger(id self) { + return 60; // Return 60 FPS for testing. + }; + IMP stubIMP = imp_implementationWithBlock(stubBlock); + method_setImplementation(originalMethod, stubIMP); + + @try { + // Create a new tracker instance. + FPRScreenTraceTracker *testTracker = [[FPRScreenTraceTracker alloc] init]; + testTracker.displayLink.paused = YES; + + // Update cached budget with stubbed value. Tests run on main thread, so call directly. + if ([NSThread isMainThread]) { + [testTracker updateCachedSlowBudget]; + } else { + dispatch_sync(dispatch_get_main_queue(), ^{ + [testTracker updateCachedSlowBudget]; + }); + } + + // Verify the stub is working - UIScreen should return 60 FPS. + UIScreen *mainScreen = [UIScreen mainScreen]; + XCTAssertEqual(mainScreen.maximumFramesPerSecond, 60, @"Stub should return 60 FPS"); + + // At 60 FPS, slow budget = 1.0/60 = 0.016666... seconds. + // With epsilon (0.001), frames > 0.017666... are slow. + // Test with frame exactly at threshold (should NOT be slow due to epsilon). + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; + CFTimeInterval exactThreshold = 1.0 / 60.0; // Exactly 1/60 second + CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + exactThreshold; + + id displayLinkMock = OCMClassMock([CADisplayLink class]); + [testTracker.displayLink invalidate]; + testTracker.displayLink = displayLinkMock; + + OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + [testTracker displayLinkStep]; + int64_t initialSlowFramesCount = testTracker.slowFramesCount; + + OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t newSlowFramesCount = testTracker.slowFramesCount; + XCTAssertEqual(newSlowFramesCount, initialSlowFramesCount, + @"Frame exactly at threshold should NOT be marked as slow due to epsilon"); + + // Test with frame just above threshold + epsilon (should be slow). + // Use a value clearly above threshold + epsilon (0.001) to account for floating point precision. + // We use 0.002 above threshold to ensure it's clearly above the epsilon threshold. + CFTimeInterval justAboveThreshold = exactThreshold + 0.001 + 0.001; // 0.002 above threshold (epsilon is 0.001) + CFAbsoluteTime thirdFrameRenderTimestamp = secondFrameRenderTimestamp + justAboveThreshold; + OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t finalSlowFramesCount = testTracker.slowFramesCount; + XCTAssertEqual(finalSlowFramesCount, newSlowFramesCount + 1, + @"Frame just above threshold + epsilon should be marked as slow"); + } @finally { + // Restore original implementation. + method_setImplementation(originalMethod, originalIMP); + } +} + +#if TARGET_OS_TV +/** Tests that the slow budget is recomputed when UIScreenModeDidChangeNotification is posted on tvOS. + * This verifies that the tracker adapts to display mode changes that affect refresh rate. + */ +- (void)testScreenModeChangeUpdatesSlowBudgetOnTvOS { + // Swizzle UIScreen.maximumFramesPerSecond to return 60 FPS initially, then 50 FPS. + Method originalMethod = class_getInstanceMethod([UIScreen class], @selector(maximumFramesPerSecond)); + IMP originalIMP = method_getImplementation(originalMethod); + + __block NSInteger stubbedMaxFPS = 60; + NSInteger (^stubBlock)(id) = ^NSInteger(id self) { + return stubbedMaxFPS; + }; + IMP stubIMP = imp_implementationWithBlock(stubBlock); + method_setImplementation(originalMethod, stubIMP); + + @try { + // Create a new tracker instance. + FPRScreenTraceTracker *testTracker = [[FPRScreenTraceTracker alloc] init]; + testTracker.displayLink.paused = YES; + + // Update cached budget with stubbed value. Tests run on main thread, so call directly. + if ([NSThread isMainThread]) { + [testTracker updateCachedSlowBudget]; + } else { + dispatch_sync(dispatch_get_main_queue(), ^{ + [testTracker updateCachedSlowBudget]; + }); + } + + // Verify initial behavior: at 60 FPS, slow budget = ~16.67ms. + // An 18ms frame should be slow at 60 FPS. + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; + CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + 0.018; // 18ms + + id displayLinkMock = OCMClassMock([CADisplayLink class]); + [testTracker.displayLink invalidate]; + testTracker.displayLink = displayLinkMock; + + OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + [testTracker displayLinkStep]; + int64_t initialSlowFramesCount = testTracker.slowFramesCount; + + OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t slowFramesAfter18ms = testTracker.slowFramesCount; + // At 60 FPS (~16.67ms threshold), 18ms frame should be slow. + XCTAssertEqual(slowFramesAfter18ms, initialSlowFramesCount + 1, + @"At 60 FPS, 18ms frame should be slow (threshold is ~16.67ms)"); + + // Change the stubbed maxFPS to 50 FPS. + stubbedMaxFPS = 50; + + // Post the notification to trigger recomputation. + NSNotification *modeChangeNotification = + [NSNotification notificationWithName:UIScreenModeDidChangeNotification object:nil]; + [testTracker screenModeDidChangeNotification:modeChangeNotification]; + + // Wait for the async update to complete. Since screenModeDidChangeNotification dispatches + // async to main queue, and tests run on main thread, we need to run the run loop to process it. + // Run the run loop once to process the async dispatch. + [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; + + // Verify the new budget is used: at 50 FPS, slow budget = 20ms. + // An 18ms frame should NOT be slow at 50 FPS (it's below the 20ms threshold). + testTracker.slowFramesCount = 0; + firstFrameRenderTimestamp = 2.0; + secondFrameRenderTimestamp = firstFrameRenderTimestamp + 0.018; // 18ms + + OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + [testTracker displayLinkStep]; + initialSlowFramesCount = testTracker.slowFramesCount; + + OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t slowFramesAfterModeChange = testTracker.slowFramesCount; + // At 50 FPS (20ms threshold), 18ms frame should NOT be slow. + XCTAssertEqual(slowFramesAfterModeChange, initialSlowFramesCount, + @"After mode change to 50 FPS, 18ms frame should NOT be slow (threshold is 20ms)"); + } @finally { + // Restore original implementation. + method_setImplementation(originalMethod, originalIMP); + } +} +#endif + #pragma mark - Helper methods + (NSString *)expectedTraceNameForViewController:(UIViewController *)viewController { From f6e26aa608177fbe318a1540c7f1feea58987647 Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Wed, 19 Nov 2025 17:56:24 -0600 Subject: [PATCH 2/5] Backwards Compatibility: Use 59 Threshold on 60 fps devices --- .../AppActivity/FPRScreenTraceTracker.m | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index b4af3dcc17d..b1fcc81d5ce 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -128,8 +128,9 @@ - (instancetype)init { // Initialize cached values with defaults. These will be updated by updateCachedSlowBudget, // but having defaults ensures reasonable behavior if initialization is delayed or fails. - _cachedMaxFPS = 60; // Default to 60 FPS. - _cachedSlowBudget = 1.0 / 60.0; // Default to 60 FPS budget. + // Use 59 FPS as default to match legacy behavior (60 FPS devices use 59 FPS threshold). + _cachedMaxFPS = 59; + _cachedSlowBudget = 1.0 / 59.0; // Initialize cached maxFPS and slowBudget on main thread. // UIScreen.maximumFramesPerSecond reflects device capability and can be up to 120 on ProMotion. @@ -250,16 +251,19 @@ - (void)updateCachedSlowBudget { if (mainScreen) { _cachedMaxFPS = mainScreen.maximumFramesPerSecond; if (_cachedMaxFPS > 0) { - _cachedSlowBudget = 1.0 / _cachedMaxFPS; + // Preserve legacy behavior: 60 FPS devices historically used 59 FPS threshold + // to avoid too many false positives for slow frames. + NSInteger effectiveFPS = (_cachedMaxFPS == 60) ? 59 : _cachedMaxFPS; + _cachedSlowBudget = 1.0 / effectiveFPS; } else { - // Fallback to 60 FPS if maximumFramesPerSecond is unavailable or invalid. - _cachedMaxFPS = 60; - _cachedSlowBudget = 1.0 / 60.0; + // Fallback to 59 FPS (matching legacy behavior) if maximumFramesPerSecond is unavailable or invalid. + _cachedMaxFPS = 59; + _cachedSlowBudget = 1.0 / 59.0; } } else { // Fallback if mainScreen is nil. - _cachedMaxFPS = 60; - _cachedSlowBudget = 1.0 / 60.0; + _cachedMaxFPS = 59; + _cachedSlowBudget = 1.0 / 59.0; } } From 37b6235016c5fdeea0b41f1c838b10b5c9195578 Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Wed, 19 Nov 2025 18:13:21 -0600 Subject: [PATCH 3/5] Run Style.sh --- .../AppActivity/FPRScreenTraceTracker.m | 3 +- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 39 +++++++++++-------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index b1fcc81d5ce..cca9d74fb20 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -256,7 +256,8 @@ - (void)updateCachedSlowBudget { NSInteger effectiveFPS = (_cachedMaxFPS == 60) ? 59 : _cachedMaxFPS; _cachedSlowBudget = 1.0 / effectiveFPS; } else { - // Fallback to 59 FPS (matching legacy behavior) if maximumFramesPerSecond is unavailable or invalid. + // Fallback to 59 FPS (matching legacy behavior) if maximumFramesPerSecond is unavailable or + // invalid. _cachedMaxFPS = 59; _cachedSlowBudget = 1.0 / 59.0; } diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index e23d0ab8fa8..ec38fd3692d 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -910,11 +910,12 @@ - (void)testSlowFrameIsRecordedWithCustomMaxFPSOnTvOS { // At 50 FPS, slow budget = 1.0/50 = 0.02 seconds = 20ms. UIScreen *mainScreen = [UIScreen mainScreen]; NSInteger originalMaxFPS = mainScreen.maximumFramesPerSecond; - + // Use method swizzling to stub maximumFramesPerSecond. - Method originalMethod = class_getInstanceMethod([UIScreen class], @selector(maximumFramesPerSecond)); + Method originalMethod = + class_getInstanceMethod([UIScreen class], @selector(maximumFramesPerSecond)); IMP originalIMP = method_getImplementation(originalMethod); - + NSInteger (^stubBlock)(id) = ^NSInteger(id self) { return 50; // Return 50 FPS for testing. }; @@ -956,7 +957,8 @@ - (void)testSlowFrameIsRecordedWithCustomMaxFPSOnTvOS { @"Frame at 21ms should be marked as slow at 50 FPS (20ms threshold)"); // Test with 19ms frame (should NOT be slow). - CFAbsoluteTime thirdFrameRenderTimestamp = secondFrameRenderTimestamp + 0.019; // 19ms, not slow + CFAbsoluteTime thirdFrameRenderTimestamp = + secondFrameRenderTimestamp + 0.019; // 19ms, not slow OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); [testTracker displayLinkStep]; @@ -975,9 +977,10 @@ - (void)testSlowFrameIsRecordedWithCustomMaxFPSOnTvOS { */ - (void)testSlowFrameEpsilonHandlesBoundaryCases { // Swizzle UIScreen.maximumFramesPerSecond to return 60 FPS. - Method originalMethod = class_getInstanceMethod([UIScreen class], @selector(maximumFramesPerSecond)); + Method originalMethod = + class_getInstanceMethod([UIScreen class], @selector(maximumFramesPerSecond)); IMP originalIMP = method_getImplementation(originalMethod); - + NSInteger (^stubBlock)(id) = ^NSInteger(id self) { return 60; // Return 60 FPS for testing. }; @@ -1025,9 +1028,10 @@ - (void)testSlowFrameEpsilonHandlesBoundaryCases { @"Frame exactly at threshold should NOT be marked as slow due to epsilon"); // Test with frame just above threshold + epsilon (should be slow). - // Use a value clearly above threshold + epsilon (0.001) to account for floating point precision. - // We use 0.002 above threshold to ensure it's clearly above the epsilon threshold. - CFTimeInterval justAboveThreshold = exactThreshold + 0.001 + 0.001; // 0.002 above threshold (epsilon is 0.001) + // Use a value clearly above threshold + epsilon (0.001) to account for floating point + // precision. We use 0.002 above threshold to ensure it's clearly above the epsilon threshold. + CFTimeInterval justAboveThreshold = + exactThreshold + 0.001 + 0.001; // 0.002 above threshold (epsilon is 0.001) CFAbsoluteTime thirdFrameRenderTimestamp = secondFrameRenderTimestamp + justAboveThreshold; OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); [testTracker displayLinkStep]; @@ -1042,14 +1046,15 @@ - (void)testSlowFrameEpsilonHandlesBoundaryCases { } #if TARGET_OS_TV -/** Tests that the slow budget is recomputed when UIScreenModeDidChangeNotification is posted on tvOS. - * This verifies that the tracker adapts to display mode changes that affect refresh rate. +/** Tests that the slow budget is recomputed when UIScreenModeDidChangeNotification is posted on + * tvOS. This verifies that the tracker adapts to display mode changes that affect refresh rate. */ - (void)testScreenModeChangeUpdatesSlowBudgetOnTvOS { // Swizzle UIScreen.maximumFramesPerSecond to return 60 FPS initially, then 50 FPS. - Method originalMethod = class_getInstanceMethod([UIScreen class], @selector(maximumFramesPerSecond)); + Method originalMethod = + class_getInstanceMethod([UIScreen class], @selector(maximumFramesPerSecond)); IMP originalIMP = method_getImplementation(originalMethod); - + __block NSInteger stubbedMaxFPS = 60; NSInteger (^stubBlock)(id) = ^NSInteger(id self) { return stubbedMaxFPS; @@ -1103,7 +1108,8 @@ - (void)testScreenModeChangeUpdatesSlowBudgetOnTvOS { // Wait for the async update to complete. Since screenModeDidChangeNotification dispatches // async to main queue, and tests run on main thread, we need to run the run loop to process it. // Run the run loop once to process the async dispatch. - [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; + [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode + beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; // Verify the new budget is used: at 50 FPS, slow budget = 20ms. // An 18ms frame should NOT be slow at 50 FPS (it's below the 20ms threshold). @@ -1120,8 +1126,9 @@ - (void)testScreenModeChangeUpdatesSlowBudgetOnTvOS { int64_t slowFramesAfterModeChange = testTracker.slowFramesCount; // At 50 FPS (20ms threshold), 18ms frame should NOT be slow. - XCTAssertEqual(slowFramesAfterModeChange, initialSlowFramesCount, - @"After mode change to 50 FPS, 18ms frame should NOT be slow (threshold is 20ms)"); + XCTAssertEqual( + slowFramesAfterModeChange, initialSlowFramesCount, + @"After mode change to 50 FPS, 18ms frame should NOT be slow (threshold is 20ms)"); } @finally { // Restore original implementation. method_setImplementation(originalMethod, originalIMP); From 41909ed5b27ced167fb2be99370e1e43f447af3c Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Thu, 20 Nov 2025 13:40:54 -0600 Subject: [PATCH 4/5] Apply Gemini suggestions and address tvOS Unit Test Fail --- .../AppActivity/FPRScreenTraceTracker.m | 24 +- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 268 +++++++++--------- 2 files changed, 144 insertions(+), 148 deletions(-) diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index cca9d74fb20..104569d942c 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -248,21 +248,19 @@ - (void)screenModeDidChangeNotification:(NSNotification *)notification { - (void)updateCachedSlowBudget { NSAssert([NSThread isMainThread], @"updateCachedSlowBudget must be called on main thread"); UIScreen *mainScreen = [UIScreen mainScreen]; + NSInteger maxFPS = 0; if (mainScreen) { - _cachedMaxFPS = mainScreen.maximumFramesPerSecond; - if (_cachedMaxFPS > 0) { - // Preserve legacy behavior: 60 FPS devices historically used 59 FPS threshold - // to avoid too many false positives for slow frames. - NSInteger effectiveFPS = (_cachedMaxFPS == 60) ? 59 : _cachedMaxFPS; - _cachedSlowBudget = 1.0 / effectiveFPS; - } else { - // Fallback to 59 FPS (matching legacy behavior) if maximumFramesPerSecond is unavailable or - // invalid. - _cachedMaxFPS = 59; - _cachedSlowBudget = 1.0 / 59.0; - } + maxFPS = mainScreen.maximumFramesPerSecond; + } + if (maxFPS > 0) { + _cachedMaxFPS = maxFPS; + // Preserve legacy behavior: 60 FPS devices historically used 59 FPS threshold + // to avoid too many false positives for slow frames. + NSInteger effectiveFPS = (maxFPS == 60) ? 59 : maxFPS; + _cachedSlowBudget = 1.0 / effectiveFPS; } else { - // Fallback if mainScreen is nil. + // Fallback to 59 FPS (matching legacy behavior) if maximumFramesPerSecond is unavailable or + // invalid. _cachedMaxFPS = 59; _cachedSlowBudget = 1.0 / 59.0; } diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index ec38fd3692d..0a797b30541 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -900,149 +900,147 @@ - (void)testScreenTracesAreCreatedForContainerViewControllerSubclasses { #pragma mark - Dynamic FPS Tests -#if TARGET_OS_TV -/** Tests that slow frames are correctly detected with a custom maxFPS value on tvOS. - * This test stubs UIScreen.maximumFramesPerSecond to 50 FPS and verifies that frames - * at ~21ms (slow) and ~19ms (not slow) are correctly classified. +/** Helper method to swizzle UIScreen.maximumFramesPerSecond for testing. + * + * @param fps The FPS value to stub. + * @param block The block to execute with the stubbed FPS. */ -- (void)testSlowFrameIsRecordedWithCustomMaxFPSOnTvOS { - // Swizzle UIScreen.maximumFramesPerSecond to return 50 FPS. - // At 50 FPS, slow budget = 1.0/50 = 0.02 seconds = 20ms. - UIScreen *mainScreen = [UIScreen mainScreen]; - NSInteger originalMaxFPS = mainScreen.maximumFramesPerSecond; - - // Use method swizzling to stub maximumFramesPerSecond. +- (void)withStubbedMaxFPS:(NSInteger)fps performBlock:(void (^)(void))block { Method originalMethod = class_getInstanceMethod([UIScreen class], @selector(maximumFramesPerSecond)); IMP originalIMP = method_getImplementation(originalMethod); NSInteger (^stubBlock)(id) = ^NSInteger(id self) { - return 50; // Return 50 FPS for testing. + return fps; }; IMP stubIMP = imp_implementationWithBlock(stubBlock); method_setImplementation(originalMethod, stubIMP); @try { - // Create a new tracker instance to pick up the stubbed maxFPS value. - FPRScreenTraceTracker *testTracker = [[FPRScreenTraceTracker alloc] init]; - testTracker.displayLink.paused = YES; - - // Update cached budget with stubbed value. Tests run on main thread, so call directly. - if ([NSThread isMainThread]) { - [testTracker updateCachedSlowBudget]; - } else { - dispatch_sync(dispatch_get_main_queue(), ^{ - [testTracker updateCachedSlowBudget]; - }); - } - - // At 50 FPS, slow budget = 20ms. With epsilon (0.001), frames > 20.001ms are slow. - // Test with 21ms frame (should be slow). - CFAbsoluteTime firstFrameRenderTimestamp = 1.0; - CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + 0.021; // 21ms, slow - - id displayLinkMock = OCMClassMock([CADisplayLink class]); - [testTracker.displayLink invalidate]; - testTracker.displayLink = displayLinkMock; - - OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); - [testTracker displayLinkStep]; - int64_t initialSlowFramesCount = testTracker.slowFramesCount; - - OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); - [testTracker displayLinkStep]; - - int64_t newSlowFramesCount = testTracker.slowFramesCount; - XCTAssertEqual(newSlowFramesCount, initialSlowFramesCount + 1, - @"Frame at 21ms should be marked as slow at 50 FPS (20ms threshold)"); - - // Test with 19ms frame (should NOT be slow). - CFAbsoluteTime thirdFrameRenderTimestamp = - secondFrameRenderTimestamp + 0.019; // 19ms, not slow - OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); - [testTracker displayLinkStep]; - - int64_t finalSlowFramesCount = testTracker.slowFramesCount; - XCTAssertEqual(finalSlowFramesCount, newSlowFramesCount, - @"Frame at 19ms should NOT be marked as slow at 50 FPS (20ms threshold)"); + block(); } @finally { - // Restore original implementation. method_setImplementation(originalMethod, originalIMP); } } + +/** Helper method to create a test tracker with stubbed FPS and updated cached budget. + * + * @param fps The FPS value to stub. + * @return A configured FPRScreenTraceTracker instance. + */ +- (FPRScreenTraceTracker *)createTestTrackerWithStubbedFPS:(NSInteger)fps { + FPRScreenTraceTracker *testTracker = [[FPRScreenTraceTracker alloc] init]; + testTracker.displayLink.paused = YES; + // Tests run on main thread, so call updateCachedSlowBudget directly. + [testTracker updateCachedSlowBudget]; + return testTracker; +} + +#if TARGET_OS_TV +/** Tests that slow frames are correctly detected with a custom maxFPS value on tvOS. + * This test stubs UIScreen.maximumFramesPerSecond to 50 FPS and verifies that frames + * at ~21ms (slow) and ~19ms (not slow) are correctly classified. + */ +- (void)testSlowFrameIsRecordedWithCustomMaxFPSOnTvOS { + // At 50 FPS, slow budget = 1.0/50 = 0.02 seconds = 20ms. + [self withStubbedMaxFPS:50 + performBlock:^{ + FPRScreenTraceTracker *testTracker = [self createTestTrackerWithStubbedFPS:50]; + + // Verify the stub is working and budget is set correctly. + UIScreen *mainScreen = [UIScreen mainScreen]; + XCTAssertEqual(mainScreen.maximumFramesPerSecond, 50, @"Stub should return 50 FPS"); + // At 50 FPS, effectiveFPS = 50 (not 60, so no 59 conversion), threshold = 1/50 = + // 0.02 = 20ms + CFTimeInterval expectedBudget = 1.0 / 50.0; + // We can't directly access _cachedSlowBudget, but we can verify behavior. + + // At 50 FPS, slow budget = 20ms. With epsilon (0.001), frames > 20.001ms are slow. + // Test with 21ms frame (should be slow). + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; + CFAbsoluteTime secondFrameRenderTimestamp = + firstFrameRenderTimestamp + 0.021; // 21ms, slow + + id displayLinkMock = OCMClassMock([CADisplayLink class]); + [testTracker.displayLink invalidate]; + testTracker.displayLink = displayLinkMock; + + OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + [testTracker displayLinkStep]; + int64_t initialSlowFramesCount = testTracker.slowFramesCount; + + OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t newSlowFramesCount = testTracker.slowFramesCount; + XCTAssertEqual(newSlowFramesCount, initialSlowFramesCount + 1, + @"Frame at 21ms should be marked as slow at 50 FPS (20ms threshold)"); + + // Test with 19ms frame (should NOT be slow). + CFAbsoluteTime thirdFrameRenderTimestamp = + secondFrameRenderTimestamp + 0.019; // 19ms, not slow + OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t finalSlowFramesCount = testTracker.slowFramesCount; + XCTAssertEqual( + finalSlowFramesCount, newSlowFramesCount, + @"Frame at 19ms should NOT be marked as slow at 50 FPS (20ms threshold)"); + }]; +} #endif /** Tests that the epsilon value correctly handles edge cases around 59.94 vs 60 Hz displays. * Frames right at the threshold should not be miscounted due to floating point precision. */ - (void)testSlowFrameEpsilonHandlesBoundaryCases { - // Swizzle UIScreen.maximumFramesPerSecond to return 60 FPS. - Method originalMethod = - class_getInstanceMethod([UIScreen class], @selector(maximumFramesPerSecond)); - IMP originalIMP = method_getImplementation(originalMethod); - - NSInteger (^stubBlock)(id) = ^NSInteger(id self) { - return 60; // Return 60 FPS for testing. - }; - IMP stubIMP = imp_implementationWithBlock(stubBlock); - method_setImplementation(originalMethod, stubIMP); - - @try { - // Create a new tracker instance. - FPRScreenTraceTracker *testTracker = [[FPRScreenTraceTracker alloc] init]; - testTracker.displayLink.paused = YES; - - // Update cached budget with stubbed value. Tests run on main thread, so call directly. - if ([NSThread isMainThread]) { - [testTracker updateCachedSlowBudget]; - } else { - dispatch_sync(dispatch_get_main_queue(), ^{ - [testTracker updateCachedSlowBudget]; - }); - } - - // Verify the stub is working - UIScreen should return 60 FPS. - UIScreen *mainScreen = [UIScreen mainScreen]; - XCTAssertEqual(mainScreen.maximumFramesPerSecond, 60, @"Stub should return 60 FPS"); - - // At 60 FPS, slow budget = 1.0/60 = 0.016666... seconds. - // With epsilon (0.001), frames > 0.017666... are slow. - // Test with frame exactly at threshold (should NOT be slow due to epsilon). - CFAbsoluteTime firstFrameRenderTimestamp = 1.0; - CFTimeInterval exactThreshold = 1.0 / 60.0; // Exactly 1/60 second - CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + exactThreshold; - - id displayLinkMock = OCMClassMock([CADisplayLink class]); - [testTracker.displayLink invalidate]; - testTracker.displayLink = displayLinkMock; - - OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); - [testTracker displayLinkStep]; - int64_t initialSlowFramesCount = testTracker.slowFramesCount; - - OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); - [testTracker displayLinkStep]; - - int64_t newSlowFramesCount = testTracker.slowFramesCount; - XCTAssertEqual(newSlowFramesCount, initialSlowFramesCount, + [self withStubbedMaxFPS:60 + performBlock:^{ + FPRScreenTraceTracker *testTracker = [self createTestTrackerWithStubbedFPS:60]; + + // Verify the stub is working - UIScreen should return 60 FPS. + UIScreen *mainScreen = [UIScreen mainScreen]; + XCTAssertEqual(mainScreen.maximumFramesPerSecond, 60, @"Stub should return 60 FPS"); + + // At 60 FPS, slow budget = 1.0/60 = 0.016666... seconds. + // With epsilon (0.001), frames > 0.017666... are slow. + // Test with frame exactly at threshold (should NOT be slow due to epsilon). + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; + CFTimeInterval exactThreshold = 1.0 / 60.0; // Exactly 1/60 second + CFAbsoluteTime secondFrameRenderTimestamp = + firstFrameRenderTimestamp + exactThreshold; + + id displayLinkMock = OCMClassMock([CADisplayLink class]); + [testTracker.displayLink invalidate]; + testTracker.displayLink = displayLinkMock; + + OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + [testTracker displayLinkStep]; + int64_t initialSlowFramesCount = testTracker.slowFramesCount; + + OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t newSlowFramesCount = testTracker.slowFramesCount; + XCTAssertEqual( + newSlowFramesCount, initialSlowFramesCount, @"Frame exactly at threshold should NOT be marked as slow due to epsilon"); - // Test with frame just above threshold + epsilon (should be slow). - // Use a value clearly above threshold + epsilon (0.001) to account for floating point - // precision. We use 0.002 above threshold to ensure it's clearly above the epsilon threshold. - CFTimeInterval justAboveThreshold = - exactThreshold + 0.001 + 0.001; // 0.002 above threshold (epsilon is 0.001) - CFAbsoluteTime thirdFrameRenderTimestamp = secondFrameRenderTimestamp + justAboveThreshold; - OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); - [testTracker displayLinkStep]; - - int64_t finalSlowFramesCount = testTracker.slowFramesCount; - XCTAssertEqual(finalSlowFramesCount, newSlowFramesCount + 1, - @"Frame just above threshold + epsilon should be marked as slow"); - } @finally { - // Restore original implementation. - method_setImplementation(originalMethod, originalIMP); - } + // Test with frame just above threshold + epsilon (should be slow). + // Use a value clearly above threshold + epsilon (0.001) to account for floating + // point precision. We use 0.002 above threshold to ensure it's clearly above the + // epsilon threshold. + CFTimeInterval justAboveThreshold = + exactThreshold + 0.001 + 0.001; // 0.002 above threshold (epsilon is 0.001) + CFAbsoluteTime thirdFrameRenderTimestamp = + secondFrameRenderTimestamp + justAboveThreshold; + OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t finalSlowFramesCount = testTracker.slowFramesCount; + XCTAssertEqual(finalSlowFramesCount, newSlowFramesCount + 1, + @"Frame just above threshold + epsilon should be marked as slow"); + }]; } #if TARGET_OS_TV @@ -1063,18 +1061,7 @@ - (void)testScreenModeChangeUpdatesSlowBudgetOnTvOS { method_setImplementation(originalMethod, stubIMP); @try { - // Create a new tracker instance. - FPRScreenTraceTracker *testTracker = [[FPRScreenTraceTracker alloc] init]; - testTracker.displayLink.paused = YES; - - // Update cached budget with stubbed value. Tests run on main thread, so call directly. - if ([NSThread isMainThread]) { - [testTracker updateCachedSlowBudget]; - } else { - dispatch_sync(dispatch_get_main_queue(), ^{ - [testTracker updateCachedSlowBudget]; - }); - } + FPRScreenTraceTracker *testTracker = [self createTestTrackerWithStubbedFPS:60]; // Verify initial behavior: at 60 FPS, slow budget = ~16.67ms. // An 18ms frame should be slow at 60 FPS. @@ -1107,9 +1094,20 @@ - (void)testScreenModeChangeUpdatesSlowBudgetOnTvOS { // Wait for the async update to complete. Since screenModeDidChangeNotification dispatches // async to main queue, and tests run on main thread, we need to run the run loop to process it. - // Run the run loop once to process the async dispatch. - [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode - beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; + // Run the run loop multiple times to ensure the async dispatch completes. + NSDate *timeout = [NSDate dateWithTimeIntervalSinceNow:0.5]; + while ([timeout timeIntervalSinceNow] > 0) { + [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode + beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.01]]; + } + + // Also directly update to ensure it's set (in case async didn't complete). + [testTracker updateCachedSlowBudget]; + + // Verify the stub is now returning 50 FPS. + UIScreen *mainScreen = [UIScreen mainScreen]; + XCTAssertEqual(mainScreen.maximumFramesPerSecond, 50, + @"Stub should now return 50 FPS after change"); // Verify the new budget is used: at 50 FPS, slow budget = 20ms. // An 18ms frame should NOT be slow at 50 FPS (it's below the 20ms threshold). From 9d3e7e0d612f41e03af65760b9c4f4237fe40bf0 Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Thu, 20 Nov 2025 17:12:41 -0600 Subject: [PATCH 5/5] Rename Test to make it more readable --- FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 0a797b30541..37e46f9ef78 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -993,7 +993,7 @@ - (void)testSlowFrameIsRecordedWithCustomMaxFPSOnTvOS { /** Tests that the epsilon value correctly handles edge cases around 59.94 vs 60 Hz displays. * Frames right at the threshold should not be miscounted due to floating point precision. */ -- (void)testSlowFrameEpsilonHandlesBoundaryCases { +- (void)testSlowFrameRate_isHandled_inEdgeCases { [self withStubbedMaxFPS:60 performBlock:^{ FPRScreenTraceTracker *testTracker = [self createTestTrackerWithStubbedFPS:60];