Files
orisys-app/apps/studio/tests/test_scene3d_skeleton.py
T
2026-09-21 09:34:56 +08:00

175 lines
7.4 KiB
Python

"""The studio-side three-dimensional skeleton: importable, inert, Qt-safe.
Acceptance for stage 0 (``docs/architecture/studio-3d-stages.md``): the module
tree exists, it can be imported without PyOpenGL, and the one piece that must run
before ``QApplication`` — the surface format — selects a complete profile.
Nothing here renders. These tests are the floor a later stage must keep: the GL
binding is imported only once a real context exists, and the stubs say which stage
fills them in.
"""
from __future__ import annotations
import sys
import pytest
@pytest.fixture
def restore_surface_format():
"""The default surface format is process-wide; put it back afterwards."""
from PySide6.QtGui import QSurfaceFormat
previous = QSurfaceFormat.defaultFormat()
try:
yield
finally:
QSurfaceFormat.setDefaultFormat(previous)
def test_the_skeleton_does_not_import_a_gl_binding(qt_app):
"""PyOpenGL must be imported for the platform Qt chose, not at import time.
The binding decides GLX versus EGL when it first loads, so importing it while
merely constructing the module tree would freeze a guess. The test fails if a
future change adds a module-level ``import OpenGL``.
"""
for name in [name for name in sys.modules if name == "OpenGL" or name.startswith("OpenGL.")]:
del sys.modules[name]
import orisys_studio.scene3d.camera # noqa: F401
import orisys_studio.scene3d.coordinator # noqa: F401
import orisys_studio.scene3d.gl_backend # noqa: F401
import orisys_studio.scene3d.panel # noqa: F401
import orisys_studio.scene3d.widget # noqa: F401
loaded = [name for name in sys.modules if name == "OpenGL" or name.startswith("OpenGL.")]
assert loaded == [], f"importing the skeleton pulled in {loaded}"
def test_configure_surface_format_selects_a_complete_profile(qt_app, restore_surface_format):
from PySide6.QtGui import QSurfaceFormat
from orisys_studio.scene3d.gl_backend import configure_surface_format
configure_surface_format("desktop33")
desktop = QSurfaceFormat.defaultFormat()
assert desktop.renderableType() == QSurfaceFormat.RenderableType.OpenGL
assert (desktop.majorVersion(), desktop.minorVersion()) == (3, 3)
assert desktop.profile() == QSurfaceFormat.OpenGLContextProfile.CoreProfile
assert desktop.depthBufferSize() == 24
assert desktop.samples() == 0, "v1 does not pay for multisampling"
configure_surface_format("gles30")
embedded = QSurfaceFormat.defaultFormat()
assert embedded.renderableType() == QSurfaceFormat.RenderableType.OpenGLES
assert (embedded.majorVersion(), embedded.minorVersion()) == (3, 0)
with pytest.raises(ValueError):
configure_surface_format("vulkan") # type: ignore[arg-type]
def test_the_widget_holds_cpu_state_and_touches_no_context(qt_app):
"""Constructing the widget must not need a current context or a GPU."""
from orisys_app_shared.scene3d.contracts import DisplaySettings, FrameIdentity
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.gl_backend import GLRenderer
from orisys_studio.scene3d.widget import Sensor3DWidget
widget = Sensor3DWidget(GLRenderer(), OrbitCamera())
try:
assert isinstance(widget, Sensor3DWidget)
widget.set_settings(DisplaySettings(display_gain=3.0))
assert widget._settings.display_gain == 3.0
# Nothing has been presented yet, and no context was needed to get here.
assert widget._frame is None
finally:
widget.deleteLater()
def test_no_scene3d_entry_point_is_still_a_stub(qt_app):
"""The stage-0 floor, kept as a gate: nothing left says "not implemented".
Every stage of the plan replaces stubs with the real thing, and a stub that
survives its stage is worse than a missing feature: it looks implemented. This
fails if a ``raise NotImplementedError`` comes back into either scene3d package,
and it exercises the last entry points that used to be stubs (stage 4's panel)
so "implemented" is also checked by calling them.
"""
from pathlib import Path
import orisys_studio.scene3d
from orisys_app_shared.scene3d.contracts import (
CameraPreset,
DisplaySettings,
FieldDescriptor,
FrameIdentity,
RenderStats,
diagnostic,
)
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
roots = [Path(orisys_studio.scene3d.__file__).resolve().parent]
import orisys_app_shared.scene3d as shared_scene3d
roots.append(Path(shared_scene3d.__file__).resolve().parent)
for root in roots:
for path in sorted(root.rglob("*.py")):
text = path.read_text(encoding="utf-8")
assert "NotImplementedError" not in text or "__init__" in path.name, path
if path.name == "__init__.py":
# The package docstring may mention the word; code may not.
assert "raise NotImplementedError" not in text, path
camera = OrbitCamera()
widget = Sensor3DWidget(GLRenderer(), camera)
coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30) # type: ignore[arg-type]
panel = ThreeDPanel(coordinator, widget)
try:
# Stage 1 is real: the camera works with no context at all.
camera.set_preset(
CameraPreset("default", (0.0, 0.0, 1.0), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 0.7)
)
assert camera.matrices((100, 100)).view.shape == (4, 4)
# Stage 3 is real too: the coordinator's schedule exists with no Qt event.
assert coordinator.state == "disabled"
assert coordinator.generation == 0
assert coordinator.superseded_frames == 0
coordinator.close() # idempotent, and safe before any scene is active
# Stage 4: the panel is the operator's view of all of the above.
panel.set_state("ready")
assert panel.state_label.text()
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
# The legend states the operator-facing name, not the internal key: the key is
# what a recording carries, the label is what a person reads.
legend = panel.legend_label.text()
assert "flow_magnitude" not in legend, legend
assert "光流幅值" in legend or "Optical flow magnitude" in legend, legend
panel.set_descriptor(None)
panel.set_render_stats(RenderStats(displayed_frames=3, superseded_frames=1))
assert "3" in panel.stats_label.text()
panel.set_diagnostic(None)
panel.set_diagnostic(diagnostic("field_unavailable", "no field in this frame"))
assert "no field in this frame" in panel.legend_label.text()
panel.set_scene(None)
panel.set_enabled(False)
settings = panel.settings()
assert settings.max_fps == 30
assert settings.display_gain == 0.0
assert settings.housing_visible is True, (
"the panel's resting state and DisplaySettings' default must agree"
)
assert settings.color_scale is not None
assert settings.color_scale.mode == "automatic"
assert DisplaySettings(max_fps=30).max_fps == settings.max_fps
del FrameIdentity
finally:
widget.deleteLater()