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
This commit is contained in:
2026-10-08 18:31:03 +08:00
parent 5575658503
commit 73fdc2026f
2 changed files with 625 additions and 0 deletions
+239
View File
@@ -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)
+386
View File
@@ -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()