PushPayloadTests.swift (10460B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 @Suite("Push payload") 7 struct PushPayloadTests { 8 private func roundTrip(_ payload: PushPayload) throws -> PushPayload { 9 let encoded = try #require(payload.encodedString()) 10 return try #require(PushPayload.decode(from: encoded)) 11 } 12 13 @Test("Events round-trip through the wire encoding") 14 func eventsRoundTrip() throws { 15 let cases: [PushPayload.Event] = [ 16 .pause(fills: 3, clears: 2, checks: 1, reveals: 0), 17 .pause(fills: 0, clears: 0, checks: 0, reveals: 0), 18 .pause(fills: 0, clears: 0, checks: 0, reveals: 2), 19 .win, 20 .resign, 21 .replay, 22 .nudge, 23 .join 24 ] 25 for event in cases { 26 let decoded = try roundTrip(PushPayload(event: event)) 27 #expect(decoded == PushPayload(event: event)) 28 } 29 } 30 31 @Test("Puzzle title round-trips through the wire encoding") 32 func puzzleTitleRoundTrips() throws { 33 let payload = PushPayload(event: .win, puzzleTitle: "Saturday Crossword") 34 let decoded = try roundTrip(payload) 35 #expect(decoded == payload) 36 #expect(decoded.puzzleTitle == "Saturday Crossword") 37 } 38 39 @Test("playerName round-trips through the wire encoding") 40 func playerNameRoundTrips() throws { 41 let payload = PushPayload( 42 event: .pause(fills: 1, clears: 0, checks: 0, reveals: 0), 43 playerName: "Alice" 44 ) 45 let decoded = try roundTrip(payload) 46 #expect(decoded == payload) 47 #expect(decoded.playerName == "Alice") 48 } 49 50 @Test("Event time round-trips through the wire encoding") 51 func occurredAtRoundTrips() throws { 52 let occurredAt = Date(timeIntervalSince1970: 1_234_567) 53 let payload = PushPayload(event: .win, occurredAt: occurredAt) 54 55 let decoded = try roundTrip(payload) 56 57 #expect(decoded == payload) 58 #expect(decoded.occurredAt == occurredAt) 59 } 60 61 @Test("An older payload without playerName decodes it as nil") 62 func playerNameAbsenceTolerated() throws { 63 let json = #"{"version":1,"event":{"type":"pause","fills":1,"clears":0,"checks":0,"reveals":0}}"# 64 let encoded = Data(json.utf8).base64EncodedString() 65 66 let decoded = try #require(PushPayload.decode(from: encoded)) 67 68 #expect(decoded.playerName == nil) 69 #expect(decoded.occurredAt == nil) 70 } 71 72 @Test("composedBody rebuilds each event's body with the given name") 73 func composedBodySubstitutesName() { 74 func body(_ event: PushPayload.Event) -> String? { 75 PushPayload(event: event, puzzleTitle: "Saturday") 76 .composedBody(playerName: "Mum") 77 } 78 #expect(body(.nudge) == "Mum nudged you to play the puzzle 'Saturday'") 79 #expect(body(.join) == "Mum joined the puzzle 'Saturday'") 80 #expect(body(.win) == "Mum solved the puzzle 'Saturday'") 81 #expect(body(.resign) == "Mum resigned the puzzle 'Saturday'.") 82 #expect(body(.pause(fills: 3, clears: 0, checks: 0, reveals: 0)) 83 == "Mum filled 3 letters in the puzzle 'Saturday'") 84 #expect(body(.pause(fills: 0, clears: 0, checks: 0, reveals: 0)) 85 == "Mum stopped solving the puzzle 'Saturday'.") 86 } 87 88 @Test("composedBody returns nil when the body can't be rebuilt") 89 func composedBodyNilWhenUncomposable() { 90 // No puzzle title (older sender) — can't faithfully rebuild. 91 #expect(PushPayload(event: .win).composedBody(playerName: "Mum") == nil) 92 // Bodyless events carry no visible text to rebuild. 93 #expect(PushPayload(event: .replay, puzzleTitle: "Saturday") 94 .composedBody(playerName: "Mum") == nil) 95 #expect(PushPayload(event: .unknown, puzzleTitle: "Saturday") 96 .composedBody(playerName: "Mum") == nil) 97 } 98 99 @Test("An unrecognised event decodes to .unknown rather than failing") 100 func unknownEventTolerated() throws { 101 // Simulates a payload a newer build might send. 102 let json = #"{"version":2,"event":{"type":"sparkle","intensity":5}}"# 103 let encoded = Data(json.utf8).base64EncodedString() 104 105 let decoded = try #require(PushPayload.decode(from: encoded)) 106 107 #expect(decoded.event == .unknown) 108 #expect(decoded.version == 2) 109 } 110 111 @Test("Absent or malformed payload decodes to nil") 112 func absentPayloadIsNil() { 113 #expect(PushPayload.decode(from: nil) == nil) 114 #expect(PushPayload.decode(from: "not base64 $$$") == nil) 115 #expect(PushPayload.decode(from: Data("plain text".utf8).base64EncodedString()) == nil) 116 } 117 118 @Test("Diagnostics round-trip through the wire encoding") 119 func diagnosticsRoundTrip() throws { 120 let diagnostics = PushPayload.Diagnostics( 121 senderNow: Date(timeIntervalSince1970: 2_000), 122 sessionStart: Date(timeIntervalSince1970: 1_500), 123 recipientPresenceUntil: Date(timeIntervalSince1970: 1_000), 124 gridWidth: 15, 125 gridHeight: 15, 126 parserVersion: 3, 127 mergedCells: 200, 128 inBounds: 78, 129 playable: 78, 130 minRow: 0, 131 maxRow: 14, 132 minCol: 0, 133 maxCol: 14, 134 deviceCount: 2, 135 earliestEdit: Date(timeIntervalSince1970: 1_200), 136 latestEdit: Date(timeIntervalSince1970: 1_900) 137 ) 138 let payload = PushPayload( 139 event: .pause(fills: 125, clears: 75, checks: 3, reveals: 1), 140 diagnostics: diagnostics 141 ) 142 143 let decoded = try roundTrip(payload) 144 145 #expect(decoded == payload) 146 #expect(decoded.diagnostics?.mergedCells == 200) 147 #expect(decoded.diagnostics?.recipientPresenceUntil == Date(timeIntervalSince1970: 1_000)) 148 } 149 150 @Test("A pause without diagnostics decodes with nil diagnostics") 151 func diagnosticsAbsenceTolerated() throws { 152 let json = #"{"version":1,"event":{"type":"pause","fills":1,"clears":0,"checks":0,"reveals":0}}"# 153 let encoded = Data(json.utf8).base64EncodedString() 154 155 let decoded = try #require(PushPayload.decode(from: encoded)) 156 157 #expect(decoded.diagnostics == nil) 158 #expect(decoded.event == .pause(fills: 1, clears: 0, checks: 0, reveals: 0)) 159 } 160 161 @Test("A pause missing the gesture counts decodes them as zero") 162 func pauseGestureCountsDefaultToZero() throws { 163 // A sender that only wrote letter counts (or a future trimmed payload). 164 let json = #"{"version":1,"event":{"type":"pause","fills":2,"clears":1}}"# 165 let encoded = Data(json.utf8).base64EncodedString() 166 167 let decoded = try #require(PushPayload.decode(from: encoded)) 168 169 #expect(decoded.event == .pause(fills: 2, clears: 1, checks: 0, reveals: 0)) 170 } 171 172 @Test("Diagnostics summary renders values and dashes for absent fields") 173 func diagnosticsSummaryLine() { 174 let diagnostics = PushPayload.Diagnostics( 175 gridWidth: 15, 176 gridHeight: 15, 177 mergedCells: 200 178 ) 179 180 let line = diagnostics.summaryLine 181 182 #expect(line.contains("grid=15x15")) 183 #expect(line.contains("merged=200")) 184 #expect(line.contains("recipientPresenceUntil=—")) 185 } 186 187 @Test("Only unseen content marks a game unread") 188 func marksUnreadMatrix() { 189 #expect(PushPayload(event: .pause(fills: 1, clears: 0, checks: 0, reveals: 0)).marksUnread) 190 #expect(PushPayload(event: .pause(fills: 0, clears: 2, checks: 0, reveals: 0)).marksUnread) 191 // A check or a reveal alone still changes the shared grid. 192 #expect(PushPayload(event: .pause(fills: 0, clears: 0, checks: 1, reveals: 0)).marksUnread) 193 #expect(PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 1)).marksUnread) 194 #expect(PushPayload(event: .win).marksUnread) 195 #expect(PushPayload(event: .resign).marksUnread) 196 197 #expect(!PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 0)).marksUnread) 198 // A nudge is a manual presence ping — it never marks a game unread. 199 #expect(!PushPayload(event: .nudge).marksUnread) 200 // Joining is presence only — no grid change, so no unread. 201 #expect(!PushPayload(event: .join).marksUnread) 202 #expect(!PushPayload(event: .replay).marksUnread) 203 #expect(!PushPayload(event: .unknown).marksUnread) 204 } 205 } 206 207 @Suite("Coalesced summary") 208 struct CoalescedSummaryTests { 209 @Test("add sums counts for a repeated sender and appends new ones in order") 210 func addAccumulates() { 211 var summary = CoalescedSummary() 212 summary.add(authorID: "a", name: "Alice", fills: 3, clears: 1, checks: 0, reveals: 0) 213 summary.add(authorID: "b", name: "Bob", fills: 2, clears: 0, checks: 1, reveals: 0) 214 summary.add(authorID: "a", name: "Alice", fills: 2, clears: 0, checks: 0, reveals: 1) 215 216 #expect(summary.contributors.count == 2) 217 let alice = summary.contributors[0] 218 #expect(alice.authorID == "a") 219 #expect(alice.fills == 5) 220 #expect(alice.clears == 1) 221 #expect(alice.reveals == 1) 222 // First-seen order preserved despite Alice's second update. 223 #expect(summary.contributors[1].authorID == "b") 224 } 225 226 @Test("A later non-empty name refreshes the stored one; an empty name does not") 227 func nameRefresh() { 228 var summary = CoalescedSummary() 229 summary.add(authorID: "a", name: "ab12cd34", fills: 1, clears: 0, checks: 0, reveals: 0) 230 summary.add(authorID: "a", name: "Alice", fills: 1, clears: 0, checks: 0, reveals: 0) 231 #expect(summary.contributors[0].name == "Alice") 232 summary.add(authorID: "a", name: "", fills: 1, clears: 0, checks: 0, reveals: 0) 233 #expect(summary.contributors[0].name == "Alice") 234 } 235 236 @Test("Round-trips through the userInfo encoding") 237 func roundTrip() throws { 238 var summary = CoalescedSummary() 239 summary.add(authorID: "a", name: "Alice", fills: 3, clears: 1, checks: 0, reveals: 0) 240 let encoded = try #require(summary.encodedString()) 241 #expect(CoalescedSummary.decode(from: encoded) == summary) 242 } 243 244 @Test("Absent or malformed encoding decodes to nil") 245 func decodeNil() { 246 #expect(CoalescedSummary.decode(from: nil) == nil) 247 #expect(CoalescedSummary.decode(from: "not base64 $$$") == nil) 248 } 249 }