crossmate

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

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 }