crossmate

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

PlayerRosterTests.swift (11690B)


      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         readAt: Date? = Date().addingTimeInterval(600)
     67     ) {
     68         let ctx = persistence.viewContext
     69         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     70         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
     71         guard let game = try? ctx.fetch(req).first else { return }
     72         let player = PlayerEntity(context: ctx)
     73         player.game = game
     74         player.authorID = authorID
     75         player.name = name
     76         player.ckRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
     77         player.updatedAt = updatedAt
     78         player.readAt = readAt
     79         if let selection {
     80             player.selRow = NSNumber(value: selection.row)
     81             player.selCol = NSNumber(value: selection.col)
     82             player.selDir = NSNumber(value: selection.direction.rawValue)
     83         }
     84         try? ctx.save()
     85     }
     86 
     87     private func makeRoster(
     88         gameID: UUID,
     89         persistence: PersistenceController,
     90         preferences: PlayerPreferences? = nil,
     91         engagementStore: EngagementStore = EngagementStore()
     92     ) -> PlayerRoster {
     93         let prefs = preferences ?? PlayerPreferences(
     94             local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
     95         )
     96         let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
     97         return PlayerRoster(
     98             gameID: gameID,
     99             authorIdentity: AuthorIdentity(testing: "_Local"),
    100             preferences: prefs,
    101             persistence: persistence,
    102             container: container,
    103             engagementStore: engagementStore
    104         )
    105     }
    106 
    107     // MARK: - Tests
    108 
    109     @Test("Three remote participants get distinct colours, none matching local")
    110     func threeParticipantsGetDistinctColors() async throws {
    111         let (persistence, gameID) = try makePersistenceWithGame()
    112         addMoves(authorIDs: ["_B", "_C", "_D"], gameID: gameID, persistence: persistence)
    113 
    114         let prefsDefaults = UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
    115         let prefs = PlayerPreferences(local: prefsDefaults)
    116 
    117         let roster = makeRoster(
    118             gameID: gameID,
    119             persistence: persistence,
    120             preferences: prefs
    121         )
    122         await roster.refresh()
    123 
    124         #expect(roster.entries.count == 4) // 1 local + 3 remote
    125         let allColorIDs = roster.entries.map { $0.color.id }
    126         #expect(Set(allColorIDs).count == 4, "all four participants should have distinct colours")
    127         let remoteColorIDs = roster.entries.filter { !$0.isLocal }.map { $0.color.id }
    128         #expect(!remoteColorIDs.contains(prefs.color.id), "remote colours should not collide with local")
    129     }
    130 
    131     @Test("Friend colour is derived per game (deliberately not stable across games)")
    132     func friendColourIsPerGame() async throws {
    133         let (p1, gameA) = try makePersistenceWithGame()
    134         addMoves(authorIDs: ["_B"], gameID: gameA, persistence: p1)
    135         let rosterA = makeRoster(gameID: gameA, persistence: p1)
    136         await rosterA.refresh()
    137 
    138         let (p2, gameB) = try makePersistenceWithGame()
    139         addMoves(authorIDs: ["_B"], gameID: gameB, persistence: p2)
    140         let rosterB = makeRoster(gameID: gameB, persistence: p2)
    141         await rosterB.refresh()
    142 
    143         // Each game derives the friend's colour from (authorID, gameID),
    144         // de-conflicted against the local user's stable colour. The colour is a
    145         // function of the game, so it is allowed to differ between games rather
    146         // than acting as a fixed per-person badge.
    147         func remoteColour(in roster: PlayerRoster, gameID: UUID) -> String? {
    148             let localColorID = roster.entries.first { $0.isLocal }?.color.id ?? PlayerColor.blue.id
    149             let expected = PlayerColor.assignedCompanions(
    150                 forSortedAuthorIDs: ["_B"],
    151                 inGame: gameID,
    152                 anchor: PlayerColor.color(for: localColorID)
    153             ).first
    154             #expect(roster.entries.first { $0.authorID == "_B" }?.color.id == expected?.id)
    155             return roster.entries.first { $0.authorID == "_B" }?.color.id
    156         }
    157 
    158         #expect(remoteColour(in: rosterA, gameID: gameA) != nil)
    159         #expect(remoteColour(in: rosterB, gameID: gameB) != nil)
    160     }
    161 
    162     @Test("Entry name comes from PlayerEntity when available")
    163     func entryNameFromPlayerEntity() async throws {
    164         let (persistence, gameID) = try makePersistenceWithGame()
    165         addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence)
    166         addPlayerEntity(authorID: "_B", name: "Alice", gameID: gameID, persistence: persistence)
    167 
    168         let roster = makeRoster(gameID: gameID, persistence: persistence)
    169         await roster.refresh()
    170 
    171         let remote = roster.entries.first { !$0.isLocal }
    172         #expect(remote?.name == "Alice")
    173     }
    174 
    175     @Test("Entry name waits for PlayerEntity when no game-specific name has arrived")
    176     func entryNameWaitsForPlayerEntity() async throws {
    177         let (persistence, gameID) = try makePersistenceWithGame()
    178         addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence)
    179         // No PlayerEntity for "_B".
    180 
    181         let roster = makeRoster(gameID: gameID, persistence: persistence)
    182         await roster.refresh()
    183 
    184         let remote = roster.entries.first { !$0.isLocal }
    185         #expect(remote?.name == "Waiting for player...")
    186     }
    187 
    188     @Test("Current user placeholder is not shown as a remote player")
    189     func currentUserPlaceholderIsHidden() async throws {
    190         let (persistence, gameID) = try makePersistenceWithGame()
    191         addMoves(
    192             authorIDs: [CKCurrentUserDefaultName, "_B"],
    193             gameID: gameID,
    194             persistence: persistence
    195         )
    196         addPlayerEntity(
    197             authorID: CKCurrentUserDefaultName,
    198             name: "Ghost",
    199             gameID: gameID,
    200             persistence: persistence
    201         )
    202 
    203         let roster = makeRoster(gameID: gameID, persistence: persistence)
    204         await roster.refresh()
    205 
    206         #expect(!roster.entries.contains { $0.authorID == CKCurrentUserDefaultName })
    207         #expect(roster.entries.map(\.authorID).contains("_B"))
    208     }
    209 
    210     @Test("Engagement selection overrides persisted cursor track")
    211     func engagementSelectionOverridesPersistedCursorTrack() async throws {
    212         let (persistence, gameID) = try makePersistenceWithGame()
    213         let now = Date()
    214         addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence)
    215         addPlayerEntity(
    216             authorID: "_B",
    217             name: "Alice",
    218             gameID: gameID,
    219             persistence: persistence,
    220             selection: PlayerSelection(row: 1, col: 2, direction: .across),
    221             updatedAt: now.addingTimeInterval(-5)
    222         )
    223         let engagementStore = EngagementStore()
    224         engagementStore.set(EngagementSelectionUpdate(
    225             gameID: gameID,
    226             authorID: "_B",
    227             deviceID: "remote-device",
    228             selection: PlayerSelection(row: 3, col: 4, direction: .down),
    229             updatedAt: now
    230         ))
    231 
    232         let roster = makeRoster(
    233             gameID: gameID,
    234             persistence: persistence,
    235             engagementStore: engagementStore
    236         )
    237         await roster.refresh()
    238 
    239         let selection = roster.remoteSelections["_B"]
    240         #expect(selection?.row == 3)
    241         #expect(selection?.col == 4)
    242         #expect(selection?.direction == .down)
    243 
    244         engagementStore.clear(gameID: gameID)
    245 
    246         let fallback = roster.remoteSelections["_B"]
    247         #expect(fallback?.row == 1)
    248         #expect(fallback?.col == 2)
    249         #expect(fallback?.direction == .across)
    250     }
    251 
    252     @Test("Cursor is hidden when the peer's lease has expired")
    253     func cursorHiddenWhenLeaseExpired() async throws {
    254         let (persistence, gameID) = try makePersistenceWithGame()
    255         addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence)
    256         addPlayerEntity(
    257             authorID: "_B",
    258             name: "Alice",
    259             gameID: gameID,
    260             persistence: persistence,
    261             selection: PlayerSelection(row: 1, col: 2, direction: .across),
    262             updatedAt: Date(),
    263             // Lapsed well past the presence grace so the peer reads as gone.
    264             readAt: Date().addingTimeInterval(-(PeerPresence.presenceGrace + 60))
    265         )
    266 
    267         let roster = makeRoster(gameID: gameID, persistence: persistence)
    268         await roster.refresh()
    269 
    270         #expect(roster.remoteSelections["_B"] == nil, "an expired lease should hide the peer cursor")
    271     }
    272 
    273     @Test("Cursor stays visible for a present peer with a stale selection")
    274     func cursorVisibleWhilePresentDespiteStaleSelection() async throws {
    275         let (persistence, gameID) = try makePersistenceWithGame()
    276         addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence)
    277         addPlayerEntity(
    278             authorID: "_B",
    279             name: "Alice",
    280             gameID: gameID,
    281             persistence: persistence,
    282             selection: PlayerSelection(row: 1, col: 2, direction: .across),
    283             updatedAt: Date().addingTimeInterval(-300),
    284             readAt: Date().addingTimeInterval(600)
    285         )
    286 
    287         let roster = makeRoster(gameID: gameID, persistence: persistence)
    288         await roster.refresh()
    289 
    290         let selection = roster.remoteSelections["_B"]
    291         #expect(selection?.row == 1, "a present peer keeps their cursor even when the selection is old")
    292         #expect(selection?.col == 2)
    293     }
    294 }