SnapshotServiceTests.swift (8136B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 @Suite("SnapshotService", .serialized) 8 @MainActor 9 struct SnapshotServiceTests { 10 @Test("Creating a compaction snapshot keeps covered moves until the snapshot is saved") 11 func snapshotCreationDoesNotImmediatelyPruneMoves() async throws { 12 let (service, gameID) = try makeServiceWithCompletedGame(moveCount: 2) 13 14 let result = await service.createSnapshotsIfNeeded(for: [gameID]) 15 service.persistence.viewContext.refreshAllObjects() 16 17 #expect(result.snapshotNames.count == 1) 18 #expect(result.prunedMoveNames.isEmpty) 19 #expect(fetchMoveNames(service: service, gameID: gameID).count == 2) 20 #expect(fetchPendingPruneSnapshotNames(service: service) == result.snapshotNames) 21 } 22 23 @Test("Saved local compaction snapshots prune their covered moves") 24 func savedSnapshotPrunesCoveredMoves() async throws { 25 let (service, gameID) = try makeServiceWithCompletedGame(moveCount: 2) 26 let result = await service.createSnapshotsIfNeeded(for: [gameID]) 27 service.persistence.viewContext.refreshAllObjects() 28 29 let pruned = service.pruneMoves( 30 ckRecordNames: Set(result.snapshotNames) 31 ) 32 33 #expect(Set(pruned) == Set([ 34 RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1), 35 RecordSerializer.recordName(forMoveInGame: gameID, lamport: 2) 36 ])) 37 #expect(fetchMoveNames(service: service, gameID: gameID).isEmpty) 38 #expect(fetchPendingPruneSnapshotNames(service: service).isEmpty) 39 } 40 41 @Test("Durable pending snapshots are pruned on a later pass") 42 func durablePendingSnapshotPrunesOnRecoveryPass() async throws { 43 let (service, gameID) = try makeServiceWithCompletedGame(moveCount: 1) 44 let result = await service.createSnapshotsIfNeeded(for: [gameID]) 45 service.persistence.viewContext.refreshAllObjects() 46 try markSnapshotSaved(service: service, ckRecordName: result.snapshotNames[0]) 47 48 let pruned = service.pruneMoves() 49 50 #expect(pruned == [ 51 RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1) 52 ]) 53 #expect(fetchMoveNames(service: service, gameID: gameID).isEmpty) 54 #expect(fetchPendingPruneSnapshotNames(service: service).isEmpty) 55 } 56 57 @Test("Remote snapshots are not used to prune local moves") 58 func remoteSnapshotDoesNotPruneLocalMoves() throws { 59 let (service, gameID) = try makeServiceWithCompletedGame(moveCount: 1) 60 let context = service.persistence.viewContext 61 let game = try #require(try fetchGame(service: service, gameID: gameID)) 62 let snapshot = SnapshotEntity(context: context) 63 snapshot.game = game 64 snapshot.ckRecordName = "snapshot-\(gameID.uuidString)-1-remote" 65 snapshot.ckSystemFields = Data([1]) 66 snapshot.createdAt = Date() 67 snapshot.gridState = try MoveLog.encodeGridState([:]) 68 snapshot.upToLamport = 1 69 snapshot.needsPruning = false 70 try context.save() 71 72 let pruned = service.pruneMoves() 73 74 #expect(pruned.isEmpty) 75 #expect(fetchMoveNames(service: service, gameID: gameID).count == 1) 76 } 77 78 @Test("Shared games do not create scalar-Lamport snapshots") 79 func sharedGamesDoNotCreateSnapshots() async throws { 80 let (service, gameID) = try makeServiceWithCompletedGame( 81 moveCount: 2, 82 configure: { game in 83 game.databaseScope = 1 84 } 85 ) 86 87 let result = await service.createSnapshotsIfNeeded(for: [gameID]) 88 service.persistence.viewContext.refreshAllObjects() 89 90 #expect(result.snapshotNames.isEmpty) 91 #expect(result.prunedMoveNames.isEmpty) 92 #expect(fetchMoveNames(service: service, gameID: gameID).count == 2) 93 #expect(fetchPendingPruneSnapshotNames(service: service).isEmpty) 94 } 95 96 @Test("Shared games do not prune moves from existing scalar snapshots") 97 func sharedGamesDoNotPruneExistingSnapshots() throws { 98 let (service, gameID) = try makeServiceWithCompletedGame( 99 moveCount: 1, 100 configure: { game in 101 game.ckShareRecordName = "share-test" 102 } 103 ) 104 let context = service.persistence.viewContext 105 let game = try #require(try fetchGame(service: service, gameID: gameID)) 106 let snapshot = SnapshotEntity(context: context) 107 snapshot.game = game 108 snapshot.ckRecordName = RecordSerializer.recordName( 109 forSnapshotInGame: gameID, 110 upToLamport: 1 111 ) 112 snapshot.ckSystemFields = Data([1]) 113 snapshot.createdAt = Date() 114 snapshot.gridState = try MoveLog.encodeGridState([:]) 115 snapshot.upToLamport = 1 116 snapshot.needsPruning = true 117 try context.save() 118 119 let pruned = service.pruneMoves() 120 121 #expect(pruned.isEmpty) 122 #expect(fetchMoveNames(service: service, gameID: gameID).count == 1) 123 #expect(fetchPendingPruneSnapshotNames(service: service).isEmpty) 124 } 125 126 private func makeServiceWithCompletedGame( 127 moveCount: Int, 128 configure: (GameEntity) -> Void = { _ in } 129 ) throws -> (SnapshotService, UUID) { 130 let persistence = makeTestPersistence() 131 let service = SnapshotService(persistence: persistence) 132 let context = persistence.viewContext 133 let gameID = UUID() 134 let game = GameEntity(context: context) 135 game.id = gameID 136 game.title = "Test" 137 game.puzzleSource = "" 138 game.createdAt = Date() 139 game.updatedAt = Date() 140 game.completedAt = Date() 141 game.ckRecordName = "game-\(gameID.uuidString)" 142 game.lamportHighWater = Int64(moveCount) 143 configure(game) 144 145 for lamport in 1...moveCount { 146 let move = MoveEntity(context: context) 147 move.game = game 148 move.lamport = Int64(lamport) 149 move.row = 0 150 move.col = Int16(lamport - 1) 151 move.letter = "\(lamport)" 152 move.markKind = 0 153 move.checkedWrong = false 154 move.createdAt = Date() 155 move.ckRecordName = RecordSerializer.recordName( 156 forMoveInGame: gameID, 157 lamport: Int64(lamport) 158 ) 159 } 160 161 try context.save() 162 return (service, gameID) 163 } 164 165 private func fetchGame(service: SnapshotService, gameID: UUID) throws -> GameEntity? { 166 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 167 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 168 request.fetchLimit = 1 169 return try service.persistence.viewContext.fetch(request).first 170 } 171 172 private func fetchMoveNames(service: SnapshotService, gameID: UUID) -> [String] { 173 let request = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") 174 request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) 175 request.sortDescriptors = [NSSortDescriptor(key: "lamport", ascending: true)] 176 return ((try? service.persistence.viewContext.fetch(request)) ?? []) 177 .compactMap(\.ckRecordName) 178 } 179 180 private func fetchPendingPruneSnapshotNames(service: SnapshotService) -> [String] { 181 let request = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") 182 request.predicate = NSPredicate(format: "needsPruning == YES") 183 return ((try? service.persistence.viewContext.fetch(request)) ?? []) 184 .compactMap(\.ckRecordName) 185 .sorted() 186 } 187 188 private func markSnapshotSaved( 189 service: SnapshotService, 190 ckRecordName: String 191 ) throws { 192 let request = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") 193 request.predicate = NSPredicate(format: "ckRecordName == %@", ckRecordName) 194 request.fetchLimit = 1 195 let snapshot = try #require(service.persistence.viewContext.fetch(request).first) 196 snapshot.ckSystemFields = Data([1]) 197 try service.persistence.viewContext.save() 198 } 199 }