"""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"))
# -- 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 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)}")