crossmate

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

PlayerNamePublisherTests.swift (11409B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 @Suite("PlayerNamePublisher", .serialized)
      9 @MainActor
     10 struct PlayerNamePublisherTests {
     11 
     12     // MARK: - Helpers
     13 
     14     /// One enqueued name Decision as seen by the spy closure.
     15     private struct EnqueuedDecision: Equatable {
     16         let authorID: String
     17         let name: String
     18         let version: Int64
     19         let zoneID: CKRecordZone.ID
     20         let scope: DatabaseScope
     21     }
     22 
     23     @MainActor
     24     private final class DecisionSpy {
     25         private(set) var decisions: [EnqueuedDecision] = []
     26         func record(_ d: EnqueuedDecision) { decisions.append(d) }
     27     }
     28 
     29     /// A unique author per test keeps `NameVersionStore`'s UserDefaults state
     30     /// from leaking between runs — every test starts at generation 0.
     31     private func freshAuthorID() -> String {
     32         "_local-\(UUID().uuidString)"
     33     }
     34 
     35     private func addFriend(
     36         to persistence: PersistenceController,
     37         authorID: String,
     38         blocked: Bool = false
     39     ) throws {
     40         let ctx = persistence.viewContext
     41         let friend = FriendEntity(context: ctx)
     42         friend.authorID = authorID
     43         friend.pairKey = "pair-\(authorID)"
     44         friend.isBlocked = blocked
     45         friend.createdAt = Date()
     46         try ctx.save()
     47         if !blocked {
     48             FriendZone.markOutboxAccepted(pairKey: "pair-\(authorID)")
     49         }
     50     }
     51 
     52     private func makeBroadcaster(
     53         preferences: PlayerPreferences,
     54         persistence: PersistenceController,
     55         authorID: String,
     56         spy: DecisionSpy,
     57         enqueuePlayer: @escaping (UUID, String, String) async -> Void = { _, _, _ in }
     58     ) -> PlayerNamePublisher {
     59         PlayerNamePublisher(
     60             preferences: preferences,
     61             persistence: persistence,
     62             authorIdentity: AuthorIdentity(testing: authorID),
     63             enqueuePlayer: enqueuePlayer,
     64             enqueueNameDecision: { authorID, name, version, zoneID, scope in
     65                 await spy.record(EnqueuedDecision(
     66                     authorID: authorID,
     67                     name: name,
     68                     version: version,
     69                     zoneID: zoneID,
     70                     scope: scope
     71                 ))
     72             }
     73         )
     74     }
     75 
     76     private func fetchPlayerNames(authorID: String, in persistence: PersistenceController) -> [String] {
     77         let ctx = persistence.container.newBackgroundContext()
     78         return ctx.performAndWait {
     79             let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
     80             req.predicate = NSPredicate(format: "authorID == %@", authorID)
     81             let entities = (try? ctx.fetch(req)) ?? []
     82             return entities.compactMap(\.name)
     83         }
     84     }
     85 
     86     // MARK: - Decision fan-out
     87 
     88     @Test("broadcastName publishes to the account zone and every non-blocked friend zone")
     89     func broadcastNameFansOutDecisions() async throws {
     90         let persistence = makeTestPersistence()
     91         let authorID = freshAuthorID()
     92         try addFriend(to: persistence, authorID: "_bob")
     93         try addFriend(to: persistence, authorID: "_carol")
     94         try addFriend(to: persistence, authorID: "_mallory", blocked: true)
     95 
     96         let prefs = PlayerPreferences(
     97             local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
     98         )
     99         prefs.name = "Alice"
    100         let spy = DecisionSpy()
    101         let broadcaster = makeBroadcaster(
    102             preferences: prefs, persistence: persistence,
    103             authorID: authorID, spy: spy
    104         )
    105 
    106         await broadcaster.broadcastName()
    107 
    108         // Account zone copy plus one per non-blocked friend; blocked zone skipped.
    109         #expect(spy.decisions.count == 3)
    110         #expect(spy.decisions.allSatisfy { $0.authorID == authorID })
    111         #expect(spy.decisions.allSatisfy { $0.name == "Alice" })
    112         #expect(spy.decisions.allSatisfy { $0.version == 1 })
    113         // A friend's name is written into their inbox — our outbox: shared
    114         // scope, owned by the friend, both zones named `friend-<pairKey>`.
    115         let bobZone = FriendZone.outboxZoneID(pairKey: "pair-_bob", friendAuthorID: "_bob")
    116         let carolZone = FriendZone.outboxZoneID(pairKey: "pair-_carol", friendAuthorID: "_carol")
    117         let zoneNames = Set(spy.decisions.map(\.zoneID.zoneName))
    118         #expect(zoneNames == [
    119             RecordSerializer.accountZoneID.zoneName, bobZone.zoneName, carolZone.zoneName
    120         ])
    121         let carol = spy.decisions.first { $0.zoneID.zoneName == carolZone.zoneName }
    122         #expect(carol?.scope == .shared)
    123         #expect(carol?.zoneID.ownerName == "_carol")
    124         // Fan-out no longer touches per-game Player rows.
    125         #expect(fetchPlayerNames(authorID: authorID, in: persistence).isEmpty)
    126 
    127         withExtendedLifetime(broadcaster) {}
    128     }
    129 
    130     @Test("each broadcast bumps the name generation")
    131     func broadcastNameBumpsVersion() async throws {
    132         let persistence = makeTestPersistence()
    133         let authorID = freshAuthorID()
    134         let prefs = PlayerPreferences(
    135             local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
    136         )
    137         prefs.name = "Alice"
    138         let spy = DecisionSpy()
    139         let broadcaster = makeBroadcaster(
    140             preferences: prefs, persistence: persistence,
    141             authorID: authorID, spy: spy
    142         )
    143 
    144         await broadcaster.broadcastName()
    145         prefs.name = "Alicia"
    146         await broadcaster.broadcastName()
    147 
    148         #expect(spy.decisions.map(\.version) == [1, 2])
    149         #expect(spy.decisions.map(\.name) == ["Alice", "Alicia"])
    150 
    151         withExtendedLifetime(broadcaster) {}
    152     }
    153 
    154     @Test("broadcastName skips an empty or whitespace-only name")
    155     func broadcastNameSkipsEmptyName() async throws {
    156         let persistence = makeTestPersistence()
    157         let authorID = freshAuthorID()
    158         let prefs = PlayerPreferences(
    159             local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
    160         )
    161         prefs.name = "   "
    162         let spy = DecisionSpy()
    163         let broadcaster = makeBroadcaster(
    164             preferences: prefs, persistence: persistence,
    165             authorID: authorID, spy: spy
    166         )
    167 
    168         await broadcaster.broadcastName()
    169 
    170         #expect(spy.decisions.isEmpty)
    171         // The skipped broadcast must not consume a generation.
    172         #expect(NameVersionStore.current(authorID: authorID) == 0)
    173 
    174         withExtendedLifetime(broadcaster) {}
    175     }
    176 
    177     // MARK: - Per-game snapshot (game open)
    178 
    179     @Test("publishName writes only the requested shared game")
    180     func publishNameWritesOnlyRequestedGame() async throws {
    181         let p = makeTestPersistence()
    182         let ctx = p.viewContext
    183         let firstID = UUID()
    184         let secondID = UUID()
    185         for id in [firstID, secondID] {
    186             let entity = GameEntity(context: ctx)
    187             entity.id = id
    188             entity.title = "Shared"
    189             entity.puzzleSource = "## Metadata\nTitle: Shared\n"
    190             entity.createdAt = Date()
    191             entity.updatedAt = Date()
    192             entity.ckRecordName = RecordSerializer.recordName(forGameID: id)
    193             entity.ckShareRecordName = "share-\(id.uuidString)"
    194         }
    195         try ctx.save()
    196 
    197         let authorID = freshAuthorID()
    198         let prefs = PlayerPreferences(
    199             local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
    200         )
    201         prefs.name = "Alice"
    202         let spy = DecisionSpy()
    203         var enqueued: [UUID] = []
    204         let broadcaster = makeBroadcaster(
    205             preferences: prefs, persistence: p,
    206             authorID: authorID, spy: spy,
    207             enqueuePlayer: { gameID, _, _ in enqueued.append(gameID) }
    208         )
    209 
    210         await broadcaster.publishName(for: secondID)
    211 
    212         #expect(enqueued == [secondID])
    213         #expect(fetchPlayerNames(authorID: authorID, in: p) == ["Alice"])
    214         // Opening a game publishes no Decisions.
    215         #expect(spy.decisions.isEmpty)
    216 
    217         withExtendedLifetime(broadcaster) {}
    218     }
    219 
    220     @Test("publishName is a no-op for a non-shared game")
    221     func publishNameSkipsNonSharedGame() async throws {
    222         let p = makeTestPersistence()
    223         let ctx = p.viewContext
    224         let gameID = UUID()
    225         let entity = GameEntity(context: ctx)
    226         entity.id = gameID
    227         entity.title = "Solo"
    228         entity.puzzleSource = ""
    229         entity.createdAt = Date()
    230         entity.updatedAt = Date()
    231         entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID)
    232         try ctx.save()
    233 
    234         let authorID = freshAuthorID()
    235         let prefs = PlayerPreferences(
    236             local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
    237         )
    238         prefs.name = "Alice"
    239         let spy = DecisionSpy()
    240         let broadcaster = makeBroadcaster(
    241             preferences: prefs, persistence: p,
    242             authorID: authorID, spy: spy
    243         )
    244 
    245         await broadcaster.publishName(for: gameID)
    246 
    247         #expect(fetchPlayerNames(authorID: authorID, in: p).isEmpty)
    248 
    249         withExtendedLifetime(broadcaster) {}
    250     }
    251 
    252     // MARK: - Debounce
    253 
    254     @Test("Debounce coalesces two rapid name changes into one fan-out with the final name")
    255     func debounceCoalescesPair() async throws {
    256         let persistence = makeTestPersistence()
    257         let authorID = freshAuthorID()
    258         let prefs = PlayerPreferences(
    259             local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
    260         )
    261         let spy = DecisionSpy()
    262         let broadcaster = makeBroadcaster(
    263             preferences: prefs, persistence: persistence,
    264             authorID: authorID, spy: spy
    265         )
    266 
    267         let fanOuts = FanOutSpy()
    268         broadcaster.onFanOutForTesting = { name in fanOuts.record(name) }
    269 
    270         // Allow the observation task to make its first withObservationTracking
    271         // registration before we mutate any values.
    272         await Task.yield()
    273 
    274         // First change — debounce timer #1 starts.
    275         prefs.name = "Alice"
    276         await Task.yield() // observation task → scheduleDebounce
    277 
    278         // Second change before timer fires — must cancel #1, start #2.
    279         prefs.name = "Bob"
    280         await Task.yield() // observation task → cancel #1, start #2
    281 
    282         // Poll until the (single, hopefully) fan-out fires. Loose deadline so
    283         // CI scheduler jitter doesn't fail the test.
    284         let deadline = Date().addingTimeInterval(2.0)
    285         while fanOuts.count == 0 && Date() < deadline {
    286             try await Task.sleep(for: .milliseconds(20))
    287         }
    288 
    289         // Grace period to catch a stray second fan-out from an uncancelled
    290         // timer — the bug we're guarding against would surface here as count==2.
    291         try await Task.sleep(for: .milliseconds(150))
    292 
    293         #expect(fanOuts.count == 1, "two rapid changes should debounce into one fan-out")
    294         #expect(fanOuts.names.last == "Bob", "the final name should win")
    295         // One coalesced rename → one generation, carrying the final name.
    296         #expect(spy.decisions.map(\.version) == [1])
    297         #expect(spy.decisions.first?.name == "Bob")
    298 
    299         // Keep broadcaster alive until assertions are done.
    300         withExtendedLifetime(broadcaster) {}
    301     }
    302 }
    303 
    304 @MainActor
    305 private final class FanOutSpy {
    306     private(set) var names: [String] = []
    307     var count: Int { names.count }
    308     func record(_ name: String) { names.append(name) }
    309 }