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