Copied unchanged from the base: screen set-up, key bindings, the animation loop with game_is_on, the eating check, the game-over check, the wait for a click and the quiet exit when the window is closed. main imports food and scoreboard only when it runs, so importing it needs neither turtle nor tkinter. Task: MIL-002#5 Closes #12
343 lines
10 KiB
Python
343 lines
10 KiB
Python
"""Tests of the main flow, with fakes instead of a real screen."""
|
|
|
|
import pytest
|
|
|
|
from fakes import (
|
|
FakeFood,
|
|
FakeScoreboard,
|
|
FakeScreen,
|
|
FakeTclError,
|
|
FakeTerminatorError,
|
|
imports_turtle_or_tkinter,
|
|
install_fake_turtle,
|
|
make_snake,
|
|
script_randint,
|
|
)
|
|
from snake_game import constants
|
|
from snake_game.main import (
|
|
bind_keys,
|
|
configure_screen,
|
|
eat_food_if_close,
|
|
end_game_if_over,
|
|
main,
|
|
play_frame,
|
|
)
|
|
|
|
ARROW_KEYS = ("Up", "Down", "Left", "Right")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def food_far_away(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Make the food appear at the top right corner of the wall, far from the snake."""
|
|
script_randint(monkeypatch, [])
|
|
|
|
|
|
@pytest.fixture
|
|
def sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
|
|
"""Replace `time.sleep` for one test and collect the waits requested."""
|
|
waited: list[float] = []
|
|
monkeypatch.setattr("time.sleep", waited.append)
|
|
return waited
|
|
|
|
|
|
def test_configure_screen_sets_size_background_and_title_and_nothing_else() -> None:
|
|
screen = FakeScreen()
|
|
|
|
configure_screen(screen)
|
|
|
|
assert screen.calls == [
|
|
("setup", (constants.SCREEN_WIDTH, constants.SCREEN_HEIGHT)),
|
|
("bgcolor", (constants.SCREEN_BACKGROUND_COLOR,)),
|
|
("title", (constants.SCREEN_TITLE,)),
|
|
]
|
|
|
|
|
|
def test_bind_keys_listens_and_binds_the_four_arrow_keys_to_the_snake() -> None:
|
|
screen = FakeScreen()
|
|
snake, _ = make_snake()
|
|
|
|
bind_keys(screen, snake)
|
|
|
|
assert screen.call_names() == ["listen", "onkey", "onkey", "onkey", "onkey"]
|
|
assert screen.bindings == {
|
|
"Up": snake.up,
|
|
"Down": snake.down,
|
|
"Left": snake.left,
|
|
"Right": snake.right,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("key", "degrees"),
|
|
[("Up", constants.UP), ("Down", constants.DOWN), ("Right", constants.RIGHT)],
|
|
)
|
|
def test_pressing_a_bound_key_turns_the_head(key: str, degrees: int) -> None:
|
|
screen = FakeScreen()
|
|
snake, _ = make_snake()
|
|
bind_keys(screen, snake)
|
|
|
|
screen.bindings[key]()
|
|
|
|
assert snake.head.heading() == degrees
|
|
|
|
|
|
def test_play_frame_updates_the_screen_then_waits_then_moves(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
screen = FakeScreen(frames_before_close=1)
|
|
snake, created = make_snake()
|
|
seen: list[tuple[float, list[str], list[str]]] = []
|
|
|
|
def record_sleep(seconds: float) -> None:
|
|
seen.append((seconds, screen.call_names(), created[0].call_names()))
|
|
|
|
monkeypatch.setattr("time.sleep", record_sleep)
|
|
|
|
play_frame(screen, snake)
|
|
|
|
assert len(seen) == 1
|
|
seconds, screen_calls_at_sleep, head_calls_at_sleep = seen[0]
|
|
assert seconds == constants.REFRESH_DELAY_SECONDS
|
|
assert screen_calls_at_sleep == ["update"]
|
|
assert "forward" not in head_calls_at_sleep
|
|
assert "forward" in created[0].call_names()
|
|
|
|
|
|
def test_main_sets_up_the_screen_turns_off_drawing_and_binds_the_keys(
|
|
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
|
|
) -> None:
|
|
_, screens = install_fake_turtle(monkeypatch)
|
|
|
|
main()
|
|
|
|
assert len(screens) == 1
|
|
assert screens[0].call_names()[:9] == [
|
|
"setup",
|
|
"bgcolor",
|
|
"title",
|
|
"tracer",
|
|
"listen",
|
|
"onkey",
|
|
"onkey",
|
|
"onkey",
|
|
"onkey",
|
|
]
|
|
assert ("tracer", (0,)) in screens[0].calls
|
|
assert sorted(screens[0].bindings) == sorted(ARROW_KEYS)
|
|
|
|
|
|
def test_main_draws_the_snake_before_the_first_frame(
|
|
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
|
|
) -> None:
|
|
turtles, _ = install_fake_turtle(monkeypatch)
|
|
|
|
main()
|
|
|
|
snake_segments = turtles[: len(constants.STARTING_POSITIONS)]
|
|
assert len(turtles) == len(constants.STARTING_POSITIONS) + 2 # food, scoreboard
|
|
assert all(
|
|
("goto", position) in turtle.calls
|
|
for turtle, position in zip(
|
|
snake_segments, constants.STARTING_POSITIONS, strict=True
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("closing_error", [FakeTerminatorError, FakeTclError])
|
|
def test_main_ends_quietly_when_the_window_is_closed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
sleeps: list[float],
|
|
closing_error: type[Exception],
|
|
) -> None:
|
|
turtles, screens = install_fake_turtle(
|
|
monkeypatch, frames_before_close=3, closing_error=closing_error
|
|
)
|
|
|
|
main()
|
|
|
|
assert screens[0].call_names().count("update") == 3 + 1
|
|
assert turtles[0].call_names().count("forward") == 3
|
|
assert sleeps == [constants.REFRESH_DELAY_SECONDS] * 3
|
|
assert "exitonclick" not in screens[0].call_names()
|
|
|
|
|
|
def test_main_lets_other_errors_through(
|
|
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
|
|
) -> None:
|
|
install_fake_turtle(monkeypatch, closing_error=KeyError)
|
|
|
|
with pytest.raises(KeyError):
|
|
main()
|
|
|
|
|
|
def test_the_snake_eats_food_closer_than_the_eating_distance() -> None:
|
|
snake, created = make_snake()
|
|
food = FakeFood(constants.FOOD_COLLISION_DISTANCE - 1, 0)
|
|
scoreboard = FakeScoreboard()
|
|
|
|
eat_food_if_close(snake, food, scoreboard)
|
|
|
|
assert food.refreshes == 1
|
|
assert scoreboard.increases == 1
|
|
assert len(snake.segments) == len(created) == len(constants.STARTING_POSITIONS) + 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"place",
|
|
[
|
|
(constants.FOOD_COLLISION_DISTANCE, 0),
|
|
(0, -constants.FOOD_COLLISION_DISTANCE),
|
|
(2 * constants.FOOD_COLLISION_DISTANCE, 0),
|
|
(200, 200),
|
|
],
|
|
)
|
|
def test_the_snake_does_not_eat_food_at_or_beyond_the_eating_distance(
|
|
place: tuple[int, int],
|
|
) -> None:
|
|
snake, _ = make_snake()
|
|
food = FakeFood(*place)
|
|
scoreboard = FakeScoreboard()
|
|
|
|
eat_food_if_close(snake, food, scoreboard)
|
|
|
|
assert food.refreshes == 0
|
|
assert scoreboard.increases == 0
|
|
assert len(snake.segments) == len(constants.STARTING_POSITIONS)
|
|
|
|
|
|
def test_the_snake_eats_food_that_is_close_diagonally() -> None:
|
|
snake, _ = make_snake()
|
|
food = FakeFood(10, 10) # a distance of about 14.1
|
|
scoreboard = FakeScoreboard()
|
|
|
|
eat_food_if_close(snake, food, scoreboard)
|
|
|
|
assert scoreboard.increases == 1
|
|
|
|
|
|
def test_main_lets_the_snake_eat_the_food_that_lies_on_its_way(
|
|
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
|
|
) -> None:
|
|
script_randint(monkeypatch, [20, 0]) # the first food lies one move ahead
|
|
turtles, _ = install_fake_turtle(monkeypatch, frames_before_close=1)
|
|
|
|
main()
|
|
|
|
snake_head, _, _, food, scoreboard, new_segment = turtles
|
|
assert snake_head.position() == (20, 0)
|
|
assert [call for call in food.calls if call[0] == "goto"] == [
|
|
("goto", (20, 0)),
|
|
("goto", (constants.WALL_LIMIT, constants.WALL_LIMIT)),
|
|
]
|
|
assert scoreboard.calls[-1][1][0] == "Score: 1"
|
|
assert new_segment.position() == (-20, 0) # where the last segment was
|
|
|
|
|
|
def test_main_does_not_raise_the_score_when_the_food_is_far_away(
|
|
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
|
|
) -> None:
|
|
turtles, _ = install_fake_turtle(monkeypatch, frames_before_close=3)
|
|
|
|
main()
|
|
|
|
scoreboard = turtles[4]
|
|
assert scoreboard.calls[-1][1][0] == "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()
|
|
scoreboard = FakeScoreboard()
|
|
|
|
assert not end_game_if_over(snake, scoreboard)
|
|
assert scoreboard.game_overs == 0
|
|
|
|
|
|
def test_the_game_is_over_when_the_head_passes_the_wall() -> None:
|
|
snake, _ = make_snake()
|
|
snake.head.goto(constants.WALL_LIMIT + 1, 0)
|
|
scoreboard = FakeScoreboard()
|
|
|
|
assert end_game_if_over(snake, scoreboard)
|
|
assert scoreboard.game_overs == 1
|
|
|
|
|
|
def test_the_game_is_over_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
|
|
|
|
|
|
def test_main_ends_the_game_at_the_wall_shows_game_over_and_waits_for_a_click(
|
|
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
|
|
) -> None:
|
|
turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=100)
|
|
|
|
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,
|
|
)
|
|
assert scoreboard.calls[-1] == (
|
|
"write",
|
|
("GAME OVER", "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
|
|
|
|
|
|
def test_main_ends_the_game_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)
|
|
|
|
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
|
|
|
|
|
|
@pytest.mark.parametrize("closing_error", [FakeTerminatorError, FakeTclError])
|
|
def test_main_ends_quietly_when_the_window_is_closed_after_game_over(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
sleeps: list[float],
|
|
closing_error: type[Exception],
|
|
) -> None:
|
|
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,
|
|
closing_error=closing_error,
|
|
)
|
|
|
|
main()
|
|
|
|
assert turtles[4].calls[-1][1][0] == "GAME OVER"
|
|
assert "exitonclick" not in screens[0].call_names()
|
|
|
|
|
|
def test_importing_the_main_module_does_not_import_turtle_or_tkinter() -> None:
|
|
assert not imports_turtle_or_tkinter("snake_game.main")
|