GameStoreMergedAuthorCellsTests.swift (5156B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 /// `GameStore.mergedAuthorCells` is the data source for the per-recipient 8 /// pause-push diff: each touched grid position's winning `TimestampedCell` 9 /// after LWW-merging the author's MovesEntities across devices, including 10 /// cleared cells whose latest write left them empty. 11 @Suite("GameStore.mergedAuthorCells", .isolatedNotificationState) 12 @MainActor 13 struct GameStoreMergedAuthorCellsTests { 14 15 private static let authorID = "alice" 16 private static let puzzleSource = """ 17 Title: Test Puzzle 18 Author: Test 19 20 21 AB 22 CD 23 """ 24 25 private func makeGame(in ctx: NSManagedObjectContext) throws -> (GameEntity, UUID) { 26 let gameID = UUID() 27 let entity = GameEntity(context: ctx) 28 entity.id = gameID 29 entity.title = "Test" 30 entity.puzzleSource = Self.puzzleSource 31 entity.createdAt = Date() 32 entity.updatedAt = Date() 33 entity.ckRecordName = "game-\(gameID.uuidString)" 34 entity.ckZoneName = "game-\(gameID.uuidString)" 35 entity.databaseScope = 1 36 try ctx.save() 37 return (entity, gameID) 38 } 39 40 private func addMoves( 41 for entity: GameEntity, 42 gameID: UUID, 43 deviceID: String, 44 cells: [GridPosition: TimestampedCell], 45 updatedAt: Date, 46 in ctx: NSManagedObjectContext 47 ) throws { 48 let row = MovesEntity(context: ctx) 49 row.game = entity 50 row.authorID = Self.authorID 51 row.deviceID = deviceID 52 row.cells = try MovesCodec.encode(cells) 53 row.updatedAt = updatedAt 54 row.ckRecordName = RecordSerializer.recordName( 55 forMovesInGame: gameID, 56 authorID: Self.authorID, 57 deviceID: deviceID 58 ) 59 try ctx.save() 60 } 61 62 private func cell( 63 letter: String, 64 at updatedAt: Date 65 ) -> TimestampedCell { 66 TimestampedCell( 67 letter: letter, 68 mark: .none, 69 updatedAt: updatedAt, 70 authorID: Self.authorID 71 ) 72 } 73 74 @Test("Returns an empty list when the author has no Moves rows") 75 func emptyWhenNoMoves() throws { 76 let persistence = makeTestPersistence() 77 let store = makeTestStore(persistence: persistence) 78 let (_, gameID) = try makeGame(in: persistence.viewContext) 79 80 let cells = store.mergedAuthorCells(for: gameID, by: Self.authorID) 81 82 #expect(cells.isEmpty) 83 } 84 85 @Test("Later-timestamped write across devices wins for the same position") 86 func laterWriteWins() throws { 87 let persistence = makeTestPersistence() 88 let store = makeTestStore(persistence: persistence) 89 let (entity, gameID) = try makeGame(in: persistence.viewContext) 90 let earlier = Date(timeIntervalSince1970: 1_000) 91 let later = Date(timeIntervalSince1970: 2_000) 92 93 try addMoves( 94 for: entity, 95 gameID: gameID, 96 deviceID: "ipad", 97 cells: [GridPosition(row: 0, col: 0): cell(letter: "A", at: earlier)], 98 updatedAt: earlier, 99 in: persistence.viewContext 100 ) 101 try addMoves( 102 for: entity, 103 gameID: gameID, 104 deviceID: "iphone", 105 cells: [GridPosition(row: 0, col: 0): cell(letter: "B", at: later)], 106 updatedAt: later, 107 in: persistence.viewContext 108 ) 109 110 let cells = store.mergedAuthorCells(for: gameID, by: Self.authorID) 111 112 #expect(cells.count == 1) 113 let winner = try #require(cells.first) 114 #expect(winner.letter == "B") 115 #expect(winner.updatedAt == later) 116 } 117 118 @Test("Cleared cells (empty letter) are retained so the diff can count them") 119 func clearedCellsRetained() throws { 120 let persistence = makeTestPersistence() 121 let store = makeTestStore(persistence: persistence) 122 let (entity, gameID) = try makeGame(in: persistence.viewContext) 123 let early = Date(timeIntervalSince1970: 1_000) 124 let later = Date(timeIntervalSince1970: 2_000) 125 126 try addMoves( 127 for: entity, 128 gameID: gameID, 129 deviceID: "ipad", 130 cells: [ 131 GridPosition(row: 0, col: 0): cell(letter: "A", at: early), 132 GridPosition(row: 0, col: 1): cell(letter: "B", at: early) 133 ], 134 updatedAt: early, 135 in: persistence.viewContext 136 ) 137 // Later write on a sibling device clears (0, 1). 138 try addMoves( 139 for: entity, 140 gameID: gameID, 141 deviceID: "iphone", 142 cells: [GridPosition(row: 0, col: 1): cell(letter: "", at: later)], 143 updatedAt: later, 144 in: persistence.viewContext 145 ) 146 147 let cells = store.mergedAuthorCells(for: gameID, by: Self.authorID) 148 149 #expect(cells.count == 2) 150 let cleared = cells.first { $0.letter.isEmpty } 151 let filled = cells.first { !$0.letter.isEmpty } 152 #expect(cleared?.updatedAt == later) 153 #expect(filled?.letter == "A") 154 #expect(filled?.updatedAt == early) 155 } 156 }