MIL-003 Turtle game and entry point #27
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user