crossmate

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

GamePlayerColorStoreTests.swift (8173B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 @Suite("GamePlayerColorStore")
      9 @MainActor
     10 struct GamePlayerColorStoreTests {
     11 
     12     private func makeStore() -> GamePlayerColorStore {
     13         // Use a fresh UserDefaults suite per test to avoid cross-test pollution.
     14         let suiteName = "test-\(UUID().uuidString)"
     15         let defaults = UserDefaults(suiteName: suiteName)!
     16         return GamePlayerColorStore(defaults: defaults)
     17     }
     18 
     19     // MARK: - ensureColor
     20 
     21     @Test("ensureColor on empty store picks a palette entry and persists it")
     22     func ensureColorPersists() {
     23         let store = makeStore()
     24         let gameID = UUID()
     25         let color = store.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: [])
     26         #expect(PlayerColor.palette.contains(color))
     27         // Second call returns the same colour.
     28         let again = store.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: [])
     29         #expect(again.id == color.id)
     30     }
     31 
     32     @Test("ensureColor never returns a reserved colour when alternatives exist")
     33     func ensureColorRespectsReserved() {
     34         let store = makeStore()
     35         let gameID = UUID()
     36         // Reserve all colours except the last one.
     37         let reserved = Set(PlayerColor.palette.dropLast().map { $0.id })
     38         let color = store.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: reserved)
     39         #expect(!reserved.contains(color.id))
     40         #expect(color.id == PlayerColor.palette.last!.id)
     41     }
     42 
     43     @Test("ensureColor with seeded RNG is deterministic")
     44     func ensureColorSeededRNG() {
     45         let store = makeStore()
     46         let gameID = UUID()
     47         var rng = SeededRNG(seed: 42)
     48         let c1 = store.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: [], using: &rng)
     49 
     50         let store2 = makeStore()
     51         var rng2 = SeededRNG(seed: 42)
     52         let c2 = store2.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: [], using: &rng2)
     53 
     54         #expect(c1.id == c2.id)
     55     }
     56 
     57     @Test("ensureColor with palette fully saturated falls back to a palette entry")
     58     func ensureColorFallback() {
     59         let store = makeStore()
     60         let gameID = UUID()
     61         // Assign all palette colours to other authors for this game.
     62         for (i, color) in PlayerColor.palette.enumerated() {
     63             store.setColor(color, forGame: gameID, authorID: "_other\(i)")
     64         }
     65         // All colours are assigned; ensureColor should still return something.
     66         let reserved = Set(PlayerColor.palette.map { $0.id })
     67         var rng = SeededRNG(seed: 99)
     68         let fallback = store.ensureColor(
     69             forGame: gameID,
     70             authorID: "_new",
     71             reservedColorIDs: reserved,
     72             using: &rng
     73         )
     74         #expect(PlayerColor.palette.contains(fallback))
     75     }
     76 
     77     @Test("ensureColor persists without posting a change notification")
     78     func ensureColorDoesNotPostChangeNotification() {
     79         let store = makeStore()
     80         let gameID = UUID()
     81         var notificationCount = 0
     82         let observer = NotificationCenter.default.addObserver(
     83             forName: .gamePlayerColorsChanged,
     84             object: nil,
     85             queue: nil
     86         ) { note in
     87             guard let id = note.userInfo?["gameID"] as? UUID, id == gameID else { return }
     88             notificationCount += 1
     89         }
     90         defer { NotificationCenter.default.removeObserver(observer) }
     91 
     92         _ = store.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: [])
     93 
     94         #expect(notificationCount == 0)
     95     }
     96 
     97     // MARK: - setColor / color round-trip
     98 
     99     @Test("setColor and color round-trip correctly")
    100     func setColorRoundTrip() {
    101         let store = makeStore()
    102         let gameID = UUID()
    103         store.setColor(.red, forGame: gameID, authorID: "_A")
    104         #expect(store.color(forGame: gameID, authorID: "_A")?.id == PlayerColor.red.id)
    105     }
    106 
    107     @Test("color returns nil for unknown authorID")
    108     func colorNilForUnknown() {
    109         let store = makeStore()
    110         #expect(store.color(forGame: UUID(), authorID: "_unknown") == nil)
    111     }
    112 
    113     // MARK: - clearColors / clearColor
    114 
    115     @Test("clearColors removes all entries for a game")
    116     func clearColorsRemovesAll() {
    117         let store = makeStore()
    118         let gameID = UUID()
    119         store.setColor(.red, forGame: gameID, authorID: "_A")
    120         store.setColor(.blue, forGame: gameID, authorID: "_B")
    121         store.clearColors(forGame: gameID)
    122         #expect(store.color(forGame: gameID, authorID: "_A") == nil)
    123         #expect(store.color(forGame: gameID, authorID: "_B") == nil)
    124     }
    125 
    126     @Test("clearColor removes only the targeted author")
    127     func clearColorScopedToAuthor() {
    128         let store = makeStore()
    129         let gameID = UUID()
    130         store.setColor(.red, forGame: gameID, authorID: "_A")
    131         store.setColor(.blue, forGame: gameID, authorID: "_B")
    132         store.clearColor(forGame: gameID, authorID: "_A")
    133         #expect(store.color(forGame: gameID, authorID: "_A") == nil)
    134         #expect(store.color(forGame: gameID, authorID: "_B")?.id == PlayerColor.blue.id)
    135     }
    136 
    137     @Test("clearColors for one game does not affect another game")
    138     func clearColorsGameScoped() {
    139         let store = makeStore()
    140         let g1 = UUID()
    141         let g2 = UUID()
    142         store.setColor(.red, forGame: g1, authorID: "_A")
    143         store.setColor(.blue, forGame: g2, authorID: "_A")
    144         store.clearColors(forGame: g1)
    145         #expect(store.color(forGame: g2, authorID: "_A")?.id == PlayerColor.blue.id)
    146     }
    147 
    148     // MARK: - assignedColorIDs
    149 
    150     @Test("assignedColorIDs returns IDs of all assigned colours for a game")
    151     func assignedColorIDsAll() {
    152         let store = makeStore()
    153         let gameID = UUID()
    154         store.setColor(.red, forGame: gameID, authorID: "_A")
    155         store.setColor(.blue, forGame: gameID, authorID: "_B")
    156         let ids = store.assignedColorIDs(forGame: gameID, excludingAuthorID: nil)
    157         #expect(ids == ["red", "blue"])
    158     }
    159 
    160     @Test("assignedColorIDs excludes the specified authorID")
    161     func assignedColorIDsExcludes() {
    162         let store = makeStore()
    163         let gameID = UUID()
    164         store.setColor(.red, forGame: gameID, authorID: "_A")
    165         store.setColor(.blue, forGame: gameID, authorID: "_B")
    166         let ids = store.assignedColorIDs(forGame: gameID, excludingAuthorID: "_A")
    167         #expect(ids == ["blue"])
    168         #expect(!ids.contains("red"))
    169     }
    170 
    171     // MARK: - Color cleanup on game deletion
    172 
    173     @Test("Color store is cleared when a game is deleted via onGameDeleted")
    174     func colorStoreCleanupOnGameDelete() throws {
    175         let persistence = makeTestPersistence()
    176         let ctx = persistence.viewContext
    177         let gameID = UUID()
    178         let entity = GameEntity(context: ctx)
    179         entity.id = gameID
    180         entity.title = "Test"
    181         entity.puzzleSource = ""
    182         entity.createdAt = Date()
    183         entity.updatedAt = Date()
    184         entity.ckRecordName = "game-\(gameID.uuidString)"
    185         try ctx.save()
    186 
    187         let colorStore = makeStore()
    188         colorStore.setColor(.red, forGame: gameID, authorID: "_A")
    189         colorStore.setColor(.blue, forGame: gameID, authorID: "_B")
    190 
    191         // Use the production factory so this test fails if the real wiring
    192         // ever drops the colour-cleanup branch.
    193         let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
    194         let syncEngine = SyncEngine(container: container, persistence: persistence)
    195         let store = makeTestStore(
    196             persistence: persistence,
    197             onGameDeleted: AppServices.makeOnGameDeleted(
    198                 syncEngine: syncEngine,
    199                 colorStore: colorStore
    200             )
    201         )
    202 
    203         try store.deleteGame(id: gameID)
    204 
    205         #expect(colorStore.storedAuthorIDs(forGame: gameID).isEmpty)
    206     }
    207 }
    208 
    209 // Minimal seeded RNG for deterministic tests.
    210 private struct SeededRNG: RandomNumberGenerator {
    211     private var state: UInt64
    212 
    213     init(seed: UInt64) { state = seed }
    214 
    215     mutating func next() -> UInt64 {
    216         state = state &* 6364136223846793005 &+ 1442695040888963407
    217         return state
    218     }
    219 }