"""Tests for revision metadata and manifest generation.""" from __future__ import annotations import json from pathlib import Path import pytest from orisys_sensor_design.cli import resolve_blender_executable from orisys_sensor_design.manifest import ( EXPORT_JOB_SCHEMA, RevisionPaths, build_export_job, build_manifest, default_manifest_path, export_object_names, export_output_dir, export_revision_dir, repo_root, sha256_directory, validate_revision_metadata, write_export_job, write_manifest, ) @pytest.fixture() def revision_dir() -> Path: root = repo_root() return root / "sensors" / "finger" / "r01" def test_repo_root_finds_pyproject(revision_dir: Path) -> None: root = repo_root(revision_dir) assert (root / "pyproject.toml").is_file() def test_validate_metadata_reports_missing_blend(tmp_path: Path) -> None: revision = tmp_path / "r99" (revision / "profiles").mkdir(parents=True) (revision / "design.yaml").write_text( "sensor: finger\nrevision: r99\nmeters_per_source_unit: 0.001\n" "source_blend: source/assembly.blend\n", encoding="utf-8", ) for name in ("compute.yaml", "studio.yaml"): (revision / "profiles" / name).write_text(f"profile: {name.split('.')[0]}\n", encoding="utf-8") paths = RevisionPaths.from_revision_dir(revision) errors = validate_revision_metadata(paths) assert any("Source blend not found" in message for message in errors) def test_export_object_names_from_design(revision_dir: Path) -> None: names = export_object_names(RevisionPaths.from_revision_dir(revision_dir)) assert names == { "sensing_surface": "sensing_surface", "cam_sensor": "cam_sensor", "origin_sensor": "origin_sensor", } def test_build_export_job_round_trips_json(revision_dir: Path, tmp_path: Path) -> None: paths = RevisionPaths.from_revision_dir(revision_dir) output_dir = tmp_path / "compute" job = build_export_job(paths, output_dir, "compute") assert job["schema"] == EXPORT_JOB_SCHEMA assert job["profile"] == "compute" assert job["objects"] == { "sensing_surface": "sensing_surface", "cam_sensor": "cam_sensor", "origin_sensor": "origin_sensor", } assert Path(job["output"]) == output_dir.resolve() job_path = write_export_job(job, tmp_path) assert job_path.name == "_export_job.json" assert job_path.parent == tmp_path loaded = json.loads(job_path.read_text(encoding="utf-8")) assert loaded["schema"] == EXPORT_JOB_SCHEMA assert loaded["objects"]["cam_sensor"] == "cam_sensor" assert loaded["objects"]["sensing_surface"] == "sensing_surface" assert loaded["objects"]["origin_sensor"] == "origin_sensor" def test_export_output_dir_matches_profile(revision_dir: Path) -> None: root = repo_root(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir) out = export_output_dir(root, paths, "compute") assert out == (root / "build/export/finger/r01/compute").resolve() assert export_revision_dir(root, paths, "compute") == ( root / "build/export/finger/r01" ).resolve() assert default_manifest_path(root, paths, "compute") == ( root / "build/export/finger/r01/manifest.json" ).resolve() def test_bundle_hash_ignores_generated_metadata(tmp_path: Path) -> None: payload = tmp_path / "compute" payload.mkdir() (payload / "mesh.npz").write_bytes(b"mesh") digest = sha256_directory(payload) (payload / "_export_job.json").write_text("{}", encoding="utf-8") (payload / "manifest.json").write_text("{}", encoding="utf-8") assert sha256_directory(payload) == digest def test_manifest_writes_without_export(revision_dir: Path, tmp_path: Path) -> None: manifest = build_manifest( root=repo_root(revision_dir), revision_dir=revision_dir, profile="compute", ) out = tmp_path / "manifest.json" write_manifest(out, manifest) loaded = json.loads(out.read_text(encoding="utf-8")) assert loaded["sensor"] == "finger" assert loaded["revision"] == "r01" assert loaded["profile"] == "compute" assert loaded["expected_artifacts"][:4] == [ "mesh.npz", "housing.npz", "camera_intrinsics.npz", "world_to_cam.npy", ] # The unwarp/valid-mask map is built by the export itself (SDK precompute); listing it # makes a missing mask fail the export instead of shipping a bundle that renders grey. # Its name embeds the profile's source_scale (`scaled_map_cache_path`), so match the # family rather than one pinned value. map_name = loaded["expected_artifacts"][4] assert map_name.startswith("unroll_map_uv_s") and map_name.endswith(".npz") assert loaded["source_blend"].endswith("geometry.blend") def test_resolve_blender_respects_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: fake = tmp_path / "blender.exe" fake.write_text("", encoding="utf-8") monkeypatch.setenv("ORISYS_BLENDER", str(fake)) monkeypatch.delenv("BLENDER_EXE", raising=False) assert resolve_blender_executable() == fake.resolve() def test_resolve_blender_from_program_files( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: monkeypatch.delenv("ORISYS_BLENDER", raising=False) monkeypatch.delenv("BLENDER_EXE", raising=False) monkeypatch.setattr("orisys_sensor_design.cli.shutil.which", lambda _name: None) pf = tmp_path / "ProgramFiles" v42 = pf / "Blender Foundation" / "Blender 4.2" v50 = pf / "Blender Foundation" / "Blender 5.0" v42.mkdir(parents=True) v50.mkdir(parents=True) exe42 = v42 / "blender.exe" exe50 = v50 / "blender.exe" exe42.write_text("", encoding="utf-8") exe50.write_text("", encoding="utf-8") monkeypatch.setenv("ProgramFiles", str(pf)) monkeypatch.setenv("ProgramFiles(x86)", str(tmp_path / "unused-x86")) # No lock preference → newest folder name wins (5.0). assert resolve_blender_executable(root=None) == exe50.resolve() def test_resolve_blender_prefers_lock_lts( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: monkeypatch.delenv("ORISYS_BLENDER", raising=False) monkeypatch.delenv("BLENDER_EXE", raising=False) monkeypatch.setattr("orisys_sensor_design.cli.shutil.which", lambda _name: None) pf = tmp_path / "ProgramFiles" v42 = pf / "Blender Foundation" / "Blender 4.2" v50 = pf / "Blender Foundation" / "Blender 5.0" v42.mkdir(parents=True) v50.mkdir(parents=True) (v42 / "blender.exe").write_text("", encoding="utf-8") (v50 / "blender.exe").write_text("", encoding="utf-8") monkeypatch.setenv("ProgramFiles", str(pf)) monkeypatch.setenv("ProgramFiles(x86)", str(tmp_path / "unused-x86")) root = tmp_path / "repo" root.mkdir() (root / "blender.lock.json").write_text( json.dumps({"blender_version": "4.2.0", "blender_lts": "4.2"}), encoding="utf-8", ) assert resolve_blender_executable(root=root) == (v42 / "blender.exe").resolve()