MIL-002: Adopt the game #36

Merged
Tirsvad merged 8 commits from mil-002-adopt-the-game into main 2026-10-13 08:31:09 +02:00
13 changed files with 2124 additions and 158 deletions
+33 -13
View File
@@ -19,11 +19,11 @@ This repository continues
and starts from its finished game; `docs/project-plan.md` tells how the work is
split into milestones.
> **Status:** in progress. The repository foundation (this README, the
> configuration, the constants, the Doxygen settings and the continuous
> integration workflow) is in place; the game itself, continuous play, the high
> score and the high score file follow in milestones 2 to 4, and the wiki in
> milestone 5. The sections below describe the finished game.
> **Status:** in progress. The finished game of day 21 is adopted and runs: the
> snake moves, eats and grows, the score is shown and the game ends at the wall
> or at the tail. Continuous play and the high score (milestone 3), the high
> score file (milestone 4) and the wiki (milestone 5) follow. The sections below
> describe the game as it is now; they are updated in each milestone.
## Requirements
@@ -88,9 +88,27 @@ With the virtual environment active:
python -m snake_game
```
The game itself is added in milestone 2 (see the status above); this section
is completed with the controls, the two scores and the high score file in
milestones 2 to 5.
It opens a black 600 by 600 window titled "My Snake Game". The snake of three
squares starts in the middle and moves to the right by itself, 20 pixels every
0.1 seconds. Its body is light green and its head is dark green with a mouth.
| 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.
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.
The game is over when the head passes the wall (more than 280 pixels from the
centre on any side) or touches the tail. The snake stops, `GAME OVER` appears in
the middle of the window, and a click on the window closes it. You can also close
the window with its close button at any time. (Milestone 3 replaces this with
continuous play and a high score.)
## Run the tests
@@ -141,17 +159,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
│ ├── __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 remaining modules (`snake.py`, `food.py`, `scoreboard.py`, `main.py` and
`__main__.py`) are added in milestone 2.
## 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 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 006 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 010 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 011 |
## Languages
+60
View File
@@ -0,0 +1,60 @@
# RC-010: Code review of MIL-002 (adopted game: snake, food, scoreboard, main flow)
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-010 |
| CrossReference | [MIL-002], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-13 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c784a5c] |
---
## Artifact Under Review
- Instance reviewed: the source and tests that [MIL-002] delivers (adopted game: snake, food, scoreboard, main flow)
- Checklist used: [QC-PY-001]
- Scope: full review of the files changed on the branch of the milestone
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`; the assistant also wrote the code)
## 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` are `PascalCase`; functions, methods and variables are `snake_case`; constants are `UPPER_SNAKE`. Names such as `create_snake`, `up` and `game_is_on` are the lectures' own. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`eat_food_if_close`, `end_game_if_over`, `hits_tail`); no unexplained abbreviation. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `python -m ruff check src tests` and `python -m ruff format --check src tests` exit 0; no `noqa`. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy` strict reports no issue in 13 files; every def has annotations including `-> None`. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | One handler, `except (Terminator, TclError): return` in `main.main`, is specific and deliberate: both mean the player closed the window; it is documented in the docstring. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default argument; no shadowed builtin. |
| 7 | Files, locks and connections are managed with context managers | N-A | No file is opened in this milestone (the high score file is MIL-004). |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Every module, class, function and method has a Doxygen docstring; `doxygen Doxyfile` ends with 0 warnings. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print` and no logging; no secret or personal data. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No Design Class Diagram exists; this deviation is recorded here and in the pull request: the classes are the base's (`Snake`, `Food`, `Scoreboard`) and trace to the lectures and to the base. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 138 tests in `tests/` run without a display or network, named for behaviour, with the fake `turtle` module of `tests/fakes.py`. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `strict = true`; the only `Protocol` classes (`ScreenLike`, `FoodLike`, `ScoreboardLike`, `Segment`) are narrow and there is no `Any`. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies = []`; the three dev tools are used. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Every value of the lectures is defined once in `constants.py`; modules import them from there. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `snake.py` and `main.py` import no `turtle` or `tkinter` at module level (checked by a test), no import cycle; `food.py` and `scoreboard.py` need `turtle` because they inherit from `Turtle`, as the lectures teach. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | `main.py` depends on narrow protocols, not on concrete turtle classes; `Snake` receives its segment factory as a parameter; each class has one reason to change. |
## Overall Verdict
Go — all Mandatory criteria pass or are not applicable. Assessed on 2026-10-13 by the assistant that wrote the code, as stand-in for S01 in autonomous mode. Mode: autonomous, not independent. The pull request is the second look.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| Challenge this `Go` in the run summary or on the pull request if the stand-in decision is not wanted | S01 | 2026-10-20 |
---
[MIL-002]: ../../milestones/mil-002-adopt-the-game.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[c784a5c]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/024-snake/commit/c784a5c374e14a26369d217eea8bc6b11d2ac532
+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())
+248
View File
@@ -0,0 +1,248 @@
"""! @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 TYPE_CHECKING, Protocol
from snake_game.constants import (
FOOD_COLLISION_DISTANCE,
HEAD_COLOR,
HEAD_OUTLINE,
HEAD_SHAPE,
MOUTH_COLOR,
MOUTH_OUTLINE,
REFRESH_DELAY_SECONDS,
SCREEN_BACKGROUND_COLOR,
SCREEN_HEIGHT,
SCREEN_TITLE,
SCREEN_WIDTH,
)
from snake_game.snake import Snake
if TYPE_CHECKING: # for the annotation only: nothing is imported when running
from turtle import Shape
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 register_shape(self, name: str, shape: "Shape", /) -> None:
"""! @brief Make a shape known under a name, so that a turtle can use it.
@param name The name of the shape.
@param shape The shape to register.
"""
...
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 register_head_shape(screen: ScreenLike) -> None:
"""! @brief Build the shape of the head and register it as `HEAD_SHAPE`.
The shape is compound: the head outline in `HEAD_COLOR` and, on top of it, the
mouth in `MOUTH_COLOR`. Turtle shape coordinates have their front in the
positive y direction, which is what the constants use, so the mouth turns with
the head and no conversion is needed.
`turtle.Shape` is imported here and not at the top of the module, so that
importing this module needs neither a display nor `tkinter`.
@param screen The screen that learns the shape.
"""
from turtle import Shape
shape = Shape("compound")
shape.addcomponent(HEAD_OUTLINE, HEAD_COLOR)
shape.addcomponent(MOUTH_OUTLINE, MOUTH_COLOR)
screen.register_shape(HEAD_SHAPE, shape)
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)
register_head_shape(screen)
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)
+248
View File
@@ -0,0 +1,248 @@
"""! @file
@brief The snake: a list of 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 (
BODY_COLOR,
DOWN,
HEAD_SHAPE,
LEFT,
MOVE_DISTANCE,
RIGHT,
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 `light green`.
@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: a dark green head and a light green body.
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 segment at each starting position.
The first one is the head, with the shape `HEAD_SHAPE`; the others are light
green squares. 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 segment at the end of the snake.
The first segment of an empty snake is the head: it gets the shape
`HEAD_SHAPE`, whose colours are part of the shape. Every other segment is a
square in `BODY_COLOR`.
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()
if self.segments:
new_segment.shape(SEGMENT_SHAPE)
new_segment.color(BODY_COLOR)
else:
new_segment.shape(HEAD_SHAPE)
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)
+315
View File
@@ -0,0 +1,315 @@
"""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 FakeShape:
"""Stands in for `turtle.Shape`: it records its type and its components."""
def __init__(self, type_: str, data: object = None) -> None:
self.type = type_
self.components: list[tuple[object, object, object]] = []
def addcomponent(self, poly: object, fill: object, outline: object = None) -> None:
self.components.append((poly, fill, outline))
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 register_shape(self, name: str, shape: object, /) -> None:
self.calls.append(("register_shape", (name, shape)))
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__["Shape"] = FakeShape
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)
+389
View File
@@ -0,0 +1,389 @@
"""Tests of the main flow, with fakes instead of a real screen."""
import pytest
from fakes import (
FakeFood,
FakeScoreboard,
FakeScreen,
FakeShape,
FakeTclError,
FakeTerminatorError,
imports_turtle_or_tkinter,
install_fake_turtle,
make_snake,
script_randint,
)
from snake_game import constants
from snake_game import snake as snake_module
from snake_game.main import (
bind_keys,
configure_screen,
eat_food_if_close,
end_game_if_over,
main,
play_frame,
register_head_shape,
)
from snake_game.snake import Snake
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()[:10] == [
"setup",
"bgcolor",
"title",
"tracer",
"register_shape",
"listen",
"onkey",
"onkey",
"onkey",
"onkey",
]
assert ("tracer", (0,)) in screens[0].calls
assert sorted(screens[0].bindings) == sorted(ARROW_KEYS)
def test_register_head_shape_builds_two_components_from_the_constants(
monkeypatch: pytest.MonkeyPatch,
) -> None:
install_fake_turtle(monkeypatch)
screen = FakeScreen()
register_head_shape(screen) # the fake `Shape` is installed above
((_, (shape_name, shape)),) = [
call for call in screen.calls if call[0] == "register_shape"
]
assert isinstance(shape, FakeShape)
assert shape_name == constants.HEAD_SHAPE
assert shape.type == "compound"
assert shape.components == [
(constants.HEAD_OUTLINE, constants.HEAD_COLOR, None),
(constants.MOUTH_OUTLINE, constants.MOUTH_COLOR, None),
]
def test_main_registers_the_head_shape_once_before_the_snake_is_made(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
turtles, screens = install_fake_turtle(monkeypatch)
registered_when_snake_made: list[bool] = []
real_snake = snake_module.Snake
def spying_snake() -> Snake:
registered_when_snake_made.append("register_shape" in screens[0].call_names())
return real_snake()
monkeypatch.setattr("snake_game.main.Snake", spying_snake)
main()
registrations = [c for c in screens[0].calls if c[0] == "register_shape"]
assert len(registrations) == 1
assert registrations[0][1][0] == constants.HEAD_SHAPE
assert registered_when_snake_made == [True]
assert ("shape", (constants.HEAD_SHAPE,)) in turtles[0].calls
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:]
+409
View File
@@ -0,0 +1,409 @@
"""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_head_has_the_head_shape_and_no_colour_call() -> None:
_, created = make_snake()
head = created[0]
assert ("shape", (constants.HEAD_SHAPE,)) in head.calls
assert "color" not in head.call_names()
def test_body_segments_are_light_green_squares() -> None:
_, created = make_snake()
assert len(created) > 1
for segment in created[1:]:
assert ("shape", (constants.SEGMENT_SHAPE,)) in segment.calls
assert ("color", (constants.BODY_COLOR,)) 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_light_green_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", (constants.SEGMENT_SHAPE,)) in new.calls
assert ("color", (constants.BODY_COLOR,)) 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_adds_a_light_green_square_and_not_a_head() -> None:
snake, _ = make_snake()
snake.extend()
new = snake.segments[-1]
assert isinstance(new, FakeSegment)
assert ("shape", (constants.SEGMENT_SHAPE,)) in new.calls
assert ("color", (constants.BODY_COLOR,)) in new.calls
assert ("shape", (constants.HEAD_SHAPE,)) not in new.calls
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()