PuzzleNotificationTextTests.swift (11030B)
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 ) 253 254 #expect(muted.isEmpty) 255 } 256 257 @Test("Each toggle maps to its push kinds") 258 func togglesMapToKinds() { 259 #expect(AccountPushCoordinator.mutedPushKinds( 260 nudges: false, 261 joins: true, 262 pauses: true, 263 completions: true 264 ) == ["nudge"]) 265 #expect(AccountPushCoordinator.mutedPushKinds( 266 nudges: true, 267 joins: false, 268 pauses: true, 269 completions: true 270 ) == ["join"]) 271 #expect(AccountPushCoordinator.mutedPushKinds( 272 nudges: true, 273 joins: true, 274 pauses: false, 275 completions: true 276 ) == ["pause"]) 277 #expect(AccountPushCoordinator.mutedPushKinds( 278 nudges: true, 279 joins: true, 280 pauses: true, 281 completions: false 282 ) == ["win", "resign"]) 283 } 284 285 @Test("Muted kinds never include background or invite kinds") 286 func neverMutesBackgroundKinds() { 287 let muted = AccountPushCoordinator.mutedPushKinds( 288 nudges: false, 289 joins: false, 290 pauses: false, 291 completions: false 292 ) 293 294 #expect(muted == ["join", "nudge", "pause", "win", "resign"]) 295 #expect(!muted.contains("replay")) 296 #expect(!muted.contains("accountJoined")) 297 #expect(!muted.contains("accountSeen")) 298 } 299 300 @Test("Publish delivery summary formats the worker counts") 301 func deliverySummaryFormatsCounts() { 302 let body = Data(#"{"delivered":2,"removed":0,"muted":1,"failed":0}"#.utf8) 303 304 #expect(PushClient.deliverySummary(from: body) == " (delivered=2 muted=1 removed=0 failed=0)") 305 } 306 307 @Test("Publish delivery summary tolerates a pre-muted worker response") 308 func deliverySummaryOldWorker() { 309 let body = Data(#"{"delivered":3,"removed":1,"failed":0}"#.utf8) 310 311 #expect(PushClient.deliverySummary(from: body) == " (delivered=3 removed=1 failed=0)") 312 } 313 314 @Test("Publish delivery summary is empty for an unparseable body") 315 func deliverySummaryUnparseable() { 316 #expect(PushClient.deliverySummary(from: Data()) == "") 317 #expect(PushClient.deliverySummary(from: Data("ok".utf8)) == "") 318 } 319 }