06e06126ca
选点、对准、加载与录像分散在两个面板,状态靠隐藏相位判断。现改由不依赖 Qt 的采集控制器驱动,并加入录像选项、汇总平铺副本、保持到时自动结束、会话结束回到空闲,以及无设备时的模拟采集。已运行 Autocal 非 Qt 测试并通过范围内门禁;界面测试待 Windows 复核,未做实机验收。
737 lines
30 KiB
Python
737 lines
30 KiB
Python
"""The capture page end to end: one queue, one primary button, one message line.
|
|
|
|
These are interface tests in the literal sense: they click the widgets an operator
|
|
clicks, in the order the controller offers them, and check the files the session
|
|
wrote. The platform move is stubbed at the window's move planner (the real move is
|
|
hardware-adjacent and already covered elsewhere); everything else runs as shipped.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import json
|
|
import re
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from orisys_app_shared.i18n import t
|
|
from orisys_autocal.sensor.uv_flat import FlatSurface
|
|
|
|
POINT = (4.872, 5.444, 16.176)
|
|
NORMAL = (0.595, 0.673, 0.439)
|
|
|
|
#: The only direct Campaign calls the frozen contract keeps in ``ui/``: the
|
|
#: recorder's lifecycle is not a workflow decision and not rendered data.
|
|
_ALLOWED_CAMPAIGN_MEMBERS = frozenset({"attach_capture", "flush_close"})
|
|
|
|
|
|
def _campaign_accesses(source: str) -> list[str]:
|
|
"""Every ``campaign.<member>`` access in one module's source, in order."""
|
|
return [match.group(1) for match in re.finditer(r"campaign\.(\w+)", source)]
|
|
|
|
|
|
def _campaign_offenders(directory) -> list[str]:
|
|
"""UI modules reaching past the controller for campaign state or actions."""
|
|
offenders = []
|
|
for path in sorted(directory.rglob("*.py")):
|
|
for member in _campaign_accesses(path.read_text(encoding="utf-8")):
|
|
if member not in _ALLOWED_CAMPAIGN_MEMBERS:
|
|
offenders.append(f"{path.name}: campaign.{member}")
|
|
return offenders
|
|
|
|
|
|
def _flat() -> FlatSurface:
|
|
"""A stand-in rasterised surface: every pixel is the same measured point."""
|
|
size = 8
|
|
positions = np.tile(np.asarray(POINT, dtype=np.float64), (size, size, 1))
|
|
normals = np.tile(np.asarray(NORMAL, dtype=np.float64), (size, size, 1))
|
|
return FlatSurface(
|
|
size_hw=(size, size),
|
|
image_rgb=np.zeros((size, size, 3), dtype=np.uint8),
|
|
positions_mm=positions,
|
|
normals=normals,
|
|
face_index=np.zeros((size, size), dtype=np.int32),
|
|
uv_min=(0.0, 0.0),
|
|
uv_max=(1.0, 1.0),
|
|
)
|
|
|
|
|
|
def _pump(app, seconds: float = 0.3) -> None:
|
|
deadline = time.monotonic() + seconds
|
|
while time.monotonic() < deadline:
|
|
app.processEvents()
|
|
time.sleep(0.005)
|
|
|
|
|
|
def _page(
|
|
window,
|
|
qt_app,
|
|
tmp_path,
|
|
*,
|
|
max_force: str = "20",
|
|
align: bool = True,
|
|
flat: bool = True,
|
|
):
|
|
"""A capture page whose readiness passes in simulation mode.
|
|
|
|
``align=True`` (the default) makes the page behave as it does with a platform
|
|
attached: the point must be aligned before a session may start, so the tests
|
|
that are about the align step keep covering it. ``align=False`` is the page as
|
|
it is with nothing connected, and ``flat=False`` leaves out the calibration
|
|
bundle too.
|
|
"""
|
|
window.resize(1440, 900)
|
|
window.show()
|
|
window.workspace.set_current_key("sensor")
|
|
qt_app.processEvents()
|
|
page = window.calibration_panel
|
|
page.settings.simulate.setChecked(True)
|
|
page.settings.sensor_sn.setText("SN1")
|
|
page.settings.max_force.setText(max_force)
|
|
page.settings.output_path.setText(str(tmp_path))
|
|
if align:
|
|
# The page treats a simulation as needing no move; these tests are about
|
|
# the step that does, so they ask for the platform-attached behaviour.
|
|
page._simulating = lambda: False # noqa: SLF001 - the seam under test
|
|
if flat:
|
|
page.set_flat(_flat(), "bundle")
|
|
# A hold of a few hundredths of a second keeps the test fast; the session
|
|
# contract is the same.
|
|
page.hold.setRange(0.05, 30)
|
|
page.hold.setValue(0.05)
|
|
page.levels.setText("5")
|
|
page.repeats.setValue(1)
|
|
return page
|
|
|
|
|
|
def _started_page(window, qt_app, tmp_path, *, sites=("P1", "P2")):
|
|
"""Two points added through the page, first aligned, session started."""
|
|
_stub_moves(window, succeeds=True)
|
|
page = _page(window, qt_app, tmp_path)
|
|
for index, site_id in enumerate(sites):
|
|
_add_point(page, site_id, index, index, qt_app)
|
|
_click_primary(page, qt_app) # align the first point
|
|
assert page.controller.primary_action().key == "start_session"
|
|
_click_primary(page, qt_app) # start the session
|
|
assert page.controller.poll()["phase"] == "load"
|
|
return page
|
|
|
|
|
|
def _stub_moves(window, *, succeeds: bool = True) -> list[dict]:
|
|
"""Replace the window's move planner: record the plan, report the outcome."""
|
|
plans: list[dict] = []
|
|
|
|
def fake_start(plan, title, *, report=None):
|
|
plans.append({"title": title, "roles": [step.role for step in plan.steps]})
|
|
if succeeds:
|
|
window.calibration_panel.alignment_succeeded()
|
|
else:
|
|
window.calibration_panel.alignment_failed("refused by the test")
|
|
|
|
window._start_move = fake_start # noqa: SLF001 - the seam under test is the page
|
|
return plans
|
|
|
|
|
|
def _click_primary(page, qt_app, timeout: float = 5.0) -> str:
|
|
"""Press the primary button once, after filling whatever it needs first."""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
qt_app.processEvents()
|
|
action = page.controller.primary_action()
|
|
if action.enabled and not (
|
|
action.key == "confirm_hold" and page.controller.poll()["remaining_s"] > 0
|
|
):
|
|
# The page redraws on its own timer; read the button the operator would
|
|
# click, not just the controller's answer, or a fast step races the redraw.
|
|
page._refresh() # noqa: SLF001 - the widget state is what is under test
|
|
if not page.primary_button.isEnabled():
|
|
_pump(qt_app, 0.05)
|
|
continue
|
|
if action.key == "confirm_target":
|
|
target = page.controller.model().progress.get("target_n") or 0.0
|
|
page.reference.setText(f"{float(target):g}")
|
|
page.primary_button.click()
|
|
_pump(qt_app, 0.1)
|
|
return action.key
|
|
_pump(qt_app, 0.05)
|
|
raise AssertionError(
|
|
f"the primary action never became available: "
|
|
f"{page.controller.primary_action()} / {page.message.text()}"
|
|
)
|
|
|
|
|
|
def _add_point(page, site_id: str, u: int, v: int, qt_app) -> None:
|
|
page.u_spin.setValue(u)
|
|
page.v_spin.setValue(v)
|
|
page.site_id.setText(site_id)
|
|
page.add_point_button.click()
|
|
_pump(qt_app, 0.05)
|
|
|
|
|
|
def test_two_point_capture_walkthrough_in_simulation(make_window, qt_app, tmp_path):
|
|
"""Align, start, load every step, hold, liftoff, align, and finish - by clicking."""
|
|
window = make_window(move_confirm=False)
|
|
plans = _stub_moves(window, succeeds=True)
|
|
page = _page(window, qt_app, tmp_path)
|
|
_add_point(page, "P1", 0, 0, qt_app)
|
|
_add_point(page, "P2", 1, 1, qt_app)
|
|
assert [row.site_id for row in page.controller.model().sites] == ["P1", "P2"]
|
|
|
|
# The first point must be aligned before the session may start.
|
|
assert page.controller.primary_action().key == "align_point"
|
|
assert t("cap.action.align") == page.primary_button.text()
|
|
_click_primary(page, qt_app)
|
|
# The press axis is held because its relation to a contact height is not
|
|
# calibrated on the test bench; the four movable roles still travel together.
|
|
assert plans and plans[-1]["roles"] == ["yaw", "pitch", "x", "y"]
|
|
assert page.controller.primary_action().key == "start_session"
|
|
assert t("cap.action.start") == page.primary_button.text()
|
|
|
|
_click_primary(page, qt_app) # start the session
|
|
state = page.controller.poll()
|
|
assert state["phase"] == "load"
|
|
# Starting a capture does not switch the workspace page.
|
|
assert window.workspace.current_key() == "sensor"
|
|
# The recording options are locked once the session exists.
|
|
assert not page.settings.video_check.isEnabled()
|
|
assert not page.settings.locked_label.isHidden()
|
|
assert page.settings.locked_label.text() == t("cap.recording.locked")
|
|
|
|
seen: list[str] = []
|
|
for _ in range(60):
|
|
action = page.controller.primary_action()
|
|
seen.append(action.key)
|
|
if action.key == "finish_session":
|
|
_click_primary(page, qt_app)
|
|
break
|
|
_click_primary(page, qt_app)
|
|
else: # pragma: no cover - a stuck walkthrough is the failure to report
|
|
raise AssertionError(f"the session never finished: {seen[-6:]}")
|
|
# The hold ends by itself at its planned time, so the button may or may not be
|
|
# caught showing "confirm hold" between two ticks; the order of the steps is
|
|
# what matters.
|
|
assert seen[0] == "confirm_target"
|
|
assert seen.count("confirm_target") >= 3
|
|
# Two points, so the second one needs its own alignment between them.
|
|
assert "align_point" in seen
|
|
assert "confirm_liftoff" in seen
|
|
|
|
directory = page._session_directory() # noqa: SLF001 - the recorded folder under test
|
|
assert directory is not None
|
|
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
|
|
assert manifest["status"] == "complete"
|
|
assert [site["site_id"] for site in manifest["sites"]] == ["P1", "P2"]
|
|
assert [trial["status"] for trial in manifest["trials"]] == ["complete", "complete"]
|
|
assert (directory / "stable_means.csv").is_file()
|
|
# A simulated run writes no video; that is the recorded state, not a failure.
|
|
assert page.controller.poll()["capture_state"] == "not_recorded"
|
|
# The end of the session publishes the candidate summary (P1's flat copy).
|
|
deadline = time.monotonic() + 20.0
|
|
while not (directory / "summary-latest.json").is_file() and time.monotonic() < deadline:
|
|
_pump(qt_app, 0.05)
|
|
assert (directory / "summary-latest.json").is_file()
|
|
window.close()
|
|
|
|
|
|
def test_a_simulation_runs_with_nothing_connected(make_window, qt_app, tmp_path):
|
|
"""No bundle, no platform, no camera: type two points, start, finish, go again."""
|
|
window = make_window(move_confirm=False)
|
|
plans = _stub_moves(window, succeeds=True)
|
|
page = _page(window, qt_app, tmp_path, align=False, flat=False)
|
|
_add_point(page, "P1", 0, 0, qt_app)
|
|
_add_point(page, "P2", 3, 4, qt_app)
|
|
assert [row.site_id for row in page.controller.model().sites] == ["P1", "P2"]
|
|
|
|
# Every ready check passes without a bundle, a bus or an input frame.
|
|
assert all(check.ok for check in page.controller.readiness()), [
|
|
check for check in page.controller.readiness() if not check.ok
|
|
]
|
|
# No align step stands between the queue and the session.
|
|
assert page.controller.primary_action().key == "start_session"
|
|
assert t("cap.action.start") == page.primary_button.text()
|
|
|
|
_click_primary(page, qt_app) # start the session
|
|
assert page.controller.poll()["phase"] == "load"
|
|
|
|
seen: list[str] = []
|
|
for _ in range(60):
|
|
action = page.controller.primary_action()
|
|
seen.append(action.key)
|
|
_click_primary(page, qt_app)
|
|
if action.key == "finish_session":
|
|
break
|
|
else: # pragma: no cover - a stuck walkthrough is the failure to report
|
|
raise AssertionError(f"the session never finished: {seen[-6:]}")
|
|
assert "align_point" not in seen
|
|
assert "confirm_liftoff" in seen
|
|
assert plans == [] # nothing asked the window to move an axis
|
|
|
|
directory = page._session_directory() # noqa: SLF001
|
|
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
|
|
assert manifest["simulate"] is True
|
|
assert manifest["status"] == "complete"
|
|
assert {site["pose"]["normal_aligned_to"] for site in manifest["sites"]} == {
|
|
"simulated"
|
|
}
|
|
# Closing out leaves the page ready to run the same queue again.
|
|
assert page.controller.poll()["phase"] == "idle"
|
|
assert page.controller.primary_action().key == "start_session"
|
|
window.close()
|
|
|
|
|
|
def test_a_refused_alignment_keeps_start_disabled(make_window, qt_app, tmp_path):
|
|
"""A move the operator did not confirm must never unlock "start capture"."""
|
|
window = make_window(move_confirm=False)
|
|
_stub_moves(window, succeeds=False)
|
|
page = _page(window, qt_app, tmp_path)
|
|
_add_point(page, "P1", 0, 0, qt_app)
|
|
|
|
_click_primary(page, qt_app) # align: the window refuses the move
|
|
assert page.controller.primary_action().key == "align_point"
|
|
assert page.controller.primary_action().key != "start_session"
|
|
assert t("cap.action.start") != page.primary_button.text()
|
|
assert t("cap.message.align_failed", detail="refused by the test") in page.message.text()
|
|
|
|
# A retry whose move is confirmed is what unlocks the session.
|
|
_stub_moves(window, succeeds=True)
|
|
_click_primary(page, qt_app)
|
|
assert page.controller.primary_action().key == "start_session"
|
|
window.close()
|
|
|
|
|
|
def test_the_capture_page_never_reaches_past_the_controller():
|
|
"""P4 exit condition: ``ui/`` renders controller output, not campaign state.
|
|
|
|
The guard is a member-access scan rather than a fixed string list: any new
|
|
``campaign.<member>`` in a UI module is a finding unless it is one of the two
|
|
recorder-lifecycle methods the frozen contract keeps (interfaces 1.11). That
|
|
covers ``phase``, ``sites``, ``_*``, every queue edit and every flow action —
|
|
including ones added later.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
ui = Path(__file__).resolve().parents[1] / "src" / "orisys_autocal" / "ui"
|
|
assert _campaign_offenders(ui) == []
|
|
|
|
|
|
def test_the_campaign_guard_catches_a_regression():
|
|
"""The guard above must fail on a forbidden access, not merely pass by luck."""
|
|
assert _campaign_accesses("x = self.campaign.sites\n") == ["sites"]
|
|
assert _campaign_accesses("if self.campaign.phase == 'idle':") == ["phase"]
|
|
assert _campaign_accesses("self.campaign._skipped") == ["_skipped"]
|
|
assert _campaign_accesses("self.campaign.add_solved_site(a)") == ["add_solved_site"]
|
|
assert _campaign_accesses("self.campaign.confirm_reference(1)") == ["confirm_reference"]
|
|
assert _campaign_accesses("self.campaign.attach_capture(w)") == ["attach_capture"]
|
|
for member in (
|
|
"phase",
|
|
"sites",
|
|
"_skipped",
|
|
"add_solved_site",
|
|
"update_site",
|
|
"remove_site",
|
|
"move_site",
|
|
"store_solved_pose",
|
|
"set_force_rotation",
|
|
"confirm_reference",
|
|
"complete_hold",
|
|
"stop",
|
|
"remeasure_step",
|
|
"skip_current_point",
|
|
):
|
|
assert member not in _ALLOWED_CAMPAIGN_MEMBERS, member
|
|
|
|
|
|
def test_unavailable_codecs_are_disabled_with_their_reason(make_window):
|
|
"""A codec this machine cannot open is refused here, never silently swapped."""
|
|
window = make_window()
|
|
settings = window.calibration_panel.settings
|
|
settings.set_availability({"mp4v": True, "avc1": False, "H264": False})
|
|
model = settings.codec.model()
|
|
for index in range(settings.codec.count()):
|
|
item = model.item(index)
|
|
codec = settings.codec.itemData(index)
|
|
assert item.isEnabled() is (codec == "mp4v")
|
|
assert settings.codec.currentData() == "mp4v"
|
|
for codec in ("avc1", "H264"):
|
|
assert t("cap.recording.unavailable", codec=codec) in settings.codec_reason.text()
|
|
window.close()
|
|
|
|
|
|
def test_the_size_estimate_warns_below_twice_the_planned_volume(make_window, monkeypatch):
|
|
from orisys_autocal.ui import capture_settings_dialog as module
|
|
|
|
window = make_window()
|
|
settings = window.calibration_panel.settings
|
|
settings.set_availability({"mp4v": True, "avc1": False, "H264": False})
|
|
settings.video_check.setChecked(True)
|
|
monkeypatch.setattr(
|
|
module.shutil,
|
|
"disk_usage",
|
|
lambda path: SimpleNamespace(total=0, used=0, free=1_000_000_000),
|
|
)
|
|
# One hour of mp4v at the measured bench bitrate is far more than 1 GB.
|
|
settings.set_plan(3600.0)
|
|
assert t("cap.recording.disk_short") in settings.estimate.text()
|
|
assert "12.15 GB" in settings.estimate.text()
|
|
monkeypatch.setattr(
|
|
module.shutil,
|
|
"disk_usage",
|
|
lambda path: SimpleNamespace(total=0, used=0, free=10**14),
|
|
)
|
|
settings.set_plan(3601.0)
|
|
assert t("cap.recording.disk_short") not in settings.estimate.text()
|
|
# Recording no video makes the size question disappear, not a zero.
|
|
settings.video_check.setChecked(False)
|
|
assert settings.estimate.text().startswith(t("cap.recording.off"))
|
|
window.close()
|
|
|
|
|
|
def test_a_session_recorded_without_video_says_so_in_the_review(qt_app, tmp_path):
|
|
from evidence_helpers import multipoint_session
|
|
from orisys_autocal.session_review import review_session
|
|
from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog
|
|
|
|
root = multipoint_session(
|
|
tmp_path / "quiet",
|
|
sites=("P1",),
|
|
repeats=1,
|
|
levels=(5.0,),
|
|
recording_block={"video": False, "codec": "mp4v", "lossless_frames": False},
|
|
)
|
|
review = review_session(root)
|
|
assert review["recording"]["video"] is False
|
|
dialog = RecordOverviewDialog(root, review=review)
|
|
try:
|
|
assert t("overview.video_not_recorded") in dialog.identity.text()
|
|
finally:
|
|
dialog.close()
|
|
# A session that kept its video says nothing about a missing one.
|
|
with_video = multipoint_session(
|
|
tmp_path / "loud", sites=("P1",), repeats=1, levels=(5.0,)
|
|
)
|
|
dialog = RecordOverviewDialog(with_video, review=review_session(with_video))
|
|
try:
|
|
assert t("overview.video_not_recorded") not in dialog.identity.text()
|
|
finally:
|
|
dialog.close()
|
|
|
|
|
|
def test_the_page_refuses_start_until_the_ready_checks_pass(make_window, qt_app):
|
|
"""Every ✗ check names itself and blocks the start with a stated reason."""
|
|
window = make_window(move_confirm=False)
|
|
page = window.calibration_panel
|
|
window.workspace.set_current_key("sensor")
|
|
qt_app.processEvents()
|
|
action = page.controller.primary_action()
|
|
reason = t(action.blocked_reason)
|
|
assert action.enabled is False
|
|
assert page.primary_reason.text() == reason
|
|
checks = {check.key: check for check in page.controller.readiness()}
|
|
assert checks["max_force"].ok is False
|
|
assert checks["sensor_sn"].ok is False
|
|
for key in ("calibration_bundle", "input_frames", "bus_ready"):
|
|
assert checks[key].ok is False
|
|
assert page.ready_labels[key].text().startswith("✗")
|
|
# Filling one fact turns only that chip over.
|
|
page.settings.sensor_sn.setText("SN1")
|
|
checks = {check.key: check for check in page.controller.readiness()}
|
|
assert checks["sensor_sn"].ok is True
|
|
assert page.controller.primary_action().enabled is False
|
|
window.close()
|
|
|
|
|
|
def test_a_point_needs_a_maximum_force_and_the_controller_is_told(
|
|
make_window, qt_app, tmp_path
|
|
):
|
|
"""The queue edit is the controller's, including its maximum-force refusal."""
|
|
window = make_window(move_confirm=False)
|
|
page = _page(window, qt_app, tmp_path, max_force="")
|
|
page._refresh() # noqa: SLF001 - the page's own render is the unit under test
|
|
page.add_point_button.click()
|
|
_pump(qt_app, 0.1)
|
|
assert page.controller.model().sites == []
|
|
assert page.message.text().endswith(t("cap.block.no_max_force"))
|
|
|
|
# The dialog value reaches the controller through set_max_force, not a rebuild.
|
|
page.settings.max_force.setText("20")
|
|
page._refresh() # noqa: SLF001
|
|
checks = {check.key: check for check in page.controller.readiness()}
|
|
assert checks["max_force"].ok is True
|
|
_add_point(page, "P1", 0, 0, qt_app)
|
|
assert [row.site_id for row in page.controller.model().sites] == ["P1"]
|
|
window.close()
|
|
|
|
|
|
def test_a_skipped_point_is_rendered_from_the_controller_row(
|
|
make_window, qt_app, tmp_path
|
|
):
|
|
"""The skip mark is the controller's; the page only draws ``SiteRow.skipped``."""
|
|
window = make_window(move_confirm=False)
|
|
page = _started_page(window, qt_app, tmp_path)
|
|
assert page.controller.model().sites[0].skipped is False
|
|
|
|
page._refresh() # noqa: SLF001 - render the freshly enabled button
|
|
page.skip_button.click()
|
|
_pump(qt_app, 0.2)
|
|
|
|
assert page.controller.model().sites[0].skipped is True
|
|
assert page.queue_table.item(0, 4).text() == t("cap.queue.state_skipped")
|
|
assert page.message.text().endswith(t("cap.message.skipped", site="P1"))
|
|
window.close()
|
|
|
|
|
|
def test_the_stop_button_leaves_the_reason_to_the_controller(
|
|
make_window, qt_app, tmp_path
|
|
):
|
|
"""The stop button passes no reason; the controller records the default one."""
|
|
window = make_window(move_confirm=False)
|
|
page = _started_page(window, qt_app, tmp_path, sites=("P1",))
|
|
directory = page._session_directory() # noqa: SLF001
|
|
page._refresh() # noqa: SLF001 - render the freshly enabled button
|
|
page.stop_button.click()
|
|
_pump(qt_app, 0.2)
|
|
|
|
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
|
|
assert manifest["reason"] == "operator stopped"
|
|
assert page.controller.primary_action().key == "finish_session"
|
|
window.close()
|
|
|
|
|
|
def test_closing_the_window_records_the_application_reason(
|
|
make_window, qt_app, tmp_path
|
|
):
|
|
"""Window shutdown names its own reason, so the data can tell the two apart."""
|
|
window = make_window(move_confirm=False)
|
|
page = _started_page(window, qt_app, tmp_path, sites=("P1",))
|
|
directory = page._session_directory() # noqa: SLF001
|
|
window.close()
|
|
|
|
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
|
|
assert manifest["reason"] == "application closed"
|
|
|
|
|
|
def test_updating_a_point_retakes_it_and_keeps_its_queue_position(
|
|
make_window, qt_app, tmp_path
|
|
):
|
|
"""A retaken point goes through the controller and stays where the operator put it."""
|
|
window = make_window(move_confirm=False)
|
|
page = _page(window, qt_app, tmp_path)
|
|
for index, site_id in enumerate(("P1", "P2", "P3")):
|
|
_add_point(page, site_id, index, index, qt_app)
|
|
page.queue_table.selectRow(1) # the operator's row selection
|
|
qt_app.processEvents()
|
|
assert page._selected_site == "P2" # noqa: SLF001
|
|
page.u_spin.setValue(5)
|
|
page.v_spin.setValue(5)
|
|
page.site_id.setText("P2b")
|
|
page.update_point_button.click()
|
|
_pump(qt_app, 0.2)
|
|
|
|
rows = page.controller.model().sites
|
|
assert [row.site_id for row in rows] == ["P1", "P2b", "P3"]
|
|
assert rows[1].site_id == "P2b"
|
|
assert rows[1].uv_px is not None
|
|
assert page._selected_site == "P2b" # noqa: SLF001
|
|
window.close()
|
|
|
|
|
|
# --------------------------------------------------- P5: geometry, sheet, reach
|
|
|
|
|
|
def _drive_to_liftoff(page) -> None:
|
|
"""Finish the first point's repeat directly, leaving the session at liftoff."""
|
|
campaign = page.controller.campaign
|
|
while campaign.phase == "load":
|
|
target = campaign.progress()["target_n"]
|
|
campaign.confirm_reference(target)
|
|
campaign.complete_hold(
|
|
now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9)
|
|
)
|
|
assert campaign.phase == "liftoff"
|
|
|
|
|
|
def test_changing_the_mounting_resolves_every_queued_pose(make_window, qt_app, tmp_path):
|
|
"""stackChanged re-solves the queue and writes the fresh poses back while idle."""
|
|
window = make_window(move_confirm=False)
|
|
page = _page(window, qt_app, tmp_path)
|
|
_add_point(page, "P1", 0, 0, qt_app)
|
|
_add_point(page, "P2", 1, 1, qt_app)
|
|
before = {row.site_id: row.pose["z"] for row in page.controller.model().sites}
|
|
|
|
spin = window.finger_panel.sensor_z_spin
|
|
spin.setValue(spin.value() + 4.0) # the operator changes the mounting height
|
|
_pump(qt_app, 0.2)
|
|
|
|
rows = page.controller.model().sites
|
|
assert all(row.reachable for row in rows)
|
|
for row in rows:
|
|
assert row.pose["z"] != pytest.approx(before[row.site_id]), row.site_id
|
|
fresh = page._solve_uv(*row.uv_px)[2] # noqa: SLF001 - the solver's answer
|
|
assert row.pose["z"] == pytest.approx(fresh["z"])
|
|
window.close()
|
|
|
|
|
|
def test_an_unreachable_point_is_marked_and_cannot_be_aligned(
|
|
make_window, qt_app, tmp_path
|
|
):
|
|
"""A travel limit that makes a point unreachable disables its align, visibly."""
|
|
window = make_window(move_confirm=False)
|
|
emitted = _stub_moves(window, succeeds=True)
|
|
page = _page(window, qt_app, tmp_path)
|
|
_add_point(page, "P1", 0, 0, qt_app)
|
|
assert page.controller.model().sites[0].reachable is True
|
|
emitted.clear()
|
|
|
|
window.finger_panel.stack.yaw_limit_deg = 0.0 # the stage can no longer turn
|
|
window.finger_panel.stackChanged.emit()
|
|
_pump(qt_app, 0.2)
|
|
|
|
row = page.controller.model().sites[0]
|
|
assert row.reachable is False
|
|
assert page.queue_table.item(0, 3).text() == t("cap.queue.unreachable")
|
|
action = page.controller.primary_action()
|
|
assert (action.key, action.enabled) == ("align_point", False)
|
|
assert action.blocked_reason == "cap.block.no_pose"
|
|
page._align() # noqa: SLF001 - a direct call must be refused too
|
|
assert not emitted
|
|
assert t("cap.block.pose_unreachable") in page.message.text()
|
|
window.close()
|
|
|
|
|
|
def test_a_frozen_queue_is_not_rewritten_when_the_mounting_changes(
|
|
make_window, qt_app, tmp_path
|
|
):
|
|
"""Between trials the queue is frozen: reachability is shown, poses are not stored."""
|
|
window = make_window(move_confirm=False)
|
|
page = _started_page(window, qt_app, tmp_path)
|
|
_drive_to_liftoff(page)
|
|
page._refresh() # noqa: SLF001
|
|
recorded = page.controller.model().sites[1].pose["z"]
|
|
|
|
spin = window.finger_panel.sensor_z_spin
|
|
spin.setValue(spin.value() + 4.0)
|
|
_pump(qt_app, 0.2)
|
|
|
|
rows = page.controller.model().sites
|
|
assert rows[1].pose["z"] == pytest.approx(recorded) # nothing written back
|
|
assert page._live_reachable # noqa: SLF001 - the display-only answer exists
|
|
fresh = page._solve_uv(*rows[1].uv_px)[2] # noqa: SLF001
|
|
assert page._live_reachable[rows[1].site_id] == bool( # noqa: SLF001
|
|
fresh["ok"] and fresh["in_range"]
|
|
)
|
|
window.close()
|
|
|
|
|
|
def test_the_point_sheet_matches_the_documented_fields(make_window, qt_app, tmp_path):
|
|
"""P5 exit: the operation table's fields are the data-format document's, in order."""
|
|
from orisys_autocal.sensor.uv_flat import mapping_sha256, uv_origin
|
|
from orisys_autocal.ui.site_export import OPERATION_TABLE_FIELDS, write_operation_table
|
|
|
|
document = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "docs"
|
|
/ "calibration-data-format.zh.md"
|
|
).read_text(encoding="utf-8")
|
|
# The document spells out the first two blocks; the rest are described as a group.
|
|
explicit = (
|
|
"order", "site_id", "u_px", "v_px",
|
|
"x_sensor_mm", "y_sensor_mm", "z_sensor_mm",
|
|
"x_platform_mm", "y_platform_mm", "z_platform_mm",
|
|
"yaw_deg", "pitch_deg",
|
|
"origin_raster_x_px", "origin_raster_y_px",
|
|
)
|
|
assert OPERATION_TABLE_FIELDS[: len(explicit)] == explicit
|
|
for field in explicit:
|
|
assert field in document, f"{field} is not named in the format document"
|
|
|
|
window = make_window(move_confirm=False)
|
|
page = _page(window, qt_app, tmp_path)
|
|
_add_point(page, "P1", 0, 0, qt_app)
|
|
_add_point(page, "P2", 1, 1, qt_app)
|
|
target = tmp_path / "points.csv"
|
|
write_operation_table(target, page.controller.model().sites, page._flat)
|
|
|
|
with target.open(encoding="utf-8-sig", newline="") as handle:
|
|
header = next(csv.reader(handle))
|
|
written = list(csv.DictReader(target.open(encoding="utf-8-sig", newline="")))
|
|
assert tuple(header) == OPERATION_TABLE_FIELDS
|
|
assert [row["site_id"] for row in written] == ["P1", "P2"]
|
|
origin = uv_origin(page._flat)
|
|
assert int(written[0]["origin_raster_x_px"]) == origin[0]
|
|
assert int(written[0]["origin_raster_y_px"]) == origin[1]
|
|
assert int(written[0]["raster_width_px"]) == page._flat.width
|
|
assert int(written[0]["raster_height_px"]) == page._flat.height
|
|
assert written[0]["u_positive"] == "right"
|
|
assert written[0]["v_positive"] == "up"
|
|
assert written[0]["mapping_sha256"] == mapping_sha256(page._flat)
|
|
for record, row in zip(written, page.controller.model().sites):
|
|
assert float(record["x_sensor_mm"]) == pytest.approx(row.position["x"])
|
|
assert float(record["x_platform_mm"]) == pytest.approx(row.pose["x"])
|
|
assert float(record["yaw_deg"]) == pytest.approx(row.pose["yaw"])
|
|
|
|
# A recorded coordinate that no longer matches the map refuses the sheet.
|
|
page.controller.campaign.sites[0].x += 0.5
|
|
with pytest.raises(ValueError):
|
|
write_operation_table(tmp_path / "invalid.csv", page.controller.model().sites, page._flat)
|
|
assert not (tmp_path / "invalid.csv").exists()
|
|
window.close()
|
|
|
|
|
|
def test_the_export_button_writes_the_sheet_and_reports_it(
|
|
make_window, qt_app, tmp_path, monkeypatch
|
|
):
|
|
from orisys_autocal.ui import calibration_panel as module
|
|
|
|
window = make_window(move_confirm=False)
|
|
page = _page(window, qt_app, tmp_path)
|
|
_add_point(page, "P1", 0, 0, qt_app)
|
|
target = tmp_path / "sheet.csv"
|
|
monkeypatch.setattr(
|
|
module.QFileDialog,
|
|
"getSaveFileName",
|
|
lambda *args, **kwargs: (str(target), ""),
|
|
)
|
|
page.export_sheet_button.click()
|
|
_pump(qt_app, 0.1)
|
|
|
|
assert target.is_file()
|
|
assert page.message.text() == t("cap.queue.export_saved", path=str(target))
|
|
# The refusal path names the point and writes nothing.
|
|
page.controller.campaign.sites[0].x += 0.5
|
|
page.export_sheet_button.click()
|
|
_pump(qt_app, 0.1)
|
|
assert t("cap.queue.export_edited_point", order=1) in page.message.text()
|
|
window.close()
|
|
|
|
def test_a_failed_summary_copy_is_reported_not_called_published(make_window):
|
|
"""The copy under summary/ is a convenience; a failure must not read as success."""
|
|
window = make_window()
|
|
page = window.calibration_panel
|
|
detail = "the readable copy under summary/ was not written: no space left"
|
|
page._summary_warnings = [detail]
|
|
page._summary_finished("/some/published/summary", None)
|
|
assert page.message.text() == t("summary.copy_failed").format(detail=detail)
|
|
window.close()
|
|
|
|
|
|
def test_a_stored_unavailable_codec_does_not_become_the_selection(make_window):
|
|
"""Loading stored options must not re-select a codec the probe disabled."""
|
|
window = make_window()
|
|
settings = window.calibration_panel.settings
|
|
settings.prefs["recording"] = {
|
|
"video": True,
|
|
"codec": "avc1",
|
|
"lossless_frames": False,
|
|
}
|
|
# The dialog probes first and loads the stored options second; that order is
|
|
# what used to put the disabled avc1 back into the combo.
|
|
settings.set_availability({"mp4v": True, "avc1": False, "H264": False})
|
|
settings._load_preferences()
|
|
assert settings.codec.currentData() == "mp4v"
|
|
assert settings.recording_options().codec == "mp4v"
|
|
window.close()
|