NYTPuzzleUpgraderTests.swift (7905B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 @Suite("NYTPuzzleUpgrader") 7 struct NYTPuzzleUpgraderTests { 8 9 // MARK: - Fixtures 10 11 /// 3×3 open grid with default solutions ABC/DEF/GHI. Override `gridRows` 12 /// for solution-diff tests; clue text overrides are independent so the 13 /// verifier sees a clue-only change. 14 private func openGridSource( 15 clueAcross1: String = "Across one", 16 clueAcross4: String = "Across four", 17 clueAcross5: String = "Across five", 18 clueDown1: String = "Down one", 19 clueDown2: String = "Down two", 20 clueDown3: String = "Down three", 21 gridRows: [String] = ["ABC", "DEF", "GHI"] 22 ) -> String { 23 precondition(gridRows.count == 3 && gridRows.allSatisfy { $0.count == 3 }) 24 let metadata = """ 25 Title: Test 26 Publisher: New York Times 27 Date: 2025-01-26 28 """ 29 let grid = gridRows.joined(separator: "\n") 30 func col(_ idx: Int) -> String { 31 String(gridRows.map { Array($0)[idx] }) 32 } 33 let clues = """ 34 A1. \(clueAcross1) ~ \(gridRows[0]) 35 A4. \(clueAcross4) ~ \(gridRows[1]) 36 A5. \(clueAcross5) ~ \(gridRows[2]) 37 38 D1. \(clueDown1) ~ \(col(0)) 39 D2. \(clueDown2) ~ \(col(1)) 40 D3. \(clueDown3) ~ \(col(2)) 41 """ 42 return [metadata, grid, clues].joined(separator: "\n\n\n") 43 } 44 45 /// 2×2 open grid for the dimensions-diff test. 46 private func smallGridSource() -> String { 47 let metadata = """ 48 Title: Small 49 Publisher: New York Times 50 Date: 2025-01-26 51 """ 52 let grid = "AB\nCD" 53 let clues = """ 54 A1. Top ~ AB 55 A3. Bottom ~ CD 56 57 D1. Left ~ AC 58 D2. Right ~ BD 59 """ 60 return [metadata, grid, clues].joined(separator: "\n\n\n") 61 } 62 63 /// 3×3 grid with a single block at (0,2). Manually written because the 64 /// open-grid helper assumes every cell is open. 65 private func blockedSource() -> String { 66 let metadata = """ 67 Title: Blocked 68 Publisher: New York Times 69 Date: 2025-01-26 70 """ 71 let grid = "AB#\nDEF\nGHI" 72 let clues = """ 73 A1. Top ~ AB 74 A3. Middle ~ DEF 75 A5. Bottom ~ GHI 76 77 D1. Left ~ ADG 78 D2. Mid ~ BEH 79 D4. Right ~ FI 80 """ 81 return [metadata, grid, clues].joined(separator: "\n\n\n") 82 } 83 84 /// 1×1 grid whose single cell is a rebus fill, used to check that a solution 85 /// differing only in Schrödinger slash placement upgrades in place. 86 private func rebusSource(fill: String) -> String { 87 let metadata = """ 88 Title: Rebus 89 Publisher: New York Times 90 Date: 2025-01-26 91 Rebus: 1=\(fill) 92 """ 93 let clues = """ 94 A1. Clue ~ \(fill) 95 D1. Clue ~ \(fill) 96 """ 97 return [metadata, "1", clues].joined(separator: "\n\n\n") 98 } 99 100 // MARK: - Outcomes 101 102 @Test("Materialised Chronicles are not re-fetched as writable NYT games") 103 @MainActor 104 func materializedChronicleHasNoUpgradePlan() throws { 105 let persistence = makeTestPersistence() 106 let store = makeTestStore(persistence: persistence) 107 let originalID = UUID() 108 let entity = GameEntity(context: persistence.viewContext) 109 entity.id = Archive.archiveGameID(for: originalID) 110 entity.title = "Test" 111 entity.puzzleSource = openGridSource() 112 entity.createdAt = Date() 113 entity.updatedAt = Date() 114 entity.completedAt = Date() 115 entity.databaseScope = 0 116 entity.ckRecordName = Archive.recordName(forOriginalGameID: originalID) 117 entity.ckZoneName = Archive.zoneName 118 try persistence.viewContext.save() 119 120 #expect(NYTPuzzleUpgrader.plan(for: entity.id!, store: store) == nil) 121 } 122 123 @Test("Completed owned games are not re-fetched for NYT upgrades") 124 @MainActor 125 func completedOwnedGameHasNoUpgradePlan() throws { 126 let persistence = makeTestPersistence() 127 let store = makeTestStore(persistence: persistence) 128 let entity = GameEntity(context: persistence.viewContext) 129 entity.id = UUID() 130 entity.title = "Test" 131 entity.puzzleSource = openGridSource() 132 entity.createdAt = Date() 133 entity.updatedAt = Date() 134 entity.completedAt = Date() 135 entity.databaseScope = 0 136 try persistence.viewContext.save() 137 138 #expect(NYTPuzzleUpgrader.plan(for: entity.id!, store: store) == nil) 139 } 140 141 @Test("Clue-only diff is .upgraded — grid + solutions match") 142 func clueOnlyDiffIsUpgraded() async { 143 let old = openGridSource(clueAcross1: "[aria-label] Old prefix") 144 let new = openGridSource(clueAcross1: "Old prefix") 145 let outcome = await NYTPuzzleUpgrader.upgrade( 146 date: Date(), 147 currentSource: old, 148 fetch: { _ in new } 149 ) 150 guard case .upgraded(let returned) = outcome else { 151 Issue.record("Expected .upgraded but got \(outcome)") 152 return 153 } 154 #expect(returned == new) 155 } 156 157 @Test("Solution diff is .mismatched") 158 func solutionDiffIsMismatched() async { 159 let old = openGridSource() 160 let new = openGridSource(gridRows: ["ABC", "DEF", "GHJ"]) 161 let outcome = await NYTPuzzleUpgrader.upgrade( 162 date: Date(), 163 currentSource: old, 164 fetch: { _ in new } 165 ) 166 if case .mismatched = outcome { return } 167 Issue.record("Expected .mismatched but got \(outcome)") 168 } 169 170 @Test("Any rebus fill change is .upgraded — slash re-representation or otherwise") 171 func rebusFillChangeIsUpgraded() async { 172 // Rebus diffs are presumed to be converter fixes and defer to the 173 // fresh conversion — refusing them would keep a broken conversion in 174 // place forever. Even a wholly different fill replaces the source; 175 // a stale existing fill at that square is the accepted cost. 176 for (old, new) in [("HITMISS", "HIT/MISS"), ("LOVE", "HATE")] { 177 let outcome = await NYTPuzzleUpgrader.upgrade( 178 date: Date(), 179 currentSource: rebusSource(fill: old), 180 fetch: { _ in rebusSource(fill: new) } 181 ) 182 guard case .upgraded(let returned) = outcome else { 183 Issue.record("Expected .upgraded for \(old)→\(new) but got \(outcome)") 184 return 185 } 186 #expect(returned == rebusSource(fill: new)) 187 } 188 } 189 190 @Test("Block-pattern diff is .mismatched") 191 func blockPatternDiffIsMismatched() async { 192 let old = openGridSource() 193 let new = blockedSource() 194 let outcome = await NYTPuzzleUpgrader.upgrade( 195 date: Date(), 196 currentSource: old, 197 fetch: { _ in new } 198 ) 199 if case .mismatched = outcome { return } 200 Issue.record("Expected .mismatched but got \(outcome)") 201 } 202 203 @Test("Dimensions diff is .mismatched") 204 func dimensionsDiffIsMismatched() async { 205 let old = openGridSource() 206 let new = smallGridSource() 207 let outcome = await NYTPuzzleUpgrader.upgrade( 208 date: Date(), 209 currentSource: old, 210 fetch: { _ in new } 211 ) 212 if case .mismatched = outcome { return } 213 Issue.record("Expected .mismatched but got \(outcome)") 214 } 215 216 @Test("Fetcher throwing is .failed") 217 func fetcherThrowingIsFailed() async { 218 struct StubError: Error {} 219 let old = openGridSource() 220 let outcome = await NYTPuzzleUpgrader.upgrade( 221 date: Date(), 222 currentSource: old, 223 fetch: { _ in throw StubError() } 224 ) 225 if case .failed = outcome { return } 226 Issue.record("Expected .failed but got \(outcome)") 227 } 228 }