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 and starts from its finished game; `docs/project-plan.md` tells how the work is
split into milestones. split into milestones.
> **Status:** in progress. The finished game of day 21 is adopted and runs: the > **Status:** in progress. The game of day 21 is adopted and now follows the
> snake moves, eats and grows, the score is shown and the game ends at the wall > day-24 lectures on continuous play: the scoreboard shows the score and the high
> or at the tail. Continuous play and the high score (milestone 3), the high > score, and a collision resets the snake and the score instead of ending the
> score file (milestone 4) and the wiki (milestone 5) follow. The sections below > game. The high score is kept in memory only; the high score file (milestone 4)
> describe the game as it is now; they are updated in each milestone. > and the wiki (milestone 5) follow. The sections below describe the game as it
> is now; they are updated in each milestone.
## Requirements ## 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 place, the snake grows by one segment, and the score at the top of the window
goes up by 1. goes up by 1.
The game is over when the head passes the wall (more than 280 pixels from the The scoreboard at the top of the window reads `Score: 0 High Score: 0`. The score
centre on any side) or touches the tail. The snake stops, `GAME OVER` appears in counts the foods eaten since the last reset; the high score is the best score so
the middle of the window, and a click on the window closes it. You can also close far.
the window with its close button at any time. (Milestone 3 replaces this with
continuous play and a high score.) 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 ## 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 | | PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 006 | | MIL | Milestone / Gateway | docs/milestones/*.md | 006 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 | | 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 ## 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. ## @brief The head touches the tail when it is closer to a segment than this, in pixels.
TAIL_COLLISION_DISTANCE: Final[int] = 10 TAIL_COLLISION_DISTANCE: Final[int] = 10
## @brief Text shown when the game is over. ## @brief Text in front of the high score on the scoreboard.
GAME_OVER_TEXT: Final[str] = "GAME OVER" #
# 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. ## @brief Where a segment of the old snake is sent when the snake is reset.
GAME_OVER_POSITION: Final[tuple[int, int]] = (0, 0) #
# 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 """! @file
@brief The main flow of the game: set up the screen, draw the snake and run it. @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", The flow follows the lectures "Screen Setup and Creating a Snake Body",
"Animating the Snake Segments on Screen" and "Controlling the Snake with "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 Keypresses", the day-21 steps in which the snake eats food and the score rises,
the game ends. and the day-24 steps in which a collision resets the game and it goes on.
""" """
import time import time
from collections.abc import Callable from collections.abc import Callable
from typing import TYPE_CHECKING, Protocol from typing import TYPE_CHECKING, Protocol
from snake_game.constants import ( from snake_game.constants import (
FOOD_COLLISION_DISTANCE, FOOD_COLLISION_DISTANCE,
HEAD_COLOR, HEAD_COLOR,
HEAD_OUTLINE, HEAD_OUTLINE,
HEAD_SHAPE, HEAD_SHAPE,
MOUTH_COLOR, MOUTH_COLOR,
MOUTH_OUTLINE, MOUTH_OUTLINE,
REFRESH_DELAY_SECONDS, REFRESH_DELAY_SECONDS,
SCREEN_BACKGROUND_COLOR, SCREEN_BACKGROUND_COLOR,
SCREEN_HEIGHT, SCREEN_HEIGHT,
SCREEN_TITLE, SCREEN_TITLE,
SCREEN_WIDTH, SCREEN_WIDTH,
) )
from snake_game.snake import Snake from snake_game.snake import Snake
if TYPE_CHECKING: # for the annotation only: nothing is imported when running if TYPE_CHECKING: # for the annotation only: nothing is imported when running
from turtle import Shape from turtle import Shape
class ScreenLike(Protocol): class ScreenLike(Protocol):
"""! @brief What the helper functions need from the screen. """! @brief What the helper functions need from the screen.
A `turtle.Screen` fits this description, and so does a fake in a test. A `turtle.Screen` fits this description, and so does a fake in a test.
""" """
def setup(self, width: float, height: float) -> None: def setup(self, width: float, height: float) -> None:
"""! @brief Set the size of the window. """! @brief Set the size of the window.
@param width Width in pixels. @param width Width in pixels.
@param height Height in pixels. @param height Height in pixels.
""" """
... ...
def bgcolor(self, color: str, /) -> None: def bgcolor(self, color: str, /) -> None:
"""! @brief Set the background colour. """! @brief Set the background colour.
@param color Name of the colour, for example `black`. @param color Name of the colour, for example `black`.
""" """
... ...
def title(self, titlestring: str, /) -> None: def title(self, titlestring: str, /) -> None:
"""! @brief Set the title of the window. """! @brief Set the title of the window.
@param titlestring The title. @param titlestring The title.
""" """
... ...
def register_shape(self, name: str, shape: "Shape", /) -> None: def register_shape(self, name: str, shape: "Shape", /) -> None:
"""! @brief Make a shape known under a name, so that a turtle can use it. """! @brief Make a shape known under a name, so that a turtle can use it.
@param name The name of the shape. @param name The name of the shape.
@param shape The shape to register. @param shape The shape to register.
""" """
... ...
def update(self) -> None: def update(self) -> None:
"""! @brief Draw everything that has changed since the last update.""" """! @brief Draw everything that has changed since the last update."""
... ...
def listen(self) -> None: def listen(self) -> None:
"""! @brief Make the screen receive the key presses.""" """! @brief Make the screen receive the key presses."""
... ...
def onkey(self, fun: Callable[[], object], key: str) -> None: def onkey(self, fun: Callable[[], object], key: str) -> None:
"""! @brief Call a function when a key is pressed. """! @brief Call a function when a key is pressed.
@param fun The function to call. @param fun The function to call.
@param key Name of the key, for example `Up`. @param key Name of the key, for example `Up`.
""" """
... ...
class FoodLike(Protocol): class FoodLike(Protocol):
"""! @brief What `eat_food_if_close` needs from the food. """! @brief What `eat_food_if_close` needs from the food.
A `Food` fits this description, and so does a fake in a test. A `Food` fits this description, and so does a fake in a test.
""" """
def refresh(self) -> None: def refresh(self) -> None:
"""! @brief Move the food to a new random place.""" """! @brief Move the food to a new random place."""
... ...
def position(self) -> tuple[float, float]: def position(self) -> tuple[float, float]:
"""! @brief Tell where the food is. """! @brief Tell where the food is.
@return The x and y coordinates. @return The x and y coordinates.
""" """
... ...
class ScoreboardLike(Protocol): class ScoreboardLike(Protocol):
"""! @brief What the eating and game-over checks need from the scoreboard. """! @brief What the eating and reset checks need from the scoreboard.
A `Scoreboard` fits this description, and so does a fake in a test. A `Scoreboard` fits this description, and so does a fake in a test.
""" """
def increase_score(self) -> None: def increase_score(self) -> None:
"""! @brief Add 1 to the score and write it.""" """! @brief Add 1 to the score and write it."""
... ...
def game_over(self) -> None: def reset(self) -> None:
"""! @brief Write the game-over text.""" """! @brief Keep the best score and start the score again at 0."""
... ...
def configure_screen(screen: ScreenLike) -> None: def configure_screen(screen: ScreenLike) -> None:
"""! @brief Give the screen the size, background colour and title of the game. """! @brief Give the screen the size, background colour and title of the game.
@param screen The screen to set up. @param screen The screen to set up.
""" """
screen.setup(width=SCREEN_WIDTH, height=SCREEN_HEIGHT) screen.setup(width=SCREEN_WIDTH, height=SCREEN_HEIGHT)
screen.bgcolor(SCREEN_BACKGROUND_COLOR) screen.bgcolor(SCREEN_BACKGROUND_COLOR)
screen.title(SCREEN_TITLE) screen.title(SCREEN_TITLE)
def register_head_shape(screen: ScreenLike) -> None: def register_head_shape(screen: ScreenLike) -> None:
"""! @brief Build the shape of the head and register it as `HEAD_SHAPE`. """! @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 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 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 positive y direction, which is what the constants use, so the mouth turns with
the head and no conversion is needed. the head and no conversion is needed.
`turtle.Shape` is imported here and not at the top of the module, so that `turtle.Shape` is imported here and not at the top of the module, so that
importing this module needs neither a display nor `tkinter`. importing this module needs neither a display nor `tkinter`.
@param screen The screen that learns the shape. @param screen The screen that learns the shape.
""" """
from turtle import Shape from turtle import Shape
shape = Shape("compound") shape = Shape("compound")
shape.addcomponent(HEAD_OUTLINE, HEAD_COLOR) shape.addcomponent(HEAD_OUTLINE, HEAD_COLOR)
shape.addcomponent(MOUTH_OUTLINE, MOUTH_COLOR) shape.addcomponent(MOUTH_OUTLINE, MOUTH_COLOR)
screen.register_shape(HEAD_SHAPE, shape) screen.register_shape(HEAD_SHAPE, shape)
def bind_keys(screen: ScreenLike, snake: Snake) -> None: def bind_keys(screen: ScreenLike, snake: Snake) -> None:
"""! @brief Turn the snake with the arrow keys. """! @brief Turn the snake with the arrow keys.
@param screen The screen that receives the key presses. @param screen The screen that receives the key presses.
@param snake The snake to steer. @param snake The snake to steer.
""" """
screen.listen() screen.listen()
screen.onkey(snake.up, "Up") screen.onkey(snake.up, "Up")
screen.onkey(snake.down, "Down") screen.onkey(snake.down, "Down")
screen.onkey(snake.left, "Left") screen.onkey(snake.left, "Left")
screen.onkey(snake.right, "Right") screen.onkey(snake.right, "Right")
def play_frame(screen: ScreenLike, snake: Snake) -> None: def play_frame(screen: ScreenLike, snake: Snake) -> None:
"""! @brief Show the snake, wait for `REFRESH_DELAY_SECONDS`, then move it. """! @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 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. automatic drawing is off, so the whole snake appears at once.
@param screen The screen to update. @param screen The screen to update.
@param snake The snake to move. @param snake The snake to move.
""" """
screen.update() screen.update()
time.sleep(REFRESH_DELAY_SECONDS) time.sleep(REFRESH_DELAY_SECONDS)
snake.move() snake.move()
def eat_food_if_close(snake: Snake, food: FoodLike, scoreboard: ScoreboardLike) -> None: 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. """! @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 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`. adds 1 to the score. The head must be closer than `FOOD_COLLISION_DISTANCE`.
@param snake The snake. @param snake The snake.
@param food The food. @param food The food.
@param scoreboard The scoreboard that shows the score. @param scoreboard The scoreboard that shows the score.
""" """
if snake.head.distance(food.position()) < FOOD_COLLISION_DISTANCE: if snake.head.distance(food.position()) < FOOD_COLLISION_DISTANCE:
food.refresh() food.refresh()
snake.extend() snake.extend()
scoreboard.increase_score() scoreboard.increase_score()
def end_game_if_over(snake: Snake, scoreboard: ScoreboardLike) -> bool: def reset_if_collision(snake: Snake, scoreboard: ScoreboardLike) -> bool:
"""! @brief End the game when the head passes the wall or touches the tail. """! @brief Start again when the head passes the wall or touches the tail.
When the game is over the scoreboard writes the game-over text. 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 writes the game-over text. @param snake The snake.
@return True when the game is over. @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.game_over() if snake.hits_wall() or snake.hits_tail():
return True scoreboard.reset()
return False snake.reset()
return True
return False
def main() -> None:
"""! @brief Open the game window and run the snake until the window is closed.
def main() -> None:
Closing the window during the animation loop makes `screen.update()` raise """! @brief Open the game window and run the snake until the window is closed.
`tkinter.TclError` ("invalid command name"), and `turtle` raises
`turtle.Terminator` in some other calls once its window is gone. Both mean Closing the window during the animation loop makes `screen.update()` raise
"the player closed the window", so the game ends quietly with exit code 0. `tkinter.TclError` ("invalid command name"), and `turtle` raises
When the snake passes the wall or touches its tail the loop ends, the screen is `turtle.Terminator` in some other calls once its window is gone. Both mean
updated so that the game-over text shows, and `screen.exitonclick()` waits for a "the player closed the window", so the game ends quietly with exit code 0.
click. 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 `turtle` and `tkinter` are imported here and not at the top of the module, and
this module needs neither a display nor `tkinter`. 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 tkinter import TclError
from turtle import Screen, Terminator
from snake_game.food import Food
from snake_game.scoreboard import Scoreboard from snake_game.food import Food
from snake_game.scoreboard import Scoreboard
screen = Screen()
configure_screen(screen) screen = Screen()
screen.tracer(0) configure_screen(screen)
register_head_shape(screen) screen.tracer(0)
snake = Snake() register_head_shape(screen)
food = Food() snake = Snake()
scoreboard = Scoreboard() food = Food()
bind_keys(screen, snake) scoreboard = Scoreboard()
bind_keys(screen, snake)
game_is_on = True
try: game_is_on = True
while game_is_on: try:
play_frame(screen, snake) while game_is_on:
eat_food_if_close(snake, food, scoreboard) play_frame(screen, snake)
if end_game_if_over(snake, scoreboard): eat_food_if_close(snake, food, scoreboard)
game_is_on = False reset_if_collision(snake, scoreboard)
screen.update() except (Terminator, TclError):
screen.exitonclick() return # the window was closed: there is nothing left to do
except (Terminator, TclError):
return # the window was closed: there is nothing left to do
+61 -58
View File
@@ -1,58 +1,61 @@
"""! @file """! @file
@brief The scoreboard: the text at the top of the screen that shows the score. @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` 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; 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` the game imports it only when it starts, and the tests install a fake `turtle`
module first. module first.
""" """
from turtle import Turtle from turtle import Turtle
from snake_game.constants import ( from snake_game.constants import (
GAME_OVER_POSITION, HIGH_SCORE_LABEL,
GAME_OVER_TEXT, SCORE_LABEL,
SCORE_LABEL, SCOREBOARD_ALIGNMENT,
SCOREBOARD_ALIGNMENT, SCOREBOARD_COLOR,
SCOREBOARD_COLOR, SCOREBOARD_FONT,
SCOREBOARD_FONT, SCOREBOARD_POSITION,
SCOREBOARD_POSITION, )
)
class Scoreboard(Turtle):
class Scoreboard(Turtle): """! @brief Shows the score as text at the top centre of the screen."""
"""! @brief Shows the score as text at the top centre of the screen."""
def __init__(self) -> None:
def __init__(self) -> None: """! @brief Set the scoreboard up and write the score 0."""
"""! @brief Set the scoreboard up and write the score 0.""" super().__init__()
super().__init__() ## @brief The number of foods the snake has eaten since the last reset.
## @brief The number of foods the snake has eaten in this game. self.score = 0
self.score = 0 ## @brief The largest score reached so far.
self.color(SCOREBOARD_COLOR) self.high_score = 0
self.penup() self.color(SCOREBOARD_COLOR)
self.hideturtle() self.penup()
self.goto(SCOREBOARD_POSITION) self.hideturtle()
self.update_scoreboard() self.goto(SCOREBOARD_POSITION)
self.update_scoreboard()
def update_scoreboard(self) -> None:
"""! @brief Wipe the old text and write the label and the score.""" def update_scoreboard(self) -> None:
self.clear() """! @brief Wipe the old text and write the score and the high score."""
self.write( self.clear()
f"{SCORE_LABEL}{self.score}", self.write(
align=SCOREBOARD_ALIGNMENT, f"{SCORE_LABEL}{self.score} {HIGH_SCORE_LABEL}{self.high_score}",
font=SCOREBOARD_FONT, align=SCOREBOARD_ALIGNMENT,
) font=SCOREBOARD_FONT,
)
def increase_score(self) -> None:
"""! @brief Add 1 to the score and write it.""" def increase_score(self) -> None:
self.score += 1 """! @brief Add 1 to the score and write it."""
self.update_scoreboard() self.score += 1
self.update_scoreboard()
def game_over(self) -> None:
"""! @brief Write the game-over text at the centre of the screen. def reset(self) -> None:
"""! @brief Keep the best score, start a new game and write the scoreboard.
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. When the score is higher than the high score, it becomes the new high
""" score. The score then goes back to 0.
self.goto(GAME_OVER_POSITION) """
self.write(GAME_OVER_TEXT, align=SCOREBOARD_ALIGNMENT, font=SCOREBOARD_FONT) 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 """! @file
@brief The snake: a list of segments that the game draws and moves. @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 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 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' the day-21 steps in which the snake grows, and the day-24 step in which it is reset
names: `Snake`, `segments`, `create_snake`, `add_segment`, `extend`, `move`, `head`, after a collision. It keeps the lectures'
`up`, `down`, `left` and `right`. The only differences are that the snake gets names: `Snake`, `segments`, `create_snake`, `add_segment`, `extend`, `move`, `head`,
the function that makes a segment as a parameter, so that a test can pass a fake `reset`, `up`, `down`, `left` and `right`. The only differences are that the snake gets
and needs no window, and how a reversal is refused (see Snake). 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 collections.abc import Callable
from typing import Protocol
from snake_game.constants import (
BODY_COLOR, from snake_game.constants import (
DOWN, BODY_COLOR,
HEAD_SHAPE, DOWN,
LEFT, HEAD_SHAPE,
MOVE_DISTANCE, LEFT,
RIGHT, MOVE_DISTANCE,
SEGMENT_SHAPE, RIGHT,
STARTING_POSITIONS, SEGMENT_PARKING_POSITION,
TAIL_COLLISION_DISTANCE, SEGMENT_SHAPE,
UP, STARTING_POSITIONS,
WALL_LIMIT, TAIL_COLLISION_DISTANCE,
) UP,
WALL_LIMIT,
)
class Segment(Protocol):
"""! @brief What the snake needs from one of its segments.
class Segment(Protocol):
A `turtle.Turtle` fits this description, and so does a fake in a test. """! @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.
def shape(self, name: str, /) -> object:
@param name Name of the shape, for example `square`. """! @brief Set the shape of the segment.
@return Whatever the implementation returns; the snake ignores it.
""" @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.
def color(self, color: str, /) -> object:
@param color Name of the colour, for example `light green`. """! @brief Set the colour of the segment.
@return Whatever the implementation returns; the snake ignores it.
""" @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 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.
def goto(self, x: float, y: float, /) -> None:
@param x The x coordinate. """! @brief Send the segment to a position.
@param y The y coordinate.
""" @param x The x coordinate.
... @param y The y coordinate.
"""
def xcor(self) -> float: ...
"""! @brief Tell the x coordinate of the segment.
def xcor(self) -> float:
@return The x coordinate. """! @brief Tell the x coordinate of the segment.
"""
... @return The x coordinate.
"""
def ycor(self) -> float: ...
"""! @brief Tell the y coordinate of the segment.
def ycor(self) -> float:
@return The y coordinate. """! @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.
def forward(self, distance: float, /) -> None:
@param distance How far to move, in pixels. """! @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.
def position(self) -> tuple[float, float]:
@return The x and y coordinates. """! @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.
def distance(self, x: tuple[float, float], /) -> float:
@param x The x and y coordinates of the other place. """! @brief Tell how far the segment is from a position.
@return The distance in pixels.
""" @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.
def heading(self) -> float:
@return The heading, 0 for right and growing counter-clockwise. """! @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.
def setheading(self, to_angle: float, /) -> None:
@param to_angle The heading in degrees. """! @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.
def make_turtle_segment() -> Segment:
`turtle` is imported here and not at the top of the module, so that """! @brief Make a real turtle, the default way to get a segment.
importing the snake needs neither a display nor `tkinter`.
`turtle` is imported here and not at the top of the module, so that
@return A new `turtle.Turtle`. importing the snake needs neither a display nor `tkinter`.
"""
from turtle import Turtle @return A new `turtle.Turtle`.
"""
return Turtle() from turtle import Turtle
return Turtle()
class Snake:
"""! @brief The snake of the game: a dark green head and a light green body.
class Snake:
The snake cannot reverse onto itself. Like the lecture, a turn is refused when """! @brief The snake of the game: a dark green head and a light green body.
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 The snake cannot reverse onto itself. Like the lecture, a turn is refused when
direction of the head: otherwise two key presses within one move (Up then Left it points opposite to the way the snake is going. Unlike the lecture, "the way
while moving right) would turn the head twice and reverse the snake. 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.
def __init__(self, segment_factory: Callable[[], Segment] | None = None) -> None:
@param segment_factory Makes one new segment; defaults to a real turtle. """! @brief Create the snake with its three starting segments.
"""
## @brief Makes one new segment. @param segment_factory Makes one new segment; defaults to a real turtle.
self._segment_factory = segment_factory or make_turtle_segment """
## @brief The segments of the snake, the head first. ## @brief Makes one new segment.
self.segments: list[Segment] = [] self._segment_factory = segment_factory or make_turtle_segment
self.create_snake() ## @brief The segments of the snake, the head first.
## @brief The first segment, which leads the snake. self.segments: list[Segment] = []
self.head: Segment = self.segments[0] self.create_snake()
## @brief The direction of the last move; a turn against it is refused. ## @brief The first segment, which leads the snake.
self._direction_of_travel: float = self.head.heading() self.head: Segment = self.segments[0]
## @brief The direction of the last move; a turn against it is refused.
def create_snake(self) -> None: self._direction_of_travel: float = self.head.heading()
"""! @brief Draw one segment at each starting position.
def create_snake(self) -> None:
The first one is the head, with the shape `HEAD_SHAPE`; the others are light """! @brief Draw one segment at each starting position.
green squares. The segments are kept in `segments`, the head first.
""" The first one is the head, with the shape `HEAD_SHAPE`; the others are light
for position in STARTING_POSITIONS: green squares. The segments are kept in `segments`, the head first.
self.add_segment(position) """
for position in STARTING_POSITIONS:
def add_segment(self, position: tuple[float, float]) -> None: self.add_segment(position)
"""! @brief Add a segment at the end of the snake.
def add_segment(self, position: tuple[float, float]) -> None:
The first segment of an empty snake is the head: it gets the shape """! @brief Add a segment at the end of the snake.
`HEAD_SHAPE`, whose colours are part of the shape. Every other segment is a
square in `BODY_COLOR`. 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
The pen is lifted before the segment is sent to its position, so no line is square in `BODY_COLOR`.
drawn.
The pen is lifted before the segment is sent to its position, so no line is
@param position The x and y coordinates of the new segment. drawn.
"""
new_segment = self._segment_factory() @param position The x and y coordinates of the new segment.
if self.segments: """
new_segment.shape(SEGMENT_SHAPE) new_segment = self._segment_factory()
new_segment.color(BODY_COLOR) if self.segments:
else: new_segment.shape(SEGMENT_SHAPE)
new_segment.shape(HEAD_SHAPE) new_segment.color(BODY_COLOR)
new_segment.penup() else:
new_segment.goto(*position) new_segment.shape(HEAD_SHAPE)
self.segments.append(new_segment) new_segment.penup()
new_segment.goto(*position)
def extend(self) -> None: self.segments.append(new_segment)
"""! @brief Make the snake one segment longer.
def extend(self) -> None:
The new segment appears where the last segment is, and follows it on the """! @brief Make the snake one segment longer.
next move.
""" The new segment appears where the last segment is, and follows it on the
self.add_segment(self.segments[-1].position()) next move.
"""
def move(self) -> None: self.add_segment(self.segments[-1].position())
"""! @brief Move the snake one step along its path.
def move(self) -> None:
Each segment, from the last to the second, goes to the place of the """! @brief Move the snake one step along its path.
segment before it; then the head goes forward by `MOVE_DISTANCE`. Moving
the tail first keeps the body joined while turning, however many segments Each segment, from the last to the second, goes to the place of the
there are. segment before it; then the head goes forward by `MOVE_DISTANCE`. Moving
""" the tail first keeps the body joined while turning, however many segments
for index in range(len(self.segments) - 1, 0, -1): there are.
ahead = self.segments[index - 1] """
self.segments[index].goto(ahead.xcor(), ahead.ycor()) for index in range(len(self.segments) - 1, 0, -1):
self.head.forward(MOVE_DISTANCE) ahead = self.segments[index - 1]
self._direction_of_travel = self.head.heading() self.segments[index].goto(ahead.xcor(), ahead.ycor())
self.head.forward(MOVE_DISTANCE)
def hits_wall(self) -> bool: self._direction_of_travel = self.head.heading()
"""! @brief Tell whether the head has passed the wall on any side.
def reset(self) -> None:
The head is outside when its x or its y is beyond `WALL_LIMIT`. """! @brief Start again with a new snake of three segments.
@return True when the head is outside the wall. 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
return abs(self.head.xcor()) > WALL_LIMIT or abs(self.head.ycor()) > WALL_LIMIT positions. The new head is the first new segment, and it points right.
"""
def hits_tail(self) -> bool: for segment in self.segments:
"""! @brief Tell whether the head touches the tail. segment.goto(*SEGMENT_PARKING_POSITION)
self.segments.clear()
The tail is every segment behind the head (a slice, `segments[1:]`). The head self.create_snake()
touches it when it is closer than `TAIL_COLLISION_DISTANCE` to one of them. self.head = self.segments[0]
A snake that has only a head has no tail to touch. self._direction_of_travel = self.head.heading()
@return True when the head touches a segment of the tail. def hits_wall(self) -> bool:
""" """! @brief Tell whether the head has passed the wall on any side.
return any(
self.head.distance(segment.position()) < TAIL_COLLISION_DISTANCE The head is outside when its x or its y is beyond `WALL_LIMIT`.
for segment in self.segments[1:]
) @return True when the head is outside the wall.
"""
def up(self) -> None: return abs(self.head.xcor()) > WALL_LIMIT or abs(self.head.ycor()) > WALL_LIMIT
"""! @brief Turn the head up, unless the snake is moving down."""
if self._direction_of_travel != DOWN: def hits_tail(self) -> bool:
self.head.setheading(UP) """! @brief Tell whether the head touches the tail.
def down(self) -> None: The tail is every segment behind the head (a slice, `segments[1:]`). The head
"""! @brief Turn the head down, unless the snake is moving up.""" touches it when it is closer than `TAIL_COLLISION_DISTANCE` to one of them.
if self._direction_of_travel != UP: A snake that has only a head has no tail to touch.
self.head.setheading(DOWN)
@return True when the head touches a segment of the tail.
def left(self) -> None: """
"""! @brief Turn the head left, unless the snake is moving right.""" return any(
if self._direction_of_travel != RIGHT: self.head.distance(segment.position()) < TAIL_COLLISION_DISTANCE
self.head.setheading(LEFT) for segment in self.segments[1:]
)
def right(self) -> None:
"""! @brief Turn the head right, unless the snake is moving left.""" def up(self) -> None:
if self._direction_of_travel != LEFT: """! @brief Turn the head up, unless the snake is moving down."""
self.head.setheading(RIGHT) 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.""" """Fakes that stand in for turtles and the screen, so that no test opens a window."""
import importlib import importlib
import math import math
import os import os
import subprocess import subprocess
import sys import sys
import types import types
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
from snake_game.snake import Snake from snake_game.snake import Snake
SRC = Path(__file__).resolve().parents[1] / "src" SRC = Path(__file__).resolve().parents[1] / "src"
# Modules whose classes inherit from `Turtle`: they bind their base class when they # 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. # are imported, so each test that installs the fake `turtle` module imports them anew.
INHERITING_MODULES = ("snake_game.food", "snake_game.scoreboard") INHERITING_MODULES = ("snake_game.food", "snake_game.scoreboard")
class FakeTerminatorError(Exception): class FakeTerminatorError(Exception):
"""Stands in for `turtle.Terminator`.""" """Stands in for `turtle.Terminator`."""
class FakeTclError(Exception): class FakeTclError(Exception):
"""Stands in for `tkinter.TclError`.""" """Stands in for `tkinter.TclError`."""
class FakeSegment: class FakeSegment:
"""A turtle that records what is done to it, in order, and keeps its state. """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 It is a segment of the snake, and also the base class that stands in for
`turtle.Turtle` when `Food` and `Scoreboard` are tested. `turtle.Turtle` when `Food` and `Scoreboard` are tested.
""" """
def __init__(self) -> None: def __init__(self) -> None:
self.calls: list[tuple[str, tuple[object, ...]]] = [] self.calls: list[tuple[str, tuple[object, ...]]] = []
self.x = 0.0 self.x = 0.0
self.y = 0.0 self.y = 0.0
self.angle = 0.0 self.angle = 0.0
def shape(self, name: str, /) -> None: def shape(self, name: str, /) -> None:
self.calls.append(("shape", (name,))) self.calls.append(("shape", (name,)))
def shapesize(self, stretch_wid: float, stretch_len: float) -> None: def shapesize(self, stretch_wid: float, stretch_len: float) -> None:
self.calls.append(("shapesize", (stretch_wid, stretch_len))) self.calls.append(("shapesize", (stretch_wid, stretch_len)))
def color(self, color: str, /) -> None: def color(self, color: str, /) -> None:
self.calls.append(("color", (color,))) self.calls.append(("color", (color,)))
def speed(self, speed: str) -> None: def speed(self, speed: str) -> None:
self.calls.append(("speed", (speed,))) self.calls.append(("speed", (speed,)))
def penup(self) -> None: def penup(self) -> None:
self.calls.append(("penup", ())) self.calls.append(("penup", ()))
def hideturtle(self) -> None: def hideturtle(self) -> None:
self.calls.append(("hideturtle", ())) self.calls.append(("hideturtle", ()))
def clear(self) -> None: def clear(self) -> None:
self.calls.append(("clear", ())) self.calls.append(("clear", ()))
def write( def write(
self, self,
arg: str, arg: str,
align: str = "left", align: str = "left",
font: tuple[str, int, str] = ("Arial", 8, "normal"), font: tuple[str, int, str] = ("Arial", 8, "normal"),
) -> None: ) -> None:
self.calls.append(("write", (arg, align, font))) self.calls.append(("write", (arg, align, font)))
def goto(self, x: float | tuple[float, float], y: float | None = None, /) -> None: def goto(self, x: float | tuple[float, float], y: float | None = None, /) -> None:
if isinstance(x, tuple): if isinstance(x, tuple):
x, y = x x, y = x
assert y is not None assert y is not None
self.calls.append(("goto", (x, y))) self.calls.append(("goto", (x, y)))
self.x, self.y = x, y self.x, self.y = x, y
def xcor(self) -> float: def xcor(self) -> float:
return self.x return self.x
def ycor(self) -> float: def ycor(self) -> float:
return self.y return self.y
def position(self) -> tuple[float, float]: def position(self) -> tuple[float, float]:
return (self.x, self.y) return (self.x, self.y)
def distance(self, x: tuple[float, float], /) -> float: def distance(self, x: tuple[float, float], /) -> float:
return math.hypot(self.x - x[0], self.y - x[1]) return math.hypot(self.x - x[0], self.y - x[1])
def forward(self, distance: float, /) -> None: def forward(self, distance: float, /) -> None:
self.calls.append(("forward", (distance,))) self.calls.append(("forward", (distance,)))
radians = math.radians(self.angle) radians = math.radians(self.angle)
self.x += round(distance * math.cos(radians), 10) self.x += round(distance * math.cos(radians), 10)
self.y += round(distance * math.sin(radians), 10) self.y += round(distance * math.sin(radians), 10)
def heading(self) -> float: def heading(self) -> float:
return self.angle return self.angle
def setheading(self, to_angle: float, /) -> None: def setheading(self, to_angle: float, /) -> None:
self.calls.append(("setheading", (to_angle,))) self.calls.append(("setheading", (to_angle,)))
self.angle = float(to_angle) % 360 self.angle = float(to_angle) % 360
def call_names(self) -> list[str]: def call_names(self) -> list[str]:
"""Return the names of the calls, in the order they were made.""" """Return the names of the calls, in the order they were made."""
return [name for name, _ in self.calls] return [name for name, _ in self.calls]
class FakeShape: class FakeShape:
"""Stands in for `turtle.Shape`: it records its type and its components.""" """Stands in for `turtle.Shape`: it records its type and its components."""
def __init__(self, type_: str, data: object = None) -> None: def __init__(self, type_: str, data: object = None) -> None:
self.type = type_ self.type = type_
self.components: list[tuple[object, object, object]] = [] self.components: list[tuple[object, object, object]] = []
def addcomponent(self, poly: object, fill: object, outline: object = None) -> None: def addcomponent(self, poly: object, fill: object, outline: object = None) -> None:
self.components.append((poly, fill, outline)) self.components.append((poly, fill, outline))
class FakeFood: class FakeFood:
"""A food that counts how often it was moved.""" """A food that counts how often it was moved."""
def __init__(self, x: float, y: float) -> None: def __init__(self, x: float, y: float) -> None:
self.x = x self.x = x
self.y = y self.y = y
self.refreshes = 0 self.refreshes = 0
def refresh(self) -> None: def refresh(self) -> None:
self.refreshes += 1 self.refreshes += 1
def position(self) -> tuple[float, float]: def position(self) -> tuple[float, float]:
return (self.x, self.y) return (self.x, self.y)
class FakeScoreboard: class FakeScoreboard:
"""A scoreboard that counts how often the score was raised.""" """A scoreboard that counts how often the score was raised."""
def __init__(self) -> None: def __init__(self) -> None:
self.increases = 0 self.increases = 0
self.game_overs = 0 self.resets = 0
def increase_score(self) -> None: def increase_score(self) -> None:
self.increases += 1 self.increases += 1
def game_over(self) -> None: def reset(self) -> None:
self.game_overs += 1 self.resets += 1
class FakeScreen: class FakeScreen:
"""A screen that records what is done to it, in order. """A screen that records what is done to it, in order.
After `frames_before_close` updates it behaves like a closed window: the next After `frames_before_close` updates it behaves like a closed window: the next
`update` raises `closing_error`. `update` raises `closing_error`.
""" """
def __init__( def __init__(
self, self,
frames_before_close: int = 0, frames_before_close: int = 0,
closing_error: type[Exception] = FakeTerminatorError, closing_error: type[Exception] = FakeTerminatorError,
) -> None: ) -> None:
self.calls: list[tuple[str, tuple[object, ...]]] = [] self.calls: list[tuple[str, tuple[object, ...]]] = []
self.bindings: dict[str, Callable[[], object]] = {} self.bindings: dict[str, Callable[[], object]] = {}
self._updates_left = frames_before_close self._updates_left = frames_before_close
self._closing_error = closing_error self._closing_error = closing_error
def setup(self, width: float, height: float) -> None: def setup(self, width: float, height: float) -> None:
self.calls.append(("setup", (width, height))) self.calls.append(("setup", (width, height)))
def bgcolor(self, color: str, /) -> None: def bgcolor(self, color: str, /) -> None:
self.calls.append(("bgcolor", (color,))) self.calls.append(("bgcolor", (color,)))
def title(self, titlestring: str, /) -> None: def title(self, titlestring: str, /) -> None:
self.calls.append(("title", (titlestring,))) self.calls.append(("title", (titlestring,)))
def register_shape(self, name: str, shape: object, /) -> None: def register_shape(self, name: str, shape: object, /) -> None:
self.calls.append(("register_shape", (name, shape))) self.calls.append(("register_shape", (name, shape)))
def tracer(self, n: int, /) -> None: def tracer(self, n: int, /) -> None:
self.calls.append(("tracer", (n,))) self.calls.append(("tracer", (n,)))
def listen(self) -> None: def listen(self) -> None:
self.calls.append(("listen", ())) self.calls.append(("listen", ()))
def onkey(self, fun: Callable[[], object], key: str) -> None: def onkey(self, fun: Callable[[], object], key: str) -> None:
self.calls.append(("onkey", (key,))) self.calls.append(("onkey", (key,)))
self.bindings[key] = fun self.bindings[key] = fun
def update(self) -> None: def update(self) -> None:
self.calls.append(("update", ())) self.calls.append(("update", ()))
if self._updates_left == 0: if self._updates_left == 0:
raise self._closing_error raise self._closing_error
self._updates_left -= 1 self._updates_left -= 1
def exitonclick(self) -> None: def exitonclick(self) -> None:
self.calls.append(("exitonclick", ())) self.calls.append(("exitonclick", ()))
def call_names(self) -> list[str]: def call_names(self) -> list[str]:
"""Return the names of the calls, in the order they were made.""" """Return the names of the calls, in the order they were made."""
return [name for name, _ in self.calls] return [name for name, _ in self.calls]
def make_snake() -> tuple[Snake, list[FakeSegment]]: def make_snake() -> tuple[Snake, list[FakeSegment]]:
"""Make a snake whose segments are fakes, and return the fakes too.""" """Make a snake whose segments are fakes, and return the fakes too."""
created: list[FakeSegment] = [] created: list[FakeSegment] = []
def factory() -> FakeSegment: def factory() -> FakeSegment:
segment = FakeSegment() segment = FakeSegment()
created.append(segment) created.append(segment)
return segment return segment
return Snake(segment_factory=factory), created return Snake(segment_factory=factory), created
def install_fake_turtle( def install_fake_turtle(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
*, *,
frames_before_close: int = 0, frames_before_close: int = 0,
closing_error: type[Exception] = FakeTerminatorError, closing_error: type[Exception] = FakeTerminatorError,
) -> tuple[list[FakeSegment], list[FakeScreen]]: ) -> tuple[list[FakeSegment], list[FakeScreen]]:
"""Replace the `turtle` and `tkinter` modules with fakes for one test. """Replace the `turtle` and `tkinter` modules with fakes for one test.
The fake screen acts like a window that the player closes after The fake screen acts like a window that the player closes after
`frames_before_close` updates, by raising `closing_error` from `update`. `frames_before_close` updates, by raising `closing_error` from `update`.
Every turtle the code under test creates, including a `Food` or a `Scoreboard` 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; 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 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. that they are imported again against the fake and removed again after the test.
""" """
segments: list[FakeSegment] = [] segments: list[FakeSegment] = []
screens: list[FakeScreen] = [] screens: list[FakeScreen] = []
class RegisteredSegment(FakeSegment): class RegisteredSegment(FakeSegment):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
segments.append(self) segments.append(self)
def make_screen() -> FakeScreen: def make_screen() -> FakeScreen:
screen = FakeScreen(frames_before_close, closing_error) screen = FakeScreen(frames_before_close, closing_error)
screens.append(screen) screens.append(screen)
return screen return screen
turtle_module = types.ModuleType("turtle") turtle_module = types.ModuleType("turtle")
turtle_module.__dict__["Turtle"] = RegisteredSegment turtle_module.__dict__["Turtle"] = RegisteredSegment
turtle_module.__dict__["Screen"] = make_screen turtle_module.__dict__["Screen"] = make_screen
turtle_module.__dict__["Shape"] = FakeShape turtle_module.__dict__["Shape"] = FakeShape
turtle_module.__dict__["Terminator"] = FakeTerminatorError turtle_module.__dict__["Terminator"] = FakeTerminatorError
tkinter_module = types.ModuleType("tkinter") tkinter_module = types.ModuleType("tkinter")
tkinter_module.__dict__["TclError"] = FakeTclError tkinter_module.__dict__["TclError"] = FakeTclError
monkeypatch.setitem(sys.modules, "turtle", turtle_module) monkeypatch.setitem(sys.modules, "turtle", turtle_module)
monkeypatch.setitem(sys.modules, "tkinter", tkinter_module) monkeypatch.setitem(sys.modules, "tkinter", tkinter_module)
for name in INHERITING_MODULES: for name in INHERITING_MODULES:
# Set, then delete: when the test ends monkeypatch undoes both in reverse # Set, then delete: when the test ends monkeypatch undoes both in reverse
# order and the key is gone again, whatever the test imported meanwhile. # order and the key is gone again, whatever the test imported meanwhile.
monkeypatch.setitem(sys.modules, name, types.ModuleType(name)) monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
monkeypatch.delitem(sys.modules, name) monkeypatch.delitem(sys.modules, name)
return segments, screens return segments, screens
def script_randint( def script_randint(
monkeypatch: pytest.MonkeyPatch, values: Sequence[int] monkeypatch: pytest.MonkeyPatch, values: Sequence[int]
) -> list[tuple[int, int]]: ) -> list[tuple[int, int]]:
"""Make `random.randint` return the given values in turn, then the upper bound. """Make `random.randint` return the given values in turn, then the upper bound.
Returns the list that collects the bounds of every call. Returns the list that collects the bounds of every call.
""" """
queue = iter(values) queue = iter(values)
bounds: list[tuple[int, int]] = [] bounds: list[tuple[int, int]] = []
def fake_randint(low: int, high: int) -> int: def fake_randint(low: int, high: int) -> int:
bounds.append((low, high)) bounds.append((low, high))
return next(queue, high) return next(queue, high)
monkeypatch.setattr("random.randint", fake_randint) monkeypatch.setattr("random.randint", fake_randint)
return bounds return bounds
def make_food(monkeypatch: pytest.MonkeyPatch) -> Any: def make_food(monkeypatch: pytest.MonkeyPatch) -> Any:
"""Make a real `Food` whose base class is the fake `Turtle`. """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 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. the recording methods of the fake base class (`calls`, `position`) at once.
""" """
install_fake_turtle(monkeypatch) install_fake_turtle(monkeypatch)
module = importlib.import_module("snake_game.food") module = importlib.import_module("snake_game.food")
return module.Food() return module.Food()
def make_scoreboard(monkeypatch: pytest.MonkeyPatch) -> Any: def make_scoreboard(monkeypatch: pytest.MonkeyPatch) -> Any:
"""Make a real `Scoreboard` whose base class is the fake `Turtle`. """Make a real `Scoreboard` whose base class is the fake `Turtle`.
The result is typed `Any` because it has the methods of `Scoreboard` The result is typed `Any` because it has the methods of `Scoreboard`
(`increase_score`) and the recording methods of the fake base class (`calls`) (`increase_score`) and the recording methods of the fake base class (`calls`)
at once. at once.
""" """
install_fake_turtle(monkeypatch) install_fake_turtle(monkeypatch)
module = importlib.import_module("snake_game.scoreboard") module = importlib.import_module("snake_game.scoreboard")
return module.Scoreboard() return module.Scoreboard()
def imports_turtle_or_tkinter(module_name: str) -> bool: def imports_turtle_or_tkinter(module_name: str) -> bool:
"""Tell whether importing a module in a fresh interpreter loads a display module.""" """Tell whether importing a module in a fresh interpreter loads a display module."""
code = ( code = (
f"import sys, {module_name}; " f"import sys, {module_name}; "
"print('turtle' in sys.modules or 'tkinter' in sys.modules)" "print('turtle' in sys.modules or 'tkinter' in sys.modules)"
) )
result = subprocess.run( result = subprocess.run(
[sys.executable, "-c", code], [sys.executable, "-c", code],
env={**os.environ, "PYTHONPATH": str(SRC)}, env={**os.environ, "PYTHONPATH": str(SRC)},
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
return result.stdout.strip() == "True" 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.""" """Tests of the constants that the day-20, day-21 and day-24 lectures fix."""
from snake_game import constants from snake_game import constants
HALF_WIDTH = constants.SCREEN_WIDTH / 2 HALF_WIDTH = constants.SCREEN_WIDTH / 2
HALF_HEIGHT = constants.SCREEN_HEIGHT / 2 HALF_HEIGHT = constants.SCREEN_HEIGHT / 2
def test_screen_size_matches_lecture() -> None: def test_screen_size_matches_lecture() -> None:
assert (constants.SCREEN_WIDTH, constants.SCREEN_HEIGHT) == (600, 600) assert (constants.SCREEN_WIDTH, constants.SCREEN_HEIGHT) == (600, 600)
def test_screen_is_black_and_titled_as_in_the_lecture() -> None: def test_screen_is_black_and_titled_as_in_the_lecture() -> None:
assert constants.SCREEN_BACKGROUND_COLOR == "black" assert constants.SCREEN_BACKGROUND_COLOR == "black"
assert constants.SCREEN_TITLE == "My Snake Game" assert constants.SCREEN_TITLE == "My Snake Game"
def test_body_segments_are_light_green_squares() -> None: def test_body_segments_are_light_green_squares() -> None:
assert constants.SEGMENT_SHAPE == "square" assert constants.SEGMENT_SHAPE == "square"
assert constants.BODY_COLOR == "light green" assert constants.BODY_COLOR == "light green"
def test_segment_color_no_longer_exists() -> None: def test_segment_color_no_longer_exists() -> None:
assert not hasattr(constants, "SEGMENT_COLOR") assert not hasattr(constants, "SEGMENT_COLOR")
def test_head_is_dark_green_and_differs_from_the_body() -> None: def test_head_is_dark_green_and_differs_from_the_body() -> None:
assert constants.HEAD_COLOR == "dark green" assert constants.HEAD_COLOR == "dark green"
assert constants.HEAD_COLOR != constants.BODY_COLOR assert constants.HEAD_COLOR != constants.BODY_COLOR
def test_head_shape_has_a_name_that_is_not_a_built_in_shape() -> None: 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 == "snake_head"
assert constants.HEAD_SHAPE != constants.SEGMENT_SHAPE assert constants.HEAD_SHAPE != constants.SEGMENT_SHAPE
def test_head_is_the_20_by_20_square() -> None: def test_head_is_the_20_by_20_square() -> None:
assert constants.HEAD_OUTLINE == ((-10, -10), (-10, 10), (10, 10), (10, -10)) assert constants.HEAD_OUTLINE == ((-10, -10), (-10, 10), (10, 10), (10, -10))
xs = {x for x, _ in constants.HEAD_OUTLINE} xs = {x for x, _ in constants.HEAD_OUTLINE}
ys = {y for _, y in constants.HEAD_OUTLINE} ys = {y for _, y in constants.HEAD_OUTLINE}
assert max(xs) - min(xs) == max(ys) - min(ys) == 20 assert max(xs) - min(xs) == max(ys) - min(ys) == 20
def test_mouth_lies_on_the_front_edge_and_inside_the_head() -> None: def test_mouth_lies_on_the_front_edge_and_inside_the_head() -> None:
front = max(y for _, y in constants.HEAD_OUTLINE) front = max(y for _, y in constants.HEAD_OUTLINE)
half = max(x for x, _ in constants.HEAD_OUTLINE) half = max(x for x, _ in constants.HEAD_OUTLINE)
assert constants.MOUTH_OUTLINE == ((-6, 10), (0, 2), (6, 10)) assert constants.MOUTH_OUTLINE == ((-6, 10), (0, 2), (6, 10))
assert any(y == front for _, y in constants.MOUTH_OUTLINE) assert any(y == front for _, y in constants.MOUTH_OUTLINE)
assert all( assert all(
abs(x) <= half and -half <= y <= front for x, y in constants.MOUTH_OUTLINE abs(x) <= half and -half <= y <= front for x, y in constants.MOUTH_OUTLINE
) )
def test_mouth_is_the_colour_of_the_background() -> None: def test_mouth_is_the_colour_of_the_background() -> None:
assert constants.MOUTH_COLOR == constants.SCREEN_BACKGROUND_COLOR assert constants.MOUTH_COLOR == constants.SCREEN_BACKGROUND_COLOR
def test_snake_starts_with_three_segments_at_the_lecture_positions() -> None: def test_snake_starts_with_three_segments_at_the_lecture_positions() -> None:
assert constants.STARTING_POSITIONS == ((0, 0), (-20, 0), (-40, 0)) assert constants.STARTING_POSITIONS == ((0, 0), (-20, 0), (-40, 0))
def test_starting_positions_are_distinct_on_one_row() -> None: def test_starting_positions_are_distinct_on_one_row() -> None:
positions = constants.STARTING_POSITIONS positions = constants.STARTING_POSITIONS
assert len(set(positions)) == len(positions) assert len(set(positions)) == len(positions)
assert len({y for _, y in positions}) == 1 assert len({y for _, y in positions}) == 1
def test_starting_positions_run_from_head_to_tail_leftwards() -> None: def test_starting_positions_run_from_head_to_tail_leftwards() -> None:
x_values = [x for x, _ in constants.STARTING_POSITIONS] x_values = [x for x, _ in constants.STARTING_POSITIONS]
assert x_values == sorted(x_values, reverse=True) assert x_values == sorted(x_values, reverse=True)
def test_starting_positions_are_one_move_distance_apart() -> None: def test_starting_positions_are_one_move_distance_apart() -> None:
x_values = [x for x, _ in constants.STARTING_POSITIONS] x_values = [x for x, _ in constants.STARTING_POSITIONS]
gaps = {left - right for left, right in zip(x_values, x_values[1:], strict=False)} gaps = {left - right for left, right in zip(x_values, x_values[1:], strict=False)}
assert gaps == {constants.MOVE_DISTANCE} assert gaps == {constants.MOVE_DISTANCE}
def test_starting_positions_are_inside_the_screen() -> None: def test_starting_positions_are_inside_the_screen() -> None:
assert all( assert all(
abs(x) < HALF_WIDTH and abs(y) < HALF_HEIGHT abs(x) < HALF_WIDTH and abs(y) < HALF_HEIGHT
for x, y in constants.STARTING_POSITIONS for x, y in constants.STARTING_POSITIONS
) )
def test_move_distance_is_twenty_pixels() -> None: def test_move_distance_is_twenty_pixels() -> None:
assert constants.MOVE_DISTANCE == 20 assert constants.MOVE_DISTANCE == 20
def test_directions_match_the_lecture_headings() -> None: def test_directions_match_the_lecture_headings() -> None:
assert constants.UP == 90 assert constants.UP == 90
assert constants.DOWN == 270 assert constants.DOWN == 270
assert constants.LEFT == 180 assert constants.LEFT == 180
assert constants.RIGHT == 0 assert constants.RIGHT == 0
def test_four_directions_are_distinct() -> None: def test_four_directions_are_distinct() -> None:
directions = (constants.UP, constants.DOWN, constants.LEFT, constants.RIGHT) directions = (constants.UP, constants.DOWN, constants.LEFT, constants.RIGHT)
assert len(set(directions)) == len(directions) assert len(set(directions)) == len(directions)
def test_opposite_directions_differ_by_half_a_turn() -> None: def test_opposite_directions_differ_by_half_a_turn() -> None:
assert (constants.UP - constants.DOWN) % 360 == 180 assert (constants.UP - constants.DOWN) % 360 == 180
assert (constants.LEFT - constants.RIGHT) % 360 == 180 assert (constants.LEFT - constants.RIGHT) % 360 == 180
def test_refresh_delay_is_a_tenth_of_a_second() -> None: def test_refresh_delay_is_a_tenth_of_a_second() -> None:
assert constants.REFRESH_DELAY_SECONDS == 0.1 assert constants.REFRESH_DELAY_SECONDS == 0.1
def test_food_is_a_small_blue_circle_without_animation() -> None: def test_food_is_a_small_blue_circle_without_animation() -> None:
assert constants.FOOD_SHAPE == "circle" assert constants.FOOD_SHAPE == "circle"
assert constants.FOOD_SIZE == 0.5 assert constants.FOOD_SIZE == 0.5
assert constants.FOOD_COLOR == "blue" assert constants.FOOD_COLOR == "blue"
assert constants.FOOD_SPEED == "fastest" assert constants.FOOD_SPEED == "fastest"
def test_wall_is_inside_the_screen() -> None: def test_wall_is_inside_the_screen() -> None:
assert 0 < constants.WALL_LIMIT < HALF_WIDTH assert 0 < constants.WALL_LIMIT < HALF_WIDTH
assert constants.WALL_LIMIT < HALF_HEIGHT assert constants.WALL_LIMIT < HALF_HEIGHT
def test_wall_is_280_pixels_from_the_centre() -> None: def test_wall_is_280_pixels_from_the_centre() -> None:
assert constants.WALL_LIMIT == 280 assert constants.WALL_LIMIT == 280
def test_food_is_eaten_closer_than_15_pixels_which_is_less_than_a_move() -> None: 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 == 15
assert constants.FOOD_COLLISION_DISTANCE < constants.MOVE_DISTANCE assert constants.FOOD_COLLISION_DISTANCE < constants.MOVE_DISTANCE
def test_scoreboard_is_white_text_at_the_top_centre_inside_the_screen() -> None: def test_scoreboard_is_white_text_at_the_top_centre_inside_the_screen() -> None:
x, y = constants.SCOREBOARD_POSITION x, y = constants.SCOREBOARD_POSITION
assert constants.SCOREBOARD_COLOR == "white" assert constants.SCOREBOARD_COLOR == "white"
assert constants.SCOREBOARD_ALIGNMENT == "center" assert constants.SCOREBOARD_ALIGNMENT == "center"
assert (x, y) == (0, 270) assert (x, y) == (0, 270)
assert abs(x) < HALF_WIDTH assert abs(x) < HALF_WIDTH
assert 0 < y < HALF_HEIGHT assert 0 < y < HALF_HEIGHT
def test_scoreboard_text_is_score_label_and_arial_24() -> None: def test_scoreboard_text_is_score_label_and_arial_24() -> None:
assert constants.SCORE_LABEL == "Score: " assert constants.SCORE_LABEL == "Score: "
assert constants.SCOREBOARD_FONT == ("Arial", 24, "normal") assert constants.SCOREBOARD_FONT == ("Arial", 24, "normal")
def test_tail_is_touched_closer_than_10_pixels_which_is_less_than_a_move() -> None: def test_tail_is_touched_closer_than_10_pixels_which_is_less_than_a_move() -> None:
assert constants.TAIL_COLLISION_DISTANCE == 10 assert constants.TAIL_COLLISION_DISTANCE == 10
assert 0 < constants.TAIL_COLLISION_DISTANCE < constants.MOVE_DISTANCE assert 0 < constants.TAIL_COLLISION_DISTANCE < constants.MOVE_DISTANCE
def test_game_over_text_is_written_at_the_centre_inside_the_wall() -> None: def test_game_over_constants_no_longer_exist() -> None:
x, y = constants.GAME_OVER_POSITION assert not hasattr(constants, "GAME_OVER_TEXT")
assert not hasattr(constants, "GAME_OVER_POSITION")
assert constants.GAME_OVER_TEXT == "GAME OVER"
assert (x, y) == (0, 0)
assert abs(x) < constants.WALL_LIMIT def test_high_score_label_follows_the_score_label_as_in_the_lecture() -> None:
assert abs(y) < constants.WALL_LIMIT 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, bind_keys,
configure_screen, configure_screen,
eat_food_if_close, eat_food_if_close,
end_game_if_over,
main, main,
play_frame, play_frame,
register_head_shape, register_head_shape,
reset_if_collision,
) )
from snake_game.snake import Snake 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", (20, 0)),
("goto", (constants.WALL_LIMIT, constants.WALL_LIMIT)), ("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 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() main()
scoreboard = turtles[4] 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 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: def test_nothing_is_reset_while_the_snake_is_inside_and_clear_of_its_tail() -> None:
snake, _ = make_snake() snake, created = make_snake()
scoreboard = FakeScoreboard() scoreboard = FakeScoreboard()
assert not end_game_if_over(snake, scoreboard) assert not reset_if_collision(snake, scoreboard)
assert scoreboard.game_overs == 0 assert scoreboard.resets == 0
assert len(created) == len(constants.STARTING_POSITIONS)
def test_the_game_is_over_when_the_head_passes_the_wall() -> None: def test_the_game_is_reset_when_the_head_passes_the_wall() -> None:
snake, _ = make_snake() snake, created = make_snake()
snake.head.goto(constants.WALL_LIMIT + 1, 0) snake.head.goto(constants.WALL_LIMIT + 1, 0)
scoreboard = FakeScoreboard() scoreboard = FakeScoreboard()
assert end_game_if_over(snake, scoreboard) assert reset_if_collision(snake, scoreboard)
assert scoreboard.game_overs == 1 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() snake, created = make_snake()
created[1].goto(3, 4) created[1].goto(3, 4)
scoreboard = FakeScoreboard() scoreboard = FakeScoreboard()
assert end_game_if_over(snake, scoreboard) assert reset_if_collision(snake, scoreboard)
assert scoreboard.game_overs == 1 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] monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None: ) -> 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() main()
snake_head, scoreboard = turtles[0], turtles[4] old_head, scoreboard = turtles[0], turtles[4]
moves_to_pass_the_wall = constants.WALL_LIMIT // constants.MOVE_DISTANCE + 1 new_head = turtles[5]
assert snake_head.call_names().count("forward") == moves_to_pass_the_wall written = [call[1][0] for call in scoreboard.calls if call[0] == "write"]
assert snake_head.position() == ( assert old_head.position() == constants.SEGMENT_PARKING_POSITION
constants.MOVE_DISTANCE * moves_to_pass_the_wall, assert new_head.call_names().count("forward") == 2
0, assert new_head.position() == (2 * constants.MOVE_DISTANCE, 0)
)
assert scoreboard.calls[-1] == ( assert scoreboard.calls[-1] == (
"write", "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 "GAME OVER" not in written
assert screens[0].call_names().count("exitonclick") == 1 assert "exitonclick" not in screens[0].call_names()
assert sleeps == [constants.REFRESH_DELAY_SECONDS] * moves_to_pass_the_wall
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] monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None: ) -> None:
monkeypatch.setattr("snake_game.snake.Snake.hits_tail", lambda self: True) 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() main()
assert turtles[0].call_names().count("forward") == 1 assert turtles[0].call_names().count("forward") == 1
assert turtles[4].calls[-1][1][0] == "GAME OVER" assert turtles[0].position() == constants.SEGMENT_PARKING_POSITION
assert screens[0].call_names()[-1] == "exitonclick" assert "exitonclick" not in screens[0].call_names()
def test_main_does_not_move_the_snake_after_game_over(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=100)
main()
updates = screens[0].call_names().count("update")
moves = turtles[0].call_names().count("forward")
assert updates == moves + 1 # one update per frame, and one for the last text
@pytest.mark.parametrize("closing_error", [FakeTerminatorError, FakeTclError]) @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, monkeypatch: pytest.MonkeyPatch,
sleeps: list[float], sleeps: list[float],
closing_error: type[Exception], closing_error: type[Exception],
@@ -381,7 +388,7 @@ def test_main_ends_quietly_when_the_window_is_closed_after_game_over(
main() 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() 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.""" """Tests of the scoreboard, with a fake `Turtle` as its base class."""
import importlib import importlib
import sys import sys
import pytest import pytest
from fakes import install_fake_turtle, make_scoreboard from fakes import install_fake_turtle, make_scoreboard
from snake_game import constants from snake_game import constants
START_TEXT = ("write", ("Score: 0", "center", ("Arial", 24, "normal"))) 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) def test_scoreboard_is_white_hidden_and_at_the_top_centre(
monkeypatch: pytest.MonkeyPatch,
assert ("color", ("white",)) in scoreboard.calls ) -> None:
assert "penup" in scoreboard.call_names() scoreboard = make_scoreboard(monkeypatch)
assert "hideturtle" in scoreboard.call_names()
assert ("goto", constants.SCOREBOARD_POSITION) in scoreboard.calls assert ("color", ("white",)) in scoreboard.calls
assert "penup" in scoreboard.call_names()
assert "hideturtle" in scoreboard.call_names()
def test_scoreboard_inherits_from_turtle(monkeypatch: pytest.MonkeyPatch) -> None: assert ("goto", constants.SCOREBOARD_POSITION) in scoreboard.calls
install_fake_turtle(monkeypatch)
module = importlib.import_module("snake_game.scoreboard")
def test_scoreboard_inherits_from_turtle(monkeypatch: pytest.MonkeyPatch) -> None:
assert issubclass(module.Scoreboard, sys.modules["turtle"].Turtle) install_fake_turtle(monkeypatch)
module = importlib.import_module("snake_game.scoreboard")
def test_scoreboard_starts_by_writing_score_0_centred_in_arial_24( assert issubclass(module.Scoreboard, sys.modules["turtle"].Turtle)
monkeypatch: pytest.MonkeyPatch,
) -> None:
scoreboard = make_scoreboard(monkeypatch) def test_scoreboard_starts_by_writing_both_scores_0_centred_in_arial_24(
monkeypatch: pytest.MonkeyPatch,
assert scoreboard.calls[-1] == START_TEXT ) -> None:
scoreboard = make_scoreboard(monkeypatch)
def test_increase_score_wipes_the_old_text_and_writes_the_new_score( assert scoreboard.calls[-1] == START_TEXT
monkeypatch: pytest.MonkeyPatch,
) -> None:
scoreboard = make_scoreboard(monkeypatch) def test_increase_score_wipes_the_old_text_and_writes_the_new_score(
monkeypatch: pytest.MonkeyPatch,
scoreboard.increase_score() ) -> None:
scoreboard = make_scoreboard(monkeypatch)
assert scoreboard.call_names()[-2:] == ["clear", "write"]
assert scoreboard.calls[-1] == ( scoreboard.increase_score()
"write",
("Score: 1", "center", ("Arial", 24, "normal")), 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): def test_every_food_adds_one_to_the_score(monkeypatch: pytest.MonkeyPatch) -> None:
scoreboard.increase_score() scoreboard = make_scoreboard(monkeypatch)
assert scoreboard.calls[-1][1][0] == "Score: 3" for _ in range(3):
scoreboard.increase_score()
def test_game_over_writes_the_text_at_the_centre_in_the_scoreboard_font( assert scoreboard.calls[-1][1][0] == "Score: 3 High Score: 0"
monkeypatch: pytest.MonkeyPatch,
) -> None:
scoreboard = make_scoreboard(monkeypatch) def test_high_score_starts_at_0(monkeypatch: pytest.MonkeyPatch) -> None:
scoreboard = make_scoreboard(monkeypatch)
scoreboard.game_over()
assert scoreboard.high_score == 0
assert scoreboard.calls[-2:] == [ assert scoreboard.score == 0
("goto", constants.GAME_OVER_POSITION),
("write", ("GAME OVER", "center", ("Arial", 24, "normal"))),
] def test_reset_keeps_a_higher_score_as_the_high_score_and_starts_again(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_game_over_leaves_the_score_on_the_screen( scoreboard = make_scoreboard(monkeypatch)
monkeypatch: pytest.MonkeyPatch, for _ in range(3):
) -> None: scoreboard.increase_score()
scoreboard = make_scoreboard(monkeypatch)
scoreboard.increase_score() scoreboard.reset()
calls_before = len(scoreboard.calls)
assert scoreboard.high_score == 3
scoreboard.game_over() assert scoreboard.score == 0
assert scoreboard.call_names()[-2:] == ["clear", "write"]
assert "clear" not in scoreboard.call_names()[calls_before:] 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.""" """Tests of the snake, with fakes instead of real turtles."""
import math import math
from collections.abc import Sequence from collections.abc import Sequence
import pytest import pytest
from fakes import ( from fakes import (
FakeSegment, FakeSegment,
imports_turtle_or_tkinter, imports_turtle_or_tkinter,
install_fake_turtle, install_fake_turtle,
make_snake, make_snake,
) )
from snake_game import constants from snake_game import constants
from snake_game.snake import Segment, Snake from snake_game.snake import Segment, Snake
DIRECTIONS = { DIRECTIONS = {
"up": constants.UP, "up": constants.UP,
"down": constants.DOWN, "down": constants.DOWN,
"left": constants.LEFT, "left": constants.LEFT,
"right": constants.RIGHT, "right": constants.RIGHT,
} }
OPPOSITE = { OPPOSITE = {
constants.UP: constants.DOWN, constants.UP: constants.DOWN,
constants.DOWN: constants.UP, constants.DOWN: constants.UP,
constants.LEFT: constants.RIGHT, constants.LEFT: constants.RIGHT,
constants.RIGHT: constants.LEFT, constants.RIGHT: constants.LEFT,
} }
def positions(segments: Sequence[Segment]) -> list[tuple[float, float]]: def positions(segments: Sequence[Segment]) -> list[tuple[float, float]]:
"""Return where every segment is, head first.""" """Return where every segment is, head first."""
return [segment.position() for segment in segments] return [segment.position() for segment in segments]
def make_snake_moving(direction: int) -> tuple[Snake, list[FakeSegment]]: def make_snake_moving(direction: int) -> tuple[Snake, list[FakeSegment]]:
"""Make a snake whose last move went in the given direction.""" """Make a snake whose last move went in the given direction."""
snake, created = make_snake() snake, created = make_snake()
created[0].setheading(direction) created[0].setheading(direction)
snake.move() snake.move()
return snake, created return snake, created
def test_snake_has_one_segment_per_starting_position() -> None: def test_snake_has_one_segment_per_starting_position() -> None:
snake, _ = make_snake() snake, _ = make_snake()
assert len(snake.segments) == len(constants.STARTING_POSITIONS) == 3 assert len(snake.segments) == len(constants.STARTING_POSITIONS) == 3
def test_segments_are_the_created_turtles_in_order() -> None: def test_segments_are_the_created_turtles_in_order() -> None:
snake, created = make_snake() snake, created = make_snake()
assert snake.segments == created assert snake.segments == created
def test_head_has_the_head_shape_and_no_colour_call() -> None: def test_head_has_the_head_shape_and_no_colour_call() -> None:
_, created = make_snake() _, created = make_snake()
head = created[0] head = created[0]
assert ("shape", (constants.HEAD_SHAPE,)) in head.calls assert ("shape", (constants.HEAD_SHAPE,)) in head.calls
assert "color" not in head.call_names() assert "color" not in head.call_names()
def test_body_segments_are_light_green_squares() -> None: def test_body_segments_are_light_green_squares() -> None:
_, created = make_snake() _, created = make_snake()
assert len(created) > 1 assert len(created) > 1
for segment in created[1:]: for segment in created[1:]:
assert ("shape", (constants.SEGMENT_SHAPE,)) in segment.calls assert ("shape", (constants.SEGMENT_SHAPE,)) in segment.calls
assert ("color", (constants.BODY_COLOR,)) in segment.calls assert ("color", (constants.BODY_COLOR,)) in segment.calls
def test_segments_are_placed_at_the_starting_positions_head_first() -> None: def test_segments_are_placed_at_the_starting_positions_head_first() -> None:
_, created = make_snake() _, created = make_snake()
assert positions(created) == [ assert positions(created) == [
(float(x), float(y)) for x, y in constants.STARTING_POSITIONS (float(x), float(y)) for x, y in constants.STARTING_POSITIONS
] ]
def test_pen_is_lifted_before_a_segment_is_moved() -> None: def test_pen_is_lifted_before_a_segment_is_moved() -> None:
_, created = make_snake() _, created = make_snake()
for segment in created: for segment in created:
names = segment.call_names() names = segment.call_names()
assert names.count("goto") == 1 assert names.count("goto") == 1
assert names.index("penup") < names.index("goto") assert names.index("penup") < names.index("goto")
def test_create_snake_adds_three_more_segments_when_called_again() -> None: def test_create_snake_adds_three_more_segments_when_called_again() -> None:
snake, _ = make_snake() snake, _ = make_snake()
snake.create_snake() snake.create_snake()
assert len(snake.segments) == 2 * len(constants.STARTING_POSITIONS) assert len(snake.segments) == 2 * len(constants.STARTING_POSITIONS)
def test_default_segment_factory_makes_turtles(monkeypatch: pytest.MonkeyPatch) -> None: def test_default_segment_factory_makes_turtles(monkeypatch: pytest.MonkeyPatch) -> None:
turtles, _ = install_fake_turtle(monkeypatch) turtles, _ = install_fake_turtle(monkeypatch)
snake = Snake() snake = Snake()
assert snake.segments == turtles assert snake.segments == turtles
assert len(turtles) == len(constants.STARTING_POSITIONS) assert len(turtles) == len(constants.STARTING_POSITIONS)
def test_importing_the_snake_module_does_not_import_turtle_or_tkinter() -> None: def test_importing_the_snake_module_does_not_import_turtle_or_tkinter() -> None:
assert not imports_turtle_or_tkinter("snake_game.snake") assert not imports_turtle_or_tkinter("snake_game.snake")
def test_head_is_the_first_segment() -> None: def test_head_is_the_first_segment() -> None:
snake, created = make_snake() snake, created = make_snake()
assert snake.head is created[0] assert snake.head is created[0]
def test_snake_starts_moving_to_the_right() -> None: def test_snake_starts_moving_to_the_right() -> None:
snake, _ = make_snake() snake, _ = make_snake()
assert snake.head.heading() == constants.RIGHT assert snake.head.heading() == constants.RIGHT
def test_move_goes_forward_by_the_move_distance() -> None: def test_move_goes_forward_by_the_move_distance() -> None:
snake, created = make_snake() snake, created = make_snake()
snake.move() snake.move()
assert ("forward", (constants.MOVE_DISTANCE,)) in created[0].calls assert ("forward", (constants.MOVE_DISTANCE,)) in created[0].calls
assert created[0].position() == (constants.MOVE_DISTANCE, 0) assert created[0].position() == (constants.MOVE_DISTANCE, 0)
def test_move_takes_each_segment_to_the_place_of_the_one_before_it() -> None: def test_move_takes_each_segment_to_the_place_of_the_one_before_it() -> None:
snake, created = make_snake() snake, created = make_snake()
snake.move() snake.move()
assert positions(created) == [(20, 0), (0, 0), (-20, 0)] assert positions(created) == [(20, 0), (0, 0), (-20, 0)]
def test_move_keeps_the_segments_joined_while_turning() -> None: def test_move_keeps_the_segments_joined_while_turning() -> None:
snake, created = make_snake() snake, created = make_snake()
snake.up() snake.up()
snake.move() snake.move()
snake.move() snake.move()
assert positions(created) == [(0, 40), (0, 20), (0, 0)] assert positions(created) == [(0, 40), (0, 20), (0, 0)]
def test_move_works_for_any_number_of_segments() -> None: def test_move_works_for_any_number_of_segments() -> None:
snake, created = make_snake() snake, created = make_snake()
for index in range(3, 6): for index in range(3, 6):
extra = FakeSegment() extra = FakeSegment()
extra.goto(-20 * index, 0) extra.goto(-20 * index, 0)
snake.segments.append(extra) snake.segments.append(extra)
created.append(extra) created.append(extra)
snake.move() snake.move()
assert positions(created) == [ assert positions(created) == [
(20, 0), (20, 0),
(0, 0), (0, 0),
(-20, 0), (-20, 0),
(-40, 0), (-40, 0),
(-60, 0), (-60, 0),
(-80, 0), (-80, 0),
] ]
@pytest.mark.parametrize("travel", list(OPPOSITE)) @pytest.mark.parametrize("travel", list(OPPOSITE))
@pytest.mark.parametrize("method", list(DIRECTIONS)) @pytest.mark.parametrize("method", list(DIRECTIONS))
def test_every_turn_is_accepted_unless_it_is_a_reversal( def test_every_turn_is_accepted_unless_it_is_a_reversal(
method: str, travel: int method: str, travel: int
) -> None: ) -> None:
snake, _ = make_snake_moving(travel) snake, _ = make_snake_moving(travel)
getattr(snake, method)() getattr(snake, method)()
wanted = DIRECTIONS[method] wanted = DIRECTIONS[method]
is_reversal = wanted == OPPOSITE[travel] is_reversal = wanted == OPPOSITE[travel]
assert snake.head.heading() == (travel if is_reversal else wanted) assert snake.head.heading() == (travel if is_reversal else wanted)
def test_up_sets_the_head_to_90_degrees() -> None: def test_up_sets_the_head_to_90_degrees() -> None:
snake, _ = make_snake() snake, _ = make_snake()
snake.up() snake.up()
assert snake.head.heading() == 90 assert snake.head.heading() == 90
def test_down_sets_the_head_to_270_degrees() -> None: def test_down_sets_the_head_to_270_degrees() -> None:
snake, _ = make_snake() snake, _ = make_snake()
snake.down() snake.down()
assert snake.head.heading() == 270 assert snake.head.heading() == 270
def test_right_keeps_the_head_at_0_degrees() -> None: def test_right_keeps_the_head_at_0_degrees() -> None:
snake, _ = make_snake() snake, _ = make_snake()
snake.right() snake.right()
assert snake.head.heading() == 0 assert snake.head.heading() == 0
def test_left_is_ignored_while_moving_right() -> None: def test_left_is_ignored_while_moving_right() -> None:
snake, _ = make_snake() snake, _ = make_snake()
snake.left() snake.left()
assert snake.head.heading() == 0 assert snake.head.heading() == 0
def test_two_key_presses_within_one_move_do_not_reverse_the_snake() -> None: def test_two_key_presses_within_one_move_do_not_reverse_the_snake() -> None:
snake, _ = make_snake() snake, _ = make_snake()
snake.up() snake.up()
snake.left() snake.left()
assert snake.head.heading() == constants.UP assert snake.head.heading() == constants.UP
def test_a_turn_is_accepted_after_the_move_that_followed_the_first_turn() -> None: def test_a_turn_is_accepted_after_the_move_that_followed_the_first_turn() -> None:
snake, _ = make_snake() snake, _ = make_snake()
snake.up() snake.up()
snake.move() snake.move()
snake.left() snake.left()
assert snake.head.heading() == constants.LEFT assert snake.head.heading() == constants.LEFT
def test_add_segment_puts_a_light_green_square_with_the_pen_up_at_the_position() -> ( def test_add_segment_puts_a_light_green_square_with_the_pen_up_at_the_position() -> (
None None
): ):
snake, created = make_snake() snake, created = make_snake()
snake.add_segment((100, -60)) snake.add_segment((100, -60))
new = created[-1] new = created[-1]
assert snake.segments[-1] is new assert snake.segments[-1] is new
assert ("shape", (constants.SEGMENT_SHAPE,)) in new.calls assert ("shape", (constants.SEGMENT_SHAPE,)) in new.calls
assert ("color", (constants.BODY_COLOR,)) in new.calls assert ("color", (constants.BODY_COLOR,)) in new.calls
assert new.call_names().index("penup") < new.call_names().index("goto") assert new.call_names().index("penup") < new.call_names().index("goto")
assert new.position() == (100, -60) assert new.position() == (100, -60)
def test_extend_adds_one_segment_where_the_last_segment_is() -> None: def test_extend_adds_one_segment_where_the_last_segment_is() -> None:
snake, created = make_snake() snake, created = make_snake()
last_place = created[-1].position() last_place = created[-1].position()
snake.extend() snake.extend()
assert len(snake.segments) == len(constants.STARTING_POSITIONS) + 1 assert len(snake.segments) == len(constants.STARTING_POSITIONS) + 1
assert snake.segments[-1].position() == last_place assert snake.segments[-1].position() == last_place
def test_extend_adds_a_light_green_square_and_not_a_head() -> None: def test_extend_adds_a_light_green_square_and_not_a_head() -> None:
snake, _ = make_snake() snake, _ = make_snake()
snake.extend() snake.extend()
new = snake.segments[-1] new = snake.segments[-1]
assert isinstance(new, FakeSegment) assert isinstance(new, FakeSegment)
assert ("shape", (constants.SEGMENT_SHAPE,)) in new.calls assert ("shape", (constants.SEGMENT_SHAPE,)) in new.calls
assert ("color", (constants.BODY_COLOR,)) in new.calls assert ("color", (constants.BODY_COLOR,)) in new.calls
assert ("shape", (constants.HEAD_SHAPE,)) not in new.calls assert ("shape", (constants.HEAD_SHAPE,)) not in new.calls
def test_extend_twice_adds_two_segments() -> None: def test_extend_twice_adds_two_segments() -> None:
snake, _ = make_snake() snake, _ = make_snake()
snake.extend() snake.extend()
snake.extend() snake.extend()
assert len(snake.segments) == len(constants.STARTING_POSITIONS) + 2 assert len(snake.segments) == len(constants.STARTING_POSITIONS) + 2
def test_head_stays_the_first_segment_after_extend() -> None: def test_head_stays_the_first_segment_after_extend() -> None:
snake, created = make_snake() snake, created = make_snake()
snake.extend() snake.extend()
assert snake.head is created[0] assert snake.head is created[0]
assert snake.segments[0] 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() -> ( def test_the_new_segment_follows_the_snake_and_the_body_stays_joined_when_turning() -> (
None None
): ):
snake, _ = make_snake() snake, _ = make_snake()
snake.up() snake.up()
snake.move() snake.move()
snake.extend() snake.extend()
snake.move() snake.move()
places = positions(snake.segments) places = positions(snake.segments)
gaps = [ gaps = [
math.dist(places[index], places[index + 1]) for index in range(len(places) - 1) math.dist(places[index], places[index + 1]) for index in range(len(places) - 1)
] ]
assert gaps == [constants.MOVE_DISTANCE] * (len(places) - 1) assert gaps == [constants.MOVE_DISTANCE] * (len(places) - 1)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"place", [(281, 0), (-281, 0), (0, 281), (0, -281), (300, 300), (0, 1000)] "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( def test_the_head_has_passed_the_wall_beyond_the_limit_on_any_side(
place: tuple[int, int], place: tuple[int, int],
) -> None: ) -> None:
snake, _ = make_snake() snake, _ = make_snake()
snake.head.goto(*place) snake.head.goto(*place)
assert snake.hits_wall() assert snake.hits_wall()
@pytest.mark.parametrize( @pytest.mark.parametrize(
"place", [(0, 0), (280, 0), (-280, 0), (0, 280), (0, -280), (280, -280)] "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( def test_the_head_has_not_passed_the_wall_at_or_inside_the_limit(
place: tuple[int, int], place: tuple[int, int],
) -> None: ) -> None:
snake, _ = make_snake() snake, _ = make_snake()
snake.head.goto(*place) snake.head.goto(*place)
assert not snake.hits_wall() assert not snake.hits_wall()
@pytest.mark.parametrize("gap", [0, 5, 9]) @pytest.mark.parametrize("gap", [0, 5, 9])
def test_the_head_touches_the_tail_closer_than_the_touch_distance(gap: int) -> None: def test_the_head_touches_the_tail_closer_than_the_touch_distance(gap: int) -> None:
snake, created = make_snake() snake, created = make_snake()
created[1].goto(gap, 0) created[1].goto(gap, 0)
assert snake.hits_tail() assert snake.hits_tail()
@pytest.mark.parametrize("gap", [10, 11, 20, 100]) @pytest.mark.parametrize("gap", [10, 11, 20, 100])
def test_the_head_does_not_touch_the_tail_at_or_beyond_the_touch_distance( def test_the_head_does_not_touch_the_tail_at_or_beyond_the_touch_distance(
gap: int, gap: int,
) -> None: ) -> None:
snake, created = make_snake() snake, created = make_snake()
created[1].goto(gap, 0) created[1].goto(gap, 0)
assert not snake.hits_tail() assert not snake.hits_tail()
def test_the_head_touches_any_segment_of_a_long_tail() -> None: def test_the_head_touches_any_segment_of_a_long_tail() -> None:
snake, created = make_snake() snake, created = make_snake()
for index in range(3, 8): for index in range(3, 8):
extra = FakeSegment() extra = FakeSegment()
extra.goto(-20 * index, 0) extra.goto(-20 * index, 0)
snake.segments.append(extra) snake.segments.append(extra)
created.append(extra) created.append(extra)
created[6].goto(3, 4) # a distance of 5 from the head, far down the tail created[6].goto(3, 4) # a distance of 5 from the head, far down the tail
assert snake.hits_tail() assert snake.hits_tail()
def test_a_new_snake_does_not_touch_its_tail() -> None: def test_a_new_snake_does_not_touch_its_tail() -> None:
snake, _ = make_snake() snake, _ = make_snake()
assert not snake.hits_tail() assert not snake.hits_tail()
def test_a_snake_with_only_a_head_has_no_tail_to_touch() -> None: def test_a_snake_with_only_a_head_has_no_tail_to_touch() -> None:
snake, created = make_snake() snake, created = make_snake()
snake.segments[:] = [created[0]] snake.segments[:] = [created[0]]
created[0].goto(0, 0) created[0].goto(0, 0)
assert not snake.hits_tail() assert not snake.hits_tail()
def test_the_new_segment_after_growing_does_not_touch_the_head() -> None: def test_the_new_segment_after_growing_does_not_touch_the_head() -> None:
snake, _ = make_snake() snake, _ = make_snake()
snake.extend() snake.extend()
snake.move() snake.move()
assert not snake.hits_tail() assert not snake.hits_tail()
def test_the_snake_hits_its_tail_when_it_turns_into_it() -> None: def test_the_snake_hits_its_tail_when_it_turns_into_it() -> None:
snake, created = make_snake() snake, created = make_snake()
for index in range(3, 5): # a snake of five segments for index in range(3, 5): # a snake of five segments
extra = FakeSegment() extra = FakeSegment()
extra.goto(-20 * index, 0) extra.goto(-20 * index, 0)
snake.segments.append(extra) snake.segments.append(extra)
created.append(extra) created.append(extra)
# Up, left and down, one move each, walk the head round a square of 20 by 20 # 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. # pixels and back onto the place where the fifth segment is.
for turn in (snake.up, snake.left, snake.down): for turn in (snake.up, snake.left, snake.down):
turn() turn()
snake.move() snake.move()
assert snake.hits_tail() assert snake.hits_tail()
def test_a_snake_of_four_segments_cannot_touch_its_tail_in_a_small_square() -> None: def test_a_snake_of_four_segments_cannot_touch_its_tail_in_a_small_square() -> None:
snake, created = make_snake() snake, created = make_snake()
extra = FakeSegment() extra = FakeSegment()
extra.goto(-60, 0) extra.goto(-60, 0)
snake.segments.append(extra) snake.segments.append(extra)
created.append(extra) created.append(extra)
for turn in (snake.up, snake.left, snake.down): for turn in (snake.up, snake.left, snake.down):
turn() turn()
snake.move() snake.move()
assert not snake.hits_tail() 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