migrate pyqt5 to pyside6
This commit is contained in:
@@ -66,6 +66,21 @@ def _scratch_dir() -> Path:
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_dir():
|
||||
"""A workspace-local scratch directory, cleared per test.
|
||||
|
||||
Deliberately not ``tmp_path``: this suite has to run inside a sandboxed
|
||||
shell, and ``tmp_path`` depends on a writable process temp root that such an
|
||||
environment may not provide.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
path = _scratch_dir()
|
||||
yield path
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_window(qt_app, monkeypatch):
|
||||
"""Build a window whose preference writes stay inside a scratch directory.
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
"""Binding policy for AutoCal.
|
||||
"""Binding policy for the Orisys apps.
|
||||
|
||||
AutoCal is **PySide6 only**, and this file is the guard for that:
|
||||
Both shipped apps are **PySide6 only**, and this file is AutoCal's guard for that:
|
||||
|
||||
* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has
|
||||
PySide6 and nothing else, so such an import is a hard start-up failure;
|
||||
* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``), which PySide6
|
||||
removed in favour of ``Qt.AlignmentFlag.AlignLeft``;
|
||||
* ``shared/`` — which serves both this app and the PyQt5-based studio — must
|
||||
import no Qt binding at all;
|
||||
* ``shared/`` may import PySide6 and nothing else: it is used by both apps, so it
|
||||
must not pull in a second binding, and the studio's own guard
|
||||
(``apps/studio/tests/test_binding_purity.py``) enforces the same rule from its
|
||||
side. (Until the studio moved to PySide6 this file forbade *any* binding in
|
||||
``shared/``; that rule existed for a two-binding workspace, which no longer
|
||||
exists. ``shared/theme/qss.py`` paints its chevron icons with PySide6 now, and
|
||||
under the old rule those imports were failing silently instead.)
|
||||
* the controller layer must stay importable from a headless process.
|
||||
|
||||
The reason the binding matters at all is licensing, not taste: PyQt5's free
|
||||
option is GPLv3-or-commercial, while PySide6 is LGPLv3, so an internal
|
||||
closed-source tool needs no licence purchase. See ``README.md``.
|
||||
option is GPLv3-or-commercial, while PySide6 is LGPLv3, so a closed-source tool
|
||||
needs no licence purchase. See ``README.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,13 +28,19 @@ from pathlib import Path
|
||||
APP_SRC = Path(__file__).resolve().parents[1] / "src"
|
||||
SHARED_SRC = Path(__file__).resolve().parents[3] / "shared" / "src"
|
||||
|
||||
#: Bindings the app must never import.
|
||||
#: Bindings no module may import.
|
||||
FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
|
||||
|
||||
#: The one binding allowed, and where it may be named.
|
||||
ALLOWED_BINDING = "PySide6"
|
||||
|
||||
#: Scoped enum holders we care about, with the value names actually used.
|
||||
#:
|
||||
#: Hand-maintained, and therefore incomplete by construction: the PySide6 migration
|
||||
#: found ``QPainter.RenderHint.Antialiasing`` and ``QDialogButtonBox.StandardButton.Ok`` slipping through a list
|
||||
#: like this one. The studio's guard asks PySide6 instead (``getattr`` + enum check),
|
||||
#: which is the better rule; this map is kept because AutoCal cannot import the binding
|
||||
#: in a headless test run.
|
||||
_SCOPED_ENUMS = {
|
||||
"Qt": (
|
||||
"AlignmentFlag",
|
||||
@@ -41,6 +52,12 @@ _SCOPED_ENUMS = {
|
||||
"ToolButtonStyle",
|
||||
"FocusPolicy",
|
||||
"ScrollBarPolicy",
|
||||
"CursorShape",
|
||||
"MouseButton",
|
||||
"Key",
|
||||
"PenStyle",
|
||||
"GlobalColor",
|
||||
"ItemFlag",
|
||||
),
|
||||
"QTableWidget": ("SelectionMode", "EditTrigger"),
|
||||
"QSizePolicy": ("Policy",),
|
||||
@@ -48,6 +65,11 @@ _SCOPED_ENUMS = {
|
||||
"QMessageBox": ("StandardButton", "Icon"),
|
||||
"QDialog": ("DialogCode",),
|
||||
"QAbstractItemView": ("SelectionMode", "EditTrigger"),
|
||||
"QDialogButtonBox": ("StandardButton",),
|
||||
"QPainter": ("RenderHint", "CompositionMode"),
|
||||
"QImage": ("Format",),
|
||||
"QSurfaceFormat": ("RenderableType", "OpenGLContextProfile", "SwapBehavior"),
|
||||
"QFrame": ("Shape",),
|
||||
}
|
||||
|
||||
|
||||
@@ -72,11 +94,13 @@ def _unscoped_enum_references(path: Path) -> list[str]:
|
||||
"""Attribute reads like ``Qt.AlignLeft`` — the spelling PySide6 rejects.
|
||||
|
||||
Parsed rather than pattern-matched for two reasons: a regex also hits prose
|
||||
in a comment or docstring (which is exactly where the scoped spelling gets
|
||||
*explained*), and ``QMessageBox.information(...)`` looks like an enumerator
|
||||
but is a static method. The parent map separates the two, and
|
||||
``Qt.AlignmentFlag.AlignLeft`` is an inner attribute on an allow-listed enum
|
||||
class rather than a plain name, so it is never a candidate.
|
||||
in a comment or docstring (which is exactly where the *unscoped* spelling gets
|
||||
explained, so a rewrite would destroy the explanation — this file quotes it
|
||||
three times, which is why the migration scripts skip it), and
|
||||
``QMessageBox.information(...)`` looks like an enumerator but is a static
|
||||
method. The parent map separates the two, and ``Qt.AlignmentFlag.AlignLeft`` is
|
||||
an inner attribute on an allow-listed enum class rather than a plain name, so it
|
||||
is never a candidate.
|
||||
"""
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
called = {
|
||||
@@ -152,15 +176,22 @@ def test_the_controller_interface_is_qt_free():
|
||||
assert not offenders, offenders
|
||||
|
||||
|
||||
def test_shared_imports_no_qt_binding():
|
||||
"""Shared code serves both products, so it must not pick a side."""
|
||||
def test_shared_imports_no_forbidden_binding():
|
||||
"""Shared code serves both products, so it must not pull in a second binding.
|
||||
|
||||
PySide6 is allowed and expected (``shared/theme/qss.py`` paints the chevron icons with
|
||||
it); what must never appear here is PyQt5/PyQt6/PySide2, which would either fail to
|
||||
import in a PySide6-only environment or — worse — work in one app's environment and not
|
||||
the other's. Before the studio moved to PySide6 this test forbade PySide6 itself, and
|
||||
the PyQt5 import in ``qss.py`` was failing silently as a result.
|
||||
"""
|
||||
offenders = []
|
||||
for path in _python_files(SHARED_SRC):
|
||||
qt_imports = _imported_packages(path) & {"PySide2", "PySide6"}
|
||||
qt_imports = _imported_packages(path) & FORBIDDEN_BINDINGS
|
||||
if qt_imports:
|
||||
offenders.append(f"{path.name}: {sorted(qt_imports)}")
|
||||
assert not offenders, (
|
||||
"shared code must not import PySide2/PySide6 (studio is PyQt5): "
|
||||
"shared code may import PySide6 only; these files import another binding: "
|
||||
f"{offenders}"
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import pytest
|
||||
|
||||
from conftest import pump
|
||||
|
||||
from orisys_autocal.sequence_io import SequenceFormatError, SequencePoint
|
||||
from orisys_autocal.ui.workspace import TABS
|
||||
|
||||
|
||||
@@ -187,13 +188,7 @@ def test_every_pending_control_names_the_phase_that_owns_it(make_window):
|
||||
"""A disabled control must say why, or it reads as a broken button."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
assert panel.pending_controls() == [
|
||||
"generate",
|
||||
"simulate",
|
||||
"dry_run",
|
||||
"save",
|
||||
"load",
|
||||
]
|
||||
assert panel.pending_controls() == ["generate", "simulate", "dry_run"]
|
||||
for name, phase in panel.pending_phases().items():
|
||||
button = panel._pending_buttons[name]
|
||||
assert not button.isEnabled(), f"{name} is usable but not implemented"
|
||||
@@ -201,6 +196,24 @@ def test_every_pending_control_names_the_phase_that_owns_it(make_window):
|
||||
assert button.text(), f"{name} has no label"
|
||||
|
||||
|
||||
def test_import_and_export_are_live_controls(make_window):
|
||||
"""A sequence is a document, and P8 says it is a versioned artifact."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
for name in ("export", "import"):
|
||||
button = panel._live_buttons[name]
|
||||
assert button.isEnabled(), f"{name} is not usable"
|
||||
assert button.text(), f"{name} has no label"
|
||||
assert button.toolTip()
|
||||
|
||||
|
||||
def test_the_point_list_has_no_empty_action_column(make_window):
|
||||
"""An empty action cell reads as a broken control."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
assert "actions" not in panel.column_keys()
|
||||
|
||||
|
||||
def test_the_sequence_tab_hands_out_no_runnable_steps(make_window):
|
||||
"""A runner must not be able to start from points nothing has validated."""
|
||||
window = _show(make_window())
|
||||
@@ -456,6 +469,110 @@ def test_the_status_bar_uses_its_own_theme_token(make_window):
|
||||
assert "QStatusBar" in window.statusBar().styleSheet()
|
||||
|
||||
|
||||
def test_the_sequence_summary_reports_count_and_span(make_window):
|
||||
"""The two numbers an operator reasons with, before anything runs."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.set_points(
|
||||
[
|
||||
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
|
||||
SequencePoint(id=2, target_mm={"x": 10.0, "y": 0.0, "z": 12.4}),
|
||||
]
|
||||
)
|
||||
text = panel.summary_label.text()
|
||||
assert "2" in text
|
||||
# A nil Z span prints once, and the real X span prints both ends.
|
||||
assert "0.000–10.000" in text
|
||||
assert "12.400" in text
|
||||
|
||||
|
||||
def test_the_sequence_summary_updates_with_the_profile(make_window):
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.set_points([SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 1.0})])
|
||||
before = panel.summary_label.text()
|
||||
panel.field("repeats").setText("10")
|
||||
assert panel.summary_label.text() != before
|
||||
|
||||
|
||||
def test_an_empty_sequence_says_so_rather_than_showing_zeros(make_window):
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.set_points([])
|
||||
assert panel.summary_label.text()
|
||||
assert "0" not in panel.summary_label.text()[:2]
|
||||
|
||||
|
||||
def test_the_design_is_dirty_until_it_is_saved_or_loaded(make_window):
|
||||
"""Import replaces the design, and there is no autosave."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.mark_clean()
|
||||
assert not panel.dirty
|
||||
panel.field("pitch_mm").setText("9")
|
||||
assert panel.dirty, "a profile edit did not mark the design dirty"
|
||||
|
||||
|
||||
def test_export_then_import_restores_the_design(make_window, tmp_dir):
|
||||
"""The round trip through the real panel, not just the format module."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.set_name("bench sweep")
|
||||
panel.field("pitch_mm").setText("3.5")
|
||||
panel.set_points(
|
||||
[
|
||||
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
|
||||
SequencePoint(id=2, target_mm={"x": 3.5, "y": 0.0, "z": 12.4}),
|
||||
]
|
||||
)
|
||||
path = tmp_dir / "sweep.oryseq"
|
||||
window.export_sequence(str(path))
|
||||
assert path.is_file()
|
||||
assert not panel.dirty, "export did not clear the dirty flag"
|
||||
|
||||
# Change everything, then import it back.
|
||||
panel.set_name("")
|
||||
panel.field("pitch_mm").setText("1")
|
||||
panel.set_points([])
|
||||
document = window.import_sequence(str(path))
|
||||
|
||||
assert document.point_count == 2
|
||||
assert panel.name() == "bench sweep"
|
||||
assert panel.profile()["pitch_mm"] == 3.5
|
||||
assert panel.point_row_count() == 2
|
||||
assert not panel.dirty
|
||||
|
||||
|
||||
def test_importing_a_bad_file_reports_and_changes_nothing(make_window, tmp_dir):
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.field("pitch_mm").setText("4")
|
||||
path = tmp_dir / "bad.oryseq"
|
||||
path.write_text("not json at all", encoding="utf-8")
|
||||
with pytest.raises(SequenceFormatError) as excinfo:
|
||||
window.import_sequence(str(path))
|
||||
assert "not valid JSON" in str(excinfo.value)
|
||||
# The design is untouched: a refused import is not a partial import.
|
||||
assert panel.profile()["pitch_mm"] == 4
|
||||
|
||||
|
||||
def test_an_exported_sequence_names_the_sensor_it_was_designed_for(make_window, tmp_dir):
|
||||
import json
|
||||
|
||||
window = _show(make_window())
|
||||
window.prefs["sensor_setup"] = {
|
||||
"sensor_type": "finger",
|
||||
"geometry_id": "finger-1.0.0",
|
||||
"probe_tip_set": True,
|
||||
}
|
||||
path = tmp_dir / "named.oryseq"
|
||||
window.export_sequence(str(path))
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert document["device"]["sensor_type"] == "finger"
|
||||
assert document["device"]["geometry_id"] == "finger-1.0.0"
|
||||
assert document["versions"]["app"]
|
||||
|
||||
|
||||
def test_language_switch_relabels_the_new_shell(make_window):
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user