`reach character {logo, strip-glb, qa, qa-analyze}` replaces make_logo.py,
glb_strip_utility_nodes.py, analyze_captures.py and the run-garment-qa bash
driver. The QA configs and method doc move beside the domain (qa_configs/,
GARMENT_QA.md), and `qa` takes a config name (`reach character qa hoodie_modern`)
or a path.
Parity, from baselines taken before anything moved:
- the logo PNG is byte-identical
- a synthetic GLB with three real utility nodes strips to identical bytes
(the committed bodies strip 0 nodes, so they proved nothing)
- re-analyzing a cached capture set gives a byte-identical report.json and
summary
run-garment-qa is rewritten, not wrapped (D-263). Its decisions — which config,
which Godot ($GODOT, then ~/bin/godot4, then PATH), and whether xvfb-run is
needed — are capture_plan(), pinned by tooling/test_character.py without
launching Godot. The bash exit codes are kept: 2 for a missing config, 3 for
no Godot.
The T-1271 domain map was wrong about this domain. Six of its ten files import
bpy: convert_outfit, inspect_glb, check_hair_symmetry, check_icosphere,
render_quaternius_test and test_quaternius_raw. They are Blender payloads and
joined the carve-out as blender_* (41 payloads now). The 22 existing payloads'
docstrings still cited tooling/garment-fit/ from before T-1273; fixed.
Archived, with reasons in tooling/archive/README.md:
- setup_clothing_metadata.py wrote coverage data for five garments that no
longer exist in the 24-garment wardrobe
- wipe-bodies.sh ran raw DELETEs on systems.db
segment_reference_distribution.md moved to docs/assets/visual/.
Behaviour changes:
- The QA analyzer exited 0 whatever it found, though its own README says
clip-through "is the real defect and it gates". qa and qa-analyze now exit 1
on clip-through, and the remedy names --min-pixels (Wave 1/2 were accepted
at 150). The cached peasant set has 33 failures at the default 8 px.
- glb strip re-reported the same nodes as stripped on every re-run and
rewrote an unchanged file: it left them as orphans and then found them
again. Only nodes still linked into the graph count now, and a first pass
writes the same bytes as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
4.5 KiB
Python
114 lines
4.5 KiB
Python
#!/usr/bin/env python3
|
|
"""`reach character` decisions, exercised without launching Godot or Blender (T-1290).
|
|
|
|
The garment-QA capture step is a guarded exec (D-263); what is testable is the
|
|
plan — which config, which Godot, whether a virtual display is needed — and the
|
|
bash script's exit codes (2 for a missing config, 3 for no Godot). The GLB
|
|
strip is pure bytes, so it gets a synthetic GLB with real utility nodes.
|
|
|
|
Run: .venv/bin/python tooling/test_character.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import io
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from tooling.core.errors import ReachError # noqa: E402
|
|
from tooling.domains.character import glb_strip, qa # noqa: E402
|
|
|
|
|
|
def check(failures: list[str], cond: bool, message: str) -> None:
|
|
if not cond:
|
|
failures.append(message)
|
|
|
|
|
|
def expect_error(failures: list[str], fn, code: int, label: str) -> None:
|
|
try:
|
|
fn()
|
|
failures.append(f"{label}: did not fail")
|
|
except ReachError as exc:
|
|
check(failures, exc.exit_code == code, f"{label}: exit {exc.exit_code}, wanted {code}")
|
|
check(failures, bool(exc.fix), f"{label}: no remedy")
|
|
|
|
|
|
def test_capture_plan(failures: list[str]) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
godot = home / "bin" / "godot4"
|
|
godot.parent.mkdir()
|
|
godot.write_text("#!/bin/sh\n")
|
|
godot.chmod(0o755)
|
|
never = lambda _name: None # noqa: E731
|
|
|
|
plan = qa.capture_plan("peasant", {"DISPLAY": ":0"}, home, which=never)
|
|
check(failures, plan.config.name == "peasant.json", f"config name not resolved: {plan.config}")
|
|
check(failures, plan.argv[0] == str(godot), f"~/bin/godot4 not preferred: {plan.argv}")
|
|
check(failures, plan.argv[-1] == qa.SCENE and "--rendering-driver" in plan.argv, "scene argv changed")
|
|
|
|
headless = qa.capture_plan(None, {}, home, which=never)
|
|
check(failures, headless.argv[:2] == ["xvfb-run", "-a"], f"no $DISPLAY did not add xvfb-run: {headless.argv}")
|
|
check(failures, headless.config.name == "peasant.json", "default config is no longer peasant")
|
|
|
|
explicit = qa.capture_plan(None, {"GODOT": "/nope/godot", "DISPLAY": ":0"}, Path("/nonexistent"),
|
|
which=lambda n: "/usr/bin/godot" if n == "godot" else None)
|
|
check(failures, explicit.argv[0] == "/usr/bin/godot", f"PATH fallback order wrong: {explicit.argv}")
|
|
|
|
expect_error(failures, lambda: qa.resolve_config("no_such_garment"), 2, "missing config")
|
|
expect_error(failures, lambda: qa.resolve_godot({}, Path("/nonexistent"), which=never), 3, "missing godot")
|
|
|
|
|
|
def test_glb_strip(failures: list[str]) -> None:
|
|
gltf = {
|
|
"asset": {"version": "2.0"},
|
|
"scene": 0,
|
|
"scenes": [{"nodes": [0, 1, 2]}],
|
|
"nodes": [
|
|
{"name": "Body", "mesh": 0, "skin": 0},
|
|
{"name": "Icosphere", "mesh": 1},
|
|
{"name": "WGT-root", "mesh": 1, "children": [3]},
|
|
{"name": "Child", "mesh": 0, "skin": 0},
|
|
],
|
|
"meshes": [
|
|
{"name": "Body", "primitives": [{"attributes": {"POSITION": 0, "JOINTS_0": 1}}]},
|
|
{"name": "Icosphere", "primitives": [{"attributes": {"POSITION": 0}}]},
|
|
],
|
|
"skins": [{"joints": [0]}],
|
|
"buffers": [{"byteLength": 8}],
|
|
}
|
|
with tempfile.TemporaryDirectory() as tmp, contextlib.redirect_stderr(io.StringIO()):
|
|
path = os.path.join(tmp, "x.glb")
|
|
glb_strip.write_glb(gltf, b"\x00" * 8, path)
|
|
result = glb_strip.process_file(path, path)
|
|
check(failures, result["stripped"] == 2, f"expected 2 utility nodes stripped, got {result}")
|
|
stripped, _bin = glb_strip.read_glb(path)
|
|
kept = [stripped["nodes"][i]["name"] for i in stripped["scenes"][0]["nodes"]]
|
|
check(failures, kept == ["Body"], f"scene roots after strip: {kept}")
|
|
|
|
again = glb_strip.process_file(path, path)
|
|
check(failures, again["stripped"] == 0, "a second strip was not a no-op")
|
|
|
|
|
|
def main() -> int:
|
|
failures: list[str] = []
|
|
test_capture_plan(failures)
|
|
test_glb_strip(failures)
|
|
if failures:
|
|
print("test_character: FAIL", file=sys.stderr)
|
|
for failure in failures:
|
|
print(f" - {failure}", file=sys.stderr)
|
|
return 1
|
|
print("test_character: OK — QA capture plan (config, godot order, xvfb, exit codes 2/3) "
|
|
"and GLB strip decided without launching Godot")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|