crossmate

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

PlayerSelectionPublisherTests.swift (19252B)


      1 import CoreData
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 @Suite("PlayerSelectionPublisher", .serialized)
      8 @MainActor
      9 struct PlayerSelectionPublisherTests {
     10 
     11     /// Captures the `(gameID, authorID)` sink fan-outs.
     12     actor Capture {
     13         private(set) var notifications: [(UUID, String)] = []
     14         var count: Int { notifications.count }
     15         func append(_ gameID: UUID, _ authorID: String) {
     16             notifications.append((gameID, authorID))
     17         }
     18     }
     19 
     20     /// Mutable flag for tests that need to toggle the peer-presence
     21     /// predicate mid-test.
     22     actor PresenceFlag {
     23         private(set) var value: Bool = false
     24         func set(_ newValue: Bool) { value = newValue }
     25     }
     26 
     27     /// Records the durations handed to the injected sleep.
     28     actor DurationRecorder {
     29         private(set) var durations: [Duration] = []
     30         func record(_ duration: Duration) { durations.append(duration) }
     31     }
     32 
     33     /// Records the game IDs the interval provider is consulted for.
     34     actor GameIDRecorder {
     35         private(set) var ids: [UUID] = []
     36         func record(_ id: UUID) { ids.append(id) }
     37     }
     38 
     39     private func makePersistenceWithGame() throws -> (PersistenceController, UUID) {
     40         let persistence = makeTestPersistence()
     41         let context = persistence.viewContext
     42         let gameID = UUID()
     43         let entity = GameEntity(context: context)
     44         entity.id = gameID
     45         entity.title = "Test"
     46         entity.puzzleSource = ""
     47         entity.createdAt = Date()
     48         entity.updatedAt = Date()
     49         entity.ckRecordName = "game-\(gameID.uuidString)"
     50         try context.save()
     51         return (persistence, gameID)
     52     }
     53 
     54     @Test("Publish + flush writes the PlayerEntity row and notifies the sink")
     55     func publishWritesEntity() async throws {
     56         let (persistence, gameID) = try makePersistenceWithGame()
     57         let capture = Capture()
     58         let publisher = PlayerSelectionPublisher(
     59             debounceInterval: { _ in .seconds(10) },
     60             persistence: persistence,
     61             sink: { id, author, _ in await capture.append(id, author) }
     62         )
     63 
     64         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
     65         await publisher.publish(PlayerSelection(row: 3, col: 4, direction: .down))
     66         await publisher.flush()
     67 
     68         let count = await capture.count
     69         #expect(count == 1)
     70         let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
     71         #expect(values?.selRow == 3)
     72         #expect(values?.selCol == 4)
     73         #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue))
     74         #expect(values?.ckRecordName == "player-\(gameID.uuidString)-alice")
     75     }
     76 
     77     @Test("Repeated identical selections don't refire the sink")
     78     func dedupesIdenticalSelections() async throws {
     79         let (persistence, gameID) = try makePersistenceWithGame()
     80         let capture = Capture()
     81         let publisher = PlayerSelectionPublisher(
     82             debounceInterval: { _ in .milliseconds(40) },
     83             persistence: persistence,
     84             sink: { id, author, _ in await capture.append(id, author) }
     85         )
     86 
     87         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
     88         let selection = PlayerSelection(row: 0, col: 0, direction: .across)
     89         await publisher.publish(selection)
     90         try await Task.sleep(for: .milliseconds(120))
     91         await publisher.publish(selection)
     92         await publisher.publish(selection)
     93         try await Task.sleep(for: .milliseconds(120))
     94 
     95         let count = await capture.count
     96         #expect(count == 1)
     97     }
     98 
     99     @Test("Rapid distinct selections coalesce into a single trailing flush")
    100     func debounceCoalescesRapidPublishes() async throws {
    101         let (persistence, gameID) = try makePersistenceWithGame()
    102         let capture = Capture()
    103         let manualSleep = ManualDebounceSleep()
    104         let publisher = PlayerSelectionPublisher(
    105             debounceInterval: { _ in .seconds(10) },
    106             persistence: persistence,
    107             sink: { id, author, _ in await capture.append(id, author) },
    108             sleep: manualSleep.sleepFn
    109         )
    110 
    111         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    112         for col in 0...4 {
    113             await publisher.publish(PlayerSelection(row: 0, col: col, direction: .across))
    114         }
    115         // Each publish cancelled the prior debounce task; only the latest is
    116         // live. Releasing the manual sleep wakes every captured continuation
    117         // — the cancelled tasks observe Task.isCancelled and exit without
    118         // flushing; the live task proceeds to flush with the final value.
    119         manualSleep.releaseAll()
    120         try await waitForCount(1, capture: capture)
    121 
    122         let count = await capture.count
    123         #expect(count == 1)
    124         let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
    125         #expect(values?.selCol == 4)
    126     }
    127 
    128     @Test("Debounce interval is sourced from the provider, keyed by the active game")
    129     func debounceIntervalComesFromProvider() async throws {
    130         let (persistence, gameID) = try makePersistenceWithGame()
    131         let capture = Capture()
    132         let sleepRecorder = DurationRecorder()
    133         let providerGames = GameIDRecorder()
    134         let publisher = PlayerSelectionPublisher(
    135             debounceInterval: { id in
    136                 await providerGames.record(id)
    137                 return .milliseconds(1234)
    138             },
    139             persistence: persistence,
    140             sink: { id, author, _ in await capture.append(id, author) },
    141             sleep: { duration in await sleepRecorder.record(duration) }
    142         )
    143 
    144         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    145         await publisher.publish(PlayerSelection(row: 0, col: 1, direction: .across))
    146         try await waitForCount(1, capture: capture)
    147 
    148         // The provider was asked for this game, and its returned interval is
    149         // exactly what the debounce slept on.
    150         #expect(await providerGames.ids == [gameID])
    151         #expect(await sleepRecorder.durations == [.milliseconds(1234)])
    152     }
    153 
    154     private func waitForCount(
    155         _ expected: Int,
    156         capture: Capture,
    157         timeout: Duration = .seconds(5)
    158     ) async throws {
    159         let deadline = ContinuousClock.now.advanced(by: timeout)
    160         while await capture.count != expected,
    161               ContinuousClock.now < deadline {
    162             try await Task.sleep(for: .milliseconds(20))
    163         }
    164     }
    165 
    166     @Test("Clear nils the selection fields and notifies the sink")
    167     func clearWritesNilSelection() async throws {
    168         let (persistence, gameID) = try makePersistenceWithGame()
    169         let capture = Capture()
    170         let publisher = PlayerSelectionPublisher(
    171             debounceInterval: { _ in .seconds(10) },
    172             persistence: persistence,
    173             sink: { id, author, _ in await capture.append(id, author) }
    174         )
    175 
    176         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    177         await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .down))
    178         await publisher.flush()
    179         await publisher.clear()
    180 
    181         let count = await capture.count
    182         #expect(count == 2)
    183         let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
    184         #expect(values != nil)
    185         #expect(values?.selRow == nil)
    186         #expect(values?.selCol == nil)
    187         #expect(values?.selDir == nil)
    188     }
    189 
    190     @Test("Clear is a no-op when nothing has been published yet")
    191     func clearWithoutPriorPublishIsNoOp() async throws {
    192         let (persistence, gameID) = try makePersistenceWithGame()
    193         let capture = Capture()
    194         let publisher = PlayerSelectionPublisher(
    195             debounceInterval: { _ in .seconds(10) },
    196             persistence: persistence,
    197             sink: { id, author, _ in await capture.append(id, author) }
    198         )
    199 
    200         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    201         await publisher.clear()
    202 
    203         let count = await capture.count
    204         #expect(count == 0)
    205     }
    206 
    207     @Test("Publish without begin is silently ignored")
    208     func publishBeforeBeginIsDropped() async throws {
    209         let (persistence, _) = try makePersistenceWithGame()
    210         let capture = Capture()
    211         let publisher = PlayerSelectionPublisher(
    212             debounceInterval: { _ in .milliseconds(40) },
    213             persistence: persistence,
    214             sink: { id, author, _ in await capture.append(id, author) }
    215         )
    216 
    217         await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across))
    218         await publisher.flush()
    219 
    220         let count = await capture.count
    221         #expect(count == 0)
    222     }
    223 
    224     @Test("Updates an existing PlayerEntity rather than creating a duplicate")
    225     func updatesExistingEntity() async throws {
    226         let (persistence, gameID) = try makePersistenceWithGame()
    227         // Pre-seed a PlayerEntity (e.g. from a name broadcast or remote sync).
    228         let context = persistence.viewContext
    229         let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    230         gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    231         let game = try #require(try context.fetch(gameReq).first)
    232         let preexisting = PlayerEntity(context: context)
    233         preexisting.game = game
    234         preexisting.authorID = "alice"
    235         preexisting.name = "Alice"
    236         preexisting.updatedAt = Date()
    237         preexisting.ckRecordName = "player-\(gameID.uuidString)-alice"
    238         try context.save()
    239 
    240         let publisher = PlayerSelectionPublisher(
    241             debounceInterval: { _ in .seconds(10) },
    242             persistence: persistence,
    243             sink: { _, _, _ in }
    244         )
    245         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    246         await publisher.publish(PlayerSelection(row: 5, col: 6, direction: .across))
    247         await publisher.flush()
    248 
    249         let rows = fetchAllPlayers(gameID: gameID, persistence: persistence)
    250         #expect(rows.count == 1)
    251         #expect(rows.first?.name == "Alice") // pre-existing field preserved
    252         #expect(rows.first?.selRow == 5)
    253         #expect(rows.first?.selCol == 6)
    254     }
    255 
    256     @Test("Gate denied: Core Data row still updated but sink is skipped")
    257     func gateDeniedSkipsSink() async throws {
    258         let (persistence, gameID) = try makePersistenceWithGame()
    259         let capture = Capture()
    260         let publisher = PlayerSelectionPublisher(
    261             debounceInterval: { _ in .seconds(10) },
    262             persistence: persistence,
    263             sink: { id, author, _ in await capture.append(id, author) },
    264             peerPresent: { _ in false }
    265         )
    266 
    267         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    268         await publisher.publish(PlayerSelection(row: 3, col: 4, direction: .down))
    269         await publisher.flush()
    270 
    271         let count = await capture.count
    272         #expect(count == 0)
    273         let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
    274         #expect(values?.selRow == 3)
    275         #expect(values?.selCol == 4)
    276     }
    277 
    278     @Test("Gate denied then peerPresenceMayHaveChanged ships the held selection")
    279     func gateResumeShipsPending() async throws {
    280         let (persistence, gameID) = try makePersistenceWithGame()
    281         let capture = Capture()
    282         let presence = PresenceFlag()
    283         let publisher = PlayerSelectionPublisher(
    284             debounceInterval: { _ in .seconds(10) },
    285             persistence: persistence,
    286             sink: { id, author, _ in await capture.append(id, author) },
    287             peerPresent: { _ in await presence.value }
    288         )
    289 
    290         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    291         await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .across))
    292         await publisher.flush()
    293         #expect(await capture.count == 0)
    294 
    295         await presence.set(true)
    296         await publisher.peerPresenceMayHaveChanged()
    297         #expect(await capture.count == 1)
    298 
    299         // A subsequent publish to the same selection shouldn't refire.
    300         await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .across))
    301         await publisher.flush()
    302         #expect(await capture.count == 1)
    303     }
    304 
    305     @Test("peerPresenceMayHaveChanged does not pre-empt an active debounce")
    306     func gateResumeRespectsActiveDebounce() async throws {
    307         let (persistence, gameID) = try makePersistenceWithGame()
    308         let capture = Capture()
    309         let manualSleep = ManualDebounceSleep()
    310         let publisher = PlayerSelectionPublisher(
    311             debounceInterval: { _ in .seconds(10) },
    312             persistence: persistence,
    313             sink: { id, author, _ in await capture.append(id, author) },
    314             peerPresent: { _ in true },
    315             sleep: manualSleep.sleepFn
    316         )
    317 
    318         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    319         await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across))
    320         // Debounce is now scheduled. A peer-presence nudge here must not
    321         // pre-empt it — that would let a chatty partner pressure us into
    322         // one CloudKit write per partner-move.
    323         await publisher.peerPresenceMayHaveChanged()
    324         #expect(await capture.count == 0)
    325 
    326         // Release the debounce normally and confirm we still ship exactly once.
    327         manualSleep.releaseAll()
    328         try await waitForCount(1, capture: capture)
    329         #expect(await capture.count == 1)
    330     }
    331 
    332     @Test("peerPresenceMayHaveChanged with non-matching gameIDs is a no-op")
    333     func gateResumeScopedByGameID() async throws {
    334         let (persistence, gameID) = try makePersistenceWithGame()
    335         let capture = Capture()
    336         let publisher = PlayerSelectionPublisher(
    337             debounceInterval: { _ in .seconds(10) },
    338             persistence: persistence,
    339             sink: { id, author, _ in await capture.append(id, author) },
    340             peerPresent: { _ in true }
    341         )
    342 
    343         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    344         await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across))
    345         // Don't flush — keep pending set by skipping the debounce. Force a
    346         // gated state by swapping the predicate isn't supported, so instead
    347         // verify scoping: a resume call for a different game shouldn't drain.
    348         await publisher.peerPresenceMayHaveChanged(gameIDs: [UUID()])
    349         #expect(await capture.count == 0)
    350     }
    351 
    352     @Test("publishImmediately bypasses the gate")
    353     func publishImmediatelyBypassesGate() async throws {
    354         let (persistence, gameID) = try makePersistenceWithGame()
    355         let capture = Capture()
    356         let publisher = PlayerSelectionPublisher(
    357             debounceInterval: { _ in .seconds(10) },
    358             persistence: persistence,
    359             sink: { id, author, _ in await capture.append(id, author) },
    360             peerPresent: { _ in false }
    361         )
    362 
    363         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    364         await publisher.publishImmediately(PlayerSelection(row: 7, col: 8, direction: .across))
    365 
    366         let count = await capture.count
    367         #expect(count == 1)
    368     }
    369 
    370     @Test("clear bypasses the gate")
    371     func clearBypassesGate() async throws {
    372         let (persistence, gameID) = try makePersistenceWithGame()
    373         let capture = Capture()
    374         let publisher = PlayerSelectionPublisher(
    375             debounceInterval: { _ in .seconds(10) },
    376             persistence: persistence,
    377             sink: { id, author, _ in await capture.append(id, author) },
    378             peerPresent: { _ in false }
    379         )
    380 
    381         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    382         await publisher.publishImmediately(PlayerSelection(row: 1, col: 1, direction: .down))
    383         await publisher.clear()
    384 
    385         let count = await capture.count
    386         #expect(count == 2)
    387     }
    388 
    389     @Test("Publishing again after clear writes the new selection")
    390     func publishAfterClear() async throws {
    391         let (persistence, gameID) = try makePersistenceWithGame()
    392         let capture = Capture()
    393         let publisher = PlayerSelectionPublisher(
    394             debounceInterval: { _ in .seconds(10) },
    395             persistence: persistence,
    396             sink: { id, author, _ in await capture.append(id, author) }
    397         )
    398 
    399         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    400         await publisher.publish(PlayerSelection(row: 1, col: 1, direction: .across))
    401         await publisher.flush()
    402         await publisher.clear()
    403         await publisher.publish(PlayerSelection(row: 2, col: 2, direction: .down))
    404         await publisher.flush()
    405 
    406         let count = await capture.count
    407         #expect(count == 3)
    408         let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
    409         #expect(values?.selRow == 2)
    410         #expect(values?.selCol == 2)
    411         #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue))
    412     }
    413 
    414     // MARK: - Helpers
    415 
    416     struct PlayerValues {
    417         let name: String?
    418         let selRow: Int64?
    419         let selCol: Int64?
    420         let selDir: Int64?
    421         let ckRecordName: String?
    422     }
    423 
    424     private func fetchPlayer(
    425         gameID: UUID,
    426         authorID: String,
    427         persistence: PersistenceController
    428     ) -> PlayerValues? {
    429         let context = persistence.container.newBackgroundContext()
    430         return context.performAndWait {
    431             let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    432             request.predicate = NSPredicate(
    433                 format: "game.id == %@ AND authorID == %@",
    434                 gameID as CVarArg,
    435                 authorID
    436             )
    437             request.fetchLimit = 1
    438             guard let entity = try? context.fetch(request).first else { return nil }
    439             return PlayerValues(
    440                 name: entity.name,
    441                 selRow: entity.selRow?.int64Value,
    442                 selCol: entity.selCol?.int64Value,
    443                 selDir: entity.selDir?.int64Value,
    444                 ckRecordName: entity.ckRecordName
    445             )
    446         }
    447     }
    448 
    449     private func fetchAllPlayers(
    450         gameID: UUID,
    451         persistence: PersistenceController
    452     ) -> [PlayerValues] {
    453         let context = persistence.container.newBackgroundContext()
    454         return context.performAndWait {
    455             let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    456             request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
    457             guard let entities = try? context.fetch(request) else { return [] }
    458             return entities.map {
    459                 PlayerValues(
    460                     name: $0.name,
    461                     selRow: $0.selRow?.int64Value,
    462                     selCol: $0.selCol?.int64Value,
    463                     selDir: $0.selDir?.int64Value,
    464                     ckRecordName: $0.ckRecordName
    465                 )
    466             }
    467         }
    468     }
    469 }