"""The single-page shell, from an operator's point of view. These assertions are about the layout contract rather than any one panel: * the motion status strip is outside the tabs, so it survives every tab change; * all five tabs exist, are labelled, and their pages fit; * the right-hand readout rail shows every drive and issues no per-axis command; * the window refuses to shrink below the size at which the page would clip. The overflow check lives in ``test_panel_smoke.py`` because it needs the colour tables; what is here is the structure. """ from __future__ import annotations import pytest from conftest import pump from orisys_autocal.ui.workspace import TABS def _show(window, size=(1600, 900)): from PySide6.QtWidgets import QApplication window.resize(*size) window.show() QApplication.processEvents() return window def test_the_tab_set_is_the_documented_one(make_window): window = _show(make_window()) assert window.workspace.page_keys() == [key for key, _ in TABS] def test_every_tab_has_a_localised_label(make_window): from orisys_app_shared.i18n import t window = _show(make_window()) for index, (_key, locale_key) in enumerate(TABS): label = window.workspace.tabText(index) assert label, f"tab {index} has no label" assert label == t(locale_key) assert label != locale_key, f"tab {index} shows its raw key" def test_the_status_strip_survives_every_tab_change(make_window): """The whole point of putting the strip above the tabs.""" window = _show(make_window()) for key in window.workspace.page_keys(): window.workspace.set_current_key(key) assert window.status_strip.isVisible(), f"the strip hid on tab {key}" assert window.status_strip.height() <= 48 def test_the_strip_reports_connection_and_bus_state(make_window): from orisys_app_shared.i18n import t from orisys_autocal.motion import BusStatus, Snapshot window = _show(make_window()) strip = window.status_strip strip.set_snapshot(Snapshot(connected=False)) assert strip.chip_connection.text() == t("connection.state_closed") assert strip.chip_init.text() == t("bus.state_pending") strip.set_snapshot( Snapshot(connected=True, bus=BusStatus(bus_type="ecat", init_status=1, total_axes=5)) ) assert strip.chip_connection.text() == t("connection.state_open") assert strip.chip_bus.text() == t("bus.type_ecat") assert strip.chip_init.text() == t("bus.state_ok") def test_a_refusal_stays_visible_on_the_strip(make_window): """A command refused while the operator was on another tab must not vanish.""" window = _show(make_window()) window.workspace.set_current_key("sensor") window.status_strip.set_action_result("轴0 回零模式未设置", False) assert "回零模式" in window.status_strip.action_label.text() def test_the_readout_rail_covers_every_axis(make_window): window = _show(make_window()) assert len(window.axis_readout._rows) == window.axis_panel.axis_count == 5 def test_the_readout_rail_shows_position_and_state(make_window): from orisys_autocal.motion import AxisState, Snapshot window = _show(make_window()) snapshot = Snapshot( connected=True, axes=( AxisState(axis=0, enabled=True, units=10.0, dpos=1000.0, idle=True), AxisState(axis=1, enabled=False, units=10.0, dpos=0.0, idle=True), ), ) window.axis_readout.update_snapshot(snapshot) # 1000 user units at units=10 is the documented 2.000 mm, and the readout # shows both so an operator never has to convert in their head. text = window.axis_readout._rows[0]["position"].text() assert "1000.000" in text and "2.000 mm" in text, text assert window.axis_readout._rows[1]["state"].text() def test_the_readout_rail_issues_no_per_axis_motion(make_window): """Per-axis commands live in one place only: the Axis-debug tab.""" window = _show(make_window()) rail = window.axis_readout assert not hasattr(rail, "jogRequested") assert not hasattr(rail, "moveAbsRequested") assert hasattr(rail, "stopAllRequested") assert hasattr(rail, "enableAllRequested") def test_enable_all_toggles_with_the_current_state(make_window): from orisys_autocal.motion import AxisState, Snapshot window = _show(make_window()) requested: list[bool] = [] window.axis_readout.enableAllRequested.connect(requested.append) window.axis_readout.update_snapshot( Snapshot(connected=True, axes=tuple(AxisState(axis=a, enabled=True) for a in range(5))) ) window.axis_readout.enable_all_button.click() assert requested == [False], "enable-all did not become disable-all" window.axis_readout.update_snapshot(Snapshot(connected=True, axes=())) window.axis_readout.enable_all_button.click() assert requested == [False, True] def test_the_window_refuses_to_shrink_below_the_page(make_window): window = _show(make_window()) window.resize(800, 600) assert window.width() >= window.minimumWidth() assert window.height() >= window.minimumHeight() assert window.minimumWidth() >= 1300 def test_the_bus_data_group_starts_collapsed(make_window): """It is commissioning-only; expanded, it cost the page ~270 px.""" window = _show(make_window()) assert not window.bus_data_group.isChecked() assert not window.bus_data_panel.isVisible() def test_expanding_bus_data_does_not_resize_the_window(make_window): window = _show(make_window()) before = (window.width(), window.height()) window.bus_data_group.setChecked(True) pump(_app(), 0.1) assert (window.width(), window.height()) == before def _app(): from PySide6.QtWidgets import QApplication return QApplication.instance() def test_the_sequence_tab_shows_the_documented_profile_fields(make_window): from orisys_autocal.ui.sequence_panel import PROFILE_FIELDS window = _show(make_window()) panel = window.sequence_panel assert panel.profile_keys() == [key for key, _locale, _kind in PROFILE_FIELDS] def test_the_sequence_panel_exposes_its_three_zones(make_window): window = _show(make_window()) panel = window.sequence_panel assert panel.validation_checks() == [ "on_surface", "within_travel", "contact_confirmable", ] assert panel.point_row_count() > 0, "the point list is empty" assert panel.summary_label.text() 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", ] for name, phase in panel.pending_phases().items(): button = panel._pending_buttons[name] assert not button.isEnabled(), f"{name} is usable but not implemented" assert phase in button.toolTip(), f"{name} does not name its phase" assert button.text(), f"{name} has no label" 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()) assert window.sequence_panel.planned_steps() == [] def test_point_coordinates_are_generated_not_typed(make_window): """A typed coordinate is one nothing has checked against the sensor.""" from orisys_autocal.ui.sequence_panel import _EDITABLE_POINT_COLUMNS assert _EDITABLE_POINT_COLUMNS == () window = _show(make_window()) panel = window.sequence_panel # The point list is a real table, so it scrolls instead of clipping rows. assert panel.table.rowCount() == panel.point_row_count() assert panel.point_row(0)["x"] == "0.000" # Every cell is read-only: the table's edit triggers are off, so a generated # coordinate cannot be typed over. from PySide6.QtWidgets import QTableWidget assert panel.table.editTriggers() == QTableWidget.EditTrigger.NoEditTriggers def test_the_profile_round_trips_through_preferences(make_window): window = _show(make_window()) panel = window.sequence_panel panel.field("pitch_mm").setText("3.5") panel.field("repeats").setText("4") window._persist_prefs() stored = window.prefs["sequence_profile"] assert stored["pitch_mm"] == 3.5 assert stored["repeats"] == 4 def test_an_unusable_profile_number_falls_back_rather_than_raising(make_window): from orisys_autocal.preferences import default_preferences window = _show(make_window()) panel = window.sequence_panel panel.field("pitch_mm").setText("not a number") assert panel.profile()["pitch_mm"] == 0.0 # A blank repeats field must not become zero repeats. panel.field("repeats").setText("") assert panel.profile()["repeats"] >= 1 assert "pitch_mm" in default_preferences()["sequence_profile"] def test_the_stored_profile_is_loaded_into_the_form(make_window): window = _show(make_window(sequence_profile={"pitch_mm": 7.5, "order": "row"})) panel = window.sequence_panel assert panel.profile()["pitch_mm"] == 7.5 assert panel.profile()["order"] == "row" def test_the_axis_table_shows_seven_columns(make_window): from orisys_autocal.ui.axis_panel import COLUMNS window = _show(make_window()) assert len(COLUMNS) == 7 assert window.axis_panel.table.columnCount() == 8 # seven data + actions def test_the_axis_detail_line_carries_what_the_table_dropped(make_window): """Axis type, accel, feedback and the status word must stay reachable.""" from orisys_autocal.motion import AxisState, Snapshot window = _show(make_window()) window.axis_panel.set_selected_axis(3) window.axis_panel.update_snapshot( Snapshot( connected=True, axes=(AxisState(axis=3, enabled=True, atype=65, accel=1234.0, mpos=5.0),), ) ) detail = window.axis_panel._detail_label.text() assert "65" in detail and "1234" in detail assert "3" in detail def test_the_axis_detail_follows_the_selected_axis(make_window): from orisys_autocal.motion import AxisState, Snapshot window = _show(make_window()) window.axis_panel.update_snapshot( Snapshot( connected=True, axes=(AxisState(axis=0, atype=65), AxisState(axis=1, atype=67)), ) ) window.axis_panel.set_selected_axis(0) assert "65" in window.axis_panel._detail_label.text() window.axis_panel.set_selected_axis(1) assert "67" in window.axis_panel._detail_label.text() def test_the_window_defaults_to_the_design_resolution(make_window): from orisys_autocal.ui.window import DEFAULT_WINDOW_SIZE, MINIMUM_WINDOW_SIZE assert DEFAULT_WINDOW_SIZE == (1920, 1080) assert MINIMUM_WINDOW_SIZE[0] >= 1440 def test_the_default_theme_is_light(make_window): from orisys_autocal.preferences import DEFAULT_THEME assert DEFAULT_THEME == "light" window = _show(make_window()) assert window._theme == "light" def test_pausing_the_strip_pauses_the_poller(make_window): window = _show(make_window()) window.status_strip.pause_button.setChecked(True) window._on_pause_polling() assert window.poller.paused window.status_strip.pause_button.setChecked(False) window._on_pause_polling() assert not window.poller.paused def test_the_diagnostics_footer_does_not_move_the_layout(make_window): window = _show(make_window()) splitters = window.main_splitter.sizes() window._set_diagnostics(True) window._set_diagnostics(False) assert window.main_splitter.sizes() == splitters def test_splitter_sizes_round_trip_through_preferences(make_window): window = _show(make_window()) window.main_splitter.setSizes([340, 1500]) window._persist_prefs() stored = window.prefs["main_splitter_sizes"] assert len(stored) == 2 assert stored[1] > stored[0] def test_the_rail_is_a_single_scrollable_column(make_window): """Two thin rails bracketing the workspace cost it 240 px at every size.""" from orisys_autocal.ui.window import MAIN_SPLITTER_SIZES assert len(MAIN_SPLITTER_SIZES) == 2, "the layout is back to three columns" window = _show(make_window()) assert window.main_splitter.count() == 2 assert window.left_rail.widget() is window.rail_content # The readout lives in the rail now, not in a rail of its own. assert window.rail_content.findChild(type(window.axis_readout)) is window.axis_readout def test_the_rail_scrolls_rather_than_clipping_its_controls(make_window): """At 900 px high the three rail groups do not fit; the rail must scroll.""" window = _show(make_window(), (1440, 900)) viewport = window.left_rail.viewport().height() content = window.rail_content.sizeHint().height() scroll = window.left_rail.verticalScrollBar() assert content <= viewport or scroll.maximum() > 0, ( f"rail content is {content}px in a {viewport}px viewport and cannot scroll" ) # --------------------------------------------------------------- run controls def test_the_run_bar_is_visible_from_every_tab(make_window): """Abort must never require finding the tab that started the run.""" window = _show(make_window()) for key in window.workspace.page_keys(): window.workspace.set_current_key(key) assert window.run_bar.isVisible(), f"the run bar hid on tab {key}" def test_run_is_wired_and_refuses_by_naming_its_phase(make_window): window = _show(make_window()) window.run_bar.run_button.click() assert "P7" in window.status_strip.action_label.text() def test_abort_stops_the_axes_and_is_enabled_once_a_run_exists(make_window): window = _show(make_window()) assert not window.run_bar.abort_button.isEnabled() requested = [] window.run_bar.abortRequested.connect(lambda: requested.append(True)) window.run_bar.set_run(True) assert window.run_bar.abort_button.isEnabled() window.run_bar.abort_button.click() assert requested == [True] assert window.run_bar.phase == "failed" def test_the_run_bar_shows_progress_and_timing(make_window): window = _show(make_window()) window.run_bar.set_progress(7, 40) assert "7" in window.run_bar.step_label.text() assert "40" in window.run_bar.step_label.text() assert window.run_bar.progress.maximum() == 40 assert window.run_bar.progress.value() == 7 window.run_bar.set_timing(125.0, 300.0) assert "2:05" in window.run_bar.time_label.text() # An unknown estimate must say so rather than showing a wrong number. window.run_bar.set_timing(125.0, None) assert "2:05" in window.run_bar.time_label.text() # ---------------------------------------------------------------- poll health def test_the_health_chip_reports_the_age_of_the_last_reading(make_window): from orisys_autocal.motion import Snapshot window = _show(make_window()) strip = window.status_strip strip.set_snapshot(Snapshot(connected=True)) assert strip.chip_health.text() assert strip.chip_health._tone == "ok" def test_the_health_chip_ages_when_the_poll_stops(make_window): """A stalled poll must not keep looking healthy.""" from orisys_autocal.motion import Snapshot window = _show(make_window()) strip = window.status_strip strip.set_poll_interval_ms(100) strip.set_snapshot(Snapshot(connected=True)) strip._last_snapshot_at -= 1.5 # fifteen intervals ago strip.refresh_health() assert strip.chip_health._tone == "error", strip.chip_health.text() def test_the_health_chip_says_paused_rather_than_ageing(make_window): from orisys_autocal.motion import Snapshot window = _show(make_window()) strip = window.status_strip strip.set_snapshot(Snapshot(connected=True)) strip.set_paused(True) strip.refresh_health() assert strip.chip_health._tone == "muted" assert strip.chip_health.text() != "" def test_the_status_bar_uses_its_own_theme_token(make_window): """The brand navy reads well as a header and heavy as a footer.""" from orisys_app_shared.theme import load_colors for theme in ("light", "dark"): colours = load_colors(theme) assert "status_bar_bg" in colours, theme assert "status_bar_text" in colours, theme window = _show(make_window()) assert "QStatusBar" in window.statusBar().styleSheet() def test_language_switch_relabels_the_new_shell(make_window): from orisys_app_shared.i18n import t window = _show(make_window(language="zh_CN")) zh_tabs = [window.workspace.tabText(i) for i in range(window.workspace.count())] window._set_language("en_US") assert window.status_strip.title.text() == t("status.motion") assert window.run_bar.title.text() == t("run.title") en_tabs = [window.workspace.tabText(i) for i in range(window.workspace.count())] assert en_tabs != zh_tabs assert all(en_tabs)