Source code for 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
"""

import logging
from dataclasses import dataclass

from ..input.enums import MouseButton
from ..input.state import Input
from ..math.types import Vec2
from ..scene_tree import SceneTree
from ..testing.input_sim import InputSimulator
from ..text.measure import measure_text_width
from .core import Control

log = logging.getLogger(__name__)

__all__ = ["UITestHarness", "DrawLog", "DrawCall"]

#: What a drawing surface records when a call passes no colour.
_DEFAULT_COLOUR = (1.0, 1.0, 1.0, 1.0)


def _xy(v) -> tuple[float, float]:
    """Extract ``(x, y)`` from a ``Vec2`` or an ``(x, y)`` tuple/list."""
    if hasattr(v, "x"):
        return float(v.x), float(v.y)
    return float(v[0]), float(v[1])


def _colour(c) -> tuple[float, ...]:
    return _DEFAULT_COLOUR if c is None else tuple(c)


# ============================================================================
# DrawCall: single recorded renderer command
# ============================================================================


[docs] @dataclass class DrawCall: """One recorded draw command.""" type: str x: float = 0.0 y: float = 0.0 w: float = 0.0 h: float = 0.0 colour: tuple[float, ...] = () text: str = "" scale: float = 1.0 # line endpoints x2: float = 0.0 y2: float = 0.0
# ============================================================================ # DrawLog: mock renderer that captures all draw calls # ============================================================================
[docs] class DrawLog: """Mock renderer that records draw commands for assertion. Implements the same interface as Draw2D so widgets can render into it. All geometry is captured but not rasterized. Example: harness.tick() assert "Save" in harness.draw_log.texts() assert harness.draw_log.rects_at(100, 50) """ def __init__(self): self.calls: list[DrawCall] = [] self._clip_stack: list[tuple[float, float, float, float]] = [] self._colour: tuple[float, ...] = (1.0, 1.0, 1.0, 1.0)
[docs] def clear(self): """Discard all recorded calls.""" self.calls.clear() self._clip_stack.clear()
# -- Renderer interface (matches Draw2D canonical API) -------------------
[docs] def text_width(self, text: str, scale: float = 1.0, *, cell_width: float = 0.0) -> float: """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. """ return measure_text_width(text, scale, cell_width=cell_width)
[docs] def text_height(self, text: str, scale: float = 1.0) -> float: """Approximate text height: 16 * 1.2 * scale per line.""" if not text: return 0 line_count = text.count("\n") + 1 return 16.0 * 1.2 * scale * line_count
[docs] def text_size(self, text: str, scale: float = 1.0) -> tuple[float, float]: return (self.text_width(text, scale), self.text_height(text, scale))
[docs] def fit_scale( self, text: str, max_width: float, *, base_scale: float = 1.0, min_scale: float | None = None, cell_width: float = 0.0, ) -> float: """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. """ floor = 10.0 / 16.0 if min_scale is None else min_scale if not text or max_width <= 0: return base_scale w = self.text_width(text, base_scale, cell_width=cell_width) if w <= max_width: return base_scale return max(floor, base_scale * max_width / w)
[docs] def push_clip(self, x: float, y: float, w: float, h: float): self._clip_stack.append((x, y, w, h)) self.calls.append(DrawCall("push_clip", x=x, y=y, w=w, h=h))
[docs] def pop_clip(self): if self._clip_stack: self._clip_stack.pop() self.calls.append(DrawCall("pop_clip"))
[docs] def push_transform(self, a: float, b: float, c: float, d: float, tx: float, ty: float): self.calls.append(DrawCall("push_transform", x=tx, y=ty))
[docs] def pop_transform(self): self.calls.append(DrawCall("pop_transform"))
[docs] def draw_rect( self, pos, size, *, colour: tuple | None = None, filled: bool = False, thickness: float = 1.0, screen_space: bool = False, blend: str = "alpha", ): """Canonical rect entry. pos and size must be 2-tuples or Vec2s.""" x, y = _xy(pos) w, h = _xy(size) del thickness, screen_space, blend # DrawLog records intent, not raster. kind = "fill_rect" if filled else "rect" self.calls.append(DrawCall(kind, x=x, y=y, w=w, h=h, colour=_colour(colour)))
[docs] def draw_line(self, a, b, *, colour: tuple | None = None, thickness: float = 1.0, screen_space: bool = False): """Canonical line entry. a and b must be 2-tuples or Vec2s.""" del thickness, screen_space x1, y1 = _xy(a) x2, y2 = _xy(b) self.calls.append(DrawCall("line", x=x1, y=y1, x2=x2, y2=y2, colour=_colour(colour)))
[docs] def draw_text( self, text: str, pos=None, *, colour: tuple | None = None, scale: float = 1.0, rect: tuple | None = None, alignment: str = "left", vertical_alignment: str = "top", fit_to_width: bool = False, min_scale: float | None = None, outline: float = 0.0, outline_colour: tuple | None = None, screen_space: bool = False, cell_width: float = 0.0, ): if rect is not None: rx, ry, rw, rh = rect[0], rect[1], rect[2], rect[3] if fit_to_width: scale = self.fit_scale(text, rw, base_scale=scale, min_scale=min_scale, cell_width=cell_width) tw = self.text_width(text, scale, cell_width=cell_width) glyph_h = 16.0 * scale n_lines = text.count("\n") + 1 th = glyph_h if n_lines == 1 else glyph_h * 1.2 * n_lines # ``centre`` is the canonical spelling and ``center`` the # transitional one the renderer still maps; the harness has to read # both, or a widget written in the canonical spelling lays itself # out one way on screen and another way under test. if alignment in ("centre", "center"): x = rx + (rw - tw) / 2 elif alignment == "right": x = rx + rw - tw else: x = rx if vertical_alignment in ("centre", "center"): y = ry + (rh - th) / 2 elif vertical_alignment == "bottom": y = ry + rh - th else: y = ry elif pos is None: x, y = 0.0, 0.0 else: x = pos[0] if isinstance(pos, tuple | list) else pos.x y = pos[1] if isinstance(pos, tuple | list) else pos.y self.calls.append(DrawCall("text", x=x, y=y, text=text, scale=scale, colour=_colour(colour)))
[docs] def draw_circle( self, center, radius: float, *, colour: tuple | None = None, filled: bool = False, segments: int = 32, screen_space: bool = False, ): del screen_space cx, cy = _xy(center) kind = "fill_circle" if filled else "draw_circle" self.calls.append(DrawCall(kind, x=cx, y=cy, w=float(radius), h=float(radius), colour=colour or ()))
[docs] def fill_rect_gradient(self, x: float, y: float, w: float, h: float, colour_top: tuple, colour_bottom: tuple): self.calls.append(DrawCall("fill_rect_gradient", x=x, y=y, w=w, h=h, colour=colour_top))
[docs] def draw_gradient_rect(self, x: float, y: float, w: float, h: float, colour_top: tuple, colour_bottom: tuple): self.fill_rect_gradient(x, y, w, h, colour_top, colour_bottom)
[docs] def draw_thick_line( self, x1: float, y1: float, x2: float, y2: float, width: float = 2.0, *, colour: tuple | None = None ): self.calls.append(DrawCall("thick_line", x=x1, y=y1, x2=x2, y2=y2, w=width, colour=_colour(colour)))
[docs] def draw_lines(self, points, closed: bool = True, colour: tuple | None = None): self.calls.append(DrawCall("draw_lines"))
[docs] def fill_triangle( self, x1: float, y1: float, x2: float, y2: float, x3: float, y3: float, *, colour: tuple | None = None ): self.calls.append(DrawCall("fill_triangle", x=x1, y=y1, x2=x2, y2=y2, colour=_colour(colour)))
[docs] def fill_quad( self, x1: float, y1: float, x2: float, y2: float, x3: float, y3: float, x4: float, y4: float, *, colour: tuple | None = None, ): self.calls.append(DrawCall("fill_quad", x=x1, y=y1, x2=x2, y2=y2, colour=_colour(colour)))
[docs] def draw_texture( self, texture_id: int, pos, size, *, colour: tuple | None = None, rotation: float = 0.0, blend: str = "alpha", screen_space: bool = False, ): self.draw_texture_region( texture_id, pos, size, (0.0, 0.0), (1.0, 1.0), colour, rotation, blend=blend, screen_space=screen_space )
[docs] def draw_texture_region( self, texture_id: int, pos, size, uv0=(0.0, 0.0), uv1=(1.0, 1.0), colour: tuple | None = None, rotation: float = 0.0, *, blend: str = "alpha", screen_space: bool = False, ): del uv0, uv1, rotation, blend, screen_space x, y = _xy(pos) w, h = _xy(size) self.calls.append(DrawCall("texture", x=x, y=y, w=w, h=h, colour=_colour(colour)))
[docs] def draw_image( self, path, x: float, y: float, w: float, h: float, *, colour: tuple | None = None, rotation: float = 0.0, filter: str = "linear", blend: str = "alpha", ): del rotation, filter, blend self.calls.append(DrawCall("image", x=x, y=y, w=w, h=h, colour=_colour(colour), text=str(path)))
[docs] def draw_polygon(self, vertices, *, colour: tuple | None = None, filled: bool = True, blend: str = "alpha"): del blend kind = "fill_polygon" if filled else "draw_polygon" self.calls.append(DrawCall(kind, colour=_colour(colour)))
# -- Query helpers --------------------------------------------------------
[docs] def texts(self) -> list[str]: """All rendered text strings.""" return [c.text for c in self.calls if c.type == "text"]
[docs] def texts_containing(self, substring: str) -> list[str]: """Text strings containing a substring.""" return [t for t in self.texts() if substring in t]
[docs] def rects_at(self, x: float, y: float) -> list[DrawCall]: """All fill_rect calls whose bounds contain (x, y).""" return [c for c in self.calls if c.type == "fill_rect" and c.x <= x < c.x + c.w and c.y <= y < c.y + c.h]
[docs] def calls_of_type(self, call_type: str) -> list[DrawCall]: """All calls of a specific type.""" return [c for c in self.calls if c.type == call_type]
[docs] def has_text(self, text: str) -> bool: """Check if exact text was rendered.""" return text in self.texts()
[docs] def has_text_containing(self, substring: str) -> bool: """Check if any rendered text contains substring.""" return any(substring in t for t in self.texts())
def _break_node_refs(node): """Recursively sever ALL references on *node* and descendants. Clears parent/child links, Signal callback lists, and then wipes each node's ``__dict__`` so Vec2/Vec3 positions, widget back-references, and every other attribute becomes immediately reclaimable. """ for child in list(node.children): _break_node_refs(child) node.children._list.clear() node.children._names.clear() node.children._snapshot.clear() for attr in list(vars(node).values()): if hasattr(attr, "_callbacks"): attr._callbacks.clear() node.__dict__.clear() # ============================================================================ # UITestHarness: headless frame runner + input injector # ============================================================================
[docs] class UITestHarness: """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") """ def __init__(self, root: Control, screen_size: tuple[float, float] = (1280, 720)): self.tree = SceneTree(screen_size=Vec2(screen_size[0], screen_size[1])) self.tree.set_root(root) self.draw_log = DrawLog() self._screen_size = screen_size # All mouse / touch / key input flows through one InputSimulator so # state injection, ``@on_input`` decorator dispatch, and UI widget # dispatch all fire from a single call (see InputSimulator docstring). # Bind the sim to this harness's tree explicitly: editor scenarios # create additional SceneTrees during ``ready`` (one per scene tab) # that would otherwise overwrite ``SceneTree.current()``. self._sim = InputSimulator(tree=self.tree)
[docs] @property def root(self) -> Control: return self.tree.root
# ======================================================================== # Cleanup # ========================================================================
[docs] def teardown(self): """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). """ if self.tree is None: return # already torn down root = self.tree.root if root: root._exit_tree() # Unlink before the wipe: ``_break_node_refs`` clears each node's # ``__dict__``, and the setter reads the old root's ``parent``. self.tree.root = None _break_node_refs(root) self.tree.root = None self.tree.clear_delete_queue() self.tree._groups.clear() self.tree.overlays.close_all() self.tree._focused_control = None self.tree._mouse_grab = None # The tree and its own top node hold each other, so refcount-only # reclamation needs that pair broken by hand. self.tree._tree_root.children._list.clear() self.tree._tree_root.children._names.clear() self.tree._tree_root.children._snapshot.clear() self.tree._tree_root._tree = None self.tree = None self.draw_log = None
# ======================================================================== # Frame simulation # ========================================================================
[docs] def tick(self, dt: float = 1 / 60, count: int = 1): """Advance count frames: process → draw for each. Draw log is cleared before the first frame, then accumulates across all frames in this tick call. """ self.draw_log.clear() for _ in range(count): self.tree.tick(dt) self.tree.render(self.draw_log) Input._end_frame()
[docs] def process_only(self, dt: float = 1 / 60, count: int = 1): """Advance frames without drawing (faster for logic-only tests).""" for _ in range(count): self.tree.tick(dt) Input._end_frame()
# ======================================================================== # Input helpers # ========================================================================
[docs] def click(self, target: Control | Vec2 | tuple[float, float], button: MouseButton = MouseButton.LEFT): """Click (press + release) at widget center or screen position.""" self._sim.click(self._resolve_position(target), button)
[docs] def double_click(self, target: Control | Vec2 | tuple[float, float], button: MouseButton = MouseButton.LEFT): """Two rapid clicks at the same position.""" pos = self._resolve_position(target) self._sim.click(pos, button) self._sim.click(pos, button)
[docs] def mouse_down(self, target: Control | Vec2 | tuple[float, float], button: MouseButton = MouseButton.LEFT): """Press mouse button without releasing.""" self._sim.press_mouse(button, position=self._resolve_position(target))
[docs] def mouse_up(self, target: Control | Vec2 | tuple[float, float], button: MouseButton = MouseButton.LEFT): """Release mouse button.""" # Move first so the release lands at the requested position (matches # the platform contract: release events carry the cursor's current # location, not the button-down location). pos = self._resolve_position(target) self._sim.move_mouse(pos[0], pos[1]) self._sim.release_mouse(button)
[docs] def mouse_move(self, target: Control | Vec2 | tuple[float, float]): """Move mouse to position (triggers hover/mouse_over updates).""" pos = self._resolve_position(target) self._sim.move_mouse(pos[0], pos[1])
[docs] def drag(self, start: Control | Vec2, end: Control | Vec2, steps: int = 5, button: MouseButton = MouseButton.LEFT): """Simulate mouse drag with intermediate moves.""" p0 = self._resolve_position(start) p1 = self._resolve_position(end) self._sim.press_mouse(button, position=p0) for i in range(1, steps + 1): t = i / steps mx = p0[0] + (p1[0] - p0[0]) * t my = p0[1] + (p1[1] - p0[1]) * t self._sim.move_mouse(mx, my) self._sim.release_mouse(button)
[docs] def type_text(self, text: str): """Send character events for each char in text to the focused widget.""" for ch in text: self.tree.ui_input(char=ch)
[docs] def press_key(self, key: str, release: bool = True): """Send key press (and optionally release) event. Accepts string key names (e.g. ``"escape"``, ``"ctrl+s"``) which the UI router understands directly: sim's ``press_key`` works on ``Key`` enums and only drives the scene-tree path, so keep the string-routed UI path here. 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. """ parts = key.split("+") base_key = parts[-1] mods = set(parts[:-1]) if len(parts) > 1 else set() old_keys = dict(Input._keys) try: for mod in mods: Input._keys[mod] = True self.tree.ui_input(key=base_key, pressed=True) if release: self.tree.ui_input(key=base_key, pressed=False) finally: Input._keys = old_keys
[docs] def scroll(self, target: Control | Vec2 | tuple[float, float], direction: str = "down", amount: int = 1): """Send scroll events. direction is 'up' or 'down'.""" pos = self._resolve_position(target) # Move cursor first so scroll lands at the right widget. self._sim.move_mouse(pos[0], pos[1]) dy = 1.0 if direction == "up" else -1.0 for _ in range(amount): self._sim.scroll(0.0, dy)
[docs] def touch(self, target: Control | Vec2 | tuple[float, float], finger_id: int = 0): """Touch down on a control (like a tap press). Routes through UI as mouse.""" pos = self._resolve_position(target) self._sim.touch_down(finger_id, pos) # Touches don't auto-emit primary-finger mouse; fire the UI mouse press # explicitly so widgets see a click. Mirrors the platform's primary- # finger emulation but avoids depending on emulation flags here. self.tree.ui_input(mouse_pos=pos, button=MouseButton.LEFT, pressed=True)
[docs] def touch_up(self, target: Control | Vec2 | tuple[float, float], finger_id: int = 0): """Touch release on a control.""" pos = self._resolve_position(target) self._sim.touch_up(finger_id, pos) self.tree.ui_input(mouse_pos=pos, button=MouseButton.LEFT, pressed=False)
[docs] def tap(self, target: Control | Vec2 | tuple[float, float], finger_id: int = 0): """Full tap gesture (touch down + release).""" self.touch(target, finger_id) self.touch_up(target, finger_id)
[docs] def set_focus(self, control: Control): """Directly focus a control.""" self.tree._set_focused_control(control)
# ======================================================================== # Widget lookup # ========================================================================
[docs] def find(self, path: str) -> Control | None: """Find widget by slash-separated name path from root. Example: harness.find("Layout/TabContainer/Editor") """ parts = path.split("/") node = self.root for part in parts: found = None for child in node.children: if child.name == part: found = child break if found is None: return None node = found return node if isinstance(node, Control) else None
[docs] def find_all(self, widget_type: type) -> list[Control]: """Find all widgets of a given type in the tree.""" results: list[Control] = [] def walk(node): if isinstance(node, widget_type): results.append(node) for child in node.children: walk(child) if self.root: walk(self.root) return results
[docs] def find_by_text(self, text: str) -> Control | None: """Find first Label or Button whose .text contains the given string.""" def walk(node): if hasattr(node, "text") and isinstance(getattr(node, "text", None), str): if text in node.text: return node for child in node.children: result = walk(child) if result: return result return None return walk(self.root) if self.root else None
[docs] def find_by_name(self, name: str) -> Control | None: """Find first widget with the given name (depth-first).""" def walk(node): if node.name == name: return node for child in node.children: result = walk(child) if result: return result return None return walk(self.root) if self.root else None
# ======================================================================== # Assertions # ========================================================================
[docs] def assert_visible(self, widget: Control, msg: str = ""): """Assert widget is visible.""" assert widget.visible, msg or f"{widget.name} should be visible"
[docs] def assert_hidden(self, widget: Control, msg: str = ""): """Assert widget is not visible.""" assert not widget.visible, msg or f"{widget.name} should be hidden"
[docs] def assert_focused(self, widget: Control, msg: str = ""): """Assert widget has focus.""" assert widget.focused, msg or f"{widget.name} should be focused"
[docs] def assert_text(self, widget: Control, expected: str, msg: str = ""): """Assert widget.text matches expected.""" actual = getattr(widget, "text", None) assert actual == expected, msg or f"Expected text '{expected}', got '{actual}'"
[docs] def assert_text_contains(self, widget: Control, substring: str, msg: str = ""): """Assert widget.text contains substring.""" actual = getattr(widget, "text", "") assert substring in actual, msg or f"Expected '{substring}' in '{actual}'"
[docs] def assert_drawn_text(self, text: str, msg: str = ""): """Assert text appears in the draw log.""" assert self.draw_log.has_text(text), msg or f"Expected '{text}' in draw output"
[docs] def assert_drawn_text_containing(self, substring: str, msg: str = ""): """Assert any drawn text contains substring.""" assert self.draw_log.has_text_containing(substring), msg or f"Expected drawn text containing '{substring}'"
[docs] def assert_signal_emitted(self, signal, msg: str = "") -> list: """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 """ emissions: list[tuple] = [] signal.connect(lambda *args: emissions.append(args)) return emissions
# ======================================================================== # Internals # ======================================================================== def _resolve_position(self, target) -> tuple[float, float]: """Convert target to screen position (center of control, or raw coords). For ``Control`` targets inside a ``ScrollContainer``, scrolls the nearest scroll ancestor so the target sits inside the viewport before returning its center. Without this, the hit-test correctly refuses clicks at coordinates outside the visible region (see ``Control. _clips_input``), and tests that click widgets in scrolled sections would silently fail. Mirrors real-world UX: a user clicking a button first scrolls it into view. """ if isinstance(target, Control): self._scroll_into_view(target) x, y, w, h = target.get_global_rect() return (x + w / 2, y + h / 2) if isinstance(target, Vec2): return (target.x, target.y) if isinstance(target, tuple | list): return (float(target[0]), float(target[1])) raise TypeError(f"Cannot resolve position from {type(target)}") def _scroll_into_view(self, target: Control) -> None: """Scroll ``target``'s nearest scrollable ancestor so it's visible.""" from .core import Control as _Control from .scroll import ScrollContainer ancestor = target.parent while ancestor is not None: if isinstance(ancestor, ScrollContainer): sx, sy, sw, sh = ancestor.get_global_rect() tx, ty, tw, th = target.get_global_rect() if ty < sy: ancestor.scroll_y = max(0.0, ancestor.scroll_y - (sy - ty)) elif ty + th > sy + sh: ancestor.scroll_y = ancestor.scroll_y + (ty + th - (sy + sh)) if tx < sx: ancestor.scroll_x = max(0.0, ancestor.scroll_x - (sx - tx)) elif tx + tw > sx + sw: ancestor.scroll_x = ancestor.scroll_x + (tx + tw - (sx + sw)) # Reflow so child positions reflect the new scroll and expire the # rect cache, via the canonical layout path (mark dirty + flush) # instead of hand-rolling the reflow and the epoch bump. ancestor.mark_layout_dirty() tree = ancestor._tree if tree is not None: tree.flush_layout() else: ancestor._do_update_layout() _Control._current_frame += 1 break ancestor = ancestor.parent