crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }