MIL-003: Continuous play and high score #37
+246
-248
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user