crossmate

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

PlayerNamePublisherTests.swift (11412B)


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