reach godot parse-sweep / cold-parse, reach visual diff / blank-check / thumbnail. Five scripts retired, and the callers rewired — tests/run-visual invoked three of them by path at four sites, which is a wider blast radius than the make targets were. The godot pair were grep pipelines encoding five hard-won lessons as comments nobody could test. They are Python filters now, with the reasons attached, and the engine invocation is a guarded exec. Verified on the real client: 229 scripts, clean. Their three not-ok states stay distinct, because only one is a verdict about the code. An engine that crashed or is missing is not a parse failure — reporting it as one blames the tree for a broken toolchain. A sweep that emitted no completion marker checked nothing, and zero errors from a check that never ran reads as clean, which is the false-green the sweep exists to close. The deliberate asymmetry between the two checks is preserved and documented: cold-parse filters "Cannot infer the type", the sweep does not, because that suppression is why cold-parse stayed silent about a helper that genuinely does not parse. All three visual scripts carried the same root bug as validate-checklist: Path(__file__).parent.parent, correct at tooling/ and two levels too deep at tooling/domains/visual. Fixed during the move rather than after, having learned that it fails silently — paths resolve to nothing, the work appears to have nothing to do, and the tool reports success. Three domains now where that would have shipped a false pass. Two bugs my own transformation introduced, both found by running rather than reading. Multi-line print(..., file=sys.stderr) became console.event(..., file=sys.stderr), and console puts unknown kwargs into the payload — a file object would have reached json.dumps at the exact moment something was already being reported as an error. And the replacement script wrote escaped quotes into three files. Mechanical transformations need mechanical verification. sys.exit removed from four sites: a service must not end the process. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
90 lines
3.2 KiB
Python
90 lines
3.2 KiB
Python
"""Transport for the `visual` domain — args in, delegate, format out."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import typer
|
|
|
|
from tooling.core import cli, console
|
|
from tooling.core.command import command
|
|
from tooling.core.errors import ReachError
|
|
from tooling.domains.visual import blank_check as blank_module
|
|
from tooling.domains.visual import diff as diff_module
|
|
from tooling.domains.visual import thumbnail as thumbnail_module
|
|
|
|
app = cli.domain("visual", "Compare captures against goldens, and catch blank ones.")
|
|
|
|
|
|
@app.callback()
|
|
def _domain() -> None:
|
|
"""Keeps `visual` a group (Typer collapses a single-command app)."""
|
|
|
|
|
|
@app.command("diff")
|
|
@command
|
|
def diff(
|
|
expected: str = typer.Argument(..., help="Path to the golden PNG."),
|
|
actual: str = typer.Argument(..., help="Path to the captured PNG."),
|
|
tolerance: int = typer.Option(None, "--tolerance", help="Per-channel pixel tolerance."),
|
|
max_diff_pct: float = typer.Option(
|
|
None, "--max-diff-pct", help="Maximum allowed differing percentage."
|
|
),
|
|
diff_output: str = typer.Option(
|
|
None, "--diff-output", help="Write a PNG highlighting the changed pixels."
|
|
),
|
|
config: str = typer.Option(None, "--config", help="Path to tests/visual.json."),
|
|
) -> None:
|
|
"""Pixel-level comparison of a capture against its golden."""
|
|
code = diff_module.run(expected, actual, tolerance, max_diff_pct, diff_output, config)
|
|
if code == 0:
|
|
return
|
|
raise ReachError(
|
|
f"visual-diff: {expected} and {actual} differ beyond the threshold",
|
|
fix="inspect the diff PNG with --diff-output, or update the golden with "
|
|
"make visual-update if the change is intended",
|
|
exit_code=code,
|
|
)
|
|
|
|
|
|
@app.command("blank-check")
|
|
@command
|
|
def blank_check(
|
|
path: Path = typer.Argument(..., help="The capture to inspect."),
|
|
max_modal: float = typer.Option(
|
|
blank_module.DEFAULT_MAX_MODAL,
|
|
"--max-modal",
|
|
help="Fail above this single-colour fraction.",
|
|
),
|
|
quiet: bool = typer.Option(False, "--quiet", help="Say nothing when the frame has content."),
|
|
) -> None:
|
|
"""Fail if a capture is overwhelmingly one colour."""
|
|
code = blank_module.run(path, max_modal, quiet)
|
|
if code == 0:
|
|
return
|
|
raise ReachError(
|
|
f"visual-blank-check: {path} has no content",
|
|
fix="the renderer drew nothing but chrome — check the scenario actually "
|
|
"loaded before recording a golden from this",
|
|
exit_code=code,
|
|
)
|
|
|
|
|
|
@app.command("thumbnail")
|
|
@command
|
|
def thumbnail(
|
|
target: Path = typer.Argument(..., help="Capture directory, or an image in crop mode."),
|
|
crop: str = typer.Option(None, "--crop", help="Extract this named region instead."),
|
|
config: Path = typer.Option(None, "--config", help="Config JSON path."),
|
|
) -> None:
|
|
"""Build a contact sheet from a flow capture, or crop a named region."""
|
|
code = thumbnail_module.run(target, crop, config)
|
|
if code == 0:
|
|
console.verdict(f"visual-thumbnail: OK — {target}")
|
|
return
|
|
raise ReachError(
|
|
f"visual-thumbnail: failed for {target}",
|
|
fix="check the target exists and the config names the region you asked for",
|
|
exit_code=code,
|
|
)
|