crossmate

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

PlayerSelectionPublisherTests.swift (10962B)


      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     private func makePersistenceWithGame() throws -> (PersistenceController, UUID) {
     21         let persistence = makeTestPersistence()
     22         let context = persistence.viewContext
     23         let gameID = UUID()
     24         let entity = GameEntity(context: context)
     25         entity.id = gameID
     26         entity.title = "Test"
     27         entity.puzzleSource = ""
     28         entity.createdAt = Date()
     29         entity.updatedAt = Date()
     30         entity.ckRecordName = "game-\(gameID.uuidString)"
     31         try context.save()
     32         return (persistence, gameID)
     33     }
     34 
     35     @Test("Publish + flush writes the PlayerEntity row and notifies the sink")
     36     func publishWritesEntity() async throws {
     37         let (persistence, gameID) = try makePersistenceWithGame()
     38         let capture = Capture()
     39         let publisher = PlayerSelectionPublisher(
     40             debounceInterval: .seconds(10),
     41             persistence: persistence,
     42             sink: { id, author in await capture.append(id, author) }
     43         )
     44 
     45         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
     46         await publisher.publish(PlayerSelection(row: 3, col: 4, direction: .down))
     47         await publisher.flush()
     48 
     49         let count = await capture.count
     50         #expect(count == 1)
     51         let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
     52         #expect(values?.selRow == 3)
     53         #expect(values?.selCol == 4)
     54         #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue))
     55         #expect(values?.ckRecordName == "player-\(gameID.uuidString)-alice")
     56     }
     57 
     58     @Test("Repeated identical selections don't refire the sink")
     59     func dedupesIdenticalSelections() async throws {
     60         let (persistence, gameID) = try makePersistenceWithGame()
     61         let capture = Capture()
     62         let publisher = PlayerSelectionPublisher(
     63             debounceInterval: .milliseconds(40),
     64             persistence: persistence,
     65             sink: { id, author in await capture.append(id, author) }
     66         )
     67 
     68         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
     69         let selection = PlayerSelection(row: 0, col: 0, direction: .across)
     70         await publisher.publish(selection)
     71         try await Task.sleep(for: .milliseconds(120))
     72         await publisher.publish(selection)
     73         await publisher.publish(selection)
     74         try await Task.sleep(for: .milliseconds(120))
     75 
     76         let count = await capture.count
     77         #expect(count == 1)
     78     }
     79 
     80     @Test("Rapid distinct selections coalesce into a single trailing flush")
     81     func debounceCoalescesRapidPublishes() async throws {
     82         let (persistence, gameID) = try makePersistenceWithGame()
     83         let capture = Capture()
     84         let publisher = PlayerSelectionPublisher(
     85             debounceInterval: .milliseconds(80),
     86             persistence: persistence,
     87             sink: { id, author in await capture.append(id, author) }
     88         )
     89 
     90         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
     91         for col in 0...4 {
     92             await publisher.publish(PlayerSelection(row: 0, col: col, direction: .across))
     93             try await Task.sleep(for: .milliseconds(20))
     94         }
     95         try await Task.sleep(for: .milliseconds(250))
     96 
     97         let count = await capture.count
     98         #expect(count == 1)
     99         let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
    100         #expect(values?.selCol == 4)
    101     }
    102 
    103     @Test("Clear nils the selection fields and notifies the sink")
    104     func clearWritesNilSelection() async throws {
    105         let (persistence, gameID) = try makePersistenceWithGame()
    106         let capture = Capture()
    107         let publisher = PlayerSelectionPublisher(
    108             debounceInterval: .seconds(10),
    109             persistence: persistence,
    110             sink: { id, author in await capture.append(id, author) }
    111         )
    112 
    113         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    114         await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .down))
    115         await publisher.flush()
    116         await publisher.clear()
    117         // clear() spawns a Task internally — give it a moment to land.
    118         try await Task.sleep(for: .milliseconds(50))
    119 
    120         let count = await capture.count
    121         #expect(count == 2)
    122         let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
    123         #expect(values != nil)
    124         #expect(values?.selRow == nil)
    125         #expect(values?.selCol == nil)
    126         #expect(values?.selDir == nil)
    127     }
    128 
    129     @Test("Clear is a no-op when nothing has been published yet")
    130     func clearWithoutPriorPublishIsNoOp() async throws {
    131         let (persistence, gameID) = try makePersistenceWithGame()
    132         let capture = Capture()
    133         let publisher = PlayerSelectionPublisher(
    134             debounceInterval: .seconds(10),
    135             persistence: persistence,
    136             sink: { id, author in await capture.append(id, author) }
    137         )
    138 
    139         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    140         await publisher.clear()
    141         try await Task.sleep(for: .milliseconds(50))
    142 
    143         let count = await capture.count
    144         #expect(count == 0)
    145     }
    146 
    147     @Test("Publish without begin is silently ignored")
    148     func publishBeforeBeginIsDropped() async throws {
    149         let (persistence, _) = try makePersistenceWithGame()
    150         let capture = Capture()
    151         let publisher = PlayerSelectionPublisher(
    152             debounceInterval: .milliseconds(40),
    153             persistence: persistence,
    154             sink: { id, author in await capture.append(id, author) }
    155         )
    156 
    157         await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across))
    158         await publisher.flush()
    159 
    160         let count = await capture.count
    161         #expect(count == 0)
    162     }
    163 
    164     @Test("Updates an existing PlayerEntity rather than creating a duplicate")
    165     func updatesExistingEntity() async throws {
    166         let (persistence, gameID) = try makePersistenceWithGame()
    167         // Pre-seed a PlayerEntity (e.g. from a name broadcast or remote sync).
    168         let context = persistence.viewContext
    169         let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    170         gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    171         let game = try #require(try context.fetch(gameReq).first)
    172         let preexisting = PlayerEntity(context: context)
    173         preexisting.game = game
    174         preexisting.authorID = "alice"
    175         preexisting.name = "Alice"
    176         preexisting.updatedAt = Date()
    177         preexisting.ckRecordName = "player-\(gameID.uuidString)-alice"
    178         try context.save()
    179 
    180         let publisher = PlayerSelectionPublisher(
    181             debounceInterval: .seconds(10),
    182             persistence: persistence,
    183             sink: { _, _ in }
    184         )
    185         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    186         await publisher.publish(PlayerSelection(row: 5, col: 6, direction: .across))
    187         await publisher.flush()
    188 
    189         let rows = fetchAllPlayers(gameID: gameID, persistence: persistence)
    190         #expect(rows.count == 1)
    191         #expect(rows.first?.name == "Alice") // pre-existing field preserved
    192         #expect(rows.first?.selRow == 5)
    193         #expect(rows.first?.selCol == 6)
    194     }
    195 
    196     @Test("Publishing again after clear writes the new selection")
    197     func publishAfterClear() async throws {
    198         let (persistence, gameID) = try makePersistenceWithGame()
    199         let capture = Capture()
    200         let publisher = PlayerSelectionPublisher(
    201             debounceInterval: .seconds(10),
    202             persistence: persistence,
    203             sink: { id, author in await capture.append(id, author) }
    204         )
    205 
    206         await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
    207         await publisher.publish(PlayerSelection(row: 1, col: 1, direction: .across))
    208         await publisher.flush()
    209         await publisher.clear()
    210         try await Task.sleep(for: .milliseconds(50))
    211         await publisher.publish(PlayerSelection(row: 2, col: 2, direction: .down))
    212         await publisher.flush()
    213 
    214         let count = await capture.count
    215         #expect(count == 3)
    216         let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
    217         #expect(values?.selRow == 2)
    218         #expect(values?.selCol == 2)
    219         #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue))
    220     }
    221 
    222     // MARK: - Helpers
    223 
    224     struct PlayerValues {
    225         let name: String?
    226         let selRow: Int64?
    227         let selCol: Int64?
    228         let selDir: Int64?
    229         let ckRecordName: String?
    230     }
    231 
    232     private func fetchPlayer(
    233         gameID: UUID,
    234         authorID: String,
    235         persistence: PersistenceController
    236     ) -> PlayerValues? {
    237         let context = persistence.container.newBackgroundContext()
    238         return context.performAndWait {
    239             let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    240             request.predicate = NSPredicate(
    241                 format: "game.id == %@ AND authorID == %@",
    242                 gameID as CVarArg,
    243                 authorID
    244             )
    245             request.fetchLimit = 1
    246             guard let entity = try? context.fetch(request).first else { return nil }
    247             return PlayerValues(
    248                 name: entity.name,
    249                 selRow: entity.selRow?.int64Value,
    250                 selCol: entity.selCol?.int64Value,
    251                 selDir: entity.selDir?.int64Value,
    252                 ckRecordName: entity.ckRecordName
    253             )
    254         }
    255     }
    256 
    257     private func fetchAllPlayers(
    258         gameID: UUID,
    259         persistence: PersistenceController
    260     ) -> [PlayerValues] {
    261         let context = persistence.container.newBackgroundContext()
    262         return context.performAndWait {
    263             let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    264             request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
    265             guard let entities = try? context.fetch(request) else { return [] }
    266             return entities.map {
    267                 PlayerValues(
    268                     name: $0.name,
    269                     selRow: $0.selRow?.int64Value,
    270                     selCol: $0.selCol?.int64Value,
    271                     selDir: $0.selDir?.int64Value,
    272                     ckRecordName: $0.ckRecordName
    273                 )
    274             }
    275         }
    276     }
    277 }