simvx.core.ui.testing

Headless UI test harness: validate widgets without GPU or window.

Provides UITestHarness for frame simulation, input injection, and draw capture. All tests run on CPU only; no Vulkan, GLFW, or display required.

Example: from simvx.core import Button, VBoxContainer, SceneTree from simvx.core.ui.testing import UITestHarness

def test_button_click():
    root = VBoxContainer()
    btn = root.add_child(Button("Save"))
    harness = UITestHarness(root)

    clicks = []
    btn.pressed.connect(lambda: clicks.append(1))

    harness.click(btn)
    assert len(clicks) == 1

Module Contents

Classes

DrawCall

One recorded draw command.

DrawLog

Mock renderer that records draw commands for assertion.

UITestHarness

Headless UI test harness for programmatic widget testing.

Data

API

simvx.core.ui.testing.log

‘getLogger(…)’

simvx.core.ui.testing.__all__

[‘UITestHarness’, ‘DrawLog’, ‘DrawCall’]

class simvx.core.ui.testing.DrawCall[source]

One recorded draw command.

type: str

None

x: float

0.0

y: float

0.0

w: float

0.0

h: float

0.0

colour: tuple[float, ...]

()

text: str = <Multiline-String>
scale: float

1.0

x2: float

0.0

y2: float

0.0

width: float

0.0

screen_space: bool

False

class simvx.core.ui.testing.DrawLog[source]

Mock renderer that records draw commands for assertion.

Implements the same drawing surface as Draw2D so widgets can render into it: the seven primitives (rect, circle, line, polygon, text, texture, nine_patch) and the transform / clip scopes. Geometry is captured, never rasterised, so a fill and a stroke are recorded as separate calls and weights, blends and segment counts are dropped: what a widget test asks is what was drawn and where.

Example: harness.tick() assert “Save” in harness.draw_log.texts() assert harness.draw_log.rects_at(100, 50)

Initialization

clear()[source]

Discard all recorded calls.

text_width(text: str, scale: float = 1.0, *, cell_width: float = 0.0) → float[source]

Width of text from the font’s real glyph advances.

Measured with the same metrics widgets size themselves by and the real renderer draws with, so a layout that fits here fits on screen. For multi-line strings (containing \n) it returns the widest line, matching the Draw2D.text_width contract.

cell_width is the caller’s character-grid pitch and means what it does on Draw2D.text_width: a character nothing can draw is reserved one cell. A widget that lays its text out on a grid passes it here as it does on screen, so what a test measures is what a player sees.

text_height(text: str, scale: float = 1.0) → float[source]

Height of text from the font’s real line-height metric.

The same number the live surfaces answer, so a layout that fits in a headless test fits on screen. Falls back to a fixed ratio only when no font could be loaded at all.

text_size(text: str, scale: float = 1.0) → tuple[float, float][source]
text_ascent(scale: float = 1.0) → float[source]

Distance from the top of a line to its baseline, as the renderer measures it.

A widget that puts something on the baseline asks for this while it draws, so a headless test has to answer with the number the screen would use.

fit_scale(text: str, max_width: float, *, base_scale: float = 1.0, min_scale: float | None = None, cell_width: float = 0.0) → float[source]

Largest scale ≤ base_scale that fits text within max_width.

Mirrors :meth:simvx.graphics.draw2d.Draw2D.fit_scale: when min_scale is None the readable-pixel floor (~10px / 16 logical) is applied; explicit values bypass the safety net.

push_clip(x: float, y: float, w: float, h: float)[source]
pop_clip()[source]
reset_clip()[source]

Drop every clip, as the live surfaces do at the end of a frame.

transform(a: float, b: float, c: float, d: float, tx: float, ty: float)[source]

Draw through an affine and restore afterwards, as the live surfaces do.

clip(rect)[source]

Draw clipped to rect = (x, y, w, h), then restore.

push_transform(a: float, b: float, c: float, d: float, tx: float, ty: float)[source]
pop_transform()[source]
push_screen_transform(a: float, b: float, c: float, d: float, tx: float, ty: float)[source]

Enter a screen-pinned scope: SET (not compose) the affine, as the real surface does.

A CanvasLayer that is not following the viewport opens this for its whole subtree, which is the only way anything draws in screen coordinates: an individual call cannot pin itself. The recorded push_screen_transform call is how a headless test sees that the scope was opened; the calls inside it are recorded where they land, with no camera in the way, each carrying

