Files
018-hirst-painting/tests/test_hirst_painting.py
Tirsvad be82070c04 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
2026-10-08 00:20:48 +08:00

403 lines
12 KiB
Python

"""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,
)
STRIPE_WIDTH = 20
def _write_stripes(path: Path, colours: list[Colour]) -> None:
"""Save an image of vertical stripes, one flat stripe per colour."""
image = Image.new("RGB", (STRIPE_WIDTH * len(colours), STRIPE_WIDTH))
for index, colour in enumerate(colours):
left = index * STRIPE_WIDTH
image.paste(colour, (left, 0, left + STRIPE_WIDTH, STRIPE_WIDTH))
image.save(path)
@pytest.mark.parametrize(
"colour",
[
(255, 255, 255),
(240, 240, 240),
(245, 250, 241),
],
)
def test_is_white_shade_when_all_channels_at_or_above_240(colour: Colour) -> None:
assert is_white_shade(colour)
@pytest.mark.parametrize(
"colour",
[
(239, 239, 239),
(255, 255, 239),
(239, 255, 255),
(255, 239, 255),
(200, 30, 40),
(0, 0, 0),
],
)
def test_is_not_white_shade_when_any_channel_below_240(colour: Colour) -> None:
assert not is_white_shade(colour)
def test_remove_white_shades_keeps_order_and_drops_white() -> None:
colours: list[Colour] = [
(255, 255, 255),
(200, 30, 40),
(245, 245, 245),
(30, 90, 160),
(240, 240, 240),
]
assert remove_white_shades(colours) == [(200, 30, 40), (30, 90, 160)]
def test_remove_white_shades_returns_empty_list_when_all_white() -> None:
assert remove_white_shades([(255, 255, 255), (240, 240, 240)]) == []
def test_remove_white_shades_returns_empty_list_when_no_colours() -> None:
assert remove_white_shades([]) == []
def test_remove_white_shades_accepts_any_iterable() -> None:
colours = ((colour, 30, 40) for colour in (200, 255))
assert remove_white_shades(colours) == [(200, 30, 40), (255, 30, 40)]
def test_remove_white_shades_leaves_input_unchanged() -> None:
colours: list[Colour] = [(255, 255, 255), (200, 30, 40)]
remove_white_shades(colours)
assert colours == [(255, 255, 255), (200, 30, 40)]
def test_extract_palette_returns_colours_without_white_shades(tmp_path: Path) -> None:
image_path = tmp_path / "stripes.png"
_write_stripes(
image_path,
[(200, 30, 40), (255, 255, 255), (30, 90, 160), (240, 240, 240)],
)
assert set(extract_palette(image_path)) == {(200, 30, 40), (30, 90, 160)}
def test_extract_palette_returns_requested_count_when_image_has_more_colours(
tmp_path: Path,
) -> None:
image_path = tmp_path / "stripes.png"
_write_stripes(
image_path,
[(200, 30, 40), (30, 90, 160), (20, 120, 60), (90, 40, 130)],
)
assert len(extract_palette(image_path, colour_count=2)) == 2
def test_extract_palette_raises_when_image_is_missing(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError):
extract_palette(tmp_path / "missing.png")
def test_reference_image_palette_has_two_colours_and_no_white_shade() -> None:
palette = extract_palette(REFERENCE_IMAGE_PATH)
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"]