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