Merge pull request 'MIL-003: Continuous play and high score' (#37) from mil-003-continuous-play-and-high-score into main
CI / checks (push) Successful in 50s
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s

This commit was merged in pull request #37.
This commit is contained in:
2026-10-13 08:35:53 +02:00
12 changed files with 1800 additions and 1586 deletions
+16 -10
View File
@@ -19,11 +19,12 @@ This repository continues
and starts from its finished game; `docs/project-plan.md` tells how the work is
split into milestones.
> **Status:** in progress. The finished game of day 21 is adopted and runs: the
> snake moves, eats and grows, the score is shown and the game ends at the wall
> or at the tail. Continuous play and the high score (milestone 3), the high
> score file (milestone 4) and the wiki (milestone 5) follow. The sections below
> describe the game as it is now; they are updated in each milestone.
> **Status:** in progress. The game of day 21 is adopted and now follows the
> day-24 lectures on continuous play: the scoreboard shows the score and the high
> score, and a collision resets the snake and the score instead of ending the
> game. The high score is kept in memory only; the high score file (milestone 4)
> and the wiki (milestone 5) follow. The sections below describe the game as it
> is now; they are updated in each milestone.
## Requirements
@@ -104,11 +105,16 @@ closer to it than 15 pixels the snake eats it: the food moves to a new random
place, the snake grows by one segment, and the score at the top of the window
goes up by 1.
The game is over when the head passes the wall (more than 280 pixels from the
centre on any side) or touches the tail. The snake stops, `GAME OVER` appears in
the middle of the window, and a click on the window closes it. You can also close
the window with its close button at any time. (Milestone 3 replaces this with
continuous play and a high score.)
The scoreboard at the top of the window reads `Score: 0 High Score: 0`. The score
counts the foods eaten since the last reset; the high score is the best score so
far.
The game does not end. When the head passes the wall (more than 280 pixels from
the centre on any side) or touches the tail, the old snake is sent off the
screen, a new snake of three segments appears in the middle, the score goes back
to 0, and the high score is raised if the score was higher. Close the window with
its close button when you have had enough. The high score is lost when the game
is closed until milestone 4 stores it in a file.
## Run the tests
+1 -1
View File
@@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 006 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 011 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 012 |
## Languages
+60
View File
@@ -0,0 +1,60 @@
# RC-011: Code review of MIL-003 (continuous play and high score)
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-011 |
| CrossReference | [MIL-003], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-13 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [8c81988] |
---
## Artifact Under Review
- Instance reviewed: the source and tests that [MIL-003] delivers (continuous play and high score)
- Checklist used: [QC-PY-001]
- Scope: full review of the files changed on the branch of the milestone
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`; the assistant also wrote the code)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | `Snake.reset`, `Scoreboard.reset`, `reset_if_collision`, `high_score` and `SEGMENT_PARKING_POSITION` follow PEP 8; `reset` and `high_score` are the lecture's names. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose; `HIGH_SCORE_LABEL` and `SEGMENT_PARKING_POSITION` say what they hold. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` exit 0; no `noqa`. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy` strict reports no issues in 13 files; every new def is annotated with `-> None` or `-> bool`. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No exception handling was added; the one handler in `main.main` is unchanged. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default argument; no shadowed builtin. |
| 7 | Files, locks and connections are managed with context managers | N-A | No file is opened in this milestone (MIL-004). |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | `reset`, `reset_if_collision` and the new constants have Doxygen comments; `doxygen Doxyfile` ends with 0 warnings. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print` and no logging. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No Design Class Diagram exists (deviation recorded in RC-010 and the pull request); the change adds `reset` to the base classes as the lecture does. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 149 tests run without a display; new tests cover higher, equal and lower score on `reset`, the parking of old segments, the new head, the forgotten direction, and `main` going on after the wall and the tail. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `strict = true`; `ScoreboardLike` stays a narrow protocol (`increase_score`, `reset`); no `Any`. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies = []` is unchanged. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The label, the parking place and the other values are defined once in `constants.py`; `GAME_OVER_*` is removed, not duplicated. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `snake.py` and `main.py` still import no `turtle` or `tkinter` at module level (tested); no import cycle. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | `Snake.reset` reuses `create_snake`, so the starting positions are one piece of knowledge; `main` depends on the `ScoreboardLike` protocol. |
## Overall Verdict
Go — all Mandatory criteria pass or are not applicable. Assessed on 2026-10-13 by the assistant that wrote the code, as stand-in for S01 in autonomous mode. Mode: autonomous, not independent. The pull request is the second look.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| Challenge this `Go` in the run summary or on the pull request if the stand-in decision is not wanted | S01 | 2026-10-20 |
---
[MIL-003]: ../../milestones/mil-003-continuous-play-and-high-score.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[8c81988]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/024-snake/commit/8c8198827bfc69c75a4f8ff8a393376d073e94c1
+10 -4
View File
@@ -117,8 +117,14 @@ SCOREBOARD_FONT: Final[tuple[str, int, str]] = ("Arial", 24, "normal")
## @brief The head touches the tail when it is closer to a segment than this, in pixels.
TAIL_COLLISION_DISTANCE: Final[int] = 10
## @brief Text shown when the game is over.
GAME_OVER_TEXT: Final[str] = "GAME OVER"
## @brief Text in front of the high score on the scoreboard.
#
# The scoreboard writes `Score: <score> High Score: <high_score>`: the score
# label, the score, a space, this label and the high score.
HIGH_SCORE_LABEL: Final[str] = "High Score: "
## @brief Where the game-over text is written: the centre of the screen.
GAME_OVER_POSITION: Final[tuple[int, int]] = (0, 0)
## @brief Where a segment of the old snake is sent when the snake is reset.
#
# The place is far outside the 600 by 600 screen, so the old segments are out
# of sight, as the day-24 lecture does it.
SEGMENT_PARKING_POSITION: Final[tuple[int, int]] = (1000, 1000)
+16 -18
View File
@@ -3,8 +3,8 @@
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.
Keypresses", the day-21 steps in which the snake eats food and the score rises,
and the day-24 steps in which a collision resets the game and it goes on.
"""
import time
@@ -102,7 +102,7 @@ class FoodLike(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.
"""
@@ -111,8 +111,8 @@ class ScoreboardLike(Protocol):
"""! @brief Add 1 to the score and write it."""
...
def game_over(self) -> None:
"""! @brief Write the game-over text."""
def reset(self) -> None:
"""! @brief Keep the best score and start the score again at 0."""
...
@@ -190,17 +190,19 @@ def eat_food_if_close(snake: Snake, food: FoodLike, scoreboard: ScoreboardLike)
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.
def reset_if_collision(snake: Snake, scoreboard: ScoreboardLike) -> bool:
"""! @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.
@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()
scoreboard.reset()
snake.reset()
return True
return False
@@ -212,9 +214,8 @@ def main() -> None:
`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.
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
@@ -240,9 +241,6 @@ def main() -> None:
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()
reset_if_collision(snake, scoreboard)
except (Terminator, TclError):
return # the window was closed: there is nothing left to do
+14 -11
View File
@@ -10,8 +10,7 @@ module first.
from turtle import Turtle
from snake_game.constants import (
GAME_OVER_POSITION,
GAME_OVER_TEXT,
HIGH_SCORE_LABEL,
SCORE_LABEL,
SCOREBOARD_ALIGNMENT,
SCOREBOARD_COLOR,
@@ -26,8 +25,10 @@ class Scoreboard(Turtle):
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.
## @brief The number of foods the snake has eaten since the last reset.
self.score = 0
## @brief The largest score reached so far.
self.high_score = 0
self.color(SCOREBOARD_COLOR)
self.penup()
self.hideturtle()
@@ -35,10 +36,10 @@ class Scoreboard(Turtle):
self.update_scoreboard()
def update_scoreboard(self) -> None:
"""! @brief Wipe the old text and write the label and the score."""
"""! @brief Wipe the old text and write the score and the high score."""
self.clear()
self.write(
f"{SCORE_LABEL}{self.score}",
f"{SCORE_LABEL}{self.score} {HIGH_SCORE_LABEL}{self.high_score}",
align=SCOREBOARD_ALIGNMENT,
font=SCOREBOARD_FONT,
)
@@ -48,11 +49,13 @@ class Scoreboard(Turtle):
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()
+18 -2
View File
@@ -3,9 +3,10 @@
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'
the day-21 steps in which the snake grows, and the day-24 step in which it is reset
after a collision. It keeps the lectures'
names: `Snake`, `segments`, `create_snake`, `add_segment`, `extend`, `move`, `head`,
`up`, `down`, `left` and `right`. The only differences are that the snake gets
`reset`, `up`, `down`, `left` and `right`. The only differences are that the snake gets
the function that makes a segment as a parameter, so that a test can pass a fake
and needs no window, and how a reversal is refused (see Snake).
"""
@@ -20,6 +21,7 @@ from snake_game.constants import (
LEFT,
MOVE_DISTANCE,
RIGHT,
SEGMENT_PARKING_POSITION,
SEGMENT_SHAPE,
STARTING_POSITIONS,
TAIL_COLLISION_DISTANCE,
@@ -204,6 +206,20 @@ class Snake:
self.head.forward(MOVE_DISTANCE)
self._direction_of_travel = self.head.heading()
def reset(self) -> None:
"""! @brief Start again with a new snake of three segments.
Every old segment is sent far outside the screen, so it can no longer be
seen, the list is emptied, and the snake is created again at the starting
positions. The new head is the first new segment, and it points right.
"""
for segment in self.segments:
segment.goto(*SEGMENT_PARKING_POSITION)
self.segments.clear()
self.create_snake()
self.head = self.segments[0]
self._direction_of_travel = self.head.heading()
def hits_wall(self) -> bool:
"""! @brief Tell whether the head has passed the wall on any side.
+3 -3
View File
@@ -138,13 +138,13 @@ class FakeScoreboard:
def __init__(self) -> None:
self.increases = 0
self.game_overs = 0
self.resets = 0
def increase_score(self) -> None:
self.increases += 1
def game_over(self) -> None:
self.game_overs += 1
def reset(self) -> None:
self.resets += 1
class FakeScreen:
+14 -6
View File
@@ -154,10 +154,18 @@ def test_tail_is_touched_closer_than_10_pixels_which_is_less_than_a_move() -> No
assert 0 < constants.TAIL_COLLISION_DISTANCE < constants.MOVE_DISTANCE
def test_game_over_text_is_written_at_the_centre_inside_the_wall() -> None:
x, y = constants.GAME_OVER_POSITION
def test_game_over_constants_no_longer_exist() -> None:
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
assert abs(y) < constants.WALL_LIMIT
def test_high_score_label_follows_the_score_label_as_in_the_lecture() -> None:
assert constants.HIGH_SCORE_LABEL == "High Score: "
assert constants.HIGH_SCORE_LABEL != constants.SCORE_LABEL
def test_old_segments_are_parked_outside_the_screen() -> None:
x, y = constants.SEGMENT_PARKING_POSITION
assert abs(x) > HALF_WIDTH
assert abs(y) > HALF_HEIGHT
+52 -45
View File
@@ -20,10 +20,10 @@ from snake_game.main import (
bind_keys,
configure_screen,
eat_food_if_close,
end_game_if_over,
main,
play_frame,
register_head_shape,
reset_if_collision,
)
from snake_game.snake import Snake
@@ -276,7 +276,7 @@ def test_main_lets_the_snake_eat_the_food_that_lies_on_its_way(
("goto", (20, 0)),
("goto", (constants.WALL_LIMIT, constants.WALL_LIMIT)),
]
assert scoreboard.calls[-1][1][0] == "Score: 1"
assert scoreboard.calls[-1][1][0] == "Score: 1 High Score: 0"
assert new_segment.position() == (-20, 0) # where the last segment was
@@ -288,86 +288,93 @@ def test_main_does_not_raise_the_score_when_the_food_is_far_away(
main()
scoreboard = turtles[4]
assert scoreboard.calls[-1][1][0] == "Score: 0"
assert scoreboard.calls[-1][1][0] == "Score: 0 High Score: 0"
assert len(turtles) == len(constants.STARTING_POSITIONS) + 2
def test_the_game_is_not_over_while_the_snake_is_inside_and_clear_of_its_tail() -> None:
snake, _ = make_snake()
def test_nothing_is_reset_while_the_snake_is_inside_and_clear_of_its_tail() -> None:
snake, created = make_snake()
scoreboard = FakeScoreboard()
assert not end_game_if_over(snake, scoreboard)
assert scoreboard.game_overs == 0
assert not reset_if_collision(snake, scoreboard)
assert scoreboard.resets == 0
assert len(created) == len(constants.STARTING_POSITIONS)
def test_the_game_is_over_when_the_head_passes_the_wall() -> None:
snake, _ = make_snake()
def test_the_game_is_reset_when_the_head_passes_the_wall() -> None:
snake, created = make_snake()
snake.head.goto(constants.WALL_LIMIT + 1, 0)
scoreboard = FakeScoreboard()
assert end_game_if_over(snake, scoreboard)
assert scoreboard.game_overs == 1
assert reset_if_collision(snake, scoreboard)
assert scoreboard.resets == 1
assert len(created) == 2 * len(constants.STARTING_POSITIONS)
def test_the_game_is_over_when_the_head_touches_the_tail() -> None:
def test_the_game_is_reset_when_the_head_touches_the_tail() -> None:
snake, created = make_snake()
created[1].goto(3, 4)
scoreboard = FakeScoreboard()
assert end_game_if_over(snake, scoreboard)
assert scoreboard.game_overs == 1
assert reset_if_collision(snake, scoreboard)
assert scoreboard.resets == 1
assert snake.head is not created[0]
def test_main_ends_the_game_at_the_wall_shows_game_over_and_waits_for_a_click(
def test_main_goes_on_after_the_wall_with_a_new_snake_and_no_game_over(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=100)
moves_to_pass_the_wall = constants.WALL_LIMIT // constants.MOVE_DISTANCE + 1
turtles, screens = install_fake_turtle(
monkeypatch, frames_before_close=moves_to_pass_the_wall + 2
)
main()
snake_head, scoreboard = turtles[0], turtles[4]
moves_to_pass_the_wall = constants.WALL_LIMIT // constants.MOVE_DISTANCE + 1
assert snake_head.call_names().count("forward") == moves_to_pass_the_wall
assert snake_head.position() == (
constants.MOVE_DISTANCE * moves_to_pass_the_wall,
0,
)
old_head, scoreboard = turtles[0], turtles[4]
new_head = turtles[5]
written = [call[1][0] for call in scoreboard.calls if call[0] == "write"]
assert old_head.position() == constants.SEGMENT_PARKING_POSITION
assert new_head.call_names().count("forward") == 2
assert new_head.position() == (2 * constants.MOVE_DISTANCE, 0)
assert scoreboard.calls[-1] == (
"write",
("GAME OVER", "center", ("Arial", 24, "normal")),
("Score: 0 High Score: 0", "center", ("Arial", 24, "normal")),
)
assert screens[0].call_names()[-2:] == ["update", "exitonclick"]
assert screens[0].call_names().count("exitonclick") == 1
assert sleeps == [constants.REFRESH_DELAY_SECONDS] * moves_to_pass_the_wall
assert "GAME OVER" not in written
assert "exitonclick" not in screens[0].call_names()
def test_main_ends_the_game_when_the_snake_touches_its_tail(
def test_main_shows_the_best_score_after_a_reset(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
script_randint(monkeypatch, [20, 0]) # the first food lies one move ahead
moves_to_pass_the_wall = constants.WALL_LIMIT // constants.MOVE_DISTANCE + 1
turtles, _ = install_fake_turtle(
monkeypatch, frames_before_close=moves_to_pass_the_wall
)
main()
scoreboard = turtles[4]
assert scoreboard.calls[-1][1][0] == "Score: 0 High Score: 1"
def test_main_resets_when_the_snake_touches_its_tail(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
monkeypatch.setattr("snake_game.snake.Snake.hits_tail", lambda self: True)
turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=100)
turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=2)
main()
assert turtles[0].call_names().count("forward") == 1
assert turtles[4].calls[-1][1][0] == "GAME OVER"
assert screens[0].call_names()[-1] == "exitonclick"
def test_main_does_not_move_the_snake_after_game_over(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=100)
main()
updates = screens[0].call_names().count("update")
moves = turtles[0].call_names().count("forward")
assert updates == moves + 1 # one update per frame, and one for the last text
assert turtles[0].position() == constants.SEGMENT_PARKING_POSITION
assert "exitonclick" not in screens[0].call_names()
@pytest.mark.parametrize("closing_error", [FakeTerminatorError, FakeTclError])
def test_main_ends_quietly_when_the_window_is_closed_after_game_over(
def test_main_ends_quietly_when_the_window_is_closed_after_a_reset(
monkeypatch: pytest.MonkeyPatch,
sleeps: list[float],
closing_error: type[Exception],
@@ -381,7 +388,7 @@ def test_main_ends_quietly_when_the_window_is_closed_after_game_over(
main()
assert turtles[4].calls[-1][1][0] == "GAME OVER"
assert turtles[4].calls[-1][1][0] == "Score: 0 High Score: 0"
assert "exitonclick" not in screens[0].call_names()
+66 -14
View File
@@ -8,7 +8,10 @@ import pytest
from fakes import install_fake_turtle, make_scoreboard
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(
@@ -29,7 +32,7 @@ def test_scoreboard_inherits_from_turtle(monkeypatch: pytest.MonkeyPatch) -> Non
assert issubclass(module.Scoreboard, sys.modules["turtle"].Turtle)
def test_scoreboard_starts_by_writing_score_0_centred_in_arial_24(
def test_scoreboard_starts_by_writing_both_scores_0_centred_in_arial_24(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scoreboard = make_scoreboard(monkeypatch)
@@ -47,7 +50,7 @@ def test_increase_score_wipes_the_old_text_and_writes_the_new_score(
assert scoreboard.call_names()[-2:] == ["clear", "write"]
assert scoreboard.calls[-1] == (
"write",
("Score: 1", "center", ("Arial", 24, "normal")),
("Score: 1 High Score: 0", "center", ("Arial", 24, "normal")),
)
@@ -57,29 +60,78 @@ def test_every_food_adds_one_to_the_score(monkeypatch: pytest.MonkeyPatch) -> No
for _ in range(3):
scoreboard.increase_score()
assert scoreboard.calls[-1][1][0] == "Score: 3"
assert scoreboard.calls[-1][1][0] == "Score: 3 High Score: 0"
def test_game_over_writes_the_text_at_the_centre_in_the_scoreboard_font(
def test_high_score_starts_at_0(monkeypatch: pytest.MonkeyPatch) -> None:
scoreboard = make_scoreboard(monkeypatch)
assert scoreboard.high_score == 0
assert scoreboard.score == 0
def test_reset_keeps_a_higher_score_as_the_high_score_and_starts_again(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scoreboard = make_scoreboard(monkeypatch)
for _ in range(3):
scoreboard.increase_score()
scoreboard.game_over()
scoreboard.reset()
assert scoreboard.calls[-2:] == [
("goto", constants.GAME_OVER_POSITION),
("write", ("GAME OVER", "center", ("Arial", 24, "normal"))),
]
assert scoreboard.high_score == 3
assert scoreboard.score == 0
assert scoreboard.call_names()[-2:] == ["clear", "write"]
assert scoreboard.calls[-1] == (
"write",
("Score: 0 High Score: 3", "center", ("Arial", 24, "normal")),
)
def test_game_over_leaves_the_score_on_the_screen(
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()
calls_before = len(scoreboard.calls)
scoreboard.reset()
scoreboard.increase_score()
scoreboard.game_over()
scoreboard.reset()
assert "clear" not in scoreboard.call_names()[calls_before:]
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")
+58
View File
@@ -407,3 +407,61 @@ def test_a_snake_of_four_segments_cannot_touch_its_tail_in_a_small_square() -> N
snake.move()
assert not snake.hits_tail()
def test_reset_sends_every_old_segment_off_the_screen() -> None:
snake, created = make_snake()
snake.extend()
old = list(created)
snake.reset()
assert len(old) == len(constants.STARTING_POSITIONS) + 1
assert all(
segment.position() == constants.SEGMENT_PARKING_POSITION for segment in old
)
def test_reset_makes_the_snake_again_at_the_starting_positions() -> None:
snake, created = make_snake()
snake.extend()
snake.extend()
old_count = len(created)
snake.reset()
assert len(snake.segments) == len(constants.STARTING_POSITIONS)
assert positions(snake.segments) == [
(float(x), float(y)) for x, y in constants.STARTING_POSITIONS
]
assert snake.segments == created[old_count:]
def test_reset_makes_the_first_new_segment_the_head() -> None:
snake, created = make_snake()
old_head = snake.head
snake.reset()
new_head = created[len(constants.STARTING_POSITIONS)]
assert snake.head is snake.segments[0] is new_head
assert snake.head is not old_head
assert ("shape", (constants.HEAD_SHAPE,)) in new_head.calls
def test_reset_snake_moves_right_from_the_starting_positions() -> None:
snake, _ = make_snake_moving(constants.UP)
snake.reset()
snake.move()
assert snake.head.position() == (constants.MOVE_DISTANCE, 0)
def test_reset_forgets_the_direction_of_the_last_move() -> None:
snake, _ = make_snake_moving(constants.DOWN)
snake.reset()
snake.up() # allowed again: the new snake moves right, not down
assert snake.head.heading() == constants.UP