crossmate

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

GameStoreCompletionLockTests.swift (5150B)


      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 }