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 }