"""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 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][0] == "z" # The status line carries the plan, so the operator sees what is moving. 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()