From bfbb58ba8cd9ea6218b44e245484f57e4c7b7c82 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Tue, 13 Oct 2026 14:33:58 +0800 Subject: [PATCH] Play on after a collision in the main flow Refs #20 Task: MIL-003#5 --- src/snake_game/main.py | 494 ++++++++++++++++---------------- tests/fakes.py | 630 ++++++++++++++++++++--------------------- tests/test_main.py | 97 ++++--- 3 files changed, 613 insertions(+), 608 deletions(-) diff --git a/src/snake_game/main.py b/src/snake_game/main.py index f44b84d..b5cbf53 100644 --- a/src/snake_game/main.py +++ b/src/snake_game/main.py @@ -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 diff --git a/tests/fakes.py b/tests/fakes.py index 7de2a8f..d291f1c 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -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" diff --git a/tests/test_main.py b/tests/test_main.py index 284f2fc..60aaed6 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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()