961 lines
40 KiB
Python
961 lines
40 KiB
Python
"""Tests for multi-view production texture pairs (`orisys-design texture-views`).
|
|
|
|
Each pair writes its own texture, and every mesh triangle belongs to exactly one pair, so the
|
|
two files cannot ink the same surface. Nothing here needs Blender — the host module is
|
|
stdlib-only, which is the point: the pair validation, the ownership policy and the job
|
|
contract are checkable in CI, and the Blender side consumes a job it did not have to
|
|
interpret.
|
|
|
|
The last group is an isolation guard: the revision on disk now carries a
|
|
``texture_patch_views`` block, so these tests pin that the block does not leak into the
|
|
existing single-view ``texture_patch`` path or its job.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from orisys_sensor_design import texture_views
|
|
from orisys_sensor_design.manifest import RevisionPaths, repo_root
|
|
from orisys_sensor_design.texture import (
|
|
TEXTURE_ARTIFACT_FORMATS,
|
|
TextureConfigError,
|
|
build_texture_job,
|
|
resolve_texture_output_dir,
|
|
resolve_texture_settings,
|
|
)
|
|
|
|
SENSOR = "finger"
|
|
REVISION = "r01"
|
|
|
|
|
|
def _write_revision(
|
|
tmp_path: Path,
|
|
views_yaml: str = "",
|
|
*,
|
|
texture_patch_yaml: str | None = None,
|
|
) -> Path:
|
|
"""Minimal revision with a texture_patch block and an optional views block."""
|
|
revision = tmp_path / "sensors" / SENSOR / REVISION
|
|
(revision / "profiles").mkdir(parents=True)
|
|
(revision / "source").mkdir(parents=True)
|
|
(revision / "source" / "assembly.blend").write_bytes(b"")
|
|
design = (
|
|
"sensor: finger\n"
|
|
f"revision: {REVISION}\n"
|
|
"meters_per_source_unit: 0.001\n"
|
|
"source_blend: source/assembly.blend\n"
|
|
)
|
|
if texture_patch_yaml is None:
|
|
texture_patch_yaml = (
|
|
"texture_patch:\n"
|
|
" mesh: sensing_surface\n"
|
|
" camera: cam_sensor\n"
|
|
" uv_outputs:\n"
|
|
" produce: patch_texture_produce.png\n"
|
|
" compute: patch_texture_compute.png\n"
|
|
" width: 512\n"
|
|
" height: 256\n"
|
|
" nx: 80\n"
|
|
" ny: 45\n"
|
|
" seed: 7\n"
|
|
" vector:\n"
|
|
" width_mm: 16.4\n"
|
|
" height_mm: 16.0\n"
|
|
)
|
|
design += texture_patch_yaml + views_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
|
|
|
|
|
|
def _block(pairs_yaml: str, enabled: str = " enabled: true\n") -> str:
|
|
return "texture_patch_views:\n" + enabled + " pairs:\n" + pairs_yaml
|
|
|
|
|
|
#: The shape the r01 revision uses: a REGION camera per layout. The pattern camera defaults
|
|
#: to `texture_patch.camera`, so both layouts carry the same surface pattern as `compute`.
|
|
TWO_PAIRS = _block(
|
|
" - id: uv_1\n"
|
|
" camera: cam_uv_1\n"
|
|
" uv_map: uv_1\n"
|
|
" - id: uv_2\n"
|
|
" camera: cam_uv_2\n"
|
|
" uv_map: uv_2\n"
|
|
)
|
|
|
|
#: Explicit per-pair pattern cameras — an override, not the default.
|
|
TWO_PAIRS_EXPLICIT_PATTERN = _block(
|
|
" - id: uv_1\n"
|
|
" camera: cam_uv_1\n"
|
|
" pattern_camera: cam_alt\n"
|
|
" uv_map: uv_1\n"
|
|
" - id: uv_2\n"
|
|
" camera: cam_uv_2\n"
|
|
" uv_map: uv_2\n"
|
|
)
|
|
|
|
#: A merge/seam policy block appended after the pairs.
|
|
AUTHORED_MERGE = (
|
|
" merge:\n"
|
|
" mode: authored\n"
|
|
" incidence_limit_deg: 62.0\n"
|
|
" min_triangle_coverage: 0.60\n"
|
|
" seam:\n"
|
|
" boundary: use_seam\n"
|
|
" pins: []\n"
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def real_revision() -> Path:
|
|
return repo_root() / "sensors" / SENSOR / REVISION
|
|
|
|
|
|
# ---------------------------------------------------------------- block loading
|
|
|
|
|
|
def test_revision_without_a_views_block_returns_none(tmp_path: Path) -> None:
|
|
paths = RevisionPaths.from_revision_dir(_write_revision(tmp_path))
|
|
assert texture_views.load_views_block(paths) is None
|
|
|
|
|
|
def test_missing_views_block_says_the_single_view_path_is_unaffected(tmp_path: Path) -> None:
|
|
paths = RevisionPaths.from_revision_dir(_write_revision(tmp_path))
|
|
settings = resolve_texture_settings(paths)
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
texture_views.resolve_view_pairs(paths, settings)
|
|
message = str(excinfo.value)
|
|
assert "texture_patch_views" in message
|
|
assert "orisys-design texture" in message
|
|
|
|
|
|
def test_views_block_must_be_a_mapping(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, "texture_patch_views: nope\n")
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
with pytest.raises(TextureConfigError):
|
|
texture_views.load_views_block(paths)
|
|
|
|
|
|
# ---------------------------------------------------------------- validation
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("views_yaml", "expected"),
|
|
[
|
|
("texture_patch_views: {}\n", "pairs"),
|
|
(_block("", enabled=" enabled: maybe\n"), "enabled"),
|
|
("texture_patch_views:\n pairs: {}\n", "pairs"),
|
|
("texture_patch_views:\n pairs: []\n", "pairs"),
|
|
(_block(" - nope\n"), "mapping"),
|
|
(_block(" - id: a\n camera: c\n"), "uv_map"),
|
|
(
|
|
_block(" - id: a\n camera: c\n uv_map: u\n extra: 1\n"),
|
|
"unknown key",
|
|
),
|
|
(
|
|
_block(" - id: a\n camera: c\n uv_map: u\n"
|
|
" - id: a\n camera: c2\n uv_map: u2\n"),
|
|
"duplicate pair id",
|
|
),
|
|
(
|
|
# Two pairs that both default to the sensing camera pattern, writing one layout:
|
|
# same pattern + same layout means byte-identical textures.
|
|
_block(" - id: a\n camera: c\n uv_map: u\n"
|
|
" - id: b\n camera: c2\n uv_map: u\n"),
|
|
"would be identical",
|
|
),
|
|
(
|
|
# Two pairs sharing one region camera.
|
|
_block(" - id: a\n camera: c\n uv_map: u\n"
|
|
" - id: b\n camera: c\n uv_map: u2\n"),
|
|
"would claim the same surface region",
|
|
),
|
|
(
|
|
_block(" - id: a\n camera: c\n uv_map: u\n"
|
|
" - id: b\n camera: c2\n uv_map: u2\n"
|
|
" filename: patch_texture_a.png\n"),
|
|
"collides",
|
|
),
|
|
(
|
|
_block(" - id: a\n camera: c\n uv_map: u\n seed: 1.5\n"),
|
|
"seed",
|
|
),
|
|
(
|
|
_block(" - id: a\n camera: c\n uv_map: u\n filename: x.svg\n"),
|
|
".png",
|
|
),
|
|
("texture_patch_views:\n bogus: 1\n pairs: []\n", "unknown key"),
|
|
],
|
|
)
|
|
def test_validation_rejects_bad_blocks(tmp_path: Path, views_yaml: str, expected: str) -> None:
|
|
revision = _write_revision(tmp_path, views_yaml)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
texture_views.resolve_view_pairs(paths, settings)
|
|
assert expected in str(excinfo.value)
|
|
|
|
|
|
# ---------------------------------------------------------------- merge and seam blocks
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("merge_yaml", "expected"),
|
|
[
|
|
(" merge:\n mode: guessed\n", "mode"),
|
|
(" merge:\n component_policy: guessing\n", "component_policy"),
|
|
(" merge:\n incidence_limit_deg: 200\n", "incidence_limit_deg"),
|
|
(" merge:\n min_triangle_coverage: 2\n", "min_triangle_coverage"),
|
|
(" merge:\n max_hole_fraction: -1\n", "max_hole_fraction"),
|
|
(" merge:\n nope: 1\n", "unknown key"),
|
|
(" merge: nope\n", "must be a mapping"),
|
|
],
|
|
)
|
|
def test_merge_block_rejects_bad_values(tmp_path: Path, merge_yaml: str, expected: str) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + merge_yaml)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
|
|
assert expected in str(excinfo.value)
|
|
|
|
|
|
def test_merge_block_rejects_non_numeric_threshold(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + " merge:\n min_footprint_mm2: thick\n")
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
|
|
assert "min_footprint_mm2" in str(excinfo.value)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("seam_yaml", "expected"),
|
|
[
|
|
(" seam:\n boundary: guesswork\n", "boundary"),
|
|
(" seam:\n nope: 1\n", "unknown key"),
|
|
(" seam: nope\n", "must be a mapping"),
|
|
(" seam:\n pins: nope\n", "pins"),
|
|
(
|
|
" seam:\n pins:\n"
|
|
" - component: -1\n view: uv_1\n reason: x\n",
|
|
"component",
|
|
),
|
|
(" seam:\n pins:\n - component: 0\n view: uv_1\n", "reason"),
|
|
(
|
|
" seam:\n pins:\n"
|
|
" - component: 0\n view: ''\n reason: x\n",
|
|
"view",
|
|
),
|
|
(
|
|
" seam:\n pins:\n"
|
|
" - component: 0\n view: uv_1\n reason: a\n"
|
|
" - component: 0\n view: uv_2\n reason: b\n",
|
|
"duplicate pin",
|
|
),
|
|
(
|
|
" seam:\n pins:\n"
|
|
" - component: 0\n view: uv_1\n reason: a\n extra: 1\n",
|
|
"unknown key",
|
|
),
|
|
],
|
|
)
|
|
def test_seam_block_rejects_bad_values(tmp_path: Path, seam_yaml: str, expected: str) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + seam_yaml)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
|
|
assert expected in str(excinfo.value)
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["none", "inked", "owned"])
|
|
def test_print_block_accepts_every_base_fill_mode(tmp_path: Path, mode: str) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + f" print:\n base_fill: {mode}\n")
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
block = texture_views.load_views_block(paths)
|
|
pairs = texture_views.resolve_view_pairs(paths, settings, block)
|
|
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
|
|
assert job["print"] == {"base_fill": mode}
|
|
|
|
|
|
def test_print_block_defaults_to_owned(tmp_path: Path) -> None:
|
|
"""The white ground is kept and split by the seam, like the pattern is."""
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
pairs = texture_views.resolve_view_pairs(paths, settings)
|
|
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o")
|
|
assert job["print"] == {"base_fill": "owned"}
|
|
|
|
|
|
def test_print_block_rejects_unknown_base_fill(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + " print:\n base_fill: guess\n")
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
|
|
assert "base_fill" in str(excinfo.value)
|
|
|
|
|
|
def test_print_block_rejects_unknown_key(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + " print:\n nope: 1\n")
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
|
|
assert "unknown key" in str(excinfo.value)
|
|
|
|
|
|
# ---------------------------------------------------------------- seam margin
|
|
|
|
|
|
def test_seam_margin_defaults_to_twice_the_alignment_tolerance(tmp_path: Path) -> None:
|
|
"""Two passes each placed to within t can close on each other by 2t, so the band is 2t."""
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
gap = texture_views.resolve_seam_settings(
|
|
texture_views.load_views_block(paths)
|
|
)["gap"]
|
|
assert gap["enabled"] is True
|
|
assert gap["treatment"] == "no_print"
|
|
assert gap["alignment_tolerance_mm"] == 0.1
|
|
assert gap["total_mm"] == pytest.approx(0.2)
|
|
assert gap["inset_mm"] == pytest.approx(0.1)
|
|
|
|
|
|
def test_seam_margin_is_enabled_even_without_a_gap_block(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
gap = texture_views.resolve_seam_settings(
|
|
texture_views.load_views_block(paths)
|
|
)["gap"]
|
|
assert gap["enabled"] is True
|
|
assert gap["total_mm"] == pytest.approx(0.2)
|
|
|
|
|
|
def test_seam_margin_explicit_total_overrides_the_derivation(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + (
|
|
" seam:\n"
|
|
" gap:\n"
|
|
" alignment_tolerance_mm: 0.05\n"
|
|
" total_mm: 0.5\n"
|
|
))
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
gap = texture_views.resolve_seam_settings(
|
|
texture_views.load_views_block(paths)
|
|
)["gap"]
|
|
assert gap["total_mm"] == pytest.approx(0.5)
|
|
assert gap["inset_mm"] == pytest.approx(0.25)
|
|
|
|
|
|
def test_seam_margin_can_be_disabled(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE + " gap: false\n")
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
gap = texture_views.resolve_seam_settings(
|
|
texture_views.load_views_block(paths)
|
|
)["gap"]
|
|
assert gap["enabled"] is False
|
|
assert gap["total_mm"] == 0.0
|
|
assert gap["inset_mm"] == 0.0
|
|
|
|
|
|
def test_unimplemented_gap_treatment_is_refused_not_silently_ignored(tmp_path: Path) -> None:
|
|
"""`reduced_density` is the planned blending mode; until it exists it must not no-op."""
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE + (
|
|
" gap:\n"
|
|
" treatment: reduced_density\n"
|
|
))
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
block = texture_views.load_views_block(paths)
|
|
# Passes schema validation as a known value...
|
|
assert texture_views.validate_views_block(block) == []
|
|
# ...but is refused when the job is built, before any Blender run.
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
texture_views.resolve_seam_settings(block)
|
|
message = str(excinfo.value)
|
|
assert "reduced_density" in message and "not implemented" in message
|
|
with pytest.raises(TextureConfigError):
|
|
pairs = texture_views.resolve_view_pairs(paths, settings, block)
|
|
texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("gap_yaml", "expected"),
|
|
[
|
|
(" gap:\n treatment: guess\n", "treatment"),
|
|
(" gap:\n alignment_tolerance_mm: 0\n", "must be > 0"),
|
|
(" gap:\n alignment_tolerance_mm: -1\n", "must be > 0"),
|
|
(" gap:\n total_mm: 0\n", "must be > 0"),
|
|
(" gap:\n nope: 1\n", "unknown key"),
|
|
(" gap: nope\n", "must be a mapping"),
|
|
],
|
|
)
|
|
def test_seam_margin_rejects_bad_values(tmp_path: Path, gap_yaml: str, expected: str) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE + gap_yaml)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
|
|
assert expected in str(excinfo.value)
|
|
|
|
|
|
def test_seam_margin_travels_in_the_job(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
block = texture_views.load_views_block(paths)
|
|
pairs = texture_views.resolve_view_pairs(paths, settings, block)
|
|
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
|
|
gap = job["seam"]["gap"]
|
|
assert gap["enabled"] is True
|
|
assert gap["treatment"] == "no_print"
|
|
assert gap["total_mm"] == pytest.approx(0.2)
|
|
assert gap["inset_mm"] == pytest.approx(0.1)
|
|
|
|
|
|
def test_real_revisions_declare_the_seam_margin(real_revision: Path) -> None:
|
|
"""Both shipped revisions carry a 0.1 mm tolerance, so the band is 0.2 mm."""
|
|
paths = RevisionPaths.from_revision_dir(real_revision)
|
|
gap = texture_views.resolve_seam_settings(
|
|
texture_views.load_views_block(paths)
|
|
)["gap"]
|
|
assert gap["enabled"] is True
|
|
assert gap["alignment_tolerance_mm"] == 0.1
|
|
assert gap["total_mm"] == pytest.approx(0.2)
|
|
|
|
|
|
# ---------------------------------------------------------------- page sizing
|
|
|
|
|
|
def test_page_defaults_to_auto_at_the_shipped_density(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
page = texture_views.resolve_page_settings(texture_views.load_views_block(paths))
|
|
assert page == {"mode": "auto", "px_per_mm": 200.0}
|
|
|
|
|
|
def test_page_density_reproduces_the_existing_rasters() -> None:
|
|
"""200 px/mm is what both revisions already use exactly, so only the proportion changes."""
|
|
assert texture_views.PAGE_DEFAULT_PX_PER_MM == 200.0
|
|
for raster, page_mm in ((3280, 16.4), (3200, 16.0), (3700, 18.5)):
|
|
assert raster / page_mm == pytest.approx(200.0)
|
|
|
|
|
|
def test_page_fixed_keeps_the_texture_patch_page(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + " page:\n mode: fixed\n")
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
block = texture_views.load_views_block(paths)
|
|
pairs = texture_views.resolve_view_pairs(paths, settings, block)
|
|
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
|
|
assert job["page"]["mode"] == "fixed"
|
|
assert all(pair["page"] == "fixed" for pair in job["pairs"])
|
|
|
|
|
|
def test_per_pair_page_override_wins(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, _block(
|
|
" - id: uv_1\n camera: cam_uv_1\n uv_map: uv_1\n page: fixed\n"
|
|
" - id: uv_2\n camera: cam_uv_2\n uv_map: uv_2\n",
|
|
) + " page:\n mode: auto\n")
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
block = texture_views.load_views_block(paths)
|
|
pairs = texture_views.resolve_view_pairs(paths, settings, block)
|
|
assert [pair.page_mode for pair in pairs] == ["fixed", "auto"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("page_yaml", "expected"),
|
|
[
|
|
(" page:\n mode: guessed\n", "mode"),
|
|
(" page:\n px_per_mm: 0\n", "px_per_mm"),
|
|
(" page:\n px_per_mm: -5\n", "px_per_mm"),
|
|
(" page:\n px_per_mm: thick\n", "px_per_mm"),
|
|
(" page:\n nope: 1\n", "unknown key"),
|
|
(" page: nope\n", "must be a mapping"),
|
|
(
|
|
" pairs:\n - {id: a, camera: c, uv_map: u, page: sideways}\n",
|
|
"page",
|
|
),
|
|
],
|
|
)
|
|
def test_page_block_rejects_bad_values(tmp_path: Path, page_yaml: str, expected: str) -> None:
|
|
if page_yaml.startswith(" pairs:"):
|
|
# This case replaces the pairs block rather than appending to it.
|
|
revision = _write_revision(
|
|
tmp_path,
|
|
"texture_patch_views:\n enabled: true\n" + page_yaml,
|
|
)
|
|
else:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + page_yaml)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
|
|
assert expected in str(excinfo.value)
|
|
|
|
|
|
def test_real_revisions_size_the_page_per_view(real_revision: Path) -> None:
|
|
"""Both shipped revisions derive the page, at the density that preserves their raster."""
|
|
paths = RevisionPaths.from_revision_dir(real_revision)
|
|
block = texture_views.load_views_block(paths)
|
|
page = texture_views.resolve_page_settings(block)
|
|
assert page["mode"] == "auto"
|
|
assert page["px_per_mm"] == 200.0
|
|
assert all(pair.page_mode == "auto" for pair in texture_views.resolve_view_pairs(
|
|
paths, resolve_texture_settings(paths), block
|
|
))
|
|
|
|
|
|
def test_page_derivation_is_not_attempted_host_side(real_revision: Path) -> None:
|
|
"""The page needs the mesh and the camera, so the host carries the mode, not a number.
|
|
|
|
If the host ever starts printing a page it computed itself, this fails — which is the
|
|
point: the console must describe the page the run used, not one it guessed.
|
|
"""
|
|
paths = RevisionPaths.from_revision_dir(real_revision)
|
|
block = texture_views.load_views_block(paths)
|
|
settings = resolve_texture_settings(paths)
|
|
pairs = texture_views.resolve_view_pairs(paths, settings, block)
|
|
job = texture_views.build_multiview_job(paths, settings, pairs, paths.root / "o", block)
|
|
assert "page_mm" not in job
|
|
assert isinstance(job["page"]["mode"], str)
|
|
for pair in job["pairs"]:
|
|
assert pair["page"] in texture_views.PAGE_MODES
|
|
|
|
|
|
def test_authored_merge_block_resolves_into_the_job(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
block = texture_views.load_views_block(paths)
|
|
pairs = texture_views.resolve_view_pairs(paths, settings, block)
|
|
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
|
|
assert job["merge"]["mode"] == "authored"
|
|
assert job["merge"]["incidence_limit_deg"] == 62.0
|
|
assert job["seam"]["boundary"] == "use_seam"
|
|
|
|
|
|
def test_seam_pins_are_carried_into_the_job(tmp_path: Path) -> None:
|
|
pins = (
|
|
" pins:\n"
|
|
" - component: 1\n view: uv_2\n reason: rim is usable here\n"
|
|
)
|
|
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE + pins)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
block = texture_views.load_views_block(paths)
|
|
pairs = texture_views.resolve_view_pairs(paths, settings, block)
|
|
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
|
|
assert job["seam"]["pins"] == [
|
|
{"component": 1, "view": "uv_2", "reason": "rim is usable here"}
|
|
]
|
|
|
|
|
|
def test_absent_merge_block_uses_the_calibrated_defaults(tmp_path: Path) -> None:
|
|
"""Zero authoring burden: the defaults alone produce a partition."""
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
pairs = texture_views.resolve_view_pairs(paths, settings)
|
|
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o")
|
|
assert job["merge"] == texture_views.MERGE_DEFAULTS
|
|
|
|
|
|
def test_validation_accepts_the_two_view_block(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
pairs = texture_views.resolve_view_pairs(paths, settings)
|
|
assert [p.id for p in pairs] == ["uv_1", "uv_2"]
|
|
assert [p.uv_map for p in pairs] == ["uv_1", "uv_2"]
|
|
# `camera` is the REGION camera: the production view that decides what this pair owns.
|
|
assert [p.camera for p in pairs] == ["cam_uv_1", "cam_uv_2"]
|
|
# The pattern source is separate, and defaults to the sensing camera.
|
|
assert [p.pattern_camera for p in pairs] == ["cam_sensor", "cam_sensor"]
|
|
|
|
|
|
def test_pattern_camera_defaults_to_the_compute_source(tmp_path: Path) -> None:
|
|
"""uv_1/uv_2 must carry the same surface pattern as `compute`."""
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
pairs = texture_views.resolve_view_pairs(paths, settings)
|
|
assert all(p.pattern_camera == settings.camera for p in pairs)
|
|
|
|
|
|
def test_pattern_camera_can_be_overridden_at_block_or_pair_level(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, _block(
|
|
" - id: uv_1\n camera: cam_uv_1\n uv_map: uv_1\n"
|
|
" - id: uv_2\n camera: cam_uv_2\n uv_map: uv_2\n",
|
|
).replace(
|
|
" enabled: true\n",
|
|
" enabled: true\n pattern_camera: cam_alt_block\n",
|
|
).replace(
|
|
" camera: cam_uv_2\n",
|
|
" camera: cam_uv_2\n pattern_camera: cam_alt_pair\n",
|
|
))
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
|
|
assert [p.pattern_camera for p in pairs] == ["cam_alt_block", "cam_alt_pair"]
|
|
# The region cameras are unaffected by the pattern override.
|
|
assert [p.camera for p in pairs] == ["cam_uv_1", "cam_uv_2"]
|
|
|
|
|
|
def test_region_camera_is_required(tmp_path: Path) -> None:
|
|
"""A pair without a region camera has no authority to own anything."""
|
|
revision = _write_revision(tmp_path, _block(
|
|
" - id: uv_1\n uv_map: uv_1\n",
|
|
))
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
|
|
assert "camera" in str(excinfo.value)
|
|
|
|
|
|
def test_bad_pattern_camera_is_rejected(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, _block(
|
|
" - id: uv_1\n uv_map: uv_1\n",
|
|
).replace(" enabled: true\n", " enabled: true\n pattern_camera: ''\n"))
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
|
|
assert "pattern_camera" in str(excinfo.value)
|
|
|
|
|
|
def test_disabled_block_is_refused(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, _block(
|
|
" - id: uv_1\n camera: cam_uv_1\n uv_map: uv_1\n",
|
|
enabled=" enabled: false\n",
|
|
))
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
with pytest.raises(TextureConfigError) as excinfo:
|
|
texture_views.resolve_view_pairs(paths, settings)
|
|
assert "enabled is false" in str(excinfo.value)
|
|
|
|
|
|
# ---------------------------------------------------------------- seeds and filenames
|
|
|
|
|
|
def test_the_colour_seed_is_shared_across_layouts(tmp_path: Path) -> None:
|
|
"""One pattern, one palette: a per-layout seed would paint two patterns on one surface."""
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
assert settings.seed == 7
|
|
pairs = texture_views.resolve_view_pairs(paths, settings)
|
|
assert [p.seed for p in pairs] == [7, 7]
|
|
|
|
|
|
def test_explicit_pair_seed_wins(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, _block(
|
|
" - id: uv_1\n camera: cam_uv_1\n uv_map: uv_1\n seed: 99\n"
|
|
" - id: uv_2\n camera: cam_uv_2\n uv_map: uv_2\n",
|
|
))
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
|
|
assert [p.seed for p in pairs] == [99, 7]
|
|
|
|
|
|
def test_no_base_seed_leaves_pair_seeds_none(tmp_path: Path) -> None:
|
|
texture_patch = (
|
|
"texture_patch:\n"
|
|
" mesh: sensing_surface\n"
|
|
" camera: cam_sensor\n"
|
|
" uv_outputs:\n"
|
|
" produce: patch_texture_produce.png\n"
|
|
)
|
|
revision = _write_revision(tmp_path, TWO_PAIRS, texture_patch_yaml=texture_patch)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
|
|
assert [p.seed for p in pairs] == [None, None]
|
|
|
|
|
|
def test_default_filenames_come_from_the_pair_id(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
|
|
assert [p.target.filename for p in pairs] == [
|
|
"patch_texture_uv_1.png",
|
|
"patch_texture_uv_2.png",
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------- job contract
|
|
|
|
|
|
def test_job_has_one_entry_per_pair_with_absolute_artifacts(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
pairs = texture_views.resolve_view_pairs(paths, settings)
|
|
out = tmp_path / "out"
|
|
job = texture_views.build_multiview_job(paths, settings, pairs, out)
|
|
|
|
assert job["schema"] == texture_views.MULTIVIEW_JOB_SCHEMA
|
|
assert job["mode"] == "patches-multiview"
|
|
assert job["objects"]["mesh"] == "sensing_surface"
|
|
assert "camera" not in job["objects"] # cameras are per pair, not global
|
|
assert job["pattern_camera"] == "cam_sensor"
|
|
assert [p["id"] for p in job["pairs"]] == ["uv_1", "uv_2"]
|
|
assert [p["camera"] for p in job["pairs"]] == ["cam_uv_1", "cam_uv_2"]
|
|
assert [p["pattern_camera"] for p in job["pairs"]] == ["cam_sensor", "cam_sensor"]
|
|
# The merge policy travels with the job so the Blender side never invents a default.
|
|
assert job["merge"]["mode"] == "computed"
|
|
assert job["merge"]["incidence_limit_deg"] == 62.0
|
|
assert job["merge"]["min_triangle_coverage"] == 0.60
|
|
assert job["seam"]["boundary"] == "use_seam"
|
|
assert job["seam"]["pins"] == []
|
|
assert job["seam"]["gap"]["enabled"] is True
|
|
for entry in job["pairs"]:
|
|
assert set(entry["artifacts"]) == set(TEXTURE_ARTIFACT_FORMATS)
|
|
for path in entry["artifacts"].values():
|
|
assert Path(path).is_absolute()
|
|
# Settings come from texture_patch, so the printed page is decided in one place.
|
|
assert job["settings"]["nx"] == 80
|
|
assert job["settings"]["vector"]["width_mm"] == pytest.approx(16.4)
|
|
|
|
|
|
def test_job_is_deterministic(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
pairs = texture_views.resolve_view_pairs(paths, settings)
|
|
first = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o")
|
|
second = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o")
|
|
assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True)
|
|
|
|
|
|
def test_write_job_round_trips(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
settings = resolve_texture_settings(paths)
|
|
pairs = texture_views.resolve_view_pairs(paths, settings)
|
|
out = tmp_path / "out"
|
|
job = texture_views.build_multiview_job(paths, settings, pairs, out)
|
|
written = texture_views.write_multiview_job(job, out)
|
|
assert written.name == texture_views.MULTIVIEW_JOB_FILENAME
|
|
assert json.loads(written.read_text(encoding="utf-8")) == job
|
|
|
|
|
|
def test_empty_pair_list_is_refused(tmp_path: Path) -> None:
|
|
paths = RevisionPaths.from_revision_dir(_write_revision(tmp_path, TWO_PAIRS))
|
|
settings = resolve_texture_settings(paths)
|
|
with pytest.raises(TextureConfigError):
|
|
texture_views.build_multiview_job(paths, settings, (), tmp_path)
|
|
|
|
|
|
def test_output_paths_are_absolute_and_ordered(tmp_path: Path) -> None:
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
|
|
targets = texture_views.multiview_output_paths(tmp_path / "o", pairs)
|
|
assert [pair.id for pair, _ in targets] == ["uv_1", "uv_2"]
|
|
for _pair, artifacts in targets:
|
|
assert list(artifacts) == list(TEXTURE_ARTIFACT_FORMATS)
|
|
assert all(path.is_absolute() for path in artifacts.values())
|
|
|
|
|
|
# ---------------------------------------------------------------- isolation guard
|
|
|
|
|
|
def test_real_revision_declares_exactly_the_two_production_views(
|
|
real_revision: Path,
|
|
) -> None:
|
|
"""Guards the on-disk wiring: uv_1/uv_2, patterned from the sensing camera.
|
|
|
|
`cam_uv_1`/`cam_uv_2` authored the layouts in Blender; they must NOT appear as pattern
|
|
sources, or the printed marker would carry two different patterns.
|
|
"""
|
|
paths = RevisionPaths.from_revision_dir(real_revision)
|
|
settings = resolve_texture_settings(paths)
|
|
block = texture_views.load_views_block(paths)
|
|
pairs = texture_views.resolve_view_pairs(paths, settings, block)
|
|
assert [(p.id, p.uv_map) for p in pairs] == [("uv_1", "uv_1"), ("uv_2", "uv_2")]
|
|
# Region cameras are the production views; the pattern comes from the sensing camera.
|
|
assert [p.camera for p in pairs] == ["cam_uv_1", "cam_uv_2"]
|
|
assert [p.pattern_camera for p in pairs] == [settings.camera, settings.camera]
|
|
assert settings.camera == "cam_sensor"
|
|
filenames = sorted(p.target.filename for p in pairs)
|
|
assert filenames == ["patch_texture_uv_1.png", "patch_texture_uv_2.png"]
|
|
# The runtime layout must never be a production view target.
|
|
assert all(p.uv_map not in {"compute", "produce"} for p in pairs)
|
|
# ...and the palette must match compute's, so the marker is one pattern.
|
|
assert [p.seed for p in pairs] == [settings.seed, settings.seed]
|
|
|
|
|
|
def test_real_revision_is_configured_for_the_authored_seam(real_revision: Path) -> None:
|
|
"""Pins the on-disk policy: the marked seam is the authority, at the calibrated limit."""
|
|
paths = RevisionPaths.from_revision_dir(real_revision)
|
|
block = texture_views.load_views_block(paths)
|
|
policy = texture_views.resolve_merge_policy(block)
|
|
assert policy["mode"] == "authored"
|
|
assert policy["incidence_limit_deg"] == 62.0
|
|
assert policy["component_policy"] == "best_coverage"
|
|
assert policy["max_hole_fraction"] == 0.02
|
|
seam = texture_views.resolve_seam_settings(block)
|
|
assert seam["boundary"] == "use_seam"
|
|
assert seam["pins"] == []
|
|
assert seam["gap"]["treatment"] == "no_print"
|
|
assert seam["gap"]["alignment_tolerance_mm"] == 0.1
|
|
assert seam["gap"]["total_mm"] == pytest.approx(0.2)
|
|
|
|
|
|
def test_host_defaults_match_the_blender_module(real_revision: Path) -> None:
|
|
"""The host duplicates the defaults; this is the test that keeps them honest.
|
|
|
|
Without it, `texture_views.MERGE_DEFAULTS` and `texture_merge.MergePolicy` could drift and
|
|
the job would silently carry thresholds different from the ones the policy was tested at.
|
|
"""
|
|
sys.path.insert(0, str(repo_root() / "blender" / "scripts"))
|
|
try:
|
|
import texture_merge # type: ignore
|
|
finally:
|
|
sys.path.pop(0)
|
|
policy = texture_merge.MergePolicy()
|
|
assert texture_views.MERGE_DEFAULTS["mode"] == policy.mode
|
|
assert texture_views.MERGE_DEFAULTS["incidence_limit_deg"] == policy.incidence_limit_deg
|
|
assert texture_views.MERGE_DEFAULTS["min_footprint_mm2"] == policy.min_footprint_mm2
|
|
assert (
|
|
texture_views.MERGE_DEFAULTS["min_triangle_coverage"] == policy.min_triangle_coverage
|
|
)
|
|
assert texture_views.MERGE_DEFAULTS["max_hole_fraction"] == policy.max_hole_fraction
|
|
assert texture_views.MERGE_DEFAULTS["component_policy"] == policy.component_policy
|
|
|
|
|
|
def test_views_block_does_not_leak_into_the_single_view_path(real_revision: Path) -> None:
|
|
"""`orisys-design texture` must still see only texture_patch's own outputs."""
|
|
paths = RevisionPaths.from_revision_dir(real_revision)
|
|
settings = resolve_texture_settings(paths)
|
|
assert [t.uv_map for t in settings.outputs] == ["produce", "compute"]
|
|
assert settings.camera == "cam_sensor"
|
|
|
|
job = build_texture_job(paths, settings, real_revision / "build")
|
|
assert "pairs" not in job
|
|
assert [entry["uv_map"] for entry in job["outputs"]] == ["produce", "compute"]
|
|
assert job["objects"]["camera"] == "cam_sensor"
|
|
|
|
|
|
def test_views_and_single_view_share_the_page_settings(real_revision: Path) -> None:
|
|
"""A page decision lives in texture_patch, so both paths must agree on it."""
|
|
paths = RevisionPaths.from_revision_dir(real_revision)
|
|
settings = resolve_texture_settings(paths)
|
|
views_settings = texture_views.resolve_views_settings(paths)
|
|
assert views_settings.nx == settings.nx
|
|
assert views_settings.ny == settings.ny
|
|
assert (views_settings.vector_width_mm, views_settings.vector_height_mm) == (
|
|
settings.vector_width_mm,
|
|
settings.vector_height_mm,
|
|
)
|
|
assert views_settings.outputs == settings.outputs
|
|
|
|
|
|
def test_views_overrides_do_not_touch_design_yaml(tmp_path: Path) -> None:
|
|
"""CLI overrides go into the job only, exactly like the single-view path."""
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
before = paths.design_yaml.read_text(encoding="utf-8")
|
|
namespace = argparse.Namespace(nx=31, ny=31, width=256, height=256)
|
|
settings = texture_views.resolve_views_settings(
|
|
paths, {"nx": namespace.nx, "ny": namespace.ny, "width": namespace.width,
|
|
"height": namespace.height}
|
|
)
|
|
assert (settings.nx, settings.ny) == (31, 31)
|
|
assert paths.design_yaml.read_text(encoding="utf-8") == before
|
|
|
|
|
|
def test_output_dir_override_wins_for_views(real_revision: Path, tmp_path: Path) -> None:
|
|
paths = RevisionPaths.from_revision_dir(real_revision)
|
|
assert resolve_texture_output_dir(repo_root(), paths, tmp_path / "elsewhere") == (
|
|
tmp_path / "elsewhere"
|
|
).resolve()
|
|
|
|
|
|
# ---------------------------------------------------------------- inspect --svg
|
|
|
|
|
|
def _inspect_namespace(revision: Path, output_dir: Path, **overrides: object):
|
|
namespace = argparse.Namespace(
|
|
revision_dir=revision,
|
|
output_dir=output_dir,
|
|
uv_map=None,
|
|
out=None,
|
|
csv=None,
|
|
csv_all=None,
|
|
top=5,
|
|
printer_dpi=None,
|
|
min_dots=None,
|
|
min_feature_mm=None,
|
|
min_edge_mm=None,
|
|
max_sliver_ratio=None,
|
|
min_patch_area_mm2=None,
|
|
svg=None,
|
|
)
|
|
for key, value in overrides.items():
|
|
setattr(namespace, key, value)
|
|
return namespace
|
|
|
|
|
|
def _minimal_svg(path: Path) -> Path:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16.4mm" height="16.0mm" '
|
|
'viewBox="0 0 3280 3200" preserveAspectRatio="none">\n'
|
|
' <g id="artwork" transform="translate(0 3200) scale(1 -1)">\n'
|
|
' <g id="mesh">\n'
|
|
' <polygon id="mesh-1" points="0,0 3280,0 3280,3200 0,3200" fill="#ffffff"/>\n'
|
|
' </g>\n'
|
|
' <g id="patches">\n'
|
|
' <polygon id="patch-1" points="10,10 210,10 210,210 10,210" fill="#000000"/>\n'
|
|
' </g>\n'
|
|
' </g>\n'
|
|
"</svg>\n",
|
|
encoding="utf-8",
|
|
)
|
|
return path
|
|
|
|
|
|
def test_inspect_svg_option_grades_an_unconfigured_layout(tmp_path: Path) -> None:
|
|
"""The per-view textures are not in texture_patch.uv_outputs; --svg grades them anyway.
|
|
|
|
``texture-views`` deliberately does not extend ``uv_outputs``, because extending it
|
|
would make the single-view ``orisys-design texture`` generate those layouts too.
|
|
"""
|
|
from orisys_sensor_design import cli
|
|
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
output_dir = tmp_path / "textures"
|
|
svg = _minimal_svg(output_dir / "patch_texture_uv_1.svg")
|
|
args = _inspect_namespace(revision, output_dir, svg=[svg])
|
|
assert cli.cmd_inspect(revision, args) == 0
|
|
|
|
report = json.loads((output_dir / "texture_report.json").read_text(encoding="utf-8"))
|
|
assert [entry["uv_map"] for entry in report["maps"]] == ["patch_texture_uv_1"]
|
|
|
|
|
|
def test_inspect_svg_option_reports_a_missing_file_as_exit_three(tmp_path: Path) -> None:
|
|
from orisys_sensor_design import cli
|
|
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
args = _inspect_namespace(
|
|
revision, tmp_path / "textures", svg=[tmp_path / "textures" / "nope.svg"]
|
|
)
|
|
assert cli.cmd_inspect(revision, args) == 3
|
|
|
|
|
|
def test_inspect_without_svg_still_uses_the_configured_maps(tmp_path: Path) -> None:
|
|
"""Additive: absent --svg, the configured-map path is unchanged."""
|
|
revision = _write_revision(tmp_path, TWO_PAIRS)
|
|
output_dir = tmp_path / "textures"
|
|
# No configured-map SVGs written at all -> the configured path reports them missing.
|
|
from orisys_sensor_design import cli
|
|
|
|
assert cli.cmd_inspect(revision, _inspect_namespace(revision, output_dir)) == 3
|