From 9318a07fa0098ecc2c50031d95ee0257f61d91bb Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 7 Oct 2026 23:59:02 +0800 Subject: [PATCH 1/3] Add random_color and the color palette random_color returns an immutable (red, green, blue) tuple of integers from 0 to 255 and random_palette_color picks a named color. Both take an optional random.Random so that tests can seed them. Task: MIL-003#1 Task: MIL-003#2 --- src/turtle_challenges/__init__.py | 3 ++ src/turtle_challenges/colors.py | 44 +++++++++++++++++++++++++ src/turtle_challenges/constants.py | 18 ++++++++++ tests/test_colors.py | 53 ++++++++++++++++++++++++++++++ tests/test_constants.py | 9 +++++ 5 files changed, 127 insertions(+) create mode 100644 src/turtle_challenges/colors.py create mode 100644 tests/test_colors.py diff --git a/src/turtle_challenges/__init__.py b/src/turtle_challenges/__init__.py index af20d82..92a4f74 100644 --- a/src/turtle_challenges/__init__.py +++ b/src/turtle_challenges/__init__.py @@ -8,10 +8,13 @@ The challenges so far are @ref turtle_challenges.square.draw_square and 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.square import draw_square __all__ = [ "draw_dashed_line", "draw_square", + "random_color", + "random_palette_color", ] diff --git a/src/turtle_challenges/colors.py b/src/turtle_challenges/colors.py new file mode 100644 index 0000000..40f313d --- /dev/null +++ b/src/turtle_challenges/colors.py @@ -0,0 +1,44 @@ +"""! +@file colors.py +@brief Random colors for the challenges. + +A color is either a name from the palette (challenge 3) or an RGB tuple +(challenges 4 and 5). A tuple is the right type for an RGB value: it is +immutable, so a color cannot change after it is made. +""" + +import random + +from turtle_challenges.constants import ( + COLOR_CHANNEL_MAX, + COLOR_CHANNEL_MIN, + COLOR_PALETTE, +) + +## Generator used when a caller passes none; tests pass a seeded one instead. +_DEFAULT_RANDOM = random.Random() + + +def random_color(rng: random.Random | None = None) -> tuple[int, int, int]: + """! + @brief Pick a random RGB color. + + @param rng Random generator to draw from; a shared one is used if omitted. + @return A tuple of red, green and blue, each from 0 to 255. + """ + generator = rng if rng is not None else _DEFAULT_RANDOM + red = generator.randint(COLOR_CHANNEL_MIN, COLOR_CHANNEL_MAX) + green = generator.randint(COLOR_CHANNEL_MIN, COLOR_CHANNEL_MAX) + blue = generator.randint(COLOR_CHANNEL_MIN, COLOR_CHANNEL_MAX) + return (red, green, blue) + + +def random_palette_color(rng: random.Random | None = None) -> str: + """! + @brief Pick a random color name from the palette. + + @param rng Random generator to draw from; a shared one is used if omitted. + @return One of the names in `COLOR_PALETTE`. + """ + generator = rng if rng is not None else _DEFAULT_RANDOM + return generator.choice(COLOR_PALETTE) diff --git a/src/turtle_challenges/constants.py b/src/turtle_challenges/constants.py index 0c5ca52..8820e51 100644 --- a/src/turtle_challenges/constants.py +++ b/src/turtle_challenges/constants.py @@ -14,6 +14,12 @@ WINDOW_TITLE = "Turtle Challenges" ## colors can be given as RGB tuples from 0 to 255. COLOR_MODE = 255 +## Smallest value of one color channel. +COLOR_CHANNEL_MIN = 0 + +## Largest value of one color channel. +COLOR_CHANNEL_MAX = COLOR_MODE + ## Degrees in a right angle. RIGHT_ANGLE_DEGREES = 90 @@ -32,6 +38,18 @@ DASH_LENGTH = 10 ## Challenge 2: length of the gap after each dash, in turtle units. GAP_LENGTH = 10 +## Challenge 3: named colors a polygon is drawn in. +COLOR_PALETTE: tuple[str, ...] = ( + "CornflowerBlue", + "DarkOrchid", + "IndianRed", + "DeepSkyBlue", + "LightSeaGreen", + "wheat", + "SlateGray", + "SeaGreen", +) + ## Command line name of challenge 1. CHALLENGE_SQUARE = "square" diff --git a/tests/test_colors.py b/tests/test_colors.py new file mode 100644 index 0000000..af1be90 --- /dev/null +++ b/tests/test_colors.py @@ -0,0 +1,53 @@ +import random + +from turtle_challenges.colors import random_color, random_palette_color +from turtle_challenges.constants import ( + COLOR_CHANNEL_MAX, + COLOR_CHANNEL_MIN, + COLOR_PALETTE, +) + + +def test_random_color_is_a_tuple_of_three_integers() -> None: + color = random_color(random.Random(1)) + + assert isinstance(color, tuple) + assert len(color) == 3 + assert all(isinstance(channel, int) for channel in color) + + +def test_random_color_channels_stay_in_range() -> None: + rng = random.Random(2) + + for _ in range(500): + assert all( + COLOR_CHANNEL_MIN <= channel <= COLOR_CHANNEL_MAX + for channel in random_color(rng) + ) + + +def test_random_color_is_repeatable_with_the_same_seed() -> None: + first = [random_color(random.Random(7)) for _ in range(3)] + second = [random_color(random.Random(7)) for _ in range(3)] + + assert first == second + + +def test_random_color_varies_between_calls() -> None: + rng = random.Random(3) + + assert len({random_color(rng) for _ in range(20)}) > 1 + + +def test_random_color_works_without_a_generator() -> None: + assert len(random_color()) == 3 + + +def test_random_palette_color_comes_from_the_palette() -> None: + rng = random.Random(4) + + assert {random_palette_color(rng) for _ in range(100)} <= set(COLOR_PALETTE) + + +def test_random_palette_color_works_without_a_generator() -> None: + assert random_palette_color() in COLOR_PALETTE diff --git a/tests/test_constants.py b/tests/test_constants.py index ac3dd94..20e82d6 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -1,5 +1,14 @@ from turtle_challenges import constants +def test_color_mode_matches_the_largest_channel_value() -> None: + assert constants.COLOR_MODE == constants.COLOR_CHANNEL_MAX == 255 + + def test_challenge_names_are_unique() -> None: assert len(set(constants.CHALLENGE_NAMES)) == len(constants.CHALLENGE_NAMES) + + +def test_palette_has_only_non_empty_names() -> None: + assert constants.COLOR_PALETTE + assert all(name for name in constants.COLOR_PALETTE) -- 2.54.0 From 32861fc58fa1dca6b173a0a750953a7834dbdeef Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 7 Oct 2026 23:59:05 +0800 Subject: [PATCH 2/3] Add draw_shape and draw_shapes with tests draw_shape turns 360 divided by the number of sides after each side and rejects fewer than 3 sides. draw_shapes draws a triangle up to a decagon, each in a color from the palette. Task: MIL-003#3 Task: MIL-003#4 Task: MIL-003#5 --- src/turtle_challenges/__init__.py | 8 ++- src/turtle_challenges/constants.py | 12 ++++ src/turtle_challenges/shapes.py | 56 ++++++++++++++++++ tests/test_constants.py | 5 ++ tests/test_shapes.py | 94 ++++++++++++++++++++++++++++++ 5 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 src/turtle_challenges/shapes.py create mode 100644 tests/test_shapes.py diff --git a/src/turtle_challenges/__init__.py b/src/turtle_challenges/__init__.py index 92a4f74..691b972 100644 --- a/src/turtle_challenges/__init__.py +++ b/src/turtle_challenges/__init__.py @@ -3,17 +3,21 @@ @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 and -@ref turtle_challenges.dashed_line.draw_dashed_line. Each takes the turtle as its +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 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.shapes import draw_shape, draw_shapes from turtle_challenges.square import draw_square __all__ = [ "draw_dashed_line", + "draw_shape", + "draw_shapes", "draw_square", "random_color", "random_palette_color", diff --git a/src/turtle_challenges/constants.py b/src/turtle_challenges/constants.py index 8820e51..b756d5b 100644 --- a/src/turtle_challenges/constants.py +++ b/src/turtle_challenges/constants.py @@ -20,6 +20,9 @@ COLOR_CHANNEL_MIN = 0 ## Largest value of one color channel. COLOR_CHANNEL_MAX = COLOR_MODE +## Degrees in a full turn. +FULL_TURN_DEGREES = 360 + ## Degrees in a right angle. RIGHT_ANGLE_DEGREES = 90 @@ -38,6 +41,15 @@ DASH_LENGTH = 10 ## Challenge 2: length of the gap after each dash, in turtle units. GAP_LENGTH = 10 +## Challenge 3: fewest sides of a polygon (a triangle). +MIN_POLYGON_SIDES = 3 + +## Challenge 3: most sides of a polygon drawn in a row (a decagon). +MAX_POLYGON_SIDES = 10 + +## Challenge 3: length of one side of a polygon, in turtle units. +POLYGON_SIDE_LENGTH = 100 + ## Challenge 3: named colors a polygon is drawn in. COLOR_PALETTE: tuple[str, ...] = ( "CornflowerBlue", diff --git a/src/turtle_challenges/shapes.py b/src/turtle_challenges/shapes.py new file mode 100644 index 0000000..46a9d7b --- /dev/null +++ b/src/turtle_challenges/shapes.py @@ -0,0 +1,56 @@ +"""! +@file shapes.py +@brief Turtle Challenge 3: draw different shapes, each in a random color. +""" + +import random + +from turtle_challenges.colors import random_palette_color +from turtle_challenges.constants import ( + FULL_TURN_DEGREES, + MAX_POLYGON_SIDES, + MIN_POLYGON_SIDES, + POLYGON_SIDE_LENGTH, +) +from turtle_challenges.pen import Pen + + +def draw_shape( + pen: Pen, num_sides: int, side_length: float = POLYGON_SIDE_LENGTH +) -> None: + """! + @brief Draw a regular polygon. + + The turtle turns 360 divided by the number of sides after each side, so + five sides turn 72 degrees and the shape closes after one lap. + + @param pen The turtle that draws. + @param num_sides Number of sides; at least 3. + @param side_length Length of each side, in turtle units. + @exception ValueError If @p num_sides is below 3, or @p side_length is not + positive. + """ + if num_sides < MIN_POLYGON_SIDES: + raise ValueError( + f"num_sides must be at least {MIN_POLYGON_SIDES}, got {num_sides}" + ) + if side_length <= 0: + raise ValueError(f"side_length must be positive, got {side_length}") + angle = FULL_TURN_DEGREES / num_sides + for _ in range(num_sides): + pen.forward(side_length) + pen.right(angle) + + +def draw_shapes(pen: Pen, rng: random.Random | None = None) -> None: + """! + @brief Draw a triangle, square, pentagon and so on up to a decagon. + + Each shape gets a random color from the palette. + + @param pen The turtle that draws. + @param rng Random generator for the colors; a shared one is used if omitted. + """ + for num_sides in range(MIN_POLYGON_SIDES, MAX_POLYGON_SIDES + 1): + pen.color(random_palette_color(rng)) + draw_shape(pen, num_sides) diff --git a/tests/test_constants.py b/tests/test_constants.py index 20e82d6..b14bbf9 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -12,3 +12,8 @@ def test_challenge_names_are_unique() -> None: def test_palette_has_only_non_empty_names() -> None: assert constants.COLOR_PALETTE assert all(name for name in constants.COLOR_PALETTE) + + +def test_polygon_range_runs_from_triangle_to_decagon() -> None: + assert constants.MIN_POLYGON_SIDES == 3 + assert constants.MAX_POLYGON_SIDES == 10 diff --git a/tests/test_shapes.py b/tests/test_shapes.py new file mode 100644 index 0000000..c855ff0 --- /dev/null +++ b/tests/test_shapes.py @@ -0,0 +1,94 @@ +import random + +import pytest + +from tests.fakes import FakePen +from turtle_challenges.constants import ( + COLOR_PALETTE, + FULL_TURN_DEGREES, + MAX_POLYGON_SIDES, + MIN_POLYGON_SIDES, + POLYGON_SIDE_LENGTH, +) +from turtle_challenges.shapes import draw_shape, draw_shapes + +SIDE_COUNTS = range(MIN_POLYGON_SIDES, MAX_POLYGON_SIDES + 1) + + +@pytest.mark.parametrize("num_sides", SIDE_COUNTS) +def test_draw_shape_draws_every_side_at_the_default_length(num_sides: int) -> None: + pen = FakePen() + + draw_shape(pen, num_sides) + + assert pen.args_of("forward") == [(POLYGON_SIDE_LENGTH,)] * num_sides + + +@pytest.mark.parametrize("num_sides", SIDE_COUNTS) +def test_draw_shape_turns_a_full_lap_in_equal_steps(num_sides: int) -> None: + pen = FakePen() + + draw_shape(pen, num_sides) + + expected_angle = FULL_TURN_DEGREES / num_sides + assert pen.args_of("right") == [(expected_angle,)] * num_sides + + +def test_draw_shape_turns_72_degrees_for_a_pentagon() -> None: + pen = FakePen() + + draw_shape(pen, 5) + + assert pen.args_of("right")[0] == (72,) + + +def test_draw_shape_uses_the_given_side_length() -> None: + pen = FakePen() + + draw_shape(pen, 3, side_length=25) + + assert pen.args_of("forward") == [(25,)] * 3 + + +@pytest.mark.parametrize("num_sides", [-1, 0, 1, 2]) +def test_draw_shape_rejects_fewer_than_three_sides(num_sides: int) -> None: + pen = FakePen() + + with pytest.raises(ValueError, match="num_sides"): + draw_shape(pen, num_sides) + + assert pen.calls == [] + + +def test_draw_shape_rejects_a_side_that_is_not_positive() -> None: + pen = FakePen() + + with pytest.raises(ValueError, match="side_length"): + draw_shape(pen, 4, side_length=0) + + +def test_draw_shapes_draws_a_triangle_up_to_a_decagon() -> None: + pen = FakePen() + + draw_shapes(pen, random.Random(5)) + + assert len(pen.args_of("forward")) == sum(SIDE_COUNTS) + assert len(pen.args_of("color")) == len(SIDE_COUNTS) + + +def test_draw_shapes_sets_a_palette_color_before_each_shape() -> None: + pen = FakePen() + + draw_shapes(pen, random.Random(6)) + + colors = [args[0] for args in pen.args_of("color")] + assert all(color in COLOR_PALETTE for color in colors) + assert pen.names()[0] == "color" + + +def test_draw_shapes_works_without_a_generator() -> None: + pen = FakePen() + + draw_shapes(pen) + + assert len(pen.args_of("color")) == len(SIDE_COUNTS) -- 2.54.0 From c695c47929cbdad1781666dd2ec2664c88a010e3 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 7 Oct 2026 23:59:08 +0800 Subject: [PATCH 3/3] Add the shapes challenge to the command line turtle-challenges shapes draws the polygons. The argument parsing tests already cover every registered name. Task: MIL-003#6 --- README.md | 5 ++++- src/turtle_challenges/cli.py | 3 +++ src/turtle_challenges/constants.py | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4893089..a8247a2 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,9 @@ python -m turtle_challenges dashed-line | --- | --- | --- | | 1. Draw a square | `turtle-challenges square` | `draw_square` | | 2. Draw a dashed line | `turtle-challenges dashed-line` | `draw_dashed_line` | +| 3. Draw different shapes | `turtle-challenges shapes` | `draw_shape`, `draw_shapes`, `random_color` | -Challenges 3 to 5 are added by milestones 003 and 004. +Challenges 4 and 5 are added by milestone 004. `turtle-challenges --help` lists the challenges. If the command prints that the turtle module needs Tk, install Tk as described under Requirements. @@ -132,8 +133,10 @@ Open `build/html/index.html` in a browser. Any Doxygen warning fails the build, │ ├── constants.py every constant of the project │ ├── pen.py the Pen and Window protocols the challenges use │ ├── window.py opens the turtle window and creates the turtle +│ ├── colors.py random_color and random_palette_color │ ├── square.py challenge 1: draw_square │ ├── dashed_line.py challenge 2: draw_dashed_line +│ ├── shapes.py challenge 3: draw_shape, draw_shapes │ └── cli.py the turtle-challenges command ├── tests/ pytest tests and the recording fake pen ├── Doxyfile Doxygen configuration diff --git a/src/turtle_challenges/cli.py b/src/turtle_challenges/cli.py index 69bbc1e..2173106 100644 --- a/src/turtle_challenges/cli.py +++ b/src/turtle_challenges/cli.py @@ -10,10 +10,12 @@ from functools import partial from turtle_challenges.constants import ( CHALLENGE_DASHED_LINE, CHALLENGE_NAMES, + CHALLENGE_SHAPES, CHALLENGE_SQUARE, ) from turtle_challenges.dashed_line import draw_dashed_line from turtle_challenges.pen import Pen +from turtle_challenges.shapes import draw_shapes from turtle_challenges.square import draw_square from turtle_challenges.window import ( TurtleUnavailableError, @@ -52,6 +54,7 @@ def build_challenges(pen: Pen) -> dict[str, Callable[[], None]]: return { CHALLENGE_SQUARE: partial(draw_square, pen), CHALLENGE_DASHED_LINE: partial(draw_dashed_line, pen), + CHALLENGE_SHAPES: partial(draw_shapes, pen), } diff --git a/src/turtle_challenges/constants.py b/src/turtle_challenges/constants.py index b756d5b..ba9fd58 100644 --- a/src/turtle_challenges/constants.py +++ b/src/turtle_challenges/constants.py @@ -68,8 +68,12 @@ CHALLENGE_SQUARE = "square" ## Command line name of challenge 2. CHALLENGE_DASHED_LINE = "dashed-line" +## Command line name of challenge 3. +CHALLENGE_SHAPES = "shapes" + ## Names of the challenges on the command line, in the order of the course. CHALLENGE_NAMES: tuple[str, ...] = ( CHALLENGE_SQUARE, CHALLENGE_DASHED_LINE, + CHALLENGE_SHAPES, ) -- 2.54.0