f81418c542
沿现有笔画向空白处增长,保留最小线宽和笔画间隙。41 项测试通过,双 UV 矢量图无重叠。
192 lines
7.8 KiB
Python
192 lines
7.8 KiB
Python
"""Run with python -m pytest tests/test_stroke_pattern.py (no Blender needed).
|
|
|
|
The stroke generator runs under Blender and this module does not, so the two copies of the
|
|
tuning names and defaults cannot be shared by import. These tests pin them against each other by
|
|
parsing the script, which is the only way drift can be caught rather than silently changing what
|
|
a run produces.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from orisys_sensor_design import stroke_pattern
|
|
from orisys_sensor_design.manifest import RevisionPaths, repo_root
|
|
|
|
SCRIPT = repo_root() / "blender" / "scripts" / "random_stroke_pattern.py"
|
|
|
|
|
|
def script_constant(name: str):
|
|
"""Read a module-level literal out of the Blender script without importing it.
|
|
|
|
The script imports `bpy` at module scope, so it cannot be imported here. Parsing keeps this
|
|
a real check on the file rather than on a copy of it.
|
|
"""
|
|
tree = ast.parse(SCRIPT.read_text(encoding="utf-8"))
|
|
for node in tree.body:
|
|
if isinstance(node, ast.Assign):
|
|
targets = [t for t in node.targets if isinstance(t, ast.Name)]
|
|
if any(t.id == name for t in targets):
|
|
return ast.literal_eval(node.value)
|
|
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
if node.target.id == name:
|
|
return ast.literal_eval(node.value)
|
|
raise AssertionError(f"{name} is not a module-level literal in {SCRIPT.name}")
|
|
|
|
|
|
# ---------------------------------------------------------------- drift against the generator
|
|
|
|
|
|
def test_defaults_match_the_generator() -> None:
|
|
"""The one test that matters: the host and the script must agree on every value."""
|
|
assert script_constant("DEFAULT_PARAMS") == stroke_pattern.STROKE_DEFAULTS
|
|
|
|
|
|
def test_key_set_matches_the_generator() -> None:
|
|
assert set(script_constant("DEFAULT_PARAMS")) == set(stroke_pattern.STROKE_KEYS)
|
|
|
|
|
|
def test_mode_matches_the_generator() -> None:
|
|
assert script_constant("MODE") == stroke_pattern.STROKE_MODE
|
|
|
|
|
|
def test_declared_defaults_pass_our_own_validation() -> None:
|
|
"""A default the host would reject would make an untouched revision fail validation."""
|
|
assert stroke_pattern.validate_stroke_block(stroke_pattern.STROKE_DEFAULTS) == []
|
|
|
|
|
|
def test_the_script_no_longer_offers_other_modes() -> None:
|
|
"""Pins the simplification: reviving a second mode must be deliberate, not drift."""
|
|
assert script_constant("MODE") == "camera_compensated_width"
|
|
source = SCRIPT.read_text(encoding="utf-8")
|
|
for removed in ("PHYSICAL_WIDTH_MM", "camera_scaled_size", "--modes"):
|
|
assert removed not in source, f"{removed} should be gone after the mode reduction"
|
|
|
|
|
|
def test_the_host_cannot_import_the_generator() -> None:
|
|
"""Why the two copies exist: the script needs bpy, so the host may never import it."""
|
|
assert "import bpy" in SCRIPT.read_text(encoding="utf-8")
|
|
|
|
|
|
# ------------------------------------------------------------------------------ validation
|
|
|
|
|
|
def test_absent_block_is_valid() -> None:
|
|
assert stroke_pattern.validate_stroke_block(None) == []
|
|
|
|
|
|
def test_block_must_be_a_mapping() -> None:
|
|
errors = stroke_pattern.validate_stroke_block(["seed"])
|
|
assert errors and "must be a mapping" in errors[0]
|
|
|
|
|
|
def test_unknown_key_is_rejected_by_name() -> None:
|
|
errors = stroke_pattern.validate_stroke_block({"width_fracton": 0.5})
|
|
assert any("width_fracton" in error for error in errors)
|
|
assert any("allowed" in error for error in errors)
|
|
|
|
|
|
def test_non_numeric_is_rejected() -> None:
|
|
errors = stroke_pattern.validate_stroke_block({"seed": "seven"})
|
|
assert any("must be a number" in error for error in errors)
|
|
|
|
|
|
def test_boolean_is_not_a_number() -> None:
|
|
"""`off`/`on` parse to booleans in YAML 1.1, which is a trap worth naming."""
|
|
errors = stroke_pattern.validate_stroke_block({"length_ratio": True})
|
|
assert any("must be a number" in error for error in errors)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("key", "value", "expected"),
|
|
[
|
|
("min_cell_pixels", 0, "must be >= 1"),
|
|
("jitter_fraction", 1.5, "within [0, 1]"),
|
|
("jitter_fraction", -0.1, "within [0, 1]"),
|
|
("angle_span_deg", -1.0, "must be >= 0"),
|
|
("width_fraction", 0.0, "must be > 0"),
|
|
("width_min_mm", -0.1, "must be > 0"),
|
|
("length_ratio", 0.0, "must be > 0"),
|
|
("coverage_cap", 0.0, "within (0, 1]"),
|
|
("coverage_cap", 1.5, "within (0, 1]"),
|
|
("neighbourhood_mm", 0.0, "must be > 0"),
|
|
("clearance_mm", -0.01, "must be >= 0"),
|
|
("candidate_count", 0, "must be >= 1"),
|
|
("target_coverage", -0.01, "within [0, 1)"),
|
|
("target_coverage", 1.0, "within [0, 1)"),
|
|
],
|
|
)
|
|
def test_range_rules(key: str, value, expected: str) -> None:
|
|
errors = stroke_pattern.validate_stroke_block({key: value})
|
|
assert any(expected in error for error in errors), errors
|
|
|
|
|
|
def test_seed_must_be_an_integer() -> None:
|
|
errors = stroke_pattern.validate_stroke_block({"seed": 7.5})
|
|
assert any("integer" in error for error in errors)
|
|
|
|
|
|
def test_inverted_width_clamp_is_rejected_and_explains_itself() -> None:
|
|
"""min > max would make every mark take the minimum, silently."""
|
|
errors = stroke_pattern.validate_stroke_block(
|
|
{"width_min_mm": 0.4, "width_max_mm": 0.2}
|
|
)
|
|
assert any("clamp inverts" in error for error in errors)
|
|
|
|
|
|
def test_width_bounds_are_compared_after_merging_with_defaults() -> None:
|
|
"""Raising only the minimum must still be caught against the default maximum."""
|
|
errors = stroke_pattern.validate_stroke_block({"width_min_mm": 0.5})
|
|
assert any("clamp inverts" in error for error in errors)
|
|
|
|
|
|
# ----------------------------------------------------------------------------------- merge
|
|
|
|
|
|
def test_empty_block_yields_the_defaults() -> None:
|
|
assert stroke_pattern.resolve_stroke_settings(None) == stroke_pattern.STROKE_DEFAULTS
|
|
assert stroke_pattern.resolve_stroke_settings({}) == stroke_pattern.STROKE_DEFAULTS
|
|
|
|
|
|
def test_partial_block_keeps_its_siblings() -> None:
|
|
resolved = stroke_pattern.resolve_stroke_settings({"length_ratio": 4.0})
|
|
assert resolved["length_ratio"] == 4.0
|
|
assert resolved["width_fraction"] == stroke_pattern.STROKE_DEFAULTS["width_fraction"]
|
|
assert set(resolved) == set(stroke_pattern.STROKE_DEFAULTS)
|
|
|
|
|
|
def test_invalid_block_raises_with_every_problem() -> None:
|
|
with pytest.raises(stroke_pattern.StrokePatternConfigError) as caught:
|
|
stroke_pattern.resolve_stroke_settings({"width_fraction": -1.0, "bogus": 1})
|
|
message = str(caught.value)
|
|
assert "width_fraction" in message and "bogus" in message
|
|
|
|
|
|
def test_written_params_round_trip(tmp_path: Path) -> None:
|
|
settings = stroke_pattern.resolve_stroke_settings({"angle_span_deg": 90.0})
|
|
path = stroke_pattern.write_stroke_params(settings, tmp_path / "out")
|
|
assert path.name == stroke_pattern.STROKE_PARAMS_FILENAME
|
|
assert json.loads(path.read_text(encoding="utf-8")) == settings
|
|
|
|
|
|
# ------------------------------------------------------------------------------ revisions
|
|
|
|
|
|
@pytest.mark.parametrize("sensor", ["finger", "finger-xynova"])
|
|
def test_shipped_revisions_declare_the_expected_block(sensor: str) -> None:
|
|
"""The YAML records both legacy and Xynova manufacturing settings."""
|
|
paths = RevisionPaths.from_revision_dir(repo_root() / "sensors" / sensor / "r01")
|
|
design = yaml.safe_load(paths.design_yaml.read_text(encoding="utf-8"))
|
|
block = design.get("stroke_pattern")
|
|
assert block is not None, f"{sensor}/r01 has no stroke_pattern block"
|
|
resolved = stroke_pattern.resolve_stroke_settings(block)
|
|
expected = dict(stroke_pattern.STROKE_DEFAULTS)
|
|
if sensor == "finger-xynova":
|
|
expected.update(width_min_mm=0.20, clearance_mm=0.03, candidate_count=12,
|
|
target_coverage=0.50)
|
|
assert resolved == expected
|