From 02b537e5384560a4e55fac137e8ad4fa236e6031 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Tue, 13 Oct 2026 15:50:45 +0800 Subject: [PATCH 1/2] 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 --- docs/artifact-registry.md | 2 +- docs/sqa/reviews/rc-010-mil-003-code.md | 60 ++++++++++++++ src/us_states_game/__main__.py | 9 ++ src/us_states_game/constants.py | 22 +++++ src/us_states_game/game.py | 81 ++++++++++++++++++ src/us_states_game/main.py | 43 ++++++++++ src/us_states_game/turtle_screen.py | 72 ++++++++++++++++ tests/test_game.py | 104 ++++++++++++++++++++++++ tests/test_main.py | 91 +++++++++++++++++++++ 9 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 docs/sqa/reviews/rc-010-mil-003-code.md create mode 100644 src/us_states_game/__main__.py create mode 100644 src/us_states_game/game.py create mode 100644 src/us_states_game/main.py create mode 100644 src/us_states_game/turtle_screen.py create mode 100644 tests/test_game.py create mode 100644 tests/test_main.py diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index bf577c5..100fcd0 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g. | PP | Project Plan | docs/project-plan.md | 002 | | MIL | Milestone / Gateway | docs/milestones/*.md | 005 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0002 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 010 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 011 | ## Languages diff --git a/docs/sqa/reviews/rc-010-mil-003-code.md b/docs/sqa/reviews/rc-010-mil-003-code.md new file mode 100644 index 0000000..eca7eb5 --- /dev/null +++ b/docs/sqa/reviews/rc-010-mil-003-code.md @@ -0,0 +1,60 @@ +# SQA Review Record + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-010 | +| CrossReference | [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-13 | Proposed | Claude (AI) for S01 | AI as stand-in for S01 | Initial review record: Go | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: source and tests delivered by MIL-003 (branch mil-003-turtle-game) +- Checklist used: [QC-PY-001] +- Scope: full review +- Language and domain: n/a (technical type) +- Language reviewer: none +- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`; the author is the same AI) + +## Checklist Results + +### QC-PY-001 + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) (Mandatory) | Pass | PEP 8 casing; ruff N rules pass. `get_mouse_click_coor` keeps the assignment's name. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes (Mandatory) | Pass | Names state purpose; assignment names guessed_states and missing_states kept. | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions (Mandatory) | Pass | ruff format and check clean, no suppressions. | +| 4 | Every function and method signature is type-annotated, including `-> None` (Mandatory) | Pass | All signatures annotated incl. `-> None`; mypy --strict passes. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) (Mandatory) | N-A | No exception handling added; a missing data file raises to the caller. | +| 6 | No mutable default arguments and no shadowed builtins (Mandatory) | Pass | No mutable defaults (Path defaults are immutable); no builtin shadowed. | +| 7 | Files, locks and connections are managed with context managers (Mandatory) | Pass | No unmanaged resources; the window is closed in a `finally` in main(). | +| 8 | Public modules, classes and functions have docstrings that say what, not how (Optional) | Pass | Docstrings with @param/@return on public items. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output (Mandatory) | Pass | Click coordinates use `logging`; no print in the package. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded (Mandatory) | N-A | No Design Class Diagram exists; the Protocol and TurtleScreen are recorded in the module docstrings. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network (Mandatory) | Pass | 15 new tests with a fake screen and monkeypatched TurtleScreen; no display, network or order dependence. TurtleScreen itself is verified by the manual run. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment (Optional) | Pass | mypy strict reports no issues. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused (Optional) | Pass | No runtime dependencies. | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike (Mandatory) | Pass | Titles, prompts, font and sizes defined once in constants.py. | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles (Mandatory) | Pass | Only turtle_screen.py imports turtle; game.py depends on a Protocol. game.py imports storage (stdlib csv file output) directly: accepted for a program of this size, noted as a stand-in decision. | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations (Mandatory) | Pass | game.py loop, storage, screen and composition root each have one reason to change; the loop depends on the GameScreen Protocol and receives its implementation. | + +## Overall Verdict + +Go - Mandatory criteria pass for game.py, turtle_screen.py, main.py and their tests (ruff, mypy --strict, 33 pytest tests, doxygen without warnings). Manual run: the real window opened with the blank map and the prompt dialog titled 0/50 States Correct; the game process was then stopped. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None | S01 | | + +--- + +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md diff --git a/src/us_states_game/__main__.py b/src/us_states_game/__main__.py new file mode 100644 index 0000000..61c9a51 --- /dev/null +++ b/src/us_states_game/__main__.py @@ -0,0 +1,9 @@ +"""Runs the game with `python -m us_states_game`. + +@file __main__.py +@brief Module entry point that calls main(). +""" + +from us_states_game.main import main + +raise SystemExit(main()) diff --git a/src/us_states_game/constants.py b/src/us_states_game/constants.py index 99fd7eb..b693d88 100644 --- a/src/us_states_game/constants.py +++ b/src/us_states_game/constants.py @@ -37,3 +37,25 @@ NUMBER_OF_STATES = 50 ## Text the player types to leave the game early. EXIT_COMMAND = "exit" + +## Title shown while playing; filled with the guessed count and the total. +TITLE_FORMAT = "{guessed}/{total} States Correct" + +## Prompt of the answer dialog. +ANSWER_PROMPT = "What's another state's name?" + +## Name under which the map image is registered as a Turtle shape. +MAP_SHAPE_NAME = str(MAP_IMAGE_PATH) + +## Size in pixels of the map image, and so of the game window. +SCREEN_WIDTH = 725 +SCREEN_HEIGHT = 491 + +## Font of the state names written on the map: family, size and style. +STATE_FONT = ("Courier", 8, "normal") + +## Alignment of a state name written at its coordinates. +STATE_ALIGNMENT = "center" + +## Name of the logger that reports mouse click coordinates. +LOGGER_NAME = "us_states_game" diff --git a/src/us_states_game/game.py b/src/us_states_game/game.py new file mode 100644 index 0000000..6e531c4 --- /dev/null +++ b/src/us_states_game/game.py @@ -0,0 +1,81 @@ +"""Game loop of the U.S. States Game, independent of any screen technology. + +@file game.py +@brief Guess loop and the end-of-game export of the states to learn. + +The loop talks to the player through the `GameScreen` port, so it runs under +test with a fake screen and no display. +""" + +from collections.abc import Sequence +from pathlib import Path +from typing import Protocol + +from us_states_game.constants import ( + ANSWER_PROMPT, + EXIT_COMMAND, + NUMBER_OF_STATES, + STATES_TO_LEARN_FILENAME, + TITLE_FORMAT, +) +from us_states_game.states import State, find_state, get_missing_states +from us_states_game.storage import save_states_to_learn + + +class GameScreen(Protocol): + """What the game loop needs from the screen.""" + + def ask_state_name(self, title: str, prompt: str) -> str | None: + """Ask the player for a state name. + + @param title Title of the dialog, showing the progress. + @param prompt Question shown to the player. + @return The typed text, or None if the player cancelled. + """ + ... + + def write_state(self, state: State) -> None: + """Write the name of a state at its coordinates on the map. + + @param state The state to write. + """ + ... + + +def play(screen: GameScreen, states: Sequence[State]) -> list[str]: + """Run the guess loop until all states are guessed or the player exits. + + @param screen Screen used to ask for answers and to write names. + @param states All states of the game. + @return Names of the states guessed, in guessing order. + """ + guessed_states: list[str] = [] + while len(guessed_states) < NUMBER_OF_STATES: + title = TITLE_FORMAT.format(guessed=len(guessed_states), total=NUMBER_OF_STATES) + answer = screen.ask_state_name(title, ANSWER_PROMPT) + if answer is None or answer.strip().casefold() == EXIT_COMMAND: + break + state = find_state(answer, states) + if state is None or state.name in guessed_states: + continue + screen.write_state(state) + guessed_states.append(state.name) + return guessed_states + + +def run_game( + screen: GameScreen, + states: Sequence[State], + output_path: Path = Path(STATES_TO_LEARN_FILENAME), +) -> list[str]: + """Play the game and save the states the player missed. + + @param screen Screen used to ask for answers and to write names. + @param states All states of the game. + @param output_path File that receives the states to learn. + @return Names of the states not guessed. + """ + guessed_states = play(screen, states) + missing_states = get_missing_states(states, guessed_states) + save_states_to_learn(missing_states, output_path) + return missing_states diff --git a/src/us_states_game/main.py b/src/us_states_game/main.py new file mode 100644 index 0000000..7f7f125 --- /dev/null +++ b/src/us_states_game/main.py @@ -0,0 +1,43 @@ +"""Entry point of the U.S. States Game and its composition root. + +@file main.py +@brief Wires the Turtle screen, the states and the game loop together. +""" + +import argparse +import logging +from collections.abc import Sequence + +from us_states_game.game import run_game +from us_states_game.storage import load_states +from us_states_game.turtle_screen import TurtleScreen + + +def main(argv: Sequence[str] | None = None) -> int: + """Play the game once. + + @param argv Command line arguments; defaults to `sys.argv[1:]`. + @return Process exit code, 0 on success. + """ + parser = argparse.ArgumentParser(description="U.S. States Game") + parser.add_argument( + "--show-coordinates", + action="store_true", + help="log the coordinates of every mouse click on the map", + ) + args = parser.parse_args(argv) + + states = load_states() + screen = TurtleScreen() + if args.show_coordinates: + logging.basicConfig(level=logging.INFO) + screen.enable_click_coordinates() + try: + run_game(screen, states) + finally: + screen.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/us_states_game/turtle_screen.py b/src/us_states_game/turtle_screen.py new file mode 100644 index 0000000..7505536 --- /dev/null +++ b/src/us_states_game/turtle_screen.py @@ -0,0 +1,72 @@ +"""Turtle implementation of the game screen. + +@file turtle_screen.py +@brief The only module that imports turtle; draws the map and asks questions. +""" + +import logging +import turtle + +from us_states_game.constants import ( + LOGGER_NAME, + MAP_SHAPE_NAME, + SCREEN_HEIGHT, + SCREEN_TITLE, + SCREEN_WIDTH, + STATE_ALIGNMENT, + STATE_FONT, +) +from us_states_game.states import State + +logger = logging.getLogger(LOGGER_NAME) + + +def get_mouse_click_coor(x: float, y: float) -> None: + """Log the coordinates of a mouse click on the map. + + @param x Horizontal coordinate of the click. + @param y Vertical coordinate of the click. + """ + logger.info("Clicked at x=%s, y=%s", x, y) + + +class TurtleScreen: + """Game screen drawn with Turtle graphics; implements `GameScreen`.""" + + def __init__(self) -> None: + """Open the window with the blank map.""" + self.screen = turtle.Screen() + self.screen.setup(SCREEN_WIDTH, SCREEN_HEIGHT) + self.screen.title(SCREEN_TITLE) + self.screen.addshape(MAP_SHAPE_NAME) + self._map = turtle.Turtle() + self._map.shape(MAP_SHAPE_NAME) + self._pen = turtle.Turtle() + self._pen.hideturtle() + self._pen.penup() + + def ask_state_name(self, title: str, prompt: str) -> str | None: + """Ask the player for a state name in a text dialog. + + @param title Title of the dialog. + @param prompt Question shown to the player. + @return The typed text, or None if the player cancelled. + """ + answer: str | None = self.screen.textinput(title, prompt) + return answer + + def write_state(self, state: State) -> None: + """Write the state name on the map at its coordinates. + + @param state The state to write. + """ + self._pen.goto(state.x, state.y) + self._pen.write(state.name, align=STATE_ALIGNMENT, font=STATE_FONT) + + def enable_click_coordinates(self) -> None: + """Log the coordinates of every mouse click on the map.""" + self.screen.onscreenclick(get_mouse_click_coor) + + def close(self) -> None: + """Close the window.""" + self.screen.bye() diff --git a/tests/test_game.py b/tests/test_game.py new file mode 100644 index 0000000..8b2d9ca --- /dev/null +++ b/tests/test_game.py @@ -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"] diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..2cb9c62 --- /dev/null +++ b/tests/test_main.py @@ -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 From 7d97e85f900fbe20fce2081fa5851f390fa2a3c0 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Tue, 13 Oct 2026 15:50:49 +0800 Subject: [PATCH 2/2] Resolve pending commit link for RC-010 --- docs/sqa/reviews/rc-010-mil-003-code.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sqa/reviews/rc-010-mil-003-code.md b/docs/sqa/reviews/rc-010-mil-003-code.md index eca7eb5..38b0c38 100644 --- a/docs/sqa/reviews/rc-010-mil-003-code.md +++ b/docs/sqa/reviews/rc-010-mil-003-code.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-13 | Proposed | Claude (AI) for S01 | AI as stand-in for S01 | Initial review record: Go | pending | +| 2026-10-13 | Proposed | Claude (AI) for S01 | AI as stand-in for S01 | Initial review record: Go | [02b537e] | --- @@ -58,3 +58,4 @@ Go - Mandatory criteria pass for game.py, turtle_screen.py, main.py and their te --- [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[02b537e]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/025-us-states-game/commit/02b537e5384560a4e55fac137e8ad4fa236e6031