Milestone 002: Square and Dashed Line #31
@@ -64,11 +64,37 @@ python -m pip install -e ".[dev]"
|
|||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
There is no challenge to run yet: the challenges arrive with milestones 002 to 004. To check the set-up, run the tests described in the next section.
|
With the virtual environment active, run one challenge by name. A window opens, the turtle draws, and the window closes when you click it.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
turtle-challenges square
|
||||||
|
python -m turtle_challenges dashed-line
|
||||||
|
```
|
||||||
|
|
||||||
|
| Challenge | Command | Function |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1. Draw a square | `turtle-challenges square` | `draw_square` |
|
||||||
|
| 2. Draw a dashed line | `turtle-challenges dashed-line` | `draw_dashed_line` |
|
||||||
|
|
||||||
|
Challenges 3 to 5 are added by milestones 003 and 004.
|
||||||
|
|
||||||
|
`turtle-challenges --help` lists the challenges. If the command prints that the turtle module needs Tk, install Tk as described under Requirements.
|
||||||
|
|
||||||
|
To use the functions in your own code, create a window and a turtle, then pass the turtle as the first argument. The assignment's functions use one global turtle; here the turtle is a parameter, which is what lets the tests run without a window:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from turtle_challenges import draw_square
|
||||||
|
from turtle_challenges.window import create_pen, create_window
|
||||||
|
|
||||||
|
window = create_window()
|
||||||
|
tim = create_pen()
|
||||||
|
draw_square(tim)
|
||||||
|
window.exitonclick()
|
||||||
|
```
|
||||||
|
|
||||||
## Run the tests
|
## Run the tests
|
||||||
|
|
||||||
The tests never open a window. With the virtual environment active:
|
The tests never open a window: they pass a recording fake in place of the turtle and check the moves it recorded. With the virtual environment active:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pytest
|
python -m pytest
|
||||||
@@ -103,8 +129,13 @@ Open `build/html/index.html` in a browser. Any Doxygen warning fails the build,
|
|||||||
├── .github/workflows/ci.yml continuous integration
|
├── .github/workflows/ci.yml continuous integration
|
||||||
├── docs/ project documents: business case, plan, milestones
|
├── docs/ project documents: business case, plan, milestones
|
||||||
├── src/turtle_challenges/ the package
|
├── src/turtle_challenges/ the package
|
||||||
│ └── constants.py every constant of the project
|
│ ├── constants.py every constant of the project
|
||||||
├── tests/ pytest tests
|
│ ├── pen.py the Pen and Window protocols the challenges use
|
||||||
|
│ ├── window.py opens the turtle window and creates the turtle
|
||||||
|
│ ├── square.py challenge 1: draw_square
|
||||||
|
│ ├── dashed_line.py challenge 2: draw_dashed_line
|
||||||
|
│ └── cli.py the turtle-challenges command
|
||||||
|
├── tests/ pytest tests and the recording fake pen
|
||||||
├── Doxyfile Doxygen configuration
|
├── Doxyfile Doxygen configuration
|
||||||
├── pyproject.toml project, tool and dependency configuration
|
├── pyproject.toml project, tool and dependency configuration
|
||||||
└── LICENSE
|
└── LICENSE
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ dev = [
|
|||||||
"ruff>=0.8",
|
"ruff>=0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
turtle-challenges = "turtle_challenges.cli:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,15 @@
|
|||||||
@brief Solutions to the turtle graphics challenges of day 18 of Udemy's
|
@brief Solutions to the turtle graphics challenges of day 18 of Udemy's
|
||||||
100 Days of Code.
|
100 Days of Code.
|
||||||
|
|
||||||
The challenges are added one milestone at a time; see the README.
|
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
|
||||||
|
first argument, so it can be tested without a window.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from turtle_challenges.dashed_line import draw_dashed_line
|
||||||
|
from turtle_challenges.square import draw_square
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"draw_dashed_line",
|
||||||
|
"draw_square",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""!
|
||||||
|
@file __main__.py
|
||||||
|
@brief Entry point of `python -m turtle_challenges`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from turtle_challenges.cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""!
|
||||||
|
@file cli.py
|
||||||
|
@brief Command line: run one challenge in a turtle window.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
from turtle_challenges.constants import (
|
||||||
|
CHALLENGE_DASHED_LINE,
|
||||||
|
CHALLENGE_NAMES,
|
||||||
|
CHALLENGE_SQUARE,
|
||||||
|
)
|
||||||
|
from turtle_challenges.dashed_line import draw_dashed_line
|
||||||
|
from turtle_challenges.pen import Pen
|
||||||
|
from turtle_challenges.square import draw_square
|
||||||
|
from turtle_challenges.window import (
|
||||||
|
TurtleUnavailableError,
|
||||||
|
create_pen,
|
||||||
|
create_window,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""!
|
||||||
|
@brief Build the argument parser of the command line.
|
||||||
|
|
||||||
|
@return A parser with the challenge name.
|
||||||
|
"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="turtle-challenges",
|
||||||
|
description="Run one of the turtle challenges in a window. "
|
||||||
|
"Click the window to close it.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"challenge",
|
||||||
|
choices=CHALLENGE_NAMES,
|
||||||
|
help="the challenge to run",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def build_challenges(pen: Pen) -> dict[str, Callable[[], None]]:
|
||||||
|
"""!
|
||||||
|
@brief Bind every challenge to a turtle.
|
||||||
|
|
||||||
|
@param pen The turtle that draws.
|
||||||
|
@return A mapping from the command line name to a function without
|
||||||
|
arguments that draws the challenge.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
CHALLENGE_SQUARE: partial(draw_square, pen),
|
||||||
|
CHALLENGE_DASHED_LINE: partial(draw_dashed_line, pen),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
"""!
|
||||||
|
@brief Run the challenge named on the command line.
|
||||||
|
|
||||||
|
@param argv Arguments without the program name; `sys.argv` if omitted.
|
||||||
|
@return The exit status: 0 on success.
|
||||||
|
"""
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
window = create_window()
|
||||||
|
tim = create_pen()
|
||||||
|
except TurtleUnavailableError as error:
|
||||||
|
parser.exit(1, f"{parser.prog}: error: {error}\n")
|
||||||
|
build_challenges(tim)[args.challenge]()
|
||||||
|
window.exitonclick()
|
||||||
|
return 0
|
||||||
@@ -13,3 +13,33 @@ WINDOW_TITLE = "Turtle Challenges"
|
|||||||
## Largest value of one color channel; also the turtle color mode, so that
|
## Largest value of one color channel; also the turtle color mode, so that
|
||||||
## colors can be given as RGB tuples from 0 to 255.
|
## colors can be given as RGB tuples from 0 to 255.
|
||||||
COLOR_MODE = 255
|
COLOR_MODE = 255
|
||||||
|
|
||||||
|
## Degrees in a right angle.
|
||||||
|
RIGHT_ANGLE_DEGREES = 90
|
||||||
|
|
||||||
|
## Challenge 1: number of sides of a square.
|
||||||
|
SQUARE_SIDES = 4
|
||||||
|
|
||||||
|
## Challenge 1: length of one side of the square, in turtle units.
|
||||||
|
SQUARE_SIDE_LENGTH = 100
|
||||||
|
|
||||||
|
## Challenge 2: number of dashes in the dashed line.
|
||||||
|
DASH_COUNT = 50
|
||||||
|
|
||||||
|
## Challenge 2: length of one dash, in turtle units.
|
||||||
|
DASH_LENGTH = 10
|
||||||
|
|
||||||
|
## Challenge 2: length of the gap after each dash, in turtle units.
|
||||||
|
GAP_LENGTH = 10
|
||||||
|
|
||||||
|
## Command line name of challenge 1.
|
||||||
|
CHALLENGE_SQUARE = "square"
|
||||||
|
|
||||||
|
## Command line name of challenge 2.
|
||||||
|
CHALLENGE_DASHED_LINE = "dashed-line"
|
||||||
|
|
||||||
|
## Names of the challenges on the command line, in the order of the course.
|
||||||
|
CHALLENGE_NAMES: tuple[str, ...] = (
|
||||||
|
CHALLENGE_SQUARE,
|
||||||
|
CHALLENGE_DASHED_LINE,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""!
|
||||||
|
@file dashed_line.py
|
||||||
|
@brief Turtle Challenge 2: draw a dashed line.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from turtle_challenges.constants import DASH_COUNT, DASH_LENGTH, GAP_LENGTH
|
||||||
|
from turtle_challenges.pen import Pen
|
||||||
|
|
||||||
|
|
||||||
|
def draw_dashed_line(
|
||||||
|
pen: Pen,
|
||||||
|
dash_count: int = DASH_COUNT,
|
||||||
|
dash_length: float = DASH_LENGTH,
|
||||||
|
gap_length: float = GAP_LENGTH,
|
||||||
|
) -> None:
|
||||||
|
"""!
|
||||||
|
@brief Draw a line of dashes: pen down for a dash, pen up for a gap.
|
||||||
|
|
||||||
|
The pen is down again when the function returns, so that the caller can
|
||||||
|
keep drawing.
|
||||||
|
|
||||||
|
@param pen The turtle that draws.
|
||||||
|
@param dash_count How many dashes to draw.
|
||||||
|
@param dash_length Length of one dash, in turtle units.
|
||||||
|
@param gap_length Length of the gap after each dash, in turtle units.
|
||||||
|
@exception ValueError If @p dash_count is negative, or a length is not
|
||||||
|
positive.
|
||||||
|
"""
|
||||||
|
if dash_count < 0:
|
||||||
|
raise ValueError(f"dash_count must not be negative, got {dash_count}")
|
||||||
|
if dash_length <= 0 or gap_length <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"lengths must be positive, got dash {dash_length} and gap {gap_length}"
|
||||||
|
)
|
||||||
|
for _ in range(dash_count):
|
||||||
|
pen.pendown()
|
||||||
|
pen.forward(dash_length)
|
||||||
|
pen.penup()
|
||||||
|
pen.forward(gap_length)
|
||||||
|
pen.pendown()
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""!
|
||||||
|
@file pen.py
|
||||||
|
@brief The turtle methods the challenges use, as protocols.
|
||||||
|
|
||||||
|
The drawing functions take a @ref turtle_challenges.pen.Pen instead of creating
|
||||||
|
a turtle themselves. A real `turtle.Turtle` fits the protocol, and so does the
|
||||||
|
recording fake of the tests, which lets every challenge be tested without a
|
||||||
|
window.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
## A color as the turtle accepts it here: a name or an RGB tuple of 0 to 255.
|
||||||
|
Color = str | tuple[int, int, int]
|
||||||
|
|
||||||
|
|
||||||
|
class Pen(Protocol):
|
||||||
|
"""!
|
||||||
|
@brief The part of `turtle.Turtle` that the challenges call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def forward(self, distance: float, /) -> None:
|
||||||
|
"""!
|
||||||
|
@brief Move forward, drawing if the pen is down.
|
||||||
|
|
||||||
|
@param distance How far to move, in turtle units.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def right(self, angle: float, /) -> None:
|
||||||
|
"""!
|
||||||
|
@brief Turn clockwise.
|
||||||
|
|
||||||
|
@param angle Degrees to turn.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def penup(self) -> None:
|
||||||
|
"""!
|
||||||
|
@brief Lift the pen: moves leave no line.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def pendown(self) -> None:
|
||||||
|
"""!
|
||||||
|
@brief Lower the pen: moves draw a line.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def color(self, color: Color, /) -> None:
|
||||||
|
"""!
|
||||||
|
@brief Set the pen and fill color.
|
||||||
|
|
||||||
|
@param color A color name or an RGB tuple of 0 to 255.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def circle(self, radius: float, /) -> None:
|
||||||
|
"""!
|
||||||
|
@brief Draw a circle that starts at the turtle and curves left.
|
||||||
|
|
||||||
|
@param radius Radius of the circle, in turtle units.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def heading(self) -> float:
|
||||||
|
"""!
|
||||||
|
@brief Return the direction the turtle faces, in degrees.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def setheading(self, to_angle: float, /) -> None:
|
||||||
|
"""!
|
||||||
|
@brief Face a direction.
|
||||||
|
|
||||||
|
@param to_angle Degrees: 0 is east, 90 is north.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def pensize(self, width: int, /) -> None:
|
||||||
|
"""!
|
||||||
|
@brief Set the thickness of the line.
|
||||||
|
|
||||||
|
@param width Thickness in pixels.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def speed(self, speed: int, /) -> None:
|
||||||
|
"""!
|
||||||
|
@brief Set the animation speed.
|
||||||
|
|
||||||
|
@param speed 0 is fastest (no animation); 1 to 10 run from slow to fast.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class Window(Protocol):
|
||||||
|
"""!
|
||||||
|
@brief The part of the turtle screen that the command line calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def exitonclick(self) -> None:
|
||||||
|
"""!
|
||||||
|
@brief Keep the window open until it is clicked, then close it.
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""!
|
||||||
|
@file square.py
|
||||||
|
@brief Turtle Challenge 1: draw a square.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from turtle_challenges.constants import (
|
||||||
|
RIGHT_ANGLE_DEGREES,
|
||||||
|
SQUARE_SIDE_LENGTH,
|
||||||
|
SQUARE_SIDES,
|
||||||
|
)
|
||||||
|
from turtle_challenges.pen import Pen
|
||||||
|
|
||||||
|
|
||||||
|
def draw_square(pen: Pen, side_length: float = SQUARE_SIDE_LENGTH) -> None:
|
||||||
|
"""!
|
||||||
|
@brief Draw a square: move forward, turn right, four times.
|
||||||
|
|
||||||
|
@param pen The turtle that draws.
|
||||||
|
@param side_length Length of each side, in turtle units.
|
||||||
|
@exception ValueError If @p side_length is not positive.
|
||||||
|
"""
|
||||||
|
if side_length <= 0:
|
||||||
|
raise ValueError(f"side_length must be positive, got {side_length}")
|
||||||
|
for _ in range(SQUARE_SIDES):
|
||||||
|
pen.forward(side_length)
|
||||||
|
pen.right(RIGHT_ANGLE_DEGREES)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""!
|
||||||
|
@file window.py
|
||||||
|
@brief Creates the turtle window and the turtle that draws in it.
|
||||||
|
|
||||||
|
This is the only module that touches the `turtle` library. The lecture on
|
||||||
|
imports shows these styles, and this project uses the second one:
|
||||||
|
|
||||||
|
- `import turtle` then `turtle.Turtle()`: clear, but long when used often.
|
||||||
|
- `from turtle import Screen, Turtle`: short, and names exactly what is used.
|
||||||
|
- `import turtle as t` then `t.Turtle()`: an alias for a long module name.
|
||||||
|
- `from turtle import *`: avoided, because it hides where a name comes from.
|
||||||
|
|
||||||
|
The import sits inside the functions so that importing the package (for
|
||||||
|
example to run the tests) does not need Tk; only opening a window does.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from turtle_challenges.constants import COLOR_MODE, WINDOW_TITLE
|
||||||
|
from turtle_challenges.pen import Pen, Window
|
||||||
|
|
||||||
|
## Hint shown when Tk is missing, the usual reason `turtle` cannot be imported.
|
||||||
|
TURTLE_UNAVAILABLE_MESSAGE = (
|
||||||
|
"the turtle module needs Tk (tkinter), which this Python does not provide; "
|
||||||
|
"on Debian run: sudo apt install python3-tk"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TurtleUnavailableError(RuntimeError):
|
||||||
|
"""!
|
||||||
|
@brief Raised when the `turtle` module cannot be imported.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def create_window() -> Window:
|
||||||
|
"""!
|
||||||
|
@brief Open the turtle window with RGB colors from 0 to 255.
|
||||||
|
|
||||||
|
@return The window, ready for drawing.
|
||||||
|
@exception TurtleUnavailableError If `turtle` (Tk) cannot be imported.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from turtle import Screen
|
||||||
|
except ImportError as error:
|
||||||
|
raise TurtleUnavailableError(TURTLE_UNAVAILABLE_MESSAGE) from error
|
||||||
|
screen = Screen()
|
||||||
|
screen.colormode(COLOR_MODE)
|
||||||
|
screen.title(WINDOW_TITLE)
|
||||||
|
return screen
|
||||||
|
|
||||||
|
|
||||||
|
def create_pen() -> Pen:
|
||||||
|
"""!
|
||||||
|
@brief Create the turtle that draws in the window.
|
||||||
|
|
||||||
|
Call @ref turtle_challenges.window.create_window first, so that the turtle
|
||||||
|
draws in the window that has the right color mode.
|
||||||
|
|
||||||
|
@return A new turtle.
|
||||||
|
@exception TurtleUnavailableError If `turtle` (Tk) cannot be imported.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from turtle import Turtle
|
||||||
|
except ImportError as error:
|
||||||
|
raise TurtleUnavailableError(TURTLE_UNAVAILABLE_MESSAGE) from error
|
||||||
|
return Turtle()
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Test doubles that stand in for the turtle and its window."""
|
||||||
|
|
||||||
|
from turtle_challenges.pen import Color
|
||||||
|
|
||||||
|
|
||||||
|
class FakePen:
|
||||||
|
"""A pen that records every call instead of drawing.
|
||||||
|
|
||||||
|
The heading follows `setheading` and `right`, like a real turtle that
|
||||||
|
starts facing east, so code that reads `heading()` behaves as in a window.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, tuple[object, ...]]] = []
|
||||||
|
self._heading = 0.0
|
||||||
|
|
||||||
|
def _record(self, name: str, *args: object) -> None:
|
||||||
|
self.calls.append((name, args))
|
||||||
|
|
||||||
|
def args_of(self, name: str) -> list[tuple[object, ...]]:
|
||||||
|
"""Return the arguments of every call to `name`, in order."""
|
||||||
|
return [args for call, args in self.calls if call == name]
|
||||||
|
|
||||||
|
def names(self) -> list[str]:
|
||||||
|
"""Return the name of every call, in order."""
|
||||||
|
return [call for call, _ in self.calls]
|
||||||
|
|
||||||
|
def forward(self, distance: float, /) -> None:
|
||||||
|
self._record("forward", distance)
|
||||||
|
|
||||||
|
def right(self, angle: float, /) -> None:
|
||||||
|
self._record("right", angle)
|
||||||
|
self._heading = (self._heading - angle) % 360
|
||||||
|
|
||||||
|
def penup(self) -> None:
|
||||||
|
self._record("penup")
|
||||||
|
|
||||||
|
def pendown(self) -> None:
|
||||||
|
self._record("pendown")
|
||||||
|
|
||||||
|
def color(self, color: Color, /) -> None:
|
||||||
|
self._record("color", color)
|
||||||
|
|
||||||
|
def circle(self, radius: float, /) -> None:
|
||||||
|
self._record("circle", radius)
|
||||||
|
|
||||||
|
def heading(self) -> float:
|
||||||
|
return self._heading
|
||||||
|
|
||||||
|
def setheading(self, to_angle: float, /) -> None:
|
||||||
|
self._record("setheading", to_angle)
|
||||||
|
self._heading = to_angle % 360
|
||||||
|
|
||||||
|
def pensize(self, width: int, /) -> None:
|
||||||
|
self._record("pensize", width)
|
||||||
|
|
||||||
|
def speed(self, speed: int, /) -> None:
|
||||||
|
self._record("speed", speed)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWindow:
|
||||||
|
"""A window that records whether it was told to wait for a click."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.waited_for_click = False
|
||||||
|
|
||||||
|
def exitonclick(self) -> None:
|
||||||
|
self.waited_for_click = True
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.fakes import FakePen, FakeWindow
|
||||||
|
from turtle_challenges import cli
|
||||||
|
from turtle_challenges.constants import (
|
||||||
|
CHALLENGE_NAMES,
|
||||||
|
DASH_COUNT,
|
||||||
|
SQUARE_SIDES,
|
||||||
|
)
|
||||||
|
from turtle_challenges.pen import Pen, Window
|
||||||
|
from turtle_challenges.window import TurtleUnavailableError
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_pen_and_window(monkeypatch: pytest.MonkeyPatch) -> tuple[FakePen, FakeWindow]:
|
||||||
|
"""Replace the turtle window and pen of the command line with fakes."""
|
||||||
|
pen = FakePen()
|
||||||
|
window = FakeWindow()
|
||||||
|
|
||||||
|
def create_fake_window() -> Window:
|
||||||
|
return window
|
||||||
|
|
||||||
|
def create_fake_pen() -> Pen:
|
||||||
|
return pen
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "create_window", create_fake_window)
|
||||||
|
monkeypatch.setattr(cli, "create_pen", create_fake_pen)
|
||||||
|
return pen, window
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_challenge_name_has_a_challenge() -> None:
|
||||||
|
challenges = cli.build_challenges(FakePen())
|
||||||
|
|
||||||
|
assert set(challenges) == set(CHALLENGE_NAMES)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", CHALLENGE_NAMES)
|
||||||
|
def test_parser_accepts_every_challenge_name(name: str) -> None:
|
||||||
|
args = cli.build_parser().parse_args([name])
|
||||||
|
|
||||||
|
assert args.challenge == name
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_rejects_an_unknown_challenge(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(SystemExit) as exit_info:
|
||||||
|
cli.build_parser().parse_args(["triangle"])
|
||||||
|
|
||||||
|
assert exit_info.value.code == 2
|
||||||
|
assert "invalid choice" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_help_lists_every_challenge(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
with pytest.raises(SystemExit) as exit_info:
|
||||||
|
cli.build_parser().parse_args(["--help"])
|
||||||
|
|
||||||
|
assert exit_info.value.code == 0
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert all(name in output for name in CHALLENGE_NAMES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_runs_the_square_and_waits_for_a_click(
|
||||||
|
fake_pen_and_window: tuple[FakePen, FakeWindow],
|
||||||
|
) -> None:
|
||||||
|
pen, window = fake_pen_and_window
|
||||||
|
|
||||||
|
status = cli.main(["square"])
|
||||||
|
|
||||||
|
assert status == 0
|
||||||
|
assert len(pen.args_of("forward")) == SQUARE_SIDES
|
||||||
|
assert window.waited_for_click
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_runs_the_dashed_line(
|
||||||
|
fake_pen_and_window: tuple[FakePen, FakeWindow],
|
||||||
|
) -> None:
|
||||||
|
pen, _ = fake_pen_and_window
|
||||||
|
|
||||||
|
cli.main(["dashed-line"])
|
||||||
|
|
||||||
|
assert len(pen.args_of("forward")) == 2 * DASH_COUNT
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_reports_a_missing_turtle_and_exits_with_status_1(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
def fail() -> Window:
|
||||||
|
raise TurtleUnavailableError("no Tk here")
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "create_window", fail)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exit_info:
|
||||||
|
cli.main(["square"])
|
||||||
|
|
||||||
|
assert exit_info.value.code == 1
|
||||||
|
assert "no Tk here" in capsys.readouterr().err
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from turtle_challenges import constants
|
||||||
|
|
||||||
|
|
||||||
|
def test_challenge_names_are_unique() -> None:
|
||||||
|
assert len(set(constants.CHALLENGE_NAMES)) == len(constants.CHALLENGE_NAMES)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.fakes import FakePen
|
||||||
|
from turtle_challenges.constants import DASH_COUNT, DASH_LENGTH, GAP_LENGTH
|
||||||
|
from turtle_challenges.dashed_line import draw_dashed_line
|
||||||
|
|
||||||
|
|
||||||
|
def test_draw_dashed_line_alternates_dash_and_gap() -> None:
|
||||||
|
pen = FakePen()
|
||||||
|
|
||||||
|
draw_dashed_line(pen, dash_count=2)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
pen.calls[:-1]
|
||||||
|
== [
|
||||||
|
("pendown", ()),
|
||||||
|
("forward", (DASH_LENGTH,)),
|
||||||
|
("penup", ()),
|
||||||
|
("forward", (GAP_LENGTH,)),
|
||||||
|
]
|
||||||
|
* 2
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_draw_dashed_line_draws_the_default_number_of_dashes() -> None:
|
||||||
|
pen = FakePen()
|
||||||
|
|
||||||
|
draw_dashed_line(pen)
|
||||||
|
|
||||||
|
assert pen.names().count("pendown") == DASH_COUNT + 1
|
||||||
|
assert pen.names().count("forward") == 2 * DASH_COUNT
|
||||||
|
|
||||||
|
|
||||||
|
def test_draw_dashed_line_leaves_the_pen_down() -> None:
|
||||||
|
pen = FakePen()
|
||||||
|
|
||||||
|
draw_dashed_line(pen, dash_count=3)
|
||||||
|
|
||||||
|
assert pen.calls[-1] == ("pendown", ())
|
||||||
|
|
||||||
|
|
||||||
|
def test_draw_dashed_line_uses_the_given_lengths() -> None:
|
||||||
|
pen = FakePen()
|
||||||
|
|
||||||
|
draw_dashed_line(pen, dash_count=1, dash_length=4, gap_length=7)
|
||||||
|
|
||||||
|
assert pen.args_of("forward") == [(4,), (7,)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_draw_dashed_line_with_no_dashes_only_puts_the_pen_down() -> None:
|
||||||
|
pen = FakePen()
|
||||||
|
|
||||||
|
draw_dashed_line(pen, dash_count=0)
|
||||||
|
|
||||||
|
assert pen.names() == ["pendown"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_draw_dashed_line_rejects_a_negative_count() -> None:
|
||||||
|
pen = FakePen()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="dash_count"):
|
||||||
|
draw_dashed_line(pen, dash_count=-1)
|
||||||
|
|
||||||
|
assert pen.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("dash_length", "gap_length"), [(0, 10), (10, 0), (-1, 10)])
|
||||||
|
def test_draw_dashed_line_rejects_a_length_that_is_not_positive(
|
||||||
|
dash_length: float, gap_length: float
|
||||||
|
) -> None:
|
||||||
|
pen = FakePen()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="lengths"):
|
||||||
|
draw_dashed_line(pen, dash_length=dash_length, gap_length=gap_length)
|
||||||
|
|
||||||
|
assert pen.calls == []
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.fakes import FakePen
|
||||||
|
from turtle_challenges.constants import RIGHT_ANGLE_DEGREES, SQUARE_SIDE_LENGTH
|
||||||
|
from turtle_challenges.square import draw_square
|
||||||
|
|
||||||
|
|
||||||
|
def test_draw_square_moves_forward_and_turns_right_four_times() -> None:
|
||||||
|
pen = FakePen()
|
||||||
|
|
||||||
|
draw_square(pen)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
pen.calls
|
||||||
|
== [
|
||||||
|
("forward", (SQUARE_SIDE_LENGTH,)),
|
||||||
|
("right", (RIGHT_ANGLE_DEGREES,)),
|
||||||
|
]
|
||||||
|
* 4
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_draw_square_uses_the_given_side_length() -> None:
|
||||||
|
pen = FakePen()
|
||||||
|
|
||||||
|
draw_square(pen, side_length=40)
|
||||||
|
|
||||||
|
assert pen.args_of("forward") == [(40,)] * 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_draw_square_returns_the_turtle_to_its_start_heading() -> None:
|
||||||
|
pen = FakePen()
|
||||||
|
|
||||||
|
draw_square(pen)
|
||||||
|
|
||||||
|
assert pen.heading() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("side_length", [0, -5])
|
||||||
|
def test_draw_square_rejects_a_side_that_is_not_positive(side_length: float) -> None:
|
||||||
|
pen = FakePen()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="side_length"):
|
||||||
|
draw_square(pen, side_length=side_length)
|
||||||
|
|
||||||
|
assert pen.calls == []
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from turtle_challenges.window import (
|
||||||
|
TURTLE_UNAVAILABLE_MESSAGE,
|
||||||
|
TurtleUnavailableError,
|
||||||
|
create_pen,
|
||||||
|
create_window,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def without_turtle(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Make `import turtle` fail, as on a Python without Tk."""
|
||||||
|
monkeypatch.setitem(sys.modules, "turtle", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_window_reports_a_missing_turtle(without_turtle: None) -> None:
|
||||||
|
with pytest.raises(TurtleUnavailableError) as error_info:
|
||||||
|
create_window()
|
||||||
|
|
||||||
|
assert str(error_info.value) == TURTLE_UNAVAILABLE_MESSAGE
|
||||||
|
assert isinstance(error_info.value.__cause__, ImportError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_pen_reports_a_missing_turtle(without_turtle: None) -> None:
|
||||||
|
with pytest.raises(TurtleUnavailableError):
|
||||||
|
create_pen()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_missing_turtle_message_names_the_debian_package() -> None:
|
||||||
|
assert "python3-tk" in TURTLE_UNAVAILABLE_MESSAGE
|
||||||
Reference in New Issue
Block a user