MIL-002: Adopt the game #24

Merged
Tirsvad merged 8 commits from mil-002-adopt-the-game into main 2026-10-08 12:38:27 +02:00
13 changed files with 1849 additions and 13 deletions
+32 -12
View File
@@ -17,9 +17,10 @@ This repository continues
[020-snake-game](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/020-snake-game) [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. 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, > **Status:** the game is in the repository: `python -m snake_game` plays the whole
> source documentation, continuous integration). The game itself, `python -m > game, adopted from the finished game of 020 together with its tests. The next
> snake_game`, is added by the next milestone, `MIL-002`. > milestone, `MIL-003`, checks it against the day-21 lectures and finishes this
> README.
## Requirements ## Requirements
@@ -84,9 +85,27 @@ With the virtual environment active:
python -m snake_game python -m snake_game
``` ```
The game is not in the repository yet: `python -m snake_game` works once `MIL-002` It opens a black 600 by 600 window titled "My Snake Game". The snake of three
is merged, and this section is then completed with the keys, the food, the score white squares starts in the middle and moves to the right by itself, 20 pixels
and the game-over rules. 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 ## 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 ├── docs/ business case, plan, milestones, reviews
├── src/snake_game/ the game ├── src/snake_game/ the game
│ ├── __init__.py │ ├── __init__.py
│ └── constants.py every constant of the game │ ├── __main__.py starts the game: python -m snake_game
├── tests/ pytest tests │ ├── constants.py every constant of the game
│ └── test_constants.py │ ├── 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 ├── Doxyfile source documentation settings
├── LICENSE ├── LICENSE
├── pyproject.toml project configuration ├── pyproject.toml project configuration
└── README.md └── README.md
``` ```
The game modules (`snake.py`, `food.py`, `scoreboard.py`, `main.py`,
`__main__.py`) and their tests are added by `MIL-002`.
## License ## License
GNU Affero General Public License v3.0 only. See [LICENSE](LICENSE). GNU Affero General Public License v3.0 only. See [LICENSE](LICENSE).
+1 -1
View File
@@ -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 | 004 | | MIL | Milestone / Gateway | docs/milestones/*.md | 004 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 | | 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 ## Languages
+62
View File
@@ -0,0 +1,62 @@
# 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 | [0d3b913] |
---
## 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
[0d3b913]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/021-snake-game/commit/0d3b91321d2b88badbf51644a8b950159d1240c0
+8
View File
@@ -0,0 +1,8 @@
"""! @file
@brief Allows `python -m snake_game`.
"""
from snake_game.main import main
if __name__ == "__main__":
main()
+49
View File
@@ -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())
+210
View File
@@ -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
+58
View File
@@ -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)
+239
View File
@@ -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)
+300
View File
@@ -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"
+77
View File
@@ -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)
+342
View File
@@ -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")
+85
View File
@@ -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:]
+386
View File
@@ -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()