crossmate

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

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 }