Files
orisys-app/apps/studio/tests/test_scene3d_skeleton.py
T
ykcheungab ac4dcd643f feat(studio): 三维视图接入产品(阶段 4:界面、生命周期与记录隔离)
- What: `Scene3DView` 装配(widget/相机/渲染器/合成器/协调器/适配器),`asset_source`
  只查找并后台加载已编译的 `.oryscene` 包,`scene3d/prefs` 持久化并**修复**显示设置,
  `ui/mesh3d_panel` 用真实容器替换占位标签(无 GL/无包/包被拒/传感器不匹配各显示一行原因),
  `main.py` 在 `QApplication` 之前选定 GL profile,`app_window` 接线:3D 作为采集线程上的
  第二个 sink、`_on_run_tick` 驱动 `tick()`、视图切换与 `closeEvent` 释放、preferences 落盘。
- Why: 阶段 4 的验收要求"三维开关不影响采集/记录/力曲线",并把阶段 1 留下的
  "开/关三维 CSV 一致"这条补上;同时把"没有 context"从"看起来像在工作"变成可显示的终态。
- Verification: app 侧 test_scene3d_view 28 项、test_scene3d_gl_smoke 14 项(真实 GPU)、
  adapters 17 项、skeleton 4 项、stage1 24 项、coordinator 13 项、ui_smoke 86 项;
  shared 侧 contract 26 / assets 33 / package 19 / live 25;`ui_check.py` 566 项检查 0 失败
  (新增 "3D pane" 段 12 项);`locale_check.py` 302 个字面 key 0 缺失、中英各 377 key 对齐;
  记录隔离:同一 96 帧会话挂/不挂 3D 消费者,`forces.csv` 逐字节相同、有效样本数与记录问题数相同;
  生产者预算:合成器放慢到 50 ms 时 `submit_packet` p99 < 1 ms。
- Not in this change: 示意位移(`displacement_sensor_m`)仍未接入(需 SDK lift 与已确认尺度);
  ARM 板卡适配(按用户要求暂缓);包签名/AES-GCM 与长时性能报告(阶段 5);
  仓库中仍未提供 `.oryscene` 包(发布产物,且其单位与变换必须由人声明)。
2026-09-14 15:47:32 +08:00

171 lines
7.1 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 PyQt5.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 PyQt5.QtGui import QSurfaceFormat
from orisys_studio.scene3d.gl_backend import configure_surface_format
configure_surface_format("desktop33")
desktop = QSurfaceFormat.defaultFormat()
assert desktop.renderableType() == QSurfaceFormat.OpenGL
assert (desktop.majorVersion(), desktop.minorVersion()) == (3, 3)
assert desktop.profile() == QSurfaceFormat.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.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))
assert "flow_magnitude" in panel.legend_label.text()
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()