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 }