"""Stage 1 acceptance: a real GL context draws the scene, and survives its life. This file deliberately does **not** force ``QT_QPA_PLATFORM=offscreen``: the offscreen plugin cannot create a platform GL context at all ("this plugin does not support createPlatformOpenGLContext"), and the design document is explicit that an offscreen UI test cannot stand in for a GPU. Every test here skips itself when no real context can be obtained, and asserts on pixels otherwise. Run it with a real platform, e.g.:: QT_QPA_PLATFORM=windows python -m pytest tests/test_scene3d_gl_smoke.py Two rules the tests themselves have to respect, learned the hard way here: the widget is driven the way the application drives it (``show``/``repaint``, so Qt owns the context), and pixels are read through Qt's own ``grabFramebuffer`` — calling ``makeCurrent``/``initializeGL`` by hand creates the context *before* Qt does, and the GPU objects then belong to a context that is thrown away. """ from __future__ import annotations import os import numpy as np import pytest from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.contracts import ( ColorScale, DisplaySettings, FieldDescriptor, FrameIdentity, RenderFrame, freeze, ) pytestmark = pytest.mark.skipif( os.environ.get("QT_QPA_PLATFORM", "").lower() in ("offscreen", "minimal"), reason="the offscreen/minimal plugin cannot create a platform GL context", ) #: The renderer's clear colour in 8-bit, so "background" can be excluded from a #: pixel assertion instead of dominating it. BACKGROUND_RGB = np.array([15, 18, 23]) def _pump(qt_app, widget, predicate, *, timeout_s: float = 5.0) -> bool: """Let Qt paint until ``predicate`` holds. Returns whether it did.""" import time deadline = time.perf_counter() + timeout_s while time.perf_counter() < deadline: widget.repaint() qt_app.processEvents() if predicate(): return True time.sleep(0.01) return False @pytest.fixture(scope="module") def gl_widget(qt_app): """A real, shown QOpenGLWidget with the synthetic scene, driven by Qt.""" from orisys_studio.scene3d.camera import OrbitCamera from orisys_studio.scene3d.gl_backend import GLDispatch, GLRenderer, configure_surface_format from orisys_studio.scene3d.widget import Sensor3DWidget configure_surface_format("desktop33") renderer = GLRenderer() widget = Sensor3DWidget(renderer, OrbitCamera(), dispatch=GLDispatch()) failures: list = [] widget.diagnosticRaised.connect(failures.append) widget.resize(240, 180) widget.show() widget.set_scene(synthetic.poc_scene()) painted = _pump(qt_app, widget, lambda: bool(renderer.part_ids), timeout_s=8.0) if not painted: widget.deleteLater() pytest.skip( "no usable GL context here: " f"{widget.diagnostic() or 'the widget never initialized'}" ) yield widget, renderer, failures widget.release_resources() widget.close() widget.deleteLater() qt_app.processEvents() def _render(widget, qt_app) -> np.ndarray: """One Qt-driven paint, read back through Qt's own framebuffer grab. ``grabFramebuffer`` hands back a ``Format_RGB32`` image, whose bytes are BGRA on a little-endian machine, with a stride that can exceed the width — so the conversion and the stride trim are both load-bearing. """ from PyQt5.QtGui import QImage widget.repaint() qt_app.processEvents() image = widget.grabFramebuffer() assert not image.isNull(), "Qt returned no framebuffer" image = image.convertToFormat(QImage.Format_RGB888) width, height = image.width(), image.height() buffer = image.constBits().asarray(image.byteCount()) stride = image.bytesPerLine() rows = np.frombuffer(buffer, dtype=np.uint8).reshape(height, stride) return rows[:, : width * 3].reshape(height, width, 3).copy() def _drawn(pixels: np.ndarray) -> np.ndarray: """Pixels that are not the clear colour, as ints.""" flat = pixels.reshape(-1, 3).astype(int) background = np.abs(flat - BACKGROUND_RGB).max(axis=1) <= 3 return flat[~background] def _plate_is_grey(pixels: np.ndarray) -> float: """Mean |r-g| of the drawn pixels. The neutral path is grey, the LUT is not.""" drawn = _drawn(pixels) assert drawn.size, "nothing was drawn" return float(np.abs(drawn[:, 0] - drawn[:, 1]).mean()) def test_the_capabilities_come_from_a_real_context(gl_widget): widget, _, _ = gl_widget capabilities = widget.capabilities() assert capabilities is not None assert capabilities.vendor and capabilities.renderer assert capabilities.profile == "desktop33", "a desktop context must compile desktop shaders" assert not capabilities.software_renderer, "the dev machine must be driven by a GPU" assert capabilities.max_texture_size >= 1024 assert widget.viewport_pixels() == (240, 180) def test_both_parts_are_drawn_and_the_housing_can_be_hidden(gl_widget, qt_app): widget, renderer, _ = gl_widget assert set(renderer.part_ids) == {"sensing_surface", "housing"} with_housing = _drawn(_render(widget, qt_app)) assert with_housing.size, "nothing was drawn at all" widget.set_settings(DisplaySettings(housing_visible=False)) without_housing = _drawn(_render(widget, qt_app)) assert 0 < without_housing.size < with_housing.size, ( "hiding the housing did not reduce what is on screen " f"({without_housing.size} vs {with_housing.size} pixels)" ) widget.set_settings(DisplaySettings(housing_visible=True)) def test_the_rest_frame_is_drawn_neutral_not_as_measurement_zero(gl_widget, qt_app): """No measurement yet: the plate must not be coloured as if it had one. Checked as a difference: the neutral path is grey (r≈g), the colour map's low end is not. Counting "grey pixels" over the whole frame would just be counting the background. """ widget, _, _ = gl_widget assets = synthetic.poc_scene() count = int(assets.sensing_part().positions_sensor_m.shape[0]) widget.set_settings(DisplaySettings(housing_visible=False)) rest_grey = _plate_is_grey(_render(widget, qt_app)) frame = RenderFrame( identity=FrameIdentity("poc", 1, 5, 0.5, generation=1), asset_hash=assets.asset_hash, geometry_id=assets.geometry_id, sensing_part_id=assets.sensing_part().part_id, positions_sensor_m=assets.sensing_part().positions_sensor_m, normals_sensor=assets.sensing_part().normals_sensor, scalar=freeze(np.full(count, 0.05, dtype=np.float32), dtype=np.float32), valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_), scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False), ) widget.set_settings( DisplaySettings( housing_visible=False, color_scale=ColorScale( descriptor=frame.scalar_descriptor, lower=0.0, upper=1.0, lut_id="sequential", ), ) ) widget.present(frame) measured_grey = _plate_is_grey(_render(widget, qt_app)) assert rest_grey < 6.0, f"the unmeasured surface is not neutral (r-g={rest_grey:.1f})" assert measured_grey > rest_grey + 10.0, ( "the colour map made no difference: the invalid path is not being taken " f"(rest r-g={rest_grey:.1f}, measured r-g={measured_grey:.1f})" ) widget.set_settings(DisplaySettings(housing_visible=True)) def test_a_frame_updates_only_the_sensing_buffers(gl_widget, qt_app): widget, renderer, _ = gl_widget assets = synthetic.poc_scene() count = int(assets.sensing_part().positions_sensor_m.shape[0]) positions = np.asarray(assets.sensing_part().positions_sensor_m).copy() positions[:, 2] = 0.004 # a visible bump frame = RenderFrame( identity=FrameIdentity("poc", 1, 6, 0.6, generation=1), asset_hash=assets.asset_hash, geometry_id=assets.geometry_id, sensing_part_id=assets.sensing_part().part_id, positions_sensor_m=freeze(positions, dtype=np.float32), normals_sensor=assets.sensing_part().normals_sensor, scalar=freeze(np.linspace(0.0, 1.0, count).astype(np.float32), dtype=np.float32), valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_), scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False), ) widget.set_settings( DisplaySettings( color_scale=ColorScale( descriptor=frame.scalar_descriptor, lower=0.0, upper=1.0, lut_id="sequential" ) ) ) # The housing's GPU buffers must be untouched by a surface update. before = _buffer_names(renderer, "housing") widget.present(frame) assert _plate_is_grey(_render(widget, qt_app)) > 2.0, "the scalar was not colour-mapped" assert _buffer_names(renderer, "housing") == before, ( "a dynamic surface update rebuilt the static housing buffers" ) def _buffer_names(renderer, part_id: str) -> tuple[int, int, int]: part = renderer._parts[part_id] return (part.vao, part.vbo, part.ebo) def test_a_frame_from_another_geometry_is_refused_without_losing_the_view(gl_widget, qt_app): """A bad frame is refused; the scene stays usable and stops showing it.""" widget, _, failures = gl_widget count = 9 before = widget.rejected_frames frame = RenderFrame( identity=FrameIdentity("poc", 1, 2, 0.2, generation=1), asset_hash="other", geometry_id="some-other-geometry", sensing_part_id="sensing_surface", positions_sensor_m=freeze(np.zeros((count, 3), dtype=np.float32), dtype=np.float32), normals_sensor=freeze( np.tile([0.0, 0.0, 1.0], (count, 1)).astype(np.float32), dtype=np.float32 ), scalar=freeze(np.zeros(count, dtype=np.float32), dtype=np.float32), valid=freeze(np.zeros(count, dtype=np.bool_), dtype=np.bool_), scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False), ) widget.present(frame) assert _drawn(_render(widget, qt_app)).size, "the view died with a refused frame" assert widget.rejected_frames == before + 1 assert any(item.code == "geometry_mismatch" for item in failures), ( "a frame from another geometry was accepted" ) assert not widget.unavailable, "one bad frame must not take the whole view down" def test_resize_is_followed(gl_widget, qt_app): widget, _, _ = gl_widget widget.resize(320, 240) image = _render(widget, qt_app) assert (image.shape[1], image.shape[0]) == (320, 240), image.shape widget.resize(120, 90) image = _render(widget, qt_app) assert (image.shape[1], image.shape[0]) == (120, 90), image.shape widget.resize(240, 180) assert _drawn(_render(widget, qt_app)).size, "nothing was drawn after resizing back" def test_release_is_idempotent_and_a_new_context_gets_the_scene_back(gl_widget, qt_app): """Closing must free everything; a rebuilt context must get the scene again. A context rebuild is the case that matters: the GPU objects are gone while the CPU asset is still here, so "the parts are still uploaded" would be a lie and the window would come back empty. Qt calls ``initializeGL`` for a new context — re-parenting does *not* rebuild one on Qt 5.15 (measured), so the test drives that one call explicitly rather than pretending re-parenting did it. """ widget, renderer, _ = gl_widget widget.release_resources() assert not renderer.is_initialized widget.release_resources() # idempotent: no crash, still released assert not renderer.is_initialized assert not renderer.part_ids, "release left GPU buffers behind" # What Qt does when it hands the widget a new context. widget.makeCurrent() widget.initializeGL() widget.doneCurrent() recovered = _pump(qt_app, widget, lambda: bool(renderer.part_ids), timeout_s=8.0) assert recovered, "the scene did not come back for the new context" assert renderer.is_initialized assert _drawn(_render(widget, qt_app)).size, "the scene is not on screen again" def test_a_software_renderer_is_refused_with_its_capabilities(gl_widget, monkeypatch): """A board must not be signed off on llvmpipe, and the report has to say so.""" from orisys_studio.scene3d import gl_backend widget, _, _ = gl_widget monkeypatch.setattr(gl_backend, "is_software_renderer", lambda vendor, renderer: True) with pytest.raises(gl_backend.ThreeDUnsupported) as excinfo: gl_backend.GLDispatch().bind_current_context(widget.context()) capabilities = excinfo.value.capabilities assert capabilities.software_renderer is True assert any(item.code == "gl_unsupported" for item in capabilities.diagnostics) assert "software" in excinfo.value.diagnostic.message.lower() def test_a_broken_shader_takes_the_view_down_not_the_application(gl_widget, qt_app, monkeypatch): """Shader compilation failure is a diagnostic and an empty view, nothing more.""" from orisys_studio.scene3d import gl_backend from orisys_studio.scene3d.camera import OrbitCamera from orisys_studio.scene3d.widget import Sensor3DWidget widget, _, _ = gl_widget capabilities = widget.capabilities() assert capabilities is not None # The renderer refuses to come up, and says why. def broken_source(profile, program): return "#version 330 core\nvoid main() { this is not glsl }\n", "#version 330 core\n" monkeypatch.setattr(gl_backend, "shader_source", broken_source) renderer = gl_backend.GLRenderer() widget.makeCurrent() try: with pytest.raises(Exception) as excinfo: renderer.initialize(capabilities) finally: widget.doneCurrent() item = getattr(excinfo.value, "diagnostic", None) assert item is not None and item.code == "shader_compile_failed", excinfo.value assert not renderer.is_initialized, "a failed initialize must not claim to be up" # The widget turns the same failure into a state: a diagnostic, an empty view, # and no exception on the way out. monkeypatch.undo() class FailingRenderer(gl_backend.GLRenderer): def initialize(self, capabilities): raise gl_backend.error("shader_compile_failed", "forced for this test") failing = Sensor3DWidget(FailingRenderer(), OrbitCamera()) seen: list = [] failing.diagnosticRaised.connect(seen.append) failing.resize(120, 90) failing.show() failing.repaint() qt_app.processEvents() failing.repaint() # a second paint of a failed view must be a no-op qt_app.processEvents() assert failing.unavailable assert seen and seen[0].code == "shader_compile_failed" failing.close() failing.deleteLater() def test_the_probe_reports_this_machine_truthfully(): """The probe's contract: exit 0 only for a GPU-driven context.""" import importlib.util from pathlib import Path path = Path(__file__).resolve().parents[3] / "tools" / "scene_assets" / "probe_gl.py" spec = importlib.util.spec_from_file_location("probe_gl_tool", path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) report = module.collect("desktop33", keep_open_s=0.3) context = report.get("context") assert context and context["valid"], report assert context["profile_used"] == "desktop33" assert not context["software_renderer"], "the dev machine has a real GPU" assert report["renderer_initialized"] and report["released"]