autocal layout first design

This commit is contained in:
ykcheungab
2026-09-20 21:15:39 +08:00
parent 07d7c3bad6
commit e276b5de0f
92 changed files with 18071 additions and 64 deletions
+469
View File
@@ -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)