fixed label under 3d view
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,19 +87,22 @@ _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, (
|
||||||
f"{name}.json contains {sequence!r}: the file was decoded with the "
|
f"{name}.json contains {sequence!r}: the file was decoded with the "
|
||||||
"wrong encoding. Rewrite it with an explicit UTF-8 writer instead "
|
"wrong encoding. Rewrite it with an explicit UTF-8 writer instead "
|
||||||
"of a PowerShell Get-Content/Set-Content round trip."
|
"of a PowerShell Get-Content/Set-Content round trip."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_chinese_locale_actually_contains_chinese():
|
def test_chinese_locale_actually_contains_chinese():
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
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)})
|
keys = resolve_scene_keys(prefs)
|
||||||
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
|
|
||||||
described = keys.describe()
|
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 keys.verify_key_pem is not None
|
||||||
|
assert described["verify_key"] is True
|
||||||
assert CONTENT_KEY_HEX.encode() not in keys.verify_key_pem
|
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)
|
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):
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"
|
||||||
assert window.toolbar.btn_run.isEnabled(), "Stop is disabled while connecting"
|
try:
|
||||||
assert window.toolbar.btn_run.text() == t("toolbar.cancel_label")
|
if cancel_via == "toolbar":
|
||||||
assert window.act_stop.isEnabled()
|
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()
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user