Skip to content

feat(uitest): add DEBUG-only board:showcase route for store captures - #1107

Merged
wei18 merged 1 commit into
mainfrom
feat/1054-showcase-route
Sep 24, 2026
Merged

wei18 merged 1 commit into
mainfrom
feat/1054-showcase-route

Conversation

@wei18

@wei18 wei18 commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

What

PR-1 of the #1054 owner ruling (Fork A): a DEBUG-only -uitest-route board:showcase launch key in both apps that pushes one fixed, pre-built, honest mid-game board with zero taps, so the App Store 03-board marketing frame can be captured from a live simulator where Liquid Glass actually renders. PR-2 (capture task, fit-height compositor rule, gates, frames, docs) follows.

part of #1054 · part of #1014

How

  • GameAppKit.UITestLaunchArg.showcaseBoardRouteKey (inside the existing #if DEBUG region) — the single key both resolvers match.
  • Minesweeper: MinesweeperShowcaseSession.build() (whole file #if DEBUG) drives a real MinesweeperEngine — seed 0xC0FF_EE15_5C0B_0008, first reveal at (8,4) — then flags 3 mines that are forced by the basic count rule: each flag has a revealed witness neighbour whose number equals its count of non-revealed neighbours. Mounted through the existing MinesweeperGameViewModel(seeded:) seam in MinesweeperFreshBoardLoaderView when the route's sentinel seed arrives (#if DEBUG block). reveal/toggleFlag now honour isSeeded (they did not; the init's doc comment claimed they did), so the fixture cannot be mutated by a stray touch.
  • Sudoku: SudokuShowcaseBoard (whole file #if DEBUG) builds a fixed GameSessionSnapshot — givens + correct entries + exactly one genuinely conflicting digit (so recomputeErrors() flags it) + legal pencil candidates — served by UITestShowcasePersistence, a PersistenceProtocol wrapper mirroring UITestSeededCompletedDailyPersistence that intercepts the fixed puzzleId and no-ops every write for it. BoardLoaderView.mountLoaded gets a #if DEBUG frozen-clock branch for that id only; the #else branch is the original line.

Accepted asymmetry (not a defect)

Sudoku uses a live GameSession + frozen clock; Minesweeper uses the seeded snapshot. MinesweeperSession.restore normalises .playing to .paused (mounting the pause overlay) and returning to .playing needs the actor's async resume(), which the synchronous fresh-board loader cannot host without a refactor; Sudoku's loader is already async, so a frozen clock is the minimal change there. Both file headers cross-reference this.

Proof the route cannot ship

Claim, stated precisely: the board:showcase literal, the showcase types and the -uitest-* keys are absent from the Release binaries, and so is the resolveShowcasePersistence symbol — its Release body is the identity function (UITestShowcasePersistence.swift:135, #else return persistence) and the optimizer strips it. Scan is case-insensitive (rg -i) over every executable in each .app (main binary + GoogleMobileAds + UserMessagingPlatform), on Release builds made on the final commit (binary mtime 23:44/23:45 > commit 20:24:51):

| app | Release rg -i 'showcase|uitest' (all executables) | Release rg -i resolveShowcasePersistence | Release positive control rg 'Persistence' (same binary) | Debug <App>.debug.dylib rg -i 'showcase|uitest' | board:showcase literal in Debug |
|---|---|---|---|---|---|
| Minesweeper | 0 | 0 | 55 | 80 | 2 |
| Sudoku | 0 | 0 | 57 | 96 | 2 |

The Debug column (positive control) shows the detector finds the symbols when they exist; the Persistence column shows the same detector on the same Release binary finds ordinary symbols. Every earlier run (developer, Leader, T6) also used grep -i; T6 additionally rebuilt Release on the final commit after finding the developer's first Release build predated the last edits.

Honesty guards (tests)

  • MS: every flag has a computed forcing witness (fails if any flag lacks one); flags == 3, revealed > 0, status playing; seeded VM reveal/toggleFlag are bit-identical no-ops.
  • Sudoku: exactly one conflicting non-given cell and the conflict is real (row/col/box duplicate); every note candidate is legal; givens untouched; two builds bit-identical.

Verification

  • swift test MinesweeperKit (filter MinesweeperAppCompositionTests|MinesweeperShowcase): 20 tests green; SudokuKit (filter SudokuUITestRouteTests|SudokuShowcase): 9 green; GameAppKit UITestLaunchArgTests: 8 green.
  • swiftlint --strict on every touched file: 0 violations.
  • No __Snapshots__ change in the diff.
  • Simulator (throwaway iPhone 17 Pro Max, deleted after): MS two captures with simctl status_bar override → identical sha256. Sudoku: board/notes/error/timer identical across captures; an intermittent ≤2/255 sub-pixel jitter confined to the glass number pad (86 px, bbox (431,2392)-(868,2746)) is a compositor effect, characterised and handled in PR-2's capture policy, not here.
  • Code review (sonnet): 1 BLOCKER (seeded-VM mutators) + 1 MAJOR (flag placement) — both fixed in this commit; MINOR asymmetry accepted as above.

🤖 Generated with Claude Code

Adds a shared `-uitest-route board:showcase` key (GameAppKit.UITestLaunchArg)
both apps resolve to ONE fixed, pre-built mid-game board, for zero-click App
Store 03-board marketing captures (fork A of the owner ruling).

Minesweeper: MinesweeperShowcaseSession builds a deterministic snapshot via
real MinesweeperEngine reveal/flag calls, mounted through
MinesweeperGameViewModel(seeded:) instead of a live session restore — that
seam already exists for snapshot/preview fixtures and is what keeps
`elapsedSeconds` from ticking, which a live actor session cannot guarantee
(`.restore` normalizes to `.paused`, which mounts a blocking overlay, and
reaching `.playing` again needs an async `resume()` the loader's synchronous
seam can't cheaply offer). MinesweeperGameViewModel.reveal(row:col:) /
toggleFlag(row:col:) now also guard on `isSeeded` (only `refresh()` did
before, so a seeded showcase VM was silently mutable by any tap).

The 3 flagged mines are chosen only among cells FORCED by the basic
single-constraint count rule: a revealed neighbor whose displayed number
equals its own count of non-revealed neighbors, which pins every one of
those neighbors (the flag included) as a certain mine — never a guess, and
stronger than mere 8-adjacency to a revealed cell (a "2" with 3 non-revealed
neighbors doesn't pin any single one of them). Seed and first-click cell
were found by a brute-force search for a combination with >=3 such forced
mines; `MinesweeperShowcaseSessionTests` computes each flag's witness fresh
from the built board (no hard-coded coordinates) and fails if any flag lacks
one.

Sudoku: SudokuShowcaseBoard builds a fixed GameSessionSnapshot (givens +
correct fills + exactly one conflicting entry + legal pencil notes) served
through UITestShowcasePersistence, a DEBUG-only PersistenceProtocol wrapper
that intercepts the fixed puzzleId and no-ops every write for it — mirrors
UITestSeededCompletedDailyPersistence's shape and injection point.
BoardLoaderView.mountLoaded gets a small DEBUG-only frozen-clock branch
(UITestFrozenMonotonicClock) so the board's elapsed timer never advances
after mount; every other puzzleId is byte-for-byte unchanged. Sudoku goes
through the live GameSession actor (unlike Minesweeper's seeded snapshot)
because it rides the existing PersistenceProtocol interception point
BoardLoaderView already has — Minesweeper's fresh-board loader has no such
seam upstream of it; both builders' headers cross-reference this asymmetry.

All new/changed production code is `#if DEBUG` gated. Verified with `strings`
against both apps' Debug and Release iphonesimulator builds: Debug shows the
new symbols, Release shows zero. Two-capture hash comparisons on a throwaway
simulator (status-bar override, warmed launches) confirm byte-identical
repeat captures for Minesweeper's app-state content; Sudoku's board/notes/
error-highlight/timer content is always pixel-identical, but its glass
digit-pad region shows a recurring sub-pixel compositor jitter (max channel
delta <=2/255, same bbox every time) regardless of warm-up depth — a known
rendering-layer concern for the PR-2 capture pipeline, not an app-state
determinism defect in this PR.

part of #1054
part of #1014

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@wei18

wei18 commented Sep 24, 2026

Copy link
Copy Markdown
Owner Author

Fresh-context acceptance (T6) — commit 080b372e

總判定:ACCEPT

Independent re-run by a fresh-context agent (did not trust the developer's report):

  1. git status clean; git show --stat 080b372e = 14 files, +888/−4.
  2. Release exclusion rebuilt on the final commit (the developer's earlier Release build predated the last source edits — binary mtime 19:43 vs edits up to 20:18 — so it was re-built at 20:27, after commit time 20:24:51): strings over the Release executables → Minesweeper 0, Sudoku 0; Debug <App>.debug.dylib → 80 / 96, board:showcase literal 2 / 2 (positive control).
  3. swift test MinesweeperKit (20), SudokuKit (9), GameAppKit (8) all green; witness lines printed: (4,7)→(5,8) count 2, (4,8)→(5,8) count 2, (5,4)→(6,5) count 2. swiftlint --strict on all 14 files: 0 violations.
  4. Test quality: witnesses are computed from the built engine (not hard-coded); witness definition = revealed neighbour whose mine count equals its non-revealed neighbour count; no-op tests compare whole snapshot equality; Sudoku tests assert exactly one real conflict via an independently written hasConflict, legal notes, untouched givens.
  5. #if DEBUG boundary verified per file/line; every showcase hit outside tests sits inside a DEBUG region or its comments; the two guard !isSeeded lines in MinesweeperGameViewModel are production code but isSeeded is false on every non-fixture init.
  6. Negative test: a temporary test asserting a witness for the hidden cell (0,0) went red (Expectation failed: (witness → nil) != nil), then the temp file was deleted, tree clean.
  7. Screenshots eyeballed: MS flags at (4,7)(4,8)(5,4), counter 7, Playing, 0:42, glass capsule; Sudoku notes, one red conflict (8), glass pad, 1:36.
  8. No new TODO/FIXME/stub; the one placeholder hit is a pre-existing comment outside the diff.
  9. No __Snapshots__ changes. 10. Commit message conventions pass (no close/fix/resolve + #NNN).

Not verified by T6: simulator captures were not re-taken (existing PNGs read); Sudoku glass-pad sub-pixel jitter not re-measured (PR-2 scope).

Lesson recorded: for any "re-run the proof" acceptance, compare binary mtime against the final diff's source mtimes, not just the exit code.

@wei18

wei18 commented Sep 24, 2026

Copy link
Copy Markdown
Owner Author

PM merge record, head 080b372e. PR-1 of two for #1054: the DEBUG-only board:showcase route both apps need before the store frames can be captured from a live simulator.

Review: one substantive code review plus a fresh-context acceptance (ACCEPT). The review found two real defects, both fixed here:

  • BLOCKER: the seeded view model left reveal and toggleFlag unguarded, so a stray touch would have replaced the fixed showcase board with a fresh game — while its doc comment claimed every mutator was gated. Now guarded, with no-op tests.
  • MAJOR: flags were placed by lowest index, leaving two of them at Chebyshev distance 4 and 7 from the opened region — positions a player could only guess, under a frame captioned "Logical deduction. No guessing required."

PM ruling on that second one: adjacency to a revealed number is a proxy, not deducibility. Every flag must be forced by the basic count rule, and the test computes the witness per flag from the board rather than hard-coding it, so a later seed or builder edit cannot quietly reintroduce a guess. Final positions: flags (4,7) and (4,8) witnessed by (5,8)=2; flag (5,4) witnessed by (6,5)=2; seed 0xC0FF_EE15_5C0B_0008, first reveal (8,4). A negative witness test goes red as required.

Release exclusion, verified. Case-insensitive strings over every executable in both .app bundles (main binary plus the two vendor frameworks), on Release builds rebuilt from this commit: 0 hits for showcase / uitest, and 0 for the resolveShowcasePersistence symbol, which the optimizer drops because its non-DEBUG body is the identity. Positive controls on the same binaries: Persistence 55 (Minesweeper) and 57 (Sudoku), so the detector works. Debug control: 80 and 96 hits. The acceptance also caught that an earlier copy of this proof had been built before the final edits and rebuilt it.

PM ref check: the only non-comment mention outside a #if DEBUG block is the call at Live.swift:251, whose callee returns its argument unchanged under #else (UITestShowcasePersistence.swift:135), with the showcase type and route parsing inside #if DEBUG. Production launch behaviour is unchanged. 0 snapshot baselines touched. No close, fix or resolve keyword in the branch commit messages. CI 14/14.

Noted in the PR body: Sudoku reaches the showcase through a live session with a frozen clock while Minesweeper uses a seeded snapshot, because MS restore has no clock injection and forces .paused. Known asymmetry, not a defect.

#1054 stays open; PR-2 carries the capture task, the fit-height compositor, the gate updates and the regenerated frames, and the Mac slot is still unresolved.

@wei18
wei18 merged commit 92411ee into main Sep 24, 2026
14 checks passed
@wei18
wei18 deleted the feat/1054-showcase-route branch September 24, 2026 15:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant