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
This commit is contained in:
@@ -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,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,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