Attr:

DrawCall.screen_space.

push_node_transform(a: float, b: float, c: float, d: float, tx: float, ty: float)[source]

A node’s own world affine, composed onto the canvas scope as the real renderer does.

rect(pos, size, *, fill=None, stroke=None, width: float = 1.0, blend: str = 'alpha')[source]

Record a rectangle. pos and size must be 2-tuples or Vec2s.

A fill and a stroke are separate records, as they are separate paints on the live surfaces, so a test can assert on either without the other.

line(*points, stroke=None, width: float = 1.0)[source]

Record an open polyline. Two points or many, loose or as one sequence.

Each segment carries its stroke weight in width, the same field every stroked primitive records it in, and it scales with the transform like any other size.

text(text: str, pos=None, *, fill=None, scale: float = 1.0, align: simvx.core.alignment.HAlign | str = HAlign.LEFT, valign: simvx.core.alignment.VAlign | str = VAlign.TOP, stroke=None, stroke_width: float = 0.0, rect: tuple | None = None, fit_to_width: bool = False, min_scale: float | None = None, cell_width: float = 0.0, rotation: float = 0.0)[source]
circle(centre, radius: float, *, fill=None, stroke=None, width: float = 1.0, segments: int = 32)[source]
polygon(points, *, fill=None, stroke=None, width: float = 1.0, blend: str = 'alpha')[source]
texture(source, pos, size, *, region=None, tint=None, rotation: float = 0.0, blend: str = 'alpha', filter: str = 'linear')[source]

Record a textured quad.

There is no texture manager here, so the source is recorded rather than resolved: an int is a slot a test can match on, and anything else (a path, bytes, an array, a resource) is recorded as an "image" call carrying its str(), which is what a test asserting “this picture was drawn” has to read.

nine_patch(source, pos, size, *, texture_size, margins=(0.0, 0.0, 0.0, 0.0), draw_centre: bool = True, tint=None, filter: str = 'linear')[source]

Record a 9-slice panel as one call, not as its nine quads.

The slicing is raster detail; what a widget test asks is whether the panel was drawn, and where.

texts() → list[str][source]

All rendered text strings.

texts_containing(substring: str) → list[str][source]

Text strings containing a substring.

rects_at(x: float, y: float) → list[simvx.core.ui.testing.DrawCall][source]

All fill_rect calls whose bounds contain (x, y).

calls_of_type(call_type: str) → list[simvx.core.ui.testing.DrawCall][source]

All calls of a specific type.

has_text(text: str) → bool[source]

Check if exact text was rendered.

has_text_containing(substring: str) → bool[source]

Check if any rendered text contains substring.

class simvx.core.ui.testing.UITestHarness(root: simvx.core.ui.core.Control, screen_size: tuple[float, float] = (1280, 720))[source]

Headless UI test harness for programmatic widget testing.

Creates a SceneTree with the given root control, provides input injection helpers, frame simulation, draw capture, and widget lookup.

Example: panel = VBoxContainer() btn = panel.add_child(Button(“OK”)) harness = UITestHarness(panel)

harness.click(btn)
harness.type_text("hello")
harness.press_key("enter")

harness.tick()
assert harness.draw_log.has_text("OK")

Initialization

property root: simvx.core.ui.core.Control[source]
teardown()[source]

Tear down the scene tree, breaking reference cycles to free memory.

Idempotent: safe to call multiple times. Recursively severs every parent↔child and node↔tree back-reference so the entire node graph becomes immediately reclaimable by refcount alone (no GC needed).

tick(dt: float = 1 / 60, count: int = 1)[source]

Advance count frames: process → draw for each.

Draw log is cleared before the first frame, then accumulates across all frames in this tick call.

process_only(dt: float = 1 / 60, count: int = 1)[source]

Advance frames without drawing (faster for logic-only tests).

property router: simvx.core.input.router.InputRouter[source]

The window this harness’s input comes through.

A pointer belongs to its source, so a test that presses here and then wants the window to lose focus calls harness.router .focus_lost(). A router built by hand is a second window, and a second window cancels nothing the first one is holding.

click(target: simvx.core.ui.core.Control | simvx.core.math.types.Vec2 | tuple[float, float], button: simvx.core.input.controls.DeviceButton = Mouse.LEFT)[source]

Click (press + release) at widget center or screen position.

double_click(target: simvx.core.ui.core.Control | simvx.core.math.types.Vec2 | tuple[float, float], button: simvx.core.input.controls.DeviceButton = Mouse.LEFT)[source]

