testsAndMisc/python_pkg/artgate/tests/test_routes.py
Krzysztof kuhy Rudnicki e6e0ba5ab3
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: diffusion + 3D routes, full vector coverage, and the margin gate
Closes the remaining bake-off cells. The phase's real result was not a route
score but a MISSING GATE.

All twelve SDXL icons measured 100% opaque coverage -- square tiles with no
sprite silhouette -- and passed every gate in the harness. Gate-pass was
measuring pipeline hygiene, not usability. MARGIN_TOO_SMALL inverts the table
from a meaningless 12/12/12 tie to:

    diffusion   0/12      (every output full-bleed)
    vector     10/12
    procedural 11/12

The two non-diffusion failures were true positives in MY shapes, not gate
error: the procedural sword pommel ran off the bottom edge and the vector meat
bone clipped the top. Both fixed; both routes back to 12/12.

Diffusion recovers to 12/12 with flood-fill background removal, but visual
inspection shows the fill eats into the art (bone reduced to a fragment, sword
nearly erased). Reported as inadequate rather than shipped -- proper matting is
needed before route (b) is a usable pipeline.

Blender exits 0 even when its embedded script raises: BLENDER_EEVEE_NEXT does
not exist in 5.2, and the failure produced a clean exit and no file. render()
now asserts the output exists, or the route would report phantom successes.

Model gating verified at execution time, never recalled: SDXL base and
nerijs/pixel-art-xl are ungated, FLUX.1-dev is gated=auto, and
runwayml/stable-diffusion-v1-5 now 307-redirects.

The finishing stage now generalises across three continuous-tone sources
(SVG raster, Blender render, SDXL): each is 0/N raw and N/N finished.

Verified: 114 tests, 100% branch coverage on 13 modules, zero suppressions,
all hooks pass. Note: a local pytest run masked a missing branch that the
pre-commit hook caught -- the hook's run is authoritative.

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

155 lines
5.7 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_all_twelve_subjects_have_shapes(self) -> None:
"""The vector cell covers the same 12 subjects as every other route.
A cell measured on five easy subjects is not comparable to one
measured on all twelve.
"""
assert {s.name for s in SUBJECTS} <= set(vector.all_shapes())
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")