MIL-003: Continuous play and high score #37

Merged
Tirsvad merged 7 commits from mil-003-continuous-play-and-high-score into main 2026-10-13 08:35:54 +02:00
12 changed files with 1800 additions and 1586 deletions
+16 -10
View File
@@ -19,11 +19,12 @@ 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 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.
> **Status:** in progress. The game of day 21 is adopted and now follows the
> day-24 lectures on continuous play: the scoreboard shows the score and the high
> score, and a collision resets the snake and the score instead of ending the
> game. The high score is kept in memory only; 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
@@ -104,11 +105,16 @@ 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.)
The scoreboard at the top of the window reads `Score: 0 High Score: 0`. The score
counts the foods eaten since the last reset; the high score is the best score so
far.
The game does not end. When the head passes the wall (more than 280 pixels from
the centre on any side) or touches the tail, the old snake is sent off the
screen, a new snake of three segments appears in the middle, the score goes back
to 0, and the high score is raised if the score was higher. Close the window with
its close button when you have had enough. The high score is lost when the game
is closed until milestone 4 stores it in a file.
## Run the tests
+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 | 011 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 012 |
## Languages
+60
View File
@@ -0,0 +1,60 @@
# RC-011: Code review of MIL-003 (continuous play and high score)
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-011 |
| CrossReference | [MIL-003], [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 | [8c81988] |
---
## Artifact Under Review
- Instance reviewed: the source and tests that [MIL-003] delivers (continuous play and high score)
- 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 | `Snake.reset`, `Scoreboard.reset`, `reset_if_collision`, `high_score` and `SEGMENT_PARKING_POSITION` follow PEP 8; `reset` and `high_score` are the lecture's names. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose; `HIGH_SCORE_LABEL` and `SEGMENT_PARKING_POSITION` say what they hold. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` exit 0; no `noqa`. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy` strict reports no issues in 13 files; every new def is annotated with `-> None` or `-> bool`. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No exception handling was added; the one handler in `main.main` is unchanged. |
| 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 (MIL-004). |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | `reset`, `reset_if_collision` and the new constants have Doxygen comments; `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. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No Design Class Diagram exists (deviation recorded in RC-010 and the pull request); the change adds `reset` to the base classes as the lecture does. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 149 tests run without a display; new tests cover higher, equal and lower score on `reset`, the parking of old segments, the new head, the forgotten direction, and `main` going on after the wall and the tail. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `strict = true`; `ScoreboardLike` stays a narrow protocol (`increase_score`, `reset`); no `Any`. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies = []` is unchanged. |
| 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 | The label, the parking place and the other values are defined once in `constants.py`; `GAME_OVER_*` is removed, not duplicated. |
| 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` still import no `turtle` or `tkinter` at module level (tested); no import cycle. |
| 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 | `Snake.reset` reuses `create_snake`, so the starting positions are one piece of knowledge; `main` depends on the `ScoreboardLike` protocol. |
## 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-003]: ../../milestones/mil-003-continuous-play-and-high-score.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[8c81988]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/024-snake/commit/8c8198827bfc69c75a4f8ff8a393376d073e94c1
+10 -4
View File
@@ -117,8 +117,14 @@ SCOREBOARD_FONT: Final[tuple[str, int, str]] = ("Arial", 24, "normal")
## @brief The head touches the tail when it is closer to a segment than this, in pixels.
TAIL_COLLISION_DISTANCE: Final[int] = 10
## @brief Text shown when the game is over.
GAME_OVER_TEXT: Final[str] = "GAME OVER"
## @brief Text in front of the high score on the scoreboard.
#
# The scoreboard writes `Score: <score> High Score: <high_score>`: the score
# label, the score, a space, this label and the high score.
HIGH_SCORE_LABEL: Final[str] = "High Score: "
## @brief Where the game-over text is written: the centre of the screen.
GAME_OVER_POSITION: Final[tuple[int, int]] = (0, 0)
## @brief Where a segment of the old snake is sent when the snake is reset.
#
# The place is far outside the 600 by 600 screen, so the old segments are out
# of sight, as the day-24 lecture does it.
SEGMENT_PARKING_POSITION: Final[tuple[int, int]] = (1000, 1000)
+246 -248
View File
@@ -1,248 +1,246 @@
"""! @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
"""! @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", the day-21 steps in which the snake eats food and the score rises,
and the day-24 steps in which a collision resets the game and it goes on.
"""
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 reset 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 reset(self) -> None:
"""! @brief Keep the best score and start the score again at 0."""
...
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 reset_if_collision(snake: Snake, scoreboard: ScoreboardLike) -> bool:
"""! @brief Start again when the head passes the wall or touches the tail.
The game does not end: the scoreboard keeps the best score and starts the
score again, and the snake is made again at the starting positions.
@param snake The snake.
@param scoreboard The scoreboard that keeps the high score.
@return True when the snake and the score were reset.
"""
if snake.hits_wall() or snake.hits_tail():
scoreboard.reset()
snake.reset()
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 score and the snake are
reset and the game goes on, so the loop runs until the window is closed.
`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)
reset_if_collision(snake, scoreboard)
except (Terminator, TclError):
return # the window was closed: there is nothing left to do
+61 -58
View File
@@ -1,58 +1,61 @@
"""! @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)
"""! @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 (
HIGH_SCORE_LABEL,
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 since the last reset.
self.score = 0
## @brief The largest score reached so far.
self.high_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 score and the high score."""
self.clear()
self.write(
f"{SCORE_LABEL}{self.score} {HIGH_SCORE_LABEL}{self.high_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 reset(self) -> None:
"""! @brief Keep the best score, start a new game and write the scoreboard.
When the score is higher than the high score, it becomes the new high
score. The score then goes back to 0.
"""
if self.score > self.high_score:
self.high_score = self.score
self.score = 0
self.update_scoreboard()
+264 -248
View File
@@ -1,248 +1,264 @@
"""! @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)
"""! @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 day-24 step in which it is reset
after a collision. It keeps the lectures'
names: `Snake`, `segments`, `create_snake`, `add_segment`, `extend`, `move`, `head`,
`reset`, `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_PARKING_POSITION,
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 reset(self) -> None:
"""! @brief Start again with a new snake of three segments.
Every old segment is sent far outside the screen, so it can no longer be
seen, the list is emptied, and the snake is created again at the starting
positions. The new head is the first new segment, and it points right.
"""
for segment in self.segments:
segment.goto(*SEGMENT_PARKING_POSITION)
self.segments.clear()
self.create_snake()
self.head = self.segments[0]
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 -315
View File
@@ -1,315 +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"
"""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.resets = 0
def increase_score(self) -> None:
self.increases += 1
def reset(self) -> None:
self.resets += 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"
+171 -163
View File
@@ -1,163 +1,171 @@
"""Tests of the constants that the day-20, day-21 and day-24 lectures fix."""
from snake_game import constants
HALF_WIDTH = constants.SCREEN_WIDTH / 2
HALF_HEIGHT = constants.SCREEN_HEIGHT / 2
def test_screen_size_matches_lecture() -> None:
assert (constants.SCREEN_WIDTH, constants.SCREEN_HEIGHT) == (600, 600)
def test_screen_is_black_and_titled_as_in_the_lecture() -> None:
assert constants.SCREEN_BACKGROUND_COLOR == "black"
assert constants.SCREEN_TITLE == "My Snake Game"
def test_body_segments_are_light_green_squares() -> None:
assert constants.SEGMENT_SHAPE == "square"
assert constants.BODY_COLOR == "light green"
def test_segment_color_no_longer_exists() -> None:
assert not hasattr(constants, "SEGMENT_COLOR")
def test_head_is_dark_green_and_differs_from_the_body() -> None:
assert constants.HEAD_COLOR == "dark green"
assert constants.HEAD_COLOR != constants.BODY_COLOR
def test_head_shape_has_a_name_that_is_not_a_built_in_shape() -> None:
assert constants.HEAD_SHAPE == "snake_head"
assert constants.HEAD_SHAPE != constants.SEGMENT_SHAPE
def test_head_is_the_20_by_20_square() -> None:
assert constants.HEAD_OUTLINE == ((-10, -10), (-10, 10), (10, 10), (10, -10))
xs = {x for x, _ in constants.HEAD_OUTLINE}
ys = {y for _, y in constants.HEAD_OUTLINE}
assert max(xs) - min(xs) == max(ys) - min(ys) == 20
def test_mouth_lies_on_the_front_edge_and_inside_the_head() -> None:
front = max(y for _, y in constants.HEAD_OUTLINE)
half = max(x for x, _ in constants.HEAD_OUTLINE)
assert constants.MOUTH_OUTLINE == ((-6, 10), (0, 2), (6, 10))
assert any(y == front for _, y in constants.MOUTH_OUTLINE)
assert all(
abs(x) <= half and -half <= y <= front for x, y in constants.MOUTH_OUTLINE
)
def test_mouth_is_the_colour_of_the_background() -> None:
assert constants.MOUTH_COLOR == constants.SCREEN_BACKGROUND_COLOR
def test_snake_starts_with_three_segments_at_the_lecture_positions() -> None:
assert constants.STARTING_POSITIONS == ((0, 0), (-20, 0), (-40, 0))
def test_starting_positions_are_distinct_on_one_row() -> None:
positions = constants.STARTING_POSITIONS
assert len(set(positions)) == len(positions)
assert len({y for _, y in positions}) == 1
def test_starting_positions_run_from_head_to_tail_leftwards() -> None:
x_values = [x for x, _ in constants.STARTING_POSITIONS]
assert x_values == sorted(x_values, reverse=True)
def test_starting_positions_are_one_move_distance_apart() -> None:
x_values = [x for x, _ in constants.STARTING_POSITIONS]
gaps = {left - right for left, right in zip(x_values, x_values[1:], strict=False)}
assert gaps == {constants.MOVE_DISTANCE}
def test_starting_positions_are_inside_the_screen() -> None:
assert all(
abs(x) < HALF_WIDTH and abs(y) < HALF_HEIGHT
for x, y in constants.STARTING_POSITIONS
)
def test_move_distance_is_twenty_pixels() -> None:
assert constants.MOVE_DISTANCE == 20
def test_directions_match_the_lecture_headings() -> None:
assert constants.UP == 90
assert constants.DOWN == 270
assert constants.LEFT == 180
assert constants.RIGHT == 0
def test_four_directions_are_distinct() -> None:
directions = (constants.UP, constants.DOWN, constants.LEFT, constants.RIGHT)
assert len(set(directions)) == len(directions)
def test_opposite_directions_differ_by_half_a_turn() -> None:
assert (constants.UP - constants.DOWN) % 360 == 180
assert (constants.LEFT - constants.RIGHT) % 360 == 180
def test_refresh_delay_is_a_tenth_of_a_second() -> None:
assert constants.REFRESH_DELAY_SECONDS == 0.1
def test_food_is_a_small_blue_circle_without_animation() -> None:
assert constants.FOOD_SHAPE == "circle"
assert constants.FOOD_SIZE == 0.5
assert constants.FOOD_COLOR == "blue"
assert constants.FOOD_SPEED == "fastest"
def test_wall_is_inside_the_screen() -> None:
assert 0 < constants.WALL_LIMIT < HALF_WIDTH
assert constants.WALL_LIMIT < HALF_HEIGHT
def test_wall_is_280_pixels_from_the_centre() -> None:
assert constants.WALL_LIMIT == 280
def test_food_is_eaten_closer_than_15_pixels_which_is_less_than_a_move() -> None:
assert constants.FOOD_COLLISION_DISTANCE == 15
assert constants.FOOD_COLLISION_DISTANCE < constants.MOVE_DISTANCE
def test_scoreboard_is_white_text_at_the_top_centre_inside_the_screen() -> None:
x, y = constants.SCOREBOARD_POSITION
assert constants.SCOREBOARD_COLOR == "white"
assert constants.SCOREBOARD_ALIGNMENT == "center"
assert (x, y) == (0, 270)
assert abs(x) < HALF_WIDTH
assert 0 < y < HALF_HEIGHT
def test_scoreboard_text_is_score_label_and_arial_24() -> None:
assert constants.SCORE_LABEL == "Score: "
assert constants.SCOREBOARD_FONT == ("Arial", 24, "normal")
def test_tail_is_touched_closer_than_10_pixels_which_is_less_than_a_move() -> None:
assert constants.TAIL_COLLISION_DISTANCE == 10
assert 0 < constants.TAIL_COLLISION_DISTANCE < constants.MOVE_DISTANCE
def test_game_over_text_is_written_at_the_centre_inside_the_wall() -> None:
x, y = constants.GAME_OVER_POSITION
assert constants.GAME_OVER_TEXT == "GAME OVER"
assert (x, y) == (0, 0)
assert abs(x) < constants.WALL_LIMIT
assert abs(y) < constants.WALL_LIMIT
"""Tests of the constants that the day-20, day-21 and day-24 lectures fix."""
from snake_game import constants
HALF_WIDTH = constants.SCREEN_WIDTH / 2
HALF_HEIGHT = constants.SCREEN_HEIGHT / 2
def test_screen_size_matches_lecture() -> None:
assert (constants.SCREEN_WIDTH, constants.SCREEN_HEIGHT) == (600, 600)
def test_screen_is_black_and_titled_as_in_the_lecture() -> None:
assert constants.SCREEN_BACKGROUND_COLOR == "black"
assert constants.SCREEN_TITLE == "My Snake Game"
def test_body_segments_are_light_green_squares() -> None:
assert constants.SEGMENT_SHAPE == "square"
assert constants.BODY_COLOR == "light green"
def test_segment_color_no_longer_exists() -> None:
assert not hasattr(constants, "SEGMENT_COLOR")
def test_head_is_dark_green_and_differs_from_the_body() -> None:
assert constants.HEAD_COLOR == "dark green"
assert constants.HEAD_COLOR != constants.BODY_COLOR
def test_head_shape_has_a_name_that_is_not_a_built_in_shape() -> None:
assert constants.HEAD_SHAPE == "snake_head"
assert constants.HEAD_SHAPE != constants.SEGMENT_SHAPE
def test_head_is_the_20_by_20_square() -> None:
assert constants.HEAD_OUTLINE == ((-10, -10), (-10, 10), (10, 10), (10, -10))
xs = {x for x, _ in constants.HEAD_OUTLINE}
ys = {y for _, y in constants.HEAD_OUTLINE}
assert max(xs) - min(xs) == max(ys) - min(ys) == 20
def test_mouth_lies_on_the_front_edge_and_inside_the_head() -> None:
front = max(y for _, y in constants.HEAD_OUTLINE)
half = max(x for x, _ in constants.HEAD_OUTLINE)
assert constants.MOUTH_OUTLINE == ((-6, 10), (0, 2), (6, 10))
assert any(y == front for _, y in constants.MOUTH_OUTLINE)
assert all(
abs(x) <= half and -half <= y <= front for x, y in constants.MOUTH_OUTLINE
)
def test_mouth_is_the_colour_of_the_background() -> None:
assert constants.MOUTH_COLOR == constants.SCREEN_BACKGROUND_COLOR
def test_snake_starts_with_three_segments_at_the_lecture_positions() -> None:
assert constants.STARTING_POSITIONS == ((0, 0), (-20, 0), (-40, 0))
def test_starting_positions_are_distinct_on_one_row() -> None:
positions = constants.STARTING_POSITIONS
assert len(set(positions)) == len(positions)
assert len({y for _, y in positions}) == 1
def test_starting_positions_run_from_head_to_tail_leftwards() -> None:
x_values = [x for x, _ in constants.STARTING_POSITIONS]
assert x_values == sorted(x_values, reverse=True)
def test_starting_positions_are_one_move_distance_apart() -> None:
x_values = [x for x, _ in constants.STARTING_POSITIONS]
gaps = {left - right for left, right in zip(x_values, x_values[1:], strict=False)}
assert gaps == {constants.MOVE_DISTANCE}
def test_starting_positions_are_inside_the_screen() -> None:
assert all(
abs(x) < HALF_WIDTH and abs(y) < HALF_HEIGHT
for x, y in constants.STARTING_POSITIONS
)
def test_move_distance_is_twenty_pixels() -> None:
assert constants.MOVE_DISTANCE == 20
def test_directions_match_the_lecture_headings() -> None:
assert constants.UP == 90
assert constants.DOWN == 270
assert constants.LEFT == 180
assert constants.RIGHT == 0
def test_four_directions_are_distinct() -> None:
directions = (constants.UP, constants.DOWN, constants.LEFT, constants.RIGHT)
assert len(set(directions)) == len(directions)
def test_opposite_directions_differ_by_half_a_turn() -> None:
assert (constants.UP - constants.DOWN) % 360 == 180
assert (constants.LEFT - constants.RIGHT) % 360 == 180
def test_refresh_delay_is_a_tenth_of_a_second() -> None:
assert constants.REFRESH_DELAY_SECONDS == 0.1
def test_food_is_a_small_blue_circle_without_animation() -> None:
assert constants.FOOD_SHAPE == "circle"
assert constants.FOOD_SIZE == 0.5
assert constants.FOOD_COLOR == "blue"
assert constants.FOOD_SPEED == "fastest"
def test_wall_is_inside_the_screen() -> None:
assert 0 < constants.WALL_LIMIT < HALF_WIDTH
assert constants.WALL_LIMIT < HALF_HEIGHT
def test_wall_is_280_pixels_from_the_centre() -> None:
assert constants.WALL_LIMIT == 280
def test_food_is_eaten_closer_than_15_pixels_which_is_less_than_a_move() -> None:
assert constants.FOOD_COLLISION_DISTANCE == 15
assert constants.FOOD_COLLISION_DISTANCE < constants.MOVE_DISTANCE
def test_scoreboard_is_white_text_at_the_top_centre_inside_the_screen() -> None:
x, y = constants.SCOREBOARD_POSITION
assert constants.SCOREBOARD_COLOR == "white"
assert constants.SCOREBOARD_ALIGNMENT == "center"
assert (x, y) == (0, 270)
assert abs(x) < HALF_WIDTH
assert 0 < y < HALF_HEIGHT
def test_scoreboard_text_is_score_label_and_arial_24() -> None:
assert constants.SCORE_LABEL == "Score: "
assert constants.SCOREBOARD_FONT == ("Arial", 24, "normal")
def test_tail_is_touched_closer_than_10_pixels_which_is_less_than_a_move() -> None:
assert constants.TAIL_COLLISION_DISTANCE == 10
assert 0 < constants.TAIL_COLLISION_DISTANCE < constants.MOVE_DISTANCE
def test_game_over_constants_no_longer_exist() -> None:
assert not hasattr(constants, "GAME_OVER_TEXT")
assert not hasattr(constants, "GAME_OVER_POSITION")
def test_high_score_label_follows_the_score_label_as_in_the_lecture() -> None:
assert constants.HIGH_SCORE_LABEL == "High Score: "
assert constants.HIGH_SCORE_LABEL != constants.SCORE_LABEL
def test_old_segments_are_parked_outside_the_screen() -> None:
x, y = constants.SEGMENT_PARKING_POSITION
assert abs(x) > HALF_WIDTH
assert abs(y) > HALF_HEIGHT
+52 -45
View File
@@ -20,10 +20,10 @@ from snake_game.main import (
bind_keys,
configure_screen,
eat_food_if_close,
end_game_if_over,
main,
play_frame,
register_head_shape,
reset_if_collision,
)
from snake_game.snake import Snake
@@ -276,7 +276,7 @@ def test_main_lets_the_snake_eat_the_food_that_lies_on_its_way(
("goto", (20, 0)),
("goto", (constants.WALL_LIMIT, constants.WALL_LIMIT)),
]
assert scoreboard.calls[-1][1][0] == "Score: 1"
assert scoreboard.calls[-1][1][0] == "Score: 1 High Score: 0"
assert new_segment.position() == (-20, 0) # where the last segment was
@@ -288,86 +288,93 @@ def test_main_does_not_raise_the_score_when_the_food_is_far_away(
main()
scoreboard = turtles[4]
assert scoreboard.calls[-1][1][0] == "Score: 0"
assert scoreboard.calls[-1][1][0] == "Score: 0 High 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()
def test_nothing_is_reset_while_the_snake_is_inside_and_clear_of_its_tail() -> None:
snake, created = make_snake()
scoreboard = FakeScoreboard()
assert not end_game_if_over(snake, scoreboard)
assert scoreboard.game_overs == 0
assert not reset_if_collision(snake, scoreboard)
assert scoreboard.resets == 0
assert len(created) == len(constants.STARTING_POSITIONS)
def test_the_game_is_over_when_the_head_passes_the_wall() -> None:
snake, _ = make_snake()
def test_the_game_is_reset_when_the_head_passes_the_wall() -> None:
snake, created = make_snake()
snake.head.goto(constants.WALL_LIMIT + 1, 0)
scoreboard = FakeScoreboard()
assert end_game_if_over(snake, scoreboard)
assert scoreboard.game_overs == 1
assert reset_if_collision(snake, scoreboard)
assert scoreboard.resets == 1
assert len(created) == 2 * len(constants.STARTING_POSITIONS)
def test_the_game_is_over_when_the_head_touches_the_tail() -> None:
def test_the_game_is_reset_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
assert reset_if_collision(snake, scoreboard)
assert scoreboard.resets == 1
assert snake.head is not created[0]
def test_main_ends_the_game_at_the_wall_shows_game_over_and_waits_for_a_click(
def test_main_goes_on_after_the_wall_with_a_new_snake_and_no_game_over(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=100)
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 + 2
)
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,
)
old_head, scoreboard = turtles[0], turtles[4]
new_head = turtles[5]
written = [call[1][0] for call in scoreboard.calls if call[0] == "write"]
assert old_head.position() == constants.SEGMENT_PARKING_POSITION
assert new_head.call_names().count("forward") == 2
assert new_head.position() == (2 * constants.MOVE_DISTANCE, 0)
assert scoreboard.calls[-1] == (
"write",
("GAME OVER", "center", ("Arial", 24, "normal")),
("Score: 0 High Score: 0", "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
assert "GAME OVER" not in written
assert "exitonclick" not in screens[0].call_names()
def test_main_ends_the_game_when_the_snake_touches_its_tail(
def test_main_shows_the_best_score_after_a_reset(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
script_randint(monkeypatch, [20, 0]) # the first food lies one move ahead
moves_to_pass_the_wall = constants.WALL_LIMIT // constants.MOVE_DISTANCE + 1
turtles, _ = install_fake_turtle(
monkeypatch, frames_before_close=moves_to_pass_the_wall
)
main()
scoreboard = turtles[4]
assert scoreboard.calls[-1][1][0] == "Score: 0 High Score: 1"
def test_main_resets_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)
turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=2)
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
assert turtles[0].position() == constants.SEGMENT_PARKING_POSITION
assert "exitonclick" not in screens[0].call_names()
@pytest.mark.parametrize("closing_error", [FakeTerminatorError, FakeTclError])
def test_main_ends_quietly_when_the_window_is_closed_after_game_over(
def test_main_ends_quietly_when_the_window_is_closed_after_a_reset(
monkeypatch: pytest.MonkeyPatch,
sleeps: list[float],
closing_error: type[Exception],
@@ -381,7 +388,7 @@ def test_main_ends_quietly_when_the_window_is_closed_after_game_over(
main()
assert turtles[4].calls[-1][1][0] == "GAME OVER"
assert turtles[4].calls[-1][1][0] == "Score: 0 High Score: 0"
assert "exitonclick" not in screens[0].call_names()
+137 -85
View File
@@ -1,85 +1,137 @@
"""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:]
"""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 High 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_both_scores_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 High Score: 0", "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 High Score: 0"
def test_high_score_starts_at_0(monkeypatch: pytest.MonkeyPatch) -> None:
scoreboard = make_scoreboard(monkeypatch)
assert scoreboard.high_score == 0
assert scoreboard.score == 0
def test_reset_keeps_a_higher_score_as_the_high_score_and_starts_again(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scoreboard = make_scoreboard(monkeypatch)
for _ in range(3):
scoreboard.increase_score()
scoreboard.reset()
assert scoreboard.high_score == 3
assert scoreboard.score == 0
assert scoreboard.call_names()[-2:] == ["clear", "write"]
assert scoreboard.calls[-1] == (
"write",
("Score: 0 High Score: 3", "center", ("Arial", 24, "normal")),
)
def test_reset_keeps_the_high_score_when_the_score_is_lower(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scoreboard = make_scoreboard(monkeypatch)
for _ in range(3):
scoreboard.increase_score()
scoreboard.reset()
scoreboard.increase_score()
scoreboard.reset()
assert scoreboard.high_score == 3
assert scoreboard.calls[-1][1][0] == "Score: 0 High Score: 3"
def test_reset_keeps_the_high_score_when_the_score_is_equal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scoreboard = make_scoreboard(monkeypatch)
scoreboard.increase_score()
scoreboard.reset()
scoreboard.increase_score()
scoreboard.reset()
assert scoreboard.high_score == 1
assert scoreboard.score == 0
def test_the_score_text_shows_both_scores_while_playing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scoreboard = make_scoreboard(monkeypatch)
scoreboard.increase_score()
scoreboard.increase_score()
scoreboard.reset()
scoreboard.increase_score()
assert scoreboard.calls[-1][1][0] == "Score: 1 High Score: 2"
def test_scoreboard_has_no_game_over_any_more(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scoreboard = make_scoreboard(monkeypatch)
assert not hasattr(scoreboard, "game_over")
+467 -409
View File
@@ -1,409 +1,467 @@
"""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()
"""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()
def test_reset_sends_every_old_segment_off_the_screen() -> None:
snake, created = make_snake()
snake.extend()
old = list(created)
snake.reset()
assert len(old) == len(constants.STARTING_POSITIONS) + 1
assert all(
segment.position() == constants.SEGMENT_PARKING_POSITION for segment in old
)
def test_reset_makes_the_snake_again_at_the_starting_positions() -> None:
snake, created = make_snake()
snake.extend()
snake.extend()
old_count = len(created)
snake.reset()
assert len(snake.segments) == len(constants.STARTING_POSITIONS)
assert positions(snake.segments) == [
(float(x), float(y)) for x, y in constants.STARTING_POSITIONS
]
assert snake.segments == created[old_count:]
def test_reset_makes_the_first_new_segment_the_head() -> None:
snake, created = make_snake()
old_head = snake.head
snake.reset()
new_head = created[len(constants.STARTING_POSITIONS)]
assert snake.head is snake.segments[0] is new_head
assert snake.head is not old_head
assert ("shape", (constants.HEAD_SHAPE,)) in new_head.calls
def test_reset_snake_moves_right_from_the_starting_positions() -> None:
snake, _ = make_snake_moving(constants.UP)
snake.reset()
snake.move()
assert snake.head.position() == (constants.MOVE_DISTANCE, 0)
def test_reset_forgets_the_direction_of_the_last_move() -> None:
snake, _ = make_snake_moving(constants.DOWN)
snake.reset()
snake.up() # allowed again: the new snake moves right, not down
assert snake.head.heading() == constants.UP