From 2936232258180d137c9f145b1b4af15f234c6963 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 15:43:26 +0000 Subject: [PATCH] Add invariant tests for colorGuess and the reducer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013EoMUcnWHajEBC54SvGaox --- src/game-logic.test.ts | 99 ++++++++++++++++++++++++++- src/state/reducer.test.ts | 136 +++++++++++++++++++++++++++++++++++++- src/test-util.ts | 15 ++++- 3 files changed, 246 insertions(+), 4 deletions(-) diff --git a/src/game-logic.test.ts b/src/game-logic.test.ts index 31724d12..dfc72fc5 100644 --- a/src/game-logic.test.ts +++ b/src/game-logic.test.ts @@ -1,6 +1,9 @@ import { describe, it } from "vitest"; -import { expectEqual } from "./test-util.js"; +import { WORD_LEN } from "./constants.js"; import { colorGuess } from "./game-logic.js"; +import { expectEqual, seededRandom } from "./test-util.js"; +import { otherValidWords } from "./words/other-valid-words.js"; +import { possibleSolutions } from "./words/possible-solutions.js"; describe("colorGuess", () => { it("colors green", () => { @@ -108,3 +111,97 @@ describe("colorGuess", () => { }); }); }); + +/* +The example tests above pin down specific cases. These state the rules those +examples are circling, and check them across the real word lists. + +Each assertion carries the solution and guess alongside the value being +checked, so a failure inside a long loop names the pair that broke it. +*/ + +const countOf = (word: string, letter: string) => + [...word].filter((c) => c == letter).length; + +function checkInvariants(solution: string, guess: string) { + const { letters } = colorGuess(solution, guess); + const where = { solution, guess }; + + // One tile per guessed letter, in the order they were guessed. + expectEqual( + { ...where, tiles: letters.map((l) => l.letter).join("") }, + { ...where, tiles: guess }, + ); + + // Green marks exactly the positions the guess already got right. + expectEqual( + { ...where, green: letters.map((l) => l.color == "green") }, + { ...where, green: [...guess].map((c, i) => c == solution[i]) }, + ); + + // Wordle's duplicate-letter rule: a letter earns as many coloured tiles as + // it has occurrences in the solution, and no more. + for (const letter of new Set(guess)) { + expectEqual( + { + ...where, + letter, + coloured: letters.filter((l) => l.letter == letter && l.color != "gray") + .length, + }, + { + ...where, + letter, + coloured: Math.min(countOf(guess, letter), countOf(solution, letter)), + }, + ); + } +} + +/** Words that repeat letters, where the colouring rules are subtlest. */ +const trickyWords = [ + "EERIE", + "GEESE", + "STEER", + "TEETH", + "ERROR", + "PUPPY", + "LLAMA", + "ABBEY", + "MUMMY", + "KAYAK", + "MADAM", + "SASSY", +]; + +describe("colorGuess invariants", () => { + it("hold for every pairing of duplicate-heavy words", () => { + for (const solution of trickyWords) { + for (const guess of trickyWords) { + checkInvariants(solution, guess); + } + } + }); + + it("hold across a sample of the real word lists", () => { + const solutions = possibleSolutions.map((w) => w.toUpperCase()); + const guesses = [...possibleSolutions, ...otherValidWords].map((w) => + w.toUpperCase(), + ); + const rand = seededRandom(20260822); + const pick = (words: string[]) => words[Math.floor(rand() * words.length)]; + + for (let i = 0; i < 2000; i++) { + checkInvariants(pick(solutions), pick(guesses)); + } + }); + + it("colour a correct guess entirely green", () => { + for (const word of trickyWords) { + expectEqual( + colorGuess(word, word).letters.map((l) => l.color), + Array(WORD_LEN).fill("green"), + ); + } + }); +}); diff --git a/src/state/reducer.test.ts b/src/state/reducer.test.ts index 103a4e8e..efd47643 100644 --- a/src/state/reducer.test.ts +++ b/src/state/reducer.test.ts @@ -1,8 +1,9 @@ import { describe, it } from "vitest"; -import { KEY_NEW_GAME, KEY_QUIT } from "../constants.js"; -import { expectEqual } from "../test-util.js"; +import { KEY_NEW_GAME, KEY_QUIT, WORD_LEN } from "../constants.js"; +import { expectEqual, seededRandom } from "../test-util.js"; import { GameAction } from "../ui.js"; import { GameState } from "../types.js"; +import { possibleSolutions } from "../words/possible-solutions.js"; import { newGame } from "./game-states.js"; import { reducer } from "./reducer.js"; @@ -200,3 +201,134 @@ describe("reducer", () => { }); }); }); + +/* +Rules that should hold throughout any game, checked by playing out seeded +random games rather than by listing cases. Violations are collected rather +than asserted one at a time, so a failure reports every rule that broke and +the seed that broke it. +*/ + +const BOARD_COUNTS = [1, 2, 3]; +const SEEDS = 30; +const MAX_TURNS = 200; + +type Violation = { boards: number; seed: number; rule: string; detail: string }; +type ReportFn = (rule: string, detail: string) => void; + +/** + * Plays one random game, stopping as soon as it ends. Staying inside a single + * game keeps the new-game key from resetting the boards mid-sequence, which + * would legitimately break several of these rules. + */ +function playRandomGame(boards: number, seed: number): GameState[] { + const rand = seededRandom(seed * 1000 + boards); + const words = possibleSolutions.map((w) => w.toUpperCase()); + const pick = (from: string[]) => from[Math.floor(rand() * from.length)]; + + const solutions = Array.from({ length: boards }, () => pick(words)); + let state = newGame({ solutions }); + const history = [state]; + + for (let turn = 0; turn < MAX_TURNS && state.status == "guessing"; turn++) { + const roll = rand(); + if (roll < 0.6) { + // a whole word, often one of the answers, so games actually finish + state = guess(state, rand() < 0.3 ? pick(solutions) : pick(words)); + } else if (roll < 0.85) { + state = type(state, String.fromCharCode(65 + Math.floor(rand() * 26))); + } else { + state = backspace(state); + } + history.push(state); + } + return history; +} + +function eachGame(check: (states: GameState[], report: ReportFn) => void) { + const violations: Violation[] = []; + for (const boards of BOARD_COUNTS) { + for (let seed = 1; seed <= SEEDS; seed++) { + check(playRandomGame(boards, seed), (rule, detail) => + violations.push({ boards, seed, rule, detail }), + ); + } + } + return violations; +} + +describe("invariants", () => { + it("hold for every state a random game passes through", () => { + const violations = eachGame((states, report) => { + for (const state of states) { + if (state.status == "guessing" && state.currentRow.length > WORD_LEN) { + report("the current row fits in a word", `row '${state.currentRow}'`); + } + state.gameBoards.forEach((board, i) => { + if (board.guessedRows.some((r) => r.letters.length != WORD_LEN)) { + report("every guessed row is a full word", `board ${i}`); + } + }); + } + }); + + expectEqual(violations, []); + }); + + it("hold across every move a random game makes", () => { + const violations = eachGame((states, report) => { + for (let i = 1; i < states.length; i++) { + const before = states[i - 1].gameBoards; + const after = states[i].gameBoards; + + after.forEach((board, b) => { + const added = board.guessedRows.length - before[b].guessedRows.length; + + if (added < 0) { + report("guessed rows never disappear", `board ${b}: ${added}`); + } + if (added > 1) { + report("a move adds at most one row", `board ${b}: +${added}`); + } + // A board takes the guess that wins it, then nothing further. + if (before[b].boardStatus == "won" && added > 0) { + report( + "an already-solved board takes no more guesses", + `board ${b}`, + ); + } + if (before[b].boardStatus == "won" && board.boardStatus != "won") { + report("a solved board stays solved", `board ${b}`); + } + }); + } + }); + + expectEqual(violations, []); + }); + + const overGuessLimit = (boardCounts: number[]) => + eachGame((states, report) => { + for (const state of states) { + state.gameBoards.forEach((board, i) => { + if (board.guessedRows.length > state.numGuessesAllowed) { + report( + "no board exceeds the guess limit", + `board ${i}: ${board.guessedRows.length} rows, ${state.numGuessesAllowed} allowed`, + ); + } + }); + } + }).filter((v) => boardCounts.includes(v.boards)); + + it("keep a single-board game inside its guess limit", () => { + expectEqual(overGuessLimit([1]), []); + }); + + // Known bug — see docs/architecture-review.md finding 1.1. This is the broad + // form of the targeted case above: random play of a multi-board game runs + // past the limit. Flip to `it` when fixed. + it.fails("keep a multi-board game inside its guess limit", () => { + expectEqual(overGuessLimit([2, 3]), []); + }); +}); diff --git a/src/test-util.ts b/src/test-util.ts index 166d025e..d7ba6101 100644 --- a/src/test-util.ts +++ b/src/test-util.ts @@ -1,4 +1,17 @@ import { expect } from "vitest"; export function expectEqual(actual: T, expected: T) { - expect(expected).toStrictEqual(actual); + expect(actual).toStrictEqual(expected); +} + +/** + * A small linear congruential generator, so invariant tests explore many + * inputs while still failing the same way on every run. Not for anything + * that needs real randomness. + */ +export function seededRandom(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (Math.imul(s, 1664525) + 1013904223) >>> 0; + return s / 0x100000000; + }; }