# Visual Testing SimVX provides headless Vulkan rendering and pixel-level assertions for automated visual testing. Tests run with a hidden window — no display required beyond a Vulkan driver. ## Testing Layers SimVX has three testing layers, each suited to different needs: | Layer | Module | GPU Required | What It Tests | |-------|--------|:---:|---| | **Logic** | `simvx.core.testing` | No | Game logic, node lifecycle, input actions | | **UI draw calls** | `simvx.core.ui.testing` | No | Widget layout, draw commands, focus/input | | **Visual rendering** | `simvx.graphics.testing` | Yes | Actual GPU output — shaders, z-order, colors | This page covers the visual rendering layer. ## Quick Start ```python from simvx.core import Node, Camera3D, MeshInstance3D, Material, Mesh, Vec3 from simvx.graphics import App from simvx.graphics.testing import assert_pixel, assert_not_blank class RedCube(Node): def ready(self): cam = Camera3D(position=Vec3(0, 2, 5)) cam.look_at(Vec3(0, 0, 0)) self.add_child(cam) cube = MeshInstance3D() cube.mesh = Mesh.cube(size=2.0) cube.material = Material(color=(1, 0, 0, 1), unlit=True) self.add_child(cube) app = App(width=320, height=240, visible=False) frames = app.run_headless(RedCube(), frames=3, capture_frames=[2]) frame = frames[0] # (240, 320, 4) uint8 RGBA assert_not_blank(frame) assert_pixel(frame, 160, 120, (238, 0, 0, 255), tolerance=20) # center is red ``` ## Headless Rendering ### `App.run_headless()` Runs the full engine pipeline (physics, process, draw, render) for a fixed number of frames with a hidden window, returning captured framebuffer contents as numpy arrays. ```python App.run_headless( root_node, *, frames: int = 1, on_frame: Callable[[int], None] | None = None, capture_frames: list[int] | None = None, ) -> list[np.ndarray] ``` **Parameters:** - **root_node** — Scene root (`Node` subclass). `ready()` is called before the first frame. - **frames** — Total number of frames to simulate and render. - **on_frame** — Called with the frame index before each frame. Use this to inject input between frames via `InputSimulator`. - **capture_frames** — Which frame indices to capture (0-based). `None` captures every frame. **Returns** a list of `(height, width, 4)` uint8 numpy arrays in RGBA order. Each call creates a fresh Vulkan context and tears it down on completion — no state leaks between runs. ### Hidden Window `App` accepts `visible=False` to create an invisible window: ```python app = App(width=640, height=480, visible=False) ``` This uses `GLFW_VISIBLE=FALSE` under the hood. The window still has a real Vulkan surface and swapchain — rendering is identical to a visible window. ### Frame Capture `Engine.capture_frame()` copies the last-rendered swapchain image to CPU memory. It handles the full Vulkan pipeline: barrier transition, image-to-buffer copy, BGRA-to-RGBA swizzle. The returned array is a detached copy — safe to store, compare, or save. ### Window Resize For testing resize behavior: ```python app = App(width=320, height=240, visible=False) # ... render some frames ... app.set_window_size(640, 480) # next frame will trigger swapchain recreation ``` ## Pixel Assertions All functions in `simvx.graphics.testing` operate on `(H, W, 4)` uint8 RGBA numpy arrays. No external dependencies. ### `assert_pixel(pixels, x, y, expected_rgba, tolerance=2)` Check a single pixel. Fails if any channel differs by more than `tolerance`. ```python assert_pixel(frame, 160, 120, (255, 0, 0, 255), tolerance=10) ``` ### `assert_not_blank(pixels)` Fails if every pixel is the same color. Catches "rendered nothing" bugs. ```python assert_not_blank(frame) ``` ### `assert_color_ratio(pixels, color, expected_ratio, tolerance=0.02, color_tolerance=10)` Assert that approximately `expected_ratio` of all pixels match `color`. ```python # Red should cover ~25% of the screen assert_color_ratio(frame, (255, 0, 0), 0.25, tolerance=0.05, color_tolerance=30) ``` ### `color_ratio(pixels, color, tolerance=10) -> float` Returns the fraction of pixels matching `color`. Useful for flexible assertions: ```python ratio = color_ratio(frame, (255, 0, 0), tolerance=30) assert ratio > 0.1, f"Not enough red: {ratio:.3f}" ``` ### `assert_region_color(pixels, rect, expected_color, tolerance=5)` Assert all pixels within a rectangle `(x, y, width, height)` match a color. ```python # Top-left 100x100 should be blue assert_region_color(frame, (0, 0, 100, 100), (0, 0, 255, 255), tolerance=15) ``` ### `assert_no_color(pixels, color, tolerance=5)` Assert a color is absent from the image. ```python # Nothing should be bright green assert_no_color(frame, (0, 255, 0), tolerance=10) ``` ### `save_image(path, pixels)` / `save_diff_image(path, actual, expected)` Debug helpers that write PPM files (no Pillow required). `save_diff_image` amplifies differences 5x for visibility. ```python from simvx.graphics.testing import save_image, save_diff_image save_image("/tmp/actual.ppm", frame) save_diff_image("/tmp/diff.ppm", frame, expected_frame) ``` ## Testing Patterns ### Z-order verification (no baseline needed) Render overlapping objects with distinct colors and check which color wins at the overlap point: ```python class ZOrderScene(Node): def ready(self): cam = Camera3D(position=Vec3(0, 0, 5)) cam.look_at(Vec3(0, 0, 0)) self.add_child(cam) # Blue cube at z=0 (far) back = MeshInstance3D(position=Vec3(0, 0, 0)) back.mesh = Mesh.cube(3.0) back.material = Material(color=(0, 0, 1, 1), unlit=True) self.add_child(back) # Red cube at z=1.5 (near) front = MeshInstance3D(position=Vec3(0, 0, 1.5)) front.mesh = Mesh.cube(2.0) front.material = Material(color=(1, 0, 0, 1), unlit=True) self.add_child(front) frames = app.run_headless(ZOrderScene(), frames=3, capture_frames=[2]) center = frames[0][120, 160] assert center[0] > 150 # red wins at center assert center[2] < 50 # not blue ``` ### Input injection Use `on_frame` with `InputSimulator` to test interactive behavior: ```python from simvx.core.testing import InputSimulator from simvx.core import Key sim = InputSimulator() def inject(frame_num): if frame_num == 2: sim.press_key(Key.SPACE) elif frame_num == 3: sim.release_key(Key.SPACE) frames = app.run_headless(MyScene(), frames=5, on_frame=inject, capture_frames=[4]) ``` ### Color coverage for render-order verification When you don't have a baseline, verify that each rendered element covers the expected proportion of the screen: ```python frames = app.run_headless(ThreeColorScene(), frames=3, capture_frames=[2]) frame = frames[0] red = color_ratio(frame, (255, 0, 0), tolerance=80) green = color_ratio(frame, (0, 255, 0), tolerance=80) blue = color_ratio(frame, (0, 0, 255), tolerance=80) assert red > 0.01, "Red cube missing" assert green > 0.01, "Green cube missing" assert blue > 0.01, "Blue cube missing" ``` ## Pytest Fixtures The graphics test suite provides fixtures in `packages/graphics/tests/conftest.py`: ```python def test_my_scene(capture, px): frames = capture(MyScene(), frames=3, capture_frames=[2]) frame = frames[0] px.not_blank(frame) px.pixel(frame, 160, 120, (255, 0, 0, 255), tolerance=20) ``` | Fixture | Type | Description | |---------|------|---| | `headless_app` | `App` | Pre-configured `App(320x240, visible=False)`. Skips if no Vulkan. | | `capture` | `callable` | Shorthand for `headless_app.run_headless(...)`. | | `px` | `PixelCheck` | Namespace with all assertion functions as methods. | The `px` fixture provides: `px.pixel()`, `px.not_blank()`, `px.region()`, `px.ratio()`, `px.no_color()`, `px.get_ratio()`. ## CI Setup Visual tests require a Vulkan driver. For headless CI, use a software renderer: ### Linux (GitHub Actions / Docker) ```yaml - name: Install software Vulkan run: sudo apt-get install -y mesa-vulkan-drivers xvfb - name: Run visual tests run: xvfb-run pytest packages/graphics/tests/test_visual.py env: VK_ICD_FILENAMES: /usr/share/vulkan/icd.d/lvp_icd.x86_64.json ``` `mesa-vulkan-drivers` provides **lavapipe**, a CPU-based Vulkan 1.4 driver. `xvfb-run` provides a virtual X display for GLFW. ### Arch Linux / Manjaro ```bash sudo pacman -S vulkan-swrast xvfb-run pytest packages/graphics/tests/test_visual.py ``` ### Running Tests ```bash # All visual tests cd packages/graphics && pytest tests/test_visual.py -v # Specific test pytest tests/test_visual.py::TestRedCube::test_center_is_red ```