Two rapid clicks at the same position.

mouse_down(target: simvx.core.ui.core.Control | simvx.core.math.types.Vec2 | tuple[float, float], button: simvx.core.input.controls.DeviceButton = Mouse.LEFT)[source]

Press mouse button without releasing.

mouse_up(target: simvx.core.ui.core.Control | simvx.core.math.types.Vec2 | tuple[float, float], button: simvx.core.input.controls.DeviceButton = Mouse.LEFT)[source]

Release mouse button.

mouse_move(target: simvx.core.ui.core.Control | simvx.core.math.types.Vec2 | tuple[float, float])[source]

Move the mouse to a position, and settle the hover it leaves behind.

The engine recomputes mouse_over once a frame, from where the frame’s motion ended, so a running game sees it at the top of the next tick. One harness move is one frame’s worth of motion, so the hover is settled here rather than leaving every caller to tick for it.

drag(start: simvx.core.ui.core.Control | simvx.core.math.types.Vec2, end: simvx.core.ui.core.Control | simvx.core.math.types.Vec2, steps: int = 5, button: simvx.core.input.controls.DeviceButton = Mouse.LEFT)[source]

Simulate mouse drag with intermediate moves.

type_text(text: str)[source]

Send a text event for each character to the focused widget.

press_key(key: str, release: bool = True)[source]

Send key press (and optionally release) event.

Takes a written combo ("escape", "ctrl+s"), which

Func:

~simvx.core.input.events.key_event_for_combo reads into the

Class:

~simvx.core.input.events.KeyEvent the UI receives. InputSimulator.press_key takes a Key enum and publishes a whole event through the router instead; both reach the UI, and this is the shorter spelling when the UI is all a test is after.

The held modifiers are restored even when a handler raises. Without that, one failing assertion inside a widget leaves the modifier down for the rest of the process, and every later test in it runs with a phantom key held: the first failure is real and the ones that follow are not, which is the expensive way round.

scroll(target: simvx.core.ui.core.Control | simvx.core.math.types.Vec2 | tuple[float, float], direction: str = 'down', amount: int = 1)[source]

Send scroll events. direction is ‘up’ or ‘down’.

touch(target: simvx.core.ui.core.Control | simvx.core.math.types.Vec2 | tuple[float, float], finger_id: int = 0)[source]

Put a finger down on a control, through the router, which hands the UI the touch press.

touch_up(target: simvx.core.ui.core.Control | simvx.core.math.types.Vec2 | tuple[float, float], finger_id: int = 0)[source]

Lift a finger over a control, through the router.

tap(target: simvx.core.ui.core.Control | simvx.core.math.types.Vec2 | tuple[float, float], finger_id: int = 0)[source]

Full tap gesture (touch down + release).

find(path: str) → simvx.core.ui.core.Control | None[source]

Find widget by slash-separated name path from root.

Example: harness.find(“Layout/TabContainer/Editor”)

find_all(widget_type: type) → list[simvx.core.ui.core.Control][source]

Find all widgets of a given type in the tree.

find_by_text(text: str) → simvx.core.ui.core.Control | None[source]

Find first Label or Button whose .text contains the given string.

find_by_name(name: str) → simvx.core.ui.core.Control | None[source]

Find first widget with the given name (depth-first).

assert_visible(widget: simvx.core.ui.core.Control, msg: str = '')[source]

Assert widget is visible.

assert_hidden(widget: simvx.core.ui.core.Control, msg: str = '')[source]

Assert widget is not visible.

assert_focused(widget: simvx.core.ui.core.Control, msg: str = '')[source]

Assert widget has focus.

assert_text(widget: simvx.core.ui.core.Control, expected: str, msg: str = '')[source]

Assert widget.text matches expected.

assert_text_contains(widget: simvx.core.ui.core.Control, substring: str, msg: str = '')[source]

Assert widget.text contains substring.

assert_drawn_text(text: str, msg: str = '')[source]

Assert text appears in the draw log.

assert_drawn_text_containing(substring: str, msg: str = '')[source]

Assert any drawn text contains substring.

assert_signal_emitted(signal, msg: str = '') → list[source]

Connect to a signal and return a list that collects emissions.

Call this BEFORE the action that should emit. Check the list after.

Example: emissions = harness.assert_signal_emitted(btn.pressed) harness.click(btn) assert len(emissions) == 1