From 63090fce3d9ff99843fb193ee9038e914f5fb8ea Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Tue, 13 Oct 2026 14:33:52 +0800 Subject: [PATCH 1/7] Add the high score constants Refs #16 Task: MIL-003#1 --- src/snake_game/constants.py | 14 +- tests/test_constants.py | 334 ++++++++++++++++++------------------ 2 files changed, 181 insertions(+), 167 deletions(-) diff --git a/src/snake_game/constants.py b/src/snake_game/constants.py index 79ab1f2..270d76a 100644 --- a/src/snake_game/constants.py +++ b/src/snake_game/constants.py @@ -117,8 +117,14 @@ SCOREBOARD_FONT: Final[tuple[str, int, str]] = ("Arial", 24, "normal") ## @brief The head touches the tail when it is closer to a segment than this, in pixels. TAIL_COLLISION_DISTANCE: Final[int] = 10 -## @brief Text shown when the game is over. -GAME_OVER_TEXT: Final[str] = "GAME OVER" +## @brief Text in front of the high score on the scoreboard. +# +# The scoreboard writes `Score: High Score: `: the score +# label, the score, a space, this label and the high score. +HIGH_SCORE_LABEL: Final[str] = "High Score: " -## @brief Where the game-over text is written: the centre of the screen. -GAME_OVER_POSITION: Final[tuple[int, int]] = (0, 0) +## @brief Where a segment of the old snake is sent when the snake is reset. +# +# The place is far outside the 600 by 600 screen, so the old segments are out +# of sight, as the day-24 lecture does it. +SEGMENT_PARKING_POSITION: Final[tuple[int, int]] = (1000, 1000) diff --git a/tests/test_constants.py b/tests/test_constants.py index b2d4315..d785adc 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -1,163 +1,171 @@ -"""Tests of the constants that the day-20, day-21 and day-24 lectures fix.""" - -from snake_game import constants - -HALF_WIDTH = constants.SCREEN_WIDTH / 2 -HALF_HEIGHT = constants.SCREEN_HEIGHT / 2 - - -def test_screen_size_matches_lecture() -> None: - assert (constants.SCREEN_WIDTH, constants.SCREEN_HEIGHT) == (600, 600) - - -def test_screen_is_black_and_titled_as_in_the_lecture() -> None: - assert constants.SCREEN_BACKGROUND_COLOR == "black" - assert constants.SCREEN_TITLE == "My Snake Game" - - -def test_body_segments_are_light_green_squares() -> None: - assert constants.SEGMENT_SHAPE == "square" - assert constants.BODY_COLOR == "light green" - - -def test_segment_color_no_longer_exists() -> None: - assert not hasattr(constants, "SEGMENT_COLOR") - - -def test_head_is_dark_green_and_differs_from_the_body() -> None: - assert constants.HEAD_COLOR == "dark green" - assert constants.HEAD_COLOR != constants.BODY_COLOR - - -def test_head_shape_has_a_name_that_is_not_a_built_in_shape() -> None: - assert constants.HEAD_SHAPE == "snake_head" - assert constants.HEAD_SHAPE != constants.SEGMENT_SHAPE - - -def test_head_is_the_20_by_20_square() -> None: - assert constants.HEAD_OUTLINE == ((-10, -10), (-10, 10), (10, 10), (10, -10)) - xs = {x for x, _ in constants.HEAD_OUTLINE} - ys = {y for _, y in constants.HEAD_OUTLINE} - assert max(xs) - min(xs) == max(ys) - min(ys) == 20 - - -def test_mouth_lies_on_the_front_edge_and_inside_the_head() -> None: - front = max(y for _, y in constants.HEAD_OUTLINE) - half = max(x for x, _ in constants.HEAD_OUTLINE) - - assert constants.MOUTH_OUTLINE == ((-6, 10), (0, 2), (6, 10)) - assert any(y == front for _, y in constants.MOUTH_OUTLINE) - assert all( - abs(x) <= half and -half <= y <= front for x, y in constants.MOUTH_OUTLINE - ) - - -def test_mouth_is_the_colour_of_the_background() -> None: - assert constants.MOUTH_COLOR == constants.SCREEN_BACKGROUND_COLOR - - -def test_snake_starts_with_three_segments_at_the_lecture_positions() -> None: - assert constants.STARTING_POSITIONS == ((0, 0), (-20, 0), (-40, 0)) - - -def test_starting_positions_are_distinct_on_one_row() -> None: - positions = constants.STARTING_POSITIONS - - assert len(set(positions)) == len(positions) - assert len({y for _, y in positions}) == 1 - - -def test_starting_positions_run_from_head_to_tail_leftwards() -> None: - x_values = [x for x, _ in constants.STARTING_POSITIONS] - - assert x_values == sorted(x_values, reverse=True) - - -def test_starting_positions_are_one_move_distance_apart() -> None: - x_values = [x for x, _ in constants.STARTING_POSITIONS] - gaps = {left - right for left, right in zip(x_values, x_values[1:], strict=False)} - - assert gaps == {constants.MOVE_DISTANCE} - - -def test_starting_positions_are_inside_the_screen() -> None: - assert all( - abs(x) < HALF_WIDTH and abs(y) < HALF_HEIGHT - for x, y in constants.STARTING_POSITIONS - ) - - -def test_move_distance_is_twenty_pixels() -> None: - assert constants.MOVE_DISTANCE == 20 - - -def test_directions_match_the_lecture_headings() -> None: - assert constants.UP == 90 - assert constants.DOWN == 270 - assert constants.LEFT == 180 - assert constants.RIGHT == 0 - - -def test_four_directions_are_distinct() -> None: - directions = (constants.UP, constants.DOWN, constants.LEFT, constants.RIGHT) - - assert len(set(directions)) == len(directions) - - -def test_opposite_directions_differ_by_half_a_turn() -> None: - assert (constants.UP - constants.DOWN) % 360 == 180 - assert (constants.LEFT - constants.RIGHT) % 360 == 180 - - -def test_refresh_delay_is_a_tenth_of_a_second() -> None: - assert constants.REFRESH_DELAY_SECONDS == 0.1 - - -def test_food_is_a_small_blue_circle_without_animation() -> None: - assert constants.FOOD_SHAPE == "circle" - assert constants.FOOD_SIZE == 0.5 - assert constants.FOOD_COLOR == "blue" - assert constants.FOOD_SPEED == "fastest" - - -def test_wall_is_inside_the_screen() -> None: - assert 0 < constants.WALL_LIMIT < HALF_WIDTH - assert constants.WALL_LIMIT < HALF_HEIGHT - - -def test_wall_is_280_pixels_from_the_centre() -> None: - assert constants.WALL_LIMIT == 280 - - -def test_food_is_eaten_closer_than_15_pixels_which_is_less_than_a_move() -> None: - assert constants.FOOD_COLLISION_DISTANCE == 15 - assert constants.FOOD_COLLISION_DISTANCE < constants.MOVE_DISTANCE - - -def test_scoreboard_is_white_text_at_the_top_centre_inside_the_screen() -> None: - x, y = constants.SCOREBOARD_POSITION - - assert constants.SCOREBOARD_COLOR == "white" - assert constants.SCOREBOARD_ALIGNMENT == "center" - assert (x, y) == (0, 270) - assert abs(x) < HALF_WIDTH - assert 0 < y < HALF_HEIGHT - - -def test_scoreboard_text_is_score_label_and_arial_24() -> None: - assert constants.SCORE_LABEL == "Score: " - assert constants.SCOREBOARD_FONT == ("Arial", 24, "normal") - - -def test_tail_is_touched_closer_than_10_pixels_which_is_less_than_a_move() -> None: - assert constants.TAIL_COLLISION_DISTANCE == 10 - assert 0 < constants.TAIL_COLLISION_DISTANCE < constants.MOVE_DISTANCE - - -def test_game_over_text_is_written_at_the_centre_inside_the_wall() -> None: - x, y = constants.GAME_OVER_POSITION - - assert constants.GAME_OVER_TEXT == "GAME OVER" - assert (x, y) == (0, 0) - assert abs(x) < constants.WALL_LIMIT - assert abs(y) < constants.WALL_LIMIT +"""Tests of the constants that the day-20, day-21 and day-24 lectures fix.""" + +from snake_game import constants + +HALF_WIDTH = constants.SCREEN_WIDTH / 2 +HALF_HEIGHT = constants.SCREEN_HEIGHT / 2 + + +def test_screen_size_matches_lecture() -> None: + assert (constants.SCREEN_WIDTH, constants.SCREEN_HEIGHT) == (600, 600) + + +def test_screen_is_black_and_titled_as_in_the_lecture() -> None: + assert constants.SCREEN_BACKGROUND_COLOR == "black" + assert constants.SCREEN_TITLE == "My Snake Game" + + +def test_body_segments_are_light_green_squares() -> None: + assert constants.SEGMENT_SHAPE == "square" + assert constants.BODY_COLOR == "light green" + + +def test_segment_color_no_longer_exists() -> None: + assert not hasattr(constants, "SEGMENT_COLOR") + + +def test_head_is_dark_green_and_differs_from_the_body() -> None: + assert constants.HEAD_COLOR == "dark green" + assert constants.HEAD_COLOR != constants.BODY_COLOR + + +def test_head_shape_has_a_name_that_is_not_a_built_in_shape() -> None: + assert constants.HEAD_SHAPE == "snake_head" + assert constants.HEAD_SHAPE != constants.SEGMENT_SHAPE + + +def test_head_is_the_20_by_20_square() -> None: + assert constants.HEAD_OUTLINE == ((-10, -10), (-10, 10), (10, 10), (10, -10)) + xs = {x for x, _ in constants.HEAD_OUTLINE} + ys = {y for _, y in constants.HEAD_OUTLINE} + assert max(xs) - min(xs) == max(ys) - min(ys) == 20 + + +def test_mouth_lies_on_the_front_edge_and_inside_the_head() -> None: + front = max(y for _, y in constants.HEAD_OUTLINE) + half = max(x for x, _ in constants.HEAD_OUTLINE) + + assert constants.MOUTH_OUTLINE == ((-6, 10), (0, 2), (6, 10)) + assert any(y == front for _, y in constants.MOUTH_OUTLINE) + assert all( + abs(x) <= half and -half <= y <= front for x, y in constants.MOUTH_OUTLINE + ) + + +def test_mouth_is_the_colour_of_the_background() -> None: + assert constants.MOUTH_COLOR == constants.SCREEN_BACKGROUND_COLOR + + +def test_snake_starts_with_three_segments_at_the_lecture_positions() -> None: + assert constants.STARTING_POSITIONS == ((0, 0), (-20, 0), (-40, 0)) + + +def test_starting_positions_are_distinct_on_one_row() -> None: + positions = constants.STARTING_POSITIONS + + assert len(set(positions)) == len(positions) + assert len({y for _, y in positions}) == 1 + + +def test_starting_positions_run_from_head_to_tail_leftwards() -> None: + x_values = [x for x, _ in constants.STARTING_POSITIONS] + + assert x_values == sorted(x_values, reverse=True) + + +def test_starting_positions_are_one_move_distance_apart() -> None: + x_values = [x for x, _ in constants.STARTING_POSITIONS] + gaps = {left - right for left, right in zip(x_values, x_values[1:], strict=False)} + + assert gaps == {constants.MOVE_DISTANCE} + + +def test_starting_positions_are_inside_the_screen() -> None: + assert all( + abs(x) < HALF_WIDTH and abs(y) < HALF_HEIGHT + for x, y in constants.STARTING_POSITIONS + ) + + +def test_move_distance_is_twenty_pixels() -> None: + assert constants.MOVE_DISTANCE == 20 + + +def test_directions_match_the_lecture_headings() -> None: + assert constants.UP == 90 + assert constants.DOWN == 270 + assert constants.LEFT == 180 + assert constants.RIGHT == 0 + + +def test_four_directions_are_distinct() -> None: + directions = (constants.UP, constants.DOWN, constants.LEFT, constants.RIGHT) + + assert len(set(directions)) == len(directions) + + +def test_opposite_directions_differ_by_half_a_turn() -> None: + assert (constants.UP - constants.DOWN) % 360 == 180 + assert (constants.LEFT - constants.RIGHT) % 360 == 180 + + +def test_refresh_delay_is_a_tenth_of_a_second() -> None: + assert constants.REFRESH_DELAY_SECONDS == 0.1 + + +def test_food_is_a_small_blue_circle_without_animation() -> None: + assert constants.FOOD_SHAPE == "circle" + assert constants.FOOD_SIZE == 0.5 + assert constants.FOOD_COLOR == "blue" + assert constants.FOOD_SPEED == "fastest" + + +def test_wall_is_inside_the_screen() -> None: + assert 0 < constants.WALL_LIMIT < HALF_WIDTH + assert constants.WALL_LIMIT < HALF_HEIGHT + + +def test_wall_is_280_pixels_from_the_centre() -> None: + assert constants.WALL_LIMIT == 280 + + +def test_food_is_eaten_closer_than_15_pixels_which_is_less_than_a_move() -> None: + assert constants.FOOD_COLLISION_DISTANCE == 15 + assert constants.FOOD_COLLISION_DISTANCE < constants.MOVE_DISTANCE + + +def test_scoreboard_is_white_text_at_the_top_centre_inside_the_screen() -> None: + x, y = constants.SCOREBOARD_POSITION + + assert constants.SCOREBOARD_COLOR == "white" + assert constants.SCOREBOARD_ALIGNMENT == "center" + assert (x, y) == (0, 270) + assert abs(x) < HALF_WIDTH + assert 0 < y < HALF_HEIGHT + + +def test_scoreboard_text_is_score_label_and_arial_24() -> None: + assert constants.SCORE_LABEL == "Score: " + assert constants.SCOREBOARD_FONT == ("Arial", 24, "normal") + + +def test_tail_is_touched_closer_than_10_pixels_which_is_less_than_a_move() -> None: + assert constants.TAIL_COLLISION_DISTANCE == 10 + assert 0 < constants.TAIL_COLLISION_DISTANCE < constants.MOVE_DISTANCE + + +def test_game_over_constants_no_longer_exist() -> None: + assert not hasattr(constants, "GAME_OVER_TEXT") + assert not hasattr(constants, "GAME_OVER_POSITION") + + +def test_high_score_label_follows_the_score_label_as_in_the_lecture() -> None: + assert constants.HIGH_SCORE_LABEL == "High Score: " + assert constants.HIGH_SCORE_LABEL != constants.SCORE_LABEL + + +def test_old_segments_are_parked_outside_the_screen() -> None: + x, y = constants.SEGMENT_PARKING_POSITION + + assert abs(x) > HALF_WIDTH + assert abs(y) > HALF_HEIGHT From 518133b68836e1a0ed20e46b5b7f64c16ae1a6b8 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Tue, 13 Oct 2026 14:33:54 +0800 Subject: [PATCH 2/7] Add the high score to the scoreboard and replace game_over with reset Refs #17 Refs #18 Task: MIL-003#2 Task: MIL-003#3 --- src/snake_game/scoreboard.py | 119 ++++++++++--------- tests/test_scoreboard.py | 222 +++++++++++++++++++++-------------- 2 files changed, 198 insertions(+), 143 deletions(-) diff --git a/src/snake_game/scoreboard.py b/src/snake_game/scoreboard.py index 650108f..7513098 100644 --- a/src/snake_game/scoreboard.py +++ b/src/snake_game/scoreboard.py @@ -1,58 +1,61 @@ -"""! @file -@brief The scoreboard: the text at the top of the screen that shows the score. - -The class follows the day-21 lecture on inheritance: `Scoreboard` is a `Turtle` -that writes text. Like `food`, this module needs `turtle` when it is imported; -the game imports it only when it starts, and the tests install a fake `turtle` -module first. -""" - -from turtle import Turtle - -from snake_game.constants import ( - GAME_OVER_POSITION, - GAME_OVER_TEXT, - SCORE_LABEL, - SCOREBOARD_ALIGNMENT, - SCOREBOARD_COLOR, - SCOREBOARD_FONT, - SCOREBOARD_POSITION, -) - - -class Scoreboard(Turtle): - """! @brief Shows the score as text at the top centre of the screen.""" - - def __init__(self) -> None: - """! @brief Set the scoreboard up and write the score 0.""" - super().__init__() - ## @brief The number of foods the snake has eaten in this game. - self.score = 0 - self.color(SCOREBOARD_COLOR) - self.penup() - self.hideturtle() - self.goto(SCOREBOARD_POSITION) - self.update_scoreboard() - - def update_scoreboard(self) -> None: - """! @brief Wipe the old text and write the label and the score.""" - self.clear() - self.write( - f"{SCORE_LABEL}{self.score}", - align=SCOREBOARD_ALIGNMENT, - font=SCOREBOARD_FONT, - ) - - def increase_score(self) -> None: - """! @brief Add 1 to the score and write it.""" - self.score += 1 - self.update_scoreboard() - - def game_over(self) -> None: - """! @brief Write the game-over text at the centre of the screen. - - The score stays where it is: the old text is not wiped. Automatic drawing is - off in the game, so the screen must be updated after this call. - """ - self.goto(GAME_OVER_POSITION) - self.write(GAME_OVER_TEXT, align=SCOREBOARD_ALIGNMENT, font=SCOREBOARD_FONT) +"""! @file +@brief The scoreboard: the text at the top of the screen that shows the score. + +The class follows the day-21 lecture on inheritance: `Scoreboard` is a `Turtle` +that writes text. Like `food`, this module needs `turtle` when it is imported; +the game imports it only when it starts, and the tests install a fake `turtle` +module first. +""" + +from turtle import Turtle + +from snake_game.constants import ( + HIGH_SCORE_LABEL, + SCORE_LABEL, + SCOREBOARD_ALIGNMENT, + SCOREBOARD_COLOR, + SCOREBOARD_FONT, + SCOREBOARD_POSITION, +) + + +class Scoreboard(Turtle): + """! @brief Shows the score as text at the top centre of the screen.""" + + def __init__(self) -> None: + """! @brief Set the scoreboard up and write the score 0.""" + super().__init__() + ## @brief The number of foods the snake has eaten since the last reset. + self.score = 0 + ## @brief The largest score reached so far. + self.high_score = 0 + self.color(SCOREBOARD_COLOR) + self.penup() + self.hideturtle() + self.goto(SCOREBOARD_POSITION) + self.update_scoreboard() + + def update_scoreboard(self) -> None: + """! @brief Wipe the old text and write the score and the high score.""" + self.clear() + self.write( + f"{SCORE_LABEL}{self.score} {HIGH_SCORE_LABEL}{self.high_score}", + align=SCOREBOARD_ALIGNMENT, + font=SCOREBOARD_FONT, + ) + + def increase_score(self) -> None: + """! @brief Add 1 to the score and write it.""" + self.score += 1 + self.update_scoreboard() + + def reset(self) -> None: + """! @brief Keep the best score, start a new game and write the scoreboard. + + When the score is higher than the high score, it becomes the new high + score. The score then goes back to 0. + """ + if self.score > self.high_score: + self.high_score = self.score + self.score = 0 + self.update_scoreboard() diff --git a/tests/test_scoreboard.py b/tests/test_scoreboard.py index 68a7a08..a7e1c2b 100644 --- a/tests/test_scoreboard.py +++ b/tests/test_scoreboard.py @@ -1,85 +1,137 @@ -"""Tests of the scoreboard, with a fake `Turtle` as its base class.""" - -import importlib -import sys - -import pytest - -from fakes import install_fake_turtle, make_scoreboard -from snake_game import constants - -START_TEXT = ("write", ("Score: 0", "center", ("Arial", 24, "normal"))) - - -def test_scoreboard_is_white_hidden_and_at_the_top_centre( - monkeypatch: pytest.MonkeyPatch, -) -> None: - scoreboard = make_scoreboard(monkeypatch) - - assert ("color", ("white",)) in scoreboard.calls - assert "penup" in scoreboard.call_names() - assert "hideturtle" in scoreboard.call_names() - assert ("goto", constants.SCOREBOARD_POSITION) in scoreboard.calls - - -def test_scoreboard_inherits_from_turtle(monkeypatch: pytest.MonkeyPatch) -> None: - install_fake_turtle(monkeypatch) - module = importlib.import_module("snake_game.scoreboard") - - assert issubclass(module.Scoreboard, sys.modules["turtle"].Turtle) - - -def test_scoreboard_starts_by_writing_score_0_centred_in_arial_24( - monkeypatch: pytest.MonkeyPatch, -) -> None: - scoreboard = make_scoreboard(monkeypatch) - - assert scoreboard.calls[-1] == START_TEXT - - -def test_increase_score_wipes_the_old_text_and_writes_the_new_score( - monkeypatch: pytest.MonkeyPatch, -) -> None: - scoreboard = make_scoreboard(monkeypatch) - - scoreboard.increase_score() - - assert scoreboard.call_names()[-2:] == ["clear", "write"] - assert scoreboard.calls[-1] == ( - "write", - ("Score: 1", "center", ("Arial", 24, "normal")), - ) - - -def test_every_food_adds_one_to_the_score(monkeypatch: pytest.MonkeyPatch) -> None: - scoreboard = make_scoreboard(monkeypatch) - - for _ in range(3): - scoreboard.increase_score() - - assert scoreboard.calls[-1][1][0] == "Score: 3" - - -def test_game_over_writes_the_text_at_the_centre_in_the_scoreboard_font( - monkeypatch: pytest.MonkeyPatch, -) -> None: - scoreboard = make_scoreboard(monkeypatch) - - scoreboard.game_over() - - assert scoreboard.calls[-2:] == [ - ("goto", constants.GAME_OVER_POSITION), - ("write", ("GAME OVER", "center", ("Arial", 24, "normal"))), - ] - - -def test_game_over_leaves_the_score_on_the_screen( - monkeypatch: pytest.MonkeyPatch, -) -> None: - scoreboard = make_scoreboard(monkeypatch) - scoreboard.increase_score() - calls_before = len(scoreboard.calls) - - scoreboard.game_over() - - assert "clear" not in scoreboard.call_names()[calls_before:] +"""Tests of the scoreboard, with a fake `Turtle` as its base class.""" + +import importlib +import sys + +import pytest + +from fakes import install_fake_turtle, make_scoreboard +from snake_game import constants + +START_TEXT = ( + "write", + ("Score: 0 High Score: 0", "center", ("Arial", 24, "normal")), +) + + +def test_scoreboard_is_white_hidden_and_at_the_top_centre( + monkeypatch: pytest.MonkeyPatch, +) -> None: + scoreboard = make_scoreboard(monkeypatch) + + assert ("color", ("white",)) in scoreboard.calls + assert "penup" in scoreboard.call_names() + assert "hideturtle" in scoreboard.call_names() + assert ("goto", constants.SCOREBOARD_POSITION) in scoreboard.calls + + +def test_scoreboard_inherits_from_turtle(monkeypatch: pytest.MonkeyPatch) -> None: + install_fake_turtle(monkeypatch) + module = importlib.import_module("snake_game.scoreboard") + + assert issubclass(module.Scoreboard, sys.modules["turtle"].Turtle) + + +def test_scoreboard_starts_by_writing_both_scores_0_centred_in_arial_24( + monkeypatch: pytest.MonkeyPatch, +) -> None: + scoreboard = make_scoreboard(monkeypatch) + + assert scoreboard.calls[-1] == START_TEXT + + +def test_increase_score_wipes_the_old_text_and_writes_the_new_score( + monkeypatch: pytest.MonkeyPatch, +) -> None: + scoreboard = make_scoreboard(monkeypatch) + + scoreboard.increase_score() + + assert scoreboard.call_names()[-2:] == ["clear", "write"] + assert scoreboard.calls[-1] == ( + "write", + ("Score: 1 High Score: 0", "center", ("Arial", 24, "normal")), + ) + + +def test_every_food_adds_one_to_the_score(monkeypatch: pytest.MonkeyPatch) -> None: + scoreboard = make_scoreboard(monkeypatch) + + for _ in range(3): + scoreboard.increase_score() + + assert scoreboard.calls[-1][1][0] == "Score: 3 High Score: 0" + + +def test_high_score_starts_at_0(monkeypatch: pytest.MonkeyPatch) -> None: + scoreboard = make_scoreboard(monkeypatch) + + assert scoreboard.high_score == 0 + assert scoreboard.score == 0 + + +def test_reset_keeps_a_higher_score_as_the_high_score_and_starts_again( + monkeypatch: pytest.MonkeyPatch, +) -> None: + scoreboard = make_scoreboard(monkeypatch) + for _ in range(3): + scoreboard.increase_score() + + scoreboard.reset() + + assert scoreboard.high_score == 3 + assert scoreboard.score == 0 + assert scoreboard.call_names()[-2:] == ["clear", "write"] + assert scoreboard.calls[-1] == ( + "write", + ("Score: 0 High Score: 3", "center", ("Arial", 24, "normal")), + ) + + +def test_reset_keeps_the_high_score_when_the_score_is_lower( + monkeypatch: pytest.MonkeyPatch, +) -> None: + scoreboard = make_scoreboard(monkeypatch) + for _ in range(3): + scoreboard.increase_score() + scoreboard.reset() + scoreboard.increase_score() + + scoreboard.reset() + + assert scoreboard.high_score == 3 + assert scoreboard.calls[-1][1][0] == "Score: 0 High Score: 3" + + +def test_reset_keeps_the_high_score_when_the_score_is_equal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + scoreboard = make_scoreboard(monkeypatch) + scoreboard.increase_score() + scoreboard.reset() + scoreboard.increase_score() + + scoreboard.reset() + + assert scoreboard.high_score == 1 + assert scoreboard.score == 0 + + +def test_the_score_text_shows_both_scores_while_playing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + scoreboard = make_scoreboard(monkeypatch) + scoreboard.increase_score() + scoreboard.increase_score() + scoreboard.reset() + scoreboard.increase_score() + + assert scoreboard.calls[-1][1][0] == "Score: 1 High Score: 2" + + +def test_scoreboard_has_no_game_over_any_more( + monkeypatch: pytest.MonkeyPatch, +) -> None: + scoreboard = make_scoreboard(monkeypatch) + + assert not hasattr(scoreboard, "game_over") From 19bc5c1431ae916f01967bfb4427a65896104c38 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Tue, 13 Oct 2026 14:33:56 +0800 Subject: [PATCH 3/7] Add Snake.reset Refs #19 Task: MIL-003#4 --- src/snake_game/snake.py | 512 +++++++++++------------ tests/test_snake.py | 876 +++++++++++++++++++++------------------- 2 files changed, 731 insertions(+), 657 deletions(-) diff --git a/src/snake_game/snake.py b/src/snake_game/snake.py index 0f6da77..6d5e0c3 100644 --- a/src/snake_game/snake.py +++ b/src/snake_game/snake.py @@ -1,248 +1,264 @@ -"""! @file -@brief The snake: a list of segments that the game draws and moves. - -The class follows the lectures "Create a Snake Class & Move to OOP", "Animating -the Snake Segments on Screen" and "Controlling the Snake with Keypresses", and -the day-21 steps in which the snake grows and the game ends. It keeps the lectures' -names: `Snake`, `segments`, `create_snake`, `add_segment`, `extend`, `move`, `head`, -`up`, `down`, `left` and `right`. The only differences are that the snake gets -the function that makes a segment as a parameter, so that a test can pass a fake -and needs no window, and how a reversal is refused (see Snake). -""" - -from collections.abc import Callable -from typing import Protocol - -from snake_game.constants import ( - BODY_COLOR, - DOWN, - HEAD_SHAPE, - LEFT, - MOVE_DISTANCE, - RIGHT, - SEGMENT_SHAPE, - STARTING_POSITIONS, - TAIL_COLLISION_DISTANCE, - UP, - WALL_LIMIT, -) - - -class Segment(Protocol): - """! @brief What the snake needs from one of its segments. - - A `turtle.Turtle` fits this description, and so does a fake in a test. - """ - - def shape(self, name: str, /) -> object: - """! @brief Set the shape of the segment. - - @param name Name of the shape, for example `square`. - @return Whatever the implementation returns; the snake ignores it. - """ - ... - - def color(self, color: str, /) -> object: - """! @brief Set the colour of the segment. - - @param color Name of the colour, for example `light green`. - @return Whatever the implementation returns; the snake ignores it. - """ - ... - - def penup(self) -> None: - """! @brief Lift the pen, so that moving the segment draws no line.""" - ... - - def goto(self, x: float, y: float, /) -> None: - """! @brief Send the segment to a position. - - @param x The x coordinate. - @param y The y coordinate. - """ - ... - - def xcor(self) -> float: - """! @brief Tell the x coordinate of the segment. - - @return The x coordinate. - """ - ... - - def ycor(self) -> float: - """! @brief Tell the y coordinate of the segment. - - @return The y coordinate. - """ - ... - - def forward(self, distance: float, /) -> None: - """! @brief Move the segment forward, in the direction it points. - - @param distance How far to move, in pixels. - """ - ... - - def position(self) -> tuple[float, float]: - """! @brief Tell where the segment is. - - @return The x and y coordinates. - """ - ... - - def distance(self, x: tuple[float, float], /) -> float: - """! @brief Tell how far the segment is from a position. - - @param x The x and y coordinates of the other place. - @return The distance in pixels. - """ - ... - - def heading(self) -> float: - """! @brief Tell the direction the segment points, in degrees. - - @return The heading, 0 for right and growing counter-clockwise. - """ - ... - - def setheading(self, to_angle: float, /) -> None: - """! @brief Turn the segment to point in a direction. - - @param to_angle The heading in degrees. - """ - ... - - -def make_turtle_segment() -> Segment: - """! @brief Make a real turtle, the default way to get a segment. - - `turtle` is imported here and not at the top of the module, so that - importing the snake needs neither a display nor `tkinter`. - - @return A new `turtle.Turtle`. - """ - from turtle import Turtle - - return Turtle() - - -class Snake: - """! @brief The snake of the game: a dark green head and a light green body. - - The snake cannot reverse onto itself. Like the lecture, a turn is refused when - it points opposite to the way the snake is going. Unlike the lecture, "the way - the snake is going" is the direction of its last move and not the current - direction of the head: otherwise two key presses within one move (Up then Left - while moving right) would turn the head twice and reverse the snake. - """ - - def __init__(self, segment_factory: Callable[[], Segment] | None = None) -> None: - """! @brief Create the snake with its three starting segments. - - @param segment_factory Makes one new segment; defaults to a real turtle. - """ - ## @brief Makes one new segment. - self._segment_factory = segment_factory or make_turtle_segment - ## @brief The segments of the snake, the head first. - self.segments: list[Segment] = [] - self.create_snake() - ## @brief The first segment, which leads the snake. - self.head: Segment = self.segments[0] - ## @brief The direction of the last move; a turn against it is refused. - self._direction_of_travel: float = self.head.heading() - - def create_snake(self) -> None: - """! @brief Draw one segment at each starting position. - - The first one is the head, with the shape `HEAD_SHAPE`; the others are light - green squares. The segments are kept in `segments`, the head first. - """ - for position in STARTING_POSITIONS: - self.add_segment(position) - - def add_segment(self, position: tuple[float, float]) -> None: - """! @brief Add a segment at the end of the snake. - - The first segment of an empty snake is the head: it gets the shape - `HEAD_SHAPE`, whose colours are part of the shape. Every other segment is a - square in `BODY_COLOR`. - - The pen is lifted before the segment is sent to its position, so no line is - drawn. - - @param position The x and y coordinates of the new segment. - """ - new_segment = self._segment_factory() - if self.segments: - new_segment.shape(SEGMENT_SHAPE) - new_segment.color(BODY_COLOR) - else: - new_segment.shape(HEAD_SHAPE) - new_segment.penup() - new_segment.goto(*position) - self.segments.append(new_segment) - - def extend(self) -> None: - """! @brief Make the snake one segment longer. - - The new segment appears where the last segment is, and follows it on the - next move. - """ - self.add_segment(self.segments[-1].position()) - - def move(self) -> None: - """! @brief Move the snake one step along its path. - - Each segment, from the last to the second, goes to the place of the - segment before it; then the head goes forward by `MOVE_DISTANCE`. Moving - the tail first keeps the body joined while turning, however many segments - there are. - """ - for index in range(len(self.segments) - 1, 0, -1): - ahead = self.segments[index - 1] - self.segments[index].goto(ahead.xcor(), ahead.ycor()) - self.head.forward(MOVE_DISTANCE) - self._direction_of_travel = self.head.heading() - - def hits_wall(self) -> bool: - """! @brief Tell whether the head has passed the wall on any side. - - The head is outside when its x or its y is beyond `WALL_LIMIT`. - - @return True when the head is outside the wall. - """ - return abs(self.head.xcor()) > WALL_LIMIT or abs(self.head.ycor()) > WALL_LIMIT - - def hits_tail(self) -> bool: - """! @brief Tell whether the head touches the tail. - - The tail is every segment behind the head (a slice, `segments[1:]`). The head - touches it when it is closer than `TAIL_COLLISION_DISTANCE` to one of them. - A snake that has only a head has no tail to touch. - - @return True when the head touches a segment of the tail. - """ - return any( - self.head.distance(segment.position()) < TAIL_COLLISION_DISTANCE - for segment in self.segments[1:] - ) - - def up(self) -> None: - """! @brief Turn the head up, unless the snake is moving down.""" - if self._direction_of_travel != DOWN: - self.head.setheading(UP) - - def down(self) -> None: - """! @brief Turn the head down, unless the snake is moving up.""" - if self._direction_of_travel != UP: - self.head.setheading(DOWN) - - def left(self) -> None: - """! @brief Turn the head left, unless the snake is moving right.""" - if self._direction_of_travel != RIGHT: - self.head.setheading(LEFT) - - def right(self) -> None: - """! @brief Turn the head right, unless the snake is moving left.""" - if self._direction_of_travel != LEFT: - self.head.setheading(RIGHT) +"""! @file +@brief The snake: a list of segments that the game draws and moves. + +The class follows the lectures "Create a Snake Class & Move to OOP", "Animating +the Snake Segments on Screen" and "Controlling the Snake with Keypresses", and +the day-21 steps in which the snake grows, and the day-24 step in which it is reset +after a collision. It keeps the lectures' +names: `Snake`, `segments`, `create_snake`, `add_segment`, `extend`, `move`, `head`, +`reset`, `up`, `down`, `left` and `right`. The only differences are that the snake gets +the function that makes a segment as a parameter, so that a test can pass a fake +and needs no window, and how a reversal is refused (see Snake). +""" + +from collections.abc import Callable +from typing import Protocol + +from snake_game.constants import ( + BODY_COLOR, + DOWN, + HEAD_SHAPE, + LEFT, + MOVE_DISTANCE, + RIGHT, + SEGMENT_PARKING_POSITION, + SEGMENT_SHAPE, + STARTING_POSITIONS, + TAIL_COLLISION_DISTANCE, + UP, + WALL_LIMIT, +) + + +class Segment(Protocol): + """! @brief What the snake needs from one of its segments. + + A `turtle.Turtle` fits this description, and so does a fake in a test. + """ + + def shape(self, name: str, /) -> object: + """! @brief Set the shape of the segment. + + @param name Name of the shape, for example `square`. + @return Whatever the implementation returns; the snake ignores it. + """ + ... + + def color(self, color: str, /) -> object: + """! @brief Set the colour of the segment. + + @param color Name of the colour, for example `light green`. + @return Whatever the implementation returns; the snake ignores it. + """ + ... + + def penup(self) -> None: + """! @brief Lift the pen, so that moving the segment draws no line.""" + ... + + def goto(self, x: float, y: float, /) -> None: + """! @brief Send the segment to a position. + + @param x The x coordinate. + @param y The y coordinate. + """ + ... + + def xcor(self) -> float: + """! @brief Tell the x coordinate of the segment. + + @return The x coordinate. + """ + ... + + def ycor(self) -> float: + """! @brief Tell the y coordinate of the segment. + + @return The y coordinate. + """ + ... + + def forward(self, distance: float, /) -> None: + """! @brief Move the segment forward, in the direction it points. + + @param distance How far to move, in pixels. + """ + ... + + def position(self) -> tuple[float, float]: + """! @brief Tell where the segment is. + + @return The x and y coordinates. + """ + ... + + def distance(self, x: tuple[float, float], /) -> float: + """! @brief Tell how far the segment is from a position. + + @param x The x and y coordinates of the other place. + @return The distance in pixels. + """ + ... + + def heading(self) -> float: + """! @brief Tell the direction the segment points, in degrees. + + @return The heading, 0 for right and growing counter-clockwise. + """ + ... + + def setheading(self, to_angle: float, /) -> None: + """! @brief Turn the segment to point in a direction. + + @param to_angle The heading in degrees. + """ + ... + + +def make_turtle_segment() -> Segment: + """! @brief Make a real turtle, the default way to get a segment. + + `turtle` is imported here and not at the top of the module, so that + importing the snake needs neither a display nor `tkinter`. + + @return A new `turtle.Turtle`. + """ + from turtle import Turtle + + return Turtle() + + +class Snake: + """! @brief The snake of the game: a dark green head and a light green body. + + The snake cannot reverse onto itself. Like the lecture, a turn is refused when + it points opposite to the way the snake is going. Unlike the lecture, "the way + the snake is going" is the direction of its last move and not the current + direction of the head: otherwise two key presses within one move (Up then Left + while moving right) would turn the head twice and reverse the snake. + """ + + def __init__(self, segment_factory: Callable[[], Segment] | None = None) -> None: + """! @brief Create the snake with its three starting segments. + + @param segment_factory Makes one new segment; defaults to a real turtle. + """ + ## @brief Makes one new segment. + self._segment_factory = segment_factory or make_turtle_segment + ## @brief The segments of the snake, the head first. + self.segments: list[Segment] = [] + self.create_snake() + ## @brief The first segment, which leads the snake. + self.head: Segment = self.segments[0] + ## @brief The direction of the last move; a turn against it is refused. + self._direction_of_travel: float = self.head.heading() + + def create_snake(self) -> None: + """! @brief Draw one segment at each starting position. + + The first one is the head, with the shape `HEAD_SHAPE`; the others are light + green squares. The segments are kept in `segments`, the head first. + """ + for position in STARTING_POSITIONS: + self.add_segment(position) + + def add_segment(self, position: tuple[float, float]) -> None: + """! @brief Add a segment at the end of the snake. + + The first segment of an empty snake is the head: it gets the shape + `HEAD_SHAPE`, whose colours are part of the shape. Every other segment is a + square in `BODY_COLOR`. + + The pen is lifted before the segment is sent to its position, so no line is + drawn. + + @param position The x and y coordinates of the new segment. + """ + new_segment = self._segment_factory() + if self.segments: + new_segment.shape(SEGMENT_SHAPE) + new_segment.color(BODY_COLOR) + else: + new_segment.shape(HEAD_SHAPE) + new_segment.penup() + new_segment.goto(*position) + self.segments.append(new_segment) + + def extend(self) -> None: + """! @brief Make the snake one segment longer. + + The new segment appears where the last segment is, and follows it on the + next move. + """ + self.add_segment(self.segments[-1].position()) + + def move(self) -> None: + """! @brief Move the snake one step along its path. + + Each segment, from the last to the second, goes to the place of the + segment before it; then the head goes forward by `MOVE_DISTANCE`. Moving + the tail first keeps the body joined while turning, however many segments + there are. + """ + for index in range(len(self.segments) - 1, 0, -1): + ahead = self.segments[index - 1] + self.segments[index].goto(ahead.xcor(), ahead.ycor()) + self.head.forward(MOVE_DISTANCE) + self._direction_of_travel = self.head.heading() + + def reset(self) -> None: + """! @brief Start again with a new snake of three segments. + + Every old segment is sent far outside the screen, so it can no longer be + seen, the list is emptied, and the snake is created again at the starting + positions. The new head is the first new segment, and it points right. + """ + for segment in self.segments: + segment.goto(*SEGMENT_PARKING_POSITION) + self.segments.clear() + self.create_snake() + self.head = self.segments[0] + self._direction_of_travel = self.head.heading() + + def hits_wall(self) -> bool: + """! @brief Tell whether the head has passed the wall on any side. + + The head is outside when its x or its y is beyond `WALL_LIMIT`. + + @return True when the head is outside the wall. + """ + return abs(self.head.xcor()) > WALL_LIMIT or abs(self.head.ycor()) > WALL_LIMIT + + def hits_tail(self) -> bool: + """! @brief Tell whether the head touches the tail. + + The tail is every segment behind the head (a slice, `segments[1:]`). The head + touches it when it is closer than `TAIL_COLLISION_DISTANCE` to one of them. + A snake that has only a head has no tail to touch. + + @return True when the head touches a segment of the tail. + """ + return any( + self.head.distance(segment.position()) < TAIL_COLLISION_DISTANCE + for segment in self.segments[1:] + ) + + def up(self) -> None: + """! @brief Turn the head up, unless the snake is moving down.""" + if self._direction_of_travel != DOWN: + self.head.setheading(UP) + + def down(self) -> None: + """! @brief Turn the head down, unless the snake is moving up.""" + if self._direction_of_travel != UP: + self.head.setheading(DOWN) + + def left(self) -> None: + """! @brief Turn the head left, unless the snake is moving right.""" + if self._direction_of_travel != RIGHT: + self.head.setheading(LEFT) + + def right(self) -> None: + """! @brief Turn the head right, unless the snake is moving left.""" + if self._direction_of_travel != LEFT: + self.head.setheading(RIGHT) diff --git a/tests/test_snake.py b/tests/test_snake.py index a856a85..9d179c4 100644 --- a/tests/test_snake.py +++ b/tests/test_snake.py @@ -1,409 +1,467 @@ -"""Tests of the snake, with fakes instead of real turtles.""" - -import math -from collections.abc import Sequence - -import pytest - -from fakes import ( - FakeSegment, - imports_turtle_or_tkinter, - install_fake_turtle, - make_snake, -) -from snake_game import constants -from snake_game.snake import Segment, Snake - -DIRECTIONS = { - "up": constants.UP, - "down": constants.DOWN, - "left": constants.LEFT, - "right": constants.RIGHT, -} -OPPOSITE = { - constants.UP: constants.DOWN, - constants.DOWN: constants.UP, - constants.LEFT: constants.RIGHT, - constants.RIGHT: constants.LEFT, -} - - -def positions(segments: Sequence[Segment]) -> list[tuple[float, float]]: - """Return where every segment is, head first.""" - return [segment.position() for segment in segments] - - -def make_snake_moving(direction: int) -> tuple[Snake, list[FakeSegment]]: - """Make a snake whose last move went in the given direction.""" - snake, created = make_snake() - created[0].setheading(direction) - snake.move() - return snake, created - - -def test_snake_has_one_segment_per_starting_position() -> None: - snake, _ = make_snake() - - assert len(snake.segments) == len(constants.STARTING_POSITIONS) == 3 - - -def test_segments_are_the_created_turtles_in_order() -> None: - snake, created = make_snake() - - assert snake.segments == created - - -def test_head_has_the_head_shape_and_no_colour_call() -> None: - _, created = make_snake() - - head = created[0] - assert ("shape", (constants.HEAD_SHAPE,)) in head.calls - assert "color" not in head.call_names() - - -def test_body_segments_are_light_green_squares() -> None: - _, created = make_snake() - - assert len(created) > 1 - for segment in created[1:]: - assert ("shape", (constants.SEGMENT_SHAPE,)) in segment.calls - assert ("color", (constants.BODY_COLOR,)) in segment.calls - - -def test_segments_are_placed_at_the_starting_positions_head_first() -> None: - _, created = make_snake() - - assert positions(created) == [ - (float(x), float(y)) for x, y in constants.STARTING_POSITIONS - ] - - -def test_pen_is_lifted_before_a_segment_is_moved() -> None: - _, created = make_snake() - - for segment in created: - names = segment.call_names() - assert names.count("goto") == 1 - assert names.index("penup") < names.index("goto") - - -def test_create_snake_adds_three_more_segments_when_called_again() -> None: - snake, _ = make_snake() - - snake.create_snake() - - assert len(snake.segments) == 2 * len(constants.STARTING_POSITIONS) - - -def test_default_segment_factory_makes_turtles(monkeypatch: pytest.MonkeyPatch) -> None: - turtles, _ = install_fake_turtle(monkeypatch) - - snake = Snake() - - assert snake.segments == turtles - assert len(turtles) == len(constants.STARTING_POSITIONS) - - -def test_importing_the_snake_module_does_not_import_turtle_or_tkinter() -> None: - assert not imports_turtle_or_tkinter("snake_game.snake") - - -def test_head_is_the_first_segment() -> None: - snake, created = make_snake() - - assert snake.head is created[0] - - -def test_snake_starts_moving_to_the_right() -> None: - snake, _ = make_snake() - - assert snake.head.heading() == constants.RIGHT - - -def test_move_goes_forward_by_the_move_distance() -> None: - snake, created = make_snake() - - snake.move() - - assert ("forward", (constants.MOVE_DISTANCE,)) in created[0].calls - assert created[0].position() == (constants.MOVE_DISTANCE, 0) - - -def test_move_takes_each_segment_to_the_place_of_the_one_before_it() -> None: - snake, created = make_snake() - - snake.move() - - assert positions(created) == [(20, 0), (0, 0), (-20, 0)] - - -def test_move_keeps_the_segments_joined_while_turning() -> None: - snake, created = make_snake() - - snake.up() - snake.move() - snake.move() - - assert positions(created) == [(0, 40), (0, 20), (0, 0)] - - -def test_move_works_for_any_number_of_segments() -> None: - snake, created = make_snake() - for index in range(3, 6): - extra = FakeSegment() - extra.goto(-20 * index, 0) - snake.segments.append(extra) - created.append(extra) - - snake.move() - - assert positions(created) == [ - (20, 0), - (0, 0), - (-20, 0), - (-40, 0), - (-60, 0), - (-80, 0), - ] - - -@pytest.mark.parametrize("travel", list(OPPOSITE)) -@pytest.mark.parametrize("method", list(DIRECTIONS)) -def test_every_turn_is_accepted_unless_it_is_a_reversal( - method: str, travel: int -) -> None: - snake, _ = make_snake_moving(travel) - - getattr(snake, method)() - - wanted = DIRECTIONS[method] - is_reversal = wanted == OPPOSITE[travel] - assert snake.head.heading() == (travel if is_reversal else wanted) - - -def test_up_sets_the_head_to_90_degrees() -> None: - snake, _ = make_snake() - - snake.up() - - assert snake.head.heading() == 90 - - -def test_down_sets_the_head_to_270_degrees() -> None: - snake, _ = make_snake() - - snake.down() - - assert snake.head.heading() == 270 - - -def test_right_keeps_the_head_at_0_degrees() -> None: - snake, _ = make_snake() - - snake.right() - - assert snake.head.heading() == 0 - - -def test_left_is_ignored_while_moving_right() -> None: - snake, _ = make_snake() - - snake.left() - - assert snake.head.heading() == 0 - - -def test_two_key_presses_within_one_move_do_not_reverse_the_snake() -> None: - snake, _ = make_snake() - - snake.up() - snake.left() - - assert snake.head.heading() == constants.UP - - -def test_a_turn_is_accepted_after_the_move_that_followed_the_first_turn() -> None: - snake, _ = make_snake() - - snake.up() - snake.move() - snake.left() - - assert snake.head.heading() == constants.LEFT - - -def test_add_segment_puts_a_light_green_square_with_the_pen_up_at_the_position() -> ( - None -): - snake, created = make_snake() - - snake.add_segment((100, -60)) - - new = created[-1] - assert snake.segments[-1] is new - assert ("shape", (constants.SEGMENT_SHAPE,)) in new.calls - assert ("color", (constants.BODY_COLOR,)) in new.calls - assert new.call_names().index("penup") < new.call_names().index("goto") - assert new.position() == (100, -60) - - -def test_extend_adds_one_segment_where_the_last_segment_is() -> None: - snake, created = make_snake() - last_place = created[-1].position() - - snake.extend() - - assert len(snake.segments) == len(constants.STARTING_POSITIONS) + 1 - assert snake.segments[-1].position() == last_place - - -def test_extend_adds_a_light_green_square_and_not_a_head() -> None: - snake, _ = make_snake() - - snake.extend() - - new = snake.segments[-1] - assert isinstance(new, FakeSegment) - assert ("shape", (constants.SEGMENT_SHAPE,)) in new.calls - assert ("color", (constants.BODY_COLOR,)) in new.calls - assert ("shape", (constants.HEAD_SHAPE,)) not in new.calls - - -def test_extend_twice_adds_two_segments() -> None: - snake, _ = make_snake() - - snake.extend() - snake.extend() - - assert len(snake.segments) == len(constants.STARTING_POSITIONS) + 2 - - -def test_head_stays_the_first_segment_after_extend() -> None: - snake, created = make_snake() - - snake.extend() - - assert snake.head is created[0] - assert snake.segments[0] is created[0] - - -def test_the_new_segment_follows_the_snake_and_the_body_stays_joined_when_turning() -> ( - None -): - snake, _ = make_snake() - snake.up() - snake.move() - snake.extend() - - snake.move() - - places = positions(snake.segments) - gaps = [ - math.dist(places[index], places[index + 1]) for index in range(len(places) - 1) - ] - assert gaps == [constants.MOVE_DISTANCE] * (len(places) - 1) - - -@pytest.mark.parametrize( - "place", [(281, 0), (-281, 0), (0, 281), (0, -281), (300, 300), (0, 1000)] -) -def test_the_head_has_passed_the_wall_beyond_the_limit_on_any_side( - place: tuple[int, int], -) -> None: - snake, _ = make_snake() - snake.head.goto(*place) - - assert snake.hits_wall() - - -@pytest.mark.parametrize( - "place", [(0, 0), (280, 0), (-280, 0), (0, 280), (0, -280), (280, -280)] -) -def test_the_head_has_not_passed_the_wall_at_or_inside_the_limit( - place: tuple[int, int], -) -> None: - snake, _ = make_snake() - snake.head.goto(*place) - - assert not snake.hits_wall() - - -@pytest.mark.parametrize("gap", [0, 5, 9]) -def test_the_head_touches_the_tail_closer_than_the_touch_distance(gap: int) -> None: - snake, created = make_snake() - created[1].goto(gap, 0) - - assert snake.hits_tail() - - -@pytest.mark.parametrize("gap", [10, 11, 20, 100]) -def test_the_head_does_not_touch_the_tail_at_or_beyond_the_touch_distance( - gap: int, -) -> None: - snake, created = make_snake() - created[1].goto(gap, 0) - - assert not snake.hits_tail() - - -def test_the_head_touches_any_segment_of_a_long_tail() -> None: - snake, created = make_snake() - for index in range(3, 8): - extra = FakeSegment() - extra.goto(-20 * index, 0) - snake.segments.append(extra) - created.append(extra) - created[6].goto(3, 4) # a distance of 5 from the head, far down the tail - - assert snake.hits_tail() - - -def test_a_new_snake_does_not_touch_its_tail() -> None: - snake, _ = make_snake() - - assert not snake.hits_tail() - - -def test_a_snake_with_only_a_head_has_no_tail_to_touch() -> None: - snake, created = make_snake() - snake.segments[:] = [created[0]] - created[0].goto(0, 0) - - assert not snake.hits_tail() - - -def test_the_new_segment_after_growing_does_not_touch_the_head() -> None: - snake, _ = make_snake() - snake.extend() - snake.move() - - assert not snake.hits_tail() - - -def test_the_snake_hits_its_tail_when_it_turns_into_it() -> None: - snake, created = make_snake() - for index in range(3, 5): # a snake of five segments - extra = FakeSegment() - extra.goto(-20 * index, 0) - snake.segments.append(extra) - created.append(extra) - # Up, left and down, one move each, walk the head round a square of 20 by 20 - # pixels and back onto the place where the fifth segment is. - for turn in (snake.up, snake.left, snake.down): - turn() - snake.move() - - assert snake.hits_tail() - - -def test_a_snake_of_four_segments_cannot_touch_its_tail_in_a_small_square() -> None: - snake, created = make_snake() - extra = FakeSegment() - extra.goto(-60, 0) - snake.segments.append(extra) - created.append(extra) - for turn in (snake.up, snake.left, snake.down): - turn() - snake.move() - - assert not snake.hits_tail() +"""Tests of the snake, with fakes instead of real turtles.""" + +import math +from collections.abc import Sequence + +import pytest + +from fakes import ( + FakeSegment, + imports_turtle_or_tkinter, + install_fake_turtle, + make_snake, +) +from snake_game import constants +from snake_game.snake import Segment, Snake + +DIRECTIONS = { + "up": constants.UP, + "down": constants.DOWN, + "left": constants.LEFT, + "right": constants.RIGHT, +} +OPPOSITE = { + constants.UP: constants.DOWN, + constants.DOWN: constants.UP, + constants.LEFT: constants.RIGHT, + constants.RIGHT: constants.LEFT, +} + + +def positions(segments: Sequence[Segment]) -> list[tuple[float, float]]: + """Return where every segment is, head first.""" + return [segment.position() for segment in segments] + + +def make_snake_moving(direction: int) -> tuple[Snake, list[FakeSegment]]: + """Make a snake whose last move went in the given direction.""" + snake, created = make_snake() + created[0].setheading(direction) + snake.move() + return snake, created + + +def test_snake_has_one_segment_per_starting_position() -> None: + snake, _ = make_snake() + + assert len(snake.segments) == len(constants.STARTING_POSITIONS) == 3 + + +def test_segments_are_the_created_turtles_in_order() -> None: + snake, created = make_snake() + + assert snake.segments == created + + +def test_head_has_the_head_shape_and_no_colour_call() -> None: + _, created = make_snake() + + head = created[0] + assert ("shape", (constants.HEAD_SHAPE,)) in head.calls + assert "color" not in head.call_names() + + +def test_body_segments_are_light_green_squares() -> None: + _, created = make_snake() + + assert len(created) > 1 + for segment in created[1:]: + assert ("shape", (constants.SEGMENT_SHAPE,)) in segment.calls + assert ("color", (constants.BODY_COLOR,)) in segment.calls + + +def test_segments_are_placed_at_the_starting_positions_head_first() -> None: + _, created = make_snake() + + assert positions(created) == [ + (float(x), float(y)) for x, y in constants.STARTING_POSITIONS + ] + + +def test_pen_is_lifted_before_a_segment_is_moved() -> None: + _, created = make_snake() + + for segment in created: + names = segment.call_names() + assert names.count("goto") == 1 + assert names.index("penup") < names.index("goto") + + +def test_create_snake_adds_three_more_segments_when_called_again() -> None: + snake, _ = make_snake() + + snake.create_snake() + + assert len(snake.segments) == 2 * len(constants.STARTING_POSITIONS) + + +def test_default_segment_factory_makes_turtles(monkeypatch: pytest.MonkeyPatch) -> None: + turtles, _ = install_fake_turtle(monkeypatch) + + snake = Snake() + + assert snake.segments == turtles + assert len(turtles) == len(constants.STARTING_POSITIONS) + + +def test_importing_the_snake_module_does_not_import_turtle_or_tkinter() -> None: + assert not imports_turtle_or_tkinter("snake_game.snake") + + +def test_head_is_the_first_segment() -> None: + snake, created = make_snake() + + assert snake.head is created[0] + + +def test_snake_starts_moving_to_the_right() -> None: + snake, _ = make_snake() + + assert snake.head.heading() == constants.RIGHT + + +def test_move_goes_forward_by_the_move_distance() -> None: + snake, created = make_snake() + + snake.move() + + assert ("forward", (constants.MOVE_DISTANCE,)) in created[0].calls + assert created[0].position() == (constants.MOVE_DISTANCE, 0) + + +def test_move_takes_each_segment_to_the_place_of_the_one_before_it() -> None: + snake, created = make_snake() + + snake.move() + + assert positions(created) == [(20, 0), (0, 0), (-20, 0)] + + +def test_move_keeps_the_segments_joined_while_turning() -> None: + snake, created = make_snake() + + snake.up() + snake.move() + snake.move() + + assert positions(created) == [(0, 40), (0, 20), (0, 0)] + + +def test_move_works_for_any_number_of_segments() -> None: + snake, created = make_snake() + for index in range(3, 6): + extra = FakeSegment() + extra.goto(-20 * index, 0) + snake.segments.append(extra) + created.append(extra) + + snake.move() + + assert positions(created) == [ + (20, 0), + (0, 0), + (-20, 0), + (-40, 0), + (-60, 0), + (-80, 0), + ] + + +@pytest.mark.parametrize("travel", list(OPPOSITE)) +@pytest.mark.parametrize("method", list(DIRECTIONS)) +def test_every_turn_is_accepted_unless_it_is_a_reversal( + method: str, travel: int +) -> None: + snake, _ = make_snake_moving(travel) + + getattr(snake, method)() + + wanted = DIRECTIONS[method] + is_reversal = wanted == OPPOSITE[travel] + assert snake.head.heading() == (travel if is_reversal else wanted) + + +def test_up_sets_the_head_to_90_degrees() -> None: + snake, _ = make_snake() + + snake.up() + + assert snake.head.heading() == 90 + + +def test_down_sets_the_head_to_270_degrees() -> None: + snake, _ = make_snake() + + snake.down() + + assert snake.head.heading() == 270 + + +def test_right_keeps_the_head_at_0_degrees() -> None: + snake, _ = make_snake() + + snake.right() + + assert snake.head.heading() == 0 + + +def test_left_is_ignored_while_moving_right() -> None: + snake, _ = make_snake() + + snake.left() + + assert snake.head.heading() == 0 + + +def test_two_key_presses_within_one_move_do_not_reverse_the_snake() -> None: + snake, _ = make_snake() + + snake.up() + snake.left() + + assert snake.head.heading() == constants.UP + + +def test_a_turn_is_accepted_after_the_move_that_followed_the_first_turn() -> None: + snake, _ = make_snake() + + snake.up() + snake.move() + snake.left() + + assert snake.head.heading() == constants.LEFT + + +def test_add_segment_puts_a_light_green_square_with_the_pen_up_at_the_position() -> ( + None +): + snake, created = make_snake() + + snake.add_segment((100, -60)) + + new = created[-1] + assert snake.segments[-1] is new + assert ("shape", (constants.SEGMENT_SHAPE,)) in new.calls + assert ("color", (constants.BODY_COLOR,)) in new.calls + assert new.call_names().index("penup") < new.call_names().index("goto") + assert new.position() == (100, -60) + + +def test_extend_adds_one_segment_where_the_last_segment_is() -> None: + snake, created = make_snake() + last_place = created[-1].position() + + snake.extend() + + assert len(snake.segments) == len(constants.STARTING_POSITIONS) + 1 + assert snake.segments[-1].position() == last_place + + +def test_extend_adds_a_light_green_square_and_not_a_head() -> None: + snake, _ = make_snake() + + snake.extend() + + new = snake.segments[-1] + assert isinstance(new, FakeSegment) + assert ("shape", (constants.SEGMENT_SHAPE,)) in new.calls + assert ("color", (constants.BODY_COLOR,)) in new.calls + assert ("shape", (constants.HEAD_SHAPE,)) not in new.calls + + +def test_extend_twice_adds_two_segments() -> None: + snake, _ = make_snake() + + snake.extend() + snake.extend() + + assert len(snake.segments) == len(constants.STARTING_POSITIONS) + 2 + + +def test_head_stays_the_first_segment_after_extend() -> None: + snake, created = make_snake() + + snake.extend() + + assert snake.head is created[0] + assert snake.segments[0] is created[0] + + +def test_the_new_segment_follows_the_snake_and_the_body_stays_joined_when_turning() -> ( + None +): + snake, _ = make_snake() + snake.up() + snake.move() + snake.extend() + + snake.move() + + places = positions(snake.segments) + gaps = [ + math.dist(places[index], places[index + 1]) for index in range(len(places) - 1) + ] + assert gaps == [constants.MOVE_DISTANCE] * (len(places) - 1) + + +@pytest.mark.parametrize( + "place", [(281, 0), (-281, 0), (0, 281), (0, -281), (300, 300), (0, 1000)] +) +def test_the_head_has_passed_the_wall_beyond_the_limit_on_any_side( + place: tuple[int, int], +) -> None: + snake, _ = make_snake() + snake.head.goto(*place) + + assert snake.hits_wall() + + +@pytest.mark.parametrize( + "place", [(0, 0), (280, 0), (-280, 0), (0, 280), (0, -280), (280, -280)] +) +def test_the_head_has_not_passed_the_wall_at_or_inside_the_limit( + place: tuple[int, int], +) -> None: + snake, _ = make_snake() + snake.head.goto(*place) + + assert not snake.hits_wall() + + +@pytest.mark.parametrize("gap", [0, 5, 9]) +def test_the_head_touches_the_tail_closer_than_the_touch_distance(gap: int) -> None: + snake, created = make_snake() + created[1].goto(gap, 0) + + assert snake.hits_tail() + + +@pytest.mark.parametrize("gap", [10, 11, 20, 100]) +def test_the_head_does_not_touch_the_tail_at_or_beyond_the_touch_distance( + gap: int, +) -> None: + snake, created = make_snake() + created[1].goto(gap, 0) + + assert not snake.hits_tail() + + +def test_the_head_touches_any_segment_of_a_long_tail() -> None: + snake, created = make_snake() + for index in range(3, 8): + extra = FakeSegment() + extra.goto(-20 * index, 0) + snake.segments.append(extra) + created.append(extra) + created[6].goto(3, 4) # a distance of 5 from the head, far down the tail + + assert snake.hits_tail() + + +def test_a_new_snake_does_not_touch_its_tail() -> None: + snake, _ = make_snake() + + assert not snake.hits_tail() + + +def test_a_snake_with_only_a_head_has_no_tail_to_touch() -> None: + snake, created = make_snake() + snake.segments[:] = [created[0]] + created[0].goto(0, 0) + + assert not snake.hits_tail() + + +def test_the_new_segment_after_growing_does_not_touch_the_head() -> None: + snake, _ = make_snake() + snake.extend() + snake.move() + + assert not snake.hits_tail() + + +def test_the_snake_hits_its_tail_when_it_turns_into_it() -> None: + snake, created = make_snake() + for index in range(3, 5): # a snake of five segments + extra = FakeSegment() + extra.goto(-20 * index, 0) + snake.segments.append(extra) + created.append(extra) + # Up, left and down, one move each, walk the head round a square of 20 by 20 + # pixels and back onto the place where the fifth segment is. + for turn in (snake.up, snake.left, snake.down): + turn() + snake.move() + + assert snake.hits_tail() + + +def test_a_snake_of_four_segments_cannot_touch_its_tail_in_a_small_square() -> None: + snake, created = make_snake() + extra = FakeSegment() + extra.goto(-60, 0) + snake.segments.append(extra) + created.append(extra) + for turn in (snake.up, snake.left, snake.down): + turn() + snake.move() + + assert not snake.hits_tail() + + +def test_reset_sends_every_old_segment_off_the_screen() -> None: + snake, created = make_snake() + snake.extend() + old = list(created) + + snake.reset() + + assert len(old) == len(constants.STARTING_POSITIONS) + 1 + assert all( + segment.position() == constants.SEGMENT_PARKING_POSITION for segment in old + ) + + +def test_reset_makes_the_snake_again_at_the_starting_positions() -> None: + snake, created = make_snake() + snake.extend() + snake.extend() + old_count = len(created) + + snake.reset() + + assert len(snake.segments) == len(constants.STARTING_POSITIONS) + assert positions(snake.segments) == [ + (float(x), float(y)) for x, y in constants.STARTING_POSITIONS + ] + assert snake.segments == created[old_count:] + + +def test_reset_makes_the_first_new_segment_the_head() -> None: + snake, created = make_snake() + old_head = snake.head + + snake.reset() + + new_head = created[len(constants.STARTING_POSITIONS)] + assert snake.head is snake.segments[0] is new_head + assert snake.head is not old_head + assert ("shape", (constants.HEAD_SHAPE,)) in new_head.calls + + +def test_reset_snake_moves_right_from_the_starting_positions() -> None: + snake, _ = make_snake_moving(constants.UP) + + snake.reset() + snake.move() + + assert snake.head.position() == (constants.MOVE_DISTANCE, 0) + + +def test_reset_forgets_the_direction_of_the_last_move() -> None: + snake, _ = make_snake_moving(constants.DOWN) + + snake.reset() + snake.up() # allowed again: the new snake moves right, not down + + assert snake.head.heading() == constants.UP From bfbb58ba8cd9ea6218b44e245484f57e4c7b7c82 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Tue, 13 Oct 2026 14:33:58 +0800 Subject: [PATCH 4/7] 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() From b38936d5ea10f88d56c9f4a11ccd9384b211e913 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Tue, 13 Oct 2026 14:33:59 +0800 Subject: [PATCH 5/7] Update the README for continuous play Refs #21 Task: MIL-003#6 --- README.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fd62787..1679a09 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,12 @@ This repository continues and starts from its finished game; `docs/project-plan.md` tells how the work is split into milestones. -> **Status:** in progress. The finished game of day 21 is adopted and runs: the -> snake moves, eats and grows, the score is shown and the game ends at the wall -> or at the tail. Continuous play and the high score (milestone 3), the high -> score file (milestone 4) and the wiki (milestone 5) follow. The sections below -> describe the game as it is now; they are updated in each milestone. +> **Status:** in progress. The game of day 21 is adopted and now follows the +> day-24 lectures on continuous play: the scoreboard shows the score and the high +> score, and a collision resets the snake and the score instead of ending the +> game. The high score is kept in memory only; the high score file (milestone 4) +> and the wiki (milestone 5) follow. The sections below describe the game as it +> is now; they are updated in each milestone. ## Requirements @@ -104,11 +105,16 @@ closer to it than 15 pixels the snake eats it: the food moves to a new random place, the snake grows by one segment, and the score at the top of the window goes up by 1. -The game is over when the head passes the wall (more than 280 pixels from the -centre on any side) or touches the tail. The snake stops, `GAME OVER` appears in -the middle of the window, and a click on the window closes it. You can also close -the window with its close button at any time. (Milestone 3 replaces this with -continuous play and a high score.) +The scoreboard at the top of the window reads `Score: 0 High Score: 0`. The score +counts the foods eaten since the last reset; the high score is the best score so +far. + +The game does not end. When the head passes the wall (more than 280 pixels from +the centre on any side) or touches the tail, the old snake is sent off the +screen, a new snake of three segments appears in the middle, the score goes back +to 0, and the high score is raised if the score was higher. Close the window with +its close button when you have had enough. The high score is lost when the game +is closed until milestone 4 stores it in a file. ## Run the tests From 8c8198827bfc69c75a4f8ff8a393376d073e94c1 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Tue, 13 Oct 2026 14:34:00 +0800 Subject: [PATCH 6/7] Record the review of the MIL-003 code (RC-011) --- docs/artifact-registry.md | 2 +- docs/sqa/reviews/rc-011-mil-003-code.md | 59 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 docs/sqa/reviews/rc-011-mil-003-code.md diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index c0e749e..eeb680f 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g. | PP | Project Plan | docs/project-plan.md | 002 | | MIL | Milestone / Gateway | docs/milestones/*.md | 006 | | DICT | Domain Dictionary | docs/dictionary.md | 002 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 011 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 012 | ## Languages diff --git a/docs/sqa/reviews/rc-011-mil-003-code.md b/docs/sqa/reviews/rc-011-mil-003-code.md new file mode 100644 index 0000000..dd58c05 --- /dev/null +++ b/docs/sqa/reviews/rc-011-mil-003-code.md @@ -0,0 +1,59 @@ +# RC-011: Code review of MIL-003 (continuous play and high score) + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-011 | +| CrossReference | [MIL-003], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-13 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: the source and tests that [MIL-003] delivers (continuous play and high score) +- Checklist used: [QC-PY-001] +- Scope: full review of the files changed on the branch of the milestone +- Language and domain: n/a +- Language reviewer: none +- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`; the assistant also wrote the code) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | `Snake.reset`, `Scoreboard.reset`, `reset_if_collision`, `high_score` and `SEGMENT_PARKING_POSITION` follow PEP 8; `reset` and `high_score` are the lecture's names. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose; `HIGH_SCORE_LABEL` and `SEGMENT_PARKING_POSITION` say what they hold. | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` exit 0; no `noqa`. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy` strict reports no issues in 13 files; every new def is annotated with `-> None` or `-> bool`. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No exception handling was added; the one handler in `main.main` is unchanged. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default argument; no shadowed builtin. | +| 7 | Files, locks and connections are managed with context managers | N-A | No file is opened in this milestone (MIL-004). | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | `reset`, `reset_if_collision` and the new constants have Doxygen comments; `doxygen Doxyfile` ends with 0 warnings. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print` and no logging. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No Design Class Diagram exists (deviation recorded in RC-010 and the pull request); the change adds `reset` to the base classes as the lecture does. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 149 tests run without a display; new tests cover higher, equal and lower score on `reset`, the parking of old segments, the new head, the forgotten direction, and `main` going on after the wall and the tail. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `strict = true`; `ScoreboardLike` stays a narrow protocol (`increase_score`, `reset`); no `Any`. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies = []` is unchanged. | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The label, the parking place and the other values are defined once in `constants.py`; `GAME_OVER_*` is removed, not duplicated. | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `snake.py` and `main.py` still import no `turtle` or `tkinter` at module level (tested); no import cycle. | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | `Snake.reset` reuses `create_snake`, so the starting positions are one piece of knowledge; `main` depends on the `ScoreboardLike` protocol. | + +## Overall Verdict + +Go — all Mandatory criteria pass or are not applicable. Assessed on 2026-10-13 by the assistant that wrote the code, as stand-in for S01 in autonomous mode. Mode: autonomous, not independent. The pull request is the second look. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| Challenge this `Go` in the run summary or on the pull request if the stand-in decision is not wanted | S01 | 2026-10-20 | + +--- + +[MIL-003]: ../../milestones/mil-003-continuous-play-and-high-score.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md From 12a64d491346952d16140e96a9649e1780949d75 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Tue, 13 Oct 2026 14:34:02 +0800 Subject: [PATCH 7/7] Resolve pending commit links --- docs/sqa/reviews/rc-011-mil-003-code.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sqa/reviews/rc-011-mil-003-code.md b/docs/sqa/reviews/rc-011-mil-003-code.md index dd58c05..f8445e8 100644 --- a/docs/sqa/reviews/rc-011-mil-003-code.md +++ b/docs/sqa/reviews/rc-011-mil-003-code.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-13 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | +| 2026-10-13 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [8c81988] | --- @@ -57,3 +57,4 @@ Go — all Mandatory criteria pass or are not applicable. Assessed on 2026-10-13 [MIL-003]: ../../milestones/mil-003-continuous-play-and-high-score.md [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[8c81988]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/024-snake/commit/8c8198827bfc69c75a4f8ff8a393376d073e94c1