crossmate

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

AppServicesAnnouncementTests.swift (7599B)


      1 import CoreData
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 @Suite("SessionCoordinator.formatSummaryBanner")
      8 struct AppServicesAnnouncementTests {
      9 
     10     private func summary(
     11         author: String,
     12         playerName: String,
     13         added: Int = 0,
     14         cleared: Int = 0
     15     ) -> SessionMonitor.SessionSummary {
     16         SessionMonitor.SessionSummary(
     17             authorID: author,
     18             playerName: playerName,
     19             added: added,
     20             cleared: cleared
     21         )
     22     }
     23 
     24     @Test("Single-author summary omits the puzzle suffix (the user is already in the puzzle)")
     25     func singleAuthor() {
     26         let body = SessionCoordinator.formatSummaryBanner([
     27             summary(author: "a", playerName: "Alice", added: 4),
     28         ])
     29         #expect(body == "Alice added 4 letters.")
     30     }
     31 
     32     @Test("Multi-author summary joins author phrases with '; '")
     33     func multipleAuthors() {
     34         let body = SessionCoordinator.formatSummaryBanner([
     35             summary(author: "a", playerName: "Alice", added: 4),
     36             summary(author: "b", playerName: "Bob",   added: 1, cleared: 2),
     37         ])
     38         #expect(body == "Alice added 4 letters; Bob added 1 letter and cleared 2 letters.")
     39     }
     40 
     41     @Test("Missing player name falls back to 'A player'")
     42     func fallbacks() {
     43         let body = SessionCoordinator.formatSummaryBanner([
     44             summary(author: "a", playerName: "", added: 3),
     45         ])
     46         #expect(body == "A player added 3 letters.")
     47     }
     48 
     49     @Test("A summary with only clears reads as a clear phrase")
     50     func clearsOnly() {
     51         let body = SessionCoordinator.formatSummaryBanner([
     52             summary(author: "a", playerName: "Alice", cleared: 2),
     53         ])
     54         #expect(body == "Alice cleared 2 letters.")
     55     }
     56 }
     57 
     58 @Suite("AppServices peer presence", .serialized)
     59 @MainActor
     60 struct AppServicesPeerPresenceTests {
     61 
     62     @Test("valid read lease counts as a present peer, even without a cursor")
     63     func validReadLeaseCountsAsPresentPeer() async throws {
     64         let (persistence, gameID) = try makePersistence(authorID: "alice")
     65         try addPlayer(
     66             gameID: gameID,
     67             authorID: "bob",
     68             selection: nil,
     69             readAt: Date().addingTimeInterval(10 * 60),
     70             updatedAt: Date(),
     71             persistence: persistence
     72         )
     73 
     74         let hasPeer = await AppServices.hasPresentPeer(
     75             persistence: persistence,
     76             gameID: gameID,
     77             localAuthorID: "alice"
     78         )
     79         let peers = await AppServices.presentPeers(
     80             persistence: persistence,
     81             gameIDs: [gameID],
     82             localAuthorID: "alice"
     83         )
     84 
     85         #expect(hasPeer)
     86         #expect(peers[gameID] == ["bob"])
     87     }
     88 
     89     @Test("expired read lease does not count as a present peer")
     90     func expiredReadLeaseDoesNotCountAsPresentPeer() async throws {
     91         let (persistence, gameID) = try makePersistence(authorID: "alice")
     92         try addPlayer(
     93             gameID: gameID,
     94             authorID: "bob",
     95             selection: PlayerSelection(row: 1, col: 2, direction: .down),
     96             // Lapsed past the presence grace so it no longer counts as present.
     97             readAt: Date().addingTimeInterval(-(PeerPresence.presenceGrace + 60)),
     98             updatedAt: Date(),
     99             persistence: persistence
    100         )
    101 
    102         let hasPeer = await AppServices.hasPresentPeer(
    103             persistence: persistence,
    104             gameID: gameID,
    105             localAuthorID: "alice"
    106         )
    107         let peers = await AppServices.presentPeers(
    108             persistence: persistence,
    109             gameIDs: [gameID],
    110             localAuthorID: "alice"
    111         )
    112 
    113         #expect(!hasPeer)
    114         #expect(peers[gameID] == nil)
    115     }
    116 
    117     @Test("a lease lapsed within the grace still counts as a present peer")
    118     func recentlyLapsedLeaseStillCountsAsPresentPeer() async throws {
    119         let (persistence, gameID) = try makePersistence(authorID: "alice")
    120         try addPlayer(
    121             gameID: gameID,
    122             authorID: "bob",
    123             selection: PlayerSelection(row: 1, col: 2, direction: .down),
    124             // A peer who bounced to another app seconds ago: lease collapsed to
    125             // a recent current-time value, still inside the grace.
    126             readAt: Date().addingTimeInterval(-(PeerPresence.presenceGrace - 10)),
    127             updatedAt: Date(),
    128             persistence: persistence
    129         )
    130 
    131         let hasPeer = await AppServices.hasPresentPeer(
    132             persistence: persistence,
    133             gameID: gameID,
    134             localAuthorID: "alice"
    135         )
    136         let peers = await AppServices.presentPeers(
    137             persistence: persistence,
    138             gameIDs: [gameID],
    139             localAuthorID: "alice"
    140         )
    141 
    142         #expect(hasPeer)
    143         #expect(peers[gameID] == ["bob"])
    144     }
    145 
    146     @Test("a fresh cursor without a read lease does not count as a present peer")
    147     func freshCursorWithoutLeaseDoesNotCountAsPresentPeer() async throws {
    148         let (persistence, gameID) = try makePersistence(authorID: "alice")
    149         try addPlayer(
    150             gameID: gameID,
    151             authorID: "bob",
    152             selection: PlayerSelection(row: 1, col: 2, direction: .down),
    153             readAt: nil,
    154             updatedAt: Date(),
    155             persistence: persistence
    156         )
    157 
    158         let hasPeer = await AppServices.hasPresentPeer(
    159             persistence: persistence,
    160             gameID: gameID,
    161             localAuthorID: "alice"
    162         )
    163         let peers = await AppServices.presentPeers(
    164             persistence: persistence,
    165             gameIDs: [gameID],
    166             localAuthorID: "alice"
    167         )
    168 
    169         #expect(!hasPeer)
    170         #expect(peers[gameID] == nil)
    171     }
    172 
    173     private func makePersistence(authorID: String) throws -> (PersistenceController, UUID) {
    174         let persistence = makeTestPersistence()
    175         let context = persistence.viewContext
    176         let gameID = UUID()
    177         let game = GameEntity(context: context)
    178         game.id = gameID
    179         game.title = "Test"
    180         game.puzzleSource = ""
    181         game.createdAt = Date()
    182         game.updatedAt = Date()
    183         game.ckRecordName = "game-\(gameID.uuidString)"
    184         try addPlayer(
    185             gameID: gameID,
    186             authorID: authorID,
    187             selection: PlayerSelection(row: 0, col: 0, direction: .across),
    188             readAt: nil,
    189             updatedAt: Date(),
    190             persistence: persistence
    191         )
    192         return (persistence, gameID)
    193     }
    194 
    195     private func addPlayer(
    196         gameID: UUID,
    197         authorID: String,
    198         selection: PlayerSelection?,
    199         readAt: Date?,
    200         updatedAt: Date = Date(),
    201         persistence: PersistenceController
    202     ) throws {
    203         let context = persistence.viewContext
    204         let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    205         gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    206         gameReq.fetchLimit = 1
    207         let game = try #require(try context.fetch(gameReq).first)
    208         let player = PlayerEntity(context: context)
    209         player.game = game
    210         player.authorID = authorID
    211         player.name = authorID
    212         player.updatedAt = updatedAt
    213         player.ckRecordName = "player-\(gameID.uuidString)-\(authorID)"
    214         player.readAt = readAt
    215         if let selection {
    216             player.selRow = NSNumber(value: selection.row)
    217             player.selCol = NSNumber(value: selection.col)
    218             player.selDir = NSNumber(value: selection.direction.rawValue)
    219         }
    220         try context.save()
    221     }
    222 }