Files
orisys-app/apps/studio/tests/test_scene3d_view.py
T
ykcheungab 5cd92254e1 feat(studio-3d): 阶段 5 —— 发布保护(签名/可选加密/平台锁定)与性能长时报告
- What: 新增 `scene3d/protect.py`(封套 v2:Ed25519 签名 + 可选 AES-256-GCM,nonce/salt 每包唯一、
  元数据作 AAD、明文摘要进签名、KeyProvider 只问 get_content_key)与 `record/visualization3d.py`
  (session.json 的 visualization3d 块:写入版本化、读取时缺块=不支持);`package.py` 支持 v2
  (payload=plain 仅签名 / aes-gcm 签名+加密)并继续读 v1,新增解压比上限与"写入后自读回"校验;
  `contracts.py` 新增 PlatformLock/PlatformInfo/check_platform_lock、Sealing、platform_mismatch 诊断码;
  `Scene3DView` 在装配前查 OS/架构、拿到 capabilities 后查图形半区,违反即撤下场景并在面板显示原因;
  工具 `compile_scene.py` 新增 --sign-key/--write-verify-key/--encrypt-key-id/--key-file/--lock-from-probe,
  `validate_scene.py` 新增 --key-file/--key-id/--verify-key/--require-signature(发布门禁),
  新增 `bench_scene.py`(p50/p95 分阶段 + --cpu-only + --vertices N + --package)与
  `soak_scene.py`(RSS 四分位速率 + 队列上界 + 抽稀采样);两份报告落到 docs/architecture/。
- Why: 规格 §5.2 的保护目标是"提高复制成本"而不是"防提取",所以要的是签名(来源)+ 可选认证加密(机密性)
  + 密钥隔离,而不是自制加密或把 ZIP 当加密;§12 的"包保护/性能/长时"三行需要可复现的数字与判定标准,
  而不是一次性的观感。平台锁定还必须只降级三维:锁定不通过时二维照常。
- Verification: shared 全量通过(新增 test_scene3d_protect.py 35 项:封套往返/篡改/密钥/锁定/记录块/
  compile→validate 端到端发布流程;test_scene3d_package.py 20 项含 v1↔v2 兼容);apps/studio 全量通过
  (test_scene3d_view.py 30 项含锁定拒绝与 session.json 块);ui_check 566 项 0 失败;locale_check 0 缺失;
  真实资产端到端:1032 顶点编译出 signed+sealed+locked 包(56 927 B 密文),validate --require-signature 退出 0,
  改一个字节退出 2;性能报告(perf-report-windows-dev.json):1032 顶点 compose p50 0.50 ms(30 fps 上限内),
  20022 顶点/39480 三角形 compose p50 6.15 ms(约 33 ms 帧的 18%),采集线程 submit p95 < 0.4 ms(合成器被人为放慢到 50 ms);
  长时报告(soak-report-windows-dev.json):无窗口 10 min/60 fps 尾段 0.354 MB/min,GUI 4 min/30 fps 尾段 0.809 MB/min,
  队列上界(mailbox=1、单飞行合成)两者均成立。过程中修掉:GUI 进程里的合成器计时被 GIL/vsync 污染(98.8 ms vs 真值 6.1 ms)、
  跨线程读 last_stats 撕裂、key id 无 provider 时会静默写明文包、soak 工具自身记时间列表会无界增长、RSS 读取失败伪装成零增长、
  平台锁定不是终态诊断。
- Not in this change: ARM 板卡的能力报告与 2 小时门禁运行(按用户要求暂缓;命令与判定标准已写入阶段 5 文档);
  GPU 显存随时间的测量(无可移植 API,报告说明渲染器缓冲估计恒定且更新路径不新建 GPU 对象);
  `cryptography` 在此环境无法联网安装,本机验收用的是本机已有的 cp310 构建(pyproject 里已声明为可选 extra
  `protected-assets`)。
2026-09-14 16:44:33 +08:00

624 lines
24 KiB
Python

"""Stage 4 acceptance: the 3D pane in the product.
Four things are being checked here, all of them about integration rather than about
mathematics (the camera, the sampler and the composer have their own files):
* **the pane degrades into a sentence.** No GL context, no package, a refused
package, the wrong sensor type: each one is a line of text in the operator's
language, and every one of them leaves the two-dimensional application alone.
* **the pane is one state with two controls.** The View menu entry and the checkbox
inside the pane are the same flag, and toggling them repeatedly does not drift or
leak.
* **the record is not affected.** The 3D sink runs on the acquisition thread next to
the record sink, so the same session is run twice — once with a consumer attached
to the pane, once without — and the CSV bytes and the valid-sample count must be
identical. This is the stage-1 note ("开/关三维的 CSV 行序一致") being paid off.
* **the saved settings are repaired, not trusted.** A preferences file is editable by
hand; it must not be able to make the window fail to open or make a stale file
relabel a live measurement.
"""
from __future__ import annotations
import itertools
import shutil
from dataclasses import replace
from pathlib import Path
import numpy as np
import pytest
import ui_check
from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import (
ColorScale,
DisplaySettings,
FieldDescriptor,
UNAVAILABLE_DESCRIPTOR,
)
from orisys_app_shared.scene3d.package import write_package
from orisys_studio.scene3d import prefs as scene3d_prefs
from orisys_studio.scene3d.asset_source import (
PACKAGE_SUFFIX,
SceneSource,
find_package,
load_package,
no_asset_diagnostic,
)
from tests.test_ui_smoke import _finish, _running_window
#: 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
def write_poc(tmp_path):
"""A real package on disk, produced by the shipping writer."""
def _write(name: str = "finger-display-1.0.0") -> "object":
path = tmp_path / f"{name}{PACKAGE_SUFFIX}"
write_package(
synthetic.poc_scene(),
path,
sensor_model="finger",
hardware_revisions=("*",),
)
return path
return _write
def _pump(qt_app, predicate, *, timeout_s: float = 10.0) -> bool:
"""Let Qt deliver queued signals until ``predicate`` holds."""
import time
deadline = time.perf_counter() + timeout_s
while time.perf_counter() < deadline:
qt_app.processEvents()
if predicate():
return True
time.sleep(0.01)
return False
# ------------------------------------------------------------------- the pane
def test_a_box_sensor_reports_that_it_has_no_3d_scene(qt_app):
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
panel = Mesh3dPanel()
try:
panel.set_sensor("box")
assert panel.showing_message
assert panel.view is None, "a box sensor must not build a GL widget at all"
assert "3D" in panel.message.text() or "三维" in panel.message.text()
finally:
panel.deleteLater()
def test_a_missing_package_names_where_it_looked(tmp_path, qt_app):
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
panel = Mesh3dPanel()
try:
panel.set_sensor("finger", package_override=tmp_path / "nope.oryscene")
assert panel.showing_message
diagnostic = panel.last_diagnostic
assert diagnostic is not None and diagnostic.code == "asset_rejected"
assert "directory" in diagnostic.details
assert "compile_scene.py" in diagnostic.details["compile"]
assert panel.message.text(), "the pane showed an empty line instead of the reason"
finally:
panel.deleteLater()
def test_the_view_is_built_once_and_only_for_a_finger(qt_app, write_poc):
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
panel = Mesh3dPanel()
try:
panel.set_sensor("box")
assert panel.ensure_view() is None
panel.set_sensor("finger", package_override=write_poc())
first = panel.ensure_view()
second = panel.ensure_view()
assert first is not None and first is second, "the pane rebuilt its GL widget"
assert panel.scene_path() is not None
finally:
panel.close_view()
panel.deleteLater()
def test_the_pane_fits_the_width_the_window_reserves_for_it(qt_app):
"""A pane whose content demands more than its declared minimum clips itself."""
from orisys_studio.app_window import MIN_PANEL_WIDTHS
from orisys_studio.scene3d.view import Scene3DView
view = Scene3DView()
try:
wanted = view.minimumSizeHint().width()
assert wanted <= MIN_PANEL_WIDTHS["mesh3d"], (
f"the 3D pane needs {wanted} px but the window reserves only "
f"{MIN_PANEL_WIDTHS['mesh3d']}"
)
assert view.gl_widget is None or view.gl_widget.minimumHeight() >= 100, (
"the picture must keep a usable height when the pane is short"
)
finally:
view.close()
def test_a_platform_without_a_context_says_so_instead_of_showing_an_empty_view(qt_app):
"""The offscreen plugin cannot create a context; that must not look like "no data".
Qt reports this by never calling ``initializeGL``, so the widget asks Qt the one
question that has an answer — is there a context at all — on its first paint.
"""
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
panel = Mesh3dPanel()
try:
panel.set_sensor("finger")
view = panel.ensure_view()
if view is None:
pytest.skip("the pane did not build a view here")
# The pane has to be a shown window for Qt to lay out and paint its child: a
# child of a hidden parent is never painted, which is why the widget only
# learns that it has no context once something is actually on screen.
panel.resize(240, 400)
panel.show()
view.show()
qt_app.processEvents()
widget = view.gl_widget
for _ in range(5):
widget.repaint()
qt_app.processEvents()
if widget.capabilities() is not None:
pytest.skip("this platform has a real GL context")
assert widget.unavailable, "a context-less widget claimed to be usable"
assert panel.showing_message, "the pane kept showing an empty GL widget"
assert panel.message.text().strip(), "the pane showed an empty line"
finally:
panel.close_view()
panel.deleteLater()
# --------------------------------------------------------- one state, two controls
def test_the_pane_and_the_view_menu_are_the_same_flag(make_window):
window = make_window(sensor_type="finger")
try:
assert window.mesh3d_panel.enabled() == bool(window._show_finger_3d)
for _ in range(20):
window.act_finger_3d.setChecked(not window.act_finger_3d.isChecked())
assert window.mesh3d_panel.enabled() == bool(window._show_finger_3d)
assert window.act_finger_3d.isChecked() == bool(window._show_finger_3d)
# The pane's own checkbox is the other way in, and it drives the same flag.
view = window.mesh3d_panel.view
if view is not None and view.panel is not None:
view.panel.enable_check.setChecked(not window._show_finger_3d)
assert window.act_finger_3d.isChecked() == bool(window._show_finger_3d)
assert window.mesh3d_panel.enabled() == bool(window._show_finger_3d)
finally:
window.close()
def test_panel_controls_do_not_touch_the_algorithm_side(make_window):
"""The observation camera is display state; nothing else may move with it."""
window = make_window(sensor_type="finger")
try:
before = {
"units": window.toolbar.units(),
"image_view_mode": window.arrow_panel.view_mode(),
"arrow_rotation_deg": window.arrow_panel.rotation_deg(),
"view_preset": window._view_preset,
"sensor_type": window.sensor_picker.sensor_type(),
"splitter": list(window.content_splitter.sizes()),
"samples": window.session_store.count,
}
view = window.mesh3d_panel.view
if view is None:
pytest.skip("no 3D view on this platform")
camera = view._camera
start = np.asarray(camera.matrices((240, 180)).view, dtype=float)
camera.orbit((30.0, 10.0), (240, 180))
camera.pan((5.0, 5.0), (240, 180))
camera.zoom(2.0)
moved = np.asarray(camera.matrices((240, 180)).view, dtype=float)
assert not np.allclose(start, moved), "the camera did not react to the mouse"
# Still a rigid transform: no mirroring, no scale change.
rotation = moved[:3, :3]
assert np.linalg.det(rotation) == pytest.approx(1.0, abs=1e-6)
assert rotation @ rotation.T == pytest.approx(np.eye(3), abs=1e-6)
after = {
"units": window.toolbar.units(),
"image_view_mode": window.arrow_panel.view_mode(),
"arrow_rotation_deg": window.arrow_panel.rotation_deg(),
"view_preset": window._view_preset,
"sensor_type": window.sensor_picker.sensor_type(),
"splitter": list(window.content_splitter.sizes()),
"samples": window.session_store.count,
}
assert after == before
finally:
window.close()
# ------------------------------------------------------------------ record isolation
class _RecordingView:
"""A stand-in for the pane's view that records what it was handed.
Used to attach a *real* consumer to the real sink path on a machine with no GL:
what is being tested is whether the window's second sink can change the record,
not whether the adapter can compute a flow magnitude.
"""
def __init__(self) -> None:
self.packets: list = []
self.coordinator = None
def submit_packet(self, packet) -> None:
# Deliberately more work than the real one does per packet, so a leak into the
# record path would show up as a difference rather than as a timing artefact.
self.packets.append(packet)
frame = np.asarray(packet.raw_frame)
float(np.asarray(frame, dtype=np.float64).sum())
def render_stats(self):
return None
def _run_session(make_window, record_root, *, with_3d: bool):
"""One full session with the 3D sink attached, or with no pane at all."""
import orisys_studio.app_window as app_module
window, stuck = _running_window(make_window)
app_module.recordings_dir = lambda: record_root
view = None
if with_3d:
view = _RecordingView()
window.mesh3d_panel._view = view
else:
window.mesh3d_panel._view = None
window.toolbar.btn_record.setChecked(True)
for index in range(96):
stuck.emit(ui_check._fake_packet(index, mono_t=index / 30.0))
window._on_run_tick()
window.toolbar.btn_record.setChecked(False)
report = window._record_last_report
rows = ""
if report is not None and (report.directory / "forces.csv").is_file():
rows = (report.directory / "forces.csv").read_bytes().decode("utf-8")
counts = (window.session_store.count, len(list(window.session_store.iter_valid("raw"))))
_finish(window, stuck)
window.close()
return rows, counts, report, view
def test_the_3d_consumer_cannot_change_the_record(make_window, next_record_root):
"""The acceptance row: identical CSV and identical valid samples, 3D on or off."""
with_3d_csv, with_3d_counts, with_3d_report, view = _run_session(
make_window, next_record_root(), with_3d=True
)
without_csv, without_counts, without_report, _ = _run_session(
make_window, next_record_root(), with_3d=False
)
assert view is not None and len(view.packets) == 96, (
"the pane's sink did not receive the session's packets"
)
assert with_3d_csv, "the recording produced no forces.csv"
assert with_3d_csv == without_csv, (
"the record changed when a 3D consumer was attached to the same packets"
)
assert with_3d_counts == without_counts, (with_3d_counts, without_counts)
assert with_3d_report is not None and without_report is not None
assert len(with_3d_report.problems) == len(without_report.problems)
header = with_3d_csv.splitlines()[0]
assert "t_rel_s" in header and len(with_3d_csv.splitlines()) > 10
def test_the_3d_sink_is_registered_and_inert_when_the_pane_is_off(make_window, next_record_root):
"""A sink that has to be added and removed around a toggle is a state machine."""
import orisys_studio.app_window as app_module
window, stuck = _running_window(make_window)
app_module.recordings_dir = lambda: next_record_root()
try:
assert window._on_packet_recorded in stuck.sinks()
assert window._on_packet_for_3d in stuck.sinks(), (
"the 3D sink must be registered with the worker, not attached per toggle"
)
window.mesh3d_panel._view = None
for index in range(10):
stuck.emit(ui_check._fake_packet(index))
assert window.session_store.count == 10
assert window.mesh3d_panel.view is None
finally:
_finish(window, stuck)
window.close()
def test_repeated_closes_are_safe(make_window):
window = make_window(sensor_type="finger")
try:
window.mesh3d_panel.ensure_view()
window.mesh3d_panel.close_view()
window.mesh3d_panel.close_view()
window.close()
window.close()
finally:
pass
# ------------------------------------------------------------------ preferences
def test_the_display_settings_round_trip():
settings = DisplaySettings(
display_gain=12.0,
deform_enabled=True,
housing_visible=False,
max_fps=60,
color_scale=ColorScale(
descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
lower=0.25,
upper=4.5,
lut_id="sequential",
mode="fixed",
),
)
restored = scene3d_prefs.settings_from_prefs(scene3d_prefs.settings_to_prefs(settings))
assert restored.display_gain == 12.0
assert restored.deform_enabled is True
assert restored.housing_visible is False
assert restored.max_fps == 60
assert restored.color_scale is not None
assert (restored.color_scale.lower, restored.color_scale.upper) == (0.25, 4.5)
assert restored.color_scale.mode == "fixed"
def test_a_saved_descriptor_is_never_restored_as_a_measurement():
"""The colour bar describes the live field, not the last one that was on screen."""
block = scene3d_prefs.settings_to_prefs(
DisplaySettings(
color_scale=ColorScale(
descriptor=FieldDescriptor("displacement", "m", "calibrated", True),
lower=0.0,
upper=1.0,
lut_id="diverging",
mode="fixed",
)
)
)
restored = scene3d_prefs.settings_from_prefs(block)
assert restored.color_scale is not None
assert restored.color_scale.descriptor == UNAVAILABLE_DESCRIPTOR
given = FieldDescriptor("flow_magnitude", "px", "approximate", False)
restored_with = scene3d_prefs.settings_from_prefs(block, descriptor=given)
assert restored_with.color_scale.descriptor == given
@pytest.mark.parametrize(
"block",
[
None,
{},
{"display_gain": "lots", "max_fps": "fast", "deform_enabled": "maybe"},
{"display_gain": -5.0, "max_fps": 17},
{"display_gain": 1e9, "max_fps": 30},
{"color_scale": {"mode": "sideways", "lower": "a", "upper": "b"}},
{"color_scale": {"mode": "fixed", "lower": 5.0, "upper": 5.0}},
{"color_scale": {"mode": "fixed", "lower": float("nan"), "upper": float("inf")}},
{"color_scale": {"mode": "fixed", "lower": 1e300, "upper": 1e301}},
],
)
def test_a_hand_edited_settings_block_is_repaired(block):
settings = scene3d_prefs.settings_from_prefs(block)
assert isinstance(settings, DisplaySettings)
assert scene3d_prefs.MIN_GAIN <= settings.display_gain <= scene3d_prefs.MAX_GAIN
assert settings.max_fps in scene3d_prefs.FPS_CHOICES
if settings.color_scale is not None:
lower, upper = settings.color_scale.lower, settings.color_scale.upper
assert lower == lower and upper == upper, "a NaN survived the repair"
assert upper > lower
assert abs(lower) <= scene3d_prefs.MAX_SCALE_BOUND
assert abs(upper) <= scene3d_prefs.MAX_SCALE_BOUND
assert settings.color_scale.mode in scene3d_prefs.SCALE_MODES
def test_the_panel_offers_exactly_the_rates_the_repair_accepts(qt_app):
from orisys_studio.scene3d.panel import FPS_CHOICES
assert tuple(FPS_CHOICES) == tuple(scene3d_prefs.FPS_CHOICES)
# ------------------------------------------------------------------- asset source
def test_the_package_search_order_is_override_then_sensor_directory(tmp_path, write_poc):
explicit = write_poc("explicit")
sensor_dir = tmp_path / "sensors" / "finger" / "scene"
sensor_dir.mkdir(parents=True)
shipped = sensor_dir / f"shipped{PACKAGE_SUFFIX}"
shipped.write_bytes(explicit.read_bytes())
found = find_package("finger", override=explicit, root=tmp_path)
assert found is not None and found.path == explicit
found_dir = find_package("finger", root=tmp_path)
assert found_dir is not None and found_dir.path == shipped
assert find_package("box", root=tmp_path) is None
assert find_package("finger", override=tmp_path / "gone.oryscene", root=tmp_path) is not None, (
"a stale override must fall through to the shipped package, not disable 3D"
)
def test_a_package_is_loaded_off_the_gui_thread(qt_app, write_poc):
from orisys_studio.scene3d.asset_source import SceneAssetLoader
path = write_poc()
loader = SceneAssetLoader()
loaded: list = []
failed: list = []
loader.loaded.connect(lambda assets, description: loaded.append((assets, description)))
loader.failed.connect(failed.append)
try:
loader.load(SceneSource(path=path, kind="package", directory=path.parent))
assert _pump(qt_app, lambda: bool(loaded or failed)), "the loader never answered"
finally:
loader.close()
assert not failed, [item.message for item in failed]
assets, description = loaded[0]
assert assets.geometry_id == synthetic.poc_scene().geometry_id
assert str(path) in description
# A second load while one is in flight is the same request, not a queue.
loader2 = SceneAssetLoader()
try:
loader2._inflight = True
loader2.load(SceneSource(path=path, kind="package", directory=path.parent))
assert loader2._executor is None
finally:
loader2.close()
def test_a_corrupt_package_is_reported_not_raised(qt_app, tmp_path):
from orisys_studio.scene3d.asset_source import SceneAssetLoader
broken = tmp_path / f"broken{PACKAGE_SUFFIX}"
broken.write_bytes(b"not a zip archive at all")
loader = SceneAssetLoader()
failed: list = []
loader.failed.connect(failed.append)
try:
loader.load(SceneSource(path=broken, kind="package", directory=tmp_path))
assert _pump(qt_app, lambda: bool(failed)), "a corrupt package produced no answer"
finally:
loader.close()
assert failed[0].code in ("asset_rejected", "asset_auth_failed")
assert failed[0].message
def test_loading_a_missing_package_raises_a_diagnostic(tmp_path):
from orisys_app_shared.scene3d.contracts import ThreeDError
with pytest.raises(ThreeDError) as excinfo:
load_package(SceneSource(path=tmp_path / "gone.oryscene", kind="package", directory=tmp_path))
assert excinfo.value.diagnostic.code == "asset_rejected"
def test_the_missing_asset_message_is_not_localised_but_the_pane_is(tmp_path):
"""Diagnostics are logged and quoted in support tickets: they stay English."""
diagnostic = no_asset_diagnostic("finger")
assert diagnostic.code == "asset_rejected"
assert PACKAGE_SUFFIX in diagnostic.message
assert "compile_scene.py" in diagnostic.details["compile"]
def test_a_finger_scene_uses_the_finger_adapter(tmp_path, write_poc, qt_app):
"""The adapter is chosen by the asset, and a box scene without a device refuses."""
from orisys_studio.scene3d.view import Scene3DView
view = Scene3DView()
try:
if not view.available:
pytest.skip("no GL binding here")
assert view.use_scene(synthetic.poc_scene(), source_id="poc")
assert view.assets is not None
finally:
view.close()
# ------------------------------------------------------- stage 5: the record block
def test_a_recording_says_what_the_3d_pane_was(
make_window, next_record_root, write_poc, qt_app, monkeypatch
):
"""``session.json`` carries the pane's state, and an event carries the totals."""
import json
import orisys_studio.app_window as app_module
window, stuck = _running_window(make_window)
app_module.recordings_dir = lambda: next_record_root()
try:
# A real view on a machine with no GL is not possible; the stand-in gives the
# metadata assembly something to describe (an enabled pane with a scene).
view = _RecordingView()
view.assets = None
window.mesh3d_panel._view = view
window.toolbar.btn_record.setChecked(True)
for index in range(20):
stuck.emit(ui_check._fake_packet(index, mono_t=index / 30.0))
window._on_run_tick()
window.toolbar.btn_record.setChecked(False)
report = window._record_last_report
assert report is not None and report.completed or report is not None
if report is None or not report.completed:
pytest.skip("the writer had not finished on this machine")
meta = json.loads((report.directory / "session.json").read_text(encoding="utf-8"))
block = meta["visualization3d"]
assert block["block_version"] == 1
assert block["enabled"] is False, (
"the pane was not switched on, so the record must say so"
)
# The pane has no scene here (no package installed), so the block names the
# reason instead of pretending to describe a picture.
assert block.get("reason")
events = (report.directory / "events.csv").read_text(encoding="utf-8")
assert "visualization3d" in events, (
"the display totals were not recorded as an event"
)
finally:
_finish(window, stuck)
window.close()
def test_a_platform_locked_scene_is_refused_in_the_application(qt_app):
"""A lock violation is a *pane* failure: the view refuses the scene and says why."""
from orisys_app_shared.scene3d.contracts import PlatformInfo, PlatformLock
from orisys_studio.scene3d.view import Scene3DView
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
here = PlatformInfo.here()
elsewhere = "linux" if here.os_family != "linux" else "windows"
locked = replace(
synthetic.poc_scene(),
platform_lock=PlatformLock(os_family=(elsewhere,), machine=("aarch64",)),
)
view = Scene3DView()
try:
if not view.available:
pytest.skip("no GL binding here")
reported: list = []
view.diagnosticRaised.connect(reported.append)
assert view.use_scene(locked, source_id="locked") is False
assert view.assets is None, "a refused scene must not remain adopted"
assert any(item.code == "platform_mismatch" for item in reported), reported
finally:
view.close()
panel = Mesh3dPanel()
try:
# The pane shows the reason rather than an empty picture: `platform_mismatch`
# is one of the terminal diagnostics.
panel._on_diagnostic(
__import__(
"orisys_app_shared.scene3d.contracts", fromlist=["diagnostic"]
).diagnostic("platform_mismatch", "this package is not released for this OS")
)
assert panel.showing_message
assert "not released" in panel.message.text()
finally:
panel.deleteLater()