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) controls.addStretch(1)
# -------------------------------------------------------------------- legend # -------------------------------------------------------------------- legend
# What is on screen, in three short pieces: the ramp the shader used, the numbers # The ramp and its effective numeric bounds remain visible in normal mode.
# it used it for, and which source produced the field. Reading the picture needs # Everything explanatory below them follows the diagnostics switch.
# the first two, so they stay; nothing here is a setting. 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() 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 = QHBoxLayout()
legend_row.setContentsMargins(0, 0, 0, 0) legend_row.setContentsMargins(0, 0, 0, 0)
@@ -345,16 +356,20 @@ class ThreeDPanel(QFrame):
def set_descriptor(self, descriptor: Optional[FieldDescriptor]) -> None: def set_descriptor(self, descriptor: Optional[FieldDescriptor]) -> None:
"""The measurement currently on screen — or none, which hides the bar.""" """The measurement currently on screen — or none, which hides the bar."""
if descriptor != self._descriptor:
self._effective_scale = None
self._descriptor = descriptor self._descriptor = descriptor
self._render_field_note() self._render_field_note()
if descriptor is None or descriptor.quantity == "unavailable": if descriptor is None or descriptor.quantity == "unavailable":
# No measurement: do not leave the previous range on display. # No measurement: do not leave the previous range on display.
self.legend_label.setText(t("scene3d.legend_none")) 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.scale_combo.setEnabled(False)
self.lower_spin.setEnabled(False) self.lower_spin.setEnabled(False)
self.upper_spin.setEnabled(False) self.upper_spin.setEnabled(False)
self.scale_bar.setVisible(False) self.scale_bar.setVisible(False)
self.scale_lower_label.setVisible(False)
self.scale_upper_label.setVisible(False)
return return
self.scale_combo.setEnabled(True) self.scale_combo.setEnabled(True)
fixed = self.scale_combo.currentData() == "fixed" fixed = self.scale_combo.currentData() == "fixed"
@@ -364,6 +379,7 @@ class ThreeDPanel(QFrame):
"diverging" if descriptor.signed and not fixed else "sequential" "diverging" if descriptor.signed and not fixed else "sequential"
) )
self.scale_bar.setVisible(True) self.scale_bar.setVisible(True)
self._render_scale_bounds()
self._render_legend() self._render_legend()
def set_capabilities(self, capabilities: Optional[SceneCapabilities]) -> None: def set_capabilities(self, capabilities: Optional[SceneCapabilities]) -> None:
@@ -410,6 +426,7 @@ class ThreeDPanel(QFrame):
quality=quality, quality=quality,
) )
) )
self.legend_label.setVisible(self._show_diagnostics)
return return
mode = t( mode = t(
"scene3d.legend_auto" "scene3d.legend_auto"
@@ -429,6 +446,32 @@ class ThreeDPanel(QFrame):
quality=quality, 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: def _set_legend(self, text: str) -> None:
"""One legend line, with the whole text in the tooltip (it may be clipped).""" """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: def set_state(self, state: SceneState) -> None:
self._state = state self._state = state
self.state_label.setText(t(f"scene3d.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: def set_source_kind(self, kind: str) -> None:
"""Say where the field comes from: ``live``, ``replay`` or ``recompute``. """Say where the field comes from: ``live``, ``replay`` or ``recompute``.
@@ -459,6 +502,7 @@ class ThreeDPanel(QFrame):
self._source_kind = kind self._source_kind = kind
self.source_label.setText(t(f"scene3d.source_{kind}")) self.source_label.setText(t(f"scene3d.source_{kind}"))
self.source_label.setToolTip(t(f"scene3d.source_{kind}_tip")) 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: def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None:
"""One diagnostic line. The 2D views stay usable behind it. """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: def set_diagnostics_visible(self, visible: bool) -> None:
"""How much the pane explains itself (see the window's 诊断信息 switch). """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 Off: only the colour ramp and its numeric bounds remain, except that warnings
its range, where the field came from, and any warning about the measurement — and errors about the measurement are still shown. On: quantity, source, state,
and stops explaining the pipeline around it. On: the same picture plus those substitutions and pipeline explanations are restored.
explanations, which is what an engineer asking "why is that frame blank" wants.
""" """
self._show_diagnostics = bool(visible) self._show_diagnostics = bool(visible)
self._render_field_note() 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) self.set_diagnostic(None)
def diagnostics_visible(self) -> bool: def diagnostics_visible(self) -> bool:
@@ -506,7 +553,13 @@ class ThreeDPanel(QFrame):
""" """
wanted = _FIELD_QUANTITY.get(self.field(), "") wanted = _FIELD_QUANTITY.get(self.field(), "")
shown = "" if self._descriptor is None else str(self._descriptor.quantity) 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.setText("")
self.field_note.setVisible(False) self.field_note.setVisible(False)
return return
@@ -528,6 +581,7 @@ class ThreeDPanel(QFrame):
def set_render_stats(self, stats: RenderStats) -> None: def set_render_stats(self, stats: RenderStats) -> None:
"""Display-side statistics only: never shown as recording health.""" """Display-side statistics only: never shown as recording health."""
self._effective_scale = stats self._effective_scale = stats
self._render_scale_bounds()
self._render_legend() self._render_legend()
self.stats_label.setText( self.stats_label.setText(
t( t(
@@ -658,6 +712,7 @@ class ThreeDPanel(QFrame):
def _emit_settings(self) -> None: def _emit_settings(self) -> None:
if self._syncing: if self._syncing:
return return
self._render_scale_bounds()
self.settingsChanged.emit(self.settings()) self.settingsChanged.emit(self.settings())
def _legend_reset_note(self) -> None: def _legend_reset_note(self) -> None:
@@ -686,5 +741,9 @@ class ThreeDPanel(QFrame):
self.state_label.setText(t(f"scene3d.state_{self._state}")) self.state_label.setText(t(f"scene3d.state_{self._state}"))
self.source_label.setText(t(f"scene3d.source_{self._source_kind}")) 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.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_capabilities(self._capabilities)
self.set_descriptor(self._descriptor) self.set_descriptor(self._descriptor)
+97
View File
@@ -7,7 +7,9 @@ whole session (Qt forbids creating more than one).
from __future__ import annotations from __future__ import annotations
import itertools
import os import os
import shutil
import sys import sys
from pathlib import Path from pathlib import Path
@@ -77,3 +79,98 @@ def visible_descendants(widget):
continue continue
if child.isVisible(): if child.isVisible():
yield child 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)
+7 -9
View File
@@ -6,6 +6,8 @@ import json
import re import re
from pathlib import Path from pathlib import Path
import pytest
APP_ROOT = Path(__file__).resolve().parents[1] APP_ROOT = Path(__file__).resolve().parents[1]
SRC_DIR = APP_ROOT / "src" SRC_DIR = APP_ROOT / "src"
LOCALES_DIR = APP_ROOT / "config" / "locales" 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 #: 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 #: 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 #: in Windows PowerShell does exactly that and silently mangles every non-ASCII
@@ -92,12 +87,15 @@ _MOJIBAKE = (
) )
def test_locale_files_are_utf8_without_bom_and_not_mojibake(): @pytest.mark.parametrize("name", ("zh_CN", "en_US"))
for name in ("zh_CN", "en_US"): def test_locale_file_is_valid_utf8_json_without_mojibake(name):
path = LOCALES_DIR / f"{name}.json" path = LOCALES_DIR / f"{name}.json"
raw = path.read_bytes() raw = path.read_bytes()
assert not raw.startswith(b"\xef\xbb\xbf"), f"{name}.json starts with a BOM" 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 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: for sequence in _MOJIBAKE:
assert sequence not in text, ( assert sequence not in text, (
+32 -15
View File
@@ -40,12 +40,14 @@ def panel(qt_app):
# --------------------------------------------------------------------- the pane # --------------------------------------------------------------------- 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.""" """What is on the pane has to be readable, not adjustable."""
# The readouts are part of the pane itself... # The readouts are part of the pane itself...
assert panel.legend_label.parent() is panel assert panel.legend_label.parent() is panel
assert panel.source_label.parent() is panel assert panel.source_label.parent() is panel
assert panel.scale_bar.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. # ...and every setting is in one block that the pane never shows.
assert panel.controls_widget.parent() is panel assert panel.controls_widget.parent() is panel
assert not panel.controls_widget.isVisible() 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 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.""" """One set of widgets either way: the dialog moves them, it does not copy them."""
from orisys_studio.scene3d.settings_dialog import Scene3dSettingsDialog 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*.""" """A gear and a view reset: the two things that belong to *looking*."""
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel 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") pane = Mesh3dPanel(theme="dark")
try: try:
assert pane.btn_settings.toolTip() == t("panels.mesh3d_settings_tip") 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: finally:
pane.deleteLater() 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 # --------------------------------------------------------------- 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.""" """The choice is the operator's; a frame that cannot honour it says so."""
from orisys_app_shared.scene3d.contracts import FieldDescriptor 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 # 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). # 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)) panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
note = panel.field_note.text() note = panel.field_note.text()
assert t("scene3d.field_depth") in note and t("scene3d.field_flow") in note, note 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" 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.""" """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) 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")) panel.set_diagnostic(diagnostic("field_stale", "the array is from another frame"))
assert "another frame" in panel.legend_label.text(), ( assert "another frame" in panel.legend_label.text(), (
"a warning about the measurement was hidden" "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(), ( assert "not current" not in panel.legend_label.text(), (
"an informational line survived diagnostics being off" "an informational line survived diagnostics being off"
) )
assert panel.legend_label.isHidden()
panel.set_diagnostics_visible(True) 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")) panel.set_diagnostic(diagnostic("stale_frame", "the picture is not current"))
assert "not current" in panel.legend_label.text() 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.""" """One line under the canvas: what the picture is, not how it was made."""
from PyQt5.QtWidgets import QApplication 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 # --------------------------------------------------------------------- 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.""" """视图 ▸ 三维显示设置… is the way in that does not depend on the pane's header."""
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
@@ -71,47 +71,40 @@ def _source(path: Path) -> SceneSource:
return SceneSource(path=path, kind="package", directory=path.parent) 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. """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 With keys resolved from preferences, the pane's own loader accepts both signed
``load_package`` accepts the package and the scene comes out intact. and sealed release packages and returns their scene intact.
""" """
scene = synthetic.poc_scene() 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)}
keys = resolve_scene_keys({PREF_VERIFY_KEY: str(verify_path)}) if sealed:
assert keys.verify_key_pem is not None prefs.update(
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_FILE: str(key_path),
PREF_CONTENT_KEY_ID: KEY_ID, PREF_CONTENT_KEY_ID: KEY_ID,
} }
) )
assert keys.provider is not None
keys = resolve_scene_keys(prefs)
described = keys.describe() described = keys.describe()
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"] is True
assert described["content_key_id"] == KEY_ID assert described["content_key_id"] == KEY_ID
# The description carries paths and ids, never key material. # The description carries paths and ids, never key material.
assert CONTENT_KEY_HEX not in json.dumps(described) assert CONTENT_KEY_HEX not in json.dumps(described)
assert keys.verify_key_pem is not None
assert CONTENT_KEY_HEX.encode() not in keys.verify_key_pem
assets = load_package(_source(package), _expected(scene), keys) assets = load_package(_source(package), _expected(scene), keys)
assert assets.geometry_id == scene.geometry_id 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): 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 from __future__ import annotations
import itertools
import shutil
import time import time
from concurrent.futures import Future from concurrent.futures import Future
from dataclasses import replace from dataclasses import replace
@@ -51,33 +49,12 @@ from orisys_studio.scene3d.asset_source import (
load_package, load_package,
no_asset_diagnostic, no_asset_diagnostic,
) )
from tests.test_ui_smoke import ( # noqa: F401 (fixtures are imported by name) from tests.conftest import (
_finish, finish_run as _finish,
_running_window, running_window as _running_window,
record_root, show_window as _show,
replay_recording,
) )
#: 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 @pytest.fixture
def write_poc(tmp_path): 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 what a recompute pass produces from the video. Those are two different claims and
the label has to distinguish them. the label has to distinguish them.
""" """
from tests.test_ui_smoke import _show
window = _show(make_window(sensor_type="finger"), (1440, 880)) window = _show(make_window(sensor_type="finger"), (1440, 880))
try: try:
assert window._load_recording(Path(replay_recording)) 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. so the picture stays on the first run's last frame forever.
""" """
from orisys_app_shared.scene3d.contracts import FrameIdentity from orisys_app_shared.scene3d.contracts import FrameIdentity
from tests.test_ui_smoke import _show
import ui_check import ui_check
window = _show(make_window(sensor_type="finger"), (1440, 880)) window = _show(make_window(sensor_type="finger"), (1440, 880))
+19 -100
View File
@@ -9,20 +9,16 @@ import numpy as np
import pytest import pytest
import ui_check 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)) 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"), [ @pytest.mark.parametrize(("theme", "sensor_type"), [
("dark", "box"), ("dark", "box"),
("light", "box"), ("light", "box"),
@@ -211,8 +207,9 @@ def test_toolbar_run_button_is_labelled(make_window):
assert window.toolbar.btn_run.text() != label assert window.toolbar.btn_run.text() != label
def test_stop_cancels_a_hung_connect(make_window): @pytest.mark.parametrize("cancel_via", ("toolbar", "escape"))
"""Stop must stay live and take effect instantly while connecting.""" 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 time
import ui_check import ui_check
@@ -226,35 +223,24 @@ def test_stop_cancels_a_hung_connect(make_window):
window.toolbar.btn_run.setChecked(True) window.toolbar.btn_run.setChecked(True)
assert window._session_phase == "connecting" assert window._session_phase == "connecting"
try:
if cancel_via == "toolbar":
assert window.toolbar.btn_run.isEnabled(), "Stop is disabled while connecting" assert window.toolbar.btn_run.isEnabled(), "Stop is disabled while connecting"
assert window.toolbar.btn_run.text() == t("toolbar.cancel_label") assert window.toolbar.btn_run.text() == t("toolbar.cancel_label")
assert window.act_stop.isEnabled() 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() started = time.perf_counter()
window.toolbar.btn_run.setChecked(False) cancel()
elapsed = time.perf_counter() - started elapsed = time.perf_counter() - started
assert elapsed < 0.5, f"Stop blocked the GUI for {elapsed:.2f}s" assert elapsed < 0.5, f"{cancel_via} cancellation blocked for {elapsed:.2f}s"
assert not window._running assert not window._running
assert stuck.thread_still_owned, "Stop abandoned the still-running worker thread" assert stuck.thread_still_owned, "Stop abandoned the still-running worker thread"
finally:
stuck.release()
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): def test_theme_switch_updates_panels(make_window):
@@ -581,31 +567,6 @@ def test_force_plots_render_demo_data(make_window):
# --------------------------------------------------------------- record channel # --------------------------------------------------------------- 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): def test_record_channel_does_not_depend_on_the_ui_timer(make_window):
window, stuck = _running_window(make_window) window, stuck = _running_window(make_window)
for index in range(90): for index in range(90):
@@ -965,48 +926,6 @@ def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window):
# ------------------------------------------------------------- experiment record # ------------------------------------------------------------- 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): def test_recording_writes_the_documented_experiment_layout(make_window, record_root):
import csv import csv
import json import json