initial commit

This commit is contained in:
2026-07-06 07:56:56 -04:00
commit 70ead4b520
17 changed files with 13974 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
import pytest
from wordle_solver.game import Clue
from wordle_solver.cli import parse_line, hard_mode_violation
G = Clue.GREEN
Y = Clue.YELLOW
B = Clue.GREY
class TestParseLine:
def test_digit_encoding(self):
guess, pattern = parse_line("c2r1a0n0e0")
assert guess == "crane"
assert pattern == (G, Y, B, B, B)
def test_letter_encoding(self):
guess, pattern = parse_line("cGrYaBnBeB")
assert guess == "crane"
assert pattern == (G, Y, B, B, B)
def test_spaces_stripped(self):
guess, pattern = parse_line("c 2 r 1 a 0 n 0 e 0")
assert guess == "crane"
assert pattern == (G, Y, B, B, B)
def test_uppercase_letters_lowercased(self):
guess, _ = parse_line("C2R1A0N0E0")
assert guess == "crane"
def test_all_green(self):
_, pattern = parse_line("c2r2a2n2e2")
assert pattern == (G, G, G, G, G)
def test_wrong_length_raises(self):
with pytest.raises(ValueError):
parse_line("c2r1a0") # only 3 pairs
def test_too_long_raises(self):
with pytest.raises(ValueError):
parse_line("c2r1a0n0e0x0") # 6 pairs
def test_digit_in_letter_position_raises(self):
with pytest.raises(ValueError):
parse_line("12r1a0n0e0") # position 0 is '1', not a letter
class TestHardModeViolation:
def test_valid_guess_returns_none(self):
green = {0: "c", 1: "r"}
from collections import Counter
min_count = Counter({"a": 1})
assert hard_mode_violation("crane", green, min_count) is None
def test_wrong_green_position_detected(self):
green = {0: "c"}
from collections import Counter
result = hard_mode_violation("trace", green, Counter())
assert result is not None
assert "1" in result # position 1 (1-indexed)
def test_missing_required_letter_detected(self):
from collections import Counter
min_count = Counter({"r": 1})
result = hard_mode_violation("piano", {}, min_count)
assert result is not None
assert "R" in result
def test_missing_duplicate_letter_detected(self):
# Word needs two 'e's
from collections import Counter
min_count = Counter({"e": 2})
result = hard_mode_violation("crane", {}, min_count)
assert result is not None # "crane" has only one 'e'
def test_satisfies_duplicate_requirement(self):
from collections import Counter
min_count = Counter({"e": 2})
result = hard_mode_violation("geese", {}, min_count)
assert result is None # "geese" has three 'e's — satisfies ≥2
def test_empty_constraints_always_none(self):
from collections import Counter
assert hard_mode_violation("crane", {}, Counter()) is None
+85
View File
@@ -0,0 +1,85 @@
import pytest
from wordle_solver.game import Clue, score_guess, str_to_pattern, pattern_to_str, pattern_to_digits
G = Clue.GREEN
Y = Clue.YELLOW
B = Clue.GREY
class TestScoreGuess:
def test_all_green(self):
assert score_guess("crane", "crane") == (G, G, G, G, G)
def test_all_grey(self):
assert score_guess("brick", "those") == (B, B, B, B, B)
def test_basic_yellow(self):
assert score_guess("trail", "blurt") == (Y, Y, B, B, Y)
def test_basic_mixed(self):
assert score_guess("arise", "spare") == (Y, Y, B, Y, G)
# Duplicate-letter cases — this is where the two-pass algorithm matters.
def test_duplicate_in_guess_one_in_answer_first_gets_yellow(self):
# 'a' appears twice in "nasal" (pos 1, 3), once in "ultra" (pos 4).
# Neither is a green, so pass-2 left-to-right: pos 1 → yellow, pos 3 → grey.
assert score_guess("nasal", "ultra") == (B, Y, B, B, Y)
def test_duplicate_in_guess_one_in_answer_green_takes_priority(self):
# "eerie" has 3 e's; "abcee" has 2. The last 'e' in the guess hits green
# at position 4; one remaining 'e' in the answer makes the first 'e' yellow;
# the middle 'e' has nothing left to match → grey.
assert score_guess("eerie", "abcee") == (Y, B, B, B, G)
def test_duplicate_in_guess_both_in_answer(self):
# "sleep" has two 'e's; answer "geese" has three 'e's
# both guess 'e's should be coloured (green or yellow)
result = score_guess("sleep", "geese")
assert result[2] == G # 'e' at pos 2 matches answer pos 2
assert result[3] in (G, Y)
def test_duplicate_in_answer_guess_has_one(self):
# "abbey" has two 'b's; "bland" has one 'b' in the wrong position → yellow
assert score_guess("bland", "abbey") == (Y, B, Y, B, B)
def test_guess_equals_answer_all_green(self):
for word in ("tales", "quick", "zzzzz"):
assert score_guess(word, word) == (G,) * 5
def test_yellow_not_double_counted(self):
# "added" vs "daddy": only two 'd's in answer, so not all three guess-d's get colour
result = score_guess("added", "daddy")
d_colours = [result[i] for i, c in enumerate("added") if c == "d"]
coloured = sum(1 for c in d_colours if c != B)
assert coloured <= 3 # answer has three 'd's at most
class TestStrToPattern:
def test_digit_encoding(self):
assert str_to_pattern("22110") == (G, G, Y, Y, B)
def test_letter_encoding(self):
assert str_to_pattern("GGYYB") == (G, G, Y, Y, B)
def test_lowercase_letter_encoding(self):
assert str_to_pattern("ggyyb") == (G, G, Y, Y, B)
def test_mixed_encodings(self):
assert str_to_pattern("2G1Y0") == (G, G, Y, Y, B)
def test_invalid_char_raises(self):
with pytest.raises(ValueError):
str_to_pattern("2210X")
def test_dot_and_dash_are_grey(self):
assert str_to_pattern("..---") == (B,) * 5
class TestPatternHelpers:
def test_pattern_to_str(self):
assert pattern_to_str((G, Y, B)) == "GYB"
def test_pattern_to_digits(self):
assert pattern_to_digits((G, Y, B)) == "210"
+147
View File
@@ -0,0 +1,147 @@
import pytest
from wordle_solver.game import Clue, score_guess
from wordle_solver.solver import Solver, rank_guesses
G = Clue.GREEN
Y = Clue.YELLOW
B = Clue.GREY
ALL_GREEN = (G, G, G, G, G)
def make_solver(*extra):
"""Solver with a small, controlled answer list."""
answers = ["crane", "crave", "trace", "grace", "brace"]
return Solver(answers, list(extra))
class TestSolverApply:
def test_all_candidates_initially(self):
s = make_solver()
assert set(s.candidates) == {"crane", "crave", "trace", "grace", "brace"}
def test_apply_narrows_candidates(self):
s = make_solver()
# scoring "crane" against "crane" → all green → only "crane" remains
s.apply("crane", ALL_GREEN)
assert s.candidates == ["crane"]
def test_apply_chain(self):
s = make_solver()
# first clue: 'c' correct, 'r' present, rest absent/wrong
pattern1 = score_guess("crane", "crave")
s.apply("crane", pattern1)
# 'crave' must still be a candidate; 'grace' and 'brace' may or may not be
assert "crave" in s.candidates
assert "crane" not in s.candidates
def test_apply_empty_candidates_on_no_match(self):
s = make_solver()
# impossible pattern eliminates everything
s.apply("zzzzz", ALL_GREEN)
assert s.candidates == []
def test_reset_restores_all_candidates(self):
s = make_solver()
s.apply("crane", ALL_GREEN)
s.reset()
assert set(s.candidates) == {"crane", "crave", "trace", "grace", "brace"}
def test_reset_clears_history(self):
s = make_solver()
s.apply("crane", ALL_GREEN)
s.reset()
assert s.history == []
def test_is_solved_after_all_green(self):
s = make_solver()
s.apply("crane", ALL_GREEN)
assert s.is_solved()
def test_is_solved_false_initially(self):
assert not make_solver().is_solved()
class TestHardModeConstraints:
def test_green_positions_extracted(self):
s = make_solver()
s.apply("crane", (G, B, B, B, B)) # 'c' locked at position 0
green, _ = s.hard_mode_constraints()
assert green == {0: "c"}
def test_yellow_minimum_count(self):
s = make_solver()
s.apply("crane", (B, Y, B, B, B)) # 'r' must appear at least once
_, min_count = s.hard_mode_constraints()
assert min_count["r"] >= 1
def test_multiple_greens_accumulated(self):
s = make_solver()
s.apply("crane", (G, G, B, B, B))
s.apply("crave", (G, G, B, G, B))
green, _ = s.hard_mode_constraints()
assert green[0] == "c"
assert green[1] == "r"
assert green[3] == "v"
def test_duplicate_yellow_counts_correctly(self):
s = make_solver()
# Two 'a's in a guess, both yellow → need at least 2 'a's
s.apply("llama", (B, B, Y, B, Y))
_, min_count = s.hard_mode_constraints()
assert min_count["a"] >= 2
class TestHardModePool:
def test_no_history_returns_all_words(self):
s = make_solver("extra1", "extra2")
pool = s.hard_mode_pool()
assert set(pool) == set(s.all_words)
def test_green_constraint_filters_pool(self):
s = make_solver()
s.apply("crane", (G, B, B, B, B)) # first letter must be 'c'
pool = s.hard_mode_pool()
assert all(w[0] == "c" for w in pool)
def test_yellow_constraint_filters_pool(self):
s = make_solver()
s.apply("crane", (B, Y, B, B, B)) # 'r' must be present
pool = s.hard_mode_pool()
assert all("r" in w for w in pool)
def test_word_violating_green_excluded(self):
s = make_solver()
s.apply("crane", (G, B, B, B, B)) # 'c' at position 0
pool = s.hard_mode_pool()
assert "trace" not in pool # starts with 't'
class TestRankGuesses:
def test_returns_at_most_top_n(self):
answers = ["crane", "crave", "trace", "grace", "brace"]
pool = answers[:]
results = rank_guesses(pool, answers, top_n=3)
assert len(results) <= 3
def test_possible_answers_tagged(self):
answers = ["crane", "crave"]
pool = answers + ["zzzzz"]
results = rank_guesses(pool, answers, top_n=10)
by_word = {w: is_ans for w, _, is_ans in results}
assert by_word["crane"] is True
assert by_word["crave"] is True
assert by_word["zzzzz"] is False
def test_scores_descending(self):
answers = ["crane", "crave", "trace", "grace", "brace"]
results = rank_guesses(answers, answers, top_n=5)
scores = [s for _, s, _ in results]
assert scores == sorted(scores, reverse=True)
def test_single_candidate_returned(self):
answers = ["crane"]
results = rank_guesses(answers, answers, top_n=5)
assert len(results) == 1
assert results[0][0] == "crane"
+79
View File
@@ -0,0 +1,79 @@
from wordle_solver.game import Clue
from wordle_solver.wager import Wager
G = Clue.GREEN
Y = Clue.YELLOW
B = Clue.GREY
class TestWagerCost:
def test_all_correct_costs_one_each(self):
w = Wager()
assert w.cost_of((G, G, G, G, G)) == 5
def test_all_present_costs_two_each(self):
w = Wager()
assert w.cost_of((Y, Y, Y, Y, Y)) == 10
def test_all_absent_costs_three_each(self):
w = Wager()
assert w.cost_of((B, B, B, B, B)) == 15
def test_mixed_cost(self):
w = Wager()
assert w.cost_of((G, Y, B, G, B)) == 1 + 2 + 3 + 1 + 3
class TestWagerAccumulation:
def test_add_accumulates_round_and_session(self):
w = Wager()
w.add((G, G, G, G, G)) # cost 5
w.add((B, B, B, B, B)) # cost 15
assert w.round_total == 20
assert w.session_total == 20
def test_new_round_resets_round_total_only(self):
w = Wager()
w.add((G, G, G, G, G)) # cost 5
w.new_round()
w.add((B, B, B, B, B)) # cost 15 in new round
assert w.round_total == 15
assert w.session_total == 20 # unchanged across rounds
def test_add_returns_cost_of_that_guess(self):
w = Wager()
cost = w.add((G, Y, B, G, B))
assert cost == 1 + 2 + 3 + 1 + 3
def test_fresh_wager_starts_at_zero(self):
w = Wager()
assert w.round_total == 0
assert w.session_total == 0
class TestWagerPrices:
def test_set_prices_changes_cost(self):
w = Wager()
w.set_prices(10, 20, 30)
assert w.cost_of((G, Y, B)) == 60
def test_reset_via_set_prices_default(self):
w = Wager()
w.set_prices(10, 20, 30)
w.set_prices(1, 2, 3)
assert w.cost_of((G, Y, B)) == 6
def test_prices_str_contains_values(self):
w = Wager()
s = w.prices_str()
assert "$1" in s
assert "$2" in s
assert "$3" in s
def test_prices_str_after_custom_prices(self):
w = Wager()
w.set_prices(5, 10, 15)
s = w.prices_str()
assert "$5" in s
assert "$10" in s
assert "$15" in s