Add Turtle screen, game loop and entry point (MIL-003)

Task: MIL-003#1
Task: MIL-003#2
Task: MIL-003#3
Task: MIL-003#4
Task: MIL-003#5
Task: MIL-003#6
This commit is contained in:
2026-10-13 15:50:45 +08:00
parent 896c807cc3
commit 02b537e538
9 changed files with 483 additions and 1 deletions
+104
View File
@@ -0,0 +1,104 @@
"""Tests of the guess loop and the end-of-game export in game.py."""
import csv
from collections.abc import Sequence
from pathlib import Path
from us_states_game.constants import ANSWER_PROMPT, NUMBER_OF_STATES
from us_states_game.game import play, run_game
from us_states_game.states import State
STATES = [State("Alabama", 139, -77), State("Ohio", 100, 20), State("Texas", -50, -120)]
ALL_STATES = [State(f"State {n}", n, -n) for n in range(NUMBER_OF_STATES)]
class FakeScreen:
"""Records what the loop asks and writes, and returns scripted answers."""
def __init__(self, answers: Sequence[str | None]) -> None:
self._answers = iter(answers)
self.titles: list[str] = []
self.prompts: list[str] = []
self.written: list[State] = []
def ask_state_name(self, title: str, prompt: str) -> str | None:
self.titles.append(title)
self.prompts.append(prompt)
return next(self._answers)
def write_state(self, state: State) -> None:
self.written.append(state)
def read_names(path: Path) -> list[str]:
with path.open(newline="", encoding="utf-8") as file:
return [row[0] for row in csv.reader(file)]
def test_play_writes_correct_guess_at_its_coordinates() -> None:
screen = FakeScreen(["Ohio", "exit"])
assert play(screen, STATES) == ["Ohio"]
assert screen.written == [State("Ohio", 100, 20)]
def test_play_increments_counter_in_title_after_correct_guess() -> None:
screen = FakeScreen(["ohio", "texas", "exit"])
play(screen, STATES)
assert screen.titles == [
"0/50 States Correct",
"1/50 States Correct",
"2/50 States Correct",
]
assert set(screen.prompts) == {ANSWER_PROMPT}
def test_play_uses_canonical_name_for_case_insensitive_guess() -> None:
assert play(FakeScreen(["oHiO", "exit"]), STATES) == ["Ohio"]
def test_play_asks_again_without_writing_for_wrong_guess() -> None:
screen = FakeScreen(["Atlantis", "exit"])
assert play(screen, STATES) == []
assert screen.written == []
assert screen.titles == ["0/50 States Correct", "0/50 States Correct"]
def test_play_counts_duplicate_guess_once() -> None:
screen = FakeScreen(["Ohio", "ohio", " OHIO ", "exit"])
assert play(screen, STATES) == ["Ohio"]
assert len(screen.written) == 1
assert screen.titles[-1] == "1/50 States Correct"
def test_play_ends_on_exit_command() -> None:
screen = FakeScreen(["exit"])
assert play(screen, STATES) == []
assert len(screen.titles) == 1
def test_play_ends_on_exit_command_in_any_case() -> None:
assert play(FakeScreen(["Ohio", "EXIT"]), STATES) == ["Ohio"]
def test_play_ends_when_player_cancels() -> None:
assert play(FakeScreen(["Ohio", None]), STATES) == ["Ohio"]
def test_play_ends_without_asking_again_when_all_states_are_guessed() -> None:
screen = FakeScreen([state.name for state in ALL_STATES])
assert len(play(screen, ALL_STATES)) == NUMBER_OF_STATES
assert len(screen.titles) == NUMBER_OF_STATES
def test_run_game_writes_exactly_the_missing_states(tmp_path: Path) -> None:
output = tmp_path / "states_to_learn.csv"
missing = run_game(FakeScreen(["Ohio", "exit"]), STATES, output)
assert missing == ["Alabama", "Texas"]
assert read_names(output) == ["state", "Alabama", "Texas"]
def test_run_game_writes_header_only_when_all_states_guessed(tmp_path: Path) -> None:
output = tmp_path / "states_to_learn.csv"
screen = FakeScreen([state.name for state in ALL_STATES])
assert run_game(screen, ALL_STATES, output) == []
assert read_names(output) == ["state"]
+91
View File
@@ -0,0 +1,91 @@
"""Tests of the entry point in main.py, with a fake screen."""
import subprocess
import sys
from collections.abc import Sequence
from pathlib import Path
import pytest
from us_states_game import main as main_module
from us_states_game.states import State
from us_states_game.storage import load_states
class FakeTurtleScreen:
"""Stands in for TurtleScreen: scripted answers, no display."""
instances: list["FakeTurtleScreen"] = []
answers: Sequence[str | None] = []
def __init__(self) -> None:
self._answers = iter(self.answers)
self.written: list[State] = []
self.coordinates_enabled = False
self.closed = False
FakeTurtleScreen.instances.append(self)
def ask_state_name(self, title: str, prompt: str) -> str | None:
return next(self._answers)
def write_state(self, state: State) -> None:
self.written.append(state)
def enable_click_coordinates(self) -> None:
self.coordinates_enabled = True
def close(self) -> None:
self.closed = True
@pytest.fixture
def fake_screen(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> type[FakeTurtleScreen]:
FakeTurtleScreen.instances = []
monkeypatch.setattr(main_module, "TurtleScreen", FakeTurtleScreen)
monkeypatch.chdir(tmp_path)
return FakeTurtleScreen
def test_main_writes_states_to_learn_and_returns_zero(
fake_screen: type[FakeTurtleScreen], tmp_path: Path
) -> None:
fake_screen.answers = ["Ohio", "texas", "exit"]
assert main_module.main([]) == 0
lines = (tmp_path / "states_to_learn.csv").read_text(encoding="utf-8").splitlines()
expected = [s.name for s in load_states() if s.name not in {"Ohio", "Texas"}]
assert lines == ["state", *expected]
assert len(expected) == 48
def test_main_closes_screen_after_game(fake_screen: type[FakeTurtleScreen]) -> None:
fake_screen.answers = ["exit"]
main_module.main([])
assert fake_screen.instances[0].closed
def test_main_enables_click_coordinates_with_flag(
fake_screen: type[FakeTurtleScreen],
) -> None:
fake_screen.answers = ["exit"]
main_module.main(["--show-coordinates"])
assert fake_screen.instances[0].coordinates_enabled
def test_main_leaves_click_coordinates_off_by_default(
fake_screen: type[FakeTurtleScreen],
) -> None:
fake_screen.answers = ["exit"]
main_module.main([])
assert not fake_screen.instances[0].coordinates_enabled
def test_game_and_states_modules_do_not_import_turtle() -> None:
code = (
"import sys, us_states_game.game, us_states_game.states, "
"us_states_game.storage; "
"sys.exit(1 if 'turtle' in sys.modules else 0)"
)
result = subprocess.run([sys.executable, "-c", code], check=False)
assert result.returncode == 0