From 5575658503111377f05f4456901faee0d42192f1 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 8 Oct 2026 18:31:01 +0800 Subject: [PATCH 1/8] Adopt the test fakes Copied unchanged from the base (020-snake-game, commit 1a638c9): the fake segment, the fake screen and the fake Turtle base class that record their calls, so every test runs without a display. The fake turtle module is installed with monkeypatch, so it is undone after each test. Task: MIL-002#1 Closes #8 --- tests/fakes.py | 300 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 tests/fakes.py diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..e9f7c21 --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,300 @@ +"""Fakes that stand in for turtles and the screen, so that no test opens a window.""" + +import importlib +import math +import os +import subprocess +import sys +import types +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Any + +import pytest + +from snake_game.snake import Snake + +SRC = Path(__file__).resolve().parents[1] / "src" +# Modules whose classes inherit from `Turtle`: they bind their base class when they +# are imported, so each test that installs the fake `turtle` module imports them anew. +INHERITING_MODULES = ("snake_game.food", "snake_game.scoreboard") + + +class FakeTerminatorError(Exception): + """Stands in for `turtle.Terminator`.""" + + +class FakeTclError(Exception): + """Stands in for `tkinter.TclError`.""" + + +class FakeSegment: + """A turtle that records what is done to it, in order, and keeps its state. + + It is a segment of the snake, and also the base class that stands in for + `turtle.Turtle` when `Food` and `Scoreboard` are tested. + """ + + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[object, ...]]] = [] + self.x = 0.0 + self.y = 0.0 + self.angle = 0.0 + + def shape(self, name: str, /) -> None: + self.calls.append(("shape", (name,))) + + def shapesize(self, stretch_wid: float, stretch_len: float) -> None: + self.calls.append(("shapesize", (stretch_wid, stretch_len))) + + def color(self, color: str, /) -> None: + self.calls.append(("color", (color,))) + + def speed(self, speed: str) -> None: + self.calls.append(("speed", (speed,))) + + def penup(self) -> None: + self.calls.append(("penup", ())) + + def hideturtle(self) -> None: + self.calls.append(("hideturtle", ())) + + def clear(self) -> None: + self.calls.append(("clear", ())) + + def write( + self, + arg: str, + align: str = "left", + font: tuple[str, int, str] = ("Arial", 8, "normal"), + ) -> None: + self.calls.append(("write", (arg, align, font))) + + def goto(self, x: float | tuple[float, float], y: float | None = None, /) -> None: + if isinstance(x, tuple): + x, y = x + assert y is not None + self.calls.append(("goto", (x, y))) + self.x, self.y = x, y + + def xcor(self) -> float: + return self.x + + def ycor(self) -> float: + return self.y + + def position(self) -> tuple[float, float]: + return (self.x, self.y) + + def distance(self, x: tuple[float, float], /) -> float: + return math.hypot(self.x - x[0], self.y - x[1]) + + def forward(self, distance: float, /) -> None: + self.calls.append(("forward", (distance,))) + radians = math.radians(self.angle) + self.x += round(distance * math.cos(radians), 10) + self.y += round(distance * math.sin(radians), 10) + + def heading(self) -> float: + return self.angle + + def setheading(self, to_angle: float, /) -> None: + self.calls.append(("setheading", (to_angle,))) + self.angle = float(to_angle) % 360 + + def call_names(self) -> list[str]: + """Return the names of the calls, in the order they were made.""" + return [name for name, _ in self.calls] + + +class FakeFood: + """A food that counts how often it was moved.""" + + def __init__(self, x: float, y: float) -> None: + self.x = x + self.y = y + self.refreshes = 0 + + def refresh(self) -> None: + self.refreshes += 1 + + def position(self) -> tuple[float, float]: + return (self.x, self.y) + + +class FakeScoreboard: + """A scoreboard that counts how often the score was raised.""" + + def __init__(self) -> None: + self.increases = 0 + self.game_overs = 0 + + def increase_score(self) -> None: + self.increases += 1 + + def game_over(self) -> None: + self.game_overs += 1 + + +class FakeScreen: + """A screen that records what is done to it, in order. + + After `frames_before_close` updates it behaves like a closed window: the next + `update` raises `closing_error`. + """ + + def __init__( + self, + frames_before_close: int = 0, + closing_error: type[Exception] = FakeTerminatorError, + ) -> None: + self.calls: list[tuple[str, tuple[object, ...]]] = [] + self.bindings: dict[str, Callable[[], object]] = {} + self._updates_left = frames_before_close + self._closing_error = closing_error + + def setup(self, width: float, height: float) -> None: + self.calls.append(("setup", (width, height))) + + def bgcolor(self, color: str, /) -> None: + self.calls.append(("bgcolor", (color,))) + + def title(self, titlestring: str, /) -> None: + self.calls.append(("title", (titlestring,))) + + def tracer(self, n: int, /) -> None: + self.calls.append(("tracer", (n,))) + + def listen(self) -> None: + self.calls.append(("listen", ())) + + def onkey(self, fun: Callable[[], object], key: str) -> None: + self.calls.append(("onkey", (key,))) + self.bindings[key] = fun + + def update(self) -> None: + self.calls.append(("update", ())) + if self._updates_left == 0: + raise self._closing_error + self._updates_left -= 1 + + def exitonclick(self) -> None: + self.calls.append(("exitonclick", ())) + + def call_names(self) -> list[str]: + """Return the names of the calls, in the order they were made.""" + return [name for name, _ in self.calls] + + +def make_snake() -> tuple[Snake, list[FakeSegment]]: + """Make a snake whose segments are fakes, and return the fakes too.""" + created: list[FakeSegment] = [] + + def factory() -> FakeSegment: + segment = FakeSegment() + created.append(segment) + return segment + + return Snake(segment_factory=factory), created + + +def install_fake_turtle( + monkeypatch: pytest.MonkeyPatch, + *, + frames_before_close: int = 0, + closing_error: type[Exception] = FakeTerminatorError, +) -> tuple[list[FakeSegment], list[FakeScreen]]: + """Replace the `turtle` and `tkinter` modules with fakes for one test. + + The fake screen acts like a window that the player closes after + `frames_before_close` updates, by raising `closing_error` from `update`. + Every turtle the code under test creates, including a `Food` or a `Scoreboard` + whose base class is the fake `Turtle`, is added to the first list returned; + every screen is added to the second. `food` and `scoreboard` are forgotten, so + that they are imported again against the fake and removed again after the test. + """ + segments: list[FakeSegment] = [] + screens: list[FakeScreen] = [] + + class RegisteredSegment(FakeSegment): + def __init__(self) -> None: + super().__init__() + segments.append(self) + + def make_screen() -> FakeScreen: + screen = FakeScreen(frames_before_close, closing_error) + screens.append(screen) + return screen + + turtle_module = types.ModuleType("turtle") + turtle_module.__dict__["Turtle"] = RegisteredSegment + turtle_module.__dict__["Screen"] = make_screen + turtle_module.__dict__["Terminator"] = FakeTerminatorError + tkinter_module = types.ModuleType("tkinter") + tkinter_module.__dict__["TclError"] = FakeTclError + monkeypatch.setitem(sys.modules, "turtle", turtle_module) + monkeypatch.setitem(sys.modules, "tkinter", tkinter_module) + for name in INHERITING_MODULES: + # Set, then delete: when the test ends monkeypatch undoes both in reverse + # order and the key is gone again, whatever the test imported meanwhile. + monkeypatch.setitem(sys.modules, name, types.ModuleType(name)) + monkeypatch.delitem(sys.modules, name) + return segments, screens + + +def script_randint( + monkeypatch: pytest.MonkeyPatch, values: Sequence[int] +) -> list[tuple[int, int]]: + """Make `random.randint` return the given values in turn, then the upper bound. + + Returns the list that collects the bounds of every call. + """ + queue = iter(values) + bounds: list[tuple[int, int]] = [] + + def fake_randint(low: int, high: int) -> int: + bounds.append((low, high)) + return next(queue, high) + + monkeypatch.setattr("random.randint", fake_randint) + return bounds + + +def make_food(monkeypatch: pytest.MonkeyPatch) -> Any: + """Make a real `Food` whose base class is the fake `Turtle`. + + The result is typed `Any` because it has the methods of `Food` (`refresh`) and + the recording methods of the fake base class (`calls`, `position`) at once. + """ + install_fake_turtle(monkeypatch) + module = importlib.import_module("snake_game.food") + return module.Food() + + +def make_scoreboard(monkeypatch: pytest.MonkeyPatch) -> Any: + """Make a real `Scoreboard` whose base class is the fake `Turtle`. + + The result is typed `Any` because it has the methods of `Scoreboard` + (`increase_score`) and the recording methods of the fake base class (`calls`) + at once. + """ + install_fake_turtle(monkeypatch) + module = importlib.import_module("snake_game.scoreboard") + return module.Scoreboard() + + +def imports_turtle_or_tkinter(module_name: str) -> bool: + """Tell whether importing a module in a fresh interpreter loads a display module.""" + code = ( + f"import sys, {module_name}; " + "print('turtle' in sys.modules or 'tkinter' in sys.modules)" + ) + result = subprocess.run( + [sys.executable, "-c", code], + env={**os.environ, "PYTHONPATH": str(SRC)}, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() == "True" -- 2.54.0 From 73fdc2026fae6b6f6ef789649cc7f06ee8037a49 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 8 Oct 2026 18:31:03 +0800 Subject: [PATCH 2/8] Adopt the Snake class and its tests Copied unchanged from the base: create_snake, add_segment, extend, move, hits_wall, hits_tail, up, down, left and right, with the segments list and the head. The tail check loops over the slice segments[1:]. snake.py does not import turtle until a real segment is made. Task: MIL-002#2 Closes #9 --- src/snake_game/snake.py | 239 +++++++++++++++++++++++++ tests/test_snake.py | 386 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 625 insertions(+) create mode 100644 src/snake_game/snake.py create mode 100644 tests/test_snake.py diff --git a/src/snake_game/snake.py b/src/snake_game/snake.py new file mode 100644 index 0000000..da16def --- /dev/null +++ b/src/snake_game/snake.py @@ -0,0 +1,239 @@ +"""! @file +@brief The snake: a list of square segments that the game draws and moves. + +The class follows the lectures "Create a Snake Class & Move to OOP", "Animating +the Snake Segments on Screen" and "Controlling the Snake with Keypresses", and +the day-21 steps in which the snake grows and the game ends. It keeps the lectures' +names: `Snake`, `segments`, `create_snake`, `add_segment`, `extend`, `move`, `head`, +`up`, `down`, `left` and `right`. The only differences are that the snake gets +the function that makes a segment as a parameter, so that a test can pass a fake +and needs no window, and how a reversal is refused (see Snake). +""" + +from collections.abc import Callable +from typing import Protocol + +from snake_game.constants import ( + DOWN, + LEFT, + MOVE_DISTANCE, + RIGHT, + SEGMENT_COLOR, + SEGMENT_SHAPE, + STARTING_POSITIONS, + TAIL_COLLISION_DISTANCE, + UP, + WALL_LIMIT, +) + + +class Segment(Protocol): + """! @brief What the snake needs from one of its segments. + + A `turtle.Turtle` fits this description, and so does a fake in a test. + """ + + def shape(self, name: str, /) -> object: + """! @brief Set the shape of the segment. + + @param name Name of the shape, for example `square`. + @return Whatever the implementation returns; the snake ignores it. + """ + ... + + def color(self, color: str, /) -> object: + """! @brief Set the colour of the segment. + + @param color Name of the colour, for example `white`. + @return Whatever the implementation returns; the snake ignores it. + """ + ... + + def penup(self) -> None: + """! @brief Lift the pen, so that moving the segment draws no line.""" + ... + + def goto(self, x: float, y: float, /) -> None: + """! @brief Send the segment to a position. + + @param x The x coordinate. + @param y The y coordinate. + """ + ... + + def xcor(self) -> float: + """! @brief Tell the x coordinate of the segment. + + @return The x coordinate. + """ + ... + + def ycor(self) -> float: + """! @brief Tell the y coordinate of the segment. + + @return The y coordinate. + """ + ... + + def forward(self, distance: float, /) -> None: + """! @brief Move the segment forward, in the direction it points. + + @param distance How far to move, in pixels. + """ + ... + + def position(self) -> tuple[float, float]: + """! @brief Tell where the segment is. + + @return The x and y coordinates. + """ + ... + + def distance(self, x: tuple[float, float], /) -> float: + """! @brief Tell how far the segment is from a position. + + @param x The x and y coordinates of the other place. + @return The distance in pixels. + """ + ... + + def heading(self) -> float: + """! @brief Tell the direction the segment points, in degrees. + + @return The heading, 0 for right and growing counter-clockwise. + """ + ... + + def setheading(self, to_angle: float, /) -> None: + """! @brief Turn the segment to point in a direction. + + @param to_angle The heading in degrees. + """ + ... + + +def make_turtle_segment() -> Segment: + """! @brief Make a real turtle, the default way to get a segment. + + `turtle` is imported here and not at the top of the module, so that + importing the snake needs neither a display nor `tkinter`. + + @return A new `turtle.Turtle`. + """ + from turtle import Turtle + + return Turtle() + + +class Snake: + """! @brief The snake of the game, drawn as a row of square segments. + + The snake cannot reverse onto itself. Like the lecture, a turn is refused when + it points opposite to the way the snake is going. Unlike the lecture, "the way + the snake is going" is the direction of its last move and not the current + direction of the head: otherwise two key presses within one move (Up then Left + while moving right) would turn the head twice and reverse the snake. + """ + + def __init__(self, segment_factory: Callable[[], Segment] | None = None) -> None: + """! @brief Create the snake with its three starting segments. + + @param segment_factory Makes one new segment; defaults to a real turtle. + """ + ## @brief Makes one new segment. + self._segment_factory = segment_factory or make_turtle_segment + ## @brief The segments of the snake, the head first. + self.segments: list[Segment] = [] + self.create_snake() + ## @brief The first segment, which leads the snake. + self.head: Segment = self.segments[0] + ## @brief The direction of the last move; a turn against it is refused. + self._direction_of_travel: float = self.head.heading() + + def create_snake(self) -> None: + """! @brief Draw one white square segment at each starting position. + + The segments are kept in `segments`, the head first. + """ + for position in STARTING_POSITIONS: + self.add_segment(position) + + def add_segment(self, position: tuple[float, float]) -> None: + """! @brief Add a white square segment at the end of the snake. + + The pen is lifted before the segment is sent to its position, so no line is + drawn. + + @param position The x and y coordinates of the new segment. + """ + new_segment = self._segment_factory() + new_segment.shape(SEGMENT_SHAPE) + new_segment.color(SEGMENT_COLOR) + new_segment.penup() + new_segment.goto(*position) + self.segments.append(new_segment) + + def extend(self) -> None: + """! @brief Make the snake one segment longer. + + The new segment appears where the last segment is, and follows it on the + next move. + """ + self.add_segment(self.segments[-1].position()) + + def move(self) -> None: + """! @brief Move the snake one step along its path. + + Each segment, from the last to the second, goes to the place of the + segment before it; then the head goes forward by `MOVE_DISTANCE`. Moving + the tail first keeps the body joined while turning, however many segments + there are. + """ + for index in range(len(self.segments) - 1, 0, -1): + ahead = self.segments[index - 1] + self.segments[index].goto(ahead.xcor(), ahead.ycor()) + self.head.forward(MOVE_DISTANCE) + self._direction_of_travel = self.head.heading() + + def hits_wall(self) -> bool: + """! @brief Tell whether the head has passed the wall on any side. + + The head is outside when its x or its y is beyond `WALL_LIMIT`. + + @return True when the head is outside the wall. + """ + return abs(self.head.xcor()) > WALL_LIMIT or abs(self.head.ycor()) > WALL_LIMIT + + def hits_tail(self) -> bool: + """! @brief Tell whether the head touches the tail. + + The tail is every segment behind the head (a slice, `segments[1:]`). The head + touches it when it is closer than `TAIL_COLLISION_DISTANCE` to one of them. + A snake that has only a head has no tail to touch. + + @return True when the head touches a segment of the tail. + """ + return any( + self.head.distance(segment.position()) < TAIL_COLLISION_DISTANCE + for segment in self.segments[1:] + ) + + def up(self) -> None: + """! @brief Turn the head up, unless the snake is moving down.""" + if self._direction_of_travel != DOWN: + self.head.setheading(UP) + + def down(self) -> None: + """! @brief Turn the head down, unless the snake is moving up.""" + if self._direction_of_travel != UP: + self.head.setheading(DOWN) + + def left(self) -> None: + """! @brief Turn the head left, unless the snake is moving right.""" + if self._direction_of_travel != RIGHT: + self.head.setheading(LEFT) + + def right(self) -> None: + """! @brief Turn the head right, unless the snake is moving left.""" + if self._direction_of_travel != LEFT: + self.head.setheading(RIGHT) diff --git a/tests/test_snake.py b/tests/test_snake.py new file mode 100644 index 0000000..8b8f86b --- /dev/null +++ b/tests/test_snake.py @@ -0,0 +1,386 @@ +"""Tests of the snake, with fakes instead of real turtles.""" + +import math +from collections.abc import Sequence + +import pytest + +from fakes import ( + FakeSegment, + imports_turtle_or_tkinter, + install_fake_turtle, + make_snake, +) +from snake_game import constants +from snake_game.snake import Segment, Snake + +DIRECTIONS = { + "up": constants.UP, + "down": constants.DOWN, + "left": constants.LEFT, + "right": constants.RIGHT, +} +OPPOSITE = { + constants.UP: constants.DOWN, + constants.DOWN: constants.UP, + constants.LEFT: constants.RIGHT, + constants.RIGHT: constants.LEFT, +} + + +def positions(segments: Sequence[Segment]) -> list[tuple[float, float]]: + """Return where every segment is, head first.""" + return [segment.position() for segment in segments] + + +def make_snake_moving(direction: int) -> tuple[Snake, list[FakeSegment]]: + """Make a snake whose last move went in the given direction.""" + snake, created = make_snake() + created[0].setheading(direction) + snake.move() + return snake, created + + +def test_snake_has_one_segment_per_starting_position() -> None: + snake, _ = make_snake() + + assert len(snake.segments) == len(constants.STARTING_POSITIONS) == 3 + + +def test_segments_are_the_created_turtles_in_order() -> None: + snake, created = make_snake() + + assert snake.segments == created + + +def test_segments_are_white_squares() -> None: + _, created = make_snake() + + for segment in created: + assert ("shape", ("square",)) in segment.calls + assert ("color", ("white",)) in segment.calls + + +def test_segments_are_placed_at_the_starting_positions_head_first() -> None: + _, created = make_snake() + + assert positions(created) == [ + (float(x), float(y)) for x, y in constants.STARTING_POSITIONS + ] + + +def test_pen_is_lifted_before_a_segment_is_moved() -> None: + _, created = make_snake() + + for segment in created: + names = segment.call_names() + assert names.count("goto") == 1 + assert names.index("penup") < names.index("goto") + + +def test_create_snake_adds_three_more_segments_when_called_again() -> None: + snake, _ = make_snake() + + snake.create_snake() + + assert len(snake.segments) == 2 * len(constants.STARTING_POSITIONS) + + +def test_default_segment_factory_makes_turtles(monkeypatch: pytest.MonkeyPatch) -> None: + turtles, _ = install_fake_turtle(monkeypatch) + + snake = Snake() + + assert snake.segments == turtles + assert len(turtles) == len(constants.STARTING_POSITIONS) + + +def test_importing_the_snake_module_does_not_import_turtle_or_tkinter() -> None: + assert not imports_turtle_or_tkinter("snake_game.snake") + + +def test_head_is_the_first_segment() -> None: + snake, created = make_snake() + + assert snake.head is created[0] + + +def test_snake_starts_moving_to_the_right() -> None: + snake, _ = make_snake() + + assert snake.head.heading() == constants.RIGHT + + +def test_move_goes_forward_by_the_move_distance() -> None: + snake, created = make_snake() + + snake.move() + + assert ("forward", (constants.MOVE_DISTANCE,)) in created[0].calls + assert created[0].position() == (constants.MOVE_DISTANCE, 0) + + +def test_move_takes_each_segment_to_the_place_of_the_one_before_it() -> None: + snake, created = make_snake() + + snake.move() + + assert positions(created) == [(20, 0), (0, 0), (-20, 0)] + + +def test_move_keeps_the_segments_joined_while_turning() -> None: + snake, created = make_snake() + + snake.up() + snake.move() + snake.move() + + assert positions(created) == [(0, 40), (0, 20), (0, 0)] + + +def test_move_works_for_any_number_of_segments() -> None: + snake, created = make_snake() + for index in range(3, 6): + extra = FakeSegment() + extra.goto(-20 * index, 0) + snake.segments.append(extra) + created.append(extra) + + snake.move() + + assert positions(created) == [ + (20, 0), + (0, 0), + (-20, 0), + (-40, 0), + (-60, 0), + (-80, 0), + ] + + +@pytest.mark.parametrize("travel", list(OPPOSITE)) +@pytest.mark.parametrize("method", list(DIRECTIONS)) +def test_every_turn_is_accepted_unless_it_is_a_reversal( + method: str, travel: int +) -> None: + snake, _ = make_snake_moving(travel) + + getattr(snake, method)() + + wanted = DIRECTIONS[method] + is_reversal = wanted == OPPOSITE[travel] + assert snake.head.heading() == (travel if is_reversal else wanted) + + +def test_up_sets_the_head_to_90_degrees() -> None: + snake, _ = make_snake() + + snake.up() + + assert snake.head.heading() == 90 + + +def test_down_sets_the_head_to_270_degrees() -> None: + snake, _ = make_snake() + + snake.down() + + assert snake.head.heading() == 270 + + +def test_right_keeps_the_head_at_0_degrees() -> None: + snake, _ = make_snake() + + snake.right() + + assert snake.head.heading() == 0 + + +def test_left_is_ignored_while_moving_right() -> None: + snake, _ = make_snake() + + snake.left() + + assert snake.head.heading() == 0 + + +def test_two_key_presses_within_one_move_do_not_reverse_the_snake() -> None: + snake, _ = make_snake() + + snake.up() + snake.left() + + assert snake.head.heading() == constants.UP + + +def test_a_turn_is_accepted_after_the_move_that_followed_the_first_turn() -> None: + snake, _ = make_snake() + + snake.up() + snake.move() + snake.left() + + assert snake.head.heading() == constants.LEFT + + +def test_add_segment_puts_a_white_square_with_the_pen_up_at_the_position() -> None: + snake, created = make_snake() + + snake.add_segment((100, -60)) + + new = created[-1] + assert snake.segments[-1] is new + assert ("shape", ("square",)) in new.calls + assert ("color", ("white",)) in new.calls + assert new.call_names().index("penup") < new.call_names().index("goto") + assert new.position() == (100, -60) + + +def test_extend_adds_one_segment_where_the_last_segment_is() -> None: + snake, created = make_snake() + last_place = created[-1].position() + + snake.extend() + + assert len(snake.segments) == len(constants.STARTING_POSITIONS) + 1 + assert snake.segments[-1].position() == last_place + + +def test_extend_twice_adds_two_segments() -> None: + snake, _ = make_snake() + + snake.extend() + snake.extend() + + assert len(snake.segments) == len(constants.STARTING_POSITIONS) + 2 + + +def test_head_stays_the_first_segment_after_extend() -> None: + snake, created = make_snake() + + snake.extend() + + assert snake.head is created[0] + assert snake.segments[0] is created[0] + + +def test_the_new_segment_follows_the_snake_and_the_body_stays_joined_when_turning() -> ( + None +): + snake, _ = make_snake() + snake.up() + snake.move() + snake.extend() + + snake.move() + + places = positions(snake.segments) + gaps = [ + math.dist(places[index], places[index + 1]) for index in range(len(places) - 1) + ] + assert gaps == [constants.MOVE_DISTANCE] * (len(places) - 1) + + +@pytest.mark.parametrize( + "place", [(281, 0), (-281, 0), (0, 281), (0, -281), (300, 300), (0, 1000)] +) +def test_the_head_has_passed_the_wall_beyond_the_limit_on_any_side( + place: tuple[int, int], +) -> None: + snake, _ = make_snake() + snake.head.goto(*place) + + assert snake.hits_wall() + + +@pytest.mark.parametrize( + "place", [(0, 0), (280, 0), (-280, 0), (0, 280), (0, -280), (280, -280)] +) +def test_the_head_has_not_passed_the_wall_at_or_inside_the_limit( + place: tuple[int, int], +) -> None: + snake, _ = make_snake() + snake.head.goto(*place) + + assert not snake.hits_wall() + + +@pytest.mark.parametrize("gap", [0, 5, 9]) +def test_the_head_touches_the_tail_closer_than_the_touch_distance(gap: int) -> None: + snake, created = make_snake() + created[1].goto(gap, 0) + + assert snake.hits_tail() + + +@pytest.mark.parametrize("gap", [10, 11, 20, 100]) +def test_the_head_does_not_touch_the_tail_at_or_beyond_the_touch_distance( + gap: int, +) -> None: + snake, created = make_snake() + created[1].goto(gap, 0) + + assert not snake.hits_tail() + + +def test_the_head_touches_any_segment_of_a_long_tail() -> None: + snake, created = make_snake() + for index in range(3, 8): + extra = FakeSegment() + extra.goto(-20 * index, 0) + snake.segments.append(extra) + created.append(extra) + created[6].goto(3, 4) # a distance of 5 from the head, far down the tail + + assert snake.hits_tail() + + +def test_a_new_snake_does_not_touch_its_tail() -> None: + snake, _ = make_snake() + + assert not snake.hits_tail() + + +def test_a_snake_with_only_a_head_has_no_tail_to_touch() -> None: + snake, created = make_snake() + snake.segments[:] = [created[0]] + created[0].goto(0, 0) + + assert not snake.hits_tail() + + +def test_the_new_segment_after_growing_does_not_touch_the_head() -> None: + snake, _ = make_snake() + snake.extend() + snake.move() + + assert not snake.hits_tail() + + +def test_the_snake_hits_its_tail_when_it_turns_into_it() -> None: + snake, created = make_snake() + for index in range(3, 5): # a snake of five segments + extra = FakeSegment() + extra.goto(-20 * index, 0) + snake.segments.append(extra) + created.append(extra) + # Up, left and down, one move each, walk the head round a square of 20 by 20 + # pixels and back onto the place where the fifth segment is. + for turn in (snake.up, snake.left, snake.down): + turn() + snake.move() + + assert snake.hits_tail() + + +def test_a_snake_of_four_segments_cannot_touch_its_tail_in_a_small_square() -> None: + snake, created = make_snake() + extra = FakeSegment() + extra.goto(-60, 0) + snake.segments.append(extra) + created.append(extra) + for turn in (snake.up, snake.left, snake.down): + turn() + snake.move() + + assert not snake.hits_tail() -- 2.54.0 From ab92d5871ce33788ad41ee2fa4307a4a9ca90110 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 8 Oct 2026 18:31:04 +0800 Subject: [PATCH 3/8] Adopt the Food class and its tests Copied unchanged from the base: class Food(Turtle) with refresh, and one random_coordinate function that the tests replace. MIL-003 checks the values against the day-21 lectures. Task: MIL-002#3 Closes #10 --- src/snake_game/food.py | 49 +++++++++++++++++++++++++++ tests/test_food.py | 77 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 src/snake_game/food.py create mode 100644 tests/test_food.py diff --git a/src/snake_game/food.py b/src/snake_game/food.py new file mode 100644 index 0000000..d88225e --- /dev/null +++ b/src/snake_game/food.py @@ -0,0 +1,49 @@ +"""! @file +@brief The food: a small dot that the snake eats. + +The class follows the day-21 lecture on inheritance: `Food` is a `Turtle` that +sets itself up and moves to a random place. Because it inherits from `Turtle`, +this module needs `turtle` (and so `tkinter`) when it is imported; the game +imports it only when it starts, and the tests install a fake `turtle` module +first. +""" + +import random +from turtle import Turtle + +from snake_game.constants import ( + FOOD_COLOR, + FOOD_SHAPE, + FOOD_SIZE, + FOOD_SPEED, + WALL_LIMIT, +) + + +def random_coordinate() -> int: + """! @brief Pick a random x or y inside the walls. + + All the randomness of the food comes from this one function, so that a test can + replace it. + + @return A whole number from `-WALL_LIMIT` to `WALL_LIMIT`. + """ + return random.randint(-WALL_LIMIT, WALL_LIMIT) + + +class Food(Turtle): + """! @brief The food, a blue circle at a random place inside the walls.""" + + def __init__(self) -> None: + """! @brief Set the food up and put it at a random place.""" + super().__init__() + self.shape(FOOD_SHAPE) + self.penup() + self.shapesize(stretch_wid=FOOD_SIZE, stretch_len=FOOD_SIZE) + self.color(FOOD_COLOR) + self.speed(FOOD_SPEED) + self.refresh() + + def refresh(self) -> None: + """! @brief Move the food to a new random place inside the walls.""" + self.goto(random_coordinate(), random_coordinate()) diff --git a/tests/test_food.py b/tests/test_food.py new file mode 100644 index 0000000..34a6cbb --- /dev/null +++ b/tests/test_food.py @@ -0,0 +1,77 @@ +"""Tests of the food, with a fake `Turtle` as its base class.""" + +import importlib +import sys + +import pytest + +from fakes import install_fake_turtle, make_food, script_randint +from snake_game import constants + + +def test_food_is_a_blue_half_size_circle_with_the_pen_up_and_no_animation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + food = make_food(monkeypatch) + + assert ("shape", ("circle",)) in food.calls + assert ("shapesize", (0.5, 0.5)) in food.calls + assert ("color", ("blue",)) in food.calls + assert ("speed", ("fastest",)) in food.calls + assert "penup" in food.call_names() + + +def test_food_inherits_from_turtle_and_calls_the_base_class_first( + monkeypatch: pytest.MonkeyPatch, +) -> None: + install_fake_turtle(monkeypatch) + module = importlib.import_module("snake_game.food") + + food = module.Food() + + assert issubclass(module.Food, sys.modules["turtle"].Turtle) + assert food.calls[0][0] == "shape" # the base class set up `calls` before + + +def test_food_starts_at_a_random_place(monkeypatch: pytest.MonkeyPatch) -> None: + script_randint(monkeypatch, [12, -34]) + + food = make_food(monkeypatch) + + assert food.position() == (12, -34) + + +def test_refresh_moves_the_food_to_a_new_random_place( + monkeypatch: pytest.MonkeyPatch, +) -> None: + script_randint(monkeypatch, [1, 2, 3, 4]) + food = make_food(monkeypatch) + + food.refresh() + + assert food.position() == (3, 4) + + +def test_random_coordinate_asks_for_a_number_from_wall_to_wall( + monkeypatch: pytest.MonkeyPatch, +) -> None: + bounds = script_randint(monkeypatch, [7]) + install_fake_turtle(monkeypatch) + module = importlib.import_module("snake_game.food") + + result = module.random_coordinate() + + assert result == 7 + assert bounds == [(-constants.WALL_LIMIT, constants.WALL_LIMIT)] + + +def test_random_places_are_always_inside_the_walls( + monkeypatch: pytest.MonkeyPatch, +) -> None: + install_fake_turtle(monkeypatch) + module = importlib.import_module("snake_game.food") + + places = [module.random_coordinate() for _ in range(500)] + + wall = constants.WALL_LIMIT + assert all(-wall <= place <= wall for place in places) -- 2.54.0 From ca65ba7830e345a36d3eac2fa1ae5d1216070903 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 8 Oct 2026 18:31:05 +0800 Subject: [PATCH 4/8] Adopt the Scoreboard class and its tests Copied unchanged from the base: class Scoreboard(Turtle) with update_scoreboard, increase_score and game_over. MIL-003 checks the values against the day-21 lectures. Task: MIL-002#4 Closes #11 --- src/snake_game/scoreboard.py | 58 ++++++++++++++++++++++++ tests/test_scoreboard.py | 85 ++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 src/snake_game/scoreboard.py create mode 100644 tests/test_scoreboard.py diff --git a/src/snake_game/scoreboard.py b/src/snake_game/scoreboard.py new file mode 100644 index 0000000..650108f --- /dev/null +++ b/src/snake_game/scoreboard.py @@ -0,0 +1,58 @@ +"""! @file +@brief The scoreboard: the text at the top of the screen that shows the score. + +The class follows the day-21 lecture on inheritance: `Scoreboard` is a `Turtle` +that writes text. Like `food`, this module needs `turtle` when it is imported; +the game imports it only when it starts, and the tests install a fake `turtle` +module first. +""" + +from turtle import Turtle + +from snake_game.constants import ( + GAME_OVER_POSITION, + GAME_OVER_TEXT, + SCORE_LABEL, + SCOREBOARD_ALIGNMENT, + SCOREBOARD_COLOR, + SCOREBOARD_FONT, + SCOREBOARD_POSITION, +) + + +class Scoreboard(Turtle): + """! @brief Shows the score as text at the top centre of the screen.""" + + def __init__(self) -> None: + """! @brief Set the scoreboard up and write the score 0.""" + super().__init__() + ## @brief The number of foods the snake has eaten in this game. + self.score = 0 + self.color(SCOREBOARD_COLOR) + self.penup() + self.hideturtle() + self.goto(SCOREBOARD_POSITION) + self.update_scoreboard() + + def update_scoreboard(self) -> None: + """! @brief Wipe the old text and write the label and the score.""" + self.clear() + self.write( + f"{SCORE_LABEL}{self.score}", + align=SCOREBOARD_ALIGNMENT, + font=SCOREBOARD_FONT, + ) + + def increase_score(self) -> None: + """! @brief Add 1 to the score and write it.""" + self.score += 1 + self.update_scoreboard() + + def game_over(self) -> None: + """! @brief Write the game-over text at the centre of the screen. + + The score stays where it is: the old text is not wiped. Automatic drawing is + off in the game, so the screen must be updated after this call. + """ + self.goto(GAME_OVER_POSITION) + self.write(GAME_OVER_TEXT, align=SCOREBOARD_ALIGNMENT, font=SCOREBOARD_FONT) diff --git a/tests/test_scoreboard.py b/tests/test_scoreboard.py new file mode 100644 index 0000000..68a7a08 --- /dev/null +++ b/tests/test_scoreboard.py @@ -0,0 +1,85 @@ +"""Tests of the scoreboard, with a fake `Turtle` as its base class.""" + +import importlib +import sys + +import pytest + +from fakes import install_fake_turtle, make_scoreboard +from snake_game import constants + +START_TEXT = ("write", ("Score: 0", "center", ("Arial", 24, "normal"))) + + +def test_scoreboard_is_white_hidden_and_at_the_top_centre( + monkeypatch: pytest.MonkeyPatch, +) -> None: + scoreboard = make_scoreboard(monkeypatch) + + assert ("color", ("white",)) in scoreboard.calls + assert "penup" in scoreboard.call_names() + assert "hideturtle" in scoreboard.call_names() + assert ("goto", constants.SCOREBOARD_POSITION) in scoreboard.calls + + +def test_scoreboard_inherits_from_turtle(monkeypatch: pytest.MonkeyPatch) -> None: + install_fake_turtle(monkeypatch) + module = importlib.import_module("snake_game.scoreboard") + + assert issubclass(module.Scoreboard, sys.modules["turtle"].Turtle) + + +def test_scoreboard_starts_by_writing_score_0_centred_in_arial_24( + monkeypatch: pytest.MonkeyPatch, +) -> None: + scoreboard = make_scoreboard(monkeypatch) + + assert scoreboard.calls[-1] == START_TEXT + + +def test_increase_score_wipes_the_old_text_and_writes_the_new_score( + monkeypatch: pytest.MonkeyPatch, +) -> None: + scoreboard = make_scoreboard(monkeypatch) + + scoreboard.increase_score() + + assert scoreboard.call_names()[-2:] == ["clear", "write"] + assert scoreboard.calls[-1] == ( + "write", + ("Score: 1", "center", ("Arial", 24, "normal")), + ) + + +def test_every_food_adds_one_to_the_score(monkeypatch: pytest.MonkeyPatch) -> None: + scoreboard = make_scoreboard(monkeypatch) + + for _ in range(3): + scoreboard.increase_score() + + assert scoreboard.calls[-1][1][0] == "Score: 3" + + +def test_game_over_writes_the_text_at_the_centre_in_the_scoreboard_font( + monkeypatch: pytest.MonkeyPatch, +) -> None: + scoreboard = make_scoreboard(monkeypatch) + + scoreboard.game_over() + + assert scoreboard.calls[-2:] == [ + ("goto", constants.GAME_OVER_POSITION), + ("write", ("GAME OVER", "center", ("Arial", 24, "normal"))), + ] + + +def test_game_over_leaves_the_score_on_the_screen( + monkeypatch: pytest.MonkeyPatch, +) -> None: + scoreboard = make_scoreboard(monkeypatch) + scoreboard.increase_score() + calls_before = len(scoreboard.calls) + + scoreboard.game_over() + + assert "clear" not in scoreboard.call_names()[calls_before:] -- 2.54.0 From 6b0ee0cc0da970b7b75d8e7b6c4c5c6726d669ed Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 8 Oct 2026 18:31:06 +0800 Subject: [PATCH 5/8] Adopt the main flow and the entry point Copied unchanged from the base: screen set-up, key bindings, the animation loop with game_is_on, the eating check, the game-over check, the wait for a click and the quiet exit when the window is closed. main imports food and scoreboard only when it runs, so importing it needs neither turtle nor tkinter. Task: MIL-002#5 Closes #12 --- src/snake_game/__main__.py | 8 + src/snake_game/main.py | 210 +++++++++++++++++++++++ tests/test_main.py | 342 +++++++++++++++++++++++++++++++++++++ 3 files changed, 560 insertions(+) create mode 100644 src/snake_game/__main__.py create mode 100644 src/snake_game/main.py create mode 100644 tests/test_main.py diff --git a/src/snake_game/__main__.py b/src/snake_game/__main__.py new file mode 100644 index 0000000..b845b7d --- /dev/null +++ b/src/snake_game/__main__.py @@ -0,0 +1,8 @@ +"""! @file +@brief Allows `python -m snake_game`. +""" + +from snake_game.main import main + +if __name__ == "__main__": + main() diff --git a/src/snake_game/main.py b/src/snake_game/main.py new file mode 100644 index 0000000..cc27772 --- /dev/null +++ b/src/snake_game/main.py @@ -0,0 +1,210 @@ +"""! @file +@brief The main flow of the game: set up the screen, draw the snake and run it. + +The flow follows the lectures "Screen Setup and Creating a Snake Body", +"Animating the Snake Segments on Screen" and "Controlling the Snake with +Keypresses", and the day-21 steps in which the snake eats food, the score rises and +the game ends. +""" + +import time +from collections.abc import Callable +from typing import Protocol + +from snake_game.constants import ( + FOOD_COLLISION_DISTANCE, + REFRESH_DELAY_SECONDS, + SCREEN_BACKGROUND_COLOR, + SCREEN_HEIGHT, + SCREEN_TITLE, + SCREEN_WIDTH, +) +from snake_game.snake import Snake + + +class ScreenLike(Protocol): + """! @brief What the helper functions need from the screen. + + A `turtle.Screen` fits this description, and so does a fake in a test. + """ + + def setup(self, width: float, height: float) -> None: + """! @brief Set the size of the window. + + @param width Width in pixels. + @param height Height in pixels. + """ + ... + + def bgcolor(self, color: str, /) -> None: + """! @brief Set the background colour. + + @param color Name of the colour, for example `black`. + """ + ... + + def title(self, titlestring: str, /) -> None: + """! @brief Set the title of the window. + + @param titlestring The title. + """ + ... + + def update(self) -> None: + """! @brief Draw everything that has changed since the last update.""" + ... + + def listen(self) -> None: + """! @brief Make the screen receive the key presses.""" + ... + + def onkey(self, fun: Callable[[], object], key: str) -> None: + """! @brief Call a function when a key is pressed. + + @param fun The function to call. + @param key Name of the key, for example `Up`. + """ + ... + + +class FoodLike(Protocol): + """! @brief What `eat_food_if_close` needs from the food. + + A `Food` fits this description, and so does a fake in a test. + """ + + def refresh(self) -> None: + """! @brief Move the food to a new random place.""" + ... + + def position(self) -> tuple[float, float]: + """! @brief Tell where the food is. + + @return The x and y coordinates. + """ + ... + + +class ScoreboardLike(Protocol): + """! @brief What the eating and game-over checks need from the scoreboard. + + A `Scoreboard` fits this description, and so does a fake in a test. + """ + + def increase_score(self) -> None: + """! @brief Add 1 to the score and write it.""" + ... + + def game_over(self) -> None: + """! @brief Write the game-over text.""" + ... + + +def configure_screen(screen: ScreenLike) -> None: + """! @brief Give the screen the size, background colour and title of the game. + + @param screen The screen to set up. + """ + screen.setup(width=SCREEN_WIDTH, height=SCREEN_HEIGHT) + screen.bgcolor(SCREEN_BACKGROUND_COLOR) + screen.title(SCREEN_TITLE) + + +def bind_keys(screen: ScreenLike, snake: Snake) -> None: + """! @brief Turn the snake with the arrow keys. + + @param screen The screen that receives the key presses. + @param snake The snake to steer. + """ + screen.listen() + screen.onkey(snake.up, "Up") + screen.onkey(snake.down, "Down") + screen.onkey(snake.left, "Left") + screen.onkey(snake.right, "Right") + + +def play_frame(screen: ScreenLike, snake: Snake) -> None: + """! @brief Show the snake, wait for `REFRESH_DELAY_SECONDS`, then move it. + + This is one pass of the animation loop. The screen is updated by hand because + automatic drawing is off, so the whole snake appears at once. + + @param screen The screen to update. + @param snake The snake to move. + """ + screen.update() + time.sleep(REFRESH_DELAY_SECONDS) + snake.move() + + +def eat_food_if_close(snake: Snake, food: FoodLike, scoreboard: ScoreboardLike) -> None: + """! @brief Let the snake eat the food when the head is close enough to it. + + Eating moves the food to a new place, makes the snake one segment longer and + adds 1 to the score. The head must be closer than `FOOD_COLLISION_DISTANCE`. + + @param snake The snake. + @param food The food. + @param scoreboard The scoreboard that shows the score. + """ + if snake.head.distance(food.position()) < FOOD_COLLISION_DISTANCE: + food.refresh() + snake.extend() + scoreboard.increase_score() + + +def end_game_if_over(snake: Snake, scoreboard: ScoreboardLike) -> bool: + """! @brief End the game when the head passes the wall or touches the tail. + + When the game is over the scoreboard writes the game-over text. + + @param snake The snake. + @param scoreboard The scoreboard that writes the game-over text. + @return True when the game is over. + """ + if snake.hits_wall() or snake.hits_tail(): + scoreboard.game_over() + return True + return False + + +def main() -> None: + """! @brief Open the game window and run the snake until the window is closed. + + Closing the window during the animation loop makes `screen.update()` raise + `tkinter.TclError` ("invalid command name"), and `turtle` raises + `turtle.Terminator` in some other calls once its window is gone. Both mean + "the player closed the window", so the game ends quietly with exit code 0. + When the snake passes the wall or touches its tail the loop ends, the screen is + updated so that the game-over text shows, and `screen.exitonclick()` waits for a + click. + + `turtle` and `tkinter` are imported here and not at the top of the module, and + so are `food` and `scoreboard`, whose classes inherit from `Turtle`: importing + this module needs neither a display nor `tkinter`. + """ + from tkinter import TclError + from turtle import Screen, Terminator + + from snake_game.food import Food + from snake_game.scoreboard import Scoreboard + + screen = Screen() + configure_screen(screen) + screen.tracer(0) + snake = Snake() + food = Food() + scoreboard = Scoreboard() + bind_keys(screen, snake) + + game_is_on = True + try: + while game_is_on: + play_frame(screen, snake) + eat_food_if_close(snake, food, scoreboard) + if end_game_if_over(snake, scoreboard): + game_is_on = False + screen.update() + screen.exitonclick() + except (Terminator, TclError): + return # the window was closed: there is nothing left to do diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..306323b --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,342 @@ +"""Tests of the main flow, with fakes instead of a real screen.""" + +import pytest + +from fakes import ( + FakeFood, + FakeScoreboard, + FakeScreen, + FakeTclError, + FakeTerminatorError, + imports_turtle_or_tkinter, + install_fake_turtle, + make_snake, + script_randint, +) +from snake_game import constants +from snake_game.main import ( + bind_keys, + configure_screen, + eat_food_if_close, + end_game_if_over, + main, + play_frame, +) + +ARROW_KEYS = ("Up", "Down", "Left", "Right") + + +@pytest.fixture(autouse=True) +def food_far_away(monkeypatch: pytest.MonkeyPatch) -> None: + """Make the food appear at the top right corner of the wall, far from the snake.""" + script_randint(monkeypatch, []) + + +@pytest.fixture +def sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]: + """Replace `time.sleep` for one test and collect the waits requested.""" + waited: list[float] = [] + monkeypatch.setattr("time.sleep", waited.append) + return waited + + +def test_configure_screen_sets_size_background_and_title_and_nothing_else() -> None: + screen = FakeScreen() + + configure_screen(screen) + + assert screen.calls == [ + ("setup", (constants.SCREEN_WIDTH, constants.SCREEN_HEIGHT)), + ("bgcolor", (constants.SCREEN_BACKGROUND_COLOR,)), + ("title", (constants.SCREEN_TITLE,)), + ] + + +def test_bind_keys_listens_and_binds_the_four_arrow_keys_to_the_snake() -> None: + screen = FakeScreen() + snake, _ = make_snake() + + bind_keys(screen, snake) + + assert screen.call_names() == ["listen", "onkey", "onkey", "onkey", "onkey"] + assert screen.bindings == { + "Up": snake.up, + "Down": snake.down, + "Left": snake.left, + "Right": snake.right, + } + + +@pytest.mark.parametrize( + ("key", "degrees"), + [("Up", constants.UP), ("Down", constants.DOWN), ("Right", constants.RIGHT)], +) +def test_pressing_a_bound_key_turns_the_head(key: str, degrees: int) -> None: + screen = FakeScreen() + snake, _ = make_snake() + bind_keys(screen, snake) + + screen.bindings[key]() + + assert snake.head.heading() == degrees + + +def test_play_frame_updates_the_screen_then_waits_then_moves( + monkeypatch: pytest.MonkeyPatch, +) -> None: + screen = FakeScreen(frames_before_close=1) + snake, created = make_snake() + seen: list[tuple[float, list[str], list[str]]] = [] + + def record_sleep(seconds: float) -> None: + seen.append((seconds, screen.call_names(), created[0].call_names())) + + monkeypatch.setattr("time.sleep", record_sleep) + + play_frame(screen, snake) + + assert len(seen) == 1 + seconds, screen_calls_at_sleep, head_calls_at_sleep = seen[0] + assert seconds == constants.REFRESH_DELAY_SECONDS + assert screen_calls_at_sleep == ["update"] + assert "forward" not in head_calls_at_sleep + assert "forward" in created[0].call_names() + + +def test_main_sets_up_the_screen_turns_off_drawing_and_binds_the_keys( + monkeypatch: pytest.MonkeyPatch, sleeps: list[float] +) -> None: + _, screens = install_fake_turtle(monkeypatch) + + main() + + assert len(screens) == 1 + assert screens[0].call_names()[:9] == [ + "setup", + "bgcolor", + "title", + "tracer", + "listen", + "onkey", + "onkey", + "onkey", + "onkey", + ] + assert ("tracer", (0,)) in screens[0].calls + assert sorted(screens[0].bindings) == sorted(ARROW_KEYS) + + +def test_main_draws_the_snake_before_the_first_frame( + monkeypatch: pytest.MonkeyPatch, sleeps: list[float] +) -> None: + turtles, _ = install_fake_turtle(monkeypatch) + + main() + + snake_segments = turtles[: len(constants.STARTING_POSITIONS)] + assert len(turtles) == len(constants.STARTING_POSITIONS) + 2 # food, scoreboard + assert all( + ("goto", position) in turtle.calls + for turtle, position in zip( + snake_segments, constants.STARTING_POSITIONS, strict=True + ) + ) + + +@pytest.mark.parametrize("closing_error", [FakeTerminatorError, FakeTclError]) +def test_main_ends_quietly_when_the_window_is_closed( + monkeypatch: pytest.MonkeyPatch, + sleeps: list[float], + closing_error: type[Exception], +) -> None: + turtles, screens = install_fake_turtle( + monkeypatch, frames_before_close=3, closing_error=closing_error + ) + + main() + + assert screens[0].call_names().count("update") == 3 + 1 + assert turtles[0].call_names().count("forward") == 3 + assert sleeps == [constants.REFRESH_DELAY_SECONDS] * 3 + assert "exitonclick" not in screens[0].call_names() + + +def test_main_lets_other_errors_through( + monkeypatch: pytest.MonkeyPatch, sleeps: list[float] +) -> None: + install_fake_turtle(monkeypatch, closing_error=KeyError) + + with pytest.raises(KeyError): + main() + + +def test_the_snake_eats_food_closer_than_the_eating_distance() -> None: + snake, created = make_snake() + food = FakeFood(constants.FOOD_COLLISION_DISTANCE - 1, 0) + scoreboard = FakeScoreboard() + + eat_food_if_close(snake, food, scoreboard) + + assert food.refreshes == 1 + assert scoreboard.increases == 1 + assert len(snake.segments) == len(created) == len(constants.STARTING_POSITIONS) + 1 + + +@pytest.mark.parametrize( + "place", + [ + (constants.FOOD_COLLISION_DISTANCE, 0), + (0, -constants.FOOD_COLLISION_DISTANCE), + (2 * constants.FOOD_COLLISION_DISTANCE, 0), + (200, 200), + ], +) +def test_the_snake_does_not_eat_food_at_or_beyond_the_eating_distance( + place: tuple[int, int], +) -> None: + snake, _ = make_snake() + food = FakeFood(*place) + scoreboard = FakeScoreboard() + + eat_food_if_close(snake, food, scoreboard) + + assert food.refreshes == 0 + assert scoreboard.increases == 0 + assert len(snake.segments) == len(constants.STARTING_POSITIONS) + + +def test_the_snake_eats_food_that_is_close_diagonally() -> None: + snake, _ = make_snake() + food = FakeFood(10, 10) # a distance of about 14.1 + scoreboard = FakeScoreboard() + + eat_food_if_close(snake, food, scoreboard) + + assert scoreboard.increases == 1 + + +def test_main_lets_the_snake_eat_the_food_that_lies_on_its_way( + monkeypatch: pytest.MonkeyPatch, sleeps: list[float] +) -> None: + script_randint(monkeypatch, [20, 0]) # the first food lies one move ahead + turtles, _ = install_fake_turtle(monkeypatch, frames_before_close=1) + + main() + + snake_head, _, _, food, scoreboard, new_segment = turtles + assert snake_head.position() == (20, 0) + assert [call for call in food.calls if call[0] == "goto"] == [ + ("goto", (20, 0)), + ("goto", (constants.WALL_LIMIT, constants.WALL_LIMIT)), + ] + assert scoreboard.calls[-1][1][0] == "Score: 1" + assert new_segment.position() == (-20, 0) # where the last segment was + + +def test_main_does_not_raise_the_score_when_the_food_is_far_away( + monkeypatch: pytest.MonkeyPatch, sleeps: list[float] +) -> None: + turtles, _ = install_fake_turtle(monkeypatch, frames_before_close=3) + + main() + + scoreboard = turtles[4] + assert scoreboard.calls[-1][1][0] == "Score: 0" + assert len(turtles) == len(constants.STARTING_POSITIONS) + 2 + + +def test_the_game_is_not_over_while_the_snake_is_inside_and_clear_of_its_tail() -> None: + snake, _ = make_snake() + scoreboard = FakeScoreboard() + + assert not end_game_if_over(snake, scoreboard) + assert scoreboard.game_overs == 0 + + +def test_the_game_is_over_when_the_head_passes_the_wall() -> None: + snake, _ = make_snake() + snake.head.goto(constants.WALL_LIMIT + 1, 0) + scoreboard = FakeScoreboard() + + assert end_game_if_over(snake, scoreboard) + assert scoreboard.game_overs == 1 + + +def test_the_game_is_over_when_the_head_touches_the_tail() -> None: + snake, created = make_snake() + created[1].goto(3, 4) + scoreboard = FakeScoreboard() + + assert end_game_if_over(snake, scoreboard) + assert scoreboard.game_overs == 1 + + +def test_main_ends_the_game_at_the_wall_shows_game_over_and_waits_for_a_click( + monkeypatch: pytest.MonkeyPatch, sleeps: list[float] +) -> None: + turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=100) + + main() + + snake_head, scoreboard = turtles[0], turtles[4] + moves_to_pass_the_wall = constants.WALL_LIMIT // constants.MOVE_DISTANCE + 1 + assert snake_head.call_names().count("forward") == moves_to_pass_the_wall + assert snake_head.position() == ( + constants.MOVE_DISTANCE * moves_to_pass_the_wall, + 0, + ) + assert scoreboard.calls[-1] == ( + "write", + ("GAME OVER", "center", ("Arial", 24, "normal")), + ) + assert screens[0].call_names()[-2:] == ["update", "exitonclick"] + assert screens[0].call_names().count("exitonclick") == 1 + assert sleeps == [constants.REFRESH_DELAY_SECONDS] * moves_to_pass_the_wall + + +def test_main_ends_the_game_when_the_snake_touches_its_tail( + monkeypatch: pytest.MonkeyPatch, sleeps: list[float] +) -> None: + monkeypatch.setattr("snake_game.snake.Snake.hits_tail", lambda self: True) + turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=100) + + main() + + assert turtles[0].call_names().count("forward") == 1 + assert turtles[4].calls[-1][1][0] == "GAME OVER" + assert screens[0].call_names()[-1] == "exitonclick" + + +def test_main_does_not_move_the_snake_after_game_over( + monkeypatch: pytest.MonkeyPatch, sleeps: list[float] +) -> None: + turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=100) + + main() + + updates = screens[0].call_names().count("update") + moves = turtles[0].call_names().count("forward") + assert updates == moves + 1 # one update per frame, and one for the last text + + +@pytest.mark.parametrize("closing_error", [FakeTerminatorError, FakeTclError]) +def test_main_ends_quietly_when_the_window_is_closed_after_game_over( + monkeypatch: pytest.MonkeyPatch, + sleeps: list[float], + closing_error: type[Exception], +) -> None: + moves_to_pass_the_wall = constants.WALL_LIMIT // constants.MOVE_DISTANCE + 1 + turtles, screens = install_fake_turtle( + monkeypatch, + frames_before_close=moves_to_pass_the_wall, + closing_error=closing_error, + ) + + main() + + assert turtles[4].calls[-1][1][0] == "GAME OVER" + assert "exitonclick" not in screens[0].call_names() + + +def test_importing_the_main_module_does_not_import_turtle_or_tkinter() -> None: + assert not imports_turtle_or_tkinter("snake_game.main") -- 2.54.0 From 38138656a1bb4f0569423e56d3cb6240a24dd641 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 8 Oct 2026 18:31:07 +0800 Subject: [PATCH 6/8] Describe the adopted game in the README The Status, Run and Project layout sections now describe python -m snake_game, the keys, the food, the score and the game-over rules, taken from the base README. MIL-003 finishes the README for the whole game. Task: MIL-002#6 Closes #13 --- README.md | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2dc500d..2187053 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,10 @@ This repository continues [020-snake-game](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/020-snake-game) and starts from its finished game; `docs/project-plan.md` tells how the work is split. -> **Status:** the project foundation is in place (environment, constants, tests, -> source documentation, continuous integration). The game itself, `python -m -> snake_game`, is added by the next milestone, `MIL-002`. +> **Status:** the game is in the repository: `python -m snake_game` plays the whole +> game, adopted from the finished game of 020 together with its tests. The next +> milestone, `MIL-003`, checks it against the day-21 lectures and finishes this +> README. ## Requirements @@ -84,9 +85,27 @@ With the virtual environment active: python -m snake_game ``` -The game is not in the repository yet: `python -m snake_game` works once `MIL-002` -is merged, and this section is then completed with the keys, the food, the score -and the game-over rules. +It opens a black 600 by 600 window titled "My Snake Game". The snake of three +white squares starts in the middle and moves to the right by itself, 20 pixels +every 0.1 seconds. + +| Key | Effect | +| --- | --- | +| Up, Down, Left, Right | Turn the snake | + +The snake never turns straight back onto itself: the arrow key opposite to the +way it is going is ignored, even when two keys are pressed within one move. + +A small blue circle, the food, appears at a random place. When the head comes closer +to it than 15 pixels the snake eats it: the food moves to a new random place, the +snake grows by one segment, and the score at the top of the window goes up by 1 +(`Score: 0`, `Score: 1`, ...). + +The game is over when the head passes the wall (more than 280 pixels from the centre +on any side) or touches the tail (comes closer than 10 pixels to a segment behind it). +The snake stops, the text `GAME OVER` appears in the middle of the window, the score +stays where it is, and a click on the window closes it. You can also close the window +with its close button at any time. ## Run the tests @@ -137,18 +156,19 @@ The HTML is written to `build/doxygen/index.html`. A warning fails the build. ├── docs/ business case, plan, milestones, reviews ├── src/snake_game/ the game │ ├── __init__.py -│ └── constants.py every constant of the game -├── tests/ pytest tests -│ └── test_constants.py +│ ├── __main__.py starts the game: python -m snake_game +│ ├── constants.py every constant of the game +│ ├── food.py the Food class (inherits from Turtle) +│ ├── main.py screen set-up and the main flow +│ ├── scoreboard.py the Scoreboard class (inherits from Turtle) +│ └── snake.py the Snake class +├── tests/ pytest tests (fakes.py holds the fake turtle and screen) ├── Doxyfile source documentation settings ├── LICENSE ├── pyproject.toml project configuration └── README.md ``` -The game modules (`snake.py`, `food.py`, `scoreboard.py`, `main.py`, -`__main__.py`) and their tests are added by `MIL-002`. - ## License GNU Affero General Public License v3.0 only. See [LICENSE](LICENSE). -- 2.54.0 From 0d3b91321d2b88badbf51644a8b950159d1240c0 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 8 Oct 2026 18:31:08 +0800 Subject: [PATCH 7/8] Record the review of the MIL-002 code (RC-008) Python code reviewed against QC-PY-001 with the verdict Go. Task 7 stays open: the manual play of criterion 5 is S01's. Task: MIL-002#7 Refs #14 --- docs/artifact-registry.md | 2 +- docs/sqa/reviews/rc-008-mil-002-code.md | 61 +++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 docs/sqa/reviews/rc-008-mil-002-code.md diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 1bbc5ee..efefd0f 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 | 004 | | DICT | Domain Dictionary | docs/dictionary.md | 002 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 008 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 009 | ## Languages diff --git a/docs/sqa/reviews/rc-008-mil-002-code.md b/docs/sqa/reviews/rc-008-mil-002-code.md new file mode 100644 index 0000000..9b07262 --- /dev/null +++ b/docs/sqa/reviews/rc-008-mil-002-code.md @@ -0,0 +1,61 @@ +# RC-008: Review of the MIL-002 code + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-008 | +| CrossReference | [MIL-002], [QC-PY-001], [BC-001], [PP-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-08 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-002] tasks 1 to 5: `src/snake_game/snake.py`, `food.py`, `scoreboard.py`, `main.py`, `__main__.py` and the tests `tests/fakes.py`, `test_snake.py`, `test_food.py`, `test_scoreboard.py`, `test_main.py` +- Checklist used: [QC-PY-001] +- Scope: full review. `constants.py` and `test_constants.py` were reviewed in RC-007. The files are byte-identical to the base ([020-snake-game], commit `1a638c9`), checked with `cmp` for all ten files, so the review reads them as adopted code and does not ask for changes that the base does not have. +- Language and domain: n/a (technical type) +- Language reviewer: none + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Classes `Snake`, `Food`, `Scoreboard`, `Segment`, `ScreenLike`, `FoodLike`, `ScoreboardLike` in `PascalCase`; functions and methods in `snake_case`; constants in `UPPER_SNAKE`; the ruff `N` (pep8-naming) rules pass. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names are the lecture's and the dictionary's: `create_snake`, `add_segment`, `extend`, `hits_wall`, `hits_tail`, `refresh`, `increase_score`, `update_scoreboard`, `game_over`, `eat_food_if_close`, `end_game_if_over`, `game_is_on`. The same names appear in [DICT-001]. The only single-letter names are the parameters `x` and `y` of `goto` and `distance` in the `Segment` protocol, which mirror the `turtle` API (found with an AST search). | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format --check src tests` reports 13 files already formatted; `ruff check src tests` reports all checks passed (rules E, F, W, I, N, UP, B, SIM); a search found no `noqa` and no `type: ignore`. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy` in strict mode reports no issues in 13 source files, which fails on an unannotated definition. Protocols (`Segment`, `ScreenLike`, `FoodLike`, `ScoreboardLike`) type the turtle objects so that the logic can take fakes. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | The only handler is `except (Terminator, TclError)` in `main.main`. It names two specific types, catches an external signal that the player closed the window, is explained in the docstring and in a comment, and a test covers the quiet exit. Nothing is raised, so there is no cause to keep. The convention warns against exceptions as normal control flow; here the window toolkit raises them, so S01 may want to confirm the decision. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | The only default argument is `segment_factory: ... | None = None`. An AST search for arguments, variables and definitions with a builtin's name found none in any module. | +| 7 | Files, locks and connections are managed with context managers | N-A | The modules open no file, lock or connection. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Every module, class, function and method has a Doxygen docstring or `##` comment; `doxygen Doxyfile` ends with 0 warnings (the Doxyfile fails the build on any warning). | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | A search found no `print` and no `logging` in `src`; the token values of `.env` occur in no tracked file. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No Design Class Diagram exists in this project; the classes and operations trace to the day-21 lectures, to the base and to tasks 2 to 5 of [MIL-002]. The deviation is recorded in the Traceability section of [MIL-002]. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | `pytest` runs 128 tests (91 test functions, some parametrized), all green, none opens a window. The fake `turtle` and `tkinter` modules are installed with `monkeypatch.setitem`, which pytest undoes after each test. Each test file passes alone, and all files pass in reverse order. One test starts a fresh interpreter to prove that importing `main` loads no display module; it uses no network. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `strict = true`; `python -m mypy` ends with "Success: no issues found in 13 source files". `Any` occurs only as the return type of `make_food` and `make_scoreboard` in `tests/fakes.py`, each with a docstring that says why. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | No new dependency: the code uses the standard library only (`random`, `time`, `turtle`, `tkinter`, `typing`, `collections.abc`). The `dev` extra is as reviewed in RC-007. | + +## Overall Verdict + +Go — all Mandatory criteria pass; criterion 7 is N-A because no module opens a file, lock or connection. Commands run on 2026-10-08 in the `.venv` in Windows PowerShell: `pytest` (128 passed), `ruff check`, `ruff format --check`, `mypy`, `doxygen Doxyfile` (0 warnings). The assistant also started `python -m snake_game` twice and closed its window, once after about 2 seconds (during play) and once after about 5 seconds, by which time a snake moving right should have passed the wall: both ended with exit code 0 and an empty error output. The window title was "My Snake Game". The game-over text was not seen. That is a smoke test, not the manual play that [MIL-002] criterion 5 asks of S01. This review is **not independent**: the assistant that adopted the code also reviewed it, and author and reviewer (S01) are one person in a single-person project (risk recorded in [BC-001] and [PP-001]). S01 can overrule this verdict at the pull request. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| Play the game in Windows PowerShell and confirm [MIL-002] criterion 5 (moves, turns, food, growth, score, wall, tail) | S01 | 2026-10-12 | +| Read the MIL-002 code and the list of differences from the base (none), and confirm or overrule this `Go` before the pull request is merged | S01 | 2026-10-12 | +| Confirm that catching `Terminator` and `TclError` to end quietly when the window is closed is acceptable (criterion 5) | S01 | 2026-10-12 | + +--- + +[MIL-002]: ../../milestones/mil-002-adopt-the-game.md +[QC-PY-001]: ../../../framework/qc/qc-programming-python.md +[BC-001]: ../../business-case.md +[PP-001]: ../../project-plan.md +[020-snake-game]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/020-snake-game +[DICT-001]: ../../dictionary.md -- 2.54.0 From efd0c7fc00d874d28407d16d31f7315a11394f13 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 8 Oct 2026 18:31:25 +0800 Subject: [PATCH 8/8] Resolve pending commit links Replaces pending in the Version History of RC-008 with a link to the commit that introduced the row. --- docs/sqa/reviews/rc-008-mil-002-code.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sqa/reviews/rc-008-mil-002-code.md b/docs/sqa/reviews/rc-008-mil-002-code.md index 9b07262..632bbc6 100644 --- a/docs/sqa/reviews/rc-008-mil-002-code.md +++ b/docs/sqa/reviews/rc-008-mil-002-code.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-08 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | pending | +| 2026-10-08 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | [0d3b913] | --- @@ -59,3 +59,4 @@ Go — all Mandatory criteria pass; criterion 7 is N-A because no module opens a [PP-001]: ../../project-plan.md [020-snake-game]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/020-snake-game [DICT-001]: ../../dictionary.md +[0d3b913]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/021-snake-game/commit/0d3b91321d2b88badbf51644a8b950159d1240c0 -- 2.54.0