From c538dbf074d5650b08fb1c566b8ce0e184d5dc0d Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Tue, 15 Sep 2026 21:50:37 +0800 Subject: [PATCH] fixed label under 3d view --- .../studio/src/orisys_studio/scene3d/panel.py | 81 +++++++++-- apps/studio/tests/conftest.py | 97 +++++++++++++ apps/studio/tests/test_locales.py | 36 +++-- apps/studio/tests/test_scene3d_pane_ui.py | 47 +++++-- .../tests/test_scene3d_protected_package.py | 53 +++---- apps/studio/tests/test_scene3d_view.py | 34 +---- apps/studio/tests/test_ui_smoke.py | 133 ++++-------------- 7 files changed, 269 insertions(+), 212 deletions(-) diff --git a/apps/studio/src/orisys_studio/scene3d/panel.py b/apps/studio/src/orisys_studio/scene3d/panel.py index 2eda914..65f958b 100644 --- a/apps/studio/src/orisys_studio/scene3d/panel.py +++ b/apps/studio/src/orisys_studio/scene3d/panel.py @@ -282,11 +282,22 @@ class ThreeDPanel(QFrame): controls.addStretch(1) # -------------------------------------------------------------------- legend - # What is on screen, in three short pieces: the ramp the shader used, the numbers - # it used it for, and which source produced the field. Reading the picture needs - # the first two, so they stay; nothing here is a setting. + # The ramp and its effective numeric bounds remain visible in normal mode. + # Everything explanatory below them follows the diagnostics switch. + scale_row = QHBoxLayout() + scale_row.setContentsMargins(0, 0, 0, 0) + scale_row.setSpacing(6) + self.scale_lower_label = QLabel() + self.scale_lower_label.setObjectName("panelMeta") + self.scale_lower_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + scale_row.addWidget(self.scale_lower_label) self.scale_bar = ColorScaleBar() - root.addWidget(self.scale_bar) + scale_row.addWidget(self.scale_bar, 1) + self.scale_upper_label = QLabel() + self.scale_upper_label.setObjectName("panelMeta") + self.scale_upper_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + scale_row.addWidget(self.scale_upper_label) + root.addLayout(scale_row) legend_row = QHBoxLayout() legend_row.setContentsMargins(0, 0, 0, 0) @@ -345,16 +356,20 @@ class ThreeDPanel(QFrame): def set_descriptor(self, descriptor: Optional[FieldDescriptor]) -> None: """The measurement currently on screen — or none, which hides the bar.""" + if descriptor != self._descriptor: + self._effective_scale = None self._descriptor = descriptor self._render_field_note() if descriptor is None or descriptor.quantity == "unavailable": # No measurement: do not leave the previous range on display. self.legend_label.setText(t("scene3d.legend_none")) - self.legend_label.setVisible(True) + self.legend_label.setVisible(self._show_diagnostics) self.scale_combo.setEnabled(False) self.lower_spin.setEnabled(False) self.upper_spin.setEnabled(False) self.scale_bar.setVisible(False) + self.scale_lower_label.setVisible(False) + self.scale_upper_label.setVisible(False) return self.scale_combo.setEnabled(True) fixed = self.scale_combo.currentData() == "fixed" @@ -364,6 +379,7 @@ class ThreeDPanel(QFrame): "diverging" if descriptor.signed and not fixed else "sequential" ) self.scale_bar.setVisible(True) + self._render_scale_bounds() self._render_legend() def set_capabilities(self, capabilities: Optional[SceneCapabilities]) -> None: @@ -410,6 +426,7 @@ class ThreeDPanel(QFrame): quality=quality, ) ) + self.legend_label.setVisible(self._show_diagnostics) return mode = t( "scene3d.legend_auto" @@ -429,6 +446,32 @@ class ThreeDPanel(QFrame): quality=quality, ) ) + self.legend_label.setVisible(self._show_diagnostics) + + def _render_scale_bounds(self) -> None: + """Show only the numeric bounds needed to read the color ramp.""" + descriptor = self._descriptor + visible = bool( + descriptor is not None and descriptor.quantity != "unavailable" + ) + lower = "" + upper = "" + mode = str(self.scale_combo.currentData() or "fixed") + effective = self._effective_scale + if visible and mode == "fixed": + lower = f"{self.lower_spin.value():.3g}" + upper = f"{self.upper_spin.value():.3g}" + elif ( + visible + and effective is not None + and effective.scale_mode == "automatic" + ): + lower = f"{effective.scale_lower:.3g}" + upper = f"{effective.scale_upper:.3g}" + self.scale_lower_label.setText(lower) + self.scale_upper_label.setText(upper) + self.scale_lower_label.setVisible(visible) + self.scale_upper_label.setVisible(visible) def _set_legend(self, text: str) -> None: """One legend line, with the whole text in the tooltip (it may be clipped).""" @@ -438,7 +481,7 @@ class ThreeDPanel(QFrame): def set_state(self, state: SceneState) -> None: self._state = state self.state_label.setText(t(f"scene3d.state_{state}")) - self.state_label.setVisible(state != "live") + self.state_label.setVisible(self._show_diagnostics and state != "live") def set_source_kind(self, kind: str) -> None: """Say where the field comes from: ``live``, ``replay`` or ``recompute``. @@ -459,6 +502,7 @@ class ThreeDPanel(QFrame): self._source_kind = kind self.source_label.setText(t(f"scene3d.source_{kind}")) self.source_label.setToolTip(t(f"scene3d.source_{kind}_tip")) + self.source_label.setVisible(self._show_diagnostics) def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None: """One diagnostic line. The 2D views stay usable behind it. @@ -484,13 +528,16 @@ class ThreeDPanel(QFrame): def set_diagnostics_visible(self, visible: bool) -> None: """How much the pane explains itself (see the window's 诊断信息 switch). - Off: the pane states what the picture *is* — the colour ramp, the quantity and - its range, where the field came from, and any warning about the measurement — - and stops explaining the pipeline around it. On: the same picture plus those - explanations, which is what an engineer asking "why is that frame blank" wants. + Off: only the colour ramp and its numeric bounds remain, except that warnings + and errors about the measurement are still shown. On: quantity, source, state, + substitutions and pipeline explanations are restored. """ self._show_diagnostics = bool(visible) self._render_field_note() + self.source_label.setVisible(self._show_diagnostics) + self.state_label.setVisible( + self._show_diagnostics and self._state != "live" + ) self.set_diagnostic(None) def diagnostics_visible(self) -> bool: @@ -506,7 +553,13 @@ class ThreeDPanel(QFrame): """ wanted = _FIELD_QUANTITY.get(self.field(), "") shown = "" if self._descriptor is None else str(self._descriptor.quantity) - if not wanted or not shown or shown == wanted or shown == "unavailable": + if ( + not self._show_diagnostics + or not wanted + or not shown + or shown == wanted + or shown == "unavailable" + ): self.field_note.setText("") self.field_note.setVisible(False) return @@ -528,6 +581,7 @@ class ThreeDPanel(QFrame): def set_render_stats(self, stats: RenderStats) -> None: """Display-side statistics only: never shown as recording health.""" self._effective_scale = stats + self._render_scale_bounds() self._render_legend() self.stats_label.setText( t( @@ -658,6 +712,7 @@ class ThreeDPanel(QFrame): def _emit_settings(self) -> None: if self._syncing: return + self._render_scale_bounds() self.settingsChanged.emit(self.settings()) def _legend_reset_note(self) -> None: @@ -686,5 +741,9 @@ class ThreeDPanel(QFrame): self.state_label.setText(t(f"scene3d.state_{self._state}")) self.source_label.setText(t(f"scene3d.source_{self._source_kind}")) self.source_label.setToolTip(t(f"scene3d.source_{self._source_kind}_tip")) + self.source_label.setVisible(self._show_diagnostics) + self.state_label.setVisible( + self._show_diagnostics and self._state != "live" + ) self.set_capabilities(self._capabilities) self.set_descriptor(self._descriptor) diff --git a/apps/studio/tests/conftest.py b/apps/studio/tests/conftest.py index cd0776b..3d2d2b7 100644 --- a/apps/studio/tests/conftest.py +++ b/apps/studio/tests/conftest.py @@ -7,7 +7,9 @@ whole session (Qt forbids creating more than one). from __future__ import annotations +import itertools import os +import shutil import sys from pathlib import Path @@ -77,3 +79,98 @@ def visible_descendants(widget): continue if child.isVisible(): yield child + + +def show_window(window, size): + """Show a window at ``size`` and flush pending Qt events.""" + from PyQt5.QtWidgets import QApplication + + window.resize(*size) + window.show() + QApplication.processEvents() + return window + + +def running_window(make_window): + """Return a running window backed by a controllable test worker.""" + import ui_check + + window = show_window( + 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._start_run() + window._run_timer.stop() + window._set_phase("running") + return window, stuck + + +def finish_run(window, stuck): + """Stop a test run and release its controllable worker.""" + window._stop_run(reason="user_stop") + stuck.release() + + +_RECORD_SCRATCH = ( + Path(__file__).resolve().parents[1] / "outputs" / "_test_record" +) +_RECORD_3D_SCRATCH = ( + Path(__file__).resolve().parents[1] / "outputs" / "_test_record_3d" +) + + +@pytest.fixture +def record_root(): + """A clean workspace-local directory for recording tests.""" + shutil.rmtree(_RECORD_SCRATCH, ignore_errors=True) + _RECORD_SCRATCH.mkdir(parents=True, exist_ok=True) + yield _RECORD_SCRATCH + shutil.rmtree(_RECORD_SCRATCH, ignore_errors=True) + + +@pytest.fixture +def next_record_root(): + """Create isolated recording directories for comparisons within one test.""" + shutil.rmtree(_RECORD_3D_SCRATCH, ignore_errors=True) + _RECORD_3D_SCRATCH.mkdir(parents=True, exist_ok=True) + counter = itertools.count() + + def _next() -> Path: + path = _RECORD_3D_SCRATCH / f"run{next(counter)}" + path.mkdir(parents=True, exist_ok=True) + return path + + yield _next + shutil.rmtree(_RECORD_3D_SCRATCH, ignore_errors=True) + + +@pytest.fixture +def replay_recording(record_root): + """One short finished recording (video, forces, frames, and metadata).""" + import ui_check + from orisys_app_shared.record import ExperimentRecorder, RecordConfig + + recorder = ExperimentRecorder( + RecordConfig(video=True, fps=30.0, segment_seconds=30.0) + ) + recording = recorder.start( + root=record_root, + session_id=1, + metadata={ + "device": { + "sensor_type": "box", + "config_ref": str(record_root / "sensor.json"), + "config_sha256": "recorded", + }, + "versions": {"sdk": "0.5.1"}, + "depth": {"stored": False, "replay": "recompute"}, + }, + ) + for index in range(60): + recorder.on_packet(ui_check._fake_packet(index)) + recorder.stop() + yield Path(recording) diff --git a/apps/studio/tests/test_locales.py b/apps/studio/tests/test_locales.py index 35a5052..1382d21 100644 --- a/apps/studio/tests/test_locales.py +++ b/apps/studio/tests/test_locales.py @@ -6,6 +6,8 @@ import json import re from pathlib import Path +import pytest + APP_ROOT = Path(__file__).resolve().parents[1] SRC_DIR = APP_ROOT / "src" LOCALES_DIR = APP_ROOT / "config" / "locales" @@ -69,13 +71,6 @@ def test_preference_defaults_match_store_defaults(): } -def test_locale_files_are_valid_utf8_json(): - for name in ("zh_CN", "en_US"): - data = _load_locale(name) - assert isinstance(data, dict) - assert data.get("app", {}).get("title") - - #: Sequences that only ever appear when UTF-8 text was decoded as a single-byte #: codepage and then re-encoded. A ``Get-Content -Raw | Set-Content`` round trip #: in Windows PowerShell does exactly that and silently mangles every non-ASCII @@ -92,19 +87,22 @@ _MOJIBAKE = ( ) -def test_locale_files_are_utf8_without_bom_and_not_mojibake(): - for name in ("zh_CN", "en_US"): - path = LOCALES_DIR / f"{name}.json" - raw = path.read_bytes() - assert not raw.startswith(b"\xef\xbb\xbf"), f"{name}.json starts with a BOM" - text = raw.decode("utf-8") # raises if it is not valid UTF-8 +@pytest.mark.parametrize("name", ("zh_CN", "en_US")) +def test_locale_file_is_valid_utf8_json_without_mojibake(name): + path = LOCALES_DIR / f"{name}.json" + raw = path.read_bytes() + assert not raw.startswith(b"\xef\xbb\xbf"), f"{name}.json starts with a BOM" + text = raw.decode("utf-8") # raises if it is not valid UTF-8 + data = json.loads(text) + assert isinstance(data, dict) + assert data.get("app", {}).get("title") - for sequence in _MOJIBAKE: - assert sequence not in text, ( - f"{name}.json contains {sequence!r}: the file was decoded with the " - "wrong encoding. Rewrite it with an explicit UTF-8 writer instead " - "of a PowerShell Get-Content/Set-Content round trip." - ) + for sequence in _MOJIBAKE: + assert sequence not in text, ( + f"{name}.json contains {sequence!r}: the file was decoded with the " + "wrong encoding. Rewrite it with an explicit UTF-8 writer instead " + "of a PowerShell Get-Content/Set-Content round trip." + ) def test_chinese_locale_actually_contains_chinese(): diff --git a/apps/studio/tests/test_scene3d_pane_ui.py b/apps/studio/tests/test_scene3d_pane_ui.py index 20921cf..0423731 100644 --- a/apps/studio/tests/test_scene3d_pane_ui.py +++ b/apps/studio/tests/test_scene3d_pane_ui.py @@ -40,12 +40,14 @@ def panel(qt_app): # --------------------------------------------------------------------- the pane -def test_the_pane_holds_a_legend_and_no_settings(qt_app, panel): +def test_the_pane_holds_a_legend_and_no_settings(panel): """What is on the pane has to be readable, not adjustable.""" # The readouts are part of the pane itself... assert panel.legend_label.parent() is panel assert panel.source_label.parent() is panel assert panel.scale_bar.parent() is panel + assert panel.scale_lower_label.parent() is panel + assert panel.scale_upper_label.parent() is panel # ...and every setting is in one block that the pane never shows. assert panel.controls_widget.parent() is panel assert not panel.controls_widget.isVisible() @@ -64,7 +66,7 @@ def test_the_pane_holds_a_legend_and_no_settings(qt_app, panel): assert panel.stats_label.parent() is panel.controls_widget -def test_the_settings_dialog_hosts_the_panes_own_controls(qt_app, panel): +def test_the_settings_dialog_hosts_the_panes_own_controls(panel): """One set of widgets either way: the dialog moves them, it does not copy them.""" from orisys_studio.scene3d.settings_dialog import Scene3dSettingsDialog @@ -96,6 +98,10 @@ def test_the_pane_header_offers_the_two_actions(qt_app): """A gear and a view reset: the two things that belong to *looking*.""" from orisys_studio.ui.mesh3d_panel import Mesh3dPanel + assert "reset_view" in icons.NAMES + for theme in ("dark", "light"): + assert not icons.icon("reset_view", theme).isNull() + pane = Mesh3dPanel(theme="dark") try: assert pane.btn_settings.toolTip() == t("panels.mesh3d_settings_tip") @@ -138,17 +144,10 @@ def test_the_pane_header_offers_the_two_actions(qt_app): finally: pane.deleteLater() - -def test_the_reset_view_icon_is_painted_from_the_theme(): - assert "reset_view" in icons.NAMES - for theme in ("dark", "light"): - assert not icons.icon("reset_view", theme).isNull() - - # --------------------------------------------------------------- which quantity -def test_the_pane_offers_the_two_quantities_and_says_which_one_it_drew(qt_app, panel): +def test_the_pane_offers_the_two_quantities_and_says_which_one_it_drew(panel): """The choice is the operator's; a frame that cannot honour it says so.""" from orisys_app_shared.scene3d.contracts import FieldDescriptor @@ -165,6 +164,7 @@ def test_the_pane_offers_the_two_quantities_and_says_which_one_it_drew(qt_app, p # The other one is drawn instead: the note names both, and the legend still shows # what is really on screen (the range is for the field that was drawn). + panel.set_diagnostics_visible(True) panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False)) note = panel.field_note.text() assert t("scene3d.field_depth") in note and t("scene3d.field_flow") in note, note @@ -184,12 +184,25 @@ def test_the_pane_offers_the_two_quantities_and_says_which_one_it_drew(qt_app, p assert panel.field() == "depth" -def test_the_diagnostics_switch_governs_what_the_pane_explains(qt_app, panel): +def test_the_diagnostics_switch_governs_what_the_pane_explains(panel): """Warnings are claims about the measurement and always show; info is explanation.""" - from orisys_app_shared.scene3d.contracts import diagnostic + from orisys_app_shared.scene3d.contracts import FieldDescriptor, RenderStats, diagnostic - panel.set_descriptor(None) + panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False)) + panel.set_render_stats( + RenderStats(scale_lower=0.25, scale_upper=2.5, scale_mode="automatic") + ) panel.set_diagnostics_visible(False) + assert not panel.scale_bar.isHidden() + assert panel.scale_lower_label.text() == "0.25" + assert panel.scale_upper_label.text() == "2.5" + assert not panel.scale_lower_label.isHidden() + assert not panel.scale_upper_label.isHidden() + assert panel.legend_label.isHidden() + assert panel.source_label.isHidden() + assert panel.state_label.isHidden() + assert panel.field_note.isHidden() + panel.set_diagnostic(diagnostic("field_stale", "the array is from another frame")) assert "another frame" in panel.legend_label.text(), ( "a warning about the measurement was hidden" @@ -204,13 +217,17 @@ def test_the_diagnostics_switch_governs_what_the_pane_explains(qt_app, panel): assert "not current" not in panel.legend_label.text(), ( "an informational line survived diagnostics being off" ) + assert panel.legend_label.isHidden() panel.set_diagnostics_visible(True) + assert not panel.legend_label.isHidden() + assert not panel.source_label.isHidden() + assert not panel.state_label.isHidden() panel.set_diagnostic(diagnostic("stale_frame", "the picture is not current")) assert "not current" in panel.legend_label.text() -def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(qt_app, make_window): +def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(make_window): """One line under the canvas: what the picture is, not how it was made.""" from PyQt5.QtWidgets import QApplication @@ -243,7 +260,7 @@ def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(qt_app, make_win # --------------------------------------------------------------------- the menu -def test_the_view_menu_opens_the_3d_settings(qt_app, make_window): +def test_the_view_menu_opens_the_3d_settings(make_window): """视图 ▸ 三维显示设置… is the way in that does not depend on the pane's header.""" from PyQt5.QtWidgets import QApplication diff --git a/apps/studio/tests/test_scene3d_protected_package.py b/apps/studio/tests/test_scene3d_protected_package.py index 0517cf8..c975444 100644 --- a/apps/studio/tests/test_scene3d_protected_package.py +++ b/apps/studio/tests/test_scene3d_protected_package.py @@ -71,47 +71,40 @@ def _source(path: Path) -> SceneSource: return SceneSource(path=path, kind="package", directory=path.parent) -def test_a_signed_package_opens_from_the_application(tmp_path): +@pytest.mark.parametrize("sealed", (False, True), ids=("signed", "sealed")) +def test_a_protected_package_opens_from_the_application(tmp_path, sealed): """The review's R4: the library could read it; the application could not. - With the verification key resolved from the preferences, the pane's own - ``load_package`` accepts the package and the scene comes out intact. + With keys resolved from preferences, the pane's own loader accepts both signed + and sealed release packages and returns their scene intact. """ scene = synthetic.poc_scene() - package, verify_path, _key_path = _write_signed(tmp_path, scene, sealed=False) + package, verify_path, key_path = _write_signed(tmp_path, scene, sealed=sealed) + prefs = {PREF_VERIFY_KEY: str(verify_path)} + if sealed: + prefs.update( + { + PREF_CONTENT_KEY_FILE: str(key_path), + PREF_CONTENT_KEY_ID: KEY_ID, + } + ) - keys = resolve_scene_keys({PREF_VERIFY_KEY: str(verify_path)}) - assert keys.verify_key_pem is not None - assert keys.describe()["verify_key"] is True - assets = load_package(_source(package), _expected(scene), keys) - assert assets.geometry_id == scene.geometry_id - # The manifest's own hash, recomputed when the package was written: the synthetic - # scene's ``asset_hash`` is a placeholder until it goes through ``write_package``. - assert len(assets.asset_hash) == 64 - - -def test_a_sealed_package_opens_with_the_content_key_from_the_preferences(tmp_path): - scene = synthetic.poc_scene() - package, verify_path, key_path = _write_signed(tmp_path, scene, sealed=True) - - keys = resolve_scene_keys( - { - PREF_VERIFY_KEY: str(verify_path), - PREF_CONTENT_KEY_FILE: str(key_path), - PREF_CONTENT_KEY_ID: KEY_ID, - } - ) - assert keys.provider is not None + keys = resolve_scene_keys(prefs) described = keys.describe() - assert described["content_key"] is True - assert described["content_key_id"] == KEY_ID - # The description carries paths and ids, never key material. - assert CONTENT_KEY_HEX not in json.dumps(described) assert keys.verify_key_pem is not None + assert described["verify_key"] is True assert CONTENT_KEY_HEX.encode() not in keys.verify_key_pem + if sealed: + assert keys.provider is not None + assert described["content_key"] is True + assert described["content_key_id"] == KEY_ID + # The description carries paths and ids, never key material. + assert CONTENT_KEY_HEX not in json.dumps(described) assets = load_package(_source(package), _expected(scene), keys) assert assets.geometry_id == scene.geometry_id + # The writer replaces the synthetic scene's placeholder with the manifest hash. + assert len(assets.asset_hash) == 64 def test_without_the_verification_key_a_signed_package_is_refused(tmp_path): diff --git a/apps/studio/tests/test_scene3d_view.py b/apps/studio/tests/test_scene3d_view.py index 3d8077d..8784fcc 100644 --- a/apps/studio/tests/test_scene3d_view.py +++ b/apps/studio/tests/test_scene3d_view.py @@ -20,8 +20,6 @@ mathematics (the camera, the sampler and the composer have their own files): from __future__ import annotations -import itertools -import shutil import time from concurrent.futures import Future from dataclasses import replace @@ -51,33 +49,12 @@ from orisys_studio.scene3d.asset_source import ( load_package, no_asset_diagnostic, ) -from tests.test_ui_smoke import ( # noqa: F401 (fixtures are imported by name) - _finish, - _running_window, - record_root, - replay_recording, +from tests.conftest import ( + finish_run as _finish, + running_window as _running_window, + show_window as _show, ) -#: Scratch root for the two-session comparison. One subdirectory per run, because -#: the point of the test is that two recordings are identical, not that one file -#: can be overwritten. -RECORD_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_record_3d" - - -@pytest.fixture -def next_record_root(): - shutil.rmtree(RECORD_SCRATCH, ignore_errors=True) - RECORD_SCRATCH.mkdir(parents=True, exist_ok=True) - counter = itertools.count() - - def _next() -> Path: - path = RECORD_SCRATCH / f"run{next(counter)}" - path.mkdir(parents=True, exist_ok=True) - return path - - yield _next - shutil.rmtree(RECORD_SCRATCH, ignore_errors=True) - @pytest.fixture def write_poc(tmp_path): @@ -1074,8 +1051,6 @@ def test_the_replay_view_shows_the_pane_and_says_where_the_field_comes_from( what a recompute pass produces from the video. Those are two different claims and the label has to distinguish them. """ - from tests.test_ui_smoke import _show - window = _show(make_window(sensor_type="finger"), (1440, 880)) try: assert window._load_recording(Path(replay_recording)) @@ -1138,7 +1113,6 @@ def test_every_run_gets_a_new_generation(make_window, qt_app): so the picture stays on the first run's last frame forever. """ from orisys_app_shared.scene3d.contracts import FrameIdentity - from tests.test_ui_smoke import _show import ui_check window = _show(make_window(sensor_type="finger"), (1440, 880)) diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index abe9557..095871a 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -9,20 +9,16 @@ import numpy as np import pytest import ui_check -from tests.conftest import visible_descendants +from tests.conftest import ( + finish_run as _finish, + running_window as _running_window, + show_window as _show, + visible_descendants, +) RESOLUTIONS = ((1280, 720), (1600, 900), (1920, 1080)) -def _show(window, size): - window.resize(*size) - window.show() - from PyQt5.QtWidgets import QApplication - - QApplication.processEvents() - return window - - @pytest.mark.parametrize(("theme", "sensor_type"), [ ("dark", "box"), ("light", "box"), @@ -211,8 +207,9 @@ 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.""" +@pytest.mark.parametrize("cancel_via", ("toolbar", "escape")) +def test_a_hung_connect_can_be_cancelled_immediately(make_window, cancel_via): + """Both cancellation paths stay responsive while a device is connecting.""" import time import ui_check @@ -226,35 +223,24 @@ def test_stop_cancels_a_hung_connect(make_window): 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() + try: + if cancel_via == "toolbar": + 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() + cancel = lambda: window.toolbar.btn_run.setChecked(False) + else: + assert window.act_stop.shortcut().toString() == "Esc" + cancel = window._on_stop_shortcut - 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 + started = time.perf_counter() + cancel() + elapsed = time.perf_counter() - started + assert elapsed < 0.5, f"{cancel_via} cancellation blocked for {elapsed:.2f}s" + assert not window._running + assert stuck.thread_still_owned, "Stop abandoned the still-running worker thread" + finally: + stuck.release() def test_theme_switch_updates_panels(make_window): @@ -581,31 +567,6 @@ def test_force_plots_render_demo_data(make_window): # --------------------------------------------------------------- record channel - - -def _running_window(make_window): - """A window in the ``running`` phase whose worker never opens a device. - - The Qt timer is stopped deliberately: these tests drive the display path by - calling ``_on_run_tick`` themselves, and a concurrent 16 ms timer would race - the assertions (and outlive the window on teardown). - """ - 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._start_run() - window._run_timer.stop() - window._set_phase("running") - return window, stuck - - -def _finish(window, stuck): - window._stop_run(reason="user_stop") - stuck.release() - - def test_record_channel_does_not_depend_on_the_ui_timer(make_window): window, stuck = _running_window(make_window) for index in range(90): @@ -965,48 +926,6 @@ def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window): # ------------------------------------------------------------- experiment record - -#: Recording scratch goes under the app's gitignored ``outputs`` tree rather than -#: the system temp directory, so the tests also run under a file sandbox that -#: only permits writes inside the workspace. -RECORD_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_record" - - -@pytest.fixture -def record_root(): - import shutil - - shutil.rmtree(RECORD_SCRATCH, ignore_errors=True) - RECORD_SCRATCH.mkdir(parents=True, exist_ok=True) - yield RECORD_SCRATCH - shutil.rmtree(RECORD_SCRATCH, ignore_errors=True) - - -@pytest.fixture -def replay_recording(record_root): - """One short finished recording (video + forces + frames + session.json).""" - from orisys_app_shared.record import ExperimentRecorder, RecordConfig - - recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0, segment_seconds=30.0)) - recording = recorder.start( - root=record_root, - session_id=1, - metadata={ - "device": { - "sensor_type": "box", - "config_ref": str(record_root / "sensor.json"), - "config_sha256": "recorded", - }, - "versions": {"sdk": "0.5.1"}, - "depth": {"stored": False, "replay": "recompute"}, - }, - ) - for index in range(60): - recorder.on_packet(ui_check._fake_packet(index)) - recorder.stop() - yield Path(recording) - - def test_recording_writes_the_documented_experiment_layout(make_window, record_root): import csv import json