Draw the Hirst painting: pen, dot positions, dots, click wait and speed
MIL-002 tasks 1 to 6. - Add create_pen, which hides the turtle, lifts the pen and switches the screen to 0 to 255 colours. - Add dot_positions: 100 dot centres in 10 rows of 10, 50 apart, centred on the origin so the painting fits the default window. - Add draw_dots, which draws a size-20 dot at every position in a colour chosen from the palette, and raises ValueError for an empty palette. - Add main: draw with the palette of the reference image, then wait for a click with exitonclick. Animation is off while drawing and the screen updates once, which takes the drawing from 21.1 to 0.35 seconds (SC6 allows 30). - Add 22 tests: pen setup, positions and spacing, dots on a recording fake pen and on a real turtle, no trail, and the order of main's steps. All tests share one hidden Tk window, because starting Tk per test fails now and then on Windows. Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 Closes #12 Task: MIL-002#1 Task: MIL-002#2 Task: MIL-002#3 Task: MIL-002#4 Task: MIL-002#5 Task: MIL-002#6
This commit is contained in:
@@ -1,15 +1,27 @@
|
||||
"""Tests for the colour palette helpers of the Hirst painting program."""
|
||||
|
||||
import tkinter
|
||||
import turtle
|
||||
from collections import Counter
|
||||
from collections.abc import Iterator, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
import hirst_painting
|
||||
from hirst_painting import (
|
||||
REFERENCE_IMAGE_PATH,
|
||||
Colour,
|
||||
DotPen,
|
||||
Position,
|
||||
create_pen,
|
||||
dot_positions,
|
||||
draw_dots,
|
||||
extract_palette,
|
||||
is_white_shade,
|
||||
main,
|
||||
remove_white_shades,
|
||||
)
|
||||
|
||||
@@ -118,3 +130,273 @@ def test_reference_image_palette_has_two_colours_and_no_white_shade() -> None:
|
||||
|
||||
assert len(palette) >= 2
|
||||
assert not any(is_white_shade(colour) for colour in palette)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tk_root() -> Iterator[tkinter.Tk]:
|
||||
"""Create one hidden Tk window for the whole run.
|
||||
|
||||
Starting Tk again and again in one process fails now and then on Windows
|
||||
(Tk cannot read its own library files), so every test shares this one.
|
||||
"""
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
yield root
|
||||
root.destroy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def canvas(tk_root: tkinter.Tk) -> Iterator[tkinter.Canvas]:
|
||||
"""Yield a canvas on the hidden window, so no window appears."""
|
||||
canvas = tkinter.Canvas(tk_root)
|
||||
yield canvas
|
||||
canvas.destroy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def screen(canvas: tkinter.Canvas) -> turtle.TurtleScreen:
|
||||
"""Return a turtle screen that draws on the hidden canvas."""
|
||||
return turtle.TurtleScreen(canvas)
|
||||
|
||||
|
||||
def test_create_pen_hides_the_turtle(screen: turtle.TurtleScreen) -> None:
|
||||
assert not create_pen(screen).isvisible()
|
||||
|
||||
|
||||
def test_create_pen_lifts_the_pen(screen: turtle.TurtleScreen) -> None:
|
||||
assert not create_pen(screen).isdown()
|
||||
|
||||
|
||||
def test_create_pen_switches_the_screen_to_255_level_colours(
|
||||
screen: turtle.TurtleScreen,
|
||||
) -> None:
|
||||
create_pen(screen)
|
||||
|
||||
assert screen.colormode() == 255
|
||||
|
||||
|
||||
def test_create_pen_draws_on_the_given_screen(screen: turtle.TurtleScreen) -> None:
|
||||
assert create_pen(screen).getscreen() is screen
|
||||
|
||||
|
||||
def test_dot_positions_returns_100_positions() -> None:
|
||||
assert len(dot_positions()) == 100
|
||||
|
||||
|
||||
def test_dot_positions_are_all_different() -> None:
|
||||
positions = dot_positions()
|
||||
|
||||
assert len(set(positions)) == len(positions)
|
||||
|
||||
|
||||
def test_dot_positions_have_10_heights_with_10_dots_each() -> None:
|
||||
dots_per_height = Counter(y for _, y in dot_positions())
|
||||
|
||||
assert list(dots_per_height.values()) == [10] * 10
|
||||
|
||||
|
||||
def test_dot_positions_are_50_apart_along_each_row() -> None:
|
||||
positions = dot_positions()
|
||||
|
||||
for start in range(0, 100, 10):
|
||||
row = positions[start : start + 10]
|
||||
assert [x for x, _ in row] == list(range(-225, 226, 50))
|
||||
assert len({y for _, y in row}) == 1
|
||||
|
||||
|
||||
def test_dot_positions_are_50_apart_between_rows() -> None:
|
||||
heights = sorted({y for _, y in dot_positions()})
|
||||
|
||||
assert heights == list(range(-225, 226, 50))
|
||||
|
||||
|
||||
def test_dot_positions_start_bottom_left_and_fill_rows_upward() -> None:
|
||||
positions = dot_positions()
|
||||
|
||||
assert positions[0] == (-225, -225)
|
||||
assert positions[1] == (-175, -225)
|
||||
assert positions[10] == (-225, -175)
|
||||
assert positions[-1] == (225, 225)
|
||||
|
||||
|
||||
def test_dot_positions_are_centred_on_the_origin() -> None:
|
||||
positions = dot_positions()
|
||||
|
||||
assert min(x for x, _ in positions) == -max(x for x, _ in positions)
|
||||
assert min(y for _, y in positions) == -max(y for _, y in positions)
|
||||
|
||||
|
||||
class RecordingPen:
|
||||
"""A pen that records the dots it was asked to draw and where."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Start at the origin with no dots drawn."""
|
||||
self.position: Position = (0, 0)
|
||||
self.dots: list[tuple[Position, int, Colour]] = []
|
||||
|
||||
def goto(self, position: Position, /) -> None:
|
||||
"""Remember the position the next dot is drawn at."""
|
||||
self.position = position
|
||||
|
||||
def dot(self, size: int, colour: Colour, /) -> None:
|
||||
"""Record a dot at the current position."""
|
||||
self.dots.append((self.position, size, colour))
|
||||
|
||||
|
||||
PALETTE: list[Colour] = [(200, 30, 40), (30, 90, 160), (20, 120, 60)]
|
||||
|
||||
|
||||
def test_draw_dots_draws_a_dot_at_every_position_in_order() -> None:
|
||||
pen = RecordingPen()
|
||||
|
||||
draw_dots(pen, PALETTE)
|
||||
|
||||
assert [position for position, _, _ in pen.dots] == dot_positions()
|
||||
|
||||
|
||||
def test_draw_dots_draws_dots_of_size_20() -> None:
|
||||
pen = RecordingPen()
|
||||
|
||||
draw_dots(pen, PALETTE)
|
||||
|
||||
assert {size for _, size, _ in pen.dots} == {20}
|
||||
|
||||
|
||||
def test_draw_dots_colours_every_dot_from_the_palette() -> None:
|
||||
pen = RecordingPen()
|
||||
|
||||
draw_dots(pen, PALETTE)
|
||||
|
||||
assert {colour for _, _, colour in pen.dots} <= set(PALETTE)
|
||||
|
||||
|
||||
def test_draw_dots_asks_the_chooser_for_the_colour_of_each_dot() -> None:
|
||||
pen = RecordingPen()
|
||||
asked: list[Sequence[Colour]] = []
|
||||
|
||||
def choose_last(colours: Sequence[Colour]) -> Colour:
|
||||
asked.append(colours)
|
||||
return colours[-1]
|
||||
|
||||
draw_dots(pen, PALETTE, choose_colour=choose_last)
|
||||
|
||||
assert len(asked) == 100
|
||||
assert {colour for _, _, colour in pen.dots} == {(20, 120, 60)}
|
||||
|
||||
|
||||
def test_draw_dots_raises_when_the_palette_is_empty() -> None:
|
||||
with pytest.raises(ValueError, match="no colours"):
|
||||
draw_dots(RecordingPen(), [])
|
||||
|
||||
|
||||
def _item_option(canvas: tkinter.Canvas, item: int, option: str) -> str:
|
||||
"""Return one option of a canvas item, such as its width or fill colour."""
|
||||
# typeshed leaves Canvas.itemcget untyped, which strict mypy refuses to call.
|
||||
return str(canvas.itemcget(item, option)) # type: ignore[no-untyped-call]
|
||||
|
||||
|
||||
def _drawn_dots(canvas: tkinter.Canvas) -> list[tuple[Position, str]]:
|
||||
"""Return the position and fill colour of every dot on the canvas.
|
||||
|
||||
Turtle draws a dot as a round-capped line whose width is the dot size.
|
||||
"""
|
||||
dots: list[tuple[Position, str]] = []
|
||||
for item in canvas.find_all():
|
||||
is_dot = (
|
||||
str(canvas.type(item)) == "line"
|
||||
and _item_option(canvas, item, "width") == "20.0"
|
||||
)
|
||||
if is_dot:
|
||||
x, y = canvas.coords(item)[:2]
|
||||
dots.append(((round(x), -round(y)), _item_option(canvas, item, "fill")))
|
||||
return dots
|
||||
|
||||
|
||||
def _other_lines(canvas: tkinter.Canvas) -> int:
|
||||
"""Count the line items that are not dots, such as a trail behind the pen."""
|
||||
lines = [item for item in canvas.find_all() if str(canvas.type(item)) == "line"]
|
||||
return len(lines) - len(_drawn_dots(canvas))
|
||||
|
||||
|
||||
def test_draw_dots_draws_100_dots_at_the_dot_positions_on_a_real_turtle(
|
||||
screen: turtle.TurtleScreen, canvas: tkinter.Canvas
|
||||
) -> None:
|
||||
screen.tracer(0)
|
||||
pen = create_pen(screen)
|
||||
|
||||
draw_dots(pen, PALETTE)
|
||||
|
||||
positions = [position for position, _ in _drawn_dots(canvas)]
|
||||
assert sorted(positions) == sorted(dot_positions())
|
||||
|
||||
|
||||
def test_draw_dots_colours_the_dots_of_a_real_turtle_from_the_palette(
|
||||
screen: turtle.TurtleScreen, canvas: tkinter.Canvas
|
||||
) -> None:
|
||||
screen.tracer(0)
|
||||
pen = create_pen(screen)
|
||||
|
||||
draw_dots(pen, PALETTE)
|
||||
|
||||
palette_fills = {f"#{red:02x}{green:02x}{blue:02x}" for red, green, blue in PALETTE}
|
||||
assert {fill for _, fill in _drawn_dots(canvas)} <= palette_fills
|
||||
|
||||
|
||||
def test_draw_dots_draws_no_trail_between_dots(
|
||||
screen: turtle.TurtleScreen, canvas: tkinter.Canvas
|
||||
) -> None:
|
||||
screen.tracer(0)
|
||||
pen = create_pen(screen)
|
||||
other_lines_before = _other_lines(canvas)
|
||||
|
||||
draw_dots(pen, PALETTE)
|
||||
|
||||
assert _other_lines(canvas) == other_lines_before
|
||||
|
||||
|
||||
@dataclass
|
||||
class MainRun:
|
||||
"""What happened during one run of main() on a fake screen."""
|
||||
|
||||
events: list[str] = field(default_factory=list)
|
||||
palettes: list[Sequence[Colour]] = field(default_factory=list)
|
||||
animation_while_drawing: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def main_run(monkeypatch: pytest.MonkeyPatch, canvas: tkinter.Canvas) -> MainRun:
|
||||
"""Run main() once with drawing and the click wait replaced by recorders."""
|
||||
run = MainRun()
|
||||
|
||||
class RecordingScreen(turtle.TurtleScreen):
|
||||
def update(self) -> None:
|
||||
run.events.append("update")
|
||||
|
||||
def exitonclick(self) -> None:
|
||||
run.events.append("exitonclick")
|
||||
|
||||
screen = RecordingScreen(canvas)
|
||||
|
||||
def record_draw(pen: DotPen, palette: Sequence[Colour]) -> None:
|
||||
run.events.append("draw_dots")
|
||||
run.palettes.append(palette)
|
||||
run.animation_while_drawing.append(screen.tracer())
|
||||
|
||||
monkeypatch.setattr(turtle, "Screen", lambda: screen)
|
||||
monkeypatch.setattr(hirst_painting, "draw_dots", record_draw)
|
||||
main()
|
||||
return run
|
||||
|
||||
|
||||
def test_main_draws_with_the_palette_of_the_reference_image(
|
||||
main_run: MainRun,
|
||||
) -> None:
|
||||
assert main_run.palettes == [extract_palette(REFERENCE_IMAGE_PATH)]
|
||||
|
||||
|
||||
def test_main_turns_the_animation_off_while_drawing(main_run: MainRun) -> None:
|
||||
assert main_run.animation_while_drawing == [0]
|
||||
|
||||
|
||||
def test_main_shows_the_painting_then_waits_for_a_click(main_run: MainRun) -> None:
|
||||
assert main_run.events == ["draw_dots", "update", "exitonclick"]
|
||||
|
||||
Reference in New Issue
Block a user