From 6b0ee0cc0da970b7b75d8e7b6c4c5c6726d669ed Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 8 Oct 2026 18:31:06 +0800 Subject: [PATCH] Adopt the main flow and the entry point 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 --- src/snake_game/__main__.py | 8 + src/snake_game/main.py | 210 +++++++++++++++++++++++ tests/test_main.py | 342 +++++++++++++++++++++++++++++++++++++ 3 files changed, 560 insertions(+) create mode 100644 src/snake_game/__main__.py create mode 100644 src/snake_game/main.py create mode 100644 tests/test_main.py diff --git a/src/snake_game/__main__.py b/src/snake_game/__main__.py new file mode 100644 index 0000000..b845b7d --- /dev/null +++ b/src/snake_game/__main__.py @@ -0,0 +1,8 @@ +"""! @file +@brief Allows `python -m snake_game`. +""" + +from snake_game.main import main + +if __name__ == "__main__": + main() diff --git a/src/snake_game/main.py b/src/snake_game/main.py new file mode 100644 index 0000000..cc27772 --- /dev/null +++ b/src/snake_game/main.py @@ -0,0 +1,210 @@ +"""! @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 Protocol + +from snake_game.constants import ( + FOOD_COLLISION_DISTANCE, + REFRESH_DELAY_SECONDS, + SCREEN_BACKGROUND_COLOR, + SCREEN_HEIGHT, + SCREEN_TITLE, + SCREEN_WIDTH, +) +from snake_game.snake import Snake + + +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 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 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) + 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 diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..306323b --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,342 @@ +"""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")