feat(config): T-1283 — the godot and visual domains
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>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"""The `godot` domain — does the client parse, and does it parse cold.
|
||||
|
||||
Two checks with a deliberate division of labour that the names do not convey,
|
||||
so it is restated here: `cold-parse` only ever sees scripts on the STARTUP path
|
||||
(autoloads plus the main scene chain), which is correct for the registration
|
||||
ORDER bug it was built for and far narrower than its name implies.
|
||||
`parse-sweep` opens every .gd in the project.
|
||||
|
||||
Verified 2026-07-27 by breaking a non-startup UI script and a test file in turn:
|
||||
cold-parse reported clean, exit 0, for both.
|
||||
"""
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Transport for the `godot` domain — args in, delegate, format out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
|
||||
from tooling.core import cli, console
|
||||
from tooling.core.command import command
|
||||
from tooling.core.errors import ReachError
|
||||
from tooling.domains.godot import service
|
||||
from tooling.domains.godot.schemas import ParseResult
|
||||
|
||||
app = cli.domain("godot", "Does the client parse, and does it parse cold.")
|
||||
|
||||
|
||||
@app.callback()
|
||||
def _domain() -> None:
|
||||
"""Keeps `godot` a group (Typer collapses a single-command app)."""
|
||||
|
||||
|
||||
def _verdict(result: ParseResult, name: str, clean: str) -> None:
|
||||
"""Shared rendering — three failure shapes, three different remedies."""
|
||||
if result.engine_failed:
|
||||
raise ReachError(
|
||||
f"{name}: godot itself exited {result.engine_exit} — not a parse verdict\n"
|
||||
+ "\n".join(f" {line}" for line in result.lines),
|
||||
fix="the engine failed rather than the code; check the Godot install "
|
||||
"with `godot --version`, or make setup-godot",
|
||||
exit_code=result.engine_exit,
|
||||
)
|
||||
|
||||
if result.did_not_run:
|
||||
raise ReachError(
|
||||
f"{name}: the sweep did not report completion — no verdict possible\n"
|
||||
+ "\n".join(f" {line}" for line in result.lines),
|
||||
fix="client/tools/parse_sweep.gd did not run to completion; a check "
|
||||
"that examined nothing must not report clean",
|
||||
)
|
||||
|
||||
if result.lines:
|
||||
shown = result.lines[:40]
|
||||
raise ReachError(
|
||||
f"{name}: at least one script does not parse\n"
|
||||
+ "\n".join(f" {line}" for line in shown)
|
||||
+ (f"\n {result.summary}" if result.summary else ""),
|
||||
fix="an unparseable file cannot run — if it is a test suite it did "
|
||||
"not execute, and any pass count reported elsewhere excludes it",
|
||||
)
|
||||
|
||||
console.verdict(clean)
|
||||
|
||||
|
||||
@app.command("parse-sweep")
|
||||
@command
|
||||
def parse_sweep() -> None:
|
||||
"""Open every project .gd and fail on any that will not parse."""
|
||||
result = service.parse_sweep()
|
||||
_verdict(
|
||||
result,
|
||||
"godot-parse-sweep",
|
||||
f"godot-parse-sweep: clean — {result.summary.removeprefix('parse-sweep: ')}",
|
||||
)
|
||||
|
||||
|
||||
@app.command("cold-parse")
|
||||
@command
|
||||
def cold_parse(
|
||||
run_menu: bool = typer.Option(
|
||||
False, "--run-menu", help="Also launch main_menu.tscn briefly, for UI branches."
|
||||
),
|
||||
) -> None:
|
||||
"""Cold-cache startup parse — the class_name/autoload registration race."""
|
||||
_verdict(service.cold_parse(run_menu), "godot-cold-parse", "godot-cold-parse: clean")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Data shapes for the `godot` domain."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ParseResult(BaseModel):
|
||||
"""The outcome of a parse check.
|
||||
|
||||
Three distinct not-ok states, kept apart because they mean different things
|
||||
and only one of them is a verdict about the code:
|
||||
|
||||
- `engine_failed` — godot itself crashed or is missing. NOT a parse verdict,
|
||||
and reporting it as one would blame the tree for a broken toolchain.
|
||||
- `did_not_run` — the sweep produced no completion marker, so it checked
|
||||
nothing. Zero errors from a check that never ran reads as clean, which is
|
||||
the false-green this exists to close.
|
||||
- `lines` non-empty — genuine parse failures.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
lines: list[str] = []
|
||||
summary: str = ""
|
||||
engine_failed: bool = False
|
||||
engine_exit: int = 0
|
||||
did_not_run: bool = False
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not (self.lines or self.engine_failed or self.did_not_run)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Logic for the `godot` domain. Transport-agnostic (D-263).
|
||||
|
||||
Ported from two bash scripts. What was shell — running the engine — stays a
|
||||
guarded exec; what was grep pipelines deciding a verdict is Python now, which
|
||||
is the whole point: these filters encode five separate hard-won lessons and
|
||||
each was a comment in a pipeline nobody could test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from tooling.core import config, console, process
|
||||
from tooling.domains.godot.schemas import ParseResult
|
||||
|
||||
# The GDScript half only OPENS files; it makes no verdict, because no Godot API
|
||||
# reports parse failure reliably — one segfaults, one false-positives 150/226,
|
||||
# and plain load() returns non-null for a broken script. The engine's own
|
||||
# stderr is the only honest signal, so the verdict is scraped from it.
|
||||
_SWEEP_RAN = re.compile(r"^parse-sweep: opened", re.MULTILINE)
|
||||
_SWEEP_ERRORS = re.compile(r"Parse Error|Failed to load script")
|
||||
_COLD_ERRORS = re.compile(r"^(SCRIPT )?ERROR|Parse Error|Export type", re.IGNORECASE)
|
||||
|
||||
# Imported assets "fail loading" on a cold tree before the import pass; that is
|
||||
# noise, not a parse failure.
|
||||
_ASSET_NOISE = "Failed loading resource: res://assets"
|
||||
|
||||
# Filtered by cold-parse ONLY. Deliberately NOT filtered by the sweep: that
|
||||
# suppression is why cold-parse stayed silent about a test helper that
|
||||
# genuinely does not parse — the muted class was hiding a real failure.
|
||||
_COLD_ONLY_NOISE = (
|
||||
"Cannot infer the type",
|
||||
)
|
||||
_COLD_NOT_DECLARED = re.compile(
|
||||
r'(Messagepack|LocalBridge|ServerProcess|Constants)" not declared'
|
||||
)
|
||||
|
||||
|
||||
def parse_sweep() -> ParseResult:
|
||||
"""Open every project .gd and report any that will not parse."""
|
||||
result = _godot("-s", "res://tools/parse_sweep.gd")
|
||||
if result.returncode != 0:
|
||||
return ParseResult(
|
||||
engine_failed=True,
|
||||
engine_exit=result.returncode,
|
||||
lines=_tail(result),
|
||||
)
|
||||
|
||||
raw = result.stdout + result.stderr
|
||||
if not _SWEEP_RAN.search(raw):
|
||||
# Without this the sweep could silently stop walking — a renamed script
|
||||
# or a broken loop yields zero error lines, which reads as clean. That
|
||||
# is the same false-green this tool exists to close.
|
||||
return ParseResult(
|
||||
did_not_run=True,
|
||||
lines=_tail(result),
|
||||
)
|
||||
|
||||
summary = next(
|
||||
(line for line in raw.splitlines() if line.startswith("parse-sweep: opened")), ""
|
||||
)
|
||||
errors = [
|
||||
line
|
||||
for line in raw.splitlines()
|
||||
if _SWEEP_ERRORS.search(line) and _ASSET_NOISE not in line
|
||||
]
|
||||
return ParseResult(summary=summary, lines=errors)
|
||||
|
||||
|
||||
def cold_parse(run_menu: bool = False) -> ParseResult:
|
||||
"""Cold-cache startup parse — the class_name/autoload registration race."""
|
||||
client = config.path("client")
|
||||
cache = client / ".godot" / "global_script_class_cache.cfg"
|
||||
cache.unlink(missing_ok=True)
|
||||
|
||||
imported = client / ".godot" / "imported"
|
||||
if not imported.is_dir() or not any(imported.iterdir()):
|
||||
# A truly cold checkout has no import cache, and every imported asset
|
||||
# then "fails loading" during the parse run — a wall of false
|
||||
# positives. Found live in a fresh worktree, 2026-07-13.
|
||||
console.event("no import cache — running one-time import pass", level="warn")
|
||||
seeded = _godot("--import")
|
||||
if seeded.returncode != 0:
|
||||
return ParseResult(
|
||||
engine_failed=True, engine_exit=seeded.returncode, lines=_tail(seeded)
|
||||
)
|
||||
|
||||
result = _godot("--quit")
|
||||
if result.returncode != 0:
|
||||
return ParseResult(
|
||||
engine_failed=True, engine_exit=result.returncode, lines=_tail(result)
|
||||
)
|
||||
|
||||
errors = _cold_filter((result.stdout + result.stderr).splitlines())
|
||||
|
||||
if run_menu:
|
||||
# No exit-code check: `timeout` kills the menu by design, so only the
|
||||
# scraped lines carry signal for this bounded run.
|
||||
menu = process.run(
|
||||
["timeout", "10", "godot", "--path", str(client), "res://scenes/main_menu.tscn"],
|
||||
check=False,
|
||||
missing_fix="install Godot — make setup-godot",
|
||||
)
|
||||
errors += _cold_filter((menu.stdout + menu.stderr).splitlines())
|
||||
|
||||
# Restore a FULL class cache before returning. The cold run re-seeds it only
|
||||
# partially — addon classes are missing, which leaves the gdUnit4 runner
|
||||
# unable to start at all (0 tests in ~350ms; found live when the push gate
|
||||
# ran the suite straight after this check, 2026-07-14). The verdict above is
|
||||
# already decided; this just leaves the tree runnable.
|
||||
_godot("--import")
|
||||
|
||||
return ParseResult(lines=errors)
|
||||
|
||||
|
||||
def _cold_filter(lines: list[str]) -> list[str]:
|
||||
return [
|
||||
line
|
||||
for line in lines
|
||||
if _COLD_ERRORS.search(line)
|
||||
and _ASSET_NOISE not in line
|
||||
and not any(noise in line for noise in _COLD_ONLY_NOISE)
|
||||
and not _COLD_NOT_DECLARED.search(line)
|
||||
]
|
||||
|
||||
|
||||
def _godot(*args: str):
|
||||
return process.run(
|
||||
["godot", "--headless", "--path", str(config.path("client")), *args],
|
||||
check=False,
|
||||
missing_fix="install Godot — make setup-godot",
|
||||
)
|
||||
|
||||
|
||||
def _tail(result, count: int = 20) -> list[str]:
|
||||
return (result.stdout + result.stderr).splitlines()[-count:]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""The `visual` domain — comparing captures, and catching empty ones.
|
||||
|
||||
`blank-check` deserves its place beside `diff`: a capture that is 99% one
|
||||
colour will match any other blank capture forever, so a golden recorded from
|
||||
one is a test that can never fail. `diff` alone cannot see that — two blank
|
||||
images are identical.
|
||||
"""
|
||||
@@ -38,9 +38,10 @@ Usage:
|
||||
Exit: 0 = has content, 1 = blank, 2 = error (unreadable/missing).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from tooling.core import console
|
||||
|
||||
DEFAULT_MAX_MODAL = 0.85
|
||||
|
||||
|
||||
@@ -49,7 +50,7 @@ def modal_fraction(path: Path) -> tuple[float, int]:
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
print("visual-blank-check: Pillow not available", file=sys.stderr)
|
||||
console.event("visual-blank-check: Pillow not available", level="error")
|
||||
raise SystemExit(2)
|
||||
|
||||
with Image.open(path) as im:
|
||||
@@ -67,39 +68,24 @@ def modal_fraction(path: Path) -> tuple[float, int]:
|
||||
return top / total, len(colors)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = [a for a in argv if not a.startswith("--")]
|
||||
if not args:
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
max_modal = DEFAULT_MAX_MODAL
|
||||
for i, a in enumerate(argv):
|
||||
if a == "--max-modal" and i + 1 < len(argv):
|
||||
max_modal = float(argv[i + 1])
|
||||
quiet = "--quiet" in argv
|
||||
|
||||
path = Path(args[0])
|
||||
def run(path, max_modal: float = DEFAULT_MAX_MODAL, quiet: bool = False) -> int:
|
||||
"""Fail if a capture is overwhelmingly one colour. Returns 0, 1 or 2."""
|
||||
if not path.is_file():
|
||||
print(f"visual-blank-check: no such file: {path}", file=sys.stderr)
|
||||
console.event(f"visual-blank-check: no such file: {path}", level="error")
|
||||
return 2
|
||||
|
||||
modal, distinct = modal_fraction(path)
|
||||
|
||||
if modal > max_modal:
|
||||
print(
|
||||
console.event(
|
||||
f"BLANK: {path.name} is {modal:.1%} a single colour "
|
||||
f"({distinct} distinct) — the renderer drew nothing but chrome. "
|
||||
f"A golden recorded from this would pass against any other blank "
|
||||
f"capture forever.",
|
||||
file=sys.stderr,
|
||||
level="error",
|
||||
)
|
||||
return 1
|
||||
|
||||
if not quiet:
|
||||
print(f"content: {path.name} modal={modal:.1%} distinct={distinct}")
|
||||
console.event(f"content: {path.name} modal={modal:.1%} distinct={distinct}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -13,14 +13,15 @@ Exit codes:
|
||||
2 = size mismatch or fatal error
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
from tooling.core import config, console
|
||||
from tooling.core.errors import ReachError
|
||||
|
||||
ROOT = config.repo_root()
|
||||
DEFAULT_CONFIG = ROOT / "tests" / "visual.json"
|
||||
DEFAULT_TOLERANCE = 5
|
||||
|
||||
@@ -61,8 +62,12 @@ def _read_png_stdlib(path: str) -> tuple[int, int, bytes]:
|
||||
with open(path, "rb") as f:
|
||||
sig = f.read(8)
|
||||
if sig != b"\x89PNG\r\n\x1a\n":
|
||||
print(f"ERROR: {path} is not a valid PNG", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
console.event(f"ERROR: {path} is not a valid PNG", level="error")
|
||||
raise ReachError(
|
||||
"visual-diff: unreadable PNG",
|
||||
fix="the file is not an 8-bit RGBA PNG — re-capture it",
|
||||
exit_code=2,
|
||||
)
|
||||
|
||||
width = height = 0
|
||||
bit_depth = color_type = 0
|
||||
@@ -81,17 +86,25 @@ def _read_png_stdlib(path: str) -> tuple[int, int, bytes]:
|
||||
">IIBB", data[:10]
|
||||
)
|
||||
if color_type != 6:
|
||||
print(
|
||||
console.event(
|
||||
f"ERROR: {path} has color type {color_type}, expected 6 (RGBA)",
|
||||
file=sys.stderr,
|
||||
level="error",
|
||||
)
|
||||
sys.exit(2)
|
||||
raise ReachError(
|
||||
"visual-diff: unreadable PNG",
|
||||
fix="the file is not an 8-bit RGBA PNG — re-capture it",
|
||||
exit_code=2,
|
||||
)
|
||||
if bit_depth != 8:
|
||||
print(
|
||||
console.event(
|
||||
f"ERROR: {path} has bit depth {bit_depth}, expected 8",
|
||||
file=sys.stderr,
|
||||
level="error",
|
||||
)
|
||||
sys.exit(2)
|
||||
raise ReachError(
|
||||
"visual-diff: unreadable PNG",
|
||||
fix="the file is not an 8-bit RGBA PNG — re-capture it",
|
||||
exit_code=2,
|
||||
)
|
||||
elif chunk_type == b"IDAT":
|
||||
idat_chunks.append(data)
|
||||
elif chunk_type == b"IEND":
|
||||
@@ -132,11 +145,15 @@ def _read_png_stdlib(path: str) -> tuple[int, int, bytes]:
|
||||
elif filter_type == 4: # Paeth
|
||||
val = (cur + _paeth(a, b, c)) & 0xFF
|
||||
else:
|
||||
print(
|
||||
console.event(
|
||||
f"ERROR: unknown PNG filter type {filter_type} at row {y}",
|
||||
file=sys.stderr,
|
||||
level="error",
|
||||
)
|
||||
sys.exit(2)
|
||||
raise ReachError(
|
||||
"visual-diff: unreadable PNG",
|
||||
fix="the file is not an 8-bit RGBA PNG — re-capture it",
|
||||
exit_code=2,
|
||||
)
|
||||
|
||||
pixels[row_start + x] = val
|
||||
|
||||
@@ -262,66 +279,50 @@ def load_config(config_path: Path | None) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Pixel-level visual diff for golden image comparison."
|
||||
)
|
||||
parser.add_argument("expected", help="Path to golden PNG")
|
||||
parser.add_argument("actual", help="Path to captured PNG")
|
||||
parser.add_argument(
|
||||
"--tolerance",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Per-channel pixel tolerance (default: from config or 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-diff-pct",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Max allowed diff percentage (default: from config or 0.0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diff-output",
|
||||
default=None,
|
||||
help="Path to write diff PNG highlighting changed pixels",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default=None,
|
||||
help="Path to tests/visual.json (default: auto-detect)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
def run(
|
||||
expected: str,
|
||||
actual: str,
|
||||
tolerance: int | None = None,
|
||||
max_diff_pct: float | None = None,
|
||||
diff_output: str | None = None,
|
||||
config_path_arg: str | None = None,
|
||||
) -> int:
|
||||
"""Pixel-level visual diff. Returns 0 pass, 1 fail, 2 unusable input.
|
||||
|
||||
The argparse parser that used to live here is gone: the router declares the
|
||||
options, and a parser inside a service would be a second transport layer
|
||||
(D-263)."""
|
||||
|
||||
# Resolve settings: CLI > config > fallback
|
||||
config_path = Path(args.config) if args.config else None
|
||||
config_path = Path(config_path_arg) if config_path_arg else None
|
||||
cfg = load_config(config_path)
|
||||
tolerance = args.tolerance if args.tolerance is not None else cfg["tolerance"]
|
||||
max_diff_pct = args.max_diff_pct if args.max_diff_pct is not None else cfg["max_diff_pct"]
|
||||
tolerance = tolerance if tolerance is not None else cfg["tolerance"]
|
||||
max_diff_pct = max_diff_pct if max_diff_pct is not None else cfg["max_diff_pct"]
|
||||
|
||||
# Read images
|
||||
try:
|
||||
ew, eh, epx = read_png(args.expected)
|
||||
ew, eh, epx = read_png(expected)
|
||||
except FileNotFoundError:
|
||||
print(f"ERROR: expected image not found: {args.expected}", file=sys.stderr)
|
||||
console.event(f"ERROR: expected image not found: {expected}", level="error")
|
||||
return 2
|
||||
except Exception as exc:
|
||||
print(f"ERROR: failed to read expected image: {exc}", file=sys.stderr)
|
||||
console.event(f"ERROR: failed to read expected image: {exc}", level="error")
|
||||
return 2
|
||||
|
||||
try:
|
||||
aw, ah, apx = read_png(args.actual)
|
||||
aw, ah, apx = read_png(actual)
|
||||
except FileNotFoundError:
|
||||
print(f"ERROR: actual image not found: {args.actual}", file=sys.stderr)
|
||||
console.event(f"ERROR: actual image not found: {actual}", level="error")
|
||||
return 2
|
||||
except Exception as exc:
|
||||
print(f"ERROR: failed to read actual image: {exc}", file=sys.stderr)
|
||||
console.event(f"ERROR: failed to read actual image: {exc}", level="error")
|
||||
return 2
|
||||
|
||||
# Size check
|
||||
if ew != aw or eh != ah:
|
||||
print(
|
||||
console.event(
|
||||
f"ERROR: size mismatch — expected {ew}x{eh}, actual {aw}x{ah}",
|
||||
file=sys.stderr,
|
||||
level="error",
|
||||
)
|
||||
return 2
|
||||
|
||||
@@ -330,23 +331,20 @@ def main() -> int:
|
||||
total = ew * eh
|
||||
|
||||
if diff_count == 0:
|
||||
print(f"PASS: images match ({ew}x{eh})")
|
||||
console.event(f"PASS: images match ({ew}x{eh})")
|
||||
return 0
|
||||
|
||||
pct = diff_count / total * 100
|
||||
|
||||
if pct <= max_diff_pct:
|
||||
print(f"PASS: {diff_count} of {total} pixels differ ({pct:.1f}%, within {max_diff_pct}% threshold)")
|
||||
console.event(f"PASS: {diff_count} of {total} pixels differ ({pct:.1f}%, within {max_diff_pct}% threshold)")
|
||||
return 0
|
||||
|
||||
print(f"FAIL: {diff_count} of {total} pixels differ ({pct:.1f}%)")
|
||||
console.event(f"FAIL: {diff_count} of {total} pixels differ ({pct:.1f}%)")
|
||||
|
||||
if args.diff_output and diff_buf:
|
||||
Path(args.diff_output).parent.mkdir(parents=True, exist_ok=True)
|
||||
write_png(args.diff_output, ew, eh, diff_buf)
|
||||
if diff_output and diff_buf:
|
||||
Path(diff_output).parent.mkdir(parents=True, exist_ok=True)
|
||||
write_png(diff_output, ew, eh, diff_buf)
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,89 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -18,18 +18,22 @@ Config: reads thumbnail dimensions, columns, and crop regions from
|
||||
tests/visual.json (auto-detected from script location, or --config).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from tooling.core import config, console
|
||||
from tooling.core.errors import ReachError
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw
|
||||
except ImportError:
|
||||
print("visual-thumbnail requires Pillow: pip install Pillow", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
console.event("visual-thumbnail requires Pillow: pip install Pillow", level="error")
|
||||
raise ReachError(
|
||||
"visual-thumbnail: config not found",
|
||||
fix="pass --config, or restore tests/visual.json",
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ROOT = config.repo_root()
|
||||
DEFAULT_CONFIG = ROOT / "tests" / "visual.json"
|
||||
|
||||
# Fallbacks when config keys are missing
|
||||
@@ -42,8 +46,8 @@ LABEL_HEIGHT = 24 # pixels reserved below each thumbnail for text
|
||||
def load_config(config_path: Path) -> dict:
|
||||
"""Load configuration from JSON file."""
|
||||
if not config_path.exists():
|
||||
print(f"Config not found: {config_path}", file=sys.stderr)
|
||||
print("Continuing with built-in defaults.", file=sys.stderr)
|
||||
console.event(f"Config not found: {config_path}", level="error")
|
||||
console.event("Continuing with built-in defaults.", level="error")
|
||||
return {}
|
||||
with open(config_path) as f:
|
||||
return json.load(f)
|
||||
@@ -81,9 +85,9 @@ def detect_flow(directory: Path) -> str | None:
|
||||
stem = manifests[0].stem
|
||||
return stem.removesuffix("_manifest")
|
||||
if len(manifests) > 1:
|
||||
print(f"Multiple manifests found in {directory}:", file=sys.stderr)
|
||||
console.event(f"Multiple manifests found in {directory}:", level="error")
|
||||
for m in manifests:
|
||||
print(f" {m.name}", file=sys.stderr)
|
||||
console.event(f" {m.name}", level="error")
|
||||
return None
|
||||
return None
|
||||
|
||||
@@ -92,18 +96,18 @@ def grid_mode(directory: Path, config: dict) -> int:
|
||||
"""Generate a contact sheet from flow captures."""
|
||||
directory = directory.resolve()
|
||||
if not directory.is_dir():
|
||||
print(f"Not a directory: {directory}", file=sys.stderr)
|
||||
console.event(f"Not a directory: {directory}", level="error")
|
||||
return 1
|
||||
|
||||
flow = detect_flow(directory)
|
||||
if flow is None:
|
||||
print(f"No manifest found in {directory}. Expected {{flow}}_manifest.txt", file=sys.stderr)
|
||||
console.event(f"No manifest found in {directory}. Expected {{flow}}_manifest.txt", level="error")
|
||||
return 1
|
||||
|
||||
manifest_path = directory / f"{flow}_manifest.txt"
|
||||
entries = parse_manifest(manifest_path)
|
||||
if not entries:
|
||||
print(f"Empty manifest: {manifest_path}", file=sys.stderr)
|
||||
console.event(f"Empty manifest: {manifest_path}", level="error")
|
||||
return 1
|
||||
|
||||
# Read thumbnail config
|
||||
@@ -123,7 +127,7 @@ def grid_mode(directory: Path, config: dict) -> int:
|
||||
for idx, entry in enumerate(entries):
|
||||
frame_file = directory / f"{flow}_{entry['frame']}.png"
|
||||
if not frame_file.exists():
|
||||
print(f" Missing frame: {frame_file.name}", file=sys.stderr)
|
||||
console.event(f" Missing frame: {frame_file.name}", level="error")
|
||||
continue
|
||||
|
||||
img = Image.open(frame_file)
|
||||
@@ -148,7 +152,7 @@ def grid_mode(directory: Path, config: dict) -> int:
|
||||
|
||||
output_path = directory / f"{flow}_sheet.png"
|
||||
sheet.save(output_path)
|
||||
print(f"Sheet: {output_path}")
|
||||
console.event(f"Sheet: {output_path}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -156,19 +160,19 @@ def crop_mode(region_name: str, image_path: Path, config: dict) -> int:
|
||||
"""Extract a named crop region from an image at 1:1 scale."""
|
||||
image_path = image_path.resolve()
|
||||
if not image_path.exists():
|
||||
print(f"Image not found: {image_path}", file=sys.stderr)
|
||||
console.event(f"Image not found: {image_path}", level="error")
|
||||
return 1
|
||||
|
||||
crops = config.get("crops", {})
|
||||
if region_name not in crops:
|
||||
available = ", ".join(sorted(crops.keys())) if crops else "(none)"
|
||||
print(f"Unknown crop region: {region_name}", file=sys.stderr)
|
||||
print(f"Available regions: {available}", file=sys.stderr)
|
||||
console.event(f"Unknown crop region: {region_name}", level="error")
|
||||
console.event(f"Available regions: {available}", level="error")
|
||||
return 1
|
||||
|
||||
coords = crops[region_name]
|
||||
if not isinstance(coords, list) or len(coords) != 4:
|
||||
print(f"Invalid crop coords for '{region_name}': expected [x, y, w, h]", file=sys.stderr)
|
||||
console.event(f"Invalid crop coords for '{region_name}': expected [x, y, w, h]", level="error")
|
||||
return 1
|
||||
|
||||
x, y, w, h = coords
|
||||
@@ -179,39 +183,19 @@ def crop_mode(region_name: str, image_path: Path, config: dict) -> int:
|
||||
suffix = image_path.suffix
|
||||
output_path = image_path.parent / f"{stem}_crop_{region_name}{suffix}"
|
||||
cropped.save(output_path)
|
||||
print(output_path)
|
||||
console.event(output_path)
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Contact sheet and crop tool for visual QA flow captures.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config", type=Path, default=DEFAULT_CONFIG,
|
||||
help=f"Config JSON path (default: {DEFAULT_CONFIG.relative_to(ROOT)})",
|
||||
)
|
||||
def run(target, crop: str | None = None, config_path=None) -> int:
|
||||
"""Contact sheet (grid mode) or named-region crop, depending on `crop`.
|
||||
|
||||
# Crop mode
|
||||
parser.add_argument(
|
||||
"--crop", metavar="REGION",
|
||||
help="Crop mode: extract named region from IMAGE",
|
||||
)
|
||||
|
||||
# Positional: DIR (grid mode) or IMAGE (crop mode)
|
||||
parser.add_argument(
|
||||
"target", type=Path,
|
||||
help="Directory of flow captures (grid mode) or image file (crop mode)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
config = load_config(args.config)
|
||||
|
||||
if args.crop:
|
||||
return crop_mode(args.crop, args.target, config)
|
||||
else:
|
||||
return grid_mode(args.target, config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Two modes behind one entry, as the original had them. The router exposes
|
||||
them as one verb with a flag rather than two, because the second argument
|
||||
changes what `target` MEANS — a directory in grid mode, a file in crop
|
||||
mode — and two verbs would each have to re-explain that.
|
||||
"""
|
||||
config = load_config(config_path or DEFAULT_CONFIG)
|
||||
if crop:
|
||||
return crop_mode(crop, target, config)
|
||||
return grid_mode(target, config)
|
||||
@@ -55,6 +55,14 @@ DOMAINS: dict[str, tuple[str, str]] = {
|
||||
"tooling.domains.validate.router:app",
|
||||
"Content, checklists and RON against their schemas",
|
||||
),
|
||||
"godot": (
|
||||
"tooling.domains.godot.router:app",
|
||||
"Does the client parse, and does it parse cold",
|
||||
),
|
||||
"visual": (
|
||||
"tooling.domains.visual.router:app",
|
||||
"Compare captures against goldens, and catch blank ones",
|
||||
),
|
||||
"jobs": (
|
||||
"tooling.domains.jobs.router:app",
|
||||
"Detached runs — status, logs and outcomes",
|
||||
|
||||
Reference in New Issue
Block a user