Merge pull request 'Milestone 003: Shapes and Random Color' (#32) from mil-003-shapes-and-random-color into mil-002-square-and-dashed-line
Reviewed-on: #32
This commit was merged in pull request #32.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -3,15 +3,22 @@
|
||||
@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",
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -14,6 +14,15 @@ 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 full turn.
|
||||
FULL_TURN_DEGREES = 360
|
||||
|
||||
## Degrees in a right angle.
|
||||
RIGHT_ANGLE_DEGREES = 90
|
||||
|
||||
@@ -32,14 +41,39 @@ 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",
|
||||
"DarkOrchid",
|
||||
"IndianRed",
|
||||
"DeepSkyBlue",
|
||||
"LightSeaGreen",
|
||||
"wheat",
|
||||
"SlateGray",
|
||||
"SeaGreen",
|
||||
)
|
||||
|
||||
## Command line name of challenge 1.
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -1,5 +1,19 @@
|
||||
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)
|
||||
|
||||
|
||||
def test_polygon_range_runs_from_triangle_to_decagon() -> None:
|
||||
assert constants.MIN_POLYGON_SIDES == 3
|
||||
assert constants.MAX_POLYGON_SIDES == 10
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user