PendingEditFlagTests.swift (8655B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 /// Pins down the disappearing-letter race. While a typed letter is buffered 8 /// in `MovesUpdater` (not yet in `MovesEntity`), `Square.enqueuedAt` is set 9 /// and `GameStore.restore` must not revert the cell from the merged grid on a 10 /// remote refresh. The flag is retired by `restore` itself, once the local 11 /// device's row shows the cell at a timestamp >= the flag. 12 @Suite("Pending-edit flag", .serialized) 13 @MainActor 14 struct PendingEditFlagTests { 15 16 private static let localAuthorID = "local-author" 17 private static let otherAuthorID = "other-author" 18 19 private static let puzzleSource = """ 20 Title: Test Puzzle 21 Author: Test 22 23 24 ABC 25 D#E 26 FGH 27 28 29 A1. Across 1 ~ ABC 30 A4. Across 4 ~ DE 31 A5. Across 5 ~ FGH 32 D1. Down 1 ~ ADF 33 D2. Down 2 ~ BG 34 D3. Down 3 ~ CEH 35 """ 36 37 private func seedGame(in ctx: NSManagedObjectContext) throws -> (GameEntity, UUID) { 38 let gameID = UUID() 39 let xd = try XD.parse(Self.puzzleSource) 40 let puzzle = Puzzle(xd: xd) 41 let entity = GameEntity(context: ctx) 42 entity.id = gameID 43 entity.title = "Test" 44 entity.puzzleSource = Self.puzzleSource 45 entity.createdAt = Date() 46 entity.updatedAt = Date() 47 entity.ckRecordName = "game-\(gameID.uuidString)" 48 entity.populateCachedSummaryFields(from: puzzle) 49 try ctx.save() 50 return (entity, gameID) 51 } 52 53 /// Upserts a `MovesEntity` row for `(authorID, deviceID)` carrying a 54 /// single cell at (0,0). 55 private func writeMovesRow( 56 for entity: GameEntity, 57 gameID: UUID, 58 authorID: String, 59 deviceID: String, 60 letter: String, 61 updatedAt: Date, 62 in ctx: NSManagedObjectContext 63 ) throws { 64 let recordName = RecordSerializer.recordName( 65 forMovesInGame: gameID, 66 authorID: authorID, 67 deviceID: deviceID 68 ) 69 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 70 req.predicate = NSPredicate(format: "ckRecordName == %@", recordName) 71 req.fetchLimit = 1 72 let row = (try? ctx.fetch(req).first) ?? MovesEntity(context: ctx) 73 row.game = entity 74 row.authorID = authorID 75 row.deviceID = deviceID 76 row.ckRecordName = recordName 77 let cells: [GridPosition: TimestampedCell] = [ 78 GridPosition(row: 0, col: 0): TimestampedCell( 79 letter: letter, 80 mark: .pen(checked: nil), 81 updatedAt: updatedAt, 82 authorID: authorID 83 ) 84 ] 85 row.cells = try MovesCodec.encode(cells) 86 row.updatedAt = updatedAt 87 try ctx.save() 88 } 89 90 private func makeStore(_ persistence: PersistenceController) -> GameStore { 91 makeTestStore( 92 persistence: persistence, 93 authorIDProvider: { Self.localAuthorID } 94 ) 95 } 96 97 @Test("Remote refresh during the debounce window keeps the typed letter") 98 func remoteRefreshKeepsBufferedLetter() throws { 99 let persistence = makeTestPersistence() 100 let store = makeStore(persistence) 101 let ctx = persistence.viewContext 102 let (entity, gameID) = try seedGame(in: ctx) 103 104 let t0 = Date(timeIntervalSinceNow: -10) 105 try writeMovesRow( 106 for: entity, gameID: gameID, 107 authorID: Self.localAuthorID, 108 deviceID: RecordSerializer.localDeviceID, 109 letter: "A", updatedAt: t0, in: ctx 110 ) 111 112 let (game, _) = try store.loadGame(id: gameID) 113 #expect(game.squares[0][0].entry == "A") 114 115 // User types "B" over "A". GameMutator stamps the flag synchronously; 116 // the buffered edit has NOT reached MovesEntity yet (still "A"@t0). 117 let t1 = Date() 118 game.squares[0][0].entry = "B" 119 game.squares[0][0].enqueuedAt = t1 120 121 // An inbound remote refresh re-runs restore against the merged grid, 122 // which still resolves to "A". 123 store.refreshCurrentGame() 124 125 #expect(game.squares[0][0].entry == "B") 126 #expect(game.squares[0][0].enqueuedAt == t1) 127 } 128 129 @Test("Flag retires once the edit lands in the local row") 130 func flagRetiresAfterFlush() throws { 131 let persistence = makeTestPersistence() 132 let store = makeStore(persistence) 133 let ctx = persistence.viewContext 134 let (entity, gameID) = try seedGame(in: ctx) 135 136 let t0 = Date(timeIntervalSinceNow: -10) 137 try writeMovesRow( 138 for: entity, gameID: gameID, 139 authorID: Self.localAuthorID, 140 deviceID: RecordSerializer.localDeviceID, 141 letter: "A", updatedAt: t0, in: ctx 142 ) 143 144 let (game, _) = try store.loadGame(id: gameID) 145 146 let t1 = Date() 147 game.squares[0][0].entry = "B" 148 game.squares[0][0].enqueuedAt = t1 149 150 // Simulate the MovesUpdater flush: "B" lands in the local row with 151 // updatedAt == the flag's timestamp. 152 try writeMovesRow( 153 for: entity, gameID: gameID, 154 authorID: Self.localAuthorID, 155 deviceID: RecordSerializer.localDeviceID, 156 letter: "B", updatedAt: t1, in: ctx 157 ) 158 159 store.refreshCurrentGame() 160 161 #expect(game.squares[0][0].entry == "B") 162 #expect(game.squares[0][0].enqueuedAt == nil) 163 } 164 165 @Test("A newer same-cell edit is not retired by an older landed value") 166 func newerEditNotRetiredByOlderLanded() throws { 167 let persistence = makeTestPersistence() 168 let store = makeStore(persistence) 169 let ctx = persistence.viewContext 170 let (entity, gameID) = try seedGame(in: ctx) 171 172 let t0 = Date(timeIntervalSinceNow: -10) 173 try writeMovesRow( 174 for: entity, gameID: gameID, 175 authorID: Self.localAuthorID, 176 deviceID: RecordSerializer.localDeviceID, 177 letter: "A", updatedAt: t0, in: ctx 178 ) 179 180 let (game, _) = try store.loadGame(id: gameID) 181 182 // "B" typed and flushed (lands at t1)... 183 let t1 = Date(timeIntervalSinceNow: -1) 184 try writeMovesRow( 185 for: entity, gameID: gameID, 186 authorID: Self.localAuthorID, 187 deviceID: RecordSerializer.localDeviceID, 188 letter: "B", updatedAt: t1, in: ctx 189 ) 190 // ...then the user re-types "C" before the next flush. The flag now 191 // carries t2 > t1; the local row is still at "B"@t1. 192 let t2 = Date() 193 game.squares[0][0].entry = "C" 194 game.squares[0][0].enqueuedAt = t2 195 196 store.refreshCurrentGame() 197 198 // t1 >= t2 is false, so the flag must not retire and "C" must stay. 199 #expect(game.squares[0][0].entry == "C") 200 #expect(game.squares[0][0].enqueuedAt == t2) 201 } 202 203 @Test("Diverged peer write converges to the LWW winner after flush") 204 func divergesThenConvergesToLWWWinner() throws { 205 let persistence = makeTestPersistence() 206 let store = makeStore(persistence) 207 let ctx = persistence.viewContext 208 let (entity, gameID) = try seedGame(in: ctx) 209 210 let t0 = Date(timeIntervalSinceNow: -10) 211 try writeMovesRow( 212 for: entity, gameID: gameID, 213 authorID: Self.localAuthorID, 214 deviceID: RecordSerializer.localDeviceID, 215 letter: "A", updatedAt: t0, in: ctx 216 ) 217 218 let (game, _) = try store.loadGame(id: gameID) 219 220 // Local user types "B" (buffered, not landed). 221 let t1 = Date(timeIntervalSinceNow: -1) 222 game.squares[0][0].entry = "B" 223 game.squares[0][0].enqueuedAt = t1 224 225 // A peer writes "Z" with a newer timestamp; it arrives via fetch. 226 let t2 = Date() 227 try writeMovesRow( 228 for: entity, gameID: gameID, 229 authorID: Self.otherAuthorID, 230 deviceID: "device-other", 231 letter: "Z", updatedAt: t2, in: ctx 232 ) 233 234 store.refreshCurrentGame() 235 // Diverged: the still-buffered local edit wins on screen. 236 #expect(game.squares[0][0].entry == "B") 237 238 // Local "B" flushes (lands at t1). Next refresh retires the flag and 239 // adopts the merged grid; peer "Z"@t2 beats local "B"@t1. 240 try writeMovesRow( 241 for: entity, gameID: gameID, 242 authorID: Self.localAuthorID, 243 deviceID: RecordSerializer.localDeviceID, 244 letter: "B", updatedAt: t1, in: ctx 245 ) 246 store.refreshCurrentGame() 247 248 #expect(game.squares[0][0].entry == "Z") 249 #expect(game.squares[0][0].enqueuedAt == nil) 250 } 251 }