MovesCodecLegacyDecodeTests.swift (4398B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 /// Pins down `MovesCodec`'s one back-compat concession: decoding `Moves` blobs 7 /// written before the single-`markCode` cutover, which encoded the mark as a 8 /// `(markKind, checkedRight, checkedWrong)` triple. Records at rest in CloudKit 9 /// (and any straggler peer) still carry that shape, so the fallback must lift 10 /// it to the right `CellMark`. New writes are markCode-only — also asserted 11 /// here so the legacy keys never creep back into the wire format. 12 @Suite("MovesCodec legacy decode") 13 struct MovesCodecLegacyDecodeTests { 14 15 /// Builds an old-format blob: one entry per (markKind, checkedRight, 16 /// checkedWrong) triple, in the JSON shape a pre-cutover client produced. 17 /// `updatedAt` is a bare number because `MovesCodec` uses a plain 18 /// `JSONEncoder`/`Decoder` (deferred-to-date strategy = seconds). 19 private func legacyBlob(_ triples: [(row: Int, kind: Int, right: Bool, wrong: Bool)]) throws -> Data { 20 let entries: [[String: Any]] = triples.map { t in 21 [ 22 "row": t.row, "col": 0, "letter": "A", 23 "markKind": t.kind, 24 "checkedRight": t.right, 25 "checkedWrong": t.wrong, 26 "updatedAt": 0.0, 27 "authorID": "alice", 28 ] 29 } 30 return try JSONSerialization.data(withJSONObject: ["entries": entries]) 31 } 32 33 @Test("legacy triple decodes to the right CellMark for every kind/check") 34 func legacyTripleDecodes() throws { 35 let blob = try legacyBlob([ 36 (row: 0, kind: 0, right: false, wrong: false), // .none 37 (row: 1, kind: 1, right: false, wrong: false), // .pen(nil) 38 (row: 2, kind: 1, right: true, wrong: false), // .pen(.right) 39 (row: 3, kind: 1, right: false, wrong: true), // .pen(.wrong) 40 (row: 4, kind: 2, right: false, wrong: false), // .pencil(nil) 41 (row: 5, kind: 2, right: true, wrong: false), // .pencil(.right) 42 (row: 6, kind: 2, right: false, wrong: true), // .pencil(.wrong) 43 (row: 7, kind: 3, right: false, wrong: false), // .revealed 44 ]) 45 46 let cells = try MovesCodec.decode(blob) 47 func mark(_ row: Int) -> CellMark? { cells[GridPosition(row: row, col: 0)]?.mark } 48 49 #expect(mark(0) == CellMark.none) 50 #expect(mark(1) == .pen(checked: nil)) 51 #expect(mark(2) == .pen(checked: .right)) 52 #expect(mark(3) == .pen(checked: .wrong)) 53 #expect(mark(4) == .pencil(checked: nil)) 54 #expect(mark(5) == .pencil(checked: .right)) 55 #expect(mark(6) == .pencil(checked: .wrong)) 56 #expect(mark(7) == .revealed) 57 } 58 59 @Test("oldest blobs without a checkedRight key still decode") 60 func missingCheckedRightKey() throws { 61 // `checkedRight` was added after the first release; the very oldest 62 // records omit it entirely. It must default to false, not throw. 63 let entry: [String: Any] = [ 64 "row": 0, "col": 0, "letter": "Q", 65 "markKind": 1, "checkedWrong": false, 66 "updatedAt": 0.0, "authorID": "alice", 67 ] 68 let blob = try JSONSerialization.data(withJSONObject: ["entries": [entry]]) 69 70 let cells = try MovesCodec.decode(blob) 71 #expect(cells[GridPosition(row: 0, col: 0)]?.mark == .pen(checked: nil)) 72 } 73 74 @Test("new writes are markCode-only — no legacy keys on the wire") 75 func newFormatOmitsLegacyKeys() throws { 76 let cells: [GridPosition: TimestampedCell] = [ 77 GridPosition(row: 0, col: 0): TimestampedCell( 78 letter: "A", mark: .pencil(checked: .wrong), 79 updatedAt: Date(timeIntervalSinceReferenceDate: 0), authorID: "alice" 80 ), 81 ] 82 let data = try MovesCodec.encode(cells) 83 84 let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] 85 let entry = (json?["entries"] as? [[String: Any]])?.first 86 #expect(entry?["markCode"] as? Int == 6) // .pencil(.wrong) 87 #expect(entry?["markKind"] == nil) 88 #expect(entry?["checkedRight"] == nil) 89 #expect(entry?["checkedWrong"] == nil) 90 91 // And it round-trips back through decode. 92 let decoded = try MovesCodec.decode(data) 93 #expect(decoded[GridPosition(row: 0, col: 0)]?.mark == .pencil(checked: .wrong)) 94 } 95 }