MovesInboundTests.swift (17013B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 /// Pins down `RecordSerializer.applyMovesRecord` — the inbound persistence path 9 /// that turns a `Moves` CKRecord into a `MovesEntity` row. 10 @Suite("RecordSerializer.applyMovesRecord") 11 @MainActor 12 struct MovesInboundTests { 13 14 private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")! 15 16 private func record( 17 in ctx: NSManagedObjectContext, 18 authorID: String, 19 deviceID: String, 20 cells: [GridPosition: TimestampedCell], 21 updatedAt: Date 22 ) throws -> (CKRecord, MovesValue) { 23 let value = MovesValue( 24 gameID: gameID, 25 authorID: authorID, 26 deviceID: deviceID, 27 cells: cells, 28 updatedAt: updatedAt 29 ) 30 let record = try RecordSerializer.movesRecord( 31 from: value, 32 zone: RecordSerializer.zoneID(for: gameID), 33 systemFields: nil 34 ) 35 return (record, value) 36 } 37 38 private func fetchAll(_ ctx: NSManagedObjectContext) -> [MovesEntity] { 39 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 40 return (try? ctx.fetch(req)) ?? [] 41 } 42 43 @Test("Persists a MovesEntity with the record's fields") 44 func persistsEntity() throws { 45 let persistence = makeTestPersistence() 46 let ctx = persistence.viewContext 47 let (rec, value) = try record( 48 in: ctx, 49 authorID: "alice", 50 deviceID: "deadbeef", 51 cells: [ 52 GridPosition(row: 0, col: 0): TimestampedCell( 53 letter: "A", mark: .none, 54 updatedAt: Date(timeIntervalSince1970: 1_700_000_000) 55 ), 56 ], 57 updatedAt: Date(timeIntervalSince1970: 1_700_000_000) 58 ) 59 60 RecordSerializer.applyMovesRecord(rec, value: value, to: ctx) 61 62 let rows = fetchAll(ctx) 63 #expect(rows.count == 1) 64 let entity = try #require(rows.first) 65 #expect(entity.ckRecordName == rec.recordID.recordName) 66 #expect(entity.authorID == "alice") 67 #expect(entity.deviceID == "deadbeef") 68 #expect(entity.updatedAt == value.updatedAt) 69 #expect(entity.ckSystemFields != nil) 70 // `cells` should be the verbatim record blob — decode round-trip recovers 71 // the same cells we encoded. 72 let decoded = try MovesCodec.decode(entity.cells ?? Data()) 73 #expect(decoded == value.cells) 74 } 75 76 @Test("Re-applying the same record updates the existing row in place") 77 func reapplyUpdatesSameRow() throws { 78 let persistence = makeTestPersistence() 79 let ctx = persistence.viewContext 80 81 let cells1: [GridPosition: TimestampedCell] = [ 82 GridPosition(row: 0, col: 0): TimestampedCell( 83 letter: "A", mark: .none, 84 updatedAt: Date(timeIntervalSince1970: 1_700_000_000), 85 authorID: "alice" 86 ), 87 ] 88 let (rec1, value1) = try record( 89 in: ctx, 90 authorID: "alice", 91 deviceID: "d1", 92 cells: cells1, 93 updatedAt: Date(timeIntervalSince1970: 1_700_000_000) 94 ) 95 RecordSerializer.applyMovesRecord(rec1, value: value1, to: ctx) 96 let firstID = try #require(fetchAll(ctx).first?.objectID) 97 98 // A later update from the same device should land on the same row 99 // (matched by ckRecordName), not create a duplicate. 100 let cells2: [GridPosition: TimestampedCell] = [ 101 GridPosition(row: 0, col: 0): TimestampedCell( 102 letter: "B", mark: .none, 103 updatedAt: Date(timeIntervalSince1970: 1_700_000_500), 104 authorID: "alice" 105 ), 106 GridPosition(row: 1, col: 1): TimestampedCell( 107 letter: "C", mark: .none, 108 updatedAt: Date(timeIntervalSince1970: 1_700_000_500), 109 authorID: "alice" 110 ), 111 ] 112 let (rec2, value2) = try record( 113 in: ctx, 114 authorID: "alice", 115 deviceID: "d1", 116 cells: cells2, 117 updatedAt: Date(timeIntervalSince1970: 1_700_000_500) 118 ) 119 RecordSerializer.applyMovesRecord(rec2, value: value2, to: ctx) 120 121 let rows = fetchAll(ctx) 122 #expect(rows.count == 1) 123 #expect(rows.first?.objectID == firstID) 124 #expect(rows.first?.updatedAt == value2.updatedAt) 125 let decoded = try MovesCodec.decode(rows.first?.cells ?? Data()) 126 #expect(decoded == cells2) 127 } 128 129 @Test("onNewAuthor fires once for a remote contributor's first row, not repeats") 130 func onNewAuthorFiresForNewRemoteContributor() throws { 131 let persistence = makeTestPersistence() 132 let ctx = persistence.viewContext 133 let cell: [GridPosition: TimestampedCell] = [ 134 GridPosition(row: 0, col: 0): TimestampedCell( 135 letter: "A", mark: .none, 136 updatedAt: Date(timeIntervalSince1970: 1_700_000_000), 137 authorID: "bob" 138 ), 139 ] 140 let (rec, value) = try record( 141 in: ctx, authorID: "bob", deviceID: "d1", 142 cells: cell, updatedAt: Date(timeIntervalSince1970: 1_700_000_000) 143 ) 144 145 var newAuthors: [String] = [] 146 RecordSerializer.applyMovesRecord( 147 rec, value: value, to: ctx, localAuthorID: "alice", 148 onNewAuthor: { newAuthors.append($0) } 149 ) 150 #expect(newAuthors == ["bob"]) 151 152 // A later update lands on the same row (ckRecordName), so it is not a 153 // new contributor and must not re-trigger the roster. 154 let (rec2, value2) = try record( 155 in: ctx, authorID: "bob", deviceID: "d1", 156 cells: cell, updatedAt: Date(timeIntervalSince1970: 1_700_000_500) 157 ) 158 RecordSerializer.applyMovesRecord( 159 rec2, value: value2, to: ctx, localAuthorID: "alice", 160 onNewAuthor: { newAuthors.append($0) } 161 ) 162 #expect(newAuthors == ["bob"]) 163 } 164 165 @Test("onNewAuthor does not fire for a known contributor's second device") 166 func onNewAuthorSkipsKnownAuthorSecondDevice() throws { 167 let persistence = makeTestPersistence() 168 let ctx = persistence.viewContext 169 let cell: [GridPosition: TimestampedCell] = [ 170 GridPosition(row: 0, col: 0): TimestampedCell( 171 letter: "A", mark: .none, 172 updatedAt: Date(timeIntervalSince1970: 1_700_000_000), 173 authorID: "bob" 174 ), 175 ] 176 let (phoneRecord, phoneValue) = try record( 177 in: ctx, authorID: "bob", deviceID: "phone", 178 cells: cell, updatedAt: Date(timeIntervalSince1970: 1_700_000_000) 179 ) 180 let (ipadRecord, ipadValue) = try record( 181 in: ctx, authorID: "bob", deviceID: "ipad", 182 cells: cell, updatedAt: Date(timeIntervalSince1970: 1_700_000_500) 183 ) 184 185 var newAuthors: [String] = [] 186 RecordSerializer.applyMovesRecord( 187 phoneRecord, value: phoneValue, to: ctx, localAuthorID: "alice", 188 onNewAuthor: { newAuthors.append($0) } 189 ) 190 RecordSerializer.applyMovesRecord( 191 ipadRecord, value: ipadValue, to: ctx, localAuthorID: "alice", 192 onNewAuthor: { newAuthors.append($0) } 193 ) 194 195 #expect(newAuthors == ["bob"]) 196 } 197 198 @Test("onNewAuthor does not fire for the local author's own moves") 199 func onNewAuthorSkipsLocalAuthor() throws { 200 let persistence = makeTestPersistence() 201 let ctx = persistence.viewContext 202 let cell: [GridPosition: TimestampedCell] = [ 203 GridPosition(row: 0, col: 0): TimestampedCell( 204 letter: "A", mark: .none, 205 updatedAt: Date(timeIntervalSince1970: 1_700_000_000), 206 authorID: "alice" 207 ), 208 ] 209 // A sibling device of the local author (same authorID, new row) is not 210 // a new participant. 211 let (rec, value) = try record( 212 in: ctx, authorID: "alice", deviceID: "sibling-device", 213 cells: cell, updatedAt: Date(timeIntervalSince1970: 1_700_000_000) 214 ) 215 216 var newAuthors: [String] = [] 217 RecordSerializer.applyMovesRecord( 218 rec, value: value, to: ctx, localAuthorID: "alice", 219 onNewAuthor: { newAuthors.append($0) } 220 ) 221 #expect(newAuthors.isEmpty) 222 } 223 224 @Test("Inbound local-device record does not clobber existing local row") 225 func inboundLocalDeviceRecordDoesNotClobberExistingLocalRow() throws { 226 let persistence = makeTestPersistence() 227 let ctx = persistence.viewContext 228 229 let game = GameEntity(context: ctx) 230 game.id = gameID 231 game.ckRecordName = "game-\(gameID.uuidString)" 232 game.title = "" 233 game.puzzleSource = "" 234 game.createdAt = Date(timeIntervalSince1970: 0) 235 game.updatedAt = Date(timeIntervalSince1970: 20) 236 237 let localCells: [GridPosition: TimestampedCell] = [ 238 GridPosition(row: 0, col: 0): TimestampedCell( 239 letter: "B", mark: .none, 240 updatedAt: Date(timeIntervalSince1970: 20), 241 authorID: "alice" 242 ), 243 ] 244 let local = MovesEntity(context: ctx) 245 local.game = game 246 local.ckRecordName = RecordSerializer.recordName( 247 forMovesInGame: gameID, 248 authorID: "alice", 249 deviceID: RecordSerializer.localDeviceID 250 ) 251 local.authorID = "alice" 252 local.deviceID = RecordSerializer.localDeviceID 253 local.updatedAt = Date(timeIntervalSince1970: 20) 254 local.cells = try MovesCodec.encode(localCells) 255 try ctx.save() 256 257 let serverCells: [GridPosition: TimestampedCell] = [ 258 GridPosition(row: 0, col: 0): TimestampedCell( 259 letter: "A", mark: .none, 260 updatedAt: Date(timeIntervalSince1970: 10), 261 authorID: "alice" 262 ), 263 ] 264 let (rec, value) = try record( 265 in: ctx, 266 authorID: "alice", 267 deviceID: RecordSerializer.localDeviceID, 268 cells: serverCells, 269 updatedAt: Date(timeIntervalSince1970: 10) 270 ) 271 272 RecordSerializer.applyMovesRecord( 273 rec, 274 value: value, 275 to: ctx, 276 localAuthorID: "alice" 277 ) 278 279 let row = try #require(fetchAll(ctx).first) 280 #expect(row.updatedAt == Date(timeIntervalSince1970: 20)) 281 let decoded = try MovesCodec.decode(row.cells ?? Data()) 282 #expect(decoded == localCells) 283 #expect(row.ckSystemFields != nil) 284 } 285 286 @Test("Inbound other-device record preserves newer realtime cells") 287 func inboundOtherDeviceRecordPreservesNewerRealtimeCells() throws { 288 let persistence = makeTestPersistence() 289 let ctx = persistence.viewContext 290 291 let game = GameEntity(context: ctx) 292 game.id = gameID 293 game.ckRecordName = "game-\(gameID.uuidString)" 294 game.title = "" 295 game.puzzleSource = "" 296 game.createdAt = Date(timeIntervalSince1970: 0) 297 game.updatedAt = Date(timeIntervalSince1970: 20) 298 299 let cachedCells: [GridPosition: TimestampedCell] = [ 300 GridPosition(row: 0, col: 0): TimestampedCell( 301 letter: "B", mark: .none, 302 updatedAt: Date(timeIntervalSince1970: 20), 303 authorID: "bob" 304 ), 305 GridPosition(row: 1, col: 1): TimestampedCell( 306 letter: "", mark: .none, 307 updatedAt: Date(timeIntervalSince1970: 30), 308 authorID: "bob" 309 ), 310 ] 311 let cached = MovesEntity(context: ctx) 312 cached.game = game 313 cached.ckRecordName = RecordSerializer.recordName( 314 forMovesInGame: gameID, 315 authorID: "bob", 316 deviceID: "phone" 317 ) 318 cached.authorID = "bob" 319 cached.deviceID = "phone" 320 cached.updatedAt = Date(timeIntervalSince1970: 20) 321 cached.cells = try MovesCodec.encode(cachedCells) 322 try ctx.save() 323 324 let serverCells: [GridPosition: TimestampedCell] = [ 325 GridPosition(row: 0, col: 0): TimestampedCell( 326 letter: "A", mark: .none, 327 updatedAt: Date(timeIntervalSince1970: 10), 328 authorID: "bob" 329 ), 330 GridPosition(row: 1, col: 1): TimestampedCell( 331 letter: "Z", mark: .none, 332 updatedAt: Date(timeIntervalSince1970: 15), 333 authorID: "bob" 334 ), 335 GridPosition(row: 2, col: 2): TimestampedCell( 336 letter: "C", mark: .none, 337 updatedAt: Date(timeIntervalSince1970: 40), 338 authorID: "bob" 339 ), 340 ] 341 let (rec, value) = try record( 342 in: ctx, 343 authorID: "bob", 344 deviceID: "phone", 345 cells: serverCells, 346 updatedAt: Date(timeIntervalSince1970: 10) 347 ) 348 349 RecordSerializer.applyMovesRecord( 350 rec, 351 value: value, 352 to: ctx, 353 localAuthorID: "alice" 354 ) 355 356 let row = try #require(fetchAll(ctx).first) 357 #expect(row.updatedAt == Date(timeIntervalSince1970: 40)) 358 let decoded = try MovesCodec.decode(row.cells ?? Data()) 359 #expect(decoded[GridPosition(row: 0, col: 0)]?.letter == "B") 360 #expect(decoded[GridPosition(row: 1, col: 1)]?.letter == "") 361 #expect(decoded[GridPosition(row: 2, col: 2)]?.letter == "C") 362 } 363 364 @Test("Two devices for the same game produce two distinct rows") 365 func twoDevicesYieldTwoRows() throws { 366 let persistence = makeTestPersistence() 367 let ctx = persistence.viewContext 368 369 let (rec1, value1) = try record( 370 in: ctx, 371 authorID: "alice", 372 deviceID: "phone", 373 cells: [ 374 GridPosition(row: 0, col: 0): TimestampedCell( 375 letter: "A", mark: .none, 376 updatedAt: Date(timeIntervalSince1970: 1), 377 authorID: "alice" 378 ), 379 ], 380 updatedAt: Date(timeIntervalSince1970: 1) 381 ) 382 let (rec2, value2) = try record( 383 in: ctx, 384 authorID: "alice", 385 deviceID: "ipad", 386 cells: [ 387 GridPosition(row: 1, col: 1): TimestampedCell( 388 letter: "B", mark: .none, 389 updatedAt: Date(timeIntervalSince1970: 2), 390 authorID: "alice" 391 ), 392 ], 393 updatedAt: Date(timeIntervalSince1970: 2) 394 ) 395 RecordSerializer.applyMovesRecord(rec1, value: value1, to: ctx) 396 RecordSerializer.applyMovesRecord(rec2, value: value2, to: ctx) 397 398 let rows = fetchAll(ctx) 399 #expect(rows.count == 2) 400 #expect(Set(rows.compactMap(\.deviceID)) == ["phone", "ipad"]) 401 } 402 403 @Test("Creates a stub GameEntity if none exists yet (lazy parent)") 404 func lazyParentStub() throws { 405 let persistence = makeTestPersistence() 406 let ctx = persistence.viewContext 407 408 let (rec, value) = try record( 409 in: ctx, 410 authorID: "alice", 411 deviceID: "d1", 412 cells: [:], 413 updatedAt: Date(timeIntervalSince1970: 1) 414 ) 415 RecordSerializer.applyMovesRecord(rec, value: value, to: ctx) 416 417 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 418 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 419 let games = try ctx.fetch(gameReq) 420 #expect(games.count == 1) 421 #expect(games.first?.title == "") 422 // The Moves row should be parented to the new stub. 423 let movesRows = fetchAll(ctx) 424 #expect(movesRows.count == 1) 425 #expect(movesRows.first?.game?.objectID == games.first?.objectID) 426 } 427 428 @Test("Bumps the parent game's updatedAt when the record is fresher") 429 func bumpsGameUpdatedAt() throws { 430 let persistence = makeTestPersistence() 431 let ctx = persistence.viewContext 432 433 // Pre-create the game with an old timestamp so the bump is observable. 434 let game = GameEntity(context: ctx) 435 game.id = gameID 436 game.ckRecordName = "game-\(gameID.uuidString)" 437 game.title = "" 438 game.puzzleSource = "" 439 game.createdAt = Date(timeIntervalSince1970: 0) 440 game.updatedAt = Date(timeIntervalSince1970: 0) 441 try ctx.save() 442 443 let (rec, value) = try record( 444 in: ctx, 445 authorID: "alice", 446 deviceID: "d1", 447 cells: [:], 448 updatedAt: Date(timeIntervalSince1970: 1_700_000_000) 449 ) 450 RecordSerializer.applyMovesRecord(rec, value: value, to: ctx) 451 452 #expect(game.updatedAt == value.updatedAt) 453 } 454 }