Files
orisys-app/apps/studio/tests/test_scene3d_skeleton.py
T
ykcheungab 0b309e7edf 三维显示骨架:可执行合同 + 模块签名 + 分阶段计划 - What: 按 docs/architecture/studio-3d-design.md 与 studio-3d-contracts.pyi 搭建骨架(阶段 0)。shared/src/orisys_app_shared/scene3d/:contracts.py 把 .pyi 的规格变成可执行代码——全部 DTO/枚举(FrameIdentity/FieldDescriptor/SurfaceFieldFrame/MeshPart/SurfaceBinding/CameraPreset/ExpectedGeometry/SceneAssets/ColorScale/DisplaySettings/VertexFields/RenderFrame/CameraMatrices/GLCapabilities/RenderStats/CompileOptions/CompileReport)、标准诊断码表(DIAGNOSTIC_SEVERITY,含 stale_frame=info、field_unavailable=warning 等)、ThreeDError、freeze()(拥有数据 + C 连续 + 只读)、五个校验器(validate_surface_frame/validate_binding/validate_mesh_part/validate_assets/validate_display_settings/validate_render_frame)、LatestFrameMailbox(容量 1、替换计数、锁内只换引用)、UNAVAILABLE_DESCRIPTOR/rest_descriptor() 与 summarize_parts(),以及全部 Protocol;该模块不 import Qt/OpenGL/SDK。assets.py/sampling.py/composer.py 为签名骨架(未实现处抛 NotImplementedError 并写明归属阶段)。apps/studio/src/orisys_studio/scene3d/:camera/adapters/coordinator/gl_backend/widget/panel 签名骨架,其中 configure_surface_format() 已实现(QApplication 之前调用,desktop33=GL3.3 Core / gles30=GLES3.0,深度 24、无 MSAA);模块级一律不 import GL 绑定,PyOpenGL 只在 GLDispatch.bind_current_context 里加载。tools/scene_assets/{probe_gl,validate_scene,compile_scene}.py 提供入口与参数,未实现时输出 not_implemented 并返回 2(避免被 CI 当成通过)。docs/architecture/studio-3d-stages.md:阶段 0-5 的产出、每阶段的验收矩阵、并行度与回归门。测试:shared/tests/test_scene3d_contracts.py 26 项(freeze 拥有数据、可写/非连续数组被拒、scalar 与 valid 形状一致、位移缺 quality 被拒、非有限 scalar 标为 valid 只报警不静默、绑定越界/-1 约定/权重和为 1、三角形索引越界、感知法线单位长度、恰好一个 sensing part、binding 必须属于 sensing part、设备几何/域/尺寸不符被拒、色标 lower<upper、邮箱替换计数与多线程不阻塞生产者、身份区分 source/generation、骨架可导入且不引入 PyOpenGL);apps/studio/tests/test_scene3d_skeleton.py 4 项(骨架不 import OpenGL、surface format 两个 profile、widget 构造不需要 context、各 stub 报出归属阶段)。 - Why: 用户要求"先从骨架搭起,然后计划功能实现顺序,最后逐阶段实现与验收"。骨架阶段的价值在于把接口变成可执行的(.pyi 本身无法被测试),并把"数组只读、单位与质量必须声明、近似位移不得当毫米、参考帧不得冒充测量、显示丢帧不得计入记录丢帧"这些容易在实现时被绕过的规则先固定下来;同时明确不 import Qt/OpenGL 的边界,使采集线程与 CI 能在无 GPU 环境导入它。 - Verification: shared 全量 187 passed(原 161 + 26);studio 95 passed(原 91 + 4);ui_check 554 项检查 0 失败;compileall 通过;probe_gl --profile desktop33 在未实现时返回 2 并说明将产出什么报告。 - Not in this change: 三维功能本身(阶段 1 起);app_window/main.py 未接线,骨架对现有应用完全惰性(无任何模块被应用导入)。
2026-09-14 14:32:38 +08:00

142 lines
5.7 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_the_stubs_name_the_stage_that_fills_them_in(qt_app):
from orisys_app_shared.scene3d.contracts import (
CameraPreset,
DisplaySettings,
FrameIdentity,
GLCapabilities,
RenderFrame,
SceneAssets,
)
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
from orisys_studio.scene3d.gl_backend import GLDispatch, GLRenderer
from orisys_studio.scene3d.widget import Sensor3DWidget
camera = OrbitCamera()
renderer = GLRenderer()
widget = Sensor3DWidget(renderer, camera)
coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30) # type: ignore[arg-type]
try:
assert coordinator.state == "disabled"
assert coordinator.generation == 0
assert coordinator.superseded_frames == 0 # the mailbox is real, not a stub
with pytest.raises(NotImplementedError) as camera_error:
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 "stage 1" in str(camera_error.value)
with pytest.raises(NotImplementedError) as gl_error:
GLDispatch().bind_current_context(object())
assert "stage 1" in str(gl_error.value)
with pytest.raises(NotImplementedError) as activate_error:
coordinator.activate(SceneAssets, "live-A") # type: ignore[arg-type]
assert "stage 3" in str(activate_error.value)
with pytest.raises(NotImplementedError) as settings_error:
coordinator.set_settings(DisplaySettings())
assert "stage 3" in str(settings_error.value)
with pytest.raises(NotImplementedError) as initialize_error:
renderer.initialize(GLCapabilities("desktop33", "", "", "", "", "", False, 0))
assert "stage 1" in str(initialize_error.value)
with pytest.raises(NotImplementedError) as update_error:
renderer.update_surface(None) # type: ignore[arg-type]
assert "stage 1" in str(update_error.value)
assert FrameIdentity("live-A", 1, 0, 0.0, 1).generation == 1
assert RenderFrame is not None and SceneAssets is not None
finally:
widget.deleteLater()