Files
2026-09-27 19:20:43 +08:00

283 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)