Files
orisys-app/apps/autocal/tests/test_layout_shell.py
T
2026-09-21 09:34:56 +08:00

587 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.sequence_io import SequenceFormatError, SequencePoint
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"]
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_import_and_export_are_live_controls(make_window):
"""A sequence is a document, and P8 says it is a versioned artifact."""
window = _show(make_window())
panel = window.sequence_panel
for name in ("export", "import"):
button = panel._live_buttons[name]
assert button.isEnabled(), f"{name} is not usable"
assert button.text(), f"{name} has no label"
assert button.toolTip()
def test_the_point_list_has_no_empty_action_column(make_window):
"""An empty action cell reads as a broken control."""
window = _show(make_window())
panel = window.sequence_panel
assert "actions" not in panel.column_keys()
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_the_sequence_summary_reports_count_and_span(make_window):
"""The two numbers an operator reasons with, before anything runs."""
window = _show(make_window())
panel = window.sequence_panel
panel.set_points(
[
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
SequencePoint(id=2, target_mm={"x": 10.0, "y": 0.0, "z": 12.4}),
]
)
text = panel.summary_label.text()
assert "2" in text
# A nil Z span prints once, and the real X span prints both ends.
assert "0.000–10.000" in text
assert "12.400" in text
def test_the_sequence_summary_updates_with_the_profile(make_window):
window = _show(make_window())
panel = window.sequence_panel
panel.set_points([SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 1.0})])
before = panel.summary_label.text()
panel.field("repeats").setText("10")
assert panel.summary_label.text() != before
def test_an_empty_sequence_says_so_rather_than_showing_zeros(make_window):
window = _show(make_window())
panel = window.sequence_panel
panel.set_points([])
assert panel.summary_label.text()
assert "0" not in panel.summary_label.text()[:2]
def test_the_design_is_dirty_until_it_is_saved_or_loaded(make_window):
"""Import replaces the design, and there is no autosave."""
window = _show(make_window())
panel = window.sequence_panel
panel.mark_clean()
assert not panel.dirty
panel.field("pitch_mm").setText("9")
assert panel.dirty, "a profile edit did not mark the design dirty"
def test_export_then_import_restores_the_design(make_window, tmp_dir):
"""The round trip through the real panel, not just the format module."""
window = _show(make_window())
panel = window.sequence_panel
panel.set_name("bench sweep")
panel.field("pitch_mm").setText("3.5")
panel.set_points(
[
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
SequencePoint(id=2, target_mm={"x": 3.5, "y": 0.0, "z": 12.4}),
]
)
path = tmp_dir / "sweep.oryseq"
window.export_sequence(str(path))
assert path.is_file()
assert not panel.dirty, "export did not clear the dirty flag"
# Change everything, then import it back.
panel.set_name("")
panel.field("pitch_mm").setText("1")
panel.set_points([])
document = window.import_sequence(str(path))
assert document.point_count == 2
assert panel.name() == "bench sweep"
assert panel.profile()["pitch_mm"] == 3.5
assert panel.point_row_count() == 2
assert not panel.dirty
def test_importing_a_bad_file_reports_and_changes_nothing(make_window, tmp_dir):
window = _show(make_window())
panel = window.sequence_panel
panel.field("pitch_mm").setText("4")
path = tmp_dir / "bad.oryseq"
path.write_text("not json at all", encoding="utf-8")
with pytest.raises(SequenceFormatError) as excinfo:
window.import_sequence(str(path))
assert "not valid JSON" in str(excinfo.value)
# The design is untouched: a refused import is not a partial import.
assert panel.profile()["pitch_mm"] == 4
def test_an_exported_sequence_names_the_sensor_it_was_designed_for(make_window, tmp_dir):
import json
window = _show(make_window())
window.prefs["sensor_setup"] = {
"sensor_type": "finger",
"geometry_id": "finger-1.0.0",
"probe_tip_set": True,
}
path = tmp_dir / "named.oryseq"
window.export_sequence(str(path))
document = json.loads(path.read_text(encoding="utf-8"))
assert document["device"]["sensor_type"] == "finger"
assert document["device"]["geometry_id"] == "finger-1.0.0"
assert document["versions"]["app"]
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)