35 payloads move to tooling/scripts/blender/ and stay outside package scope. They run under Blender's bundled Python, which cannot see the repo venv, so they physically cannot import tooling.core — holding them to the D-263 contract would either fail the gate forever or force the contract to be weakened for everyone, and the second is how a gate stops meaning anything. Count verified by import rather than filename: 33 import bpy/bmesh directly, and the two that do not are still payloads per their own usage lines. garment-fit/make_logo.py is the one genuine non-payload and stays for T-1290. The bash wrapper is retired rather than kept. Keeping it would have put the install-resolution logic in two places, which is the duplication T-1286 had just finished collapsing three copies of. domains/blender/service.py owns the decisions — resolve_blender (native beats flatpak, ordering preserved), resolve_payload, absolutise — and only run_payload performs. test_blender.py pins all of them without launching Blender, which matters here more than usual: the thing being launched is a 200 MB GUI application that writes GLBs. `reach blender run` takes a registered payload name OR a path to any script, because the wrapper served both — the spikes and the glb-gen skill hand it one-off scripts of their own. An unknown name enumerates all 35 and exits 2. The exclusion now defends itself. check_carve_out_stays_carved fails if `scripts` is added to PACKAGE_ROOTS, if the payload directory empties (an empty exclusion proves nothing), or if an __init__.py appears there (which would make the payloads importable — the coupling the carve-out exists to prevent). All three arms mutation-proved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
128 lines
5.1 KiB
Python
128 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""The `reach blender` decisions, exercised without launching Blender (T-1273).
|
|
|
|
Same principle as test_environment.py, and for a stronger reason: the thing
|
|
being launched is a 200 MB GUI application that opens files and writes GLBs, so
|
|
"run it and see" is not a test. Every decision the retired bash wrapper made —
|
|
which install to use, which script a name refers to, which arguments need
|
|
absolutising — is a pure function here, and this is what pins them.
|
|
|
|
The absolutise rule is the one worth having a test for. Flatpak resolves
|
|
relative paths against a different root, so a relative path silently becomes a
|
|
file-not-found raised from inside Blender, a long way from its cause.
|
|
|
|
Run: python3 tooling/test_blender.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
from tooling.core.errors import ReachError # noqa: E402
|
|
from tooling.domains.blender import service # noqa: E402
|
|
|
|
|
|
def test_payloads_are_discoverable(failures: list[str]) -> None:
|
|
"""The carve-out is the only place these names are written down."""
|
|
names = service.payloads()
|
|
if len(names) < 30:
|
|
failures.append(f"expected the 35-file carve-out, found {len(names)} payloads")
|
|
if any(n.endswith(".py") for n in names):
|
|
failures.append("payload names should be stems, not filenames")
|
|
|
|
|
|
def test_registered_name_resolves(failures: list[str]) -> None:
|
|
"""Both the stem and the filename form reach the same file."""
|
|
first = service.payloads()[0]
|
|
by_stem = service.resolve_payload(first)
|
|
by_filename = service.resolve_payload(f"{first}.py")
|
|
if by_stem != by_filename:
|
|
failures.append(f"'{first}' and '{first}.py' resolved differently")
|
|
if by_stem.parent != service.payload_dir():
|
|
failures.append(f"a registered name resolved outside the payload dir: {by_stem}")
|
|
|
|
|
|
def test_arbitrary_path_resolves(failures: list[str]) -> None:
|
|
"""The wrapper ran one-off scripts too — the spikes and glb-gen still do."""
|
|
with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as handle:
|
|
handle.write(b"# a one-off script\n")
|
|
loose = Path(handle.name)
|
|
try:
|
|
resolved = service.resolve_payload(str(loose))
|
|
if resolved != loose.resolve():
|
|
failures.append(f"a script path resolved to {resolved}, expected {loose}")
|
|
if not resolved.is_absolute():
|
|
failures.append("a script path resolved to a relative path")
|
|
finally:
|
|
loose.unlink()
|
|
|
|
|
|
def test_unknown_name_enumerates(failures: list[str]) -> None:
|
|
"""A payload name is not guessable, so a miss must list the options."""
|
|
try:
|
|
service.resolve_payload("definitely_not_a_payload")
|
|
failures.append("an unknown payload resolved instead of raising")
|
|
except ReachError as exc:
|
|
if not exc.fix or "blender_" not in exc.fix:
|
|
failures.append("the unknown-payload error does not enumerate the payloads")
|
|
if exc.exit_code != 2:
|
|
failures.append(
|
|
f"an unknown payload exited {exc.exit_code}, expected 2 (usage error)"
|
|
)
|
|
|
|
|
|
def test_absolutise_only_touches_real_paths(failures: list[str]) -> None:
|
|
"""Flags and values must survive; existing paths must be made absolute."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
real = Path(tmp) / "input.glb"
|
|
real.write_text("")
|
|
args = ["--mode", "fit", str(real), "not/a/real/path.glb", "12"]
|
|
out = service.absolutise(args)
|
|
|
|
if out[0] != "--mode" or out[1] != "fit" or out[4] != "12":
|
|
failures.append(f"absolutise mangled non-path arguments: {out}")
|
|
if out[2] != str(real.resolve()):
|
|
failures.append(f"an existing path was not absolutised: {out[2]}")
|
|
if out[3] != "not/a/real/path.glb":
|
|
failures.append(f"a non-existent path was rewritten: {out[3]}")
|
|
|
|
|
|
def test_invocation_reports_its_sandbox(failures: list[str]) -> None:
|
|
"""`sandboxed` is what decides whether absolutising is load-bearing."""
|
|
native = service.BlenderInvocation(["blender"], "native")
|
|
flatpak = service.BlenderInvocation(["flatpak", "run", service.FLATPAK_APP], "flatpak")
|
|
if native.sandboxed:
|
|
failures.append("a native install was reported as sandboxed")
|
|
if not flatpak.sandboxed:
|
|
failures.append("a flatpak install was not reported as sandboxed")
|
|
|
|
|
|
def main() -> int:
|
|
failures: list[str] = []
|
|
test_payloads_are_discoverable(failures)
|
|
test_registered_name_resolves(failures)
|
|
test_arbitrary_path_resolves(failures)
|
|
test_unknown_name_enumerates(failures)
|
|
test_absolutise_only_touches_real_paths(failures)
|
|
test_invocation_reports_its_sandbox(failures)
|
|
|
|
if failures:
|
|
print("test_blender: FAIL", file=sys.stderr)
|
|
for failure in failures:
|
|
print(f" - {failure}", file=sys.stderr)
|
|
return 1
|
|
print(
|
|
"test_blender: OK — payload discovery, both resolve forms, enumeration "
|
|
"on a miss and the flatpak path rule, without launching Blender"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|