Files
orisys-sensor-design/tests/test_manifest.py
T
2026-09-25 17:10:05 +08:00

192 lines
7.0 KiB
Python

"""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()