"""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"), ] ARTIFACT_FORMATS = ("png", "svg", "pdf") @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// 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 for target, _ in targets] == ["produce", "compute"] for (target, artifacts), (_, png_name) in zip(targets, DEFAULT_OUTPUTS): stem = Path(png_name).stem assert list(artifacts) == list(ARTIFACT_FORMATS) assert {name: path.name for name, path in artifacts.items()} == { format_name: f"{stem}.{format_name}" for format_name in ARTIFACT_FORMATS } assert all(path.parent == tmp_path.resolve() for path in artifacts.values()) 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 list(entry["artifacts"]) == list(ARTIFACT_FORMATS) assert all(Path(path).is_absolute() for path in entry["artifacts"].values()) assert {name: Path(path).name for name, path in entry["artifacts"].items()} == { "png": "a.png", "svg": "a.svg", "pdf": "a.pdf", } # ------------------------------------------------------- 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 # Pinned in design.yaml so the committed PNG/SVG/PDF set is reproducible. assert settings.seed == 7 assert settings.vector_width_mm == pytest.approx(164.0) assert settings.vector_height_mm == pytest.approx(160.0) 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, "vector": {"width_mm": 82.0, "height_mm": 80.0}, "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.vector_width_mm == pytest.approx(82.0) assert settings.vector_height_mm == pytest.approx(80.0) 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_unknown_vector_key_is_rejected(tmp_path: Path) -> None: revision = _write_revision( tmp_path, "texture_patch:\n vector:\n dpi: 508\n" ) with pytest.raises(TextureConfigError) as excinfo: resolve_texture_settings(RevisionPaths.from_revision_dir(revision)) assert "dpi" 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"), ({"vector": {"width_mm": 0}}, "vector.width_mm"), ({"vector": {"height_mm": "large"}}, "vector.height_mm"), ({"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"] for entry, (_, png_name) in zip(job["outputs"], DEFAULT_OUTPUTS): stem = Path(png_name).stem assert list(entry["artifacts"]) == list(ARTIFACT_FORMATS) assert {name: Path(path).name for name, path in entry["artifacts"].items()} == { format_name: f"{stem}.{format_name}" for format_name in ARTIFACT_FORMATS } assert all(Path(path).is_absolute() for path in entry["artifacts"].values()) assert set(job["objects"]) == {"mesh", "camera"} assert set(job["settings"]) == { "width", "height", "nx", "ny", "color_r", "edge_sample_grid", "seed", "vector", "cross", } assert job["settings"]["vector"] == { "width_mm": 164.0, "height_mm": 160.0, } 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_artifacts: bool = True, skip_uv: str | None = None, skip_format: str | None = None, ): calls: list[tuple[str, Path, list[str]]] = [] payloads = { "png": b"\x89PNG\r\n\x1a\n", "svg": b"", "pdf": b"%PDF-1.4\n", } def fake_run(script: str, blend: Path, extra_args: list[str]): calls.append((script, blend, list(extra_args))) if write_artifacts: 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"]: for format_name, raw_path in entry["artifacts"].items(): if skip_uv is not None and entry["uv_map"] == skip_uv: continue if skip_format is not None and format_name == skip_format: continue Path(raw_path).write_bytes(payloads[format_name]) 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: stem = Path(filename).stem for format_name in ARTIFACT_FORMATS: assert (out_dir / f"{stem}.{format_name}").is_file() def test_cmd_texture_propagates_blender_failure( monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path ) -> None: _fake_blender(monkeypatch, 9, write_artifacts=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_artifacts=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_cmd_texture_detects_one_missing_vector_format( monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path ) -> None: """Every UV target must have PNG, SVG, and PDF before success is reported.""" _fake_blender(monkeypatch, 0, skip_format="svg") 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: stem = Path(filename).stem for format_name in ARTIFACT_FORMATS: assert (out_dir / f"{stem}.{format_name}").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