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 |
|
No |
Game logic, node lifecycle, input actions |
UI draw calls |
|
No |
Widget layout, draw commands, focus/input |
Visual rendering |
|
Yes |
Actual GPU output — shaders, z-order, colors |
This page covers the visual rendering layer.
Quick Start¶
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.
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 (
Nodesubclass).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).
Nonecaptures 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.
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:
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.
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.
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.
# 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:
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.
# 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.
# 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.
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:
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:
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:
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:
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 |
|---|---|---|
|
|
Pre-configured |
|
|
Shorthand for |
|
|
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)¶
- 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¶
sudo pacman -S vulkan-swrast
xvfb-run pytest packages/graphics/tests/test_visual.py
Running Tests¶
# All visual tests
cd packages/graphics && pytest tests/test_visual.py -v
# Specific test
pytest tests/test_visual.py::TestRedCube::test_center_is_red