autocal layout first design
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user