diff --git a/Packages/GameAppKit/Sources/GameAppKit/UITestLaunchArg.swift b/Packages/GameAppKit/Sources/GameAppKit/UITestLaunchArg.swift index bef7108f..cad44ba5 100644 --- a/Packages/GameAppKit/Sources/GameAppKit/UITestLaunchArg.swift +++ b/Packages/GameAppKit/Sources/GameAppKit/UITestLaunchArg.swift @@ -176,6 +176,17 @@ public enum UITestLaunchArg { /// per `LiveAdMobBridge`) still loads and renders for real. Absent from /// Release builds via the `#if DEBUG` guard. public static let openAdGate = "-uitest-open-ad-gate" + + /// #1054: shared `-uitest-route` screen key both apps resolve to their + /// own FIXED, pre-built mid-game board — a deterministic fixture for App + /// Store marketing captures (03-board slot). Distinct from Minesweeper's + /// `board:` key (a FRESH, empty board at a fixed seed): this + /// key always resolves to the SAME one pre-populated scenario per app + /// (Minesweeper: a revealed region + 3 flags on real mines; Sudoku: + /// pencil notes + one conflicting entry). Held here (not duplicated per + /// app) so both `uitestRoute(for:)` resolvers compare against the same + /// string. Absent from Release builds via the `#if DEBUG` guard. + public static let showcaseBoardRouteKey = "board:showcase" } #endif diff --git a/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/MinesweeperAppComposition.swift b/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/MinesweeperAppComposition.swift index 251df2da..ff85e757 100644 --- a/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/MinesweeperAppComposition.swift +++ b/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/MinesweeperAppComposition.swift @@ -98,7 +98,7 @@ public struct MinesweeperAppComposition { /// #1020: `daily` / `practice` used to be push routes; they are tab /// identities now, so both just select their tab — `settings` still /// pushes onto the Today tab's stack. Unknown keys stay at the root. Keys: - /// `daily` / `practice` / `settings` / `resumeFail` / + /// `daily` / `practice` / `settings` / `resumeFail` / `board:showcase` / /// `board:` (#1026 B-1: zero-click straight /// into a practice board at that difficulty, fixed seed above, pushed on /// the Practice tab so the runner sees it where a Start tap would land it). @@ -115,6 +115,19 @@ public struct MinesweeperAppComposition { // read when `-uitest-loader-fail` is also passed — the hook short- // circuits `load()` before `store.loadInProgress` is called. case "resumeFail": return .push(.resumeBoard(recordName: "uitest-loader-fail", mode: .practice)) + // #1054: checked BEFORE the `board:` prefix fallback below so it can + // never be shadowed by (or accidentally fall through into) the + // `board:` parsing — `Difficulty(rawValue: "showcase")` + // is nil regardless, but the explicit case makes that non-shadowing + // a compile-time-adjacent guarantee, not a coincidence of the + // fallback's own rejection logic. Fixed seed lives on + // `MinesweeperShowcaseSession`, not `uitestBoardSeed` — this route + // always lands on the ONE pre-built showcase board, never a fresh one. + case UITestLaunchArg.showcaseBoardRouteKey: + return .push( + .board(difficulty: .beginner, seed: MinesweeperShowcaseSession.seed, mode: .practice), + tab: .practice + ) default: // #1026 B-1: `board:` — macOS has no synthetic tap // path to the practice hub's Start button, so this is the only diff --git a/Packages/MinesweeperKit/Sources/MinesweeperUI/MinesweeperFreshBoardLoaderView.swift b/Packages/MinesweeperKit/Sources/MinesweeperUI/MinesweeperFreshBoardLoaderView.swift index 298618fc..f229ff42 100644 --- a/Packages/MinesweeperKit/Sources/MinesweeperUI/MinesweeperFreshBoardLoaderView.swift +++ b/Packages/MinesweeperKit/Sources/MinesweeperUI/MinesweeperFreshBoardLoaderView.swift @@ -211,7 +211,17 @@ public struct MinesweeperFreshBoardLoaderView: View { recordName: String? = nil, personalRecordStore: MinesweeperPersonalRecordStore? = nil ) -> MinesweeperGameViewModel { - MinesweeperGameViewModel( + #if DEBUG + // #1054: the showcase route's fixed (difficulty, seed) pair mounts + // the pre-built deterministic fixture instead of a fresh + // actor-backed board. See `MinesweeperShowcaseSession`'s header doc + // for why this must go through the `(seeded:)` init, not a session + // restore. Absent from Release builds via the `#if DEBUG` guard. + if difficulty == .beginner, seed == MinesweeperShowcaseSession.seed { + return MinesweeperGameViewModel(seeded: MinesweeperShowcaseSession.snapshot) + } + #endif + return MinesweeperGameViewModel( difficulty: difficulty, seed: seed, mode: mode, diff --git a/Packages/MinesweeperKit/Sources/MinesweeperUI/MinesweeperGameViewModel.swift b/Packages/MinesweeperKit/Sources/MinesweeperUI/MinesweeperGameViewModel.swift index 18b8a654..587fa798 100644 --- a/Packages/MinesweeperKit/Sources/MinesweeperUI/MinesweeperGameViewModel.swift +++ b/Packages/MinesweeperKit/Sources/MinesweeperUI/MinesweeperGameViewModel.swift @@ -266,6 +266,13 @@ public final class MinesweeperGameViewModel { } public func reveal(row: Int, col: Int) async { + // #1054 CR round 1 (blocker): a seeded VM's `session` is a throwaway + // actor at the snapshot's difficulty (see `init(seeded:)`) — routing + // a real tap through it would silently replace the fixed showcase + // board with a fresh seed-0 game on first touch. Every mutator that + // would touch `session` must no-op when seeded; `refresh()` already + // does (see its doc), this one didn't. + guard !isSeeded else { return } // #330 P2: snapshot the prior status + revealed-count so we can classify // the outcome of this reveal into the right audio event. let previousStatus = snapshot.status @@ -322,6 +329,8 @@ public final class MinesweeperGameViewModel { // file under the 400-line lint ceiling). public func toggleFlag(row: Int, col: Int) async { + // #1054 CR round 1 (blocker): see `reveal(row:col:)` — same reasoning. + guard !isSeeded else { return } let flagsBefore = snapshot.flagCount do { snapshot = try await session.toggleFlag(row: row, col: col) diff --git a/Packages/MinesweeperKit/Sources/MinesweeperUI/MinesweeperShowcaseSession.swift b/Packages/MinesweeperKit/Sources/MinesweeperUI/MinesweeperShowcaseSession.swift new file mode 100644 index 00000000..181805a9 --- /dev/null +++ b/Packages/MinesweeperKit/Sources/MinesweeperUI/MinesweeperShowcaseSession.swift @@ -0,0 +1,157 @@ +// MinesweeperShowcaseSession — DEBUG-only fixed showcase board (#1054). +// +// Produces a deterministic, byte-stable mid-game board for the App Store +// 03-board marketing capture: a revealed region (flood-filled from a fixed +// first click) plus 3 flags planted on real mines. +// +// Deliberate shape deviation from the sibling `MinesweeperNearWinSession`: +// that builder restores a `MinesweeperSession` ACTOR (`.restore(from:)`, +// always normalized to `.paused`) so the player's one remaining tap can win +// the game. This builder instead mounts via +// `MinesweeperGameViewModel(seeded:)` — the #297 snapshot/preview seam — for +// two reasons specific to a marketing capture: +// 1. `.paused` mounts `PauseOverlayView`, a full-screen mask that hides the +// board entirely (`MinesweeperBoardView.swift`) — the opposite of what a +// store screenshot needs. +// 2. A LIVE `.playing` session's `elapsedSeconds` advances off a real +// `MonotonicClock`; two captures taken seconds apart would show +// different times and fail the required byte-identical hash check +// (#1054 acceptance item 6). `(seeded:)` sets `isSeeded = true`, which +// makes `MinesweeperBoardView`'s `.task { refresh() }` ticker a no-op — +// the cached snapshot (and its `elapsedSeconds`) never changes, no +// matter how long the board stays on screen. +// +// Accepted asymmetry vs. `SudokuShowcaseBoard` (CR round 2, PM ruling): Sudoku's +// builder goes through the LIVE `GameSession` actor with an injected frozen +// clock instead. `MinesweeperSession.restore(from:clock:)` does expose a +// `clock:` parameter, but `applySnapshot` unconditionally normalizes a +// restored `.playing` to `.paused`, and the only way back to a genuinely +// `.playing` state is an async `resume()` call on the actor. +// `MinesweeperFreshBoardLoaderView.makeViewModel(...)` — the seam this +// builder is injected into — is a synchronous function (`.task(id:) { state = +// .loaded(makeViewModel()) }`), so wiring in that async restore+resume dance +// would mean restructuring the loader's synchronous seam. Sudoku's +// `BoardLoaderView.mountLoaded` is already `async` (it awaits +// `GameSession.restore` + `viewModel.startOrResume()`), so the same +// frozen-clock + resume approach is cheap there instead — see that file's +// header doc for the full reasoning. +// +// Availability: `#if DEBUG` only — stripped from Release builds entirely. + +#if DEBUG + +internal import MinesweeperEngine +public import MinesweeperGameState + +/// A fixed, deterministic mid-game Minesweeper board. See the header doc +/// above for why this is a plain snapshot builder, not a session restore. +public enum MinesweeperShowcaseSession { + + /// Fixed seed for the showcase board. Distinct from + /// `MinesweeperAppComposition.uitestBoardSeed` (`0x5EED_B1`, a FRESH + /// board for the `board:` key) and + /// `MinesweeperNearWinSession.nearWinSeed` (`0xAABB_CCDD_EEFF_0011`). + /// #1054 CR round 2 (PM ruling): chosen, together with + /// `firstClickRow`/`firstClickCol` below, by a brute-force search over + /// nearby seeds × first-click cells for a combination whose flood-reveal + /// opens a region with at least 3 mines FORCED by the basic + /// single-constraint count rule (see `flagsQualifyingAsForced(engine:)`) + /// — this exact seed + cell yields 7 such forced mines (comfortably over + /// the 3 flagged) with a 20-cell revealed region. + /// `MinesweeperShowcaseSessionTests` pins the result. + public static let seed: UInt64 = 0xC0FF_EE15_5C0B_0008 + + /// First-click cell — bottom-center of the beginner grid. Fixed + /// alongside `seed` (see its doc): the combination is what produces the + /// 20-cell revealed region / 7 forced mines. + private static let firstClickRow = 8 + private static let firstClickCol = 4 + + /// Frozen elapsed-time value baked into the snapshot. Never advances — + /// see the header doc's determinism rationale. + private static let elapsedSeconds = 42 + + /// Number of real mines flagged in the fixed scenario. + private static let flaggedMineCount = 3 + + /// The fixed showcase snapshot. A `static let` initializer runs lazily, + /// exactly once per process, on first access — so every caller + /// (the loader, tests) observes the identical value. + public static let snapshot: MinesweeperSessionSnapshot = { + let difficulty = Difficulty.beginner + var engine = MinesweeperEngine(difficulty: difficulty, seed: seed) + // First-click-safe placement, then a real flood-reveal of the + // connected zero region from `firstClickRow`/`firstClickCol` — never + // hand-set state. + _ = try? engine.reveal(row: firstClickRow, col: firstClickCol) + + // Flag up to `flaggedMineCount` real, still-hidden mines via the + // engine's own `toggleFlag` — never by hand-writing `Cell.state`. + // #1054 CR round 2 (PM ruling, strengthens round 1's adjacency-only + // check): a flag must be FORCED by the basic single-constraint count + // rule — there must exist a revealed neighbor cell whose displayed + // number equals ITS OWN count of non-revealed (hidden or flagged) + // neighbors, which makes every one of that revealed cell's + // non-revealed neighbors (this candidate included) a certain mine. + // Mere adjacency to a revealed cell is not enough (a "2" with 3 + // non-revealed neighbors doesn't pin any single one of them). + // Row-major among the qualifying set for determinism; if fewer than + // `flaggedMineCount` qualify, only those are flagged — never falls + // back to an unforced mine. + var flaggedCount = 0 + for index in engine.cells.indices where flaggedCount < flaggedMineCount { + let cell = engine.cells[index] + guard cell.isMine, cell.state == .hidden else { continue } + guard hasForcingWitness(atIndex: index, engine: engine) else { continue } + let row = index / engine.columns + let col = index % engine.columns + _ = try? engine.toggleFlag(row: row, col: col) + flaggedCount += 1 + } + + return MinesweeperSessionSnapshot( + difficulty: difficulty, + seed: seed, + cells: engine.cells, + status: .playing, + elapsedSeconds: elapsedSeconds, + mineCount: engine.mineCount, + flagCount: flaggedCount, + everFlagged: flaggedCount > 0 + ) + }() + + /// True iff some revealed neighbor of `index` "forces" it as a mine by + /// the basic single-constraint count rule: that neighbor's displayed + /// `neighborMineCount` equals its OWN count of non-revealed (hidden or + /// flagged) neighboring cells — meaning every one of those non-revealed + /// neighbors, `index` included, must be a mine. `internal` (not + /// `private`) so `MinesweeperShowcaseSessionTests` can independently + /// recompute the witness for each flag from the built board, rather than + /// hard-coding coordinates. + static func hasForcingWitness(atIndex index: Int, engine: MinesweeperEngine) -> Bool { + forcingWitness(atIndex: index, engine: engine) != nil + } + + /// The board index of a revealed cell that forces `index` as a mine (see + /// `hasForcingWitness`), or `nil` if none exists. + static func forcingWitness(atIndex index: Int, engine: MinesweeperEngine) -> Int? { + let row = index / engine.columns + let col = index % engine.columns + for neighborIndex in engine.neighborIndices(row: row, col: col) { + let neighbor = engine.cells[neighborIndex] + guard neighbor.state == .revealed else { continue } + let neighborRow = neighborIndex / engine.columns + let neighborCol = neighborIndex % engine.columns + let nonRevealedCount = engine.neighborIndices(row: neighborRow, col: neighborCol) + .filter { engine.cells[$0].state != .revealed } + .count + if neighbor.neighborMineCount == nonRevealedCount { + return neighborIndex + } + } + return nil + } +} + +#endif diff --git a/Packages/MinesweeperKit/Tests/MinesweeperUITests/MinesweeperAppCompositionTests.swift b/Packages/MinesweeperKit/Tests/MinesweeperUITests/MinesweeperAppCompositionTests.swift index 03793c26..671177dc 100644 --- a/Packages/MinesweeperKit/Tests/MinesweeperUITests/MinesweeperAppCompositionTests.swift +++ b/Packages/MinesweeperKit/Tests/MinesweeperUITests/MinesweeperAppCompositionTests.swift @@ -146,5 +146,37 @@ import Telemetry // pre-existing exact-match keys. #expect(MinesweeperAppComposition.uitestRoute(for: "practice") == .tab(.practice)) } + + // #1054: `board:showcase` — the fixed pre-built board for App Store + // marketing captures. Distinct from the `board:` branch + // above (a fresh board at `uitestBoardSeed`). + + @Test func uitestRouteBoardShowcaseResolvesToFixedShowcaseBoard() { + #expect( + MinesweeperAppComposition.uitestRoute(for: "board:showcase") + == .push( + .board(difficulty: .beginner, seed: MinesweeperShowcaseSession.seed, mode: .practice), + tab: .practice + ) + ) + } + + @Test func uitestRouteBoardShowcaseDoesNotShadowDifficultyPrefixBranch() { + // Regression: `board:showcase` must be handled by its own explicit + // case, never by falling into the `board:` prefix + // parsing — and that branch must still resolve `board:beginner` to + // the FRESH-board seed, not the showcase seed. + #expect( + MinesweeperAppComposition.uitestRoute(for: "board:beginner") + == .push( + .board(difficulty: .beginner, seed: MinesweeperAppComposition.uitestBoardSeed, mode: .practice), + tab: .practice + ) + ) + } + + @Test func uitestRouteBoardShowcaseRejectsTrailingWhitespace() { + #expect(MinesweeperAppComposition.uitestRoute(for: "board:showcase ") == nil) + } #endif } diff --git a/Packages/MinesweeperKit/Tests/MinesweeperUITests/MinesweeperShowcaseSessionTests.swift b/Packages/MinesweeperKit/Tests/MinesweeperUITests/MinesweeperShowcaseSessionTests.swift new file mode 100644 index 00000000..86ac4569 --- /dev/null +++ b/Packages/MinesweeperKit/Tests/MinesweeperUITests/MinesweeperShowcaseSessionTests.swift @@ -0,0 +1,128 @@ +// MinesweeperShowcaseSessionTests — verifies the DEBUG showcase-board +// builder produces a deterministic, marketing-ready mid-game board (#1054). +// +// Assertions: +// 1. Exactly 3 flagged cells, all real mines (never a false flag). +// 2. Every flag has a FORCING WITNESS — CR round 2 (PM ruling, strengthens +// round 1's adjacency-only check): a revealed neighbor cell whose +// displayed number equals its own count of non-revealed neighbors, +// which pins every one of those non-revealed neighbors (the flag +// included) as a certain mine by the basic single-constraint count +// rule. Computed fresh from the built board for each flag — no +// hard-coded coordinates — so a future seed/algorithm change that +// breaks the property fails this test instead of silently shipping a +// guessed flag. +// 3. At least one revealed cell (the board looks "played", not fresh). +// 4. Status is `.playing` (board fully visible, no pause overlay). +// 5. The snapshot is deterministic across every access. +// 6. CR round 1 (blocker): a seeded `MinesweeperGameViewModel`'s `reveal` / +// `toggleFlag` must be no-ops — proving `isSeeded` actually gates every +// mutator, not just `refresh()`. +// +// Tests run in DEBUG only (the builder is not compiled in Release). + +#if DEBUG + +import Testing +@testable import MinesweeperUI +import MinesweeperEngine +import MinesweeperGameState + +@Suite("MinesweeperShowcaseSession") +struct MinesweeperShowcaseSessionTests { + + @Test("snapshot flags exactly 3 real mines") + func showcaseSnapshotFlagsThreeRealMines() { + let snapshot = MinesweeperShowcaseSession.snapshot + let flaggedIndices = snapshot.cells.indices.filter { snapshot.cells[$0].state == .flagged } + #expect(flaggedIndices.count == 3, "expected 3 flagged cells, got \(flaggedIndices.count)") + for index in flaggedIndices { + #expect(snapshot.cells[index].isMine, "flagged cell at \(index) must be a real mine") + } + } + + @Test("every flag has a forcing witness (basic single-constraint count rule)") + func showcaseSnapshotFlagsAreForcedNotGuesses() { + let snapshot = MinesweeperShowcaseSession.snapshot + // Reconstruct an engine from the snapshot's own cells (mines already + // placed) so the witness is computed against the EXACT built board, + // not re-derived from a fresh reveal — this is what the production + // builder itself checked before flagging. + let engine = MinesweeperEngine( + difficulty: snapshot.difficulty, + seed: snapshot.seed, + cells: snapshot.cells, + minesPlaced: true, + isLost: false + ) + let columns = snapshot.columns + let flaggedIndices = snapshot.cells.indices.filter { snapshot.cells[$0].state == .flagged } + for index in flaggedIndices { + let row = index / columns + let col = index % columns + guard let witnessIndex = MinesweeperShowcaseSession.forcingWitness(atIndex: index, engine: engine) else { + Issue.record("flag at (\(row),\(col)) has NO forcing witness — it is a guess, not a deduction") + continue + } + let witnessRow = witnessIndex / columns + let witnessCol = witnessIndex % columns + let witnessCount = engine.cells[witnessIndex].neighborMineCount + #expect( + engine.cells[witnessIndex].state == .revealed, + "witness (\(witnessRow),\(witnessCol)) for flag at (\(row),\(col)) must be revealed" + ) + print("flag (\(row),\(col)) witness=(\(witnessRow),\(witnessCol)) count=\(witnessCount)") + } + } + + @Test("snapshot has at least one revealed cell") + func showcaseSnapshotHasRevealedCells() { + let snapshot = MinesweeperShowcaseSession.snapshot + let revealedCount = snapshot.cells.filter { $0.state == .revealed }.count + #expect(revealedCount > 0, "expected at least one revealed cell, got 0") + } + + @Test("snapshot status is .playing") + func showcaseSnapshotIsPlaying() { + #expect(MinesweeperShowcaseSession.snapshot.status == .playing) + } + + @Test("snapshot is deterministic across every access") + func showcaseSnapshotIsDeterministic() { + let first = MinesweeperShowcaseSession.snapshot + let second = MinesweeperShowcaseSession.snapshot + #expect(first == second, "showcase snapshot must be bit-identical across accesses") + } + + @MainActor + @Test("a seeded VM's reveal(row:col:) is a no-op") + func seededViewModelRevealIsNoOp() async { + let viewModel = MinesweeperGameViewModel(seeded: MinesweeperShowcaseSession.snapshot) + let before = viewModel.snapshot + // Target a cell that is NOT already revealed/flagged, so a real + // mutation (were the guard missing) would visibly change the board. + let columns = before.columns + guard let targetIndex = before.cells.indices.first(where: { before.cells[$0].state == .hidden }) else { + Issue.record("fixture has no hidden cell to target") + return + } + await viewModel.reveal(row: targetIndex / columns, col: targetIndex % columns) + #expect(viewModel.snapshot == before, "seeded VM's reveal(row:col:) must leave the board bit-identical") + } + + @MainActor + @Test("a seeded VM's toggleFlag(row:col:) is a no-op") + func seededViewModelToggleFlagIsNoOp() async { + let viewModel = MinesweeperGameViewModel(seeded: MinesweeperShowcaseSession.snapshot) + let before = viewModel.snapshot + let columns = before.columns + guard let targetIndex = before.cells.indices.first(where: { before.cells[$0].state == .hidden }) else { + Issue.record("fixture has no hidden cell to target") + return + } + await viewModel.toggleFlag(row: targetIndex / columns, col: targetIndex % columns) + #expect(viewModel.snapshot == before, "seeded VM's toggleFlag(row:col:) must leave the board bit-identical") + } +} + +#endif diff --git a/Packages/SudokuKit/Sources/SudokuAppComposition/Live.swift b/Packages/SudokuKit/Sources/SudokuAppComposition/Live.swift index b8270742..56f1c064 100644 --- a/Packages/SudokuKit/Sources/SudokuAppComposition/Live.swift +++ b/Packages/SudokuKit/Sources/SudokuAppComposition/Live.swift @@ -245,7 +245,12 @@ extension SudokuAppComposition { // completion route (N12) — see `resolvePersistence`. A no-op // pass-through outside DEBUG / without the matching launch arg, so // this is `deps.persistence` itself in every production launch. - let persistence = resolvePersistence(live: deps.persistence, puzzleProvider: puzzleProvider) + // #1054: layered with `resolveShowcasePersistence` (same injection + // point) — DEBUG-only, gated on a DIFFERENT launch arg + // (`-uitest-route board:showcase`), also a pass-through otherwise. + let persistence = resolveShowcasePersistence( + resolvePersistence(live: deps.persistence, puzzleProvider: puzzleProvider) + ) // #968: macOS has zero ads to remove (Google ships no macOS AdMob/UMP // xcframework slice — D-v2-03), so Settings must not offer "Remove // Ads" / "Restore Purchases" there (Guideline 2.1 / 3.1.1 — the IAP diff --git a/Packages/SudokuKit/Sources/SudokuAppComposition/SudokuAppComposition.swift b/Packages/SudokuKit/Sources/SudokuAppComposition/SudokuAppComposition.swift index 734a8821..06ef790e 100644 --- a/Packages/SudokuKit/Sources/SudokuAppComposition/SudokuAppComposition.swift +++ b/Packages/SudokuKit/Sources/SudokuAppComposition/SudokuAppComposition.swift @@ -114,12 +114,20 @@ public struct SudokuAppComposition { /// #510: map a `-uitest-route` screen key to Sudoku's launch target. /// #1020: `daily` / `practice` used to be push routes; they are tab /// identities now, so both just select their tab — `settings` still - /// pushes onto the Today tab's stack. Unknown keys stay at the root. + /// pushes onto the Today tab's stack. #1054: `board:showcase` pushes the + /// ONE fixed, pre-built showcase board (pencil notes + one conflicting + /// entry) for App Store marketing captures — its content comes from + /// `UITestShowcasePersistence` intercepting the board's own + /// `puzzleId` (`SudokuShowcaseBoard.puzzleId`), not from a case here; + /// this just routes to the same `.board` destination a real Start tap + /// would use. Unknown keys stay at the root. static func uitestRoute(for key: String) -> UITestLaunchTarget? { switch key { case "daily": return .tab(.today) case "practice": return .tab(.practice) case "settings": return .push(.settings) + case UITestLaunchArg.showcaseBoardRouteKey: + return .push(.board(puzzleId: SudokuShowcaseBoard.puzzleId), tab: .practice) default: return nil } } diff --git a/Packages/SudokuKit/Sources/SudokuAppComposition/UITestShowcasePersistence.swift b/Packages/SudokuKit/Sources/SudokuAppComposition/UITestShowcasePersistence.swift new file mode 100644 index 00000000..460392c0 --- /dev/null +++ b/Packages/SudokuKit/Sources/SudokuAppComposition/UITestShowcasePersistence.swift @@ -0,0 +1,137 @@ +// UITestShowcasePersistence — #1054, DEBUG-only fixed-snapshot injection for +// `PersistenceProtocol`, gated by the `-uitest-route board:showcase` launch +// argument (`GameAppKit.UITestLaunchArg.showcaseBoardRouteKey`). Wraps the +// live persistence and, for the ONE fixed `SudokuShowcaseBoard.puzzleId`, +// hands back the deterministic showcase snapshot instead of touching +// CloudKit — so the App Store 03-board marketing capture is reproducible +// without depending on (or polluting) a real saved game. Every write for +// that id no-ops; every other puzzleId delegates to the wrapped live +// persistence unchanged. Mirrors `UITestSeededCompletedDailyPersistence`'s +// wrapper shape and injection point. Absent from Release builds via the +// `#if DEBUG` guard. + +internal import Foundation +internal import SudokuEngine +internal import SudokuGameState +internal import SudokuPersistence +internal import Persistence +#if DEBUG +internal import GameAppKit +// `SudokuShowcaseBoard` (the fixed snapshot builder + `puzzleId`) lives in +// SudokuUI — only needed for this DEBUG-only wrapper. +internal import SudokuUI +#endif + +#if DEBUG + +/// Wraps a live `PersistenceProtocol`; for `SudokuShowcaseBoard.puzzleId`, +/// `loadOrCreate` / `loadIfExists` return the fixed showcase snapshot and +/// `save` / `markCompleted` for that id no-op — the showcase board must +/// never reach CloudKit. `deleteAbandoned` has no puzzleId to match against +/// and is never reachable from this board's lifecycle (the board never +/// fully fills, so `complete()` never fires, and `save` never writes a +/// record for it to begin with) — delegates unconditionally. +struct UITestShowcasePersistence: PersistenceProtocol { + private let wrapped: any PersistenceProtocol + + init(wrapping wrapped: any PersistenceProtocol) { + self.wrapped = wrapped + } + + func bootstrap() async throws { + try await wrapped.bootstrap() + } + + func latestInProgress() async throws -> SavedGameSummary? { + try await wrapped.latestInProgress() + } + + func loadOrCreate( + puzzleId: String, + mode: Mode, + difficulty: Difficulty + ) async throws -> GameSessionSnapshot { + guard puzzleId == SudokuShowcaseBoard.puzzleId else { + return try await wrapped.loadOrCreate(puzzleId: puzzleId, mode: mode, difficulty: difficulty) + } + return try SudokuShowcaseBoard.snapshot() + } + + func loadIfExists( + puzzleId: String, + mode: Mode, + difficulty: Difficulty + ) async throws -> GameSessionSnapshot? { + guard puzzleId == SudokuShowcaseBoard.puzzleId else { + return try await wrapped.loadIfExists(puzzleId: puzzleId, mode: mode, difficulty: difficulty) + } + return try SudokuShowcaseBoard.snapshot() + } + + func save( + _ snapshot: GameSessionSnapshot, + puzzleId: String, + mode: Mode, + difficulty: Difficulty + ) async throws { + guard puzzleId != SudokuShowcaseBoard.puzzleId else { return } + try await wrapped.save(snapshot, puzzleId: puzzleId, mode: mode, difficulty: difficulty) + } + + func markCompleted(_ summary: SavedGameSummary) async throws { + guard summary.puzzleId != SudokuShowcaseBoard.puzzleId else { return } + try await wrapped.markCompleted(summary) + } + + func deleteAbandoned(recordName: String) async throws { + try await wrapped.deleteAbandoned(recordName: recordName) + } + + func fetchCompletedDailyIds(for date: Date) async throws -> Set { + try await wrapped.fetchCompletedDailyIds(for: date) + } + + func fetchCompletedDailyIdsByDay() async throws -> [String: Set] { + try await wrapped.fetchCompletedDailyIdsByDay() + } + + func fetchPersonalRecord(mode: Mode, difficulty: Difficulty) async throws -> PersonalRecord { + try await wrapped.fetchPersonalRecord(mode: mode, difficulty: difficulty) + } + + func upsertPersonalRecord(_ record: PersonalRecord) async throws { + try await wrapped.upsertPersonalRecord(record) + } + + func recordPuzzleCompletion( + puzzleId: String, + mode: Mode, + difficulty: Difficulty, + elapsedSeconds: Int + ) async throws { + try await wrapped.recordPuzzleCompletion( + puzzleId: puzzleId, + mode: mode, + difficulty: difficulty, + elapsedSeconds: elapsedSeconds + ) + } +} + +#endif + +/// Resolves the `PersistenceProtocol` `SudokuAppComposition.Live` wires, +/// wrapping `persistence` in `UITestShowcasePersistence` under +/// `-uitest-route board:showcase` (DEBUG only) so the fixed showcase board +/// never reaches CloudKit. Always defined (mirrors `resolvePersistence`) so +/// `Live.swift` can call it unconditionally without its own `#if DEBUG`. +func resolveShowcasePersistence(_ persistence: any PersistenceProtocol) -> any PersistenceProtocol { + #if DEBUG + guard UITestLaunchArg.routeValue() == UITestLaunchArg.showcaseBoardRouteKey else { + return persistence + } + return UITestShowcasePersistence(wrapping: persistence) + #else + return persistence + #endif +} diff --git a/Packages/SudokuKit/Sources/SudokuUI/Board/BoardLoaderView.swift b/Packages/SudokuKit/Sources/SudokuUI/Board/BoardLoaderView.swift index 830996c5..d51be5f7 100644 --- a/Packages/SudokuKit/Sources/SudokuUI/Board/BoardLoaderView.swift +++ b/Packages/SudokuKit/Sources/SudokuUI/Board/BoardLoaderView.swift @@ -341,7 +341,22 @@ public struct BoardLoaderView: View { difficulty: identity.difficulty ) } ?? NoOpGameStateTelemetry() + #if DEBUG + // #1054: the showcase board's elapsed time must never tick — two + // captures taken seconds apart must hash byte-identical (App Store + // 03-board marketing slot, acceptance item 6). A live + // `LiveMonotonicClock` would advance between `startOrResume()` below + // and the screenshot; a frozen clock keeps `elapsedSeconds` pinned at + // the snapshot's value forever. Every other puzzleId is byte-for-byte + // unchanged (still `LiveMonotonicClock`). See `SudokuShowcaseBoard`'s + // header doc. Absent from Release builds via the `#if DEBUG` guard. + let clock: any MonotonicClock = puzzleId == SudokuShowcaseBoard.puzzleId + ? UITestFrozenMonotonicClock() + : LiveMonotonicClock() + let session = await GameSession.restore(from: snapshot, clock: clock, telemetry: gameTelemetry) + #else let session = await GameSession.restore(from: snapshot, telemetry: gameTelemetry) + #endif let viewModel = GameViewModel( identity: identity, session: session, diff --git a/Packages/SudokuKit/Sources/SudokuUI/Board/SudokuShowcaseBoard.swift b/Packages/SudokuKit/Sources/SudokuUI/Board/SudokuShowcaseBoard.swift new file mode 100644 index 00000000..a68fc04a --- /dev/null +++ b/Packages/SudokuKit/Sources/SudokuUI/Board/SudokuShowcaseBoard.swift @@ -0,0 +1,209 @@ +// SudokuShowcaseBoard — DEBUG-only fixed showcase board (#1054). +// +// Produces a deterministic `GameSessionSnapshot` for the App Store 03-board +// marketing capture: a puzzle with several correct fills, pencil-mark +// candidates on a few blank cells, and EXACTLY ONE conflicting entry so the +// board's red error highlight is visible. +// +// Unlike `SudokuNearWinBoard` (which builds a live `GameViewModel` directly +// and is presented through a root-level modifier), this board is reached +// through the PRODUCTION `.board(puzzleId:)` route — `UITestShowcasePersistence` +// (SudokuAppComposition) intercepts `puzzleId` and hands `BoardLoaderView` +// this fixed snapshot, exactly like a real saved game. That keeps the +// captured frame's chrome (toolbar, glass) identical to what Start would +// produce, at the cost of going through the LIVE `GameSession` actor. +// +// Determinism (#1054 acceptance item 6 — two captures must hash +// byte-identical): a live, genuinely `.playing` session's `elapsedSeconds` +// advances off a real wall clock. `BoardLoaderView.mountLoaded` restores this +// board's session with `UITestFrozenMonotonicClock` (below) instead of the +// default `LiveMonotonicClock` — `elapsedSeconds` then stays pinned at the +// snapshot's value forever, no matter how long the board stays on screen. +// +// Asymmetry vs. `MinesweeperShowcaseSession` (CR round 1/2, accepted as-is): +// that builder skips the actor entirely (`MinesweeperGameViewModel(seeded:)`) +// rather than mirroring this file's live-session + frozen-clock approach. +// Two reasons: (1) Minesweeper has no route that reaches +// `MinesweeperFreshBoardLoaderView` other than a direct +// `MinesweeperGameViewModel` construction — there is no persistence-protocol +// interception point upstream of it the way Sudoku's `BoardLoaderView` has +// (every `.board(puzzleId:)` funnels through +// `PersistenceProtocol.loadIfExists`/`loadOrCreate` first), so Sudoku's +// fixture rides that existing interception point instead of adding a new +// one. (2) `MinesweeperSession.restore(from:clock:)` DOES expose a `clock:` +// parameter, but its `applySnapshot` unconditionally normalizes a restored +// `.playing` to `.paused`, and the only way back to a genuinely `.playing` +// state is an async `resume()` call on the actor — +// `MinesweeperFreshBoardLoaderView.makeViewModel(...)` (the seam the +// showcase snapshot is injected into) is a synchronous function, so wiring +// that async dance in would mean restructuring the loader's synchronous +// seam. `BoardLoaderView.mountLoaded` here is already `async`, so the same +// frozen-clock + resume approach costs nothing extra. +// +// Availability: `#if DEBUG` only — stripped from Release builds entirely. + +#if DEBUG + +import Foundation +public import SudokuGameState +import SudokuEngine +import SudokuPersistence + +public enum SudokuShowcaseBoard { + + /// The puzzleId `UITestShowcasePersistence` matches on and + /// `SudokuAppComposition.uitestRoute(for:)` routes to. + public static let puzzleId = "uitest-showcase" + + /// Fixed seed for the showcase puzzle. Distinct from + /// `SudokuNearWinBoard.nearWinSeed` (`0x5544_3322_1100_9988`) and + /// collision-free with `PuzzleStore`'s stableHash derivation paths + /// (those fold in "daily" / "practice" prefix strings; this does not). + public static let seed: UInt64 = 0x1DEA_B0AD_5C0B_1054 + + /// Number of non-given cells filled with their correct solution digit. + private static let correctFillCount = 8 + + /// Number of blank cells given pencil-mark candidates. + private static let noteCellCount = 4 + + /// Frozen elapsed-time value baked into the snapshot. See the header doc + /// for why this never actually advances once mounted. + private static let elapsedSeconds = 96 + + /// Build the fixed showcase snapshot: the puzzle's givens, plus + /// `correctFillCount` cells filled with their true solution digit, plus + /// EXACTLY ONE cell holding a digit that conflicts with a GIVEN peer — + /// copying a peer that is itself one of the correct fills would flag + /// that peer too, since `GameViewModel.recomputeErrors()` only excludes + /// givens — plus `noteCellCount` blank cells carrying legal pencil-mark + /// candidates (computed from the actual final board, never hand-picked). + /// + /// The wrong cell is chosen FIRST, purely from the puzzle's givens (never + /// from a correct fill), and every correct fill is then chosen to + /// EXCLUDE the wrong cell's own row/column/box peers — a peer holding + /// its true solution digit could, in principle, coincidentally equal the + /// wrong digit (a value shared by two DIFFERENT units of the wrong cell + /// that never conflict with each other), which would flag that peer too + /// and break the "exactly one conflict" guarantee. Excluding all of the + /// wrong cell's peers from the correct-fill pool removes that + /// possibility entirely, not just the digit-collision case. + public static func snapshot() throws -> GameSessionSnapshot { + let generator = LivePuzzleGenerating() + let puzzle = try generator.generate(seed: seed, difficulty: .easy, version: .v1) + + let nonGivenIndices = (0.. [Int] { + let row = index / Board.dimension + let col = index % Board.dimension + var peers: [Int] = [] + for col2 in 0.. Int? { + for peerIndex in peerIndices(ofIndex: index) { + if board.givenMask[peerIndex], let digit = board.digit(atIndex: peerIndex) { return digit } + } + return nil + } + + /// Digits 1...9 that do not already appear among `index`'s peers in + /// `board` — i.e. digits legal to pencil there right now. + private static func legalCandidates(atIndex index: Int, in board: Board) -> [Int] { + let present = Set(peerIndices(ofIndex: index).compactMap { board.digit(atIndex: $0) }) + return (1...9).filter { !present.contains($0) } + } +} + +enum SudokuShowcaseBoardError: Error { + case notEnoughBlankCells + case noConflictableCell +} + +// MARK: - Frozen clock (determinism — #1054) + +/// A `MonotonicClock` that never advances. Injected by +/// `BoardLoaderView.mountLoaded` ONLY for `SudokuShowcaseBoard.puzzleId` so +/// the mounted board's `elapsedSeconds` stays pinned at the snapshot's value +/// forever — see this file's header doc. +struct UITestFrozenMonotonicClock: MonotonicClock { + var now: TimeInterval { 0 } +} + +#endif diff --git a/Packages/SudokuKit/Tests/SudokuAppCompositionTests/SudokuUITestRouteTests.swift b/Packages/SudokuKit/Tests/SudokuAppCompositionTests/SudokuUITestRouteTests.swift new file mode 100644 index 00000000..21187ac9 --- /dev/null +++ b/Packages/SudokuKit/Tests/SudokuAppCompositionTests/SudokuUITestRouteTests.swift @@ -0,0 +1,46 @@ +// SudokuUITestRouteTests — coverage for `SudokuAppComposition.uitestRoute(for:)`, +// the DEBUG-only `-uitest-route` key resolver (#510 / #1020 / #1054). No +// prior test covered this resolver directly (unlike Minesweeper's +// `MinesweeperAppCompositionTests`); this fills that gap and adds the new +// #1054 `board:showcase` key. + +import Testing +@testable import SudokuAppComposition +import GameAppKit +import SudokuUI + +#if DEBUG + +@MainActor +@Suite("SudokuAppComposition.uitestRoute(for:)") +struct SudokuUITestRouteTests { + + @Test func dailyResolvesToTodayTab() { + #expect(SudokuAppComposition.uitestRoute(for: "daily") == .tab(.today)) + } + + @Test func practiceResolvesToPracticeTab() { + #expect(SudokuAppComposition.uitestRoute(for: "practice") == .tab(.practice)) + } + + @Test func settingsResolvesToSettingsPush() { + #expect(SudokuAppComposition.uitestRoute(for: "settings") == .push(.settings, tab: .today)) + } + + // #1054: the fixed showcase board — pushed onto the Practice tab, same + // shape a real Start tap from Practice would produce. + @Test func boardShowcaseResolvesToFixedShowcaseBoard() { + #expect( + SudokuAppComposition.uitestRoute(for: "board:showcase") + == .push(.board(puzzleId: SudokuShowcaseBoard.puzzleId), tab: .practice) + ) + } + + @Test func unknownKeyResolvesToNil() { + #expect(SudokuAppComposition.uitestRoute(for: "bogus") == nil) + #expect(SudokuAppComposition.uitestRoute(for: "board:showcase ") == nil) + #expect(SudokuAppComposition.uitestRoute(for: "") == nil) + } +} + +#endif diff --git a/Packages/SudokuKit/Tests/SudokuUITests/SudokuShowcaseBoardTests.swift b/Packages/SudokuKit/Tests/SudokuUITests/SudokuShowcaseBoardTests.swift new file mode 100644 index 00000000..725b456c --- /dev/null +++ b/Packages/SudokuKit/Tests/SudokuUITests/SudokuShowcaseBoardTests.swift @@ -0,0 +1,104 @@ +// SudokuShowcaseBoardTests — verifies the DEBUG showcase-board builder +// produces a deterministic, marketing-ready fixture (#1054). +// +// Assertions: +// 1. Every given cell in `currentBoard` matches the puzzle's clue board +// (givens are untouched). +// 2. Exactly one non-given cell conflicts with the board (the visible red +// error highlight). +// 3. `notes` is non-empty, and every pencilled candidate is legal under +// the final board (no already-placed peer holds that digit). +// 4. The snapshot is deterministic across every call. +// +// Tests run in DEBUG only (the builder is not compiled in Release). + +#if DEBUG + +import Testing +@testable import SudokuUI +import SudokuEngine +import SudokuGameState + +@Suite("SudokuShowcaseBoard") +struct SudokuShowcaseBoardTests { + + @Test("snapshot's givens exactly match the puzzle's clue board") + func showcaseSnapshotGivensMatchPuzzle() throws { + let snapshot = try SudokuShowcaseBoard.snapshot() + for index in 0.. 0, "expected at least one noted cell") + } + + @Test("snapshot is deterministic across every call") + func showcaseSnapshotIsDeterministic() throws { + let first = try SudokuShowcaseBoard.snapshot() + let second = try SudokuShowcaseBoard.snapshot() + #expect(first == second, "showcase snapshot must be bit-identical across calls") + } + + // Mirrors `GameViewModel.hasConflict` (private, MainActor) — duplicated + // here so the test independently proves the same conflict property the + // production `recomputeErrors()` will compute, without reaching into a + // private VM method. + private static func hasConflict(digit: Int, atRow row: Int, col: Int, in board: Board) -> Bool { + for col2 in 0..