MoveBufferTests.swift (13612B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 @Suite("MoveBuffer", .serialized) 8 @MainActor 9 struct MoveBufferTests { 10 11 /// Thread-safe collector for moves emitted by the buffer under test. 12 actor Capture { 13 private(set) var flushes: [[Move]] = [] 14 var allMoves: [Move] { flushes.flatMap { $0 } } 15 var flushCount: Int { flushes.count } 16 func append(_ moves: [Move]) { flushes.append(moves) } 17 } 18 19 /// Builds a `PersistenceController` backed by an in-memory store and 20 /// seeds a single `GameEntity`, returning its id so tests can target it. 21 private func makePersistenceWithGame( 22 lamportHighWater: Int64 = 0 23 ) throws -> (PersistenceController, UUID) { 24 let persistence = makeTestPersistence() 25 let context = persistence.viewContext 26 let gameID = UUID() 27 let entity = GameEntity(context: context) 28 entity.id = gameID 29 entity.title = "Test" 30 entity.puzzleSource = "" 31 entity.createdAt = Date() 32 entity.updatedAt = Date() 33 entity.ckRecordName = "game-\(gameID.uuidString)" 34 entity.lamportHighWater = lamportHighWater 35 try context.save() 36 return (persistence, gameID) 37 } 38 39 @Test("Same-cell enqueues coalesce to one move carrying the latest value") 40 func coalescesSameCell() async throws { 41 let (persistence, gameID) = try makePersistenceWithGame() 42 let capture = Capture() 43 let buffer = MoveBuffer( 44 debounceInterval: .seconds(10), 45 persistence: persistence, 46 sink: { await capture.append($0) } 47 ) 48 49 await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil) 50 await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", markKind: 0, checkedWrong: false, authorID: nil) 51 await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "C", markKind: 0, checkedWrong: false, authorID: nil) 52 await buffer.flush() 53 54 let moves = await capture.allMoves 55 #expect(moves.count == 1) 56 #expect(moves.first?.letter == "C") 57 #expect(moves.first?.lamport == 1) 58 } 59 60 @Test("Enqueuing a different cell persists and enqueues the previous cell") 61 func cellChangeFlushesPrevious() async throws { 62 // A long debounce makes this test insensitive to timer jitter: 63 // only the cell-change trigger (and the final explicit flush) can 64 // fire. 65 let (persistence, gameID) = try makePersistenceWithGame() 66 let capture = Capture() 67 let buffer = MoveBuffer( 68 debounceInterval: .seconds(10), 69 persistence: persistence, 70 sink: { await capture.append($0) } 71 ) 72 73 await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil) 74 await buffer.enqueue(gameID: gameID, row: 0, col: 1, letter: "B", markKind: 0, checkedWrong: false, authorID: nil) 75 76 // The cell-change flush allocates Lamport 1, writes the prior cell 77 // durably, and immediately hands it to the sink. 78 #expect(await capture.flushCount == 1) 79 let persistedBeforeFinalFlush = fetchMoveValues(gameID: gameID, persistence: persistence) 80 #expect(persistedBeforeFinalFlush.count == 1) 81 #expect(persistedBeforeFinalFlush.first?.letter == "A") 82 #expect(persistedBeforeFinalFlush.first?.lamport == 1) 83 84 await buffer.flush() 85 86 let flushes = await capture.flushes 87 #expect(flushes.count == 2) 88 #expect(flushes.map { $0.map(\.letter) } == [["A"], ["B"]]) 89 #expect(flushes.map { $0.map(\.lamport) } == [[1], [2]]) 90 } 91 92 @Test("Lamports are allocated from GameEntity.lamportHighWater and bump it") 93 func lamportsUseGameHighWater() async throws { 94 let (persistence, gameID) = try makePersistenceWithGame(lamportHighWater: 10) 95 let capture = Capture() 96 let buffer = MoveBuffer( 97 debounceInterval: .seconds(10), 98 persistence: persistence, 99 sink: { await capture.append($0) } 100 ) 101 102 await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "X", markKind: 0, checkedWrong: false, authorID: nil) 103 await buffer.flush() 104 105 let moves = await capture.allMoves 106 #expect(moves.first?.lamport == 11) 107 108 // Verify the bump landed in Core Data by reading the game back from 109 // a fresh context — the writer used a background context, so we 110 // need to read from the same underlying store. 111 let highWater = fetchHighWater(gameID: gameID, persistence: persistence) 112 #expect(highWater == 11) 113 } 114 115 @Test("Flushed moves bump the parent game's updatedAt timestamp") 116 func flushedMovesUpdateGameTimestamp() async throws { 117 let (persistence, gameID) = try makePersistenceWithGame() 118 let before = try #require(fetchUpdatedAt(gameID: gameID, persistence: persistence)) 119 let buffer = MoveBuffer( 120 debounceInterval: .seconds(10), 121 persistence: persistence, 122 sink: { _ in } 123 ) 124 125 try await Task.sleep(for: .milliseconds(10)) 126 await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "X", markKind: 0, checkedWrong: false, authorID: nil) 127 await buffer.flush() 128 129 let after = try #require(fetchUpdatedAt(gameID: gameID, persistence: persistence)) 130 #expect(after > before) 131 } 132 133 @Test("Debounce coalesces rapid same-cell enqueues into one flush") 134 func debounceCoalescesRapidEnqueues() async throws { 135 let (persistence, gameID) = try makePersistenceWithGame() 136 let capture = Capture() 137 let buffer = MoveBuffer( 138 debounceInterval: .milliseconds(80), 139 persistence: persistence, 140 sink: { await capture.append($0) } 141 ) 142 143 for letter in ["A", "B", "C", "D", "E"] { 144 await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: letter, markKind: 0, checkedWrong: false, authorID: nil) 145 try await Task.sleep(for: .milliseconds(20)) 146 } 147 try await Task.sleep(for: .milliseconds(250)) 148 149 let count = await capture.flushCount 150 #expect(count == 1) 151 let moves = await capture.allMoves 152 #expect(moves.first?.letter == "E") 153 } 154 155 @Test("Explicit flush fires immediately and cancels any pending debounce") 156 func flushCancelsDebounce() async throws { 157 let (persistence, gameID) = try makePersistenceWithGame() 158 let capture = Capture() 159 let buffer = MoveBuffer( 160 debounceInterval: .seconds(5), 161 persistence: persistence, 162 sink: { await capture.append($0) } 163 ) 164 165 await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil) 166 await buffer.flush() 167 let afterFlush = await capture.flushCount 168 #expect(afterFlush == 1) 169 170 // The debounce should have been cancelled by flush, so waiting past 171 // the original interval must not add a second call. 172 try await Task.sleep(for: .milliseconds(200)) 173 let later = await capture.flushCount 174 #expect(later == 1) 175 } 176 177 @Test("Flush on an empty buffer does not invoke the sink") 178 func emptyFlushDoesNothing() async throws { 179 let (persistence, _) = try makePersistenceWithGame() 180 let capture = Capture() 181 let buffer = MoveBuffer( 182 debounceInterval: .milliseconds(50), 183 persistence: persistence, 184 sink: { await capture.append($0) } 185 ) 186 187 await buffer.flush() 188 189 let count = await capture.flushCount 190 #expect(count == 0) 191 } 192 193 @Test("MoveEntity rows are written with the enqueued fields") 194 func persistsMoveEntity() async throws { 195 let (persistence, gameID) = try makePersistenceWithGame() 196 let buffer = MoveBuffer( 197 debounceInterval: .seconds(10), 198 persistence: persistence, 199 sink: { _ in } 200 ) 201 202 await buffer.enqueue( 203 gameID: gameID, row: 2, col: 3, 204 letter: "Q", markKind: 1, checkedWrong: true, authorID: "alice" 205 ) 206 await buffer.flush() 207 208 let moves = fetchMoveValues(gameID: gameID, persistence: persistence) 209 #expect(moves.count == 1) 210 let m = moves.first 211 #expect(m?.letter == "Q") 212 #expect(m?.row == 2) 213 #expect(m?.col == 3) 214 #expect(m?.markKind == 1) 215 #expect(m?.checkedWrong == true) 216 #expect(m?.authorID == "alice") 217 #expect(m?.lamport == 1) 218 #expect(m?.ckRecordName == "move-\(gameID.uuidString)-1-\(RecordSerializer.localDeviceID)") 219 } 220 221 @Test("Flush updates cell cache with the latest local cell values") 222 func flushUpdatesCellCache() async throws { 223 let (persistence, gameID) = try makePersistenceWithGame() 224 let buffer = MoveBuffer( 225 debounceInterval: .seconds(10), 226 persistence: persistence, 227 sink: { _ in } 228 ) 229 230 await buffer.enqueue( 231 gameID: gameID, 232 row: 2, 233 col: 3, 234 letter: "Q", 235 markKind: 1, 236 checkedWrong: true, 237 authorID: "alice" 238 ) 239 await buffer.enqueue( 240 gameID: gameID, 241 row: 2, 242 col: 3, 243 letter: "R", 244 markKind: 2, 245 checkedWrong: false, 246 authorID: "bob" 247 ) 248 await buffer.flush() 249 250 let cells = fetchCellValues(gameID: gameID, persistence: persistence) 251 #expect(cells.count == 1) 252 let cell = cells.first 253 #expect(cell?.row == 2) 254 #expect(cell?.col == 3) 255 #expect(cell?.letter == "R") 256 #expect(cell?.markKind == 2) 257 #expect(cell?.checkedWrong == false) 258 #expect(cell?.authorID == "bob") 259 } 260 261 // MARK: - Helpers 262 263 /// Reads the game's lamport high-water from a fresh background context. 264 private func fetchHighWater(gameID: UUID, persistence: PersistenceController) -> Int64 { 265 let context = persistence.container.newBackgroundContext() 266 return context.performAndWait { 267 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 268 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 269 request.fetchLimit = 1 270 return (try? context.fetch(request).first?.lamportHighWater) ?? 0 271 } 272 } 273 274 private func fetchUpdatedAt(gameID: UUID, persistence: PersistenceController) -> Date? { 275 let context = persistence.container.newBackgroundContext() 276 return context.performAndWait { 277 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 278 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 279 request.fetchLimit = 1 280 return try? context.fetch(request).first?.updatedAt 281 } 282 } 283 284 /// Extracts `MoveEntity` field values inside the background context so 285 /// no `NSManagedObject` escapes its owning context. 286 struct MoveValues { 287 let letter: String 288 let row: Int16 289 let col: Int16 290 let markKind: Int16 291 let checkedWrong: Bool 292 let authorID: String? 293 let lamport: Int64 294 let ckRecordName: String? 295 } 296 297 struct CellValues { 298 let letter: String 299 let row: Int16 300 let col: Int16 301 let markKind: Int16 302 let checkedWrong: Bool 303 let authorID: String? 304 } 305 306 private func fetchMoveValues(gameID: UUID, persistence: PersistenceController) -> [MoveValues] { 307 let context = persistence.container.newBackgroundContext() 308 return context.performAndWait { 309 let request = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") 310 request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) 311 request.sortDescriptors = [NSSortDescriptor(key: "lamport", ascending: true)] 312 guard let entities = try? context.fetch(request) else { return [] } 313 return entities.map { 314 MoveValues( 315 letter: $0.letter ?? "", 316 row: $0.row, 317 col: $0.col, 318 markKind: $0.markKind, 319 checkedWrong: $0.checkedWrong, 320 authorID: $0.authorID, 321 lamport: $0.lamport, 322 ckRecordName: $0.ckRecordName 323 ) 324 } 325 } 326 } 327 328 private func fetchCellValues(gameID: UUID, persistence: PersistenceController) -> [CellValues] { 329 let context = persistence.container.newBackgroundContext() 330 return context.performAndWait { 331 let request = NSFetchRequest<CellEntity>(entityName: "CellEntity") 332 request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) 333 request.sortDescriptors = [ 334 NSSortDescriptor(key: "row", ascending: true), 335 NSSortDescriptor(key: "col", ascending: true) 336 ] 337 guard let entities = try? context.fetch(request) else { return [] } 338 return entities.map { 339 CellValues( 340 letter: $0.letter ?? "", 341 row: $0.row, 342 col: $0.col, 343 markKind: $0.markKind, 344 checkedWrong: $0.checkedWrong, 345 authorID: $0.letterAuthorID 346 ) 347 } 348 } 349 } 350 }