498 lines
18 KiB
Python
498 lines
18 KiB
Python
"""Tests for headless colored-patch texture settings, job I/O, and CLI wiring."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from orisys_sensor_design import cli
|
|
from orisys_sensor_design.manifest import RevisionPaths, repo_root
|
|
from orisys_sensor_design.texture import (
|
|
DEFAULT_TEXTURE_PATCH,
|
|
TEXTURE_JOB_FILENAME,
|
|
TEXTURE_JOB_SCHEMA,
|
|
TextureConfigError,
|
|
TextureOutputTarget,
|
|
build_texture_job,
|
|
load_texture_patch_block,
|
|
merge_texture_patch,
|
|
resolve_texture_output_dir,
|
|
resolve_texture_settings,
|
|
texture_job_path,
|
|
texture_output_dir,
|
|
texture_output_paths,
|
|
texture_revision_rel,
|
|
write_texture_job,
|
|
)
|
|
|
|
DEFAULT_OUTPUTS = [
|
|
("produce", "patch_texture_produce.png"),
|
|
("compute", "patch_texture_compute.png"),
|
|
]
|
|
|
|
|
|
@pytest.fixture()
|
|
def revision_dir() -> Path:
|
|
return repo_root() / "sensors" / "finger" / "r01"
|
|
|
|
|
|
def _texture_namespace(
|
|
revision_dir: Path, output_dir: Path | None = None, **overrides: object
|
|
) -> argparse.Namespace:
|
|
namespace = argparse.Namespace(
|
|
revision_dir=revision_dir,
|
|
output_dir=output_dir,
|
|
mesh=None,
|
|
cam=None,
|
|
width=None,
|
|
height=None,
|
|
nx=None,
|
|
ny=None,
|
|
color_r=None,
|
|
edge_sample_grid=None,
|
|
seed=None,
|
|
no_cross=None,
|
|
cross_length=None,
|
|
cross_thickness=None,
|
|
cross_color=None,
|
|
)
|
|
for key, value in overrides.items():
|
|
setattr(namespace, key, value)
|
|
return namespace
|
|
|
|
|
|
def _write_revision(
|
|
tmp_path: Path,
|
|
texture_patch_yaml: str = "",
|
|
*,
|
|
sensor_dir: str = "finger",
|
|
revision_name: str = "r99",
|
|
) -> Path:
|
|
"""Create a minimal sensors/<sensor_dir>/<revision_name> revision under tmp_path."""
|
|
revision = tmp_path / "sensors" / sensor_dir / revision_name
|
|
(revision / "profiles").mkdir(parents=True)
|
|
(revision / "source").mkdir(parents=True)
|
|
(revision / "source" / "assembly.blend").write_bytes(b"")
|
|
design = (
|
|
"sensor: finger\n"
|
|
f"revision: {revision_name}\n"
|
|
"meters_per_source_unit: 0.001\n"
|
|
"source_blend: source/assembly.blend\n"
|
|
)
|
|
design += texture_patch_yaml
|
|
(revision / "design.yaml").write_text(design, encoding="utf-8")
|
|
for name in ("compute.yaml", "studio.yaml"):
|
|
(revision / "profiles" / name).write_text(
|
|
f"profile: {name.split('.')[0]}\n", encoding="utf-8"
|
|
)
|
|
return revision
|
|
|
|
|
|
# ---------------------------------------------------------------- paths
|
|
|
|
|
|
def test_output_dir_is_revision_scoped(revision_dir: Path) -> None:
|
|
root = repo_root()
|
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
|
assert texture_revision_rel(root, paths).as_posix() == "finger/r01"
|
|
assert texture_output_dir(root, paths) == (
|
|
root / "build" / "textures" / "finger" / "r01"
|
|
).resolve()
|
|
|
|
|
|
def test_output_dir_explicit_override_wins(revision_dir: Path, tmp_path: Path) -> None:
|
|
root = repo_root()
|
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
|
assert resolve_texture_output_dir(root, paths, tmp_path / "elsewhere") == (
|
|
tmp_path / "elsewhere"
|
|
).resolve()
|
|
|
|
|
|
def test_output_dir_uses_directory_names_not_design_sensor_field(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""The on-disk sensor folder must win over design.yaml's sensor field."""
|
|
revision = _write_revision(tmp_path, sensor_dir="finger-xynova", revision_name="r01")
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
assert texture_revision_rel(tmp_path, paths) == Path("finger-xynova") / "r01"
|
|
|
|
|
|
def test_output_paths_pair_each_target(revision_dir: Path, tmp_path: Path) -> None:
|
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
|
settings = resolve_texture_settings(paths)
|
|
|
|
targets = texture_output_paths(tmp_path, settings)
|
|
|
|
assert [(target.uv_map, path.name) for target, path in targets] == DEFAULT_OUTPUTS
|
|
assert all(path.parent == tmp_path.resolve() for _, path in targets)
|
|
|
|
|
|
def test_job_path_sits_beside_texture(tmp_path: Path) -> None:
|
|
assert texture_job_path(tmp_path / "out") == tmp_path / "out" / TEXTURE_JOB_FILENAME
|
|
|
|
|
|
def test_output_target_entry_is_absolute(revision_dir: Path, tmp_path: Path) -> None:
|
|
target = TextureOutputTarget(uv_map="produce", filename="a.png")
|
|
entry = target.to_job_entry(tmp_path)
|
|
assert entry["uv_map"] == "produce"
|
|
assert Path(entry["output"]).is_absolute()
|
|
assert Path(entry["output"]).name == "a.png"
|
|
|
|
|
|
# ------------------------------------------------------- settings merge
|
|
|
|
|
|
def test_defaults_come_from_design_yaml(revision_dir: Path) -> None:
|
|
settings = resolve_texture_settings(RevisionPaths.from_revision_dir(revision_dir))
|
|
assert settings.mesh == "sensing_surface"
|
|
assert settings.camera == "cam_sensor"
|
|
assert [(target.uv_map, target.filename) for target in settings.outputs] == DEFAULT_OUTPUTS
|
|
assert (settings.width, settings.height) == (3280, 3200)
|
|
assert (settings.nx, settings.ny) == (80, 45)
|
|
assert settings.color_r == pytest.approx(0.05)
|
|
assert settings.edge_sample_grid == 5
|
|
assert settings.seed is None
|
|
assert settings.cross_enabled is True
|
|
assert settings.cross_length_px == 40
|
|
assert settings.cross_thickness_px == 3
|
|
assert settings.cross_color == (1.0, 0.0, 0.0, 1.0)
|
|
|
|
|
|
def test_builtin_defaults_when_design_block_absent(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
assert load_texture_patch_block(paths) == {}
|
|
settings = resolve_texture_settings(paths)
|
|
assert settings.width == DEFAULT_TEXTURE_PATCH["width"]
|
|
assert settings.height == DEFAULT_TEXTURE_PATCH["height"]
|
|
assert [target.uv_map for target in settings.outputs] == ["produce", "compute"]
|
|
# Object names fall back to design.yaml `objects` defaults.
|
|
assert settings.mesh == "sensing_surface"
|
|
assert settings.camera == "cam_sensor"
|
|
|
|
|
|
def test_design_yaml_uv_outputs_replace_builtin_defaults(tmp_path: Path) -> None:
|
|
revision = _write_revision(
|
|
tmp_path,
|
|
"texture_patch:\n uv_outputs:\n custom: my_custom_layout.png\n",
|
|
)
|
|
settings = resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
|
|
assert [(target.uv_map, target.filename) for target in settings.outputs] == [
|
|
("custom", "my_custom_layout.png")
|
|
]
|
|
|
|
|
|
def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None:
|
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
|
settings = resolve_texture_settings(
|
|
paths,
|
|
{
|
|
"width": 640,
|
|
"height": 512,
|
|
"nx": 4,
|
|
"ny": 3,
|
|
"mesh": "other_mesh",
|
|
"camera": "other_cam",
|
|
"color_r": 0.25,
|
|
"edge_sample_grid": 3,
|
|
"seed": 11,
|
|
"cross": {"enabled": False, "length_px": 8, "color": [0.0, 1.0, 0.0]},
|
|
},
|
|
)
|
|
assert (settings.width, settings.height) == (640, 512)
|
|
assert (settings.nx, settings.ny) == (4, 3)
|
|
assert settings.mesh == "other_mesh"
|
|
assert settings.camera == "other_cam"
|
|
assert settings.color_r == pytest.approx(0.25)
|
|
assert settings.edge_sample_grid == 3
|
|
assert settings.seed == 11
|
|
assert settings.cross_enabled is False
|
|
assert settings.cross_length_px == 8
|
|
# A 3-channel cross colour is widened to RGBA.
|
|
assert settings.cross_color == (0.0, 1.0, 0.0, 1.0)
|
|
# Shared rendering settings leave both targets intact.
|
|
assert [target.uv_map for target in settings.outputs] == ["produce", "compute"]
|
|
|
|
|
|
def test_unset_overrides_do_not_erase_configured_values(revision_dir: Path) -> None:
|
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
|
merged = merge_texture_patch(paths, {"width": None, "mesh": None, "cross": None})
|
|
assert merged["width"] == 3280
|
|
assert merged["mesh"] == "sensing_surface"
|
|
assert merged["cross"]["length_px"] == 40
|
|
|
|
|
|
def test_unknown_design_key_is_rejected(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, "texture_patch:\n widht: 100\n")
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
|
|
assert "widht" in str(excinfo.value)
|
|
|
|
|
|
def test_legacy_filename_key_is_rejected(tmp_path: Path) -> None:
|
|
"""The singular filename key was replaced by uv_outputs."""
|
|
revision = _write_revision(tmp_path, "texture_patch:\n filename: patch_texture.png\n")
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
|
|
assert "filename" in str(excinfo.value)
|
|
|
|
|
|
def test_unknown_cross_key_is_rejected(tmp_path: Path) -> None:
|
|
revision = _write_revision(
|
|
tmp_path, "texture_patch:\n cross:\n thickness: 3\n"
|
|
)
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
|
|
assert "thickness" in str(excinfo.value)
|
|
|
|
|
|
def test_texture_patch_must_be_a_mapping(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, "texture_patch: patches\n")
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
|
|
assert "must be a mapping" in str(excinfo.value)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("overrides", "expected"),
|
|
[
|
|
({"width": 0}, "width"),
|
|
({"height": -4}, "height"),
|
|
({"nx": 0}, "nx"),
|
|
({"ny": "many"}, "ny"),
|
|
({"color_r": 2.0}, "color_r"),
|
|
({"edge_sample_grid": 1}, "edge_sample_grid"),
|
|
({"seed": "abc"}, "seed"),
|
|
({"cross": {"enabled": "yes"}}, "cross.enabled"),
|
|
({"cross": {"length_px": 0}}, "cross.length_px"),
|
|
({"cross": {"color": [2.0, 0.0, 0.0, 1.0]}}, "cross.color"),
|
|
({"cross": {"color": [0.0, 0.0]}}, "cross.color"),
|
|
],
|
|
)
|
|
def test_invalid_overrides_are_rejected(
|
|
revision_dir: Path, overrides: dict, expected: str
|
|
) -> None:
|
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
resolve_texture_settings(paths, overrides)
|
|
assert expected in str(excinfo.value)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("overrides", "expected"),
|
|
[
|
|
({"uv_outputs": {}}, "uv_outputs"),
|
|
({"uv_outputs": "produce.png"}, "uv_outputs"),
|
|
({"uv_outputs": {"": "produce.png"}}, "uv_outputs"),
|
|
({"uv_outputs": {"produce": ""}}, "uv_outputs.produce"),
|
|
({"uv_outputs": {"produce": "texture.jpg"}}, "uv_outputs.produce"),
|
|
({"uv_outputs": {"produce": "nested/a.png"}}, "uv_outputs.produce"),
|
|
({"uv_outputs": {"produce": "..\\a.png"}}, "uv_outputs.produce"),
|
|
(
|
|
{"uv_outputs": {"produce": "same.png", "compute": "same.png"}},
|
|
"duplicate output file name",
|
|
),
|
|
],
|
|
)
|
|
def test_invalid_uv_outputs_are_rejected(
|
|
revision_dir: Path, overrides: dict, expected: str
|
|
) -> None:
|
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
resolve_texture_settings(paths, overrides)
|
|
assert expected in str(excinfo.value)
|
|
|
|
|
|
# ------------------------------------------------------------- job I/O
|
|
|
|
|
|
def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path) -> None:
|
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
|
settings = resolve_texture_settings(paths, {"width": 256, "height": 256, "seed": 3})
|
|
output_dir = tmp_path / "textures"
|
|
|
|
job = build_texture_job(paths, settings, output_dir)
|
|
assert job["schema"] == TEXTURE_JOB_SCHEMA
|
|
assert job["mode"] == "patches"
|
|
assert set(job) == {
|
|
"schema",
|
|
"mode",
|
|
"revision_dir",
|
|
"source_blend",
|
|
"outputs",
|
|
"objects",
|
|
"settings",
|
|
}
|
|
assert "output" not in job
|
|
assert [entry["uv_map"] for entry in job["outputs"]] == ["produce", "compute"]
|
|
assert [Path(entry["output"]).name for entry in job["outputs"]] == [
|
|
filename for _, filename in DEFAULT_OUTPUTS
|
|
]
|
|
assert all(Path(entry["output"]).is_absolute() for entry in job["outputs"])
|
|
assert set(job["objects"]) == {"mesh", "camera"}
|
|
assert set(job["settings"]) == {
|
|
"width",
|
|
"height",
|
|
"nx",
|
|
"ny",
|
|
"color_r",
|
|
"edge_sample_grid",
|
|
"seed",
|
|
"cross",
|
|
}
|
|
assert set(job["settings"]["cross"]) == {
|
|
"enabled",
|
|
"length_px",
|
|
"thickness_px",
|
|
"color",
|
|
}
|
|
|
|
job_path = write_texture_job(job, output_dir)
|
|
assert job_path == output_dir / TEXTURE_JOB_FILENAME
|
|
loaded = json.loads(job_path.read_text(encoding="utf-8"))
|
|
assert loaded["outputs"] == job["outputs"]
|
|
assert loaded["source_blend"] == str(paths.source_blend.resolve())
|
|
assert loaded["objects"] == {"mesh": "sensing_surface", "camera": "cam_sensor"}
|
|
assert loaded["settings"]["seed"] == 3
|
|
assert loaded["settings"]["cross"]["color"] == [1.0, 0.0, 0.0, 1.0]
|
|
|
|
|
|
def test_write_job_creates_output_directory(revision_dir: Path, tmp_path: Path) -> None:
|
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
|
settings = resolve_texture_settings(paths)
|
|
nested = tmp_path / "a" / "b"
|
|
write_texture_job(build_texture_job(paths, settings, nested), nested)
|
|
assert (nested / TEXTURE_JOB_FILENAME).is_file()
|
|
|
|
|
|
# ----------------------------------------------------------- CLI wiring
|
|
|
|
|
|
def _fake_blender(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
exit_code: int,
|
|
write_pngs: bool = True,
|
|
skip_uv: str | None = None,
|
|
):
|
|
calls: list[tuple[str, Path, list[str]]] = []
|
|
|
|
def fake_run(script: str, blend: Path, extra_args: list[str]):
|
|
calls.append((script, blend, list(extra_args)))
|
|
if write_pngs:
|
|
job_path = Path(extra_args[extra_args.index("--job") + 1])
|
|
job = json.loads(job_path.read_text(encoding="utf-8"))
|
|
for entry in job["outputs"]:
|
|
if skip_uv is not None and entry["uv_map"] == skip_uv:
|
|
continue
|
|
Path(entry["output"]).write_bytes(b"\x89PNG\r\n\x1a\n")
|
|
return exit_code
|
|
|
|
monkeypatch.setattr(cli, "_run_blender_script", fake_run)
|
|
return calls
|
|
|
|
|
|
def test_cmd_texture_forwards_job_and_verifies_both_artifacts(
|
|
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
|
) -> None:
|
|
calls = _fake_blender(monkeypatch, 0)
|
|
out_dir = tmp_path / "out"
|
|
assert cli.cmd_texture(revision_dir, _texture_namespace(revision_dir, out_dir)) == 0
|
|
|
|
assert len(calls) == 1, "one Blender launch must render every target"
|
|
script, blend, extra = calls[0]
|
|
assert script == "random_patch_dd02c.py"
|
|
assert blend == revision_dir.resolve() / "source" / "geometry.blend"
|
|
assert extra[0] == "--job"
|
|
job_path = Path(extra[1])
|
|
assert job_path == out_dir.resolve() / TEXTURE_JOB_FILENAME
|
|
|
|
for _, filename in DEFAULT_OUTPUTS:
|
|
assert (out_dir / filename).is_file()
|
|
|
|
|
|
def test_cmd_texture_propagates_blender_failure(
|
|
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
|
) -> None:
|
|
_fake_blender(monkeypatch, 9, write_pngs=False)
|
|
namespace = _texture_namespace(revision_dir, tmp_path / "out")
|
|
assert cli.cmd_texture(revision_dir, namespace) == 9
|
|
|
|
|
|
def test_cmd_texture_detects_both_textures_missing(
|
|
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
|
) -> None:
|
|
_fake_blender(monkeypatch, 0, write_pngs=False)
|
|
namespace = _texture_namespace(revision_dir, tmp_path / "out")
|
|
assert cli.cmd_texture(revision_dir, namespace) == 3
|
|
|
|
|
|
def test_cmd_texture_detects_partial_pair(
|
|
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
|
) -> None:
|
|
"""One UV map written, the other missing, must not look like success."""
|
|
_fake_blender(monkeypatch, 0, skip_uv="compute")
|
|
namespace = _texture_namespace(revision_dir, tmp_path / "out")
|
|
assert cli.cmd_texture(revision_dir, namespace) == 3
|
|
|
|
|
|
def test_main_texture_parses_flags_into_job(
|
|
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
|
) -> None:
|
|
calls = _fake_blender(monkeypatch, 0)
|
|
out_dir = tmp_path / "flagged"
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
cli.main(
|
|
[
|
|
"texture",
|
|
str(revision_dir),
|
|
"--output-dir",
|
|
str(out_dir),
|
|
"--mesh",
|
|
"sensing_surface",
|
|
"--width",
|
|
"256",
|
|
"--height",
|
|
"256",
|
|
"--nx",
|
|
"4",
|
|
"--ny",
|
|
"3",
|
|
"--seed",
|
|
"7",
|
|
"--no-cross",
|
|
]
|
|
)
|
|
assert excinfo.value.code == 0
|
|
|
|
job = json.loads(Path(calls[0][2][1]).read_text(encoding="utf-8"))
|
|
assert [entry["uv_map"] for entry in job["outputs"]] == ["produce", "compute"]
|
|
assert job["settings"]["width"] == 256
|
|
assert job["settings"]["nx"] == 4
|
|
assert job["settings"]["ny"] == 3
|
|
assert job["settings"]["seed"] == 7
|
|
assert job["settings"]["cross"]["enabled"] is False
|
|
for _, filename in DEFAULT_OUTPUTS:
|
|
assert (out_dir / filename).is_file()
|
|
|
|
|
|
def test_main_texture_rejects_filename_flag(
|
|
monkeypatch: pytest.MonkeyPatch, revision_dir: Path
|
|
) -> None:
|
|
"""The singular --filename flag is gone; per-UV names come from design.yaml."""
|
|
_fake_blender(monkeypatch, 0)
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
cli.main(["texture", str(revision_dir), "--filename", "custom.png"])
|
|
assert excinfo.value.code == 2
|
|
|
|
|
|
def test_main_texture_rejects_invalid_flag_with_exit_2(
|
|
monkeypatch: pytest.MonkeyPatch, revision_dir: Path
|
|
) -> None:
|
|
_fake_blender(monkeypatch, 0)
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
cli.main(["texture", str(revision_dir), "--width", "0"])
|
|
assert excinfo.value.code == 2
|