MovesInboundTests.swift (19341B)
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.ckZoneName = RecordSerializer.zoneID(for: gameID).zoneName 233 game.title = "" 234 game.puzzleSource = "" 235 game.createdAt = Date(timeIntervalSince1970: 0) 236 game.updatedAt = Date(timeIntervalSince1970: 20) 237 238 let localCells: [GridPosition: TimestampedCell] = [ 239 GridPosition(row: 0, col: 0): TimestampedCell( 240 letter: "B", mark: .none, 241 updatedAt: Date(timeIntervalSince1970: 20), 242 authorID: "alice" 243 ), 244 ] 245 let local = MovesEntity(context: ctx) 246 local.game = game 247 local.ckRecordName = RecordSerializer.recordName( 248 forMovesInGame: gameID, 249 authorID: "alice", 250 deviceID: RecordSerializer.localDeviceID 251 ) 252 local.authorID = "alice" 253 local.deviceID = RecordSerializer.localDeviceID 254 local.updatedAt = Date(timeIntervalSince1970: 20) 255 local.cells = try MovesCodec.encode(localCells) 256 try ctx.save() 257 258 let serverCells: [GridPosition: TimestampedCell] = [ 259 GridPosition(row: 0, col: 0): TimestampedCell( 260 letter: "A", mark: .none, 261 updatedAt: Date(timeIntervalSince1970: 10), 262 authorID: "alice" 263 ), 264 ] 265 let (rec, value) = try record( 266 in: ctx, 267 authorID: "alice", 268 deviceID: RecordSerializer.localDeviceID, 269 cells: serverCells, 270 updatedAt: Date(timeIntervalSince1970: 10) 271 ) 272 273 RecordSerializer.applyMovesRecord( 274 rec, 275 value: value, 276 to: ctx, 277 localAuthorID: "alice" 278 ) 279 280 let row = try #require(fetchAll(ctx).first) 281 #expect(row.updatedAt == Date(timeIntervalSince1970: 20)) 282 let decoded = try MovesCodec.decode(row.cells ?? Data()) 283 #expect(decoded == localCells) 284 #expect(row.ckSystemFields != nil) 285 } 286 287 @Test("Inbound other-device record preserves newer realtime cells") 288 func inboundOtherDeviceRecordPreservesNewerRealtimeCells() throws { 289 let persistence = makeTestPersistence() 290 let ctx = persistence.viewContext 291 292 let game = GameEntity(context: ctx) 293 game.id = gameID 294 game.ckRecordName = "game-\(gameID.uuidString)" 295 game.ckZoneName = RecordSerializer.zoneID(for: gameID).zoneName 296 game.title = "" 297 game.puzzleSource = "" 298 game.createdAt = Date(timeIntervalSince1970: 0) 299 game.updatedAt = Date(timeIntervalSince1970: 20) 300 301 let cachedCells: [GridPosition: TimestampedCell] = [ 302 GridPosition(row: 0, col: 0): TimestampedCell( 303 letter: "B", mark: .none, 304 updatedAt: Date(timeIntervalSince1970: 20), 305 authorID: "bob" 306 ), 307 GridPosition(row: 1, col: 1): TimestampedCell( 308 letter: "", mark: .none, 309 updatedAt: Date(timeIntervalSince1970: 30), 310 authorID: "bob" 311 ), 312 ] 313 let cached = MovesEntity(context: ctx) 314 cached.game = game 315 cached.ckRecordName = RecordSerializer.recordName( 316 forMovesInGame: gameID, 317 authorID: "bob", 318 deviceID: "phone" 319 ) 320 cached.authorID = "bob" 321 cached.deviceID = "phone" 322 cached.updatedAt = Date(timeIntervalSince1970: 20) 323 cached.cells = try MovesCodec.encode(cachedCells) 324 try ctx.save() 325 326 let serverCells: [GridPosition: TimestampedCell] = [ 327 GridPosition(row: 0, col: 0): TimestampedCell( 328 letter: "A", mark: .none, 329 updatedAt: Date(timeIntervalSince1970: 10), 330 authorID: "bob" 331 ), 332 GridPosition(row: 1, col: 1): TimestampedCell( 333 letter: "Z", mark: .none, 334 updatedAt: Date(timeIntervalSince1970: 15), 335 authorID: "bob" 336 ), 337 GridPosition(row: 2, col: 2): TimestampedCell( 338 letter: "C", mark: .none, 339 updatedAt: Date(timeIntervalSince1970: 40), 340 authorID: "bob" 341 ), 342 ] 343 let (rec, value) = try record( 344 in: ctx, 345 authorID: "bob", 346 deviceID: "phone", 347 cells: serverCells, 348 updatedAt: Date(timeIntervalSince1970: 10) 349 ) 350 351 RecordSerializer.applyMovesRecord( 352 rec, 353 value: value, 354 to: ctx, 355 localAuthorID: "alice" 356 ) 357 358 let row = try #require(fetchAll(ctx).first) 359 #expect(row.updatedAt == Date(timeIntervalSince1970: 40)) 360 let decoded = try MovesCodec.decode(row.cells ?? Data()) 361 #expect(decoded[GridPosition(row: 0, col: 0)]?.letter == "B") 362 #expect(decoded[GridPosition(row: 1, col: 1)]?.letter == "") 363 #expect(decoded[GridPosition(row: 2, col: 2)]?.letter == "C") 364 } 365 366 @Test("Inbound other-device record keeps existing cells on equal timestamps") 367 func inboundOtherDeviceRecordKeepsExistingCellsOnEqualTimestamps() throws { 368 let persistence = makeTestPersistence() 369 let ctx = persistence.viewContext 370 371 let game = GameEntity(context: ctx) 372 game.id = gameID 373 game.ckRecordName = "game-\(gameID.uuidString)" 374 game.ckZoneName = RecordSerializer.zoneID(for: gameID).zoneName 375 game.title = "" 376 game.puzzleSource = "" 377 game.createdAt = Date(timeIntervalSince1970: 0) 378 game.updatedAt = Date(timeIntervalSince1970: 20) 379 380 let same = Date(timeIntervalSince1970: 20) 381 let position = GridPosition(row: 0, col: 0) 382 let cachedCells: [GridPosition: TimestampedCell] = [ 383 position: TimestampedCell( 384 letter: "B", mark: .none, updatedAt: same, authorID: "bob" 385 ), 386 ] 387 let cached = MovesEntity(context: ctx) 388 cached.game = game 389 cached.ckRecordName = RecordSerializer.recordName( 390 forMovesInGame: gameID, 391 authorID: "bob", 392 deviceID: "phone" 393 ) 394 cached.authorID = "bob" 395 cached.deviceID = "phone" 396 cached.updatedAt = same 397 cached.cells = try MovesCodec.encode(cachedCells) 398 try ctx.save() 399 400 let serverCells: [GridPosition: TimestampedCell] = [ 401 position: TimestampedCell( 402 letter: "A", mark: .none, updatedAt: same, authorID: "bob" 403 ), 404 ] 405 let (rec, value) = try record( 406 in: ctx, 407 authorID: "bob", 408 deviceID: "phone", 409 cells: serverCells, 410 updatedAt: same 411 ) 412 413 RecordSerializer.applyMovesRecord( 414 rec, 415 value: value, 416 to: ctx, 417 localAuthorID: "alice" 418 ) 419 420 let row = try #require(fetchAll(ctx).first) 421 let decoded = try MovesCodec.decode(row.cells ?? Data()) 422 #expect(decoded[position]?.letter == "B") 423 #expect(decoded[position]?.updatedAt == same) 424 } 425 426 @Test("Two devices for the same game produce two distinct rows") 427 func twoDevicesYieldTwoRows() throws { 428 let persistence = makeTestPersistence() 429 let ctx = persistence.viewContext 430 431 let (rec1, value1) = try record( 432 in: ctx, 433 authorID: "alice", 434 deviceID: "phone", 435 cells: [ 436 GridPosition(row: 0, col: 0): TimestampedCell( 437 letter: "A", mark: .none, 438 updatedAt: Date(timeIntervalSince1970: 1), 439 authorID: "alice" 440 ), 441 ], 442 updatedAt: Date(timeIntervalSince1970: 1) 443 ) 444 let (rec2, value2) = try record( 445 in: ctx, 446 authorID: "alice", 447 deviceID: "ipad", 448 cells: [ 449 GridPosition(row: 1, col: 1): TimestampedCell( 450 letter: "B", mark: .none, 451 updatedAt: Date(timeIntervalSince1970: 2), 452 authorID: "alice" 453 ), 454 ], 455 updatedAt: Date(timeIntervalSince1970: 2) 456 ) 457 RecordSerializer.applyMovesRecord(rec1, value: value1, to: ctx) 458 RecordSerializer.applyMovesRecord(rec2, value: value2, to: ctx) 459 460 let rows = fetchAll(ctx) 461 #expect(rows.count == 2) 462 #expect(Set(rows.compactMap(\.deviceID)) == ["phone", "ipad"]) 463 } 464 465 @Test("Creates a stub GameEntity if none exists yet (lazy parent)") 466 func lazyParentStub() throws { 467 let persistence = makeTestPersistence() 468 let ctx = persistence.viewContext 469 470 let (rec, value) = try record( 471 in: ctx, 472 authorID: "alice", 473 deviceID: "d1", 474 cells: [:], 475 updatedAt: Date(timeIntervalSince1970: 1) 476 ) 477 RecordSerializer.applyMovesRecord(rec, value: value, to: ctx) 478 479 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 480 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 481 let games = try ctx.fetch(gameReq) 482 #expect(games.count == 1) 483 #expect(games.first?.title == "") 484 // The Moves row should be parented to the new stub. 485 let movesRows = fetchAll(ctx) 486 #expect(movesRows.count == 1) 487 #expect(movesRows.first?.game?.objectID == games.first?.objectID) 488 } 489 490 @Test("Bumps the parent game's updatedAt when the record is fresher") 491 func bumpsGameUpdatedAt() throws { 492 let persistence = makeTestPersistence() 493 let ctx = persistence.viewContext 494 495 // Pre-create the game with an old timestamp so the bump is observable. 496 let game = GameEntity(context: ctx) 497 game.id = gameID 498 game.ckRecordName = "game-\(gameID.uuidString)" 499 game.ckZoneName = RecordSerializer.zoneID(for: gameID).zoneName 500 game.title = "" 501 game.puzzleSource = "" 502 game.createdAt = Date(timeIntervalSince1970: 0) 503 game.updatedAt = Date(timeIntervalSince1970: 0) 504 try ctx.save() 505 506 let (rec, value) = try record( 507 in: ctx, 508 authorID: "alice", 509 deviceID: "d1", 510 cells: [:], 511 updatedAt: Date(timeIntervalSince1970: 1_700_000_000) 512 ) 513 RecordSerializer.applyMovesRecord(rec, value: value, to: ctx) 514 515 #expect(game.updatedAt == value.updatedAt) 516 } 517 }