From 73fdc2026fae6b6f6ef789649cc7f06ee8037a49 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 8 Oct 2026 18:31:03 +0800 Subject: [PATCH] Adopt the Snake class and its tests Copied unchanged from the base: create_snake, add_segment, extend, move, hits_wall, hits_tail, up, down, left and right, with the segments list and the head. The tail check loops over the slice segments[1:]. snake.py does not import turtle until a real segment is made. Task: MIL-002#2 Closes #9 --- src/snake_game/snake.py | 239 +++++++++++++++++++++++++ tests/test_snake.py | 386 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 625 insertions(+) create mode 100644 src/snake_game/snake.py create mode 100644 tests/test_snake.py diff --git a/src/snake_game/snake.py b/src/snake_game/snake.py new file mode 100644 index 0000000..da16def --- /dev/null +++ b/src/snake_game/snake.py @@ -0,0 +1,239 @@ +"""! @file +@brief The snake: a list of square 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 ( + DOWN, + LEFT, + MOVE_DISTANCE, + RIGHT, + SEGMENT_COLOR, + 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 `white`. + @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, drawn as a row of square segments. + + 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 white square segment at each starting position. + + 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 white square segment at the end of the snake. + + 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() + new_segment.shape(SEGMENT_SHAPE) + new_segment.color(SEGMENT_COLOR) + 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) diff --git a/tests/test_snake.py b/tests/test_snake.py new file mode 100644 index 0000000..8b8f86b --- /dev/null +++ b/tests/test_snake.py @@ -0,0 +1,386 @@ +"""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_segments_are_white_squares() -> None: + _, created = make_snake() + + for segment in created: + assert ("shape", ("square",)) in segment.calls + assert ("color", ("white",)) 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_white_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", ("square",)) in new.calls + assert ("color", ("white",)) 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_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()