crossmate

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

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 }