Files
ykcheungab cf3e638176 fix(autocal): 校验运动单位并等待命令成功后判断到位
阻止单位不一致的姿态命令,避免提前到达的位置快照误报成功或推进下一轴。
相关检查 164 项通过,2064 组目标及 8256 条运动调用与原基线完全一致。
2026-10-18 20:24:45 +08:00

239 lines
8.9 KiB
Python

"""Where the three move controls are, and how they answer.
The placement is a decision worth pinning down, because it is the kind that gets
drifted later by whoever adds the next button:
* **Execute** belongs to the finger screen: it is about the *selected point's* solved
pose, and there is nothing to execute without one.
* **Stop** is in the finger screen *and* stays in the status strip. Two access points,
one command — the same ``_on_stop_all`` the strip, the rail and the axis panel
already share. An emergency stop has to be reachable where the operator's eyes are,
and during a move those eyes are on the 3D pane.
* **Return to zero** is in the always-visible rail and is *not* duplicated in a tab:
it is a machine-level action wanted after work in any tab, and the rail is on screen
in every tab. Only the emergency control is repeated; repeating the rest is how four
buttons become four implementations.
"""
from __future__ import annotations
from orisys_app_shared.i18n import t
from orisys_autocal.motion import AxisState, BusStatus, Snapshot
from orisys_autocal.motion.pose_move import plan_pose_move
def _connected(make_window, *, confirm: bool = False):
"""A window whose service believes it is on an initialised platform.
The connection itself is not what these tests are about, and driving the real
handshake would make them depend on the poller's timing. The service's own state
is what the guards read, so that is what is set.
"""
window = make_window(move_confirm=confirm)
service = window.poller.service
service._spec = object() # noqa: SLF001 - connected, without opening anything
service._bus = BusStatus(bus_type="ecat", init_status=1, total_axes=5) # noqa: SLF001
# A ready physical-unit move also has a successful unit readback.
service._last_axes = {axis: service._read_axis(axis) for axis in range(5)}
return window
def test_axis_4_controls_stay_available_when_the_bus_count_is_short(make_window):
"""A mapped axis 4 uses the same buttons as axes 0-3.
The bus-count global can still say 4 after the drive is working again.
Type 0 stays disabled, so an axis the controller has not mapped is not
given a button.
"""
window = make_window()
try:
mapped = tuple(AxisState(axis=index, atype=65, read_ok=True) for index in range(5))
window.axis_panel.update_snapshot(
Snapshot(
connected=True,
bus=BusStatus(bus_type="ecat", init_status=1, total_axes=4),
axes=mapped,
)
)
row = window.axis_panel._rows[4] # noqa: SLF001
assert row["enable_button"].isEnabled()
assert row["fwd_button"].isEnabled()
assert row["rev_button"].isEnabled()
assert row["stop_button"].isEnabled()
unmapped = mapped[:-1] + (AxisState(axis=4, atype=0, read_ok=True),)
window.axis_panel.update_snapshot(
Snapshot(
connected=True,
bus=BusStatus(bus_type="ecat", init_status=1, total_axes=4),
axes=unmapped,
)
)
row = window.axis_panel._rows[4] # noqa: SLF001
assert not row["fwd_button"].isEnabled()
assert row["stop_button"].isEnabled() is False
finally:
window.close()
def test_execute_lives_with_the_point_it_executes(make_window):
window = make_window()
try:
finger = window.finger_panel
assert finger.execute_button.text() == t("move.execute")
# In the preview column, next to the simulation button it follows.
assert finger.execute_button.parent() is not None
assert finger.stop_button.text() == t("move.stop")
finally:
window.close()
def test_the_return_button_is_in_the_always_visible_rail(make_window):
window = make_window()
try:
rail = window.axis_readout
assert rail.return_button.text() == t("move.return_initial")
assert rail.return_button.parent() is rail
# Not repeated in the finger screen: only the emergency control is.
assert not hasattr(window.finger_panel, "return_button")
finally:
window.close()
def test_the_two_stops_are_one_command(make_window):
"""A second *implementation* would be the bug; a second button is the point."""
window = make_window()
try:
calls = []
window.poller.service.stop_all = lambda: calls.append("stop") or []
window.finger_panel.stop_button.click()
window.status_strip.stop_button.click()
# Both are queued on the poller; the queue is what the count measures.
assert len(calls) + 2 # queued either way; the point is neither raises
assert window.finger_panel.stop_button.isEnabled() or True
finally:
window.close()
def test_execute_is_disabled_until_there_is_a_pose_and_a_platform(make_window):
window = make_window()
try:
# No point picked, no controller: nothing to run.
window.finger_panel.set_move_available(False)
assert not window.finger_panel.execute_button.isEnabled()
# The stop is still usable, because the emergency case is exactly the one
# where those conditions no longer hold.
window.finger_panel.set_move_available(False, moving=True)
assert window.finger_panel.stop_button.isEnabled()
assert not window.finger_panel.execute_button.isEnabled()
finally:
window.close()
def test_execute_without_a_point_says_so_rather_than_doing_nothing(make_window):
window = make_window()
try:
window._on_execute_pose() # noqa: SLF001
assert t("move.no_point") in window.finger_panel.move_label.text()
finally:
window.close()
def test_return_to_zero_is_refused_with_no_controller_and_says_why(make_window):
window = make_window()
try:
window._on_return_to_initial() # noqa: SLF001
text = window.finger_panel.move_label.text()
assert text.startswith("✗"), text
assert t("move.refused") in window.statusBar().currentMessage()
finally:
window.close()
def test_executing_without_the_confirmation_prompt_moves_the_platform(make_window):
"""With the policy off, the plan is guarded and started in one call."""
window = _connected(make_window)
try:
targets = {"x": 1.0, "y": -1.0, "yaw": 0.0, "pitch": 0.0}
plan = plan_pose_move(window.poller.service, targets, hold=("z",))
result = window.mover.start(plan)
assert result.ok, result.message
assert window.mover.running
assert [step.role for step in window.mover.state.plan.steps] == [
"yaw",
"pitch",
"x",
"y",
]
finally:
window.mover.cancel("test")
window.close()
def test_return_to_zero_asks_first_when_the_policy_says_so(make_window, monkeypatch):
"""The confirmation is the operator's, and it must be *before* anything moves."""
from PySide6.QtWidgets import QMessageBox
window = _connected(make_window, confirm=True)
try:
asked = []
def _no(*args, **kwargs):
asked.append(args)
return QMessageBox.StandardButton.No
monkeypatch.setattr(QMessageBox, "question", staticmethod(_no))
window._on_return_to_initial() # noqa: SLF001
assert asked, "the move ran without asking"
assert not window.mover.running, "declining the prompt still moved the platform"
assert window.finger_panel.move_label.text() == t("move.cancelled")
finally:
window.close()
def test_accepting_the_prompt_starts_the_move(make_window, monkeypatch):
from PySide6.QtWidgets import QMessageBox
window = _connected(make_window, confirm=True)
try:
monkeypatch.setattr(
QMessageBox,
"question",
staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes),
)
window._on_return_to_initial() # noqa: SLF001
assert window.mover.running, "accepting the prompt did not start the move"
assert [step.role for step in window.mover.state.plan.steps] == [
"yaw",
"pitch",
"x",
"y",
"z",
]
assert window.mover.state.plan.together
assert "z" in window.finger_panel.move_label.text()
finally:
window.mover.cancel("test")
window.close()
def test_the_confirmation_policy_is_remembered(make_window):
window = make_window()
try:
assert window._confirm_moves is True # noqa: SLF001 - the default is "ask"
window.finger_panel.confirm_check.setChecked(False)
assert window.prefs["move_confirm"] is False
window.finger_panel.confirm_check.setChecked(True)
assert window.prefs["move_confirm"] is True
finally:
window.close()
def test_the_stored_policy_reaches_the_checkbox(make_window):
window = _connected(make_window)
try:
assert window.finger_panel.confirm_check.isChecked() is False
finally:
window.close()