"""The calibration sequence document: round trip, refusals, derived summary. The format is a contract with another machine's bench, so most of these tests are about what happens when the file is *wrong* — a sequence half-applied is worse than one rejected. """ from __future__ import annotations import json import shutil from pathlib import Path import pytest from orisys_autocal.sequence_io import ( DOCUMENT_KIND, FORMAT_VERSION, SequenceFormatError, SequencePoint, _version_tuple, format_duration, format_span, from_document, load, save, summarise, to_document, ) #: Where scratch files go. Deliberately not ``tmp_dir``: this suite has to run #: in a sandboxed shell, and ``tmp_dir`` needs a writable process temp root that #: such an environment may not provide (the same reason ``conftest.make_window`` #: avoids it). _SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_sequence_io" @pytest.fixture def tmp_dir(): shutil.rmtree(_SCRATCH, ignore_errors=True) _SCRATCH.mkdir(parents=True, exist_ok=True) yield _SCRATCH shutil.rmtree(_SCRATCH, ignore_errors=True) PROFILE = { "mode": "flat", "pitch_mm": 2.0, "margin_mm": 1.0, "radius_mm": 0.0, "clear_height_mm": 5.0, "approach_speed_mm_s": 2.0, "press_speed_mm_s": 0.5, "press_depth_mm": 0.2, "dwell_s": 0.5, "settle_window_s": 0.5, "repeats": 3, "order": "serpentine", } def _points(count: int = 6) -> list[SequencePoint]: return [ SequencePoint( id=index + 1, target_mm={"x": 2.0 * index, "y": 0.0, "z": 12.4}, sensor_mm={"x": 2.0 * index, "y": 0.0, "z": 0.0}, clearance_mm=5.0, ) for index in range(count) ] def _document(**overrides) -> dict: payload = to_document( profile=PROFILE, points=_points(), name="sweep", units_per_mm=10.0, app_version="0.1.0", shared_version="0.1.0", shared_api="0.1", ) payload.update(overrides) return payload # ------------------------------------------------------------------ round trip def test_a_document_round_trips_through_a_file(tmp_dir): path = tmp_dir / "sweep.oryseq" save(_document(), path) back = load(path, current_profile=PROFILE) assert back.point_count == 6 assert back.name == "sweep" assert back.profile["pitch_mm"] == 2.0 assert back.units_per_mm == 10.0 assert not back.warnings # Both frames survive: the platform target and the sensor position. assert back.points[0].target_mm["z"] == pytest.approx(12.4) assert back.points[0].sensor_mm is not None def test_the_written_file_is_readable_json_with_the_house_style(tmp_dir): path = tmp_dir / "sweep.oryseq" save(_document(), path) text = path.read_text(encoding="utf-8") assert text.endswith("\n") assert "\n " in text, "the document is not indented" assert "\\u" not in text, "non-ASCII was escaped" payload = json.loads(text) assert payload["kind"] == DOCUMENT_KIND assert payload["format_version"] == FORMAT_VERSION def test_saving_leaves_no_temporary_file_behind(tmp_dir): path = tmp_dir / "sweep.oryseq" save(_document(), path) assert [entry.name for entry in tmp_dir.iterdir()] == ["sweep.oryseq"] def test_saving_over_an_existing_file_replaces_it(tmp_dir): path = tmp_dir / "sweep.oryseq" save(_document(), path) save(_document(name="second"), path) assert json.loads(path.read_text(encoding="utf-8"))["name"] == "second" # -------------------------------------------------------------------- refusals @pytest.mark.parametrize( ("case", "mutate", "expected"), [ ("wrong kind", lambda d: d.update(kind="something.else"), "not a sequence document"), ("missing version", lambda d: d.pop("format_version"), "format_version"), ("version not a string", lambda d: d.update(format_version=1.0), "format_version"), ("newer version", lambda d: d.update(format_version="2.0"), "newer AutoCal"), ("missing frame", lambda d: d.pop("frame"), "frame"), ("wrong unit", lambda d: d.update(frame={"length_unit": "um"}), "length_unit"), ("missing profile", lambda d: d.pop("profile"), "profile"), ("points not a list", lambda d: d.update(points={}), "must be a list"), ("point not an object", lambda d: d.update(points=["nope"]), "not an object"), ("point missing axis", lambda d: d.update(points=[{"target_mm": {"x": 1, "y": 2}}]), "missing 'z'"), ("point without target", lambda d: d.update(points=[{}]), "no target_mm"), ], ) def test_a_bad_document_is_refused_with_a_specific_message(case, mutate, expected): document = _document() mutate(document) with pytest.raises(SequenceFormatError) as excinfo: from_document(document, current_profile=PROFILE) assert expected in str(excinfo.value), case def test_a_non_json_file_is_refused_with_its_position(tmp_dir): path = tmp_dir / "broken.oryseq" path.write_text("this is not json", encoding="utf-8") with pytest.raises(SequenceFormatError) as excinfo: load(path, current_profile=PROFILE) assert "not valid JSON" in str(excinfo.value) assert "line 1" in str(excinfo.value) def test_a_missing_file_is_refused_not_crashed(tmp_dir): with pytest.raises(SequenceFormatError): load(tmp_dir / "absent.oryseq", current_profile=PROFILE) def test_a_json_array_is_refused(tmp_dir): path = tmp_dir / "array.oryseq" path.write_text("[1, 2, 3]", encoding="utf-8") with pytest.raises(SequenceFormatError) as excinfo: load(path, current_profile=PROFILE) assert "top level" in str(excinfo.value) # ------------------------------------------------------------------- summary def test_the_summary_reports_count_and_travel_span(): summary = summarise(_points(6), PROFILE) assert summary["count"] == 6 assert summary["min_mm"]["x"] == 0.0 assert summary["max_mm"]["x"] == 10.0 assert summary["span_mm"]["x"] == pytest.approx(10.0) # A constant coordinate has a nil span, and says so. assert summary["span_mm"]["z"] == pytest.approx(0.0) def test_the_summary_is_recomputed_and_wins_over_the_file(): """A hand-edited summary must not be believed.""" document = _document() document["summary"]["count"] = 999 document["summary"]["span_mm"]["x"] = 999.0 back = from_document(document, current_profile=PROFILE) assert back.summary["count"] == 6 assert back.summary["span_mm"]["x"] == pytest.approx(10.0) assert back.warnings and "disagrees" in back.warnings[0] def test_an_agreeing_summary_produces_no_warning(): back = from_document(_document(), current_profile=PROFILE) assert not any("disagrees" in warning for warning in back.warnings) def test_no_points_imports_the_profile_and_says_so(): document = _document() document.pop("points") back = from_document(document, current_profile=PROFILE) assert back.point_count == 0 assert any("no points" in warning for warning in back.warnings) def test_a_missing_profile_key_keeps_the_current_value(): document = _document() document["profile"].pop("pitch_mm") back = from_document(document, current_profile=PROFILE) assert back.profile["pitch_mm"] == 2.0 assert any("pitch_mm" in warning for warning in back.warnings) def test_a_disabled_point_is_counted_separately(): points = _points(4) points[1] = SequencePoint(id=2, target_mm=points[1].target_mm, enabled=False) summary = summarise(points, PROFILE) assert summary["count"] == 4 assert summary["enabled"] == 3 # ------------------------------------------------------------------- duration def test_the_duration_estimate_scales_with_repeats_and_points(): one = summarise(_points(1), {**PROFILE, "repeats": 1})["estimated_duration_s"] three = summarise(_points(1), {**PROFILE, "repeats": 3})["estimated_duration_s"] assert three > one six = summarise(_points(6), {**PROFILE, "repeats": 3})["estimated_duration_s"] assert six > three def test_no_points_means_no_estimate(): summary = summarise([], PROFILE) assert summary["estimated_duration_s"] == 0.0 assert format_duration(0.0) == "—" def test_the_duration_is_formatted_as_a_bench_timer(): assert format_duration(72.0) == "1:12" assert format_duration(3600.0) == "1:00:00" assert format_duration(None) == "—" assert format_duration(float("nan")) == "—" # ---------------------------------------------------------------------- spans def test_a_zero_span_prints_once_not_twice(): assert format_span(12.4, 12.4) == "12.400" assert format_span(0.0, 0.0) == "0.000" def test_a_real_span_shows_both_ends_and_the_length(): assert format_span(0.0, 10.0) == "0.000–10.000 (10.000)" def test_a_missing_span_is_a_dash(): assert format_span(None, None) == "—" # --------------------------------------------------------------------- version def test_versions_compare_as_tuples_not_strings(): """``"1.10" < "1.9"`` as strings would load a newer file as if supported.""" assert _version_tuple("1.10") > _version_tuple("1.9") assert _version_tuple("1.0") == (1, 0) assert _version_tuple("1") == (1,) # A same-major newer minor is still refused, but is *recognised* as newer. document = _document() document["format_version"] = "1.10" with pytest.raises(SequenceFormatError): from_document(document, current_profile=PROFILE)