testsAndMisc/python_pkg/artgate/tests/test_spec.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

184 lines
7.1 KiB
Python

"""Spec-loading tests.
A malformed spec must raise ``SpecError`` (harness error, exit 2) rather than
producing a permissive spec that silently skips gates -- a spec typo that
turned gates off would be indistinguishable from art that passed.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from python_pkg.artgate.codes import Code
from python_pkg.artgate.spec import SpecError, TargetSpec, load_spec
VALID = """
name = "pixel_icon"
canvas = [32, 32]
palette = ["#1b1d21", "#ffffff"]
max_colors = 16
alpha_binary = true
scale = 1
min_margin = 1
gates = ["ALPHA_NOT_BINARY", "OFF_PALETTE", "TOO_MANY_COLORS"]
"""
def _write(tmp_path: Path, text: str) -> Path:
"""Write a spec file and return its path.
Args:
tmp_path: Pytest temporary directory.
text: TOML contents.
Returns:
The written path.
"""
path = tmp_path / "spec.toml"
path.write_text(text, encoding="utf-8")
return path
class TestValidSpec:
"""A well-formed spec parses into the expected frozen dataclass."""
def test_fields_round_trip(self, tmp_path: Path) -> None:
"""Every declared field is parsed."""
spec = load_spec(_write(tmp_path, VALID))
assert spec.name == "pixel_icon"
assert spec.canvas == (32, 32)
assert spec.max_colors == 16
assert spec.alpha_binary is True
assert spec.min_margin == 1
assert (27, 29, 33) in spec.palette
def test_gate_applicability(self, tmp_path: Path) -> None:
"""``runs`` reports only the declared gates."""
spec = load_spec(_write(tmp_path, VALID))
assert spec.runs(Code.OFF_PALETTE)
assert not spec.runs(Code.SEAM_ENERGY)
def test_defaults_when_optional_fields_absent(self, tmp_path: Path) -> None:
"""Optional fields default, for a gate needing no extra config.
``SILHOUETTE_LOW_CONTRAST`` is used deliberately: it is the only
declarable gate with a self-contained threshold, so it exercises the
defaults without tripping the require-config check.
"""
body = 'name = "m"\ngates = ["SILHOUETTE_LOW_CONTRAST"]\n'
spec = load_spec(_write(tmp_path, body))
assert spec.canvas is None
assert spec.palette == frozenset()
assert spec.max_colors is None
assert spec.scale is None
assert spec.min_margin == 0
assert spec.alpha_binary is False
def test_default_construction_has_no_gates(self) -> None:
"""A bare TargetSpec runs nothing."""
assert not TargetSpec(name="bare").runs(Code.OFF_PALETTE)
class TestMalformedSpec:
"""Every malformed input raises SpecError, never a permissive spec."""
def test_missing_file(self, tmp_path: Path) -> None:
"""A missing spec file is a harness error."""
with pytest.raises(SpecError, match="cannot read spec"):
load_spec(tmp_path / "absent.toml")
def test_invalid_toml(self, tmp_path: Path) -> None:
"""Unparsable TOML is a harness error."""
with pytest.raises(SpecError, match="malformed spec"):
load_spec(_write(tmp_path, "name = [unclosed"))
@pytest.mark.parametrize("body", ["gates = []", 'name = ""\ngates = []'])
def test_missing_name(self, tmp_path: Path, body: str) -> None:
"""A blank or absent name is rejected."""
with pytest.raises(SpecError, match="non-empty 'name'"):
load_spec(_write(tmp_path, body))
@pytest.mark.parametrize("gates", ["", "gates = []", 'gates = "nope"'])
def test_missing_gates(self, tmp_path: Path, gates: str) -> None:
"""A spec with no gates would pass everything, so it is rejected."""
with pytest.raises(SpecError, match="at least one gate"):
load_spec(_write(tmp_path, f'name = "m"\n{gates}\n'))
def test_unknown_gate(self, tmp_path: Path) -> None:
"""A typo in a gate name must not silently disable it."""
with pytest.raises(SpecError, match="unknown gate"):
load_spec(_write(tmp_path, 'name = "m"\ngates = ["NOT_A_GATE"]\n'))
@pytest.mark.parametrize(
"palette",
['palette = "#fff"', 'palette = ["fff"]', 'palette = ["#gggggg"]'],
)
def test_bad_palette(self, tmp_path: Path, palette: str) -> None:
"""Palette entries must be '#rrggbb'."""
body = f'name = "m"\ngates = ["OFF_PALETTE"]\n{palette}\n'
with pytest.raises(SpecError, match="palette"):
load_spec(_write(tmp_path, body))
@pytest.mark.parametrize(
"canvas",
["canvas = 32", "canvas = [32]", "canvas = [0, 32]", 'canvas = ["a","b"]'],
)
def test_bad_canvas(self, tmp_path: Path, canvas: str) -> None:
"""Canvas must be a pair of positive integers."""
body = f'name = "m"\ngates = ["CANVAS_SIZE"]\n{canvas}\n'
with pytest.raises(SpecError, match="canvas must be"):
load_spec(_write(tmp_path, body))
class TestFailClosed:
"""A declared gate that cannot actually run is a spec fault, not a pass.
Regression: ``hand_painted.toml`` declared ``SILHOUETTE_LOW_CONTRAST``
while no implementation existed. The spec parsed, the gate never ran, and
a featureless image scored exit 0 -- indistinguishable from real art
passing. All three mechanisms below are now rejected at load time.
"""
def test_unimplemented_gate_is_rejected(self, tmp_path: Path) -> None:
"""A gate with no implementation cannot be declared."""
body = 'name = "m"\ngates = ["SEAM_ENERGY"]\n'
with pytest.raises(SpecError, match="not implemented"):
load_spec(_write(tmp_path, body))
def test_result_only_code_is_rejected(self, tmp_path: Path) -> None:
"""A result code is emitted by a gate, never requested by a spec."""
body = 'name = "m"\ncanvas = [8, 8]\ngates = ["NO_OPAQUE_PIXELS"]\n'
with pytest.raises(SpecError, match="emitted by gates"):
load_spec(_write(tmp_path, body))
@pytest.mark.parametrize(
("body", "match"),
[
('gates = ["CANVAS_SIZE"]', "CANVAS_SIZE needs"),
('gates = ["TOO_MANY_COLORS"]', "TOO_MANY_COLORS needs"),
('gates = ["OFF_PALETTE"]', "OFF_PALETTE needs"),
('gates = ["ALPHA_NOT_BINARY"]', "ALPHA_NOT_BINARY needs"),
('gates = ["MARGIN_TOO_SMALL"]', "MARGIN_TOO_SMALL needs"),
],
)
def test_gate_without_config_is_rejected(
self, tmp_path: Path, body: str, match: str
) -> None:
"""Declaring a gate without its config would silently skip it."""
with pytest.raises(SpecError, match=match):
load_spec(_write(tmp_path, f'name = "m"\n{body}\n'))
def test_shipped_styles_load(self) -> None:
"""Every style shipped in the package must itself be valid."""
styles = Path(__file__).resolve().parent.parent / "styles"
loaded = [load_spec(p) for p in sorted(styles.glob("*.toml"))]
assert loaded
assert all(spec.gates for spec in loaded)
def test_code_str_is_bare_value() -> None:
"""Codes format without an enum prefix, so JSONL stays greppable."""
assert f"{Code.OFF_PALETTE}" == "OFF_PALETTE"