From 2328490945489c4fe315d6b89a68e046482b5952 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 7 Oct 2026 23:59:12 +0800 Subject: [PATCH] Add draw_spirograph with tests Draws int(360 / size_of_gap) circles, each in a random RGB color, turning the heading by the gap after each. A gap that is not positive raises ValueError, and the integer count avoids the TypeError that range raises for a float. Task: MIL-004#2 Task: MIL-004#3 --- src/turtle_challenges/__init__.py | 9 ++- src/turtle_challenges/constants.py | 6 ++ src/turtle_challenges/spirograph.py | 47 ++++++++++++ tests/test_spirograph.py | 111 ++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 src/turtle_challenges/spirograph.py create mode 100644 tests/test_spirograph.py diff --git a/src/turtle_challenges/__init__.py b/src/turtle_challenges/__init__.py index ef4ce9e..c335f70 100644 --- a/src/turtle_challenges/__init__.py +++ b/src/turtle_challenges/__init__.py @@ -3,10 +3,11 @@ @brief Solutions to the turtle graphics challenges of day 18 of Udemy's 100 Days of Code. -The challenges so far are @ref turtle_challenges.square.draw_square, +The five challenges are @ref turtle_challenges.square.draw_square, @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 +@ref turtle_challenges.shapes.draw_shape, +@ref turtle_challenges.random_walk.random_walk and +@ref turtle_challenges.spirograph.draw_spirograph. Each takes the turtle as its first argument, so it can be tested without a window. """ @@ -14,12 +15,14 @@ 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.spirograph import draw_spirograph from turtle_challenges.square import draw_square __all__ = [ "draw_dashed_line", "draw_shape", "draw_shapes", + "draw_spirograph", "draw_square", "random_color", "random_palette_color", diff --git a/src/turtle_challenges/constants.py b/src/turtle_challenges/constants.py index b497e24..0aa941e 100644 --- a/src/turtle_challenges/constants.py +++ b/src/turtle_challenges/constants.py @@ -77,6 +77,12 @@ 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) +## Challenge 5: radius of every circle of the spirograph, in turtle units. +SPIROGRAPH_RADIUS = 100 + +## Challenge 5: degrees the heading turns after each circle. +SPIROGRAPH_GAP_DEGREES = 5 + ## Command line name of challenge 1. CHALLENGE_SQUARE = "square" diff --git a/src/turtle_challenges/spirograph.py b/src/turtle_challenges/spirograph.py new file mode 100644 index 0000000..ccc7b8d --- /dev/null +++ b/src/turtle_challenges/spirograph.py @@ -0,0 +1,47 @@ +"""! +@file spirograph.py +@brief Turtle Challenge 5: draw a spirograph. +""" + +import random + +from turtle_challenges.colors import random_color +from turtle_challenges.constants import ( + FASTEST_SPEED, + FULL_TURN_DEGREES, + SPIROGRAPH_GAP_DEGREES, + SPIROGRAPH_RADIUS, +) +from turtle_challenges.pen import Pen + + +def draw_spirograph( + pen: Pen, + size_of_gap: float = SPIROGRAPH_GAP_DEGREES, + radius: float = SPIROGRAPH_RADIUS, + rng: random.Random | None = None, +) -> None: + """! + @brief Draw overlapping circles, turning a little after each one. + + After every circle the heading grows by @p size_of_gap, so the circles + overlap into a spirograph once the heading has made a full turn. The number + of circles is 360 divided by the gap, rounded down: `range` takes only an + integer, and a float such as 72.0 would raise a `TypeError`. + + @param pen The turtle that draws. + @param size_of_gap Degrees between two circles; must be positive. + @param radius Radius of every circle, in turtle units. + @param rng Random generator for the colors; a shared one is used if + omitted. + @exception ValueError If @p size_of_gap or @p radius is not positive. + """ + if size_of_gap <= 0: + raise ValueError(f"size_of_gap must be positive, got {size_of_gap}") + if radius <= 0: + raise ValueError(f"radius must be positive, got {radius}") + pen.speed(FASTEST_SPEED) + for _ in range(int(FULL_TURN_DEGREES / size_of_gap)): + pen.color(random_color(rng)) + pen.circle(radius) + pen.setheading(pen.heading() + size_of_gap) diff --git a/tests/test_spirograph.py b/tests/test_spirograph.py new file mode 100644 index 0000000..bb9e632 --- /dev/null +++ b/tests/test_spirograph.py @@ -0,0 +1,111 @@ +import random + +import pytest + +from tests.fakes import FakePen +from turtle_challenges.constants import ( + FASTEST_SPEED, + FULL_TURN_DEGREES, + SPIROGRAPH_GAP_DEGREES, + SPIROGRAPH_RADIUS, +) +from turtle_challenges.spirograph import draw_spirograph + + +def test_draw_spirograph_draws_one_circle_per_gap_in_a_full_turn() -> None: + pen = FakePen() + + draw_spirograph(pen, rng=random.Random(1)) + + expected = FULL_TURN_DEGREES // SPIROGRAPH_GAP_DEGREES + assert pen.args_of("circle") == [(SPIROGRAPH_RADIUS,)] * expected + + +def test_draw_spirograph_turns_by_the_gap_after_each_circle() -> None: + pen = FakePen() + + draw_spirograph(pen, size_of_gap=90, rng=random.Random(2)) + + assert pen.args_of("setheading") == [(90,), (180,), (270,), (360,)] + + +def test_draw_spirograph_sets_a_color_before_each_circle() -> None: + pen = FakePen() + + draw_spirograph(pen, size_of_gap=120, rng=random.Random(3)) + + assert pen.names()[1:] == ["color", "circle", "setheading"] * 3 + + +def test_draw_spirograph_gives_every_circle_an_rgb_color() -> None: + pen = FakePen() + + draw_spirograph(pen, size_of_gap=30, rng=random.Random(4)) + + for (color,) in pen.args_of("color"): + assert isinstance(color, tuple) + assert len(color) == 3 + + +def test_draw_spirograph_sets_the_fastest_speed_first() -> None: + pen = FakePen() + + draw_spirograph(pen, size_of_gap=180) + + assert pen.calls[0] == ("speed", (FASTEST_SPEED,)) + + +def test_draw_spirograph_rounds_the_circle_count_down_for_an_uneven_gap() -> None: + pen = FakePen() + + draw_spirograph(pen, size_of_gap=7) + + assert len(pen.args_of("circle")) == 51 + + +def test_draw_spirograph_accepts_a_float_gap_without_a_type_error() -> None: + pen = FakePen() + + draw_spirograph(pen, size_of_gap=2.5) + + assert len(pen.args_of("circle")) == 144 + + +def test_draw_spirograph_uses_the_given_radius() -> None: + pen = FakePen() + + draw_spirograph(pen, size_of_gap=180, radius=42) + + assert pen.args_of("circle") == [(42,), (42,)] + + +def test_draw_spirograph_is_repeatable_with_the_same_seed() -> None: + first = FakePen() + second = FakePen() + + draw_spirograph(first, size_of_gap=45, rng=random.Random(9)) + draw_spirograph(second, size_of_gap=45, rng=random.Random(9)) + + assert first.calls == second.calls + + +@pytest.mark.parametrize("size_of_gap", [0, -5]) +def test_draw_spirograph_rejects_a_gap_that_is_not_positive( + size_of_gap: float, +) -> None: + pen = FakePen() + + with pytest.raises(ValueError, match="size_of_gap"): + draw_spirograph(pen, size_of_gap=size_of_gap) + + assert pen.calls == [] + + +@pytest.mark.parametrize("radius", [0, -1]) +def test_draw_spirograph_rejects_a_radius_that_is_not_positive(radius: float) -> None: + pen = FakePen() + + with pytest.raises(ValueError, match="radius"): + draw_spirograph(pen, radius=radius) + + assert pen.calls == []