crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }