Skip to content

Add invariant tests for colorGuess and the reducer - #440

Merged
jrr merged 1 commit into
devfrom
claude/invariant-tests
Aug 22, 2026
Merged

Add invariant tests for colorGuess and the reducer#440
jrr merged 1 commit into
devfrom
claude/invariant-tests

Conversation

@jrr

@jrr jrr commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Second of three test-focused PRs (#439 was the first). Tests only — no production code changes, no new dependencies. The one non-test change is a one-line fix inside test-util.ts, explained at the bottom.

Where the existing tests pin down specific cases, these state the rules those cases are circling and check them across many inputs, using a small seeded LCG so failures reproduce exactly.

colorGuess

Three rules, checked over all 144 pairings of a duplicate-heavy word set (EERIE, GEESE, TEETH, MUMMY, KAYAK, …) and 2000 seeded pairs drawn from the real word lists:

  • one tile per guessed letter, in the order guessed
  • green marks exactly the positions the guess already got right
  • Wordle's duplicate-letter rule — a letter earns min(count in guess, count in solution) coloured tiles, and no more

That third one is what the eight existing examples approximate. Before writing it as a test I brute-forced it across 92,600 real word pairs to confirm it actually holds rather than merely looking plausible.

Deliberately not asserted: "a gray letter is absent from the solution". That isn't true — a letter goes gray once its occurrences are used up.

reducer

Games are played out with random actions and stop as soon as they end, so the new-game key can't reset the boards mid-sequence and legitimately break these.

Per state: the current row fits in a word; every guessed row is a full word.

Per move: rows never disappear; a move adds at most one row; an already-solved board takes no more guesses and stays solved.

The guess-limit rule is split in two. Single-board games stay inside the limit. The multi-board case is another it.fails for finding 1.1 — the broad form of the targeted case from #439: no random play of a multi-board game stays inside its limit.

Verified by mutation

An invariant that passes against broken code is worthless, so each was checked against a deliberate break:

Mutation Caught by
colorGuess stops consuming a matched letter duplicate-letter rule (both invariant tests; only 1 of the 8 examples)
won boards no longer frozen "an already-solved board takes no more guesses"
row-full clamp removed "the current row fits in a word"

One invariant I first wrote was simply wrong — "a solved board takes no guesses" fired on the winning move itself, since a board becomes won on the same transition that appends the winning row. The test caught it; it's now expressed over state transitions rather than snapshots.

Why expectEqual changed

Assertions carry their inputs alongside the value being checked, so a failure inside a 2000-iteration loop names the pair that broke it:

{ "coloured": 1, "guess": "KNEES", "letter": "E", "solution": "BASTE" }

That made expectEqual's swapped arguments (finding 3.3) actively misleading — the correct value was labelled "Received" and the buggy one "Expected". Since diagnosable failures are the point of this PR and test-util.ts was already being edited for the RNG, the one-line fix is included. Outcomes are unchanged; only the labels move.

Checks

yarn build, yarn lint, yarn prettier --check . and the --test midgame --quit smoke run all pass. 30 → 36 passing plus 3 expected failures, suite still under a second.

Next, as a separate PR: extracting keyToAction and the CLI flag handling into pure functions so they can be tested directly.


Generated by Claude Code

Where the existing tests pin down specific cases, these state the rules
those cases are circling and check them across many inputs, using a small
seeded LCG so failures reproduce exactly.

colorGuess: one tile per guessed letter in order; green marks exactly the
positions already correct; and Wordle's duplicate-letter rule, that a
letter earns min(count in guess, count in solution) coloured tiles.
Checked over all 144 pairings of a duplicate-heavy word set and 2000
seeded pairs from the real word lists.

reducer: rules checked per state (the current row fits in a word, every
guessed row is a full word) and per move (rows never disappear, a move
adds at most one row, an already-solved board takes no more guesses and
stays solved). Games are played out with random actions and stop as soon
as they end, so the new-game key can't reset the boards mid-sequence.

The guess-limit rule is split in two: single-board games stay inside the
limit, while the multi-board case is another it.fails for finding 1.1 —
the broad form of the targeted case added earlier.

Assertions carry the inputs alongside the value being checked, so a
failure inside a long loop names the pair or seed that broke it. That
made expectEqual's swapped arguments actively misleading during
development, so this also fixes that (finding 3.3): outcomes are
unchanged, but diffs now label expected and actual the right way round.

Verified by mutation: removing colorGuess's consume step, unfreezing won
boards, and dropping the row-full clamp are each caught by the invariant
naming that rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EoMUcnWHajEBC54SvGaox
@jrr
jrr merged commit 0fb1dfc into dev Aug 22, 2026
6 checks passed
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.

2 participants