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 .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 canonical API) -------------------
[docs] def text_width(self, text: str, scale: float = 1.0) -> float: """Approximate text width: 8px per character at scale 1.0. For multi-line strings (containing ``\\n``), returns the widest line, matching the real ``Draw2D.text_width`` contract. """ key = (text, scale) cached = self._text_width_cache.get(key) if cached is not None: return cached widest = max((len(line) for line in text.split("\n")), default=0) result = widest * 8.0 * scale self._text_width_cache[key] = result return result
[docs] def text_height(self, text: str, scale: float = 1.0) -> float: """Approximate text height: 14 * 1.2 * scale per line.""" if not text: return 0 line_count = text.count("\n") + 1 return 14.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) -> 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 / 14 logical) is applied; explicit values bypass the safety net. """ floor = 10.0 / 14.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) 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, 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"))
[docs] def draw_rect(self, pos, size, *, colour: tuple = (1, 1, 1, 1), filled: bool = False, thickness: float = 1.0): """Canonical rect entry. pos and size must be 2-tuples or Vec2s.""" x = pos[0] if isinstance(pos, tuple | list) else pos.x y = pos[1] if isinstance(pos, tuple | list) else pos.y w = size[0] if isinstance(size, tuple | list) else size.x h = size[1] if isinstance(size, tuple | list) else size.y del thickness # 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))
[docs] def draw_line(self, a, b, *, colour: tuple = (1, 1, 1, 1), thickness: float = 1.0): """Canonical line entry. a and b must be 2-tuples or Vec2s.""" del thickness x1 = a[0] if isinstance(a, tuple | list) else a.x y1 = a[1] if isinstance(a, tuple | list) else a.y x2 = b[0] if isinstance(b, tuple | list) else b.x y2 = b[1] if isinstance(b, tuple | list) else b.y self.calls.append(DrawCall("line", x=x1, y=y1, x2=x2, y2=y2, colour=colour))
[docs] def draw_text( self, text: str, pos: tuple | None = None, *, colour: tuple = (1, 1, 1, 1), 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, ): 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) tw = self.text_width(text, scale) glyph_h = 14.0 * scale n_lines = text.count("\n") + 1 th = glyph_h if n_lines == 1 else glyph_h * 1.2 * n_lines if alignment == "center": x = rx + (rw - tw) / 2 elif alignment == "right": x = rx + rw - tw else: x = rx if vertical_alignment == "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))
[docs] def draw_circle(self, center, radius, *, colour=None, filled: bool = False, segments: int = 32): if hasattr(center, "x"): cx, cy = float(center.x), float(center.y) else: cx, cy = float(center[0]), float(center[1]) 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=(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_lines(self, points, closed=True, colour=None): self.calls.append(DrawCall("draw_lines"))
[docs] def fill_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, *, colour=(1, 1, 1, 1)): self.calls.append(DrawCall("fill_quad", x=x1, y=y1, x2=x2, y2=y2, 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 # 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() _break_node_refs(root) self.tree.root = None self.tree._delete_queue.clear() self.tree._groups.clear() self.tree._modal_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.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. """ parts = key.split("+") base_key = parts[-1] mods = set(parts[:-1]) if len(parts) > 1 else set() 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) 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)) # Re-layout so child positions reflect the new scroll, then bump # the per-frame rect cache so the next get_global_rect() walks # the fresh positions instead of returning stale cached coords. if hasattr(ancestor, "_do_update_layout"): ancestor._do_update_layout() _Control._current_frame += 1 break ancestor = ancestor.parent