fix stop button disabled during connection

This commit is contained in:
ykcheungab
2026-09-12 20:27:34 +08:00
parent 904c27125f
commit 85d700bc06
15 changed files with 750 additions and 65 deletions
+10
View File
@@ -26,6 +26,7 @@
"plot_settings": "Plot settings…",
"rotate_arrows": "Rotate arrows 90°",
"run": "Run / Stop",
"stop": "Cancel acquisition",
"preview": "Preview / Stop preview",
"reset": "Reset tracker",
"help": "Help",
@@ -39,6 +40,10 @@
"plot_fn_max_tip": "Normal force axis max (Newton mode)",
"plot_shear_max": "Shear max",
"plot_shear_max_tip": "Shear force axis ±max for FX/FY (Newton mode)",
"plot_fn_raw_max": "FN raw max",
"plot_fn_raw_max_tip": "Normal-force axis max in raw (uncalibrated) mode. 0 = auto range on the data; the default is sized so idle jitter is invisible in the plot",
"plot_shear_raw_max": "Shear raw max",
"plot_shear_raw_max_tip": "Shear-force axis ±max for FX/FY in raw (uncalibrated) mode. 0 = auto range",
"reset_defaults": "Reset to defaults",
"ok": "OK",
"cancel": "Cancel"
@@ -57,6 +62,8 @@
"stop": "Stop",
"run_label": "▶ Run",
"stop_label": "■ Stop",
"cancel_label": "■ Cancel",
"cancel_tip": "Abort the connection (always available while connecting, shortcut Esc)",
"run_unavailable": "Not available yet",
"run_disabled_video": "Select a video file first",
"run_disabled_sensor": "Run is not available for this sensor type",
@@ -134,8 +141,11 @@
"preview_stopped": "Preview stopped",
"preview_failed": "Preview failed: {error}",
"connecting": "Connecting…",
"connect_slow": "This is taking a while — click “Cancel” or press Esc to abort",
"run_started": "Running ({source})",
"run_stopped": "Stopped",
"run_stopping": "Stopping… (releasing the device)",
"run_stopping_cancelled": "Connection cancelled (releasing the device)",
"run_failed": "Run failed: {error}",
"video_ended": "Video ended",
"camera_disconnected": "Camera disconnected",
+10
View File
@@ -26,6 +26,7 @@
"plot_settings": "绘图设置…",
"rotate_arrows": "旋转箭头图 90°",
"run": "运行 / 停止",
"stop": "取消采集",
"preview": "预览 / 停止预览",
"reset": "重置追踪器",
"help": "帮助",
@@ -39,6 +40,10 @@
"plot_fn_max_tip": "法向力纵轴上限(牛顿模式)",
"plot_shear_max": "剪切上限",
"plot_shear_max_tip": "剪切力 FX/FY 纵轴 ±上限(牛顿模式)",
"plot_fn_raw_max": "FN 原始上限",
"plot_fn_raw_max_tip": "未标定(原始)模式下的法向力纵轴上限。0 = 按数据自动量程;默认值按实测峰值设定,使静止时的抖动在图中不可见",
"plot_shear_raw_max": "原始剪切上限",
"plot_shear_raw_max_tip": "未标定(原始)模式下的剪切力 FX/FY 纵轴 ±上限。0 = 自动量程",
"reset_defaults": "恢复默认",
"ok": "确定",
"cancel": "取消"
@@ -57,6 +62,8 @@
"stop": "停止",
"run_label": "▶ 运行",
"stop_label": "■ 停止",
"cancel_label": "■ 取消连接",
"cancel_tip": "中断连接(连接卡住时随时可用,快捷键 Esc)",
"run_unavailable": "当前不可运行",
"run_disabled_video": "请先选择一个视频文件",
"run_disabled_sensor": "此传感器类型暂不支持运行",
@@ -135,8 +142,11 @@
"preview_stopped": "预览已停止",
"preview_failed": "预览失败: {error}",
"connecting": "正在连接…",
"connect_slow": "连接耗时较长,可点击「取消连接」或按 Esc 中断",
"run_started": "运行中({source})",
"run_stopped": "已停止",
"run_stopping": "正在停止…(正在释放设备)",
"run_stopping_cancelled": "已取消连接(正在释放设备)",
"run_failed": "启动失败: {error}",
"video_ended": "视频已结束",
"camera_disconnected": "摄像机已断开",
@@ -15,6 +15,8 @@
"plot_window_sec": 4.0,
"plot_fn_max": 20.0,
"plot_shear_max": 20.0,
"plot_fn_raw_max": 1000000.0,
"plot_shear_raw_max": 500000.0,
"image_view_mode": "arrows_pure",
"arrow_rotation_deg": 90,
"splitter_sizes": [340, 480, 400]
+68 -7
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
@@ -42,7 +43,9 @@ from orisys_studio.ui.force_plots import ForcePlotsPanel
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
from orisys_studio.ui.plot_settings_dialog import (
DEFAULT_PLOT_FN_MAX,
DEFAULT_PLOT_FN_RAW_MAX,
DEFAULT_PLOT_SHEAR_MAX,
DEFAULT_PLOT_SHEAR_RAW_MAX,
DEFAULT_PLOT_WINDOW_SEC,
PlotSettingsDialog,
)
@@ -61,6 +64,8 @@ MIN_PANEL_WIDTHS = {
"cal": 300,
}
SESSION_PHASES = ("idle", "connecting", "running", "error")
#: After this long in ``connecting``, tell the user that Stop is the way out.
CONNECT_SLOW_HINT_S = 8.0
def _bgr_to_qimage(frame: np.ndarray) -> QImage:
@@ -93,6 +98,8 @@ class StudioMainWindow(QMainWindow):
self._show_fps = bool(self.prefs.get("show_fps", False))
self._session_phase = "idle"
self._running = False
self._connect_started_at = 0.0
self._connect_slow_notified = False
self._preview = None
self._preview_timer = QTimer(self)
self._preview_timer.setInterval(33)
@@ -309,6 +316,11 @@ class StudioMainWindow(QMainWindow):
self.act_run.triggered.connect(self._on_run_shortcut)
self.view_menu.addAction(self.act_run)
self.act_stop = QAction(self)
self.act_stop.setShortcut("Esc")
self.act_stop.triggered.connect(self._on_stop_shortcut)
self.view_menu.addAction(self.act_stop)
self.act_preview = QAction(self)
self.act_preview.setShortcut("F6")
self.act_preview.triggered.connect(self._on_preview_shortcut)
@@ -421,6 +433,7 @@ class StudioMainWindow(QMainWindow):
self.act_plot_settings.setText(t("menu.plot_settings"))
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
self.act_run.setText(t("menu.run"))
self.act_stop.setText(t("menu.stop"))
self.act_preview.setText(t("menu.preview"))
self.act_reset.setText(t("menu.reset"))
self.theme_menu.setTitle(t("menu.theme"))
@@ -446,6 +459,7 @@ class StudioMainWindow(QMainWindow):
def _set_phase(self, phase: str) -> None:
"""Track the acquisition phase and reflect it in the status chip."""
self._session_phase = phase if phase in SESSION_PHASES else "idle"
self.toolbar.set_run_phase(self._session_phase)
self._refresh_status_chip()
def _refresh_status_chip(self) -> None:
@@ -513,16 +527,24 @@ class StudioMainWindow(QMainWindow):
window_sec=float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC),
fn_max=float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX),
shear_max=float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX),
fn_raw_max=float(
self.prefs.get("plot_fn_raw_max") or DEFAULT_PLOT_FN_RAW_MAX
),
shear_raw_max=float(
self.prefs.get("plot_shear_raw_max") or DEFAULT_PLOT_SHEAR_RAW_MAX
),
units=self.toolbar.units(),
)
if dialog.exec_() != PlotSettingsDialog.Accepted:
return
window_sec, fn_max, shear_max = dialog.values()
window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values()
self.prefs["plot_window_sec"] = window_sec
self.prefs["plot_fn_max"] = fn_max
self.prefs["plot_shear_max"] = shear_max
self.prefs["plot_fn_raw_max"] = fn_raw_max
self.prefs["plot_shear_raw_max"] = shear_raw_max
self.force_plots.set_window_sec(window_sec)
self.force_plots.set_axis_limits(fn_max, shear_max)
self.force_plots.set_axis_limits(fn_max, shear_max, fn_raw_max, shear_raw_max)
self._last_plot_packet_key = None
self._persist_prefs()
@@ -533,6 +555,8 @@ class StudioMainWindow(QMainWindow):
self.force_plots.set_axis_limits(
float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX),
float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX),
float(self.prefs.get("plot_fn_raw_max") or DEFAULT_PLOT_FN_RAW_MAX),
float(self.prefs.get("plot_shear_raw_max") or DEFAULT_PLOT_SHEAR_RAW_MAX),
)
self.force_plots.set_fixed_newton(self.toolbar.units() == "newton")
@@ -590,10 +614,19 @@ class StudioMainWindow(QMainWindow):
self._persist_prefs()
# ----------------------------------------------------------- shortcuts
@property
def _session_active(self) -> bool:
"""True until the worker has fully settled, not just until Stop was hit."""
if self._running:
return True
worker = self._active_worker
return worker is not None and worker.is_running
def _sync_shortcut_actions(self) -> None:
if getattr(self, "act_run", None) is None:
return
self.act_run.setEnabled(self.toolbar.btn_run.isEnabled())
self.act_stop.setEnabled(self._session_active)
self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled())
self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled())
@@ -602,6 +635,12 @@ class StudioMainWindow(QMainWindow):
return
self.toolbar.btn_run.setChecked(not self.toolbar.btn_run.isChecked())
def _on_stop_shortcut(self) -> None:
"""Esc must always be able to cancel a session, even a stuck connect."""
if not self._session_active:
return
self._stop_run(reason="user_stop")
def _on_preview_shortcut(self) -> None:
if not self.toolbar.btn_preview.isEnabled():
return
@@ -781,12 +820,16 @@ class StudioMainWindow(QMainWindow):
self._running = True
self._set_phase("connecting")
self._connect_started_at = time.perf_counter()
self._connect_slow_notified = False
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
self.cal_panel.set_running(True)
self.toolbar.set_source_controls_enabled(False)
self.sensor_picker.set_picker_enabled(False)
self.toolbar.set_reset_enabled(False)
self._run_timer.start()
# Keep the stop affordance consistent with the phase we just entered.
self._update_run_enabled()
self.statusBar().showMessage(t("status.connecting"))
logger.info(
"Connecting type=%s source=%s config=%s",
@@ -795,10 +838,13 @@ class StudioMainWindow(QMainWindow):
config_ref,
)
def _stop_run(self, *, reason: str = "user_stop") -> None:
def _stop_run(self, *, reason: str = "user_stop", wait: bool = False) -> None:
self._run_timer.stop()
if self._active_worker is not None:
self._active_worker.stop()
# Never wait here: a device open that hangs must not freeze the GUI.
# ``wait=True`` is reserved for shutdown paths.
self._active_worker.stop(wait=wait)
was_connecting = self._session_phase == "connecting"
self._running = False
self._set_phase("error" if reason == "failed" else "idle")
self.cal_panel.set_running(False)
@@ -812,14 +858,18 @@ class StudioMainWindow(QMainWindow):
self.arrow_panel.clear_preview(t("panels.arrow_empty"))
else:
self.arrow_panel.set_fps(None)
winding_down = self._session_active
if reason == "ended":
self.statusBar().showMessage(t("status.video_ended"))
elif reason == "disconnected":
self.statusBar().showMessage(t("status.camera_disconnected"))
elif reason == "failed":
pass # caller sets status
elif reason == "user_stop":
self.statusBar().showMessage(t("status.run_stopped"))
elif winding_down and was_connecting:
# The worker thread is still releasing the device in the background.
self.statusBar().showMessage(t("status.run_stopping_cancelled"))
elif winding_down:
self.statusBar().showMessage(t("status.run_stopping"))
else:
self.statusBar().showMessage(t("status.run_stopped"))
@@ -874,6 +924,13 @@ class StudioMainWindow(QMainWindow):
self._stop_run(reason="user_stop")
return
if not self._connect_slow_notified and self._session_phase == "connecting":
# The worker may be stuck opening a device; make sure the user knows
# the (non-blocking) Stop button is the way out.
if time.perf_counter() - self._connect_started_at >= CONNECT_SLOW_HINT_S:
self._connect_slow_notified = True
self.statusBar().showMessage(t("status.connect_slow"))
packet = self._active_worker.latest()
if packet is None:
return
@@ -882,6 +939,7 @@ class StudioMainWindow(QMainWindow):
if self._session_phase == "connecting" and is_new:
self._set_phase("running")
self._connect_slow_notified = True
self.toolbar.set_reset_enabled(True)
self._sync_shortcut_actions()
self.statusBar().showMessage(
@@ -987,6 +1045,8 @@ class StudioMainWindow(QMainWindow):
self.prefs.setdefault("plot_window_sec", DEFAULT_PLOT_WINDOW_SEC)
self.prefs.setdefault("plot_fn_max", DEFAULT_PLOT_FN_MAX)
self.prefs.setdefault("plot_shear_max", DEFAULT_PLOT_SHEAR_MAX)
self.prefs.setdefault("plot_fn_raw_max", DEFAULT_PLOT_FN_RAW_MAX)
self.prefs.setdefault("plot_shear_raw_max", DEFAULT_PLOT_SHEAR_RAW_MAX)
self.prefs["show_cal_panel"] = bool(self._show_cal)
self.prefs["show_finger_3d"] = bool(self._show_finger_3d)
self.prefs["show_fps"] = bool(self._show_fps)
@@ -996,7 +1056,8 @@ class StudioMainWindow(QMainWindow):
def closeEvent(self, event) -> None: # noqa: N802
self._stop_preview(announce=False)
self._stop_run(reason="user_stop")
# Blocking: on the way out we must not leave a device handle behind.
self._stop_run(reason="user_stop", wait=True)
self._persist_prefs()
logger.info("Studio shell closed; preferences saved")
super().closeEvent(event)
@@ -63,6 +63,8 @@ class ForcePlotsPanel(QFrame):
self._theme = theme
self._fn_max = 20.0
self._shear_max = 20.0
self._fn_raw_max = 1000000.0
self._shear_raw_max = 500000.0
self._fixed_newton = True
self._unit = "raw"
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
@@ -197,9 +199,19 @@ class ForcePlotsPanel(QFrame):
self._clear_curves()
self._apply_axis_mode()
def set_axis_limits(self, fn_max: float, shear_max: float) -> None:
def set_axis_limits(
self,
fn_max: float,
shear_max: float,
fn_raw_max: float | None = None,
shear_raw_max: float | None = None,
) -> None:
self._fn_max = max(0.1, float(fn_max))
self._shear_max = max(0.1, float(shear_max))
if fn_raw_max is not None:
self._fn_raw_max = max(0.0, float(fn_raw_max))
if shear_raw_max is not None:
self._shear_raw_max = max(0.0, float(shear_raw_max))
self._apply_axis_mode()
def set_fixed_newton(self, enabled: bool) -> None:
@@ -247,17 +259,36 @@ class ForcePlotsPanel(QFrame):
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
def _uses_fixed_y(self) -> bool:
return bool(self._fixed_newton) and self._unit == "N"
"""Fixed Y whenever we have limits for the active unit.
Raw mode used to auto-range, which collapsed the axis onto the idle
jitter and then rescaled on every contact. A fixed raw range keeps the
baseline visually flat: the raw maxima are sized so the idle jitter is
well under one pixel.
"""
if self._unit == "N":
return bool(self._fixed_newton)
return self._fn_raw_max > 0.0 and self._shear_raw_max > 0.0
def _fixed_y_range(self, channel_index: int) -> tuple[float, float]:
if self._unit == "N":
fn_max, shear_max = self._fn_max, self._shear_max
else:
fn_max, shear_max = self._fn_raw_max, self._shear_raw_max
if channel_index == 0:
return 0.0, self._fn_max
return -self._shear_max, self._shear_max
return 0.0, fn_max
return -shear_max, shear_max
def _preview_fixed_y(self) -> bool:
"""Whether to paint fixed axes before any sample has arrived."""
if self.history.sample_count > 0:
return self._uses_fixed_y()
if self._unit == "N":
return bool(self._fixed_newton)
return True
def _apply_axis_mode(self) -> None:
# Preview fixed newton axes when newton mode is selected, even before samples.
preview_fixed = bool(self._fixed_newton)
use_fixed = self._uses_fixed_y() or (preview_fixed and self.history.sample_count == 0)
use_fixed = self._preview_fixed_y()
for i, plot in enumerate(self.plots):
if use_fixed:
ymin, ymax = self._fixed_y_range(i)
@@ -18,6 +18,10 @@ from orisys_app_shared.i18n import t
DEFAULT_PLOT_WINDOW_SEC = 4.0
DEFAULT_PLOT_FN_MAX = 20.0
DEFAULT_PLOT_SHEAR_MAX = 20.0
# Raw (uncalibrated) HHD units: sized from measured peaks so the idle jitter
# stays sub-pixel while a full contact still fits (see prefs store).
DEFAULT_PLOT_FN_RAW_MAX = 1000000.0
DEFAULT_PLOT_SHEAR_RAW_MAX = 500000.0
class PlotSettingsDialog(QDialog):
@@ -28,11 +32,13 @@ class PlotSettingsDialog(QDialog):
window_sec: float = DEFAULT_PLOT_WINDOW_SEC,
fn_max: float = DEFAULT_PLOT_FN_MAX,
shear_max: float = DEFAULT_PLOT_SHEAR_MAX,
fn_raw_max: float = DEFAULT_PLOT_FN_RAW_MAX,
shear_raw_max: float = DEFAULT_PLOT_SHEAR_RAW_MAX,
units: str = "newton",
) -> None:
super().__init__(parent)
self.setModal(True)
self.resize(360, 200)
self.resize(380, 260)
root = QVBoxLayout(self)
form = QFormLayout()
@@ -61,16 +67,40 @@ class PlotSettingsDialog(QDialog):
self.shear_max_spin.setSuffix(" N")
self.shear_max_spin.setValue(float(shear_max))
# Raw limits: 0 means "auto range on the data".
self.fn_raw_max_label = QLabel()
self.fn_raw_max_spin = QDoubleSpinBox()
self.fn_raw_max_spin.setRange(0.0, 1e9)
self.fn_raw_max_spin.setSingleStep(50000.0)
self.fn_raw_max_spin.setDecimals(0)
self.fn_raw_max_spin.setValue(float(fn_raw_max))
self.shear_raw_max_label = QLabel()
self.shear_raw_max_spin = QDoubleSpinBox()
self.shear_raw_max_spin.setRange(0.0, 1e9)
self.shear_raw_max_spin.setSingleStep(50000.0)
self.shear_raw_max_spin.setDecimals(0)
self.shear_raw_max_spin.setValue(float(shear_raw_max))
form.addRow(self.window_label, self.window_spin)
form.addRow(self.fn_max_label, self.fn_max_spin)
form.addRow(self.shear_max_label, self.shear_max_spin)
form.addRow(self.fn_raw_max_label, self.fn_raw_max_spin)
form.addRow(self.shear_raw_max_label, self.shear_raw_max_spin)
root.addLayout(form)
newton = str(units or "newton").lower().startswith("n")
self.fn_max_spin.setEnabled(newton)
self.shear_max_spin.setEnabled(newton)
self.fn_max_label.setEnabled(newton)
self.shear_max_label.setEnabled(newton)
raw_widgets = (
self.fn_raw_max_spin,
self.shear_raw_max_spin,
self.fn_raw_max_label,
self.shear_raw_max_label,
)
for widget in (self.fn_max_spin, self.shear_max_spin, self.fn_max_label,
self.shear_max_label):
widget.setEnabled(newton)
for widget in raw_widgets:
widget.setEnabled(not newton)
buttons_row = QHBoxLayout()
self.btn_reset = QPushButton()
@@ -85,17 +115,21 @@ class PlotSettingsDialog(QDialog):
self.retranslate()
def values(self) -> tuple[float, float, float]:
def values(self) -> tuple[float, float, float, float, float]:
return (
float(self.window_spin.value()),
float(self.fn_max_spin.value()),
float(self.shear_max_spin.value()),
float(self.fn_raw_max_spin.value()),
float(self.shear_raw_max_spin.value()),
)
def _reset_defaults(self) -> None:
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
self.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX)
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX)
self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_RAW_MAX)
def retranslate(self) -> None:
self.setWindowTitle(t("dialog.plot_settings_title"))
@@ -105,6 +139,10 @@ class PlotSettingsDialog(QDialog):
self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip"))
self.shear_max_label.setText(t("dialog.plot_shear_max"))
self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip"))
self.fn_raw_max_label.setText(t("dialog.plot_fn_raw_max"))
self.fn_raw_max_spin.setToolTip(t("dialog.plot_fn_raw_max_tip"))
self.shear_raw_max_label.setText(t("dialog.plot_shear_raw_max"))
self.shear_raw_max_spin.setToolTip(t("dialog.plot_shear_raw_max_tip"))
self.btn_reset.setText(t("dialog.reset_defaults"))
ok_btn = self.button_box.button(QDialogButtonBox.Ok)
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
+16 -2
View File
@@ -59,6 +59,7 @@ class ToolbarWidget(QWidget):
super().__init__(parent)
self._run_tooltip_reason = ""
self._run_available = True
self._run_phase = "idle"
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
@@ -244,6 +245,11 @@ class ToolbarWidget(QWidget):
self._run_tooltip_reason = str(reason or "")
self._apply_run_tooltip()
def set_run_phase(self, phase: str) -> None:
"""Track the session phase so Stop can advertise itself while connecting."""
self._run_phase = str(phase or "idle")
self.refresh_labels()
def active_source(self) -> str | int | None:
if self.source_mode() == "video":
path = self.video_path()
@@ -307,7 +313,12 @@ class ToolbarWidget(QWidget):
def _refresh_run_label(self) -> None:
if self.btn_run.isChecked():
self.btn_run.setText(t("toolbar.stop_label"))
# While connecting, cancel is the action the user actually needs —
# a device open can hang, and this is the way out of it.
if self._run_phase == "connecting":
self.btn_run.setText(t("toolbar.cancel_label"))
else:
self.btn_run.setText(t("toolbar.stop_label"))
else:
self.btn_run.setText(t("toolbar.run_label"))
self.btn_run.setObjectName(
@@ -319,7 +330,10 @@ class ToolbarWidget(QWidget):
def _apply_run_tooltip(self) -> None:
if self.btn_run.isChecked():
self.btn_run.setToolTip(t("toolbar.stop"))
if self._run_phase == "connecting":
self.btn_run.setToolTip(t("toolbar.cancel_tip"))
else:
self.btn_run.setToolTip(t("toolbar.stop"))
elif not self._run_available and self._run_tooltip_reason:
self.btn_run.setToolTip(self._run_tooltip_reason)
elif not self._run_available:
+46
View File
@@ -201,6 +201,52 @@ def test_toolbar_run_button_is_labelled(make_window):
assert window.toolbar.btn_run.text() != label
def test_stop_cancels_a_hung_connect(make_window):
"""Stop must stay live and take effect instantly while connecting."""
import time
import ui_check
from orisys_app_shared.i18n import t
window = _show(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
stuck = ui_check._StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window.toolbar.btn_run.setChecked(True)
assert window._session_phase == "connecting"
assert window.toolbar.btn_run.isEnabled(), "Stop is disabled while connecting"
assert window.toolbar.btn_run.text() == t("toolbar.cancel_label")
assert window.act_stop.isEnabled()
started = time.perf_counter()
window.toolbar.btn_run.setChecked(False)
elapsed = time.perf_counter() - started
assert elapsed < 0.5, f"Stop blocked the GUI for {elapsed:.2f}s"
assert not window._running
assert stuck.thread_still_owned, "Stop abandoned the still-running worker thread"
def test_escape_cancels_a_hung_connect(make_window):
import time
import ui_check
window = _show(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
stuck = ui_check._StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window.toolbar.btn_run.setChecked(True)
assert window.act_stop.shortcut().toString() == "Esc"
started = time.perf_counter()
window._on_stop_shortcut()
assert time.perf_counter() - started < 0.5
assert not window._running
def test_theme_switch_updates_panels(make_window):
window = _show(make_window(), (1400, 880))
window._set_theme("light")
+119
View File
@@ -15,6 +15,8 @@ from __future__ import annotations
import os
import sys
import threading
import time
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
@@ -45,6 +47,55 @@ def section(name: str) -> None:
print(f"\n== {name} ==")
class _StuckWorker:
"""A worker whose device open never returns.
Mirrors the real worker's non-blocking ``stop()`` contract so the window can
be exercised against the failure mode the Stop button exists to survive.
"""
def __init__(self) -> None:
self._thread: threading.Thread | None = None
self._end_reason: str | None = None
self.thread_still_owned = False
self.stop_blocked_for = 0.0
def start(self, _source, *, config_ref=None) -> None:
self._thread = threading.Thread(target=self._hang, daemon=True)
self._thread.start()
def _hang(self) -> None:
time.sleep(600.0)
@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
@property
def phase(self) -> str:
return "connecting"
def latest(self):
return None
def consume_end_reason(self):
reason, self._end_reason = self._end_reason, None
return reason
def reset(self) -> None:
return None
def stop(self, *, wait: bool = False) -> None:
self._end_reason = self._end_reason or "stopped"
started = time.perf_counter()
if wait and self._thread is not None and self._thread.is_alive():
self._thread.join(timeout=5.0)
if self._thread is None or not self._thread.is_alive():
self._thread = None
self.thread_still_owned = self._thread is not None
self.stop_blocked_for = time.perf_counter() - started
# --------------------------------------------------------------------------
# Palette rules
#
@@ -440,6 +491,74 @@ def main() -> int:
window.close()
app.processEvents()
section("stopping a hung connect")
stuck = _StuckWorker()
window = StudioMainWindow(
prefs={
"language": "zh_CN",
"theme": "dark",
"sensor_type": "box",
"source_mode": "camera",
}
)
window.resize(1440, 880)
window.show()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
app.processEvents()
check(not window.act_stop.isEnabled(), "Stop shortcut is live before a session")
window.toolbar.btn_run.setChecked(True)
app.processEvents()
check(window._session_phase == "connecting", "run did not enter the connecting phase")
check(
window.toolbar.btn_run.isEnabled(),
"Stop is disabled while connecting — a hung connect could not be cancelled",
)
check(window.toolbar.btn_run.isChecked(), "run button did not latch into the stop state")
check(
window.toolbar.btn_run.text() == t("toolbar.cancel_label"),
f"connecting label is not the cancel label: {window.toolbar.btn_run.text()!r}",
)
check(
window.toolbar.btn_run.toolTip() == t("toolbar.cancel_tip"),
"connecting tooltip does not explain how to cancel",
)
check(window.act_stop.isEnabled(), "Stop shortcut is disabled while connecting")
started = time.perf_counter()
window.toolbar.btn_run.setChecked(False)
elapsed = time.perf_counter() - started
check(elapsed < 0.5, f"Stop blocked the GUI for {elapsed:.2f}s on a hung connect")
check(not window._running, "session stayed active after Stop")
check(window._session_phase == "idle", "phase did not return to idle after Stop")
check(
stuck.thread_still_owned,
"Stop abandoned the still-running worker thread",
)
# The slow-connect hint must point at the escape hatch.
window.toolbar.btn_run.setChecked(True)
app.processEvents()
window._connect_started_at = time.perf_counter() - 3600.0
window._on_run_tick()
check(
window.statusBar().currentMessage() == t("status.connect_slow"),
f"no slow-connect hint: {window.statusBar().currentMessage()!r}",
)
escaped = time.perf_counter()
window._on_stop_shortcut()
check(
time.perf_counter() - escaped < 0.5,
"Esc did not cancel the hung connect immediately",
)
check(not window._running, "Esc left the session running")
window.close()
app.processEvents()
print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}")
for failure in FAILURES:
print(f" FAIL: {failure}")
+20 -1
View File
@@ -21,7 +21,7 @@ from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
REPO_ROOT = APP_ROOT.parents[1]
for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"):
for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src", APP_ROOT / "tools"):
if _path.is_dir() and str(_path) not in sys.path:
sys.path.insert(0, str(_path))
@@ -96,6 +96,12 @@ def main() -> int:
parser.add_argument("--size", default="1440x880", help="WxH window size")
parser.add_argument("--view", default="arrows_pure")
parser.add_argument("--demo", action="store_true", help="inject demo plot/table data")
parser.add_argument(
"--scene",
choices=("idle", "connecting"),
default="idle",
help="connecting renders the Stop/cancel state of a hung device open",
)
parser.add_argument("--out", required=True, help="output PNG path")
args = parser.parse_args()
@@ -135,6 +141,19 @@ def main() -> int:
for _ in range(4):
app.processEvents()
if args.scene == "connecting":
# A device open that never returns: the state the Stop button must
# survive. ``ui_check._StuckWorker`` drives the same window state.
from ui_check import _StuckWorker
stuck = _StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window.toolbar.btn_run.setChecked(True)
for _ in range(4):
app.processEvents()
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
ok = window.grab().save(str(out))