PuzzleNotificationTextTests.swift (11436B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 @Suite("Puzzle notification text") 7 struct PuzzleNotificationTextTests { 8 @Test("Puzzle title includes subtitle separated by an en dash") 9 func titleIncludesSubtitle() { 10 let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2001, month: 1, day: 1)) 11 12 let title = PuzzleNotificationText.title("Saturday Puzzle", publisher: nil, date: date) 13 14 #expect(title.contains("Saturday Puzzle – ")) 15 #expect(title.contains("2001")) 16 } 17 18 @Test("Legacy join ping is system-only") 19 func legacyJoinPingIsSystemOnly() { 20 let ping = Ping( 21 recordName: "ping-test-1", 22 gameID: UUID(), 23 authorID: "alice", 24 deviceID: "device-a", 25 playerName: "Alice", 26 puzzleTitle: "Saturday Puzzle – 1 January 2001", 27 kind: .join, 28 payload: nil, 29 addressee: nil 30 ) 31 32 #expect(InviteCoordinator.bodyText(for: ping) == "system-only ping should not be presented") 33 } 34 35 @Test("nudgeBody names the nudger and puzzle") 36 func nudgeBodyNamesNudger() { 37 #expect(PuzzleNotificationText.nudgeBody(playerName: "Alice", puzzleTitle: "Saturday Puzzle") 38 == "Alice nudged you to play the puzzle 'Saturday Puzzle'") 39 // Empty name and title fall back to neutral wording. 40 #expect(PuzzleNotificationText.nudgeBody(playerName: "", puzzleTitle: "") 41 == "A player nudged you to play the puzzle") 42 } 43 44 @Test("joinBody names the joiner and puzzle") 45 func joinBodyNamesJoiner() { 46 #expect(PuzzleNotificationText.joinBody(playerName: "Alice", puzzleTitle: "Saturday Puzzle") 47 == "Alice joined the puzzle 'Saturday Puzzle'") 48 // Empty name and title fall back to neutral wording. 49 #expect(PuzzleNotificationText.joinBody(playerName: "", puzzleTitle: "") 50 == "A player joined the puzzle") 51 } 52 53 @Test("completionBody distinguishes a solve from a resignation") 54 func completionBodySolveVsResign() { 55 #expect(PuzzleNotificationText.completionBody( 56 playerName: "Alice", puzzleTitle: "X", resigned: false 57 ) == "Alice solved the puzzle 'X'") 58 #expect(PuzzleNotificationText.completionBody( 59 playerName: "Alice", puzzleTitle: "X", resigned: true 60 ) == "Alice resigned the puzzle 'X'.") 61 } 62 63 @Test("pauseBody combines fills and clears counts when both are non-zero") 64 func pauseBodyFillsAndClears() { 65 let body = PuzzleNotificationText.pauseBody( 66 playerName: "Alice", 67 puzzleTitle: "Saturday Puzzle", 68 fills: 3, 69 clears: 2, 70 checks: 0, 71 reveals: 0 72 ) 73 74 #expect(body == "Alice filled 3 letters and cleared 2 letters in the puzzle 'Saturday Puzzle'") 75 } 76 77 @Test("pauseBody pluralises only multi-letter counts") 78 func pauseBodyPluralisation() { 79 let filled = PuzzleNotificationText.pauseBody( 80 playerName: "Alice", 81 puzzleTitle: "X", 82 fills: 1, 83 clears: 0, 84 checks: 0, 85 reveals: 0 86 ) 87 let cleared = PuzzleNotificationText.pauseBody( 88 playerName: "Alice", 89 puzzleTitle: "X", 90 fills: 0, 91 clears: 1, 92 checks: 0, 93 reveals: 0 94 ) 95 96 #expect(filled == "Alice filled 1 letter in the puzzle 'X'") 97 #expect(cleared == "Alice cleared 1 letter in the puzzle 'X'") 98 } 99 100 @Test("pauseBody reports check and reveal gestures, folded into one clause") 101 func pauseBodyGestures() { 102 let checkOnly = PuzzleNotificationText.pauseBody( 103 playerName: "Alice", 104 puzzleTitle: "X", 105 fills: 0, 106 clears: 0, 107 checks: 1, 108 reveals: 0 109 ) 110 let both = PuzzleNotificationText.pauseBody( 111 playerName: "Alice", 112 puzzleTitle: "X", 113 fills: 0, 114 clears: 0, 115 checks: 2, 116 reveals: 1 117 ) 118 119 #expect(checkOnly == "Alice ran 1 check in the puzzle 'X'") 120 #expect(both == "Alice ran 2 checks and 1 reveal in the puzzle 'X'") 121 } 122 123 @Test("pauseBody joins all four clauses with an Oxford comma") 124 func pauseBodyAllClauses() { 125 let body = PuzzleNotificationText.pauseBody( 126 playerName: "Alice", 127 puzzleTitle: "X", 128 fills: 5, 129 clears: 2, 130 checks: 1, 131 reveals: 1 132 ) 133 134 #expect(body == "Alice filled 5 letters, cleared 2 letters and ran 1 check and 1 reveal in the puzzle 'X'") 135 } 136 137 @Test("pauseBody falls back to no-edits wording when every count is zero") 138 func pauseBodyZeroCounts() { 139 let body = PuzzleNotificationText.pauseBody( 140 playerName: "Alice", 141 puzzleTitle: "Saturday Puzzle", 142 fills: 0, 143 clears: 0, 144 checks: 0, 145 reveals: 0 146 ) 147 148 #expect(body == "Alice stopped solving the puzzle 'Saturday Puzzle'.") 149 } 150 151 @Test("pauseBody substitutes a default name and a generic suffix when missing") 152 func pauseBodyMissingNameAndTitle() { 153 let body = PuzzleNotificationText.pauseBody( 154 playerName: "", 155 puzzleTitle: "", 156 fills: 2, 157 clears: 0, 158 checks: 0, 159 reveals: 0 160 ) 161 162 #expect(body == "A player filled 2 letters in the puzzle") 163 } 164 165 @Test("coalescedBody reuses the full pause wording for a single contributor") 166 func coalescedBodySingle() { 167 let body = PuzzleNotificationText.coalescedBody( 168 puzzleTitle: "Saturday Puzzle", 169 contributors: [ 170 .init(authorID: "a", name: "Alice", fills: 5, clears: 2, checks: 0, reveals: 0) 171 ] 172 ) 173 174 #expect(body == "Alice filled 5 letters and cleared 2 letters in the puzzle 'Saturday Puzzle'") 175 } 176 177 @Test("coalescedBody describes multiple contributors in full, joined by semicolons") 178 func coalescedBodyMultiple() { 179 let body = PuzzleNotificationText.coalescedBody( 180 puzzleTitle: "X", 181 contributors: [ 182 .init(authorID: "a", name: "Alice", fills: 5, clears: 1, checks: 0, reveals: 0), 183 .init(authorID: "b", name: "Bob", fills: 3, clears: 0, checks: 0, reveals: 0) 184 ] 185 ) 186 187 #expect(body == "Alice filled 5 letters and cleared 1 letter; Bob filled 3 letters in the puzzle 'X'") 188 } 189 190 @Test("coalescedBody reports each contributor's help gestures in full") 191 func coalescedBodyGestures() { 192 let body = PuzzleNotificationText.coalescedBody( 193 puzzleTitle: "X", 194 contributors: [ 195 .init(authorID: "a", name: "Alice", fills: 0, clears: 0, checks: 2, reveals: 0), 196 .init(authorID: "b", name: "Bob", fills: 0, clears: 0, checks: 0, reveals: 1) 197 ] 198 ) 199 200 #expect(body == "Alice ran 2 checks; Bob ran 1 reveal in the puzzle 'X'") 201 } 202 203 @Test("coalescedBody is nil with no contributors") 204 func coalescedBodyEmpty() { 205 #expect(PuzzleNotificationText.coalescedBody(puzzleTitle: "X", contributors: []) == nil) 206 } 207 208 @Test("Invite body names the inviter and puzzle") 209 func inviteBody() { 210 let ping = Ping( 211 recordName: "ping-test-2", 212 gameID: UUID(), 213 authorID: "alice", 214 deviceID: "device-a", 215 playerName: "Alice", 216 puzzleTitle: "Saturday Puzzle – 1 January 2001", 217 kind: .invite, 218 payload: nil, 219 addressee: "bob" 220 ) 221 222 #expect(InviteCoordinator.bodyText(for: ping) == "Alice invited you to the puzzle 'Saturday Puzzle – 1 January 2001'") 223 } 224 225 @Test("Decline body names the decliner and puzzle") 226 func declineBody() { 227 let ping = Ping( 228 recordName: "ping-test-3", 229 gameID: UUID(), 230 authorID: "bob", 231 deviceID: "device-b", 232 playerName: "Bob", 233 puzzleTitle: "Saturday Puzzle – 1 January 2001", 234 kind: .decline, 235 payload: nil, 236 addressee: "alice" 237 ) 238 239 #expect(InviteCoordinator.bodyText(for: ping) == "Bob declined your invitation to the puzzle 'Saturday Puzzle – 1 January 2001'") 240 } 241 } 242 243 @Suite("Notification preference muting") 244 struct NotificationMutedKindsTests { 245 @Test("All toggles on mutes nothing") 246 func allOnMutesNothing() { 247 let muted = AccountPushCoordinator.mutedPushKinds( 248 nudges: true, 249 joins: true, 250 pauses: true, 251 completions: true, 252 invitations: true 253 ) 254 255 #expect(muted.isEmpty) 256 } 257 258 @Test("Each toggle maps to its push kinds") 259 func togglesMapToKinds() { 260 #expect(AccountPushCoordinator.mutedPushKinds( 261 nudges: false, 262 joins: true, 263 pauses: true, 264 completions: true, 265 invitations: true 266 ) == ["nudge"]) 267 #expect(AccountPushCoordinator.mutedPushKinds( 268 nudges: true, 269 joins: false, 270 pauses: true, 271 completions: true, 272 invitations: true 273 ) == ["join"]) 274 #expect(AccountPushCoordinator.mutedPushKinds( 275 nudges: true, 276 joins: true, 277 pauses: false, 278 completions: true, 279 invitations: true 280 ) == ["pause"]) 281 #expect(AccountPushCoordinator.mutedPushKinds( 282 nudges: true, 283 joins: true, 284 pauses: true, 285 completions: false, 286 invitations: true 287 ) == ["win", "resign"]) 288 #expect(AccountPushCoordinator.mutedPushKinds( 289 nudges: true, 290 joins: true, 291 pauses: true, 292 completions: true, 293 invitations: false 294 ) == ["invite"]) 295 } 296 297 @Test("Muted kinds never include background kinds") 298 func neverMutesBackgroundKinds() { 299 let muted = AccountPushCoordinator.mutedPushKinds( 300 nudges: false, 301 joins: false, 302 pauses: false, 303 completions: false, 304 invitations: false 305 ) 306 307 #expect(muted == ["invite", "join", "nudge", "pause", "win", "resign"]) 308 #expect(!muted.contains("replay")) 309 #expect(!muted.contains("accountJoined")) 310 #expect(!muted.contains("accountSeen")) 311 } 312 313 @Test("Publish delivery summary formats the worker counts") 314 func deliverySummaryFormatsCounts() { 315 let body = Data(#"{"delivered":2,"removed":0,"muted":1,"failed":0}"#.utf8) 316 317 #expect(PushClient.deliverySummary(from: body) == " (delivered=2 muted=1 removed=0 failed=0)") 318 } 319 320 @Test("Publish delivery summary tolerates a pre-muted worker response") 321 func deliverySummaryOldWorker() { 322 let body = Data(#"{"delivered":3,"removed":1,"failed":0}"#.utf8) 323 324 #expect(PushClient.deliverySummary(from: body) == " (delivered=3 removed=1 failed=0)") 325 } 326 327 @Test("Publish delivery summary is empty for an unparseable body") 328 func deliverySummaryUnparseable() { 329 #expect(PushClient.deliverySummary(from: Data()) == "") 330 #expect(PushClient.deliverySummary(from: Data("ok".utf8)) == "") 331 } 332 }