Play on after a collision in the main flow

Refs #20
Task: MIL-003#5
This commit is contained in:
2026-10-13 14:33:58 +08:00
parent 19bc5c1431
commit bfbb58ba8c
3 changed files with 613 additions and 608 deletions
+246 -248
View File
@@ -1,248 +1,246 @@
"""! @file
@brief The main flow of the game: set up the screen, draw the snake and run it.
The flow follows the lectures "Screen Setup and Creating a Snake Body",
"Animating the Snake Segments on Screen" and "Controlling the Snake with
Keypresses", and the day-21 steps in which the snake eats food, the score rises and
the game ends.
"""
import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Protocol
from snake_game.constants import (
FOOD_COLLISION_DISTANCE,
HEAD_COLOR,
HEAD_OUTLINE,
HEAD_SHAPE,
MOUTH_COLOR,
MOUTH_OUTLINE,
REFRESH_DELAY_SECONDS,
SCREEN_BACKGROUND_COLOR,
SCREEN_HEIGHT,
SCREEN_TITLE,
SCREEN_WIDTH,
)
from snake_game.snake import Snake
if TYPE_CHECKING: # for the annotation only: nothing is imported when running
from turtle import Shape
class ScreenLike(Protocol):
"""! @brief What the helper functions need from the screen.
A `turtle.Screen` fits this description, and so does a fake in a test.
"""
def setup(self, width: float, height: float) -> None:
"""! @brief Set the size of the window.
@param width Width in pixels.
@param height Height in pixels.
"""
...
def bgcolor(self, color: str, /) -> None:
"""! @brief Set the background colour.
@param color Name of the colour, for example `black`.
"""
...
def title(self, titlestring: str, /) -> None:
"""! @brief Set the title of the window.
@param titlestring The title.
"""
...
def register_shape(self, name: str, shape: "Shape", /) -> None:
"""! @brief Make a shape known under a name, so that a turtle can use it.
@param name The name of the shape.
@param shape The shape to register.
"""
...
def update(self) -> None:
"""! @brief Draw everything that has changed since the last update."""
...
def listen(self) -> None:
"""! @brief Make the screen receive the key presses."""
...
def onkey(self, fun: Callable[[], object], key: str) -> None:
"""! @brief Call a function when a key is pressed.
@param fun The function to call.
@param key Name of the key, for example `Up`.
"""
...
class FoodLike(Protocol):
"""! @brief What `eat_food_if_close` needs from the food.
A `Food` fits this description, and so does a fake in a test.
"""
def refresh(self) -> None:
"""! @brief Move the food to a new random place."""
...
def position(self) -> tuple[float, float]:
"""! @brief Tell where the food is.
@return The x and y coordinates.
"""
...
class ScoreboardLike(Protocol):
"""! @brief What the eating and game-over checks need from the scoreboard.
A `Scoreboard` fits this description, and so does a fake in a test.
"""
def increase_score(self) -> None:
"""! @brief Add 1 to the score and write it."""
...
def game_over(self) -> None:
"""! @brief Write the game-over text."""
...
def configure_screen(screen: ScreenLike) -> None:
"""! @brief Give the screen the size, background colour and title of the game.
@param screen The screen to set up.
"""
screen.setup(width=SCREEN_WIDTH, height=SCREEN_HEIGHT)
screen.bgcolor(SCREEN_BACKGROUND_COLOR)
screen.title(SCREEN_TITLE)
def register_head_shape(screen: ScreenLike) -> None:
"""! @brief Build the shape of the head and register it as `HEAD_SHAPE`.
The shape is compound: the head outline in `HEAD_COLOR` and, on top of it, the
mouth in `MOUTH_COLOR`. Turtle shape coordinates have their front in the
positive y direction, which is what the constants use, so the mouth turns with
the head and no conversion is needed.
`turtle.Shape` is imported here and not at the top of the module, so that
importing this module needs neither a display nor `tkinter`.
@param screen The screen that learns the shape.
"""
from turtle import Shape
shape = Shape("compound")
shape.addcomponent(HEAD_OUTLINE, HEAD_COLOR)
shape.addcomponent(MOUTH_OUTLINE, MOUTH_COLOR)
screen.register_shape(HEAD_SHAPE, shape)
def bind_keys(screen: ScreenLike, snake: Snake) -> None:
"""! @brief Turn the snake with the arrow keys.
@param screen The screen that receives the key presses.
@param snake The snake to steer.
"""
screen.listen()
screen.onkey(snake.up, "Up")
screen.onkey(snake.down, "Down")
screen.onkey(snake.left, "Left")
screen.onkey(snake.right, "Right")
def play_frame(screen: ScreenLike, snake: Snake) -> None:
"""! @brief Show the snake, wait for `REFRESH_DELAY_SECONDS`, then move it.
This is one pass of the animation loop. The screen is updated by hand because
automatic drawing is off, so the whole snake appears at once.
@param screen The screen to update.
@param snake The snake to move.
"""
screen.update()
time.sleep(REFRESH_DELAY_SECONDS)
snake.move()
def eat_food_if_close(snake: Snake, food: FoodLike, scoreboard: ScoreboardLike) -> None:
"""! @brief Let the snake eat the food when the head is close enough to it.
Eating moves the food to a new place, makes the snake one segment longer and
adds 1 to the score. The head must be closer than `FOOD_COLLISION_DISTANCE`.
@param snake The snake.
@param food The food.
@param scoreboard The scoreboard that shows the score.
"""
if snake.head.distance(food.position()) < FOOD_COLLISION_DISTANCE:
food.refresh()
snake.extend()
scoreboard.increase_score()
def end_game_if_over(snake: Snake, scoreboard: ScoreboardLike) -> bool:
"""! @brief End the game when the head passes the wall or touches the tail.
When the game is over the scoreboard writes the game-over text.
@param snake The snake.
@param scoreboard The scoreboard that writes the game-over text.
@return True when the game is over.
"""
if snake.hits_wall() or snake.hits_tail():
scoreboard.game_over()
return True
return False
def main() -> None:
"""! @brief Open the game window and run the snake until the window is closed.
Closing the window during the animation loop makes `screen.update()` raise
`tkinter.TclError` ("invalid command name"), and `turtle` raises
`turtle.Terminator` in some other calls once its window is gone. Both mean
"the player closed the window", so the game ends quietly with exit code 0.
When the snake passes the wall or touches its tail the loop ends, the screen is
updated so that the game-over text shows, and `screen.exitonclick()` waits for a
click.
`turtle` and `tkinter` are imported here and not at the top of the module, and
so are `food` and `scoreboard`, whose classes inherit from `Turtle`: importing
this module needs neither a display nor `tkinter`.
"""
from tkinter import TclError
from turtle import Screen, Terminator
from snake_game.food import Food
from snake_game.scoreboard import Scoreboard
screen = Screen()
configure_screen(screen)
screen.tracer(0)
register_head_shape(screen)
snake = Snake()
food = Food()
scoreboard = Scoreboard()
bind_keys(screen, snake)
game_is_on = True
try:
while game_is_on:
play_frame(screen, snake)
eat_food_if_close(snake, food, scoreboard)
if end_game_if_over(snake, scoreboard):
game_is_on = False
screen.update()
screen.exitonclick()
except (Terminator, TclError):
return # the window was closed: there is nothing left to do
"""! @file
@brief The main flow of the game: set up the screen, draw the snake and run it.
The flow follows the lectures "Screen Setup and Creating a Snake Body",
"Animating the Snake Segments on Screen" and "Controlling the Snake with
Keypresses", the day-21 steps in which the snake eats food and the score rises,
and the day-24 steps in which a collision resets the game and it goes on.
"""
import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Protocol
from snake_game.constants import (
FOOD_COLLISION_DISTANCE,
HEAD_COLOR,
HEAD_OUTLINE,
HEAD_SHAPE,
MOUTH_COLOR,
MOUTH_OUTLINE,
REFRESH_DELAY_SECONDS,
SCREEN_BACKGROUND_COLOR,
SCREEN_HEIGHT,
SCREEN_TITLE,
SCREEN_WIDTH,
)
from snake_game.snake import Snake
if TYPE_CHECKING: # for the annotation only: nothing is imported when running
from turtle import Shape
class ScreenLike(Protocol):
"""! @brief What the helper functions need from the screen.
A `turtle.Screen` fits this description, and so does a fake in a test.
"""
def setup(self, width: float, height: float) -> None:
"""! @brief Set the size of the window.
@param width Width in pixels.
@param height Height in pixels.
"""
...
def bgcolor(self, color: str, /) -> None:
"""! @brief Set the background colour.
@param color Name of the colour, for example `black`.
"""
...
def title(self, titlestring: str, /) -> None:
"""! @brief Set the title of the window.
@param titlestring The title.
"""
...
def register_shape(self, name: str, shape: "Shape", /) -> None:
"""! @brief Make a shape known under a name, so that a turtle can use it.
@param name The name of the shape.
@param shape The shape to register.
"""
...
def update(self) -> None:
"""! @brief Draw everything that has changed since the last update."""
...
def listen(self) -> None:
"""! @brief Make the screen receive the key presses."""
...
def onkey(self, fun: Callable[[], object], key: str) -> None:
"""! @brief Call a function when a key is pressed.
@param fun The function to call.
@param key Name of the key, for example `Up`.
"""
...
class FoodLike(Protocol):
"""! @brief What `eat_food_if_close` needs from the food.
A `Food` fits this description, and so does a fake in a test.
"""
def refresh(self) -> None:
"""! @brief Move the food to a new random place."""
...
def position(self) -> tuple[float, float]:
"""! @brief Tell where the food is.
@return The x and y coordinates.
"""
...
class ScoreboardLike(Protocol):
"""! @brief What the eating and reset checks need from the scoreboard.
A `Scoreboard` fits this description, and so does a fake in a test.
"""
def increase_score(self) -> None:
"""! @brief Add 1 to the score and write it."""
...
def reset(self) -> None:
"""! @brief Keep the best score and start the score again at 0."""
...
def configure_screen(screen: ScreenLike) -> None:
"""! @brief Give the screen the size, background colour and title of the game.
@param screen The screen to set up.
"""
screen.setup(width=SCREEN_WIDTH, height=SCREEN_HEIGHT)
screen.bgcolor(SCREEN_BACKGROUND_COLOR)
screen.title(SCREEN_TITLE)
def register_head_shape(screen: ScreenLike) -> None:
"""! @brief Build the shape of the head and register it as `HEAD_SHAPE`.
The shape is compound: the head outline in `HEAD_COLOR` and, on top of it, the
mouth in `MOUTH_COLOR`. Turtle shape coordinates have their front in the
positive y direction, which is what the constants use, so the mouth turns with
the head and no conversion is needed.
`turtle.Shape` is imported here and not at the top of the module, so that
importing this module needs neither a display nor `tkinter`.
@param screen The screen that learns the shape.
"""
from turtle import Shape
shape = Shape("compound")
shape.addcomponent(HEAD_OUTLINE, HEAD_COLOR)
shape.addcomponent(MOUTH_OUTLINE, MOUTH_COLOR)
screen.register_shape(HEAD_SHAPE, shape)
def bind_keys(screen: ScreenLike, snake: Snake) -> None:
"""! @brief Turn the snake with the arrow keys.
@param screen The screen that receives the key presses.
@param snake The snake to steer.
"""
screen.listen()
screen.onkey(snake.up, "Up")
screen.onkey(snake.down, "Down")
screen.onkey(snake.left, "Left")
screen.onkey(snake.right, "Right")
def play_frame(screen: ScreenLike, snake: Snake) -> None:
"""! @brief Show the snake, wait for `REFRESH_DELAY_SECONDS`, then move it.
This is one pass of the animation loop. The screen is updated by hand because
automatic drawing is off, so the whole snake appears at once.
@param screen The screen to update.
@param snake The snake to move.
"""
screen.update()
time.sleep(REFRESH_DELAY_SECONDS)
snake.move()
def eat_food_if_close(snake: Snake, food: FoodLike, scoreboard: ScoreboardLike) -> None:
"""! @brief Let the snake eat the food when the head is close enough to it.
Eating moves the food to a new place, makes the snake one segment longer and
adds 1 to the score. The head must be closer than `FOOD_COLLISION_DISTANCE`.
@param snake The snake.
@param food The food.
@param scoreboard The scoreboard that shows the score.
"""
if snake.head.distance(food.position()) < FOOD_COLLISION_DISTANCE:
food.refresh()
snake.extend()
scoreboard.increase_score()
def reset_if_collision(snake: Snake, scoreboard: ScoreboardLike) -> bool:
"""! @brief Start again when the head passes the wall or touches the tail.
The game does not end: the scoreboard keeps the best score and starts the
score again, and the snake is made again at the starting positions.
@param snake The snake.
@param scoreboard The scoreboard that keeps the high score.
@return True when the snake and the score were reset.
"""
if snake.hits_wall() or snake.hits_tail():
scoreboard.reset()
snake.reset()
return True
return False
def main() -> None:
"""! @brief Open the game window and run the snake until the window is closed.
Closing the window during the animation loop makes `screen.update()` raise
`tkinter.TclError` ("invalid command name"), and `turtle` raises
`turtle.Terminator` in some other calls once its window is gone. Both mean
"the player closed the window", so the game ends quietly with exit code 0.
When the snake passes the wall or touches its tail the score and the snake are
reset and the game goes on, so the loop runs until the window is closed.
`turtle` and `tkinter` are imported here and not at the top of the module, and
so are `food` and `scoreboard`, whose classes inherit from `Turtle`: importing
this module needs neither a display nor `tkinter`.
"""
from tkinter import TclError
from turtle import Screen, Terminator
from snake_game.food import Food
from snake_game.scoreboard import Scoreboard
screen = Screen()
configure_screen(screen)
screen.tracer(0)
register_head_shape(screen)
snake = Snake()
food = Food()
scoreboard = Scoreboard()
bind_keys(screen, snake)
game_is_on = True
try:
while game_is_on:
play_frame(screen, snake)
eat_food_if_close(snake, food, scoreboard)
reset_if_collision(snake, scoreboard)
except (Terminator, TclError):
return # the window was closed: there is nothing left to do
+315 -315
View File
@@ -1,315 +1,315 @@
"""Fakes that stand in for turtles and the screen, so that no test opens a window."""
import importlib
import math
import os
import subprocess
import sys
import types
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Any
import pytest
from snake_game.snake import Snake
SRC = Path(__file__).resolve().parents[1] / "src"
# Modules whose classes inherit from `Turtle`: they bind their base class when they
# are imported, so each test that installs the fake `turtle` module imports them anew.
INHERITING_MODULES = ("snake_game.food", "snake_game.scoreboard")
class FakeTerminatorError(Exception):
"""Stands in for `turtle.Terminator`."""
class FakeTclError(Exception):
"""Stands in for `tkinter.TclError`."""
class FakeSegment:
"""A turtle that records what is done to it, in order, and keeps its state.
It is a segment of the snake, and also the base class that stands in for
`turtle.Turtle` when `Food` and `Scoreboard` are tested.
"""
def __init__(self) -> None:
self.calls: list[tuple[str, tuple[object, ...]]] = []
self.x = 0.0
self.y = 0.0
self.angle = 0.0
def shape(self, name: str, /) -> None:
self.calls.append(("shape", (name,)))
def shapesize(self, stretch_wid: float, stretch_len: float) -> None:
self.calls.append(("shapesize", (stretch_wid, stretch_len)))
def color(self, color: str, /) -> None:
self.calls.append(("color", (color,)))
def speed(self, speed: str) -> None:
self.calls.append(("speed", (speed,)))
def penup(self) -> None:
self.calls.append(("penup", ()))
def hideturtle(self) -> None:
self.calls.append(("hideturtle", ()))
def clear(self) -> None:
self.calls.append(("clear", ()))
def write(
self,
arg: str,
align: str = "left",
font: tuple[str, int, str] = ("Arial", 8, "normal"),
) -> None:
self.calls.append(("write", (arg, align, font)))
def goto(self, x: float | tuple[float, float], y: float | None = None, /) -> None:
if isinstance(x, tuple):
x, y = x
assert y is not None
self.calls.append(("goto", (x, y)))
self.x, self.y = x, y
def xcor(self) -> float:
return self.x
def ycor(self) -> float:
return self.y
def position(self) -> tuple[float, float]:
return (self.x, self.y)
def distance(self, x: tuple[float, float], /) -> float:
return math.hypot(self.x - x[0], self.y - x[1])
def forward(self, distance: float, /) -> None:
self.calls.append(("forward", (distance,)))
radians = math.radians(self.angle)
self.x += round(distance * math.cos(radians), 10)
self.y += round(distance * math.sin(radians), 10)
def heading(self) -> float:
return self.angle
def setheading(self, to_angle: float, /) -> None:
self.calls.append(("setheading", (to_angle,)))
self.angle = float(to_angle) % 360
def call_names(self) -> list[str]:
"""Return the names of the calls, in the order they were made."""
return [name for name, _ in self.calls]
class FakeShape:
"""Stands in for `turtle.Shape`: it records its type and its components."""
def __init__(self, type_: str, data: object = None) -> None:
self.type = type_
self.components: list[tuple[object, object, object]] = []
def addcomponent(self, poly: object, fill: object, outline: object = None) -> None:
self.components.append((poly, fill, outline))
class FakeFood:
"""A food that counts how often it was moved."""
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
self.refreshes = 0
def refresh(self) -> None:
self.refreshes += 1
def position(self) -> tuple[float, float]:
return (self.x, self.y)
class FakeScoreboard:
"""A scoreboard that counts how often the score was raised."""
def __init__(self) -> None:
self.increases = 0
self.game_overs = 0
def increase_score(self) -> None:
self.increases += 1
def game_over(self) -> None:
self.game_overs += 1
class FakeScreen:
"""A screen that records what is done to it, in order.
After `frames_before_close` updates it behaves like a closed window: the next
`update` raises `closing_error`.
"""
def __init__(
self,
frames_before_close: int = 0,
closing_error: type[Exception] = FakeTerminatorError,
) -> None:
self.calls: list[tuple[str, tuple[object, ...]]] = []
self.bindings: dict[str, Callable[[], object]] = {}
self._updates_left = frames_before_close
self._closing_error = closing_error
def setup(self, width: float, height: float) -> None:
self.calls.append(("setup", (width, height)))
def bgcolor(self, color: str, /) -> None:
self.calls.append(("bgcolor", (color,)))
def title(self, titlestring: str, /) -> None:
self.calls.append(("title", (titlestring,)))
def register_shape(self, name: str, shape: object, /) -> None:
self.calls.append(("register_shape", (name, shape)))
def tracer(self, n: int, /) -> None:
self.calls.append(("tracer", (n,)))
def listen(self) -> None:
self.calls.append(("listen", ()))
def onkey(self, fun: Callable[[], object], key: str) -> None:
self.calls.append(("onkey", (key,)))
self.bindings[key] = fun
def update(self) -> None:
self.calls.append(("update", ()))
if self._updates_left == 0:
raise self._closing_error
self._updates_left -= 1
def exitonclick(self) -> None:
self.calls.append(("exitonclick", ()))
def call_names(self) -> list[str]:
"""Return the names of the calls, in the order they were made."""
return [name for name, _ in self.calls]
def make_snake() -> tuple[Snake, list[FakeSegment]]:
"""Make a snake whose segments are fakes, and return the fakes too."""
created: list[FakeSegment] = []
def factory() -> FakeSegment:
segment = FakeSegment()
created.append(segment)
return segment
return Snake(segment_factory=factory), created
def install_fake_turtle(
monkeypatch: pytest.MonkeyPatch,
*,
frames_before_close: int = 0,
closing_error: type[Exception] = FakeTerminatorError,
) -> tuple[list[FakeSegment], list[FakeScreen]]:
"""Replace the `turtle` and `tkinter` modules with fakes for one test.
The fake screen acts like a window that the player closes after
`frames_before_close` updates, by raising `closing_error` from `update`.
Every turtle the code under test creates, including a `Food` or a `Scoreboard`
whose base class is the fake `Turtle`, is added to the first list returned;
every screen is added to the second. `food` and `scoreboard` are forgotten, so
that they are imported again against the fake and removed again after the test.
"""
segments: list[FakeSegment] = []
screens: list[FakeScreen] = []
class RegisteredSegment(FakeSegment):
def __init__(self) -> None:
super().__init__()
segments.append(self)
def make_screen() -> FakeScreen:
screen = FakeScreen(frames_before_close, closing_error)
screens.append(screen)
return screen
turtle_module = types.ModuleType("turtle")
turtle_module.__dict__["Turtle"] = RegisteredSegment
turtle_module.__dict__["Screen"] = make_screen
turtle_module.__dict__["Shape"] = FakeShape
turtle_module.__dict__["Terminator"] = FakeTerminatorError
tkinter_module = types.ModuleType("tkinter")
tkinter_module.__dict__["TclError"] = FakeTclError
monkeypatch.setitem(sys.modules, "turtle", turtle_module)
monkeypatch.setitem(sys.modules, "tkinter", tkinter_module)
for name in INHERITING_MODULES:
# Set, then delete: when the test ends monkeypatch undoes both in reverse
# order and the key is gone again, whatever the test imported meanwhile.
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
monkeypatch.delitem(sys.modules, name)
return segments, screens
def script_randint(
monkeypatch: pytest.MonkeyPatch, values: Sequence[int]
) -> list[tuple[int, int]]:
"""Make `random.randint` return the given values in turn, then the upper bound.
Returns the list that collects the bounds of every call.
"""
queue = iter(values)
bounds: list[tuple[int, int]] = []
def fake_randint(low: int, high: int) -> int:
bounds.append((low, high))
return next(queue, high)
monkeypatch.setattr("random.randint", fake_randint)
return bounds
def make_food(monkeypatch: pytest.MonkeyPatch) -> Any:
"""Make a real `Food` whose base class is the fake `Turtle`.
The result is typed `Any` because it has the methods of `Food` (`refresh`) and
the recording methods of the fake base class (`calls`, `position`) at once.
"""
install_fake_turtle(monkeypatch)
module = importlib.import_module("snake_game.food")
return module.Food()
def make_scoreboard(monkeypatch: pytest.MonkeyPatch) -> Any:
"""Make a real `Scoreboard` whose base class is the fake `Turtle`.
The result is typed `Any` because it has the methods of `Scoreboard`
(`increase_score`) and the recording methods of the fake base class (`calls`)
at once.
"""
install_fake_turtle(monkeypatch)
module = importlib.import_module("snake_game.scoreboard")
return module.Scoreboard()
def imports_turtle_or_tkinter(module_name: str) -> bool:
"""Tell whether importing a module in a fresh interpreter loads a display module."""
code = (
f"import sys, {module_name}; "
"print('turtle' in sys.modules or 'tkinter' in sys.modules)"
)
result = subprocess.run(
[sys.executable, "-c", code],
env={**os.environ, "PYTHONPATH": str(SRC)},
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
return result.stdout.strip() == "True"
"""Fakes that stand in for turtles and the screen, so that no test opens a window."""
import importlib
import math
import os
import subprocess
import sys
import types
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Any
import pytest
from snake_game.snake import Snake
SRC = Path(__file__).resolve().parents[1] / "src"
# Modules whose classes inherit from `Turtle`: they bind their base class when they
# are imported, so each test that installs the fake `turtle` module imports them anew.
INHERITING_MODULES = ("snake_game.food", "snake_game.scoreboard")
class FakeTerminatorError(Exception):
"""Stands in for `turtle.Terminator`."""
class FakeTclError(Exception):
"""Stands in for `tkinter.TclError`."""
class FakeSegment:
"""A turtle that records what is done to it, in order, and keeps its state.
It is a segment of the snake, and also the base class that stands in for
`turtle.Turtle` when `Food` and `Scoreboard` are tested.
"""
def __init__(self) -> None:
self.calls: list[tuple[str, tuple[object, ...]]] = []
self.x = 0.0
self.y = 0.0
self.angle = 0.0
def shape(self, name: str, /) -> None:
self.calls.append(("shape", (name,)))
def shapesize(self, stretch_wid: float, stretch_len: float) -> None:
self.calls.append(("shapesize", (stretch_wid, stretch_len)))
def color(self, color: str, /) -> None:
self.calls.append(("color", (color,)))
def speed(self, speed: str) -> None:
self.calls.append(("speed", (speed,)))
def penup(self) -> None:
self.calls.append(("penup", ()))
def hideturtle(self) -> None:
self.calls.append(("hideturtle", ()))
def clear(self) -> None:
self.calls.append(("clear", ()))
def write(
self,
arg: str,
align: str = "left",
font: tuple[str, int, str] = ("Arial", 8, "normal"),
) -> None:
self.calls.append(("write", (arg, align, font)))
def goto(self, x: float | tuple[float, float], y: float | None = None, /) -> None:
if isinstance(x, tuple):
x, y = x
assert y is not None
self.calls.append(("goto", (x, y)))
self.x, self.y = x, y
def xcor(self) -> float:
return self.x
def ycor(self) -> float:
return self.y
def position(self) -> tuple[float, float]:
return (self.x, self.y)
def distance(self, x: tuple[float, float], /) -> float:
return math.hypot(self.x - x[0], self.y - x[1])
def forward(self, distance: float, /) -> None:
self.calls.append(("forward", (distance,)))
radians = math.radians(self.angle)
self.x += round(distance * math.cos(radians), 10)
self.y += round(distance * math.sin(radians), 10)
def heading(self) -> float:
return self.angle
def setheading(self, to_angle: float, /) -> None:
self.calls.append(("setheading", (to_angle,)))
self.angle = float(to_angle) % 360
def call_names(self) -> list[str]:
"""Return the names of the calls, in the order they were made."""
return [name for name, _ in self.calls]
class FakeShape:
"""Stands in for `turtle.Shape`: it records its type and its components."""
def __init__(self, type_: str, data: object = None) -> None:
self.type = type_
self.components: list[tuple[object, object, object]] = []
def addcomponent(self, poly: object, fill: object, outline: object = None) -> None:
self.components.append((poly, fill, outline))
class FakeFood:
"""A food that counts how often it was moved."""
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
self.refreshes = 0
def refresh(self) -> None:
self.refreshes += 1
def position(self) -> tuple[float, float]:
return (self.x, self.y)
class FakeScoreboard:
"""A scoreboard that counts how often the score was raised."""
def __init__(self) -> None:
self.increases = 0
self.resets = 0
def increase_score(self) -> None:
self.increases += 1
def reset(self) -> None:
self.resets += 1
class FakeScreen:
"""A screen that records what is done to it, in order.
After `frames_before_close` updates it behaves like a closed window: the next
`update` raises `closing_error`.
"""
def __init__(
self,
frames_before_close: int = 0,
closing_error: type[Exception] = FakeTerminatorError,
) -> None:
self.calls: list[tuple[str, tuple[object, ...]]] = []
self.bindings: dict[str, Callable[[], object]] = {}
self._updates_left = frames_before_close
self._closing_error = closing_error
def setup(self, width: float, height: float) -> None:
self.calls.append(("setup", (width, height)))
def bgcolor(self, color: str, /) -> None:
self.calls.append(("bgcolor", (color,)))
def title(self, titlestring: str, /) -> None:
self.calls.append(("title", (titlestring,)))
def register_shape(self, name: str, shape: object, /) -> None:
self.calls.append(("register_shape", (name, shape)))
def tracer(self, n: int, /) -> None:
self.calls.append(("tracer", (n,)))
def listen(self) -> None:
self.calls.append(("listen", ()))
def onkey(self, fun: Callable[[], object], key: str) -> None:
self.calls.append(("onkey", (key,)))
self.bindings[key] = fun
def update(self) -> None:
self.calls.append(("update", ()))
if self._updates_left == 0:
raise self._closing_error
self._updates_left -= 1
def exitonclick(self) -> None:
self.calls.append(("exitonclick", ()))
def call_names(self) -> list[str]:
"""Return the names of the calls, in the order they were made."""
return [name for name, _ in self.calls]
def make_snake() -> tuple[Snake, list[FakeSegment]]:
"""Make a snake whose segments are fakes, and return the fakes too."""
created: list[FakeSegment] = []
def factory() -> FakeSegment:
segment = FakeSegment()
created.append(segment)
return segment
return Snake(segment_factory=factory), created
def install_fake_turtle(
monkeypatch: pytest.MonkeyPatch,
*,
frames_before_close: int = 0,
closing_error: type[Exception] = FakeTerminatorError,
) -> tuple[list[FakeSegment], list[FakeScreen]]:
"""Replace the `turtle` and `tkinter` modules with fakes for one test.
The fake screen acts like a window that the player closes after
`frames_before_close` updates, by raising `closing_error` from `update`.
Every turtle the code under test creates, including a `Food` or a `Scoreboard`
whose base class is the fake `Turtle`, is added to the first list returned;
every screen is added to the second. `food` and `scoreboard` are forgotten, so
that they are imported again against the fake and removed again after the test.
"""
segments: list[FakeSegment] = []
screens: list[FakeScreen] = []
class RegisteredSegment(FakeSegment):
def __init__(self) -> None:
super().__init__()
segments.append(self)
def make_screen() -> FakeScreen:
screen = FakeScreen(frames_before_close, closing_error)
screens.append(screen)
return screen
turtle_module = types.ModuleType("turtle")
turtle_module.__dict__["Turtle"] = RegisteredSegment
turtle_module.__dict__["Screen"] = make_screen
turtle_module.__dict__["Shape"] = FakeShape
turtle_module.__dict__["Terminator"] = FakeTerminatorError
tkinter_module = types.ModuleType("tkinter")
tkinter_module.__dict__["TclError"] = FakeTclError
monkeypatch.setitem(sys.modules, "turtle", turtle_module)
monkeypatch.setitem(sys.modules, "tkinter", tkinter_module)
for name in INHERITING_MODULES:
# Set, then delete: when the test ends monkeypatch undoes both in reverse
# order and the key is gone again, whatever the test imported meanwhile.
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
monkeypatch.delitem(sys.modules, name)
return segments, screens
def script_randint(
monkeypatch: pytest.MonkeyPatch, values: Sequence[int]
) -> list[tuple[int, int]]:
"""Make `random.randint` return the given values in turn, then the upper bound.
Returns the list that collects the bounds of every call.
"""
queue = iter(values)
bounds: list[tuple[int, int]] = []
def fake_randint(low: int, high: int) -> int:
bounds.append((low, high))
return next(queue, high)
monkeypatch.setattr("random.randint", fake_randint)
return bounds
def make_food(monkeypatch: pytest.MonkeyPatch) -> Any:
"""Make a real `Food` whose base class is the fake `Turtle`.
The result is typed `Any` because it has the methods of `Food` (`refresh`) and
the recording methods of the fake base class (`calls`, `position`) at once.
"""
install_fake_turtle(monkeypatch)
module = importlib.import_module("snake_game.food")
return module.Food()
def make_scoreboard(monkeypatch: pytest.MonkeyPatch) -> Any:
"""Make a real `Scoreboard` whose base class is the fake `Turtle`.
The result is typed `Any` because it has the methods of `Scoreboard`
(`increase_score`) and the recording methods of the fake base class (`calls`)
at once.
"""
install_fake_turtle(monkeypatch)
module = importlib.import_module("snake_game.scoreboard")
return module.Scoreboard()
def imports_turtle_or_tkinter(module_name: str) -> bool:
"""Tell whether importing a module in a fresh interpreter loads a display module."""
code = (
f"import sys, {module_name}; "
"print('turtle' in sys.modules or 'tkinter' in sys.modules)"
)
result = subprocess.run(
[sys.executable, "-c", code],
env={**os.environ, "PYTHONPATH": str(SRC)},
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
return result.stdout.strip() == "True"
+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()