crossmate

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

MovesUpdaterTests.swift (13268B)


      1 import CoreData
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 @Suite("MovesUpdater", .serialized)
      8 @MainActor
      9 struct MovesUpdaterTests {
     10 
     11     /// Thread-safe collector for sink fan-outs (affected game IDs plus the
     12     /// drain flag, per flush).
     13     actor Capture {
     14         private(set) var events: [(ids: Set<UUID>, drain: Bool)] = []
     15         var flushes: [Set<UUID>] { events.map(\.ids) }
     16         var allGameIDs: Set<UUID> { events.reduce(into: Set()) { $0.formUnion($1.ids) } }
     17         var flushCount: Int { events.count }
     18         var drains: [Bool] { events.map(\.drain) }
     19         func append(_ ids: Set<UUID>, drain: Bool) { events.append((ids, drain)) }
     20     }
     21 
     22     actor MutableAuthor {
     23         private var value: String?
     24         init(_ value: String?) { self.value = value }
     25         func current() -> String? { value }
     26         func set(_ newValue: String?) { value = newValue }
     27     }
     28 
     29     private static let writerAuthorID = "alice"
     30 
     31     private func makePersistenceWithGame() throws -> (PersistenceController, UUID) {
     32         let persistence = makeTestPersistence()
     33         let context = persistence.viewContext
     34         let gameID = UUID()
     35         let entity = GameEntity(context: context)
     36         entity.id = gameID
     37         entity.title = "Test"
     38         entity.puzzleSource = ""
     39         entity.createdAt = Date()
     40         entity.updatedAt = Date()
     41         entity.ckRecordName = "game-\(gameID.uuidString)"
     42         try context.save()
     43         return (persistence, gameID)
     44     }
     45 
     46     private func makeUpdater(
     47         persistence: PersistenceController,
     48         capture: Capture,
     49         debounce: Duration = .seconds(10),
     50         writerAuthorID: String? = MovesUpdaterTests.writerAuthorID,
     51         sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
     52     ) -> MovesUpdater {
     53         MovesUpdater(
     54             debounceInterval: debounce,
     55             persistence: persistence,
     56             writerAuthorIDProvider: { writerAuthorID },
     57             sink: { ids, drain in await capture.append(ids, drain: drain) },
     58             sleep: sleep
     59         )
     60     }
     61 
     62     private func movesEntity(
     63         gameID: UUID,
     64         persistence: PersistenceController
     65     ) -> MovesEntity? {
     66         let ctx = persistence.viewContext
     67         ctx.refreshAllObjects()
     68         let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
     69         req.predicate = NSPredicate(
     70             format: "game.id == %@ AND deviceID == %@",
     71             gameID as CVarArg,
     72             RecordSerializer.localDeviceID
     73         )
     74         req.fetchLimit = 1
     75         return try? ctx.fetch(req).first
     76     }
     77 
     78     private func decodedCells(
     79         gameID: UUID,
     80         persistence: PersistenceController
     81     ) throws -> [GridPosition: TimestampedCell] {
     82         guard let entity = movesEntity(gameID: gameID, persistence: persistence),
     83               let data = entity.cells
     84         else { return [:] }
     85         return try MovesCodec.decode(data)
     86     }
     87 
     88     private func waitForFlushCount(
     89         _ expected: Int,
     90         capture: Capture,
     91         timeout: Duration = .seconds(5)
     92     ) async throws {
     93         let deadline = ContinuousClock.now.advanced(by: timeout)
     94         while await capture.flushCount != expected,
     95               ContinuousClock.now < deadline {
     96             try await Task.sleep(for: .milliseconds(20))
     97         }
     98     }
     99 
    100     @Test("Same-cell enqueues coalesce; latest value lands in MovesEntity")
    101     func coalescesSameCell() async throws {
    102         let (persistence, gameID) = try makePersistenceWithGame()
    103         let capture = Capture()
    104         let updater = makeUpdater(persistence: persistence, capture: capture)
    105 
    106         await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", mark: .none, authorID: "alice")
    107         await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", mark: .none, authorID: "alice")
    108         await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "C", mark: .none, authorID: "alice")
    109         await updater.flush()
    110 
    111         let cells = try decodedCells(gameID: gameID, persistence: persistence)
    112         #expect(cells.count == 1)
    113         #expect(cells[GridPosition(row: 0, col: 0)]?.letter == "C")
    114     }
    115 
    116     @Test("Cross-cell enqueues coalesce into a single flush")
    117     func crossCellEnqueuesCoalesce() async throws {
    118         let (persistence, gameID) = try makePersistenceWithGame()
    119         let capture = Capture()
    120         let updater = makeUpdater(persistence: persistence, capture: capture)
    121 
    122         await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", mark: .none, authorID: "alice")
    123         await updater.enqueue(gameID: gameID, row: 0, col: 1, letter: "B", mark: .none, authorID: "alice")
    124 
    125         // The cell change should NOT have triggered a flush — both edits are
    126         // still buffered until the debounce (or explicit flush) fires.
    127         #expect(await capture.flushCount == 0)
    128 
    129         await updater.flush()
    130 
    131         let cells = try decodedCells(gameID: gameID, persistence: persistence)
    132         #expect(cells.count == 2)
    133         #expect(cells[GridPosition(row: 0, col: 0)]?.letter == "A")
    134         #expect(cells[GridPosition(row: 0, col: 1)]?.letter == "B")
    135         #expect(await capture.flushCount == 1)
    136     }
    137 
    138     @Test("Debounce coalesces rapid same-cell enqueues into one flush")
    139     func debounceCoalescesRapidEnqueues() async throws {
    140         let (persistence, gameID) = try makePersistenceWithGame()
    141         let capture = Capture()
    142         let manualSleep = ManualDebounceSleep()
    143         let updater = makeUpdater(
    144             persistence: persistence,
    145             capture: capture,
    146             sleep: manualSleep.sleepFn
    147         )
    148 
    149         await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", mark: .none, authorID: "alice")
    150         await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", mark: .none, authorID: "alice")
    151 
    152         // Both enqueues are buffered; the second enqueue cancelled the
    153         // first debounce task. Releasing the manual sleep lets the live
    154         // debounce proceed to flush; the cancelled task wakes, observes
    155         // Task.isCancelled, and returns without flushing.
    156         manualSleep.releaseAll()
    157 
    158         try await waitForFlushCount(1, capture: capture)
    159         #expect(await capture.flushCount == 1)
    160         let cells = try decodedCells(gameID: gameID, persistence: persistence)
    161         #expect(cells[GridPosition(row: 0, col: 0)]?.letter == "B")
    162     }
    163 
    164     @Test("Flush on an empty buffer does nothing")
    165     func emptyFlushDoesNothing() async throws {
    166         let (persistence, _) = try makePersistenceWithGame()
    167         let capture = Capture()
    168         let updater = makeUpdater(persistence: persistence, capture: capture)
    169 
    170         await updater.flush()
    171 
    172         #expect(await capture.flushCount == 0)
    173     }
    174 
    175     @Test("Cell-level authorID is persisted into the cells blob")
    176     func persistsCellAuthor() async throws {
    177         let (persistence, gameID) = try makePersistenceWithGame()
    178         let capture = Capture()
    179         // Writer is bob; preserved cell-author is alice (e.g. reveal-of-correct).
    180         let updater = makeUpdater(
    181             persistence: persistence,
    182             capture: capture,
    183             writerAuthorID: "bob"
    184         )
    185 
    186         await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", mark: .none, authorID: "alice")
    187         await updater.flush()
    188 
    189         let cells = try decodedCells(gameID: gameID, persistence: persistence)
    190         #expect(cells[GridPosition(row: 0, col: 0)]?.authorID == "alice")
    191         // Parent record's authorID is the writer (bob), distinct from cell author.
    192         let entity = try #require(movesEntity(gameID: gameID, persistence: persistence))
    193         #expect(entity.authorID == "bob")
    194     }
    195 
    196     @Test("Two games produce two distinct MovesEntity rows")
    197     func multiGameMultiRow() async throws {
    198         let persistence = makeTestPersistence()
    199         let ctx = persistence.viewContext
    200         let g1 = UUID(), g2 = UUID()
    201         for id in [g1, g2] {
    202             let e = GameEntity(context: ctx)
    203             e.id = id
    204             e.title = "T"
    205             e.puzzleSource = ""
    206             e.createdAt = Date()
    207             e.updatedAt = Date()
    208             e.ckRecordName = "game-\(id.uuidString)"
    209         }
    210         try ctx.save()
    211 
    212         let capture = Capture()
    213         let updater = makeUpdater(persistence: persistence, capture: capture)
    214 
    215         await updater.enqueue(gameID: g1, row: 0, col: 0, letter: "A", mark: .none, authorID: "alice")
    216         await updater.enqueue(gameID: g2, row: 0, col: 0, letter: "B", mark: .none, authorID: "alice")
    217         await updater.flush()
    218 
    219         let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    220         req.predicate = NSPredicate(format: "deviceID == %@", RecordSerializer.localDeviceID)
    221         let rows = try ctx.fetch(req)
    222         #expect(rows.count == 2)
    223         #expect(Set(rows.compactMap { $0.game?.id }) == [g1, g2])
    224         #expect(await capture.allGameIDs == [g1, g2])
    225     }
    226 
    227     @Test("Subsequent flush merges into the existing MovesEntity row")
    228     func subsequentFlushMerges() async throws {
    229         let (persistence, gameID) = try makePersistenceWithGame()
    230         let capture = Capture()
    231         let updater = makeUpdater(persistence: persistence, capture: capture)
    232 
    233         await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", mark: .none, authorID: "alice")
    234         await updater.flush()
    235         let firstObjectID = try #require(movesEntity(gameID: gameID, persistence: persistence)).objectID
    236 
    237         await updater.enqueue(gameID: gameID, row: 1, col: 1, letter: "B", mark: .none, authorID: "alice")
    238         await updater.flush()
    239 
    240         let entity = try #require(movesEntity(gameID: gameID, persistence: persistence))
    241         #expect(entity.objectID == firstObjectID)
    242         let cells = try decodedCells(gameID: gameID, persistence: persistence)
    243         #expect(cells.count == 2)
    244         #expect(cells[GridPosition(row: 0, col: 0)]?.letter == "A")
    245         #expect(cells[GridPosition(row: 1, col: 1)]?.letter == "B")
    246     }
    247 
    248     @Test("Flush updates the local CellEntity cache for immediate UI feedback")
    249     func flushUpdatesCellCache() async throws {
    250         let (persistence, gameID) = try makePersistenceWithGame()
    251         let capture = Capture()
    252         let updater = makeUpdater(persistence: persistence, capture: capture)
    253 
    254         await updater.enqueue(gameID: gameID, row: 2, col: 3, letter: "Q", mark: .pen(checked: .wrong), authorID: "alice")
    255         await updater.flush()
    256 
    257         let ctx = persistence.viewContext
    258         ctx.refreshAllObjects()
    259         let req = NSFetchRequest<CellEntity>(entityName: "CellEntity")
    260         req.predicate = NSPredicate(format: "game.id == %@ AND row == 2 AND col == 3", gameID as CVarArg)
    261         let cells = try ctx.fetch(req)
    262         #expect(cells.count == 1)
    263         #expect(cells.first?.letter == "Q")
    264         #expect(cells.first.map { CellMark(code: $0.markCode) } == .pen(checked: .wrong))
    265         #expect(cells.first?.letterAuthorID == "alice")
    266     }
    267 
    268     @Test("Flush keeps pending edits when the writer authorID is nil")
    269     func keepsPendingFlushWithoutWriter() async throws {
    270         let (persistence, gameID) = try makePersistenceWithGame()
    271         let capture = Capture()
    272         let author = MutableAuthor(nil)
    273         let updater = MovesUpdater(
    274             debounceInterval: .seconds(10),
    275             persistence: persistence,
    276             writerAuthorIDProvider: { await author.current() },
    277             sink: { ids, drain in await capture.append(ids, drain: drain) }
    278         )
    279 
    280         await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", mark: .none, authorID: nil)
    281         await updater.flush()
    282 
    283         #expect(await capture.flushCount == 0)
    284         #expect(movesEntity(gameID: gameID, persistence: persistence) == nil)
    285 
    286         await author.set(Self.writerAuthorID)
    287         await updater.flush()
    288 
    289         #expect(await capture.flushCount == 1)
    290         let cells = try decodedCells(gameID: gameID, persistence: persistence)
    291         #expect(cells[GridPosition(row: 0, col: 0)]?.letter == "A")
    292     }
    293 
    294     @Test("Explicit flush drains; debounce does not")
    295     func flushDrainsDebounceDoesNot() async throws {
    296         let (persistence, gameID) = try makePersistenceWithGame()
    297         let capture = Capture()
    298         // A short debounce so the trailing-edge flush fires on its own.
    299         let updater = makeUpdater(
    300             persistence: persistence,
    301             capture: capture,
    302             debounce: .milliseconds(1)
    303         )
    304 
    305         // Debounced flush (live typing): the engagement socket carries the
    306         // letters, so the durable write rides CKSyncEngine's own scheduler.
    307         await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", mark: .none, authorID: nil)
    308         try await Task.sleep(for: .milliseconds(50))
    309         #expect(await capture.drains == [false])
    310 
    311         // Explicit flush (leave/background): the solver's final letters must
    312         // reach CloudKit promptly even with no peer on the socket.
    313         await updater.enqueue(gameID: gameID, row: 0, col: 1, letter: "B", mark: .none, authorID: nil)
    314         await updater.flush()
    315         #expect(await capture.drains == [false, true])
    316     }
    317 }