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¶
One recorded draw command. |
|
Mock renderer that records draw commands for assertion. |
|
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
Draw2Dso widgets can render into it: the seven primitives (rect,circle,line,polygon,text,texture,nine_patch) and thetransform/clipscopes. 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
- 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 theDraw2D.text_widthcontract.cell_widthis the caller’s character-grid pitch and means what it does onDraw2D.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_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: whenmin_scaleisNonethe readable-pixel floor (~10px / 16 logical) is applied; explicit values bypass the safety net.
- 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.
- 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
CanvasLayerthat 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 recordedpush_screen_transformcall 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.
posandsizemust 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]¶
- 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
intis a slot a test can match on, and anything else (a path, bytes, an array, a resource) is recorded as an"image"call carrying itsstr(), 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.
- 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.
- 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_overonce 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.
- 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_comboreads into the- Class:
~simvx.core.input.events.KeyEventthe UI receives.InputSimulator.press_keytakes aKeyenum 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 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.