From e2249de46e015075fdac6bfcbdb871115b1de088 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 7 Oct 2026 23:58:50 +0800 Subject: [PATCH 1/3] Add the Pen protocol, window helpers and a recording fake turtle The drawing functions take a Pen, the part of turtle.Turtle they use, so a test can pass a fake that records every call. window.py is the only module that imports turtle, and it does so inside the functions so that importing the package needs no Tk. Task: MIL-002#1 Task: MIL-002#2 --- src/turtle_challenges/pen.py | 106 ++++++++++++++++++++++++++++++++ src/turtle_challenges/window.py | 64 +++++++++++++++++++ tests/fakes.py | 68 ++++++++++++++++++++ tests/test_window.py | 33 ++++++++++ 4 files changed, 271 insertions(+) create mode 100644 src/turtle_challenges/pen.py create mode 100644 src/turtle_challenges/window.py create mode 100644 tests/fakes.py create mode 100644 tests/test_window.py diff --git a/src/turtle_challenges/pen.py b/src/turtle_challenges/pen.py new file mode 100644 index 0000000..d05dd93 --- /dev/null +++ b/src/turtle_challenges/pen.py @@ -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. + """ + ... diff --git a/src/turtle_challenges/window.py b/src/turtle_challenges/window.py new file mode 100644 index 0000000..8772228 --- /dev/null +++ b/src/turtle_challenges/window.py @@ -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() diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..88f3ed7 --- /dev/null +++ b/tests/fakes.py @@ -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 diff --git a/tests/test_window.py b/tests/test_window.py new file mode 100644 index 0000000..568cb7a --- /dev/null +++ b/tests/test_window.py @@ -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 -- 2.54.0 From a34bf6bbc26da5b3790c9e51b3af1dc188695905 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 7 Oct 2026 23:58:52 +0800 Subject: [PATCH 2/3] Add draw_square and draw_dashed_line with tests Challenge 1 draws four sides and turns right 90 degrees after each. Challenge 2 puts the pen down for a dash and up for a gap and leaves it down at the end. Lengths and counts come from constants.py; invalid values raise ValueError. Task: MIL-002#3 Task: MIL-002#4 Task: MIL-002#5 --- src/turtle_challenges/__init__.py | 12 ++++- src/turtle_challenges/constants.py | 18 +++++++ src/turtle_challenges/dashed_line.py | 40 +++++++++++++++ src/turtle_challenges/square.py | 26 ++++++++++ tests/test_dashed_line.py | 76 ++++++++++++++++++++++++++++ tests/test_square.py | 46 +++++++++++++++++ 6 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 src/turtle_challenges/dashed_line.py create mode 100644 src/turtle_challenges/square.py create mode 100644 tests/test_dashed_line.py create mode 100644 tests/test_square.py diff --git a/src/turtle_challenges/__init__.py b/src/turtle_challenges/__init__.py index fda5d58..af20d82 100644 --- a/src/turtle_challenges/__init__.py +++ b/src/turtle_challenges/__init__.py @@ -3,5 +3,15 @@ @brief Solutions to the turtle graphics challenges of day 18 of Udemy's 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", +] diff --git a/src/turtle_challenges/constants.py b/src/turtle_challenges/constants.py index cb2fb0a..84f1140 100644 --- a/src/turtle_challenges/constants.py +++ b/src/turtle_challenges/constants.py @@ -13,3 +13,21 @@ WINDOW_TITLE = "Turtle Challenges" ## Largest value of one color channel; also the turtle color mode, so that ## colors can be given as RGB tuples from 0 to 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 diff --git a/src/turtle_challenges/dashed_line.py b/src/turtle_challenges/dashed_line.py new file mode 100644 index 0000000..f38ae5d --- /dev/null +++ b/src/turtle_challenges/dashed_line.py @@ -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() diff --git a/src/turtle_challenges/square.py b/src/turtle_challenges/square.py new file mode 100644 index 0000000..be0e409 --- /dev/null +++ b/src/turtle_challenges/square.py @@ -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) diff --git a/tests/test_dashed_line.py b/tests/test_dashed_line.py new file mode 100644 index 0000000..fec1c7c --- /dev/null +++ b/tests/test_dashed_line.py @@ -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 == [] diff --git a/tests/test_square.py b/tests/test_square.py new file mode 100644 index 0000000..e0021df --- /dev/null +++ b/tests/test_square.py @@ -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 == [] -- 2.54.0 From b3153b9dc1996758fea03a222aa56c75ba19a603 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 7 Oct 2026 23:58:56 +0800 Subject: [PATCH 3/3] Add the command line entry point python -m turtle_challenges and the turtle-challenges command open a window, run the named challenge and wait for a click. square and dashed-line are registered; a missing Tk is reported with a hint instead of a traceback. Task: MIL-002#6 --- README.md | 39 ++++++++++-- pyproject.toml | 3 + src/turtle_challenges/__main__.py | 9 +++ src/turtle_challenges/cli.py | 74 +++++++++++++++++++++++ src/turtle_challenges/constants.py | 12 ++++ tests/test_cli.py | 97 ++++++++++++++++++++++++++++++ tests/test_constants.py | 5 ++ 7 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 src/turtle_challenges/__main__.py create mode 100644 src/turtle_challenges/cli.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_constants.py diff --git a/README.md b/README.md index 2f221ad..4893089 100644 --- a/README.md +++ b/README.md @@ -64,11 +64,37 @@ python -m pip install -e ".[dev]" ## 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 -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 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 ├── docs/ project documents: business case, plan, milestones ├── src/turtle_challenges/ the package -│ └── constants.py every constant of the project -├── tests/ pytest tests +│ ├── 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 +│ ├── 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 ├── pyproject.toml project, tool and dependency configuration └── LICENSE diff --git a/pyproject.toml b/pyproject.toml index da07083..3b718ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,9 @@ dev = [ "ruff>=0.8", ] +[project.scripts] +turtle-challenges = "turtle_challenges.cli:main" + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/turtle_challenges/__main__.py b/src/turtle_challenges/__main__.py new file mode 100644 index 0000000..58e33ea --- /dev/null +++ b/src/turtle_challenges/__main__.py @@ -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()) diff --git a/src/turtle_challenges/cli.py b/src/turtle_challenges/cli.py new file mode 100644 index 0000000..69bbc1e --- /dev/null +++ b/src/turtle_challenges/cli.py @@ -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 diff --git a/src/turtle_challenges/constants.py b/src/turtle_challenges/constants.py index 84f1140..0c5ca52 100644 --- a/src/turtle_challenges/constants.py +++ b/src/turtle_challenges/constants.py @@ -31,3 +31,15 @@ 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, +) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7077631 --- /dev/null +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 0000000..ac3dd94 --- /dev/null +++ b/tests/test_constants.py @@ -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) -- 2.54.0