fixed label under 3d view

This commit is contained in:
ykcheungab
2026-09-15 21:50:37 +08:00
parent c2293d4d41
commit c538dbf074
7 changed files with 269 additions and 212 deletions
+70 -11
View File
@@ -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)
+97
View File
@@ -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)
+17 -19
View File
@@ -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():
+32 -15
View File
@@ -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
@@ -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):
+4 -30
View File
@@ -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))
+26 -107
View File
@@ -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