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 }