"""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"), ], ) 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) assert resolved == expected