testsAndMisc/python_pkg/artgate/tests/test_routes.py
Krzysztof kuhy Rudnicki 691a228499
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
artgate: procedural + vector routes, and the shared finishing stage
Closes the two CPU-only cells of the art bake-off and builds the bridge the
diffusion cell will reuse.

Pins the bake-off to the SAME twelve subjects item-icons measured, with its
easy/hard split intact. Without this a route tested on distinctive silhouettes
beats the 6/12 baseline for free and the decision table means nothing. It also
makes the subject-routing rule falsifiable: a generator route is predicted to
lift specifically the six human-prior failures, and if it does not, the rule is
recorded as wrong.

Route (a) procedural: all twelve subjects drawn deterministically from a
7-colour locked palette. Every shape gets a distinguishing feature up front,
because item-icons measured that plain primitives read as placeholders however
correct the geometry is. Machine gate: 12/12 -- which proves "not malformed",
NOT quality. A procedural generator satisfies palette/alpha/canvas constraints
trivially; the human contact sheet is the real verdict.

Route (d) code-as-art: SVG emitted as text and rasterised by rsvg-convert, so
no new Python dependency (cairosvg/svgwrite stay absent).

The structural finding, measured on real rasteriser output rather than a
synthetic stand-in: raw vector renders pass 0/5 -- every one fails
ALPHA_NOT_BINARY, most also TOO_MANY_COLORS. After the finishing stage, 5/5.
Continuous-tone routes are therefore GENERATORS FEEDING A FINISHER, not
pixel-art generators. finish.py binarises alpha before quantising colour,
mirroring the gate order: quantising first blends transparent pixels' arbitrary
RGB into the palette.

Verified: 92 tests, 100% branch coverage across all ten modules, zero
suppressions, all hooks pass. The PLR2004 lint refactor was confirmed
pixel-neutral by byte-comparing all 12 icons before and after (12/12 identical).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9rQ12yZ7sJxiPQTCS5dUF
2026-08-15 22:31:59 +02:00

147 lines
5.3 KiB
Python

"""Route generator tests.
These assert the *contract* a route must satisfy to enter the bake-off, not
that any particular icon looks good -- that is the human gate's job and cannot
be asserted in code.
"""
from __future__ import annotations
import shutil
from typing import TYPE_CHECKING
import numpy as np
import pytest
from python_pkg.artgate.pixels import check_alpha_binary, check_color_count
from python_pkg.artgate.routes import vector
from python_pkg.artgate.routes.procedural import PALETTE, SIZE, _put, outline
from python_pkg.artgate.routes.shapes import DRAWINGS, draw
from python_pkg.artgate.subjects import (
BASELINE_PASS,
BASELINE_TOTAL,
MAX_ROUNDS,
SUBJECTS,
Difficulty,
by_name,
)
if TYPE_CHECKING:
from pathlib import Path
class TestSubjectSet:
"""The bake-off is pinned to one subject set, or comparisons are void."""
def test_twelve_subjects(self) -> None:
"""The set matches the measured item-icons baseline size."""
assert len(SUBJECTS) == BASELINE_TOTAL
def test_six_safe_six_hard(self) -> None:
"""The easy/hard split is preserved exactly."""
safe = [s for s in SUBJECTS if s.difficulty is Difficulty.SAFE]
assert len(safe) == BASELINE_PASS
def test_lookup(self) -> None:
"""Subjects are addressable by name."""
assert by_name("sword").difficulty is Difficulty.HUMAN_PRIOR
def test_unknown_subject_raises(self) -> None:
"""A name outside the fixed set is an error, not a new subject."""
with pytest.raises(KeyError, match="not one of"):
by_name("dragon")
def test_round_cap_is_evidence_backed(self) -> None:
"""Two rounds, because a third produced no measured improvement."""
assert MAX_ROUNDS == 2
def test_difficulty_str(self) -> None:
"""Difficulty formats bare for report tables."""
assert f"{Difficulty.SAFE}" == "SAFE"
class TestProceduralRoute:
"""Every subject renders, and renders inside the spec."""
def test_all_subjects_have_drawings(self) -> None:
"""No subject is silently missing from the route."""
assert {s.name for s in SUBJECTS} == set(DRAWINGS)
@pytest.mark.parametrize("subject", [s.name for s in SUBJECTS])
def test_output_satisfies_pixel_spec(self, subject: str) -> None:
"""Procedural output is gate-clean by construction."""
art = draw(subject)
assert art.shape == (SIZE, SIZE, 4)
assert check_alpha_binary(art) == []
assert check_color_count(art, 32) == []
def test_output_is_not_blank(self) -> None:
"""A route that draws nothing would pass gates vacuously."""
assert (draw("key")[:, :, 3] == 255).sum() > 20
def test_unknown_subject_raises(self) -> None:
"""Asking for an undrawn subject fails loudly."""
with pytest.raises(KeyError, match="no procedural drawing"):
draw("dragon")
def test_out_of_bounds_writes_are_clipped(self) -> None:
"""Drawing past the canvas edge is ignored, not an error.
Shapes are written with plain arithmetic, so a radius or offset that
runs off the canvas must clip silently rather than raise or wrap.
"""
canvas = np.zeros((SIZE, SIZE, 4), dtype=np.uint8)
for x, y in ((-1, 0), (0, -1), (SIZE, 0), (0, SIZE)):
_put(canvas, x, y, "gold")
assert not canvas.any()
def test_outline_surrounds_content(self) -> None:
"""Outlining adds a dark border adjacent to opaque pixels."""
canvas = np.zeros((SIZE, SIZE, 4), dtype=np.uint8)
canvas[10:12, 10:12, :3] = PALETTE["gold"]
canvas[10:12, 10:12, 3] = 255
outline(canvas)
assert tuple(canvas[9, 10, :3]) == PALETTE["outline"]
assert canvas[9, 10, 3] == 255
class TestVectorRoute:
"""SVG rasterisation is available and produces real output."""
def test_unknown_shape_raises(self, tmp_path: Path) -> None:
"""An undefined vector subject fails loudly."""
with pytest.raises(KeyError, match="no vector shape"):
vector.render("dragon", tmp_path / "x.png")
@pytest.mark.skipif(
shutil.which("rsvg-convert") is None,
reason="rsvg-convert not installed",
)
def test_render_writes_png(self, tmp_path: Path) -> None:
"""A rendered subject lands on disk at the requested size."""
out = vector.render("gem", tmp_path / "gem.png", size=64)
assert out.exists()
assert out.stat().st_size > 0
def test_missing_binary_raises(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A missing rasteriser is reported, never silently skipped."""
monkeypatch.setattr(vector.shutil, "which", lambda _: None)
with pytest.raises(vector.RenderError, match="not installed"):
vector.render("gem", tmp_path / "gem.png")
def test_rasteriser_failure_raises(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A non-zero exit from the rasteriser is surfaced."""
class _Result:
returncode = 1
stderr = b"boom"
monkeypatch.setattr(vector.shutil, "which", lambda _: "/bin/true")
monkeypatch.setattr(vector.subprocess, "run", lambda *a, **k: _Result())
with pytest.raises(vector.RenderError, match="failed"):
vector.render("gem", tmp_path / "gem.png")