diff --git a/src/turtle_challenges/__init__.py b/src/turtle_challenges/__init__.py index 691b972..ef4ce9e 100644 --- a/src/turtle_challenges/__init__.py +++ b/src/turtle_challenges/__init__.py @@ -4,13 +4,15 @@ 100 Days of Code. The challenges so far are @ref turtle_challenges.square.draw_square, -@ref turtle_challenges.dashed_line.draw_dashed_line and -@ref turtle_challenges.shapes.draw_shape. Each takes the turtle as its +@ref turtle_challenges.dashed_line.draw_dashed_line, +@ref turtle_challenges.shapes.draw_shape and +@ref turtle_challenges.random_walk.random_walk. Each takes the turtle as its first argument, so it can be tested without a window. """ from turtle_challenges.colors import random_color, random_palette_color from turtle_challenges.dashed_line import draw_dashed_line +from turtle_challenges.random_walk import random_walk from turtle_challenges.shapes import draw_shape, draw_shapes from turtle_challenges.square import draw_square @@ -21,4 +23,5 @@ __all__ = [ "draw_square", "random_color", "random_palette_color", + "random_walk", ] diff --git a/src/turtle_challenges/constants.py b/src/turtle_challenges/constants.py index ba9fd58..b497e24 100644 --- a/src/turtle_challenges/constants.py +++ b/src/turtle_challenges/constants.py @@ -26,6 +26,9 @@ FULL_TURN_DEGREES = 360 ## Degrees in a right angle. RIGHT_ANGLE_DEGREES = 90 +## Turtle speed setting for "fastest": no animation between moves. +FASTEST_SPEED = 0 + ## Challenge 1: number of sides of a square. SQUARE_SIDES = 4 @@ -62,6 +65,18 @@ COLOR_PALETTE: tuple[str, ...] = ( "SeaGreen", ) +## Challenge 4: number of steps of the random walk. +WALK_STEPS = 200 + +## Challenge 4: distance of one step, in turtle units. +WALK_STEP_DISTANCE = 30 + +## Challenge 4: thickness of the line, in pixels. +WALK_PEN_SIZE = 10 + +## Challenge 4: headings of a step: east, north, west and south, in degrees. +WALK_HEADINGS: tuple[int, ...] = (0, 90, 180, 270) + ## Command line name of challenge 1. CHALLENGE_SQUARE = "square" diff --git a/src/turtle_challenges/random_walk.py b/src/turtle_challenges/random_walk.py new file mode 100644 index 0000000..b80e87a --- /dev/null +++ b/src/turtle_challenges/random_walk.py @@ -0,0 +1,49 @@ +"""! +@file random_walk.py +@brief Turtle Challenge 4: generate a random walk. +""" + +import random + +from turtle_challenges.colors import random_color +from turtle_challenges.constants import ( + FASTEST_SPEED, + WALK_HEADINGS, + WALK_PEN_SIZE, + WALK_STEP_DISTANCE, + WALK_STEPS, +) +from turtle_challenges.pen import Pen + + +def random_walk( + pen: Pen, + steps: int = WALK_STEPS, + distance: float = WALK_STEP_DISTANCE, + rng: random.Random | None = None, +) -> None: + """! + @brief Walk in random directions, changing color at every step. + + Each step faces east, north, west or south at random and moves the same + distance in a new random RGB color. + + @param pen The turtle that draws. + @param steps Number of steps to take. + @param distance Distance of one step, in turtle units. + @param rng Random generator for headings and colors; a shared one is used + if omitted. + @exception ValueError If @p steps is negative, or @p distance is not + positive. + """ + if steps < 0: + raise ValueError(f"steps must not be negative, got {steps}") + if distance <= 0: + raise ValueError(f"distance must be positive, got {distance}") + generator = rng if rng is not None else random.Random() + pen.pensize(WALK_PEN_SIZE) + pen.speed(FASTEST_SPEED) + for _ in range(steps): + pen.color(random_color(generator)) + pen.setheading(generator.choice(WALK_HEADINGS)) + pen.forward(distance) diff --git a/tests/test_constants.py b/tests/test_constants.py index b14bbf9..7d8e311 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -9,6 +9,10 @@ def test_challenge_names_are_unique() -> None: assert len(set(constants.CHALLENGE_NAMES)) == len(constants.CHALLENGE_NAMES) +def test_walk_headings_are_the_four_compass_directions() -> None: + assert constants.WALK_HEADINGS == (0, 90, 180, 270) + + def test_palette_has_only_non_empty_names() -> None: assert constants.COLOR_PALETTE assert all(name for name in constants.COLOR_PALETTE) diff --git a/tests/test_random_walk.py b/tests/test_random_walk.py new file mode 100644 index 0000000..53e220f --- /dev/null +++ b/tests/test_random_walk.py @@ -0,0 +1,110 @@ +import random + +import pytest + +from tests.fakes import FakePen +from turtle_challenges.constants import ( + COLOR_CHANNEL_MAX, + COLOR_CHANNEL_MIN, + FASTEST_SPEED, + WALK_HEADINGS, + WALK_PEN_SIZE, + WALK_STEP_DISTANCE, + WALK_STEPS, +) +from turtle_challenges.random_walk import random_walk + + +def test_random_walk_sets_pen_size_and_speed_before_the_first_move() -> None: + pen = FakePen() + + random_walk(pen, rng=random.Random(1)) + + assert pen.calls[0] == ("pensize", (WALK_PEN_SIZE,)) + assert pen.calls[1] == ("speed", (FASTEST_SPEED,)) + + +def test_random_walk_takes_the_default_number_of_equal_steps() -> None: + pen = FakePen() + + random_walk(pen, rng=random.Random(2)) + + assert pen.args_of("forward") == [(WALK_STEP_DISTANCE,)] * WALK_STEPS + + +def test_random_walk_faces_only_the_four_headings() -> None: + pen = FakePen() + + random_walk(pen, rng=random.Random(3)) + + headings = {args[0] for args in pen.args_of("setheading")} + assert headings <= set(WALK_HEADINGS) + assert len(headings) > 1 + + +def test_random_walk_gives_every_step_an_rgb_color() -> None: + pen = FakePen() + + random_walk(pen, steps=30, rng=random.Random(4)) + + colors = [args[0] for args in pen.args_of("color")] + assert len(colors) == 30 + for color in colors: + assert isinstance(color, tuple) + assert len(color) == 3 + assert all( + COLOR_CHANNEL_MIN <= channel <= COLOR_CHANNEL_MAX for channel in color + ) + + +def test_random_walk_picks_color_and_heading_before_each_move() -> None: + pen = FakePen() + + random_walk(pen, steps=2, rng=random.Random(5)) + + assert pen.names()[2:] == ["color", "setheading", "forward"] * 2 + + +def test_random_walk_is_repeatable_with_the_same_seed() -> None: + first = FakePen() + second = FakePen() + + random_walk(first, steps=25, rng=random.Random(8)) + random_walk(second, steps=25, rng=random.Random(8)) + + assert first.calls == second.calls + + +def test_random_walk_with_no_steps_only_sets_up_the_pen() -> None: + pen = FakePen() + + random_walk(pen, steps=0) + + assert pen.names() == ["pensize", "speed"] + + +def test_random_walk_works_without_a_generator() -> None: + pen = FakePen() + + random_walk(pen, steps=3) + + assert len(pen.args_of("forward")) == 3 + + +def test_random_walk_rejects_negative_steps() -> None: + pen = FakePen() + + with pytest.raises(ValueError, match="steps"): + random_walk(pen, steps=-1) + + assert pen.calls == [] + + +@pytest.mark.parametrize("distance", [0, -3]) +def test_random_walk_rejects_a_distance_that_is_not_positive(distance: float) -> None: + pen = FakePen() + + with pytest.raises(ValueError, match="distance"): + random_walk(pen, distance=distance) + + assert pen.calls == []