crossmate

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

GameCursorStoreTests.swift (5276B)


      1 import Foundation
      2 import Testing
      3 
      4 @testable import Crossmate
      5 
      6 @Suite("GameCursorStore")
      7 @MainActor
      8 struct GameCursorStoreTests {
      9 
     10     private func makeStore() -> GameCursorStore {
     11         // Use a fresh UserDefaults suite per test to avoid cross-test pollution.
     12         let suiteName = "test-\(UUID().uuidString)"
     13         let defaults = UserDefaults(suiteName: suiteName)!
     14         return GameCursorStore(defaults: defaults)
     15     }
     16 
     17     /// 3×3 grid with a block at (1, 1):
     18     ///   row 0: A B C   (A1 across, D1/D2/… downs start here)
     19     ///   row 1: D # E
     20     ///   row 2: F G H
     21     /// (1, 0) = 'D' is part of the D1 down answer only — it has no across word.
     22     private func makeGame() throws -> Game {
     23         let source = """
     24         Title: Cursor Test
     25         Author: Test
     26 
     27 
     28         ABC
     29         D#E
     30         FGH
     31 
     32 
     33         A1. First across ~ ABC
     34         A3. Final across ~ FGH
     35         D1. First down ~ ADF
     36         D2. Final down ~ CEH
     37         """
     38         let puzzle = Puzzle(xd: try XD.parse(source))
     39         return Game(puzzle: puzzle)
     40     }
     41 
     42     private func makeSession(
     43         game: Game,
     44         gameID: UUID,
     45         store: GameCursorStore?
     46     ) -> PlayerSession {
     47         let mutator = GameMutator(game: game, gameID: gameID, movesUpdater: nil)
     48         return PlayerSession(game: game, mutator: mutator, cursorStore: store)
     49     }
     50 
     51     // MARK: - Store round-trip
     52 
     53     @Test("Cursor round-trips through the store")
     54     func cursorRoundTrips() {
     55         let store = makeStore()
     56         let gameID = UUID()
     57         #expect(store.cursor(forGame: gameID) == nil)
     58 
     59         store.setCursor(.init(row: 2, col: 1, direction: .down), forGame: gameID)
     60         #expect(store.cursor(forGame: gameID) == .init(row: 2, col: 1, direction: .down))
     61 
     62         store.clearCursor(forGame: gameID)
     63         #expect(store.cursor(forGame: gameID) == nil)
     64     }
     65 
     66     @Test("Cursors are isolated per game")
     67     func cursorsIsolatedPerGame() {
     68         let store = makeStore()
     69         let a = UUID()
     70         let b = UUID()
     71         store.setCursor(.init(row: 0, col: 1, direction: .across), forGame: a)
     72         store.setCursor(.init(row: 2, col: 2, direction: .down), forGame: b)
     73         #expect(store.cursor(forGame: a) == .init(row: 0, col: 1, direction: .across))
     74         #expect(store.cursor(forGame: b) == .init(row: 2, col: 2, direction: .down))
     75     }
     76 
     77     // MARK: - PlayerSession persistence + restore
     78 
     79     @Test("Moving the cursor persists it to the store")
     80     func movingCursorPersists() throws {
     81         let store = makeStore()
     82         let gameID = UUID()
     83         let session = makeSession(game: try makeGame(), gameID: gameID, store: store)
     84 
     85         session.select(row: 2, col: 2)
     86 
     87         let saved = try #require(store.cursor(forGame: gameID))
     88         #expect(saved.row == 2)
     89         #expect(saved.col == 2)
     90     }
     91 
     92     @Test("A new session restores the persisted cursor")
     93     func sessionRestoresPersistedCursor() throws {
     94         let store = makeStore()
     95         let gameID = UUID()
     96 
     97         let first = makeSession(game: try makeGame(), gameID: gameID, store: store)
     98         first.select(row: 2, col: 0)
     99         first.setDirection(.down)
    100 
    101         let reopened = makeSession(game: try makeGame(), gameID: gameID, store: store)
    102         #expect(reopened.selectedRow == 2)
    103         #expect(reopened.selectedCol == 0)
    104         #expect(reopened.direction == .down)
    105     }
    106 
    107     @Test("Without a store the session uses the default start position")
    108     func defaultsWithoutStore() throws {
    109         let session = makeSession(game: try makeGame(), gameID: UUID(), store: nil)
    110         #expect(session.selectedRow == 0)
    111         #expect(session.selectedCol == 0)
    112         #expect(session.direction == .across)
    113     }
    114 
    115     @Test("An out-of-bounds saved cursor falls back to the default start")
    116     func invalidSavedCursorFallsBack() throws {
    117         let store = makeStore()
    118         let gameID = UUID()
    119         store.setCursor(.init(row: 9, col: 9, direction: .across), forGame: gameID)
    120 
    121         let session = makeSession(game: try makeGame(), gameID: gameID, store: store)
    122         #expect(session.selectedRow == 0)
    123         #expect(session.selectedCol == 0)
    124         #expect(session.direction == .across)
    125     }
    126 
    127     @Test("A saved cursor on a block cell falls back to the default start")
    128     func blockSavedCursorFallsBack() throws {
    129         let store = makeStore()
    130         let gameID = UUID()
    131         store.setCursor(.init(row: 1, col: 1, direction: .across), forGame: gameID)
    132 
    133         let session = makeSession(game: try makeGame(), gameID: gameID, store: store)
    134         #expect(session.selectedRow == 0)
    135         #expect(session.selectedCol == 0)
    136     }
    137 
    138     @Test("Restore flips direction when the saved one has no word")
    139     func restoreFlipsDirectionWithoutWord() throws {
    140         let store = makeStore()
    141         let gameID = UUID()
    142         // (1, 0) = 'D' belongs only to the D1 down answer; there is no across
    143         // word through it, so a saved `.across` must flip to `.down`.
    144         store.setCursor(.init(row: 1, col: 0, direction: .across), forGame: gameID)
    145 
    146         let session = makeSession(game: try makeGame(), gameID: gameID, store: store)
    147         #expect(session.selectedRow == 1)
    148         #expect(session.selectedCol == 0)
    149         #expect(session.direction == .down)
    150     }
    151 }