TestHelpers.swift (6807B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 /// Creates a fresh PersistenceController with in-memory storage for isolated testing. 7 @MainActor 8 func makeTestPersistence() -> PersistenceController { 9 PersistenceController(inMemory: true) 10 } 11 12 /// Builds a `GameStore` wired with no-op collaborators for tests that don't 13 /// exercise the sync/identity/updater paths. Override only what the test needs. 14 @MainActor 15 func makeTestStore( 16 persistence: PersistenceController, 17 movesUpdater: MovesUpdater? = nil, 18 authorIDProvider: @escaping @MainActor () -> String? = { nil }, 19 onGameCreated: @escaping (String) -> Void = { _ in }, 20 onGameUpdated: @escaping (String) -> Void = { _ in }, 21 onGameDeleted: @escaping (GameCloudDeletion) -> Void = { _ in } 22 ) -> GameStore { 23 let updater = movesUpdater ?? MovesUpdater( 24 persistence: persistence, 25 writerAuthorIDProvider: { nil }, 26 sink: { _, _ in } 27 ) 28 return GameStore( 29 persistence: persistence, 30 movesUpdater: updater, 31 authorIDProvider: authorIDProvider, 32 onGameCreated: onGameCreated, 33 onGameUpdated: onGameUpdated, 34 onGameDeleted: onGameDeleted 35 ) 36 } 37 38 /// Test-controllable sleep: pending awaits hang until `releaseAll()`. Lets 39 /// debounce-based tests verify "rapid inputs coalesce" without depending on 40 /// wall-clock timing — Task.sleep on a contended simulator can wake before 41 /// the next actor hop lands, racing the cancellation that should have 42 /// suppressed the prior debounce task. 43 /// 44 /// `releaseAll()` is sticky: it opens the gate permanently, so a debounce 45 /// task that only reaches its `sleep()` *after* the test called 46 /// `releaseAll()` resumes immediately instead of parking forever. Without 47 /// this, the live (non-cancelled) debounce task — spawned with no 48 /// suspension point before `releaseAll()` — could miss the one-shot 49 /// release and strand the flush, failing the test with `count → 0`. 50 final class ManualDebounceSleep: @unchecked Sendable { 51 private let lock = NSLock() 52 private var continuations: [CheckedContinuation<Void, Never>] = [] 53 private var released = false 54 private var sleeperCount = 0 55 56 var sleepFn: @Sendable (Duration) async throws -> Void { 57 { @Sendable [weak self] _ in 58 await withCheckedContinuation { cont in 59 guard let self else { cont.resume(); return } 60 self.lock.lock() 61 self.sleeperCount += 1 62 if self.released { 63 self.lock.unlock() 64 cont.resume() 65 return 66 } 67 self.continuations.append(cont) 68 self.lock.unlock() 69 } 70 } 71 } 72 73 func releaseAll() { 74 lock.lock() 75 released = true 76 let toRelease = continuations 77 continuations.removeAll() 78 lock.unlock() 79 toRelease.forEach { $0.resume() } 80 } 81 82 func waitForSleeperCount( 83 _ expected: Int, 84 timeout: Duration = .seconds(5) 85 ) async throws { 86 let deadline = ContinuousClock.now.advanced(by: timeout) 87 while lock.withLock({ sleeperCount }) < expected, 88 ContinuousClock.now < deadline { 89 try await Task.sleep(for: .milliseconds(20)) 90 } 91 } 92 } 93 94 /// Creates a Game, GameEntity, and GameMutator backed by an in-memory store. 95 /// The puzzle is a minimal 3x3 grid with a single block at (1,1). 96 /// `movesUpdater` is nil — tests that need emission verify via MovesUpdater's own suite. 97 @MainActor 98 func makeTestGame() throws -> (Game, GameMutator, GameEntity, PersistenceController) { 99 let persistence = makeTestPersistence() 100 let context = persistence.viewContext 101 102 let source = """ 103 Title: Test Puzzle 104 Author: Test 105 106 107 ABC 108 D#E 109 FGH 110 111 112 A1. Across 1 ~ ABC 113 A4. Across 4 ~ DE 114 A5. Across 5 ~ FGH 115 D1. Down 1 ~ ADF 116 D2. Down 2 ~ BG 117 D3. Down 3 ~ CEH 118 """ 119 120 let xd = try XD.parse(source) 121 let puzzle = Puzzle(xd: xd) 122 let game = Game(puzzle: puzzle) 123 124 let gameID = UUID() 125 let entity = GameEntity(context: context) 126 entity.id = gameID 127 entity.title = puzzle.title 128 entity.puzzleSource = source 129 entity.createdAt = Date() 130 entity.updatedAt = Date() 131 entity.ckRecordName = "game-\(gameID.uuidString)" 132 133 try context.save() 134 135 let mutator = GameMutator( 136 game: game, 137 gameID: gameID, 138 movesUpdater: nil, 139 movesJournal: MovesJournal(persistence: persistence) 140 ) 141 return (game, mutator, entity, persistence) 142 } 143 144 // MARK: - NotificationState test isolation 145 146 /// Wraps a test (or whole suite) in an isolated `UserDefaults` suite so any 147 /// `NotificationState` writes never leak to the shared App-Group store, and 148 /// concurrent test suites that touch `NotificationState` can't contaminate 149 /// each other. Applied via the `.isolatedNotificationState` sugar below. 150 /// 151 /// The trait routes the override through `NotificationState.$testingDefaults` 152 /// (a `TaskLocal`), which means the scope flows naturally through actor hops 153 /// and `Task` continuations the test body kicks off. Marked recursive so that 154 /// applying it to a suite re-invokes `provideScope` per test rather than once 155 /// around the whole suite — without that, parallel sibling tests inside one 156 /// suite would share a single override and race on global `NotificationState` 157 /// keys (e.g. `activePuzzleID`). 158 struct IsolatedNotificationStateTrait: TestTrait, SuiteTrait, TestScoping { 159 var isRecursive: Bool { true } 160 161 func provideScope( 162 for test: Test, 163 testCase: Test.Case?, 164 performing function: @Sendable () async throws -> Void 165 ) async throws { 166 let suiteName = "test.notif.\(UUID().uuidString)" 167 guard let userDefaults = UserDefaults(suiteName: suiteName) else { 168 // The standard suite is always constructable in test environments, 169 // so reaching this branch indicates broken test infrastructure 170 // rather than a recoverable error — run the body unscoped so the 171 // failure surfaces in the assertions instead of being masked. 172 try await function() 173 return 174 } 175 defer { UserDefaults().removePersistentDomain(forName: suiteName) } 176 try await NotificationState.$testingDefaults.withValue( 177 NotificationState.TestingDefaults(userDefaults: userDefaults) 178 ) { 179 try await function() 180 } 181 } 182 } 183 184 extension Trait where Self == IsolatedNotificationStateTrait { 185 /// Isolates a test or suite's `NotificationState` writes into a per-call 186 /// `UserDefaults` suite. See `IsolatedNotificationStateTrait`. 187 static var isolatedNotificationState: Self { IsolatedNotificationStateTrait() } 188 }