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