crossmate

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

PlayerNamePublisherTests.swift (5780B)


      1 import CoreData
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 @Suite("PlayerNamePublisher", .serialized)
      8 @MainActor
      9 struct PlayerNamePublisherTests {
     10 
     11     // MARK: - Helpers
     12 
     13     /// Creates a persistence store with one game that qualifies for fan-out
     14     /// (it has a `ckShareRecordName`, so `upsertPlayerRecords` will visit it).
     15     private func makeSharedGame() throws -> (PersistenceController, UUID) {
     16         let p = makeTestPersistence()
     17         let ctx = p.viewContext
     18         let gameID = UUID()
     19         let entity = GameEntity(context: ctx)
     20         entity.id = gameID
     21         entity.title = "Shared Test"
     22         entity.puzzleSource = ""
     23         entity.createdAt = Date()
     24         entity.updatedAt = Date()
     25         entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID)
     26         entity.ckShareRecordName = "share-\(UUID().uuidString)"
     27         try ctx.save()
     28         return (p, gameID)
     29     }
     30 
     31     private func makeNonSharedGame() throws -> (PersistenceController, UUID) {
     32         let p = makeTestPersistence()
     33         let ctx = p.viewContext
     34         let gameID = UUID()
     35         let entity = GameEntity(context: ctx)
     36         entity.id = gameID
     37         entity.title = "Solo Test"
     38         entity.puzzleSource = ""
     39         entity.createdAt = Date()
     40         entity.updatedAt = Date()
     41         entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID)
     42         // No ckShareRecordName and databaseScope == 0 → not picked up by fan-out.
     43         try ctx.save()
     44         return (p, gameID)
     45     }
     46 
     47     private func fetchPlayerName(authorID: String, in persistence: PersistenceController) -> String? {
     48         let ctx = persistence.container.newBackgroundContext()
     49         return ctx.performAndWait {
     50             let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
     51             req.predicate = NSPredicate(format: "authorID == %@", authorID)
     52             req.fetchLimit = 1
     53             return (try? ctx.fetch(req).first)?.name
     54         }
     55     }
     56 
     57     private func makeBroadcaster(
     58         preferences: PlayerPreferences,
     59         persistence: PersistenceController,
     60         authorID: String
     61     ) -> PlayerNamePublisher {
     62         PlayerNamePublisher(
     63             preferences: preferences,
     64             persistence: persistence,
     65             authorIdentity: AuthorIdentity(testing: authorID),
     66             enqueuePlayerRecord: { _, _ in }
     67         )
     68     }
     69 
     70     // MARK: - Tests
     71 
     72     @Test("broadcastName writes a PlayerEntity for shared/joined games")
     73     func broadcastNameWritesPlayerEntityForSharedGame() async throws {
     74         let (persistence, _) = try makeSharedGame()
     75         let prefs = PlayerPreferences(
     76             local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
     77         )
     78         prefs.name = "Alice"
     79         let broadcaster = makeBroadcaster(
     80             preferences: prefs,
     81             persistence: persistence,
     82             authorID: "_local"
     83         )
     84 
     85         await broadcaster.broadcastName()
     86 
     87         #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Alice")
     88     }
     89 
     90     @Test("broadcastName is a no-op for non-shared games")
     91     func broadcastNameSkipsNonSharedGames() async throws {
     92         let (persistence, _) = try makeNonSharedGame()
     93         let prefs = PlayerPreferences(
     94             local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
     95         )
     96         prefs.name = "Alice"
     97         let broadcaster = makeBroadcaster(
     98             preferences: prefs,
     99             persistence: persistence,
    100             authorID: "_local"
    101         )
    102 
    103         await broadcaster.broadcastName()
    104 
    105         #expect(fetchPlayerName(authorID: "_local", in: persistence) == nil)
    106     }
    107 
    108     @Test("Debounce coalesces two rapid name changes into one fan-out with the final name")
    109     func debounceCoalescesPair() async throws {
    110         let (persistence, _) = try makeSharedGame()
    111         let prefs = PlayerPreferences(
    112             local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
    113         )
    114         let broadcaster = makeBroadcaster(
    115             preferences: prefs,
    116             persistence: persistence,
    117             authorID: "_local"
    118         )
    119 
    120         let spy = FanOutSpy()
    121         broadcaster.onFanOutForTesting = { name in spy.record(name) }
    122 
    123         // Allow the observation task to make its first withObservationTracking
    124         // registration before we mutate any values.
    125         await Task.yield()
    126 
    127         // First change — debounce timer #1 starts.
    128         prefs.name = "Alice"
    129         await Task.yield() // observation task → scheduleDebounce
    130 
    131         // Second change before timer fires — must cancel #1, start #2.
    132         prefs.name = "Bob"
    133         await Task.yield() // observation task → cancel #1, start #2
    134 
    135         // Poll until the (single, hopefully) fan-out fires. Loose deadline so
    136         // CI scheduler jitter doesn't fail the test.
    137         let deadline = Date().addingTimeInterval(2.0)
    138         while spy.count == 0 && Date() < deadline {
    139             try await Task.sleep(for: .milliseconds(20))
    140         }
    141 
    142         // Grace period to catch a stray second fan-out from an uncancelled
    143         // timer — the bug we're guarding against would surface here as count==2.
    144         try await Task.sleep(for: .milliseconds(150))
    145 
    146         #expect(spy.count == 1, "two rapid changes should debounce into one fan-out")
    147         #expect(spy.names.last == "Bob", "the final name should win")
    148         #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Bob")
    149 
    150         // Keep broadcaster alive until assertions are done.
    151         withExtendedLifetime(broadcaster) {}
    152     }
    153 }
    154 
    155 @MainActor
    156 private final class FanOutSpy {
    157     private(set) var names: [String] = []
    158     var count: Int { names.count }
    159     func record(_ name: String) { names.append(name) }
    160 }