commit dc6c036f999618beb282cf7f5597e85c08615f3e
parent 882b63a36136e69f693352c318ce41dfde172d85
Author: Michael Camilleri <[email protected]>
Date: Sat, 23 May 2026 15:57:35 +0900
Support refreshing puzzles from external providers
The JSON-to-XD converter used for New York Times puzzles remains a work in
progress and has gaps that get found over time. Most recently, it was
discovered that some puzzles include SVG-based clues. These puzzles are
accompanied by a text-only clue but this clue is prefixed with 'aria-label' and
this was being display in Crossmate as part of the clue. The converter now
strips that prefix but this will not by itself fix games that are in progress.
This commit bumps XD.currentCmVersion to 4 and adds an owner-side re-fetch that
triggers whenever a game's stored CmVer falls behind. On puzzle open,
NYTPuzzleUpgrader.plan(for:store:) inspects the entity's persisted XD: NYT
publisher + date + isOwned + CmVer mismatch yields a Plan, otherwise nil. When
there's a plan, the open task shows an 'Updating puzzle...' overlay and
apply(plan:store:fetch:) runs the existing `NYTPuzzleFetcher`, re-runs
NYTToXDConverter.convert, and structurally verifies the new XD against the old
one: same width/height, same block layout, identical solution at every open
cell. Clue text, accepted variants, specials and metadata may all differ.
There are three outcomes to the verification:
- .upgraded: This replaces puzzleSource, stamps the new CmVer, raises a new
hasPushPending flag and calls onGameUpdated. The flag is checked in
RecordBuilder to force re-inclusion of the puzzleSource CKAsset on the next
outbound Game record, and clearPendingSaveFlag resets it after a confirmed
save alongside the existing hasPendingSave reset. Participants pick up the
corrected XD on their next CKSyncEngine fetch — no participant-side code
needed.
- .mismatched: This stamps the new CmVer without touching the source. The
structural verifier rejected the new XD (changed grid or our converter
regressed), so swapping would invalidate the player's in-progress moves.
Stamping CmVer anyway prevents the upgrade from being re-attempted on every
launch.
- .failed: This writes nothing. Covers transient fetch failures — no network,
expired NYT cookie — and lets the upgrade retry next time the puzzle is
opened.
'databaseScope == 0' gates the planner so only the zone owner rewrites the
canonical source; participants never run the upgrade themselves and receive the
swap through the normal CKShare delivery path.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
11 files changed, 397 insertions(+), 3 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -39,6 +39,7 @@
4C874B69A9D9187490BC2C42 /* FriendAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */; };
4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */; };
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; };
+ 50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */; };
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; };
5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */; };
5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */; };
@@ -59,6 +60,7 @@
8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */; };
849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABB557BA10CBE9909056882 /* GameShareItem.swift */; };
85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */; };
+ 85B9BAC5ED404FE4496250CB /* NYTPuzzleUpgrader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */; };
886A451A134D88B9324D9A1C /* FriendPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE3412F437AABD2988B6976D /* FriendPickerView.swift */; };
89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */; };
8B356C953DA0FAF149C3391A /* Puzzles in Resources */ = {isa = PBXBuildFile; fileRef = BA67C509B467132D1B7510A4 /* Puzzles */; };
@@ -211,6 +213,7 @@
B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; };
B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorIdentity.swift; sourceTree = "<group>"; };
B23A692318044351247606DF /* SuccessPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessPanel.swift; sourceTree = "<group>"; };
+ B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleUpgraderTests.swift; sourceTree = "<group>"; };
B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverter.swift; sourceTree = "<group>"; };
B689A7138429641E61E9E558 /* Crossmate.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Crossmate.app; sourceTree = BUILT_PRODUCTS_DIR; };
B766E872B12DC79ECCD80941 /* FriendModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendModelTests.swift; sourceTree = "<group>"; };
@@ -226,6 +229,7 @@
C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationTextTests.swift; sourceTree = "<group>"; };
CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; };
CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; };
+ CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleUpgrader.swift; sourceTree = "<group>"; };
CFC4FF046BF772646B5CA73F /* Presence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presence.swift; sourceTree = "<group>"; };
D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationText.swift; sourceTree = "<group>"; };
D491B7232333AA8957732387 /* PendingEditFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingEditFlagTests.swift; sourceTree = "<group>"; };
@@ -307,6 +311,7 @@
FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */,
47532AED239AEF476D8E9206 /* NotificationStateTests.swift */,
ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */,
+ B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */,
C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */,
A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */,
D491B7232333AA8957732387 /* PendingEditFlagTests.swift */,
@@ -466,6 +471,7 @@
33878A29B09A6154C7A63C82 /* KeychainHelper.swift */,
A253416F4FEA271A80B22A73 /* NYTAuthService.swift */,
B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */,
+ CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */,
BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */,
71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */,
B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */,
@@ -582,6 +588,7 @@
DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */,
C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */,
C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */,
+ 50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */,
AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */,
18D5BB584DBF92A2EC580AEA /* NotificationNavigationBrokerTests.swift in Sources */,
E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */,
@@ -654,6 +661,7 @@
FFBE2EC8A3A60E119A0D314F /* NYTBrowseView.swift in Sources */,
D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */,
0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */,
+ 85B9BAC5ED404FE4496250CB /* NYTPuzzleUpgrader.swift in Sources */,
B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */,
DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */,
6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -448,6 +448,13 @@ private struct PuzzleDisplayView: View {
while !Task.isCancelled {
do {
+ if let plan = NYTPuzzleUpgrader.plan(for: gameID, store: store) {
+ loadingMessage = "Updating puzzle..."
+ let fetcher = services.nytFetcher
+ await NYTPuzzleUpgrader.apply(plan: plan, store: store) { date in
+ try await fetcher.fetchPuzzle(for: date)
+ }
+ }
let (game, mutator) = try store.loadGame(id: gameID)
let newSession = PlayerSession(
game: game,
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -22,6 +22,7 @@
<attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="latestOtherMoveAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="puzzleResourceID" optional="YES" attributeType="String"/>
+ <attribute name="hasPushPending" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="puzzleCmVersion" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="puzzleSource" attributeType="String"/>
<attribute name="title" attributeType="String"/>
diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift
@@ -4,7 +4,7 @@ import Foundation
/// full specification. Supports just enough of the format to parse our
/// bundled puzzles: metadata, grid (with rebus), and across/down clues.
struct XD: Sendable {
- static let currentCmVersion = 3
+ static let currentCmVersion = 4
let title: String?
let publisher: String?
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -711,6 +711,69 @@ final class GameStore {
return Puzzle(xd: try XD.parse(source))
}
+ /// Minimal read snapshot of a game's persisted puzzle metadata, sized for
+ /// out-of-store decision logic (e.g. whether to run a converter upgrade)
+ /// without exposing the underlying `GameEntity`.
+ struct PuzzleInfo: Sendable {
+ let gameID: UUID
+ let source: String
+ let cmVersion: Int64
+ let isOwned: Bool
+ }
+
+ func puzzleInfo(for id: UUID) -> PuzzleInfo? {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
+ request.fetchLimit = 1
+ guard let entity = try? context.fetch(request).first,
+ let source = entity.puzzleSource
+ else { return nil }
+ return PuzzleInfo(
+ gameID: id,
+ source: source,
+ cmVersion: entity.puzzleCmVersion,
+ isOwned: entity.databaseScope == 0
+ )
+ }
+
+ /// Replaces a game's persisted XD source with a re-converted equivalent,
+ /// stamps the current CmVer, raises `hasPushPending` so the next outbound
+ /// Game record re-includes the `puzzleSource` asset, and enqueues the push
+ /// via `onGameUpdated`. Callers (currently the NYT upgrade flow) are
+ /// responsible for verifying that `newSource` is structurally compatible
+ /// with the player's in-progress moves before invoking this.
+ func replacePuzzleSource(id: UUID, with newSource: String) {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
+ request.fetchLimit = 1
+ guard let entity = try? context.fetch(request).first,
+ let parsed = try? XD.parse(newSource) else { return }
+ let puzzle = Puzzle(xd: parsed)
+ entity.puzzleSource = newSource
+ entity.title = puzzle.title
+ entity.puzzleCmVersion = Int64(XD.currentCmVersion)
+ entity.hasPushPending = true
+ entity.hasPendingSave = true
+ entity.populateCachedSummaryFields(from: puzzle)
+ try? context.save()
+ if let ckName = entity.ckRecordName {
+ onGameUpdated(ckName)
+ }
+ }
+
+ /// Stamps the current CmVer onto a game without other changes. Used when
+ /// an attempted upgrade decided not to replace the source (e.g. structural
+ /// mismatch) but the caller still wants to retire the stale version so the
+ /// upgrade isn't reattempted every launch.
+ func bumpPuzzleCmVersion(for id: UUID) {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
+ request.fetchLimit = 1
+ guard let entity = try? context.fetch(request).first else { return }
+ entity.puzzleCmVersion = Int64(XD.currentCmVersion)
+ try? context.save()
+ }
+
private func restore(game: Game, from entity: GameEntity, updateCache: Bool = true) {
let movesRequest = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
movesRequest.predicate = NSPredicate(format: "game == %@", entity)
diff --git a/Crossmate/Services/NYTPuzzleUpgrader.swift b/Crossmate/Services/NYTPuzzleUpgrader.swift
@@ -0,0 +1,130 @@
+import Foundation
+
+/// Re-fetches a NYT puzzle and runs the current `NYTToXDConverter` over it.
+/// Used when `XD.currentCmVersion` advances and an existing game's stored
+/// XD source predates a converter fix; the upgrader produces an XD string
+/// that can replace the persisted source provided the puzzle's grid hasn't
+/// changed underneath us. Only clue text, accepted variants, specials, and
+/// metadata are allowed to differ — the structural verifier rejects anything
+/// that would invalidate the player's in-progress moves.
+enum NYTPuzzleUpgrader {
+ typealias PuzzleFetch = @Sendable (Date) async throws -> String
+
+ enum Outcome {
+ /// The fetched + re-converted XD is structurally identical to the old
+ /// one; the persisted source can be replaced.
+ case upgraded(newSource: String)
+ /// The new XD's grid differs from the persisted one. Per the upgrade
+ /// policy, the caller should stamp the new CmVer but keep the old
+ /// source so the player's moves stay valid.
+ case mismatched
+ /// The fetch or a parse step failed. Caller should leave both the
+ /// source and the CmVer untouched so the upgrade is retried on the
+ /// next launch.
+ case failed(Error)
+ }
+
+ static func upgrade(
+ date: Date,
+ currentSource: String,
+ fetch: PuzzleFetch
+ ) async -> Outcome {
+ let newSource: String
+ do {
+ newSource = try await fetch(date)
+ } catch {
+ return .failed(error)
+ }
+
+ let oldXD: XD
+ let newXD: XD
+ do {
+ oldXD = try XD.parse(currentSource)
+ newXD = try XD.parse(newSource)
+ } catch {
+ return .failed(error)
+ }
+
+ guard structurallyEquivalent(oldXD, newXD) else { return .mismatched }
+ return .upgraded(newSource: newSource)
+ }
+
+ /// Describes a deferred NYT re-conversion for an owned game whose stored
+ /// `puzzleSource` predates `XD.currentCmVersion`. Built up-front (before
+ /// `loadGame`) so the caller can show an "Updating puzzle…" state during
+ /// the network round-trip. The bundled-catalog upgrade in
+ /// `GameStore.preparePuzzleForLoad` runs synchronously and doesn't need
+ /// this.
+ struct Plan: Sendable {
+ let gameID: UUID
+ let date: Date
+ fileprivate let currentSource: String
+ }
+
+ /// Returns a plan when an opened game would benefit from a NYT re-
+ /// conversion: CmVer mismatch, the local user owns the zone (so this
+ /// device's write will sync to participants), the persisted XD identifies
+ /// a NYT puzzle, and a publication date is present. Bundled puzzles and
+ /// participant-side rows are skipped — the bundled-catalog path already
+ /// handles the former, and only the owner should rewrite the canonical
+ /// source.
+ @MainActor
+ static func plan(for id: UUID, store: GameStore) -> Plan? {
+ guard let info = store.puzzleInfo(for: id),
+ info.isOwned,
+ info.cmVersion != Int64(XD.currentCmVersion),
+ let xd = try? XD.parse(info.source),
+ xd.publisher == "New York Times",
+ let date = xd.date
+ else { return nil }
+ return Plan(gameID: info.gameID, date: date, currentSource: info.source)
+ }
+
+ /// Runs the upgrader and applies the result against `store`:
+ /// `.upgraded` swaps in the new source via `replacePuzzleSource`;
+ /// `.mismatched` stamps the new CmVer via `bumpPuzzleCmVersion` so we
+ /// don't retry every launch; `.failed` writes nothing, so the upgrade
+ /// is re-attempted next time the game is opened (covers transient
+ /// network / auth failures).
+ @MainActor
+ static func apply(
+ plan: Plan,
+ store: GameStore,
+ fetch: PuzzleFetch
+ ) async {
+ let outcome = await upgrade(
+ date: plan.date,
+ currentSource: plan.currentSource,
+ fetch: fetch
+ )
+ switch outcome {
+ case .upgraded(let newSource):
+ store.replacePuzzleSource(id: plan.gameID, with: newSource)
+ case .mismatched:
+ store.bumpPuzzleCmVersion(for: plan.gameID)
+ case .failed:
+ break
+ }
+ }
+
+ /// Two puzzles are structurally equivalent when their grids have the same
+ /// dimensions, the same block layout, and the same solution at every open
+ /// cell. Special markers, accepted variants, clues, and headers may all
+ /// differ.
+ static func structurallyEquivalent(_ a: XD, _ b: XD) -> Bool {
+ guard a.width == b.width, a.height == b.height else { return false }
+ for row in 0..<a.height {
+ for col in 0..<a.width {
+ switch (a.cells[row][col], b.cells[row][col]) {
+ case (.block, .block):
+ continue
+ case let (.open(left, _, _), .open(right, _, _)):
+ if left != right { return false }
+ default:
+ return false
+ }
+ }
+ }
+ return true
+ }
+}
diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift
@@ -146,7 +146,7 @@ enum NYTToXDConverter {
if let textArray = clue["text"] as? [[String: Any]],
let firstText = textArray.first,
let plain = firstText["plain"] as? String {
- clueText = plain
+ clueText = stripAriaLabelPrefix(plain)
} else {
clueText = ""
}
@@ -447,6 +447,16 @@ enum NYTToXDConverter {
return parts.joined(separator: " ")
}
+ /// NYT image-based clues mirror the image's aria-label in the `plain`
+ /// field, prefixed with the literal token `[aria-label]`. The prefix is a
+ /// machine marker, not part of the clue itself, so strip it.
+ private static func stripAriaLabelPrefix(_ text: String) -> String {
+ let trimmed = text.drop(while: { $0 == " " })
+ guard trimmed.lowercased().hasPrefix("[aria-label]") else { return text }
+ let afterToken = trimmed.dropFirst("[aria-label]".count)
+ return String(afterToken.drop(while: { $0 == " " }))
+ }
+
/// Extracts an Int from a JSON value that may be NSNumber, Int, or Double.
private static func intValue(_ value: Any?) -> Int? {
if let n = value as? Int { return n }
diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift
@@ -45,7 +45,7 @@ extension SyncEngine {
return RecordSerializer.gameRecord(
from: entity,
recordID: recordID,
- includePuzzleSource: entity.ckSystemFields == nil
+ includePuzzleSource: entity.ckSystemFields == nil || entity.hasPushPending
)
} else if name.hasPrefix("moves-") {
let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -1282,6 +1282,9 @@ actor SyncEngine {
/// push so future inbound fetches resume adopting server fields. Called
/// from `handleSentRecordZoneChanges` only on confirmed saves — *not* from
/// the oplock-recovery path, which only adopts a fresh etag for retry.
+ /// Also clears `hasPushPending`, the one-shot flag that forces the next
+ /// push to re-include the `puzzleSource` asset (used by the NYT re-upgrade
+ /// path to replace an already-uploaded puzzle).
private nonisolated func clearPendingSaveFlag(
for recordName: String,
in ctx: NSManagedObjectContext
@@ -1291,6 +1294,7 @@ actor SyncEngine {
req.fetchLimit = 1
guard let entity = try? ctx.fetch(req).first else { return }
entity.hasPendingSave = false
+ entity.hasPushPending = false
}
// MARK: - Logging helpers
diff --git a/Tests/Unit/NYTPuzzleUpgraderTests.swift b/Tests/Unit/NYTPuzzleUpgraderTests.swift
@@ -0,0 +1,153 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("NYTPuzzleUpgrader")
+struct NYTPuzzleUpgraderTests {
+
+ // MARK: - Fixtures
+
+ /// 3×3 open grid with default solutions ABC/DEF/GHI. Override `gridRows`
+ /// for solution-diff tests; clue text overrides are independent so the
+ /// verifier sees a clue-only change.
+ private func openGridSource(
+ clueAcross1: String = "Across one",
+ clueAcross4: String = "Across four",
+ clueAcross5: String = "Across five",
+ clueDown1: String = "Down one",
+ clueDown2: String = "Down two",
+ clueDown3: String = "Down three",
+ gridRows: [String] = ["ABC", "DEF", "GHI"]
+ ) -> String {
+ precondition(gridRows.count == 3 && gridRows.allSatisfy { $0.count == 3 })
+ let metadata = """
+ Title: Test
+ Publisher: New York Times
+ Date: 2025-01-26
+ """
+ let grid = gridRows.joined(separator: "\n")
+ func col(_ idx: Int) -> String {
+ String(gridRows.map { Array($0)[idx] })
+ }
+ let clues = """
+ A1. \(clueAcross1) ~ \(gridRows[0])
+ A4. \(clueAcross4) ~ \(gridRows[1])
+ A5. \(clueAcross5) ~ \(gridRows[2])
+
+ D1. \(clueDown1) ~ \(col(0))
+ D2. \(clueDown2) ~ \(col(1))
+ D3. \(clueDown3) ~ \(col(2))
+ """
+ return [metadata, grid, clues].joined(separator: "\n\n\n")
+ }
+
+ /// 2×2 open grid for the dimensions-diff test.
+ private func smallGridSource() -> String {
+ let metadata = """
+ Title: Small
+ Publisher: New York Times
+ Date: 2025-01-26
+ """
+ let grid = "AB\nCD"
+ let clues = """
+ A1. Top ~ AB
+ A3. Bottom ~ CD
+
+ D1. Left ~ AC
+ D2. Right ~ BD
+ """
+ return [metadata, grid, clues].joined(separator: "\n\n\n")
+ }
+
+ /// 3×3 grid with a single block at (0,2). Manually written because the
+ /// open-grid helper assumes every cell is open.
+ private func blockedSource() -> String {
+ let metadata = """
+ Title: Blocked
+ Publisher: New York Times
+ Date: 2025-01-26
+ """
+ let grid = "AB#\nDEF\nGHI"
+ let clues = """
+ A1. Top ~ AB
+ A3. Middle ~ DEF
+ A5. Bottom ~ GHI
+
+ D1. Left ~ ADG
+ D2. Mid ~ BEH
+ D4. Right ~ FI
+ """
+ return [metadata, grid, clues].joined(separator: "\n\n\n")
+ }
+
+ // MARK: - Outcomes
+
+ @Test("Clue-only diff is .upgraded — grid + solutions match")
+ func clueOnlyDiffIsUpgraded() async {
+ let old = openGridSource(clueAcross1: "[aria-label] Old prefix")
+ let new = openGridSource(clueAcross1: "Old prefix")
+ let outcome = await NYTPuzzleUpgrader.upgrade(
+ date: Date(),
+ currentSource: old,
+ fetch: { _ in new }
+ )
+ guard case .upgraded(let returned) = outcome else {
+ Issue.record("Expected .upgraded but got \(outcome)")
+ return
+ }
+ #expect(returned == new)
+ }
+
+ @Test("Solution diff is .mismatched")
+ func solutionDiffIsMismatched() async {
+ let old = openGridSource()
+ let new = openGridSource(gridRows: ["ABC", "DEF", "GHJ"])
+ let outcome = await NYTPuzzleUpgrader.upgrade(
+ date: Date(),
+ currentSource: old,
+ fetch: { _ in new }
+ )
+ if case .mismatched = outcome { return }
+ Issue.record("Expected .mismatched but got \(outcome)")
+ }
+
+ @Test("Block-pattern diff is .mismatched")
+ func blockPatternDiffIsMismatched() async {
+ let old = openGridSource()
+ let new = blockedSource()
+ let outcome = await NYTPuzzleUpgrader.upgrade(
+ date: Date(),
+ currentSource: old,
+ fetch: { _ in new }
+ )
+ if case .mismatched = outcome { return }
+ Issue.record("Expected .mismatched but got \(outcome)")
+ }
+
+ @Test("Dimensions diff is .mismatched")
+ func dimensionsDiffIsMismatched() async {
+ let old = openGridSource()
+ let new = smallGridSource()
+ let outcome = await NYTPuzzleUpgrader.upgrade(
+ date: Date(),
+ currentSource: old,
+ fetch: { _ in new }
+ )
+ if case .mismatched = outcome { return }
+ Issue.record("Expected .mismatched but got \(outcome)")
+ }
+
+ @Test("Fetcher throwing is .failed")
+ func fetcherThrowingIsFailed() async {
+ struct StubError: Error {}
+ let old = openGridSource()
+ let outcome = await NYTPuzzleUpgrader.upgrade(
+ date: Date(),
+ currentSource: old,
+ fetch: { _ in throw StubError() }
+ )
+ if case .failed = outcome { return }
+ Issue.record("Expected .failed but got \(outcome)")
+ }
+}
diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift
@@ -470,6 +470,24 @@ struct NYTToXDConverterTests {
}
}
+ @Test("[aria-label] prefix is stripped from clue text")
+ func ariaLabelPrefixStripped() throws {
+ let data = try puzzleJSON(
+ relatives: [nil, nil, nil, nil, nil, nil],
+ clueTexts: [
+ 0: "[aria-label] Circled letter + walking stick",
+ 3: "[ARIA-LABEL] Circled letter + map line",
+ 4: "Normal clue, no prefix"
+ ]
+ )
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+ #expect(xd.contains("A1. Circled letter + walking stick ~ ABC"))
+ #expect(xd.contains("D1. Circled letter + map line ~ ADG"))
+ #expect(xd.contains("D2. Normal clue, no prefix ~ BEH"))
+ #expect(!xd.contains("[aria-label]"))
+ #expect(!xd.lowercased().contains("[aria-label]"))
+ }
+
@Test("Slash-separated clue references link all mentioned clues")
func slashSeparatedClueReferencesLinkAllMentionedClues() throws {
let data = try puzzleJSON(