crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }