PuzzleSessionTests.swift (6304B)
1 import Foundation 2 import Testing 3 import UIKit 4 5 @testable import Crossmate 6 7 @Suite("PuzzleSession", .serialized) 8 @MainActor 9 struct PuzzleSessionTests { 10 /// Records every effect firing so tests can assert which pushes a given 11 /// interleaving produced, and drive the background-assertion expiration 12 /// handler by hand. 13 @MainActor 14 final class EffectLog { 15 private(set) var ends: [Date] = [] 16 private(set) var banners = 0 17 private(set) var notes: [String] = [] 18 private(set) var assertionNames: [String] = [] 19 private(set) var releasedAssertions: [UIBackgroundTaskIdentifier] = [] 20 private(set) var expirationHandler: (@MainActor () -> Void)? 21 22 var effects: PuzzleSession.Effects { 23 PuzzleSession.Effects( 24 publishEnd: { self.ends.append($0) }, 25 postSummaryBanner: { self.banners += 1 }, 26 note: { self.notes.append($0) }, 27 beginBackgroundAssertion: { name, onExpiration in 28 self.assertionNames.append(name) 29 self.expirationHandler = onExpiration 30 return UIBackgroundTaskIdentifier(rawValue: self.assertionNames.count) 31 }, 32 endBackgroundAssertion: { self.releasedAssertions.append($0) } 33 ) 34 } 35 } 36 37 private func waitUntil( 38 timeout: Duration = .seconds(5), 39 _ condition: () -> Bool 40 ) async throws { 41 let deadline = ContinuousClock.now.advanced(by: timeout) 42 while !condition(), ContinuousClock.now < deadline { 43 try await Task.sleep(for: .milliseconds(20)) 44 } 45 #expect(condition()) 46 } 47 48 @Test("End push fires with the pause start captured at scheduling time") 49 func endPushFiresWithScheduledPauseStart() async throws { 50 let log = EffectLog() 51 let sleeps = ManualDebounceSleep() 52 let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn) 53 54 let before = Date() 55 session.scheduleEndPush(after: 30) 56 let after = Date() 57 #expect(log.assertionNames.count == 1) 58 try await sleeps.waitForSleeperCount(1) 59 60 sleeps.releaseAll() 61 try await waitUntil { log.ends.count == 1 } 62 let pauseStart = try #require(log.ends.first) 63 #expect(pauseStart >= before && pauseStart <= after) 64 // The assertion is released only after the publish completes. 65 try await waitUntil { log.releasedAssertions.count == 1 } 66 #expect(session.isIdle) 67 } 68 69 @Test("Resuming inside the end grace cancels the pause and frees the assertion") 70 func resumeInsideEndGrace() async throws { 71 let log = EffectLog() 72 let sleeps = ManualDebounceSleep() 73 let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn) 74 75 session.scheduleEndPush(after: 30) 76 try await sleeps.waitForSleeperCount(1) 77 #expect(session.cancelPendingEndPush()) // true: this is a resume 78 #expect(log.releasedAssertions.count == 1) 79 80 sleeps.releaseAll() 81 try await Task.sleep(for: .milliseconds(120)) 82 #expect(log.ends.isEmpty) 83 #expect(session.isIdle) 84 } 85 86 @Test("Assertion expiration fires the pause early, exactly once") 87 func expirationFiresEndEarlyExactlyOnce() async throws { 88 let log = EffectLog() 89 let sleeps = ManualDebounceSleep() 90 let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn) 91 92 session.scheduleEndPush(after: 30) 93 try await sleeps.waitForSleeperCount(1) 94 95 // iOS reclaims the app before the grace elapses. 96 log.expirationHandler?() 97 try await waitUntil { log.ends.count == 1 } 98 #expect(log.notes.contains { $0.contains("firing early") }) 99 100 // The grace timer wakes late; the fired latch swallows it. 101 sleeps.releaseAll() 102 try await Task.sleep(for: .milliseconds(120)) 103 #expect(log.ends.count == 1) 104 try await waitUntil { log.releasedAssertions.count == 1 } 105 #expect(session.isIdle) 106 } 107 108 @Test("A direct publish supersedes the pending timer; the latch frees the assertion") 109 func supersedeDropsTimerWithoutPublishing() async throws { 110 let log = EffectLog() 111 let sleeps = ManualDebounceSleep() 112 let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn) 113 114 session.scheduleEndPush(after: 30) 115 try await sleeps.waitForSleeperCount(1) 116 session.supersedePendingEndPush() 117 118 sleeps.releaseAll() 119 try await Task.sleep(for: .milliseconds(120)) 120 #expect(log.ends.isEmpty) // the timer never fires its own pause 121 // The assertion is deliberately left for the expiration latch (the 122 // direct publish it covers is still in flight at this point in 123 // production). 124 #expect(log.releasedAssertions.isEmpty) 125 #expect(!session.isIdle) 126 127 // The expiration latch finds no pending timer and only releases. 128 log.expirationHandler?() 129 #expect(log.ends.isEmpty) 130 #expect(log.releasedAssertions.count == 1) 131 #expect(session.isIdle) 132 } 133 134 @Test("Catch-up banner fires after the settle delay") 135 func bannerFiresAfterSettle() async throws { 136 let log = EffectLog() 137 let sleeps = ManualDebounceSleep() 138 let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn) 139 140 session.scheduleSummaryBanner(after: 3) 141 try await sleeps.waitForSleeperCount(1) 142 sleeps.releaseAll() 143 try await waitUntil { log.banners == 1 } 144 #expect(session.isIdle) 145 } 146 147 @Test("Leaving cancels a pending catch-up banner") 148 func leaveCancelsPendingBanner() async throws { 149 let log = EffectLog() 150 let sleeps = ManualDebounceSleep() 151 let session = PuzzleSession(gameID: UUID(), effects: log.effects, sleep: sleeps.sleepFn) 152 153 session.scheduleSummaryBanner(after: 3) 154 try await sleeps.waitForSleeperCount(1) 155 session.cancelPendingSummaryBanner() 156 157 sleeps.releaseAll() 158 try await Task.sleep(for: .milliseconds(120)) 159 #expect(log.banners == 0) 160 #expect(session.isIdle) 161 } 162 }