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 }