"""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, build_texture_job, load_texture_patch_block, merge_texture_patch, resolve_texture_output_dir, resolve_texture_settings, texture_job_path, texture_output_dir, texture_revision_rel, write_texture_job, ) @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, filename=None, 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: """finger-xynova declares `sensor: finger`; the folder name must win.""" 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_job_path_sits_beside_texture(tmp_path: Path) -> None: assert texture_job_path(tmp_path / "out") == tmp_path / "out" / TEXTURE_JOB_FILENAME # ------------------------------------------------------- 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 settings.filename == "patch_texture.png" 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"] # Object names fall back to design.yaml `objects` defaults. assert settings.mesh == "sensing_surface" assert settings.camera == "cam_sensor" 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) 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_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"), ({"filename": "tex.jpg"}, "filename"), ({"filename": "nested/tex.png"}, "filename"), ({"filename": "..\\tex.png"}, "filename"), ({"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) # ------------------------------------------------------------- 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" output_path = output_dir / settings.filename job = build_texture_job(paths, settings, output_path) assert job["schema"] == TEXTURE_JOB_SCHEMA assert job["mode"] == "patches" assert set(job) == { "schema", "mode", "revision_dir", "source_blend", "output", "objects", "settings", } 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["output"] == str(output_path.resolve()) assert loaded["source_blend"] == str(paths.source_blend.resolve()) assert Path(loaded["output"]).is_absolute() 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 / settings.filename), nested) assert (nested / TEXTURE_JOB_FILENAME).is_file() # ----------------------------------------------------------- CLI wiring def _fake_blender(monkeypatch: pytest.MonkeyPatch, exit_code: int, write_png: bool): 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_png: job_path = Path(extra_args[extra_args.index("--job") + 1]) job = json.loads(job_path.read_text(encoding="utf-8")) Path(job["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_artifact( monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path ) -> None: calls = _fake_blender(monkeypatch, 0, write_png=True) out_dir = tmp_path / "out" assert cli.cmd_texture(revision_dir, _texture_namespace(revision_dir, out_dir)) == 0 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 assert (out_dir / "patch_texture.png").is_file() def test_cmd_texture_propagates_blender_failure( monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path ) -> None: _fake_blender(monkeypatch, 9, write_png=False) namespace = _texture_namespace(revision_dir, tmp_path / "out") assert cli.cmd_texture(revision_dir, namespace) == 9 def test_cmd_texture_detects_missing_texture( monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path ) -> None: _fake_blender(monkeypatch, 0, write_png=False) 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, write_png=True) out_dir = tmp_path / "flagged" with pytest.raises(SystemExit) as excinfo: cli.main( [ "texture", str(revision_dir), "--output-dir", str(out_dir), "--filename", "custom.png", "--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 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 assert (out_dir / "custom.png").is_file() def test_main_texture_rejects_invalid_flag_with_exit_2( monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path ) -> None: _fake_blender(monkeypatch, 0, write_png=True) with pytest.raises(SystemExit) as excinfo: cli.main(["texture", str(revision_dir), "--width", "0"]) assert excinfo.value.code == 2