crossmate

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

GameStoreCompletionLockTests.swift (8859B)


      1 import CoreData
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 /// Completion makes a game terminal. Opening it seals the displayed grid to the
      8 /// puzzle solution — so post-completion merge drift (a late clear, clock skew,
      9 /// or an edit that reached a peer over engagement but never synced) can't leave
     10 /// a hole or stray letter in a "finished" puzzle — and the returned mutator is
     11 /// locked against further input.
     12 @Suite("GameStore completion lock", .isolatedNotificationState)
     13 @MainActor
     14 struct GameStoreCompletionLockTests {
     15 
     16     private static let authorID = "alice"
     17     private static let puzzleSource = """
     18     Title: Test Puzzle
     19     Author: Test
     20 
     21 
     22     AB
     23     CD
     24 
     25 
     26     A1. Across 1 ~ AB
     27     A3. Across 3 ~ CD
     28     D1. Down 1 ~ AC
     29     D2. Down 2 ~ BD
     30     """
     31 
     32     private func makeGame(
     33         completed: Bool,
     34         in ctx: NSManagedObjectContext
     35     ) throws -> (GameEntity, UUID) {
     36         let xd = try XD.parse(Self.puzzleSource)
     37         let puzzle = Puzzle(xd: xd)
     38         let gameID = UUID()
     39         let entity = GameEntity(context: ctx)
     40         entity.id = gameID
     41         entity.title = "Test"
     42         entity.puzzleSource = Self.puzzleSource
     43         entity.createdAt = Date()
     44         entity.updatedAt = Date()
     45         if completed { entity.completedAt = Date() }
     46         entity.ckRecordName = "game-\(gameID.uuidString)"
     47         entity.populateCachedSummaryFields(from: puzzle)
     48         try ctx.save()
     49         return (entity, gameID)
     50     }
     51 
     52     /// Writes a single local-device Moves row carrying `cells` (position → letter).
     53     private func writeMoves(
     54         for entity: GameEntity,
     55         gameID: UUID,
     56         cells: [GridPosition: String],
     57         in ctx: NSManagedObjectContext
     58     ) throws {
     59         let row = MovesEntity(context: ctx)
     60         row.game = entity
     61         row.authorID = Self.authorID
     62         row.deviceID = "device-a"
     63         row.ckRecordName = RecordSerializer.recordName(
     64             forMovesInGame: gameID,
     65             authorID: Self.authorID,
     66             deviceID: "device-a"
     67         )
     68         let now = Date()
     69         var encoded: [GridPosition: TimestampedCell] = [:]
     70         for (pos, letter) in cells {
     71             encoded[pos] = TimestampedCell(
     72                 letter: letter, mark: .none, updatedAt: now, authorID: Self.authorID
     73             )
     74         }
     75         row.cells = try MovesCodec.encode(encoded)
     76         row.updatedAt = now
     77         try ctx.save()
     78     }
     79 
     80     @Test("A completed game seals a hole and a stray wrong letter to the solution")
     81     func sealsToSolution() throws {
     82         let persistence = makeTestPersistence()
     83         let store = makeTestStore(persistence: persistence)
     84         let ctx = persistence.viewContext
     85         let (entity, gameID) = try makeGame(completed: true, in: ctx)
     86 
     87         // (0,0) correct, (0,1) a stray wrong letter, (1,0) correct, (1,1) a
     88         // hole — the post-completion drift the seal has to repair.
     89         try writeMoves(
     90             for: entity, gameID: gameID,
     91             cells: [
     92                 GridPosition(row: 0, col: 0): "A",
     93                 GridPosition(row: 0, col: 1): "Z",
     94                 GridPosition(row: 1, col: 0): "C"
     95             ],
     96             in: ctx
     97         )
     98 
     99         let (game, _) = try store.loadGame(id: gameID)
    100 
    101         #expect(game.squares[0][0].entry == "A")   // correct — kept
    102         #expect(game.squares[0][1].entry == "B")   // stray — overwritten
    103         #expect(game.squares[1][0].entry == "C")   // correct — kept
    104         #expect(game.squares[1][1].entry == "D")   // hole — filled
    105         #expect(game.completionState == .solved)
    106     }
    107 
    108     @Test("Loading a completed game returns a locked mutator that rejects input")
    109     func loadedMutatorIsLocked() throws {
    110         let persistence = makeTestPersistence()
    111         let store = makeTestStore(persistence: persistence)
    112         let ctx = persistence.viewContext
    113         let (entity, gameID) = try makeGame(completed: true, in: ctx)
    114         try writeMoves(
    115             for: entity, gameID: gameID,
    116             cells: [GridPosition(row: 0, col: 0): "A"],
    117             in: ctx
    118         )
    119 
    120         let (game, mutator) = try store.loadGame(id: gameID)
    121         #expect(mutator.isCompleted)
    122 
    123         mutator.setLetter("Z", atRow: 1, atCol: 1, pencil: false)
    124         #expect(game.squares[1][1].entry == "D")   // seal stands; input rejected
    125     }
    126 
    127     @Test("An incomplete game is not sealed — an untouched hole stays empty")
    128     func incompleteGameNotSealed() throws {
    129         let persistence = makeTestPersistence()
    130         let store = makeTestStore(persistence: persistence)
    131         let ctx = persistence.viewContext
    132         let (entity, gameID) = try makeGame(completed: false, in: ctx)
    133         try writeMoves(
    134             for: entity, gameID: gameID,
    135             cells: [GridPosition(row: 0, col: 0): "A"],
    136             in: ctx
    137         )
    138 
    139         let (game, mutator) = try store.loadGame(id: gameID)
    140 
    141         #expect(game.squares[0][0].entry == "A")
    142         #expect(game.squares[1][1].entry == "")    // untouched hole stays empty
    143         #expect(mutator.isCompleted == false)
    144         #expect(game.completionState != .solved)
    145     }
    146 
    147     @Test("Opening an owned legacy game upgrades it to the current sync version")
    148     func ownerOpenUpgradesSyncVersion() throws {
    149         let persistence = makeTestPersistence()
    150         var enqueuedRecordNames: [String] = []
    151         let store = makeTestStore(
    152             persistence: persistence,
    153             onGameUpdated: { enqueuedRecordNames.append($0) }
    154         )
    155         let ctx = persistence.viewContext
    156         let (entity, gameID) = try makeGame(completed: false, in: ctx)
    157         entity.databaseScope = DatabaseScope.private.rawValue
    158         entity.syncVersion = GameSyncVersion.legacy
    159         try ctx.save()
    160 
    161         _ = try store.loadGame(id: gameID)
    162 
    163         #expect(entity.syncVersion == GameSyncVersion.current)
    164         #expect(entity.hasPendingSave)
    165         #expect(enqueuedRecordNames == ["game-\(gameID.uuidString)"])
    166     }
    167 
    168     @Test("Opening a participant legacy game waits for the owner's upgrade")
    169     func participantOpenDoesNotUpgradeSyncVersion() throws {
    170         let persistence = makeTestPersistence()
    171         var enqueuedRecordNames: [String] = []
    172         let store = makeTestStore(
    173             persistence: persistence,
    174             onGameUpdated: { enqueuedRecordNames.append($0) }
    175         )
    176         let ctx = persistence.viewContext
    177         let (entity, gameID) = try makeGame(completed: false, in: ctx)
    178         entity.databaseScope = DatabaseScope.shared.rawValue
    179         entity.syncVersion = GameSyncVersion.legacy
    180         try ctx.save()
    181 
    182         _ = try store.loadGame(id: gameID)
    183 
    184         #expect(entity.syncVersion == GameSyncVersion.legacy)
    185         #expect(!entity.hasPendingSave)
    186         #expect(enqueuedRecordNames.isEmpty)
    187     }
    188 
    189     @Test("Completion delivery waits for both Game and Moves, then journal")
    190     func completionDeliveryGates() {
    191         var progress = CompletionDeliveryProgress(resigned: false)
    192         #expect(!progress.canDeliverCompletion)
    193 
    194         progress.durableRecords.insert(.game)
    195         #expect(!progress.canDeliverCompletion)
    196         progress.durableRecords.insert(.journal)
    197         #expect(!progress.canDeliverCompletion)
    198 
    199         progress.durableRecords.insert(.moves)
    200         #expect(progress.canDeliverCompletion)
    201         #expect(!progress.canDeliverReplay)
    202 
    203         progress.completionDelivered = true
    204         #expect(progress.canDeliverReplay)
    205         progress.replayDelivered = true
    206         #expect(!progress.canDeliverReplay)
    207     }
    208 
    209     @Test("A pre-completion Moves acknowledgement cannot open the gate")
    210     func completionDeliveryOrdering() {
    211         var progress = CompletionDeliveryProgress(resigned: false)
    212         progress.acknowledge([.moves, .journal])
    213         #expect(progress.durableRecords.isEmpty)
    214         progress.acknowledge([.game])
    215         #expect(!progress.canDeliverCompletion)
    216         progress.acknowledge([.moves, .journal])
    217         #expect(progress.canDeliverCompletion)
    218         #expect(progress.durableRecords.contains(.journal))
    219     }
    220 
    221     @Test("Only the device that initiates completion stages a peer notification")
    222     func completionCallbackDistinguishesObservedSolve() throws {
    223         let persistence = makeTestPersistence()
    224         let store = makeTestStore(
    225             persistence: persistence,
    226             authorIDProvider: { Self.authorID }
    227         )
    228         let (_, gameID) = try makeGame(completed: false, in: persistence.viewContext)
    229         var callback: (resigned: Bool, notifyPeers: Bool)?
    230         store.onJournalComplete = { _, _, resigned, notifyPeers in
    231             callback = (resigned, notifyPeers)
    232         }
    233 
    234         #expect(try store.markCompletedFromObservedSolvedState(id: gameID))
    235         #expect(callback?.resigned == false)
    236         #expect(callback?.notifyPeers == false)
    237     }
    238 }