Files
orisys-sensor-design/tests/test_texture.py
T
2026-09-20 12:14:51 +08:00

559 lines
20 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"),
]
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/<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 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(16.4)
assert settings.vector_height_mm == pytest.approx(16.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": 16.4,
"height_mm": 16.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"<svg/>",
"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