crossmate

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

MovesUpdaterTests.swift (13417B)


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