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