crossmate

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

PlayerRosterTests.swift (15799B)


      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         selection: PlayerSelection? = nil,
     65         updatedAt: Date = Date(),
     66         presenceUntil: Date? = Date().addingTimeInterval(600),
     67         timeLog: TimeLog? = nil
     68     ) {
     69         let ctx = persistence.viewContext
     70         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     71         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
     72         guard let game = try? ctx.fetch(req).first else { return }
     73         let player = PlayerEntity(context: ctx)
     74         player.game = game
     75         player.authorID = authorID
     76         player.name = name
     77         player.ckRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
     78         player.updatedAt = updatedAt
     79         player.presenceUntil = presenceUntil
     80         if let timeLog {
     81             player.timeLog = TimeLog.encode(timeLog)
     82         }
     83         if let selection {
     84             player.selRow = NSNumber(value: selection.row)
     85             player.selCol = NSNumber(value: selection.col)
     86             player.selDir = NSNumber(value: selection.direction.rawValue)
     87         }
     88         try? ctx.save()
     89     }
     90 
     91     private func updateGame(
     92         gameID: UUID,
     93         persistence: PersistenceController,
     94         completedAt: Date? = nil,
     95         finalSolveSeconds: Int64? = nil
     96     ) {
     97         let ctx = persistence.viewContext
     98         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     99         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    100         guard let game = try? ctx.fetch(req).first else { return }
    101         game.completedAt = completedAt
    102         if let finalSolveSeconds {
    103             game.finalSolveSeconds = NSNumber(value: finalSolveSeconds)
    104         }
    105         try? ctx.save()
    106     }
    107 
    108     private func makeRoster(
    109         gameID: UUID,
    110         persistence: PersistenceController,
    111         preferences: PlayerPreferences? = nil,
    112         engagementStore: EngagementStore = EngagementStore()
    113     ) -> PlayerRoster {
    114         let prefs = preferences ?? PlayerPreferences(
    115             local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
    116         )
    117         let container = CloudContainer.container
    118         return PlayerRoster(
    119             gameID: gameID,
    120             authorIdentity: AuthorIdentity(testing: "_Local"),
    121             preferences: prefs,
    122             persistence: persistence,
    123             container: container,
    124             engagementStore: engagementStore
    125         )
    126     }
    127 
    128     // MARK: - Tests
    129 
    130     @Test("Three remote participants get distinct colours, none matching local")
    131     func threeParticipantsGetDistinctColors() async throws {
    132         let (persistence, gameID) = try makePersistenceWithGame()
    133         addMoves(authorIDs: ["_B", "_C", "_D"], gameID: gameID, persistence: persistence)
    134 
    135         let prefsDefaults = UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
    136         let prefs = PlayerPreferences(local: prefsDefaults)
    137 
    138         let roster = makeRoster(
    139             gameID: gameID,
    140             persistence: persistence,
    141             preferences: prefs
    142         )
    143         await roster.refresh()
    144 
    145         #expect(roster.entries.count == 4) // 1 local + 3 remote
    146         let allColorIDs = roster.entries.map { $0.color.id }
    147         #expect(Set(allColorIDs).count == 4, "all four participants should have distinct colours")
    148         let remoteColorIDs = roster.entries.filter { !$0.isLocal }.map { $0.color.id }
    149         #expect(!remoteColorIDs.contains(prefs.color.id), "remote colours should not collide with local")
    150     }
    151 
    152     @Test("Friend colour is derived per game (deliberately not stable across games)")
    153     func friendColourIsPerGame() async throws {
    154         let (p1, gameA) = try makePersistenceWithGame()
    155         addMoves(authorIDs: ["_B"], gameID: gameA, persistence: p1)
    156         let rosterA = makeRoster(gameID: gameA, persistence: p1)
    157         await rosterA.refresh()
    158 
    159         let (p2, gameB) = try makePersistenceWithGame()
    160         addMoves(authorIDs: ["_B"], gameID: gameB, persistence: p2)
    161         let rosterB = makeRoster(gameID: gameB, persistence: p2)
    162         await rosterB.refresh()
    163 
    164         // Each game derives the friend's colour from (authorID, gameID),
    165         // de-conflicted against the local user's stable colour. The colour is a
    166         // function of the game, so it is allowed to differ between games rather
    167         // than acting as a fixed per-person badge.
    168         func remoteColour(in roster: PlayerRoster, gameID: UUID) -> String? {
    169             let localColorID = roster.entries.first { $0.isLocal }?.color.id ?? PlayerColor.blue.id
    170             let expected = PlayerColor.assignedCompanions(
    171                 forSortedAuthorIDs: ["_B"],
    172                 inGame: gameID,
    173                 anchor: PlayerColor.color(for: localColorID)
    174             ).first
    175             #expect(roster.entries.first { $0.authorID == "_B" }?.color.id == expected?.id)
    176             return roster.entries.first { $0.authorID == "_B" }?.color.id
    177         }
    178 
    179         #expect(remoteColour(in: rosterA, gameID: gameA) != nil)
    180         #expect(remoteColour(in: rosterB, gameID: gameB) != nil)
    181     }
    182 
    183     @Test("Entry name comes from PlayerEntity when available")
    184     func entryNameFromPlayerEntity() async throws {
    185         let (persistence, gameID) = try makePersistenceWithGame()
    186         addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence)
    187         addPlayerEntity(authorID: "_B", name: "Alice", gameID: gameID, persistence: persistence)
    188 
    189         let roster = makeRoster(gameID: gameID, persistence: persistence)
    190         await roster.refresh()
    191 
    192         let remote = roster.entries.first { !$0.isLocal }
    193         #expect(remote?.name == "Alice")
    194     }
    195 
    196     @Test("Preload restores durable names before presentation")
    197     func preloadRestoresDurableNames() async throws {
    198         let (persistence, gameID) = try makePersistenceWithGame()
    199         addPlayerEntity(authorID: "_B", name: "Alice", gameID: gameID, persistence: persistence)
    200 
    201         let roster = makeRoster(gameID: gameID, persistence: persistence)
    202         #expect(roster.entries.isEmpty)
    203 
    204         await roster.preload()
    205 
    206         #expect(roster.entries.map(\.authorID) == ["_Local", "_B"])
    207         #expect(roster.entries.first { $0.authorID == "_B" }?.name == "Alice")
    208     }
    209 
    210     @Test("Entry name waits for PlayerEntity when no game-specific name has arrived")
    211     func entryNameWaitsForPlayerEntity() async throws {
    212         let (persistence, gameID) = try makePersistenceWithGame()
    213         addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence)
    214         // No PlayerEntity for "_B".
    215 
    216         let roster = makeRoster(gameID: gameID, persistence: persistence)
    217         await roster.refresh()
    218 
    219         let remote = roster.entries.first { !$0.isLocal }
    220         #expect(remote?.name == "Waiting for player…")
    221     }
    222 
    223     @Test("Player row without name still identifies a remote participant")
    224     func unnamedPlayerStillIdentifiesParticipant() async throws {
    225         let (persistence, gameID) = try makePersistenceWithGame()
    226         addPlayerEntity(authorID: "_B", name: "", gameID: gameID, persistence: persistence)
    227 
    228         let roster = makeRoster(gameID: gameID, persistence: persistence)
    229         await roster.preload()
    230 
    231         let remote = roster.entries.first { !$0.isLocal }
    232         #expect(remote?.authorID == "_B")
    233         #expect(remote?.name == "Waiting for player…")
    234     }
    235 
    236     @Test("Current user placeholder is not shown as a remote player")
    237     func currentUserPlaceholderIsHidden() async throws {
    238         let (persistence, gameID) = try makePersistenceWithGame()
    239         addMoves(
    240             authorIDs: [CKCurrentUserDefaultName, "_B"],
    241             gameID: gameID,
    242             persistence: persistence
    243         )
    244         addPlayerEntity(
    245             authorID: CKCurrentUserDefaultName,
    246             name: "Ghost",
    247             gameID: gameID,
    248             persistence: persistence
    249         )
    250 
    251         let roster = makeRoster(gameID: gameID, persistence: persistence)
    252         await roster.refresh()
    253 
    254         #expect(!roster.entries.contains { $0.authorID == CKCurrentUserDefaultName })
    255         #expect(roster.entries.map(\.authorID).contains("_B"))
    256     }
    257 
    258     @Test("Engagement selection overrides persisted cursor track")
    259     func engagementSelectionOverridesPersistedCursorTrack() async throws {
    260         let (persistence, gameID) = try makePersistenceWithGame()
    261         let now = Date()
    262         addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence)
    263         addPlayerEntity(
    264             authorID: "_B",
    265             name: "Alice",
    266             gameID: gameID,
    267             persistence: persistence,
    268             selection: PlayerSelection(row: 1, col: 2, direction: .across),
    269             updatedAt: now.addingTimeInterval(-5)
    270         )
    271         let engagementStore = EngagementStore()
    272         engagementStore.set(EngagementSelectionUpdate(
    273             gameID: gameID,
    274             authorID: "_B",
    275             deviceID: "remote-device",
    276             selection: PlayerSelection(row: 3, col: 4, direction: .down),
    277             updatedAt: now
    278         ))
    279 
    280         let roster = makeRoster(
    281             gameID: gameID,
    282             persistence: persistence,
    283             engagementStore: engagementStore
    284         )
    285         await roster.refresh()
    286 
    287         let selection = roster.remoteSelections["_B"]
    288         #expect(selection?.row == 3)
    289         #expect(selection?.col == 4)
    290         #expect(selection?.direction == .down)
    291 
    292         engagementStore.clear(gameID: gameID)
    293 
    294         let fallback = roster.remoteSelections["_B"]
    295         #expect(fallback?.row == 1)
    296         #expect(fallback?.col == 2)
    297         #expect(fallback?.direction == .across)
    298     }
    299 
    300     @Test("Cursor is hidden when the peer's lease has expired")
    301     func cursorHiddenWhenLeaseExpired() async throws {
    302         let (persistence, gameID) = try makePersistenceWithGame()
    303         addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence)
    304         addPlayerEntity(
    305             authorID: "_B",
    306             name: "Alice",
    307             gameID: gameID,
    308             persistence: persistence,
    309             selection: PlayerSelection(row: 1, col: 2, direction: .across),
    310             updatedAt: Date(),
    311             // Lapsed well past the presence grace so the peer reads as gone.
    312             presenceUntil: Date().addingTimeInterval(-(PeerPresence.presenceGrace + 60))
    313         )
    314 
    315         let roster = makeRoster(gameID: gameID, persistence: persistence)
    316         await roster.refresh()
    317 
    318         #expect(roster.remoteSelections["_B"] == nil, "an expired lease should hide the peer cursor")
    319     }
    320 
    321     @Test("Cursor stays visible for a present peer with a stale selection")
    322     func cursorVisibleWhilePresentDespiteStaleSelection() async throws {
    323         let (persistence, gameID) = try makePersistenceWithGame()
    324         addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence)
    325         addPlayerEntity(
    326             authorID: "_B",
    327             name: "Alice",
    328             gameID: gameID,
    329             persistence: persistence,
    330             selection: PlayerSelection(row: 1, col: 2, direction: .across),
    331             updatedAt: Date().addingTimeInterval(-300),
    332             presenceUntil: Date().addingTimeInterval(600)
    333         )
    334 
    335         let roster = makeRoster(gameID: gameID, persistence: persistence)
    336         await roster.refresh()
    337 
    338         let selection = roster.remoteSelections["_B"]
    339         #expect(selection?.row == 1, "a present peer keeps their cursor even when the selection is old")
    340         #expect(selection?.col == 2)
    341     }
    342 
    343     @Test("Solve time is read from the refreshed roster snapshot")
    344     func solveTimeUsesRefreshedSnapshot() async throws {
    345         let (persistence, gameID) = try makePersistenceWithGame()
    346         let start = Date(timeIntervalSinceReferenceDate: 1_000)
    347         var log = TimeLog()
    348         log.open(deviceID: RecordSerializer.localDeviceID, at: start)
    349         addPlayerEntity(
    350             authorID: "_Local",
    351             name: "Me",
    352             gameID: gameID,
    353             persistence: persistence,
    354             timeLog: log
    355         )
    356 
    357         let roster = makeRoster(gameID: gameID, persistence: persistence)
    358         #expect(roster.solveTime(asOf: start.addingTimeInterval(60)) == 0)
    359 
    360         await roster.refresh()
    361 
    362         #expect(roster.solveTime(asOf: start.addingTimeInterval(60)) == 60)
    363         #expect(roster.solveTime(asOf: start.addingTimeInterval(90)) == 90)
    364     }
    365 
    366     @Test("Solve time freezes at completion")
    367     func solveTimeFreezesAtCompletion() async throws {
    368         let (persistence, gameID) = try makePersistenceWithGame()
    369         let start = Date(timeIntervalSinceReferenceDate: 2_000)
    370         let completedAt = start.addingTimeInterval(75)
    371         var log = TimeLog()
    372         log.open(deviceID: RecordSerializer.localDeviceID, at: start)
    373         addPlayerEntity(
    374             authorID: "_Local",
    375             name: "Me",
    376             gameID: gameID,
    377             persistence: persistence,
    378             timeLog: log
    379         )
    380         updateGame(gameID: gameID, persistence: persistence, completedAt: completedAt)
    381 
    382         let roster = makeRoster(gameID: gameID, persistence: persistence)
    383         await roster.refresh()
    384 
    385         #expect(roster.solveTime(asOf: start.addingTimeInterval(300)) == 75)
    386     }
    387 
    388     @Test("Materialized archive solve time uses final seconds")
    389     func materializedArchiveSolveTimeUsesFinalSeconds() async throws {
    390         let (persistence, gameID) = try makePersistenceWithGame()
    391         updateGame(gameID: gameID, persistence: persistence, finalSolveSeconds: 743)
    392 
    393         let roster = makeRoster(gameID: gameID, persistence: persistence)
    394         await roster.refresh()
    395 
    396         #expect(roster.solveTime(asOf: Date(timeIntervalSinceReferenceDate: 3_000)) == 743)
    397     }
    398 }