crossmate

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

PlayerRosterTests.swift (7541B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 @Suite("PlayerRoster")
      9 @MainActor
     10 struct PlayerRosterTests {
     11 
     12     // MARK: - Helpers
     13 
     14     private func makePersistenceWithGame() throws -> (PersistenceController, UUID) {
     15         let p = makeTestPersistence()
     16         let ctx = p.viewContext
     17         let gameID = UUID()
     18         let entity = GameEntity(context: ctx)
     19         entity.id = gameID
     20         entity.title = "Test"
     21         entity.puzzleSource = ""
     22         entity.createdAt = Date()
     23         entity.updatedAt = Date()
     24         entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID)
     25         // No ckZoneName — prevents the roster from attempting a CloudKit share fetch.
     26         try ctx.save()
     27         return (p, gameID)
     28     }
     29 
     30     private func addMoves(
     31         authorIDs: [String],
     32         gameID: UUID,
     33         persistence: PersistenceController
     34     ) {
     35         let ctx = persistence.viewContext
     36         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     37         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
     38         guard let game = try? ctx.fetch(req).first else { return }
     39         for (i, authorID) in authorIDs.enumerated() {
     40             // Each MovesEntity row is per-(game, author, device); for the
     41             // roster's purposes the per-cell content is irrelevant — only the
     42             // `authorID` matters. Synthesise a unique deviceID per row.
     43             let deviceID = "test-device-\(i)"
     44             let entity = MovesEntity(context: ctx)
     45             entity.game = game
     46             entity.authorID = authorID
     47             entity.deviceID = deviceID
     48             entity.cells = Data()
     49             entity.updatedAt = Date()
     50             entity.ckRecordName = RecordSerializer.recordName(
     51                 forMovesInGame: gameID,
     52                 authorID: authorID,
     53                 deviceID: deviceID
     54             )
     55         }
     56         try? ctx.save()
     57     }
     58 
     59     private func addPlayerEntity(
     60         authorID: String,
     61         name: String,
     62         gameID: UUID,
     63         persistence: PersistenceController
     64     ) {
     65         let ctx = persistence.viewContext
     66         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     67         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
     68         guard let game = try? ctx.fetch(req).first else { return }
     69         let player = PlayerEntity(context: ctx)
     70         player.game = game
     71         player.authorID = authorID
     72         player.name = name
     73         player.ckRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
     74         player.updatedAt = Date()
     75         try? ctx.save()
     76     }
     77 
     78     private func makeRoster(
     79         gameID: UUID,
     80         persistence: PersistenceController,
     81         colorStore: GamePlayerColorStore? = nil,
     82         preferences: PlayerPreferences? = nil
     83     ) -> PlayerRoster {
     84         let store = colorStore ?? GamePlayerColorStore(
     85             defaults: UserDefaults(suiteName: "test-cs-\(UUID().uuidString)")!
     86         )
     87         let prefs = preferences ?? PlayerPreferences(
     88             local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
     89         )
     90         let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
     91         return PlayerRoster(
     92             gameID: gameID,
     93             colorStore: store,
     94             authorIdentity: AuthorIdentity(testing: "_Local"),
     95             preferences: prefs,
     96             persistence: persistence,
     97             container: container
     98         )
     99     }
    100 
    101     // MARK: - Tests
    102 
    103     @Test("Three remote participants are assigned distinct colors, none matching local")
    104     func threeParticipantsGetDistinctColors() async throws {
    105         let (persistence, gameID) = try makePersistenceWithGame()
    106         addMoves(authorIDs: ["_B", "_C", "_D"], gameID: gameID, persistence: persistence)
    107 
    108         let colorStore = GamePlayerColorStore(
    109             defaults: UserDefaults(suiteName: "test-cs-\(UUID().uuidString)")!
    110         )
    111         let prefsDefaults = UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
    112         let prefs = PlayerPreferences(local: prefsDefaults)
    113 
    114         let roster = makeRoster(
    115             gameID: gameID,
    116             persistence: persistence,
    117             colorStore: colorStore,
    118             preferences: prefs
    119         )
    120         await roster.refresh()
    121 
    122         #expect(roster.entries.count == 4) // 1 local + 3 remote
    123         let allColorIDs = roster.entries.map { $0.color.id }
    124         #expect(Set(allColorIDs).count == 4, "all four participants should have distinct colors")
    125         let remoteColorIDs = roster.entries.filter { !$0.isLocal }.map { $0.color.id }
    126         #expect(!remoteColorIDs.contains(prefs.color.id), "remote colors should not collide with local")
    127     }
    128 
    129     @Test("Stale color entries are GC'd after refresh")
    130     func staleColorEntriesAreGCd() async throws {
    131         let (persistence, gameID) = try makePersistenceWithGame()
    132         addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence)
    133 
    134         let colorStore = GamePlayerColorStore(
    135             defaults: UserDefaults(suiteName: "test-cs-\(UUID().uuidString)")!
    136         )
    137         colorStore.setColor(.red, forGame: gameID, authorID: "_B")
    138         colorStore.setColor(.green, forGame: gameID, authorID: "_Stale")
    139 
    140         let roster = makeRoster(gameID: gameID, persistence: persistence, colorStore: colorStore)
    141         await roster.refresh()
    142 
    143         #expect(colorStore.color(forGame: gameID, authorID: "_B") != nil, "_B should be kept")
    144         #expect(colorStore.color(forGame: gameID, authorID: "_Stale") == nil, "_Stale should be GC'd")
    145     }
    146 
    147     @Test("Entry name comes from PlayerEntity when available")
    148     func entryNameFromPlayerEntity() async throws {
    149         let (persistence, gameID) = try makePersistenceWithGame()
    150         addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence)
    151         addPlayerEntity(authorID: "_B", name: "Alice", gameID: gameID, persistence: persistence)
    152 
    153         let roster = makeRoster(gameID: gameID, persistence: persistence)
    154         await roster.refresh()
    155 
    156         let remote = roster.entries.first { !$0.isLocal }
    157         #expect(remote?.name == "Alice")
    158     }
    159 
    160     @Test("Entry name waits for PlayerEntity when no game-specific name has arrived")
    161     func entryNameWaitsForPlayerEntity() async throws {
    162         let (persistence, gameID) = try makePersistenceWithGame()
    163         addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence)
    164         // No PlayerEntity for "_B".
    165 
    166         let roster = makeRoster(gameID: gameID, persistence: persistence)
    167         await roster.refresh()
    168 
    169         let remote = roster.entries.first { !$0.isLocal }
    170         #expect(remote?.name == "Waiting for player...")
    171     }
    172 
    173     @Test("Current user placeholder is not shown as a remote player")
    174     func currentUserPlaceholderIsHidden() async throws {
    175         let (persistence, gameID) = try makePersistenceWithGame()
    176         addMoves(
    177             authorIDs: [CKCurrentUserDefaultName, "_B"],
    178             gameID: gameID,
    179             persistence: persistence
    180         )
    181         addPlayerEntity(
    182             authorID: CKCurrentUserDefaultName,
    183             name: "Ghost",
    184             gameID: gameID,
    185             persistence: persistence
    186         )
    187 
    188         let roster = makeRoster(gameID: gameID, persistence: persistence)
    189         await roster.refresh()
    190 
    191         #expect(!roster.entries.contains { $0.authorID == CKCurrentUserDefaultName })
    192         #expect(roster.entries.map(\.authorID).contains("_B"))
    193     }
    194 }