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
"""


from __future__ import annotations

import logging
from dataclasses import dataclass

from ..input.state import Input
from ..scene_tree import SceneTree
from ..math.types import Vec2
from .core import Control

log = logging.getLogger(__name__)

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


# ============================================================================
# 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) self._text_width_cache: dict[tuple[str, float], float] = {}
[docs] def clear(self): """Discard all recorded calls.""" self.calls.clear() self._clip_stack.clear() self._text_width_cache.clear()
# -- Renderer interface (matches Draw2D) ----------------------------------
[docs] def draw_filled_rect(self, x: float, y: float, w: float, h: float, colour: tuple = (1, 1, 1, 1)): self.calls.append(DrawCall("fill_rect", x=x, y=y, w=w, h=h, colour=colour))
[docs] def draw_rect_coloured(self, x: float, y: float, w: float, h: float, colour: tuple = (1, 1, 1, 1)): self.calls.append(DrawCall("rect", x=x, y=y, w=w, h=h, colour=colour))
[docs] def draw_line_coloured(self, x1: float, y1: float, x2: float, y2: float, colour: tuple = (1, 1, 1, 1)): self.calls.append(DrawCall("line", x=x1, y=y1, x2=x2, y2=y2, colour=colour))
[docs] def draw_text_coloured(self, text: str, x: float, y: float, scale: float = 1.0, colour: tuple = (1, 1, 1, 1)): self.calls.append(DrawCall("text", x=x, y=y, text=text, scale=scale, colour=colour))
[docs] def text_width(self, text: str, scale: float = 1.0) -> float: """Approximate text width: 8px per character at scale 1.0.""" key = (text, scale) cached = self._text_width_cache.get(key) if cached is not None: return cached result = len(text) * 8.0 * scale self._text_width_cache[key] = result return result
[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 new_layer(self): self.calls.append(DrawCall("new_layer"))
[docs] def push_transform(self, a, b, c, d, tx, ty): self.calls.append(DrawCall("push_transform", x=tx, y=ty))
[docs] def pop_transform(self): self.calls.append(DrawCall("pop_transform"))
# -- Also support non-coloured variants some widgets may use ---------------
[docs] def set_colour(self, r=255, g=255, b=255, a=255): """Track current colour (no-op for recording; colour is per-call).""" self._current_colour = (r / 255, g / 255, b / 255, a / 255)
[docs] def fill_rect(self, x: float, y: float, w: float, h: float, colour: tuple = (1, 1, 1, 1)): self.draw_filled_rect(x, y, w, h, colour)
[docs] def draw_rect(self, x: float, y: float, w: float, h: float, colour: tuple = (1, 1, 1, 1)): self.draw_rect_coloured(x, y, w, h, colour)
[docs] def draw_line(self, x1: float, y1: float, x2: float, y2: float, colour: tuple = (1, 1, 1, 1)): self.draw_line_coloured(x1, y1, x2, y2, colour)
[docs] def draw_text(self, text: str, pos: tuple, scale: float = 1.0, colour: tuple = (1, 1, 1, 1)): x = pos[0] if isinstance(pos, tuple | list) else pos.x y = pos[1] if isinstance(pos, tuple | list) else pos.y self.draw_text_coloured(text, x, y, scale, colour)
[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 fill_circle(self, cx: float, cy: float, radius: float, segments: int = 24): self.calls.append(DrawCall("fill_circle", x=cx, y=cy, w=radius, h=radius))
[docs] def draw_filled_circle(self, cx: float, cy: float, radius: float, colour: tuple = (1, 1, 1, 1), segments: int = 24): self.calls.append(DrawCall("fill_circle", x=cx, y=cy, w=radius, h=radius, colour=colour))
[docs] def draw_thick_line(self, x1: float, y1: float, x2: float, y2: float, width: float = 2.0): self.calls.append(DrawCall("thick_line", x=x1, y=y1, x2=x2, y2=y2, w=width))
[docs] def draw_circle(self, center, radius_or_y=None, radius=None, segments=24, colour=None): if hasattr(center, 'x'): cx, cy = float(center.x), float(center.y) r = float(radius_or_y) if radius_or_y is not None else 0.0 else: cx = float(center) cy = float(radius_or_y) if radius_or_y is not None else 0.0 r = float(radius) if radius is not None else 0.0 self.calls.append(DrawCall("draw_circle", x=cx, y=cy, w=r, h=r))
[docs] def draw_lines(self, points, closed=True, colour=None): self.calls.append(DrawCall("draw_lines"))
[docs] def fill_triangle(self, x1, y1, x2, y2, x3, y3): self.calls.append(DrawCall("fill_triangle", x=x1, y=y1, x2=x2, y2=y2))
[docs] def draw_filled_triangle(self, x1, y1, x2, y2, x3, y3, colour=(1, 1, 1, 1)): self.calls.append(DrawCall("fill_triangle", x=x1, y=y1, x2=x2, y2=y2, colour=colour))
[docs] def fill_quad(self, x1, y1, x2, y2, x3, y3, x4, y4): self.calls.append(DrawCall("fill_quad", x=x1, y=y1, x2=x2, y2=y2))
[docs] def draw_filled_quad(self, x1, y1, x2, y2, x3, y3, x4, y4, colour=(1, 1, 1, 1)): self.calls.append(DrawCall("fill_quad", x=x1, y=y1, x2=x2, y2=y2, colour=colour))
[docs] def draw_thick_line_coloured(self, x1, y1, x2, y2, width=2.0, colour=(1, 1, 1, 1)): self.calls.append(DrawCall("thick_line", x=x1, y=y1, x2=x2, y2=y2, w=width, colour=colour))
[docs] def draw_texture(self, texture_id, x, y, w, h, colour=None, rotation=0.0): self.calls.append(DrawCall("texture", x=x, y=y, w=w, h=h))
# -- 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 @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() _break_node_refs(root) self.tree.root = None self.tree._delete_queue.clear() self.tree._groups.clear() self.tree._popup_stack.clear() self.tree._focused_control = None self.tree._mouse_grab = 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.process(dt) self.tree.draw(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.process(dt) Input._end_frame()
# ======================================================================== # Input helpers # ========================================================================
[docs] def click(self, target: Control | Vec2 | tuple[float, float], button: int = 1): """Click (press + release) at widget center or screen position.""" pos = self._resolve_position(target) self.tree.ui_input(mouse_pos=pos, button=button, pressed=True) self.tree.ui_input(mouse_pos=pos, button=button, pressed=False)
[docs] def double_click(self, target: Control | Vec2 | tuple[float, float], button: int = 1): """Two rapid clicks at the same position.""" pos = self._resolve_position(target) for _ in range(2): self.tree.ui_input(mouse_pos=pos, button=button, pressed=True) self.tree.ui_input(mouse_pos=pos, button=button, pressed=False)
[docs] def mouse_down(self, target: Control | Vec2 | tuple[float, float], button: int = 1): """Press mouse button without releasing.""" pos = self._resolve_position(target) self.tree.ui_input(mouse_pos=pos, button=button, pressed=True)
[docs] def mouse_up(self, target: Control | Vec2 | tuple[float, float], button: int = 1): """Release mouse button.""" pos = self._resolve_position(target) self.tree.ui_input(mouse_pos=pos, button=button, pressed=False)
[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.tree.ui_input(mouse_pos=pos)
[docs] def drag(self, start: Control | Vec2, end: Control | Vec2, steps: int = 5, button: int = 1): """Simulate mouse drag with intermediate moves.""" p0 = self._resolve_position(start) p1 = self._resolve_position(end) self.tree.ui_input(mouse_pos=p0, button=button, pressed=True) 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.tree.ui_input(mouse_pos=(mx, my)) self.tree.ui_input(mouse_pos=p1, button=button, pressed=False)
[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. Supports modifier combos: "ctrl+s", "shift+enter", "escape". For modifier combos, temporarily sets Input._keys so SceneTree builds the correct combo_key string. """ parts = key.split("+") base_key = parts[-1] mods = set(parts[:-1]) if len(parts) > 1 else set() # Set modifier state so SceneTree.ui_input builds the combo old_keys = dict(Input._keys) 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) # Restore modifier state 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) key = f"scroll_{direction}" for _ in range(amount): self.tree.ui_input(mouse_pos=pos, key=key, pressed=True)
[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.tree.ui_input(mouse_pos=pos, button=1, 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.tree.ui_input(mouse_pos=pos, button=1, 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).""" if isinstance(target, Control): 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)}")