added printability report when generating texture
This commit is contained in:
@@ -0,0 +1,674 @@
|
||||
"""Tests for post-generation printability inspection (report schema v2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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.printability import (
|
||||
DEFAULT_PRINTABILITY,
|
||||
REPORT_FILENAME,
|
||||
REPORT_SCHEMA,
|
||||
PrintabilityConfigError,
|
||||
PrintabilitySettings,
|
||||
analyse_map,
|
||||
build_report,
|
||||
merge_printability,
|
||||
min_feature_width,
|
||||
parse_svg_artwork,
|
||||
polygon_area,
|
||||
resolve_printability,
|
||||
round_value,
|
||||
validate_printability,
|
||||
write_csv,
|
||||
write_report,
|
||||
)
|
||||
|
||||
SETTINGS = PrintabilitySettings(
|
||||
printer_dpi=600.0,
|
||||
min_dots=2.0,
|
||||
min_feature_mm=0.10,
|
||||
min_edge_mm=0.05,
|
||||
max_sliver_ratio=4.0,
|
||||
min_patch_area_mm2=1e-4,
|
||||
)
|
||||
|
||||
# Fields removed when the report was simplified from v1 to v2.
|
||||
V1_ONLY_KEYS = (
|
||||
"raster_px",
|
||||
"mm_per_px_x",
|
||||
"mm_per_px_y",
|
||||
"effective_raster_dpi_x",
|
||||
"effective_raster_dpi_y",
|
||||
"declared_page_mm",
|
||||
"stale_artifact",
|
||||
"page_mismatch_reason",
|
||||
"groups_present",
|
||||
"patch_count",
|
||||
"material_patch_count",
|
||||
"debris_patch_count",
|
||||
"sub_dot_pitch_count",
|
||||
"mesh_polygon_count",
|
||||
"edges_mm",
|
||||
"patch_area_mm2",
|
||||
"sliver_ratio",
|
||||
"degenerate_polygon_count",
|
||||
"nonconvex_polygon_count",
|
||||
"distinct_fill_count",
|
||||
"min_fill_rgb_distance",
|
||||
"cross_arm_thickness_mm",
|
||||
"patch_area_fraction_of_mesh",
|
||||
"checks_basis",
|
||||
"nominal_cell_mm",
|
||||
"reasons",
|
||||
"worst_offenders",
|
||||
"debris_examples",
|
||||
)
|
||||
|
||||
|
||||
def _svg(
|
||||
path: Path,
|
||||
body: str,
|
||||
*,
|
||||
width: str = "10mm",
|
||||
height: str = "10mm",
|
||||
view: str = "0 0 100 100",
|
||||
) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
|
||||
f'height="{height}" viewBox="{view}" preserveAspectRatio="none">\n'
|
||||
f' <g id="artwork" transform="translate(0 100) scale(1 -1)">\n{body}\n'
|
||||
" </g>\n</svg>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _polygon(element_id: str, points: str, fill: str = "#ff0000") -> str:
|
||||
return (
|
||||
' <g id="patches">\n'
|
||||
f' <polygon id="{element_id}" points="{points}" fill="{fill}" '
|
||||
'stroke="none"/>\n'
|
||||
" </g>"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------- polygon geometry
|
||||
|
||||
|
||||
def test_polygon_area_and_convex_width() -> None:
|
||||
square = [(0.0, 0.0), (4.0, 0.0), (4.0, 2.0), (0.0, 2.0)]
|
||||
assert polygon_area(square) == pytest.approx(8.0)
|
||||
assert min_feature_width(square) == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_min_feature_width_of_rotated_square() -> None:
|
||||
side = 3.0
|
||||
root = side / 2.0**0.5
|
||||
diamond = [(root, 0.0), (2 * root, root), (root, 2 * root), (0.0, root)]
|
||||
assert min_feature_width(diamond) == pytest.approx(side)
|
||||
|
||||
|
||||
def test_min_feature_width_of_triangle_is_shortest_altitude() -> None:
|
||||
triangle = [(0.0, 0.0), (10.0, 0.0), (5.0, 1.0)]
|
||||
assert min_feature_width(triangle) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_round_value_preserves_small_non_zero() -> None:
|
||||
assert round_value(0.00000025) == pytest.approx(2.5e-07)
|
||||
assert round_value(0.00000025) != 0.0
|
||||
assert round_value(0.0304349) == 0.030435
|
||||
assert round_value(18.5) == 18.5
|
||||
# json.dumps would emit Infinity for these, so they become null.
|
||||
assert round_value(float("inf")) is None
|
||||
assert round_value(float("nan")) is None
|
||||
assert round_value(None) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------- SVG parsing
|
||||
|
||||
|
||||
def test_parse_reads_mm_page_and_viewbox(tmp_path: Path) -> None:
|
||||
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 10,0 10,10 0,10"))
|
||||
artwork = parse_svg_artwork(svg)
|
||||
assert (artwork.page_width_mm, artwork.page_height_mm) == (10.0, 10.0)
|
||||
assert (artwork.view_width_px, artwork.view_height_px) == (100.0, 100.0)
|
||||
assert len(artwork.group("patches")) == 1
|
||||
|
||||
|
||||
def test_parse_accepts_space_separated_points(tmp_path: Path) -> None:
|
||||
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0 0 10 0 10 10 0 10"))
|
||||
assert len(parse_svg_artwork(svg).group("patches")) == 1
|
||||
|
||||
|
||||
def test_parse_counts_rects_in_named_groups(tmp_path: Path) -> None:
|
||||
body = (
|
||||
' <g id="reference-cross">\n'
|
||||
' <rect id="cross-arm-0" x="0" y="0" width="20" height="2" '
|
||||
'fill="#ff0000"/>\n'
|
||||
" </g>"
|
||||
)
|
||||
artwork = parse_svg_artwork(_svg(tmp_path / "a.svg", body))
|
||||
assert len(artwork.group("reference-cross")) == 1
|
||||
|
||||
|
||||
def test_parse_rejects_non_millimetre_page(tmp_path: Path) -> None:
|
||||
svg = _svg(
|
||||
tmp_path / "a.svg", _polygon("patch-1", "0,0 10,0 10,10 0,10"), width="100px"
|
||||
)
|
||||
with pytest.raises(PrintabilityConfigError, match="millimetres"):
|
||||
parse_svg_artwork(svg)
|
||||
|
||||
|
||||
def test_parse_rejects_missing_viewbox(tmp_path: Path) -> None:
|
||||
path = tmp_path / "a.svg"
|
||||
path.write_text(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="10mm" height="10mm"/>',
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(PrintabilityConfigError, match="viewBox"):
|
||||
parse_svg_artwork(path)
|
||||
|
||||
|
||||
def test_parse_missing_file_raises() -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
parse_svg_artwork(Path("does-not-exist.svg"))
|
||||
|
||||
|
||||
# ------------------------------------------------------------- metrics
|
||||
|
||||
|
||||
def test_analyse_applies_mm_scale_not_pixels(tmp_path: Path) -> None:
|
||||
# 100 px page is 10 mm, so a 10 px square is 1 mm on a side.
|
||||
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 10,0 10,10 0,10"))
|
||||
result = analyse_map(parse_svg_artwork(svg), SETTINGS)
|
||||
assert result["mm_per_px"] == pytest.approx(0.1)
|
||||
assert result["min_width_mm"]["min"] == pytest.approx(1.0)
|
||||
assert result["patches"]["total"] == 1
|
||||
|
||||
|
||||
def test_analyse_reports_only_v2_keys(tmp_path: Path) -> None:
|
||||
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 50,0 50,50 0,50"))
|
||||
result = analyse_map(parse_svg_artwork(svg), SETTINGS)
|
||||
for key in V1_ONLY_KEYS:
|
||||
assert key not in result, key
|
||||
assert set(result) == {
|
||||
"page_mm",
|
||||
"mm_per_px",
|
||||
"uniform_scale",
|
||||
"raster_dpi",
|
||||
"nominal_cell",
|
||||
"patches",
|
||||
"min_width_mm",
|
||||
"min_edge_mm",
|
||||
"max_sliver_ratio",
|
||||
"coverage",
|
||||
"colours",
|
||||
"reference_cross",
|
||||
"page_mismatch",
|
||||
"notes",
|
||||
"checks",
|
||||
"verdict",
|
||||
"offenders",
|
||||
}
|
||||
|
||||
|
||||
def test_analyse_separates_debris_from_material(tmp_path: Path) -> None:
|
||||
body = "\n".join(
|
||||
[
|
||||
_polygon("patch-big", "0,0 20,0 20,20 0,20"),
|
||||
# 0.2 px square = 0.02 mm x 0.02 mm = 4e-4 mm^2, above 1e-4 mm^2.
|
||||
_polygon("patch-small", "50,50 50.2,50 50.2,50.2 50,50.2"),
|
||||
]
|
||||
)
|
||||
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
|
||||
# patch-big is 2 mm wide; patch-small is 0.02 mm, so it is material by area
|
||||
# but still narrower than one printer dot.
|
||||
assert result["patches"] == {
|
||||
"total": 2,
|
||||
"material": 2,
|
||||
"debris": 0,
|
||||
"below_dot": 1,
|
||||
"degenerate": 0,
|
||||
"nonconvex": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_analyse_flags_stale_page(tmp_path: Path) -> None:
|
||||
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 20,0 20,20 0,20"))
|
||||
result = analyse_map(
|
||||
parse_svg_artwork(svg), SETTINGS, declared_page_mm=(20.0, 20.0)
|
||||
)
|
||||
mismatch = result["page_mismatch"]
|
||||
assert mismatch is not None
|
||||
assert mismatch["artifact_mm"] == [10.0, 10.0]
|
||||
assert mismatch["declared_mm"] == [20.0, 20.0]
|
||||
assert "differs from design.yaml" in mismatch["reason"]
|
||||
|
||||
|
||||
def test_analyse_accepts_matching_page(tmp_path: Path) -> None:
|
||||
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 20,0 20,20 0,20"))
|
||||
result = analyse_map(
|
||||
parse_svg_artwork(svg), SETTINGS, declared_page_mm=(10.0, 10.0)
|
||||
)
|
||||
assert result["page_mismatch"] is None
|
||||
|
||||
|
||||
def test_analyse_reports_nonconvex_fallback(tmp_path: Path) -> None:
|
||||
# An arrowhead is non-convex; width falls back to the shortest edge.
|
||||
body = _polygon("patch-1", "0,0 30,10 0,20 8,10")
|
||||
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
|
||||
assert result["patches"]["nonconvex"] == 1
|
||||
assert "non-convex" in result["min_width_mm"]["method"]
|
||||
|
||||
|
||||
def test_analyse_reports_nominal_cell_dots(tmp_path: Path) -> None:
|
||||
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 20,0 20,20 0,20"))
|
||||
result = analyse_map(parse_svg_artwork(svg), SETTINGS, nominal_cell_mm=(1.0, 0.5))
|
||||
cell = result["nominal_cell"]
|
||||
assert cell is not None
|
||||
# 0.5 mm shortest side at 0.0423 mm per dot.
|
||||
assert cell["dots"] == pytest.approx(0.5 / SETTINGS.dot_pitch_mm, abs=0.01)
|
||||
|
||||
|
||||
def test_analyse_marks_non_uniform_scale_inconclusive(tmp_path: Path) -> None:
|
||||
svg = _svg(
|
||||
tmp_path / "a.svg",
|
||||
_polygon("patch-1", "0,0 20,0 20,20 0,20"),
|
||||
width="20mm",
|
||||
height="10mm",
|
||||
)
|
||||
result = analyse_map(parse_svg_artwork(svg), SETTINGS)
|
||||
assert result["uniform_scale"] is False
|
||||
assert result["verdict"] == "inconclusive"
|
||||
assert any("axis-dependent" in note for note in result["notes"])
|
||||
|
||||
|
||||
def test_analyse_fails_when_feature_below_requirement(tmp_path: Path) -> None:
|
||||
# 0.5 px square on a 10 mm / 100 px page = 0.05 mm wide, half the requirement.
|
||||
body = _polygon("patch-1", "0,0 0.5,0 0.5,0.5 0,0.5")
|
||||
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
|
||||
assert result["verdict"] == "fail"
|
||||
names = {check["name"]: check["passed"] for check in result["checks"]}
|
||||
assert names["min_feature_mm"] is False
|
||||
|
||||
|
||||
def test_analyse_passes_a_printable_square(tmp_path: Path) -> None:
|
||||
# 5 mm square on a 10 mm page: passes width, dots, edge and sliver checks.
|
||||
body = _polygon("patch-1", "0,0 50,0 50,50 0,50")
|
||||
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
|
||||
assert result["verdict"] == "pass"
|
||||
assert result["notes"] == []
|
||||
|
||||
|
||||
def test_analyse_reports_fill_separation(tmp_path: Path) -> None:
|
||||
body = "\n".join(
|
||||
[
|
||||
_polygon("patch-1", "0,0 50,0 50,50 0,50", "#000000"),
|
||||
_polygon("patch-2", "50,0 100,0 100,50 50,50", "#010101"),
|
||||
]
|
||||
)
|
||||
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
|
||||
assert result["colours"]["distinct"] == 2
|
||||
assert result["colours"]["min_rgb_distance"] == pytest.approx(3**0.5, abs=0.01)
|
||||
|
||||
|
||||
def test_analyse_reports_coverage(tmp_path: Path) -> None:
|
||||
body = "\n".join(
|
||||
[
|
||||
_polygon("patch-1", "0,0 50,0 50,50 0,50"),
|
||||
' <g id="mesh">\n'
|
||||
' <polygon id="mesh-1" points="0,0 100,0 100,100 0,100" '
|
||||
'fill="#ffffff"/>\n'
|
||||
" </g>",
|
||||
]
|
||||
)
|
||||
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
|
||||
# 5 mm square patch inside a 10 mm square mesh.
|
||||
assert result["coverage"]["patch_area_fraction"] == pytest.approx(0.25, abs=0.001)
|
||||
assert result["coverage"]["mesh_polygons"] == 1
|
||||
|
||||
|
||||
def test_reference_cross_printable_flag(tmp_path: Path) -> None:
|
||||
def cross(height_px: float) -> dict:
|
||||
body = (
|
||||
_polygon("patch-1", "0,0 50,0 50,50 0,50")
|
||||
+ "\n"
|
||||
+ ' <g id="reference-cross">\n'
|
||||
+ f' <rect id="cross-arm-0" x="0" y="0" width="20" '
|
||||
+ f'height="{height_px}" fill="#ff0000"/>\n'
|
||||
+ " </g>"
|
||||
)
|
||||
return analyse_map(
|
||||
parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS
|
||||
)["reference_cross"]
|
||||
|
||||
# 3 px on a 10 mm / 100 px page = 0.3 mm, thinner than 2 dots (0.0847 mm)
|
||||
# is false; use a genuinely thin arm.
|
||||
thin = cross(0.5)
|
||||
assert thin["thickness_mm"] == pytest.approx(0.05)
|
||||
assert thin["printable"] is False
|
||||
thick = cross(20)
|
||||
assert thick["thickness_mm"] == pytest.approx(2.0)
|
||||
assert thick["printable"] is True
|
||||
|
||||
|
||||
def test_reference_cross_absent_gives_null(tmp_path: Path) -> None:
|
||||
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 50,0 50,50 0,50"))
|
||||
cross = analyse_map(parse_svg_artwork(svg), SETTINGS)["reference_cross"]
|
||||
assert cross == {"thickness_mm": None, "printable": None}
|
||||
|
||||
|
||||
# ------------------------------------------------------ offender locators
|
||||
|
||||
|
||||
def test_offenders_carry_id_fill_and_centroid(tmp_path: Path) -> None:
|
||||
body = _polygon("patch-7", "10,20 30,20 30,40 10,40", "#123456")
|
||||
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
|
||||
offender = result["offenders"][0]
|
||||
assert offender["kind"] == "material"
|
||||
assert offender["id"] == "patch-7"
|
||||
assert offender["fill"] == "#123456"
|
||||
# Centroid of the square is (20, 30) in viewBox pixels.
|
||||
assert offender["centroid_px"] == [20.0, 30.0]
|
||||
assert offender["centroid_mm"] == [2.0, 3.0]
|
||||
|
||||
|
||||
def test_debris_offenders_are_labelled(tmp_path: Path) -> None:
|
||||
# 0.05 px square = 0.005 mm x 0.005 mm = 2.5e-5 mm^2, below min_patch_area.
|
||||
body = _polygon("patch-tiny", "0,0 0.05,0 0.05,0.05 0,0.05")
|
||||
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
|
||||
assert result["patches"]["debris"] == 1
|
||||
assert result["patches"]["material"] == 0
|
||||
kinds = {offender["kind"] for offender in result["offenders"]}
|
||||
assert kinds == {"debris"}
|
||||
|
||||
|
||||
def test_offender_centroid_lies_inside_its_polygon(tmp_path: Path) -> None:
|
||||
body = _polygon("patch-3", "10,10 90,20 70,80 20,60")
|
||||
artwork = parse_svg_artwork(_svg(tmp_path / "a.svg", body))
|
||||
offender = analyse_map(artwork, SETTINGS)["offenders"][0]
|
||||
shape = artwork.group("patches")[0]
|
||||
xs = [point[0] for point in shape.points]
|
||||
ys = [point[1] for point in shape.points]
|
||||
cx, cy = offender["centroid_px"]
|
||||
assert min(xs) <= cx <= max(xs)
|
||||
assert min(ys) <= cy <= max(ys)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- config
|
||||
|
||||
|
||||
def _revision(tmp_path: Path, printability: str = "") -> Path:
|
||||
revision = tmp_path / "sensors" / "finger" / "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" + printability,
|
||||
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 test_defaults_apply_without_block(tmp_path: Path) -> None:
|
||||
paths = RevisionPaths.from_revision_dir(_revision(tmp_path))
|
||||
settings = resolve_printability(paths)
|
||||
assert settings.printer_dpi == DEFAULT_PRINTABILITY["printer_dpi"]
|
||||
assert settings.dot_pitch_mm == pytest.approx(25.4 / 600.0)
|
||||
|
||||
|
||||
def test_design_block_overrides_defaults(tmp_path: Path) -> None:
|
||||
paths = RevisionPaths.from_revision_dir(
|
||||
_revision(tmp_path, "printability:\n printer_dpi: 1200\n")
|
||||
)
|
||||
assert resolve_printability(paths).printer_dpi == 1200.0
|
||||
|
||||
|
||||
def test_cli_override_beats_design_block(tmp_path: Path) -> None:
|
||||
paths = RevisionPaths.from_revision_dir(
|
||||
_revision(tmp_path, "printability:\n printer_dpi: 1200\n")
|
||||
)
|
||||
assert resolve_printability(paths, {"printer_dpi": 300}).printer_dpi == 300.0
|
||||
|
||||
|
||||
def test_unset_override_keeps_configured_value(tmp_path: Path) -> None:
|
||||
paths = RevisionPaths.from_revision_dir(
|
||||
_revision(tmp_path, "printability:\n printer_dpi: 1200\n")
|
||||
)
|
||||
assert resolve_printability(paths, {"printer_dpi": None}).printer_dpi == 1200.0
|
||||
|
||||
|
||||
def test_unknown_key_rejected(tmp_path: Path) -> None:
|
||||
paths = RevisionPaths.from_revision_dir(
|
||||
_revision(tmp_path, "printability:\n dpi: 600\n")
|
||||
)
|
||||
with pytest.raises(PrintabilityConfigError, match="unknown key"):
|
||||
merge_printability(paths)
|
||||
|
||||
|
||||
def test_invalid_values_rejected() -> None:
|
||||
errors = validate_printability(
|
||||
{**DEFAULT_PRINTABILITY, "printer_dpi": 0, "max_sliver_ratio": 0.5}
|
||||
)
|
||||
assert any("printer_dpi" in message for message in errors)
|
||||
assert any("max_sliver_ratio" in message for message in errors)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- report
|
||||
|
||||
|
||||
def test_checked_file_record_reports_name_and_relative_path(tmp_path: Path) -> None:
|
||||
from orisys_sensor_design.printability import checked_file_record
|
||||
|
||||
svg = _svg(tmp_path / "nested" / "patch_texture_produce.svg", "")
|
||||
record = checked_file_record(svg, tmp_path)
|
||||
assert record["name"] == "patch_texture_produce.svg"
|
||||
assert record["relative_path"] == "nested/patch_texture_produce.svg"
|
||||
assert len(record["sha256"]) == 64
|
||||
|
||||
|
||||
def test_build_report_is_v2_and_records_file(tmp_path: Path) -> None:
|
||||
revision = repo_root() / "sensors" / "finger-xynova" / "r01"
|
||||
paths = RevisionPaths.from_revision_dir(revision)
|
||||
svg = _svg(
|
||||
tmp_path / "patch_texture_produce.svg",
|
||||
_polygon("patch-1", "0,0 50,0 50,50 0,50"),
|
||||
width="18.5mm",
|
||||
height="16.4mm",
|
||||
view="0 0 3700 3280",
|
||||
)
|
||||
report = build_report(
|
||||
paths=paths,
|
||||
settings=SETTINGS,
|
||||
maps=[("produce", svg)],
|
||||
declared_page_mm=(18.5, 16.4),
|
||||
)
|
||||
assert report["schema"] == REPORT_SCHEMA
|
||||
assert set(report) == {
|
||||
"schema",
|
||||
"generated_at",
|
||||
"revision",
|
||||
"settings",
|
||||
"maps",
|
||||
"overall",
|
||||
}
|
||||
assert report["revision"] == "sensors/finger-xynova/r01"
|
||||
assert report["maps"][0]["file"]["name"] == "patch_texture_produce.svg"
|
||||
|
||||
|
||||
def test_build_report_detail_collects_material_patches(tmp_path: Path) -> None:
|
||||
revision = repo_root() / "sensors" / "finger-xynova" / "r01"
|
||||
paths = RevisionPaths.from_revision_dir(revision)
|
||||
body = "\n".join(
|
||||
[
|
||||
_polygon("patch-1", "0,0 50,0 50,50 0,50"),
|
||||
_polygon("patch-2", "0.1,0.1 0.2,0.1 0.2,0.2 0.1,0.2"),
|
||||
]
|
||||
)
|
||||
svg = _svg(
|
||||
tmp_path / "patch_texture_produce.svg",
|
||||
body,
|
||||
width="18.5mm",
|
||||
height="16.4mm",
|
||||
view="0 0 3700 3280",
|
||||
)
|
||||
detail: dict = {}
|
||||
report = build_report(
|
||||
paths=paths,
|
||||
settings=SETTINGS,
|
||||
maps=[("produce", svg)],
|
||||
declared_page_mm=(18.5, 16.4),
|
||||
detail=detail,
|
||||
)
|
||||
# The tiny polygon is debris, so only the material patch is collected.
|
||||
assert [row["id"] for row in detail["produce"]] == ["patch-1"]
|
||||
# ...and the JSON report itself stays short.
|
||||
assert len(report["maps"][0]["offenders"]) == 2
|
||||
|
||||
|
||||
def test_write_report_and_csv(tmp_path: Path) -> None:
|
||||
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 5,0 5,5 0,5"))
|
||||
entry = analyse_map(parse_svg_artwork(svg), SETTINGS)
|
||||
entry["uv_map"] = "produce"
|
||||
report = {"maps": [entry], "overall": entry["verdict"]}
|
||||
|
||||
out = write_report(report, tmp_path / "texture_report.json")
|
||||
assert json.loads(out.read_text(encoding="utf-8"))["maps"][0]["uv_map"] == "produce"
|
||||
|
||||
csv_path = write_csv(report, tmp_path / "offenders.csv")
|
||||
lines = csv_path.read_text(encoding="utf-8").strip().splitlines()
|
||||
assert lines[0].startswith("uv_map,kind,id,fill,centroid_px_x")
|
||||
assert len(lines) == 1 + len(entry["offenders"])
|
||||
|
||||
|
||||
def test_write_csv_all_rows(tmp_path: Path) -> None:
|
||||
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 5,0 5,5 0,5"))
|
||||
entry = analyse_map(parse_svg_artwork(svg), SETTINGS)
|
||||
entry["uv_map"] = "produce"
|
||||
report = {"maps": [entry], "overall": "pass"}
|
||||
rows = {
|
||||
"produce": [
|
||||
{
|
||||
"kind": "material",
|
||||
"id": f"patch-{index}",
|
||||
"fill": "#000000",
|
||||
"centroid_px": [1.0, 2.0],
|
||||
"centroid_mm": [0.1, 0.2],
|
||||
"min_width_mm": 0.5,
|
||||
"sliver_ratio": 1.0,
|
||||
"area_mm2": 0.25,
|
||||
}
|
||||
for index in range(3)
|
||||
]
|
||||
}
|
||||
csv_path = write_csv(report, tmp_path / "all.csv", rows=rows)
|
||||
lines = csv_path.read_text(encoding="utf-8").strip().splitlines()
|
||||
assert len(lines) == 4
|
||||
assert "patch-2" in lines[3]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ CLI
|
||||
|
||||
|
||||
def _inspect_namespace(revision_dir: Path, output_dir: Path, **overrides: object):
|
||||
import argparse
|
||||
|
||||
namespace = argparse.Namespace(
|
||||
revision_dir=revision_dir,
|
||||
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,
|
||||
)
|
||||
for key, value in overrides.items():
|
||||
setattr(namespace, key, value)
|
||||
return namespace
|
||||
|
||||
|
||||
def _xynova_revision() -> Path:
|
||||
return repo_root() / "sensors" / "finger-xynova" / "r01"
|
||||
|
||||
|
||||
def test_cmd_inspect_single_uv_map_succeeds(tmp_path: Path) -> None:
|
||||
output_dir = tmp_path / "textures"
|
||||
_svg(
|
||||
output_dir / "patch_texture_compute.svg",
|
||||
_polygon("patch-1", "0,0 50,0 50,50 0,50"),
|
||||
width="18.5mm",
|
||||
height="16.4mm",
|
||||
view="0 0 3700 3280",
|
||||
)
|
||||
revision = _xynova_revision()
|
||||
args = _inspect_namespace(revision, output_dir, uv_map="compute")
|
||||
assert cli.cmd_inspect(revision, args) == 0
|
||||
report = json.loads((output_dir / REPORT_FILENAME).read_text(encoding="utf-8"))
|
||||
assert [entry["uv_map"] for entry in report["maps"]] == ["compute"]
|
||||
|
||||
|
||||
def test_cmd_inspect_partial_artifact_set_exits_three(tmp_path: Path) -> None:
|
||||
"""Without --uv-map, a missing SVG for any configured map is reported."""
|
||||
output_dir = tmp_path / "textures"
|
||||
_svg(
|
||||
output_dir / "patch_texture_compute.svg",
|
||||
_polygon("patch-1", "0,0 50,0 50,50 0,50"),
|
||||
width="18.5mm",
|
||||
height="16.4mm",
|
||||
view="0 0 3700 3280",
|
||||
)
|
||||
revision = _xynova_revision()
|
||||
assert cli.cmd_inspect(revision, _inspect_namespace(revision, output_dir)) == 3
|
||||
|
||||
|
||||
def test_cmd_inspect_unknown_uv_map_is_config_error(tmp_path: Path) -> None:
|
||||
revision = _xynova_revision()
|
||||
args = _inspect_namespace(revision, tmp_path, uv_map="nope")
|
||||
assert cli.cmd_inspect(revision, args) == 2
|
||||
|
||||
|
||||
def test_cmd_inspect_missing_artifacts_exit_three(tmp_path: Path) -> None:
|
||||
revision = _xynova_revision()
|
||||
args = _inspect_namespace(revision, tmp_path / "empty")
|
||||
assert cli.cmd_inspect(revision, args) == 3
|
||||
|
||||
|
||||
def test_cmd_inspect_writes_report_and_csv_all(tmp_path: Path) -> None:
|
||||
output_dir = tmp_path / "textures"
|
||||
for uv_map, fill in (("produce", "#ff0000"), ("compute", "#00ff00")):
|
||||
_svg(
|
||||
output_dir / f"patch_texture_{uv_map}.svg",
|
||||
_polygon("patch-1", "0,0 50,0 50,50 0,50", fill),
|
||||
width="18.5mm",
|
||||
height="16.4mm",
|
||||
view="0 0 3700 3280",
|
||||
)
|
||||
revision = _xynova_revision()
|
||||
csv_all = tmp_path / "all.csv"
|
||||
args = _inspect_namespace(revision, output_dir, csv_all=csv_all)
|
||||
assert cli.cmd_inspect(revision, args) == 0
|
||||
|
||||
report = json.loads((output_dir / REPORT_FILENAME).read_text(encoding="utf-8"))
|
||||
assert [entry["uv_map"] for entry in report["maps"]] == ["produce", "compute"]
|
||||
assert report["maps"][0]["page_mismatch"] is None
|
||||
assert report["maps"][0]["reference_cross"]["printable"] is None
|
||||
|
||||
lines = csv_all.read_text(encoding="utf-8").strip().splitlines()
|
||||
# One row per material patch in each map.
|
||||
assert len(lines) == 3
|
||||
Reference in New Issue
Block a user