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 |
|
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 005 |
|
| MIL | Milestone / Gateway | docs/milestones/*.md | 005 |
|
||||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0002 |
|
| 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
|
## Languages
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 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 | [02b537e] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
[02b537e]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/025-us-states-game/commit/02b537e5384560a4e55fac137e8ad4fa236e6031
|
||||||
@@ -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.
|
## Text the player types to leave the game early.
|
||||||
EXIT_COMMAND = "exit"
|
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