diff --git a/tests/test_nyt_check.py b/tests/test_nyt_check.py new file mode 100644 index 0000000..67622e0 --- /dev/null +++ b/tests/test_nyt_check.py @@ -0,0 +1,123 @@ +import json +from datetime import date +from unittest.mock import patch + +from wordle_solver.nyt_check import fetch_todays_answer, maybe_check_nyt_word +from wordle_solver.solver import Solver + + +def _write_lists(data_dir, answers, guesses): + (data_dir / "answers.txt").write_text("\n".join(answers) + "\n") + (data_dir / "guesses.txt").write_text("\n".join(guesses) + "\n") + + +def _mock_response(body: dict): + class _Resp: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self): + return json.dumps(body).encode() + + return _Resp() + + +class TestFetchTodaysAnswer: + def test_returns_solution_on_success(self): + with patch("wordle_solver.nyt_check.urllib.request.urlopen", + return_value=_mock_response({"solution": "CRANE"})): + assert fetch_todays_answer() == "crane" + + def test_returns_none_on_network_error(self): + with patch("wordle_solver.nyt_check.urllib.request.urlopen", side_effect=OSError("boom")): + assert fetch_todays_answer() is None + + def test_returns_none_on_malformed_body(self): + with patch("wordle_solver.nyt_check.urllib.request.urlopen", + return_value=_mock_response({"solution": "notfive"})): + assert fetch_todays_answer() is None + + +class TestMaybeCheckNytWord: + def test_skip_flag_short_circuits(self, tmp_path): + solver = Solver(["crane"], ["trace"]) + with patch("wordle_solver.nyt_check.fetch_todays_answer") as fetch: + result = maybe_check_nyt_word(solver, data_dir=tmp_path, state_path=tmp_path / "state.json", + skip=True) + assert result is None + fetch.assert_not_called() + + def test_cache_hit_skips_fetch(self, tmp_path): + _write_lists(tmp_path, ["crane"], ["trace"]) + state_path = tmp_path / "state.json" + state_path.write_text(json.dumps({"last_checked": date.today().isoformat()})) + solver = Solver(["crane"], ["trace"]) + + with patch("wordle_solver.nyt_check.fetch_todays_answer") as fetch: + result = maybe_check_nyt_word(solver, data_dir=tmp_path, state_path=state_path) + + assert result is None + fetch.assert_not_called() + + def test_adds_missing_word_and_updates_state(self, tmp_path): + _write_lists(tmp_path, ["crane"], ["zesty"]) + state_path = tmp_path / "state.json" + solver = Solver(["crane"], ["zesty"]) + + with patch("wordle_solver.nyt_check.fetch_todays_answer", return_value="zesty"): + result = maybe_check_nyt_word(solver, data_dir=tmp_path, state_path=state_path) + + assert result == "zesty" + assert "zesty" in solver.answers + assert "zesty" not in solver.extra_guesses + assert "zesty" in solver.candidates + assert (tmp_path / "answers.txt").read_text().splitlines() == ["crane", "zesty"] + assert json.loads(state_path.read_text())["last_checked"] + + def test_already_known_word_is_noop_but_updates_state(self, tmp_path): + _write_lists(tmp_path, ["crane"], []) + state_path = tmp_path / "state.json" + solver = Solver(["crane"], []) + + with patch("wordle_solver.nyt_check.fetch_todays_answer", return_value="crane"): + result = maybe_check_nyt_word(solver, data_dir=tmp_path, state_path=state_path) + + assert result is None + assert json.loads(state_path.read_text())["last_checked"] + + def test_network_failure_does_not_update_state(self, tmp_path): + _write_lists(tmp_path, ["crane"], []) + state_path = tmp_path / "state.json" + solver = Solver(["crane"], []) + + with patch("wordle_solver.nyt_check.fetch_todays_answer", return_value=None): + result = maybe_check_nyt_word(solver, data_dir=tmp_path, state_path=state_path) + + assert result is None + assert not state_path.exists() + + def test_force_bypasses_cache(self, tmp_path): + _write_lists(tmp_path, ["crane"], ["zesty"]) + state_path = tmp_path / "state.json" + state_path.write_text(json.dumps({"last_checked": date.today().isoformat()})) + solver = Solver(["crane"], ["zesty"]) + + with patch("wordle_solver.nyt_check.fetch_todays_answer", return_value="zesty") as fetch: + result = maybe_check_nyt_word(solver, data_dir=tmp_path, state_path=state_path, force=True) + + fetch.assert_called_once() + assert result == "zesty" + + def test_corrupt_state_file_treated_as_empty(self, tmp_path): + _write_lists(tmp_path, ["crane"], []) + state_path = tmp_path / "state.json" + state_path.write_text("not json") + solver = Solver(["crane"], []) + + with patch("wordle_solver.nyt_check.fetch_todays_answer", return_value=None): + result = maybe_check_nyt_word(solver, data_dir=tmp_path, state_path=state_path) + + assert result is None diff --git a/wordle_solver/cli.py b/wordle_solver/cli.py index dcd2d95..fadac38 100644 --- a/wordle_solver/cli.py +++ b/wordle_solver/cli.py @@ -5,6 +5,7 @@ import random from collections import Counter from .game import Clue, score_guess, str_to_pattern +from .nyt_check import maybe_check_nyt_word from .solver import MAX_GUESSES, WORD_LEN, Solver, rank_guesses from .wager import Wager from .words import add_answer_word, load_answers, load_extra_guesses @@ -155,6 +156,11 @@ def run(args=None): help="weight on per-position letter frequency (default: 1.0)") parser.add_argument("--presence-weight", type=float, default=1.0, help="weight on overall letter presence frequency (default: 1.0)") + nyt_group = parser.add_mutually_exclusive_group() + nyt_group.add_argument("--no-nyt-check", action="store_true", + help="skip the automatic check of today's NYT Wordle answer") + nyt_group.add_argument("--force-nyt-check", action="store_true", + help="check today's NYT Wordle answer even if already checked today") opts = parser.parse_args(args) answers = load_answers() @@ -163,6 +169,8 @@ def run(args=None): wager = Wager() state = {"secret": None, "guesses_made": 0} + maybe_check_nyt_word(solver, skip=opts.no_nyt_check, force=opts.force_nyt_check) + print("Wordle hard-mode assistant. Type 'help' for instructions.") print(f"Wager prices: {wager.prices_str()}") print_suggestions(solver, opts.top, opts.position_weight, opts.presence_weight) diff --git a/wordle_solver/nyt_check.py b/wordle_solver/nyt_check.py new file mode 100644 index 0000000..d7b1ef5 --- /dev/null +++ b/wordle_solver/nyt_check.py @@ -0,0 +1,83 @@ +"""Silent, once-a-day check of the NYT Wordle answer against our word list. + +NYT's `svc/wordle/v2` endpoint discloses the solution for a given date as +soon as the local calendar rolls to that date. On startup we check today's +date once per day and add the word via the same mechanism as `/addword` if +it's missing from `answers.txt`. Never prints anything and never raises — +failures are silently ignored and retried on the next run. +""" + +import json +import urllib.request +from datetime import date +from pathlib import Path + +from .game import score_guess +from .solver import WORD_LEN, Solver +from .words import DATA_DIR, add_answer_word + +STATE_PATH = Path.home() / ".wordle_solver" / "state.json" +NYT_URL_TEMPLATE = "https://www.nytimes.com/svc/wordle/v2/{date}.json" +REQUEST_TIMEOUT = 3 + + +def get_state_path() -> Path: + return STATE_PATH + + +def load_state(state_path: Path = STATE_PATH) -> dict: + try: + return json.loads(state_path.read_text()) + except (OSError, ValueError): + return {} + + +def save_state(state: dict, state_path: Path = STATE_PATH) -> None: + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text(json.dumps(state)) + + +def fetch_todays_answer() -> str | None: + url = NYT_URL_TEMPLATE.format(date=date.today().isoformat()) + try: + with urllib.request.urlopen(url, timeout=REQUEST_TIMEOUT) as resp: + body = json.loads(resp.read()) + word = body["solution"].strip().lower() + except Exception: + return None + if len(word) != WORD_LEN or not word.isalpha(): + return None + return word + + +def maybe_check_nyt_word(solver: Solver, data_dir: Path = DATA_DIR, + state_path: Path = STATE_PATH, + skip: bool = False, force: bool = False) -> str | None: + if skip: + return None + + today = date.today().isoformat() + state = load_state(state_path) + if not force and state.get("last_checked") == today: + return None + + word = fetch_todays_answer() + if word is None: + return None + + state["last_checked"] = today + save_state(state, state_path) + + if word in solver.answers: + return None + + result = add_answer_word(word, data_dir) + solver.answers.append(word) + solver.answers.sort() + if result["removed_from_guesses"]: + solver.extra_guesses.remove(word) + + if all(score_guess(rec.guess, word) == rec.pattern for rec in solver.history): + solver.candidates.append(word) + + return word