Land milestones 002 to 004 on main #34

Merged
Tirsvad merged 22 commits from mil-004-random-walk-and-spirograph into main 2026-10-07 18:47:25 +02:00
5 changed files with 183 additions and 2 deletions
Showing only changes of commit 949d2edb63 - Show all commits
+5 -2
View File
@@ -4,13 +4,15 @@
100 Days of Code. 100 Days of Code.
The challenges so far are @ref turtle_challenges.square.draw_square, The challenges so far are @ref turtle_challenges.square.draw_square,
@ref turtle_challenges.dashed_line.draw_dashed_line and @ref turtle_challenges.dashed_line.draw_dashed_line,
@ref turtle_challenges.shapes.draw_shape. Each takes the turtle as its @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. first argument, so it can be tested without a window.
""" """
from turtle_challenges.colors import random_color, random_palette_color from turtle_challenges.colors import random_color, random_palette_color
from turtle_challenges.dashed_line import draw_dashed_line 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.shapes import draw_shape, draw_shapes
from turtle_challenges.square import draw_square from turtle_challenges.square import draw_square
@@ -21,4 +23,5 @@ __all__ = [
"draw_square", "draw_square",
"random_color", "random_color",
"random_palette_color", "random_palette_color",
"random_walk",
] ]
+15
View File
@@ -26,6 +26,9 @@ FULL_TURN_DEGREES = 360
## Degrees in a right angle. ## Degrees in a right angle.
RIGHT_ANGLE_DEGREES = 90 RIGHT_ANGLE_DEGREES = 90
## Turtle speed setting for "fastest": no animation between moves.
FASTEST_SPEED = 0
## Challenge 1: number of sides of a square. ## Challenge 1: number of sides of a square.
SQUARE_SIDES = 4 SQUARE_SIDES = 4
@@ -62,6 +65,18 @@ COLOR_PALETTE: tuple[str, ...] = (
"SeaGreen", "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. ## Command line name of challenge 1.
CHALLENGE_SQUARE = "square" CHALLENGE_SQUARE = "square"
+49
View File
@@ -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)
+4
View File
@@ -9,6 +9,10 @@ def test_challenge_names_are_unique() -> None:
assert len(set(constants.CHALLENGE_NAMES)) == len(constants.CHALLENGE_NAMES) 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: def test_palette_has_only_non_empty_names() -> None:
assert constants.COLOR_PALETTE assert constants.COLOR_PALETTE
assert all(name for name in constants.COLOR_PALETTE) assert all(name for name in constants.COLOR_PALETTE)
+110
View File
@@ -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 == []