360 lines
14 KiB
Python
360 lines
14 KiB
Python
"""The 3D pane's interface: a picture, one legend line, and a settings dialog.
|
|
|
|
The pane used to be half form fields, and that is what left the 3D view occupying a
|
|
corner of its own splitter slot. Everything configurable now lives in
|
|
``Scene3dSettingsDialog``; what stays under the picture is what *reading* it needs — the
|
|
colour ramp, the numbers it was drawn with, and which source produced the field.
|
|
|
|
These tests pin three things: the pane shows no settings, the dialog holds the **same**
|
|
control widgets the panel has always had (so the settings path did not fork), and the
|
|
view menu / the pane's header both reach that dialog.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from orisys_app_shared.i18n import t
|
|
from orisys_studio.ui import icons
|
|
from PySide6.QtCore import Signal
|
|
from PySide6.QtWidgets import QWidget
|
|
|
|
|
|
@pytest.fixture
|
|
def panel(qt_app):
|
|
"""A ``ThreeDPanel`` with the coordinator and widget it needs, and no scene."""
|
|
from orisys_studio.scene3d.camera import OrbitCamera
|
|
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
|
from orisys_studio.scene3d.gl_backend import GLRenderer
|
|
from orisys_studio.scene3d.panel import ThreeDPanel
|
|
from orisys_studio.scene3d.widget import Sensor3DWidget
|
|
|
|
widget = Sensor3DWidget(GLRenderer(), OrbitCamera())
|
|
coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30)
|
|
created = ThreeDPanel(coordinator, widget)
|
|
yield created
|
|
coordinator.close()
|
|
created.deleteLater()
|
|
|
|
|
|
# --------------------------------------------------------------------- the pane
|
|
|
|
|
|
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()
|
|
for control in (
|
|
panel.enable_check,
|
|
panel.preset_combo,
|
|
panel.housing_check,
|
|
panel.deform_check,
|
|
panel.gain_spin,
|
|
panel.flow_threshold_spin,
|
|
panel.depth_threshold_spin,
|
|
panel.scale_combo,
|
|
panel.depth_adaptive_check,
|
|
panel.fps_combo,
|
|
panel.reset_button,
|
|
):
|
|
assert control.parent() is panel.controls_widget, control
|
|
# The engineering counters went with the settings rather than under the picture.
|
|
assert panel.stats_label.parent() is panel.controls_widget
|
|
|
|
|
|
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
|
|
|
|
dialog = Scene3dSettingsDialog(panel)
|
|
try:
|
|
assert panel.controls_widget.parent() is dialog, (
|
|
"the dialog kept a second copy of the controls"
|
|
)
|
|
assert panel.controls_widget.isVisibleTo(dialog)
|
|
assert not dialog.isModal(), "the pane has to stay visible while tuning it"
|
|
assert dialog.windowTitle() == t("scene3d.settings_title")
|
|
|
|
# ...and they still work from there: the signal path is the panel's own.
|
|
seen: list[bool] = []
|
|
panel.enabledChanged.connect(seen.append)
|
|
panel.enable_check.setChecked(True)
|
|
assert seen == [True], "a control in the dialog stopped reaching the view"
|
|
|
|
# Closing hides it: the controls have to survive for the next time.
|
|
dialog.close()
|
|
assert not dialog.isVisible()
|
|
assert panel.enable_check.text(), "closing the dialog destroyed its controls"
|
|
assert panel.settings().max_fps == 30
|
|
assert panel.settings().depth_scale_adaptive is False
|
|
finally:
|
|
dialog.deleteLater()
|
|
|
|
|
|
def test_adaptive_depth_scale_round_trips_through_the_dialog(panel):
|
|
"""The per-frame colour bar is a saved setting, not a one-shot toggle."""
|
|
from orisys_app_shared.scene3d.contracts import DisplaySettings
|
|
|
|
panel.apply_settings(DisplaySettings(depth_scale_adaptive=True))
|
|
assert panel.depth_adaptive_check.isChecked()
|
|
assert panel.settings().depth_scale_adaptive is True
|
|
panel.apply_settings(DisplaySettings())
|
|
assert panel.settings().depth_scale_adaptive is False
|
|
|
|
|
|
def test_depth_threshold_round_trips_through_the_dialog(panel):
|
|
from orisys_app_shared.scene3d.contracts import DisplaySettings
|
|
|
|
panel.apply_settings(DisplaySettings(depth_threshold=0.025))
|
|
assert panel.depth_threshold_spin.value() == pytest.approx(0.025)
|
|
assert panel.settings().depth_threshold == pytest.approx(0.025)
|
|
|
|
|
|
def test_arrow_threshold_round_trips_and_feeds_the_shared_policy(panel):
|
|
"""The threshold the dialog writes is the one both views sample with."""
|
|
from orisys_app_shared.scene3d.contracts import DisplaySettings
|
|
from orisys_app_shared.scene3d.visual_policy import policy_for_settings
|
|
|
|
panel.apply_settings(
|
|
DisplaySettings(flow_on_px=0.9, flow_grid_spacing=12, max_arrows=200)
|
|
)
|
|
assert panel.flow_threshold_spin.value() == pytest.approx(0.9)
|
|
settings = panel.settings()
|
|
assert settings.flow_on_px == pytest.approx(0.9)
|
|
assert settings.flow_off_px == pytest.approx(0.8)
|
|
assert settings.flow_grid_spacing == 12
|
|
assert settings.max_arrows == 200
|
|
# A colour-scale click must not wipe the held grid or the typed threshold.
|
|
panel.housing_check.setChecked(True)
|
|
again = panel.settings()
|
|
assert again.flow_on_px == pytest.approx(0.9)
|
|
assert again.flow_grid_spacing == 12
|
|
assert policy_for_settings(again).flow_on_px == pytest.approx(0.9)
|
|
|
|
|
|
def test_depth_legend_follows_the_shared_2d_span_immediately(panel):
|
|
"""The 3D colour-bar numbers are the 2D span, not a lagged GL automatic range."""
|
|
from orisys_app_shared.scene3d.contracts import FieldDescriptor, RenderStats
|
|
|
|
panel.set_descriptor(
|
|
FieldDescriptor("depth_response", "1", "approximate", False)
|
|
)
|
|
panel.set_render_stats(
|
|
RenderStats(scale_lower=0.0, scale_upper=0.0489, scale_mode="automatic")
|
|
)
|
|
panel.set_shared_depth_scale((0.0, 0.029))
|
|
assert panel.scale_lower_label.text() == "0.000"
|
|
assert panel.scale_upper_label.text() == "0.029"
|
|
|
|
|
|
def test_source_kind_analysis_uses_its_own_label(panel):
|
|
"""A stored result is not labelled as live acquisition."""
|
|
panel.set_diagnostics_visible(True)
|
|
panel.set_source_kind("analysis")
|
|
assert panel.source_label.text() == t("scene3d.source_analysis")
|
|
assert panel.source_label.toolTip() == t("scene3d.source_analysis_tip")
|
|
|
|
|
|
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")
|
|
assert pane.btn_reset_view.toolTip() == t("panels.mesh3d_reset_tip")
|
|
assert not pane.btn_settings.icon().isNull()
|
|
assert not pane.btn_reset_view.icon().isNull()
|
|
|
|
# A box sensor has no scene, so there is nothing to configure and the pane says
|
|
# so instead of opening an empty dialog.
|
|
pane.set_sensor("box")
|
|
assert pane.open_settings() is False
|
|
assert pane.view is None
|
|
|
|
# Reset forwards to the view when there is one.
|
|
class _View(QWidget):
|
|
diagnosticRaised = Signal(object)
|
|
enabledChanged = Signal(bool)
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.resets = 0
|
|
self.available = True
|
|
|
|
def set_source_kind(self, kind: str) -> None:
|
|
self.source_kind = kind
|
|
|
|
def set_enabled(self, enabled: bool) -> None:
|
|
self.enabled = bool(enabled)
|
|
|
|
def restore_settings(self, settings) -> None:
|
|
self.settings_value = settings
|
|
|
|
def reset_view(self) -> None:
|
|
self.resets += 1
|
|
|
|
view = _View()
|
|
pane.attach_view(view) # type: ignore[arg-type]
|
|
pane.reset_view()
|
|
assert view.resets == 1
|
|
finally:
|
|
pane.deleteLater()
|
|
|
|
# --------------------------------------------------------------- which quantity
|
|
|
|
|
|
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
|
|
|
|
assert [panel.field_combo.itemData(i) for i in range(panel.field_combo.count())] == [
|
|
"depth",
|
|
"flow",
|
|
]
|
|
assert panel.field() == "depth", "the depth response is what the pane offers first"
|
|
assert panel.settings().field == "depth"
|
|
|
|
# The chosen quantity is drawn: no note.
|
|
panel.set_descriptor(FieldDescriptor("depth_response", "1", "approximate", False))
|
|
assert not panel.field_note.isVisible() or not panel.field_note.text()
|
|
|
|
# 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
|
|
assert "flow_magnitude" not in panel.legend_label.text()
|
|
|
|
# Choosing flow makes that frame the honoured one, and the note goes away.
|
|
index = panel.field_combo.findData("flow")
|
|
panel.field_combo.setCurrentIndex(index)
|
|
assert panel.settings().field == "flow"
|
|
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
|
|
assert not panel.field_note.isVisible() or not panel.field_note.text()
|
|
|
|
# A restored preference moves the combo (and therefore the adapter).
|
|
from orisys_app_shared.scene3d.contracts import DisplaySettings
|
|
|
|
panel.apply_settings(DisplaySettings(field="depth"))
|
|
assert panel.field() == "depth"
|
|
|
|
|
|
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 (
|
|
FieldDescriptor,
|
|
RenderStats,
|
|
diagnostic,
|
|
)
|
|
|
|
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.250"
|
|
assert panel.scale_upper_label.text() == "2.500"
|
|
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"
|
|
)
|
|
|
|
panel.set_diagnostic(diagnostic("geometry_unverified", "the scene was not compared"))
|
|
assert "not compared" in panel.legend_label.text(), (
|
|
"a claim about verification was hidden"
|
|
)
|
|
|
|
panel.set_diagnostic(diagnostic("stale_frame", "the picture is not current"))
|
|
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(make_window):
|
|
"""One line under the canvas: what the picture is, not how it was made."""
|
|
from PySide6.QtWidgets import QApplication
|
|
|
|
window = make_window()
|
|
window.show()
|
|
QApplication.processEvents()
|
|
try:
|
|
arrow = window.arrow_panel
|
|
arrow.set_plan(None)
|
|
arrow.set_diagnostics_visible(False)
|
|
assert not arrow.scale_label.isVisible()
|
|
assert not arrow.pipeline_label.isVisible()
|
|
assert not arrow.area_label.isVisible()
|
|
|
|
arrow.set_diagnostics_visible(True)
|
|
assert arrow.pipeline_label.isVisible() or not arrow.view_modes(), (
|
|
"the pipeline line came back"
|
|
)
|
|
|
|
# The window's switch is the same thing, and it is remembered.
|
|
window.act_show_diagnostics.setChecked(True)
|
|
QApplication.processEvents()
|
|
assert window.display_ctl.show_diagnostics is True
|
|
assert arrow.diagnostics_visible() is True
|
|
assert window.prefs["show_diagnostics"] is True
|
|
finally:
|
|
window.close()
|
|
|
|
|
|
# --------------------------------------------------------------------- the menu
|
|
|
|
|
|
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 PySide6.QtWidgets import QApplication
|
|
|
|
window = make_window(sensor_type="finger", show_finger_3d=True)
|
|
window.show()
|
|
QApplication.processEvents()
|
|
try:
|
|
assert window.act_scene3d_settings.text() == t("menu.scene3d_settings")
|
|
assert window.act_scene3d_settings in window.view_menu.actions()
|
|
|
|
view = window.mesh3d_panel.view
|
|
if view is None or not view.available:
|
|
# No GL here: the entry has to say why instead of doing nothing.
|
|
window.act_scene3d_settings.trigger()
|
|
QApplication.processEvents()
|
|
assert window.statusBar().currentMessage().strip()
|
|
return
|
|
window.mesh3d_panel.ensure_view()
|
|
window.act_scene3d_settings.trigger()
|
|
QApplication.processEvents()
|
|
dialog = window.mesh3d_panel.view.settings_dialog()
|
|
assert dialog is not None and dialog.isVisible()
|
|
dialog.hide()
|
|
finally:
|
|
window.close()
|