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 dataclasses
import logging
import math
from contextlib import contextmanager
from dataclasses import dataclass

from ..alignment import HAlign, VAlign
from ..gradient import Gradient, reject_gradient
from ..input.controls import DeviceButton, Mouse
from ..input.events import TextEvent, key_event_for_combo
from ..input.router import InputRouter
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, metrics_font
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)


def _fill_colour(fill) -> tuple[float, ...]:
    """The colour a fill records as; a gradient records the end it starts from."""
    return _colour(fill.start) if isinstance(fill, Gradient) else _colour(fill)


def _line_points(points) -> list[tuple[float, float]]:
    """The points of a ``line(...)`` call, loose or as one sequence."""
    if len(points) == 1:
        points = points[0]
    return [_xy(v) for v in points]


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


_IDENTITY_XF = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)


[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 #: The stroke weight of a stroked call, in the space the call was drawn in, so #: a test can assert that one run of line work is heavier than another. Weight #: is the only channel some drawings use to separate two strokes of the same #: colour on the same path. Zero on a fill, which has no stroke to weigh. width: float = 0.0 #: Whether this call was recorded inside a screen-pinned scope, the state a #: screen-pinned ``CanvasLayer`` opens for its whole subtree with #: ``push_screen_transform``. It answers where the call was drawn, NOT which #: post-processing lane it composites in: a drawable's ``post_processed`` #: override can move it either way, and resolving that needs the node context #: the item walk has and this test double does not model. The scope's own #: opening and closing markers count as inside it. screen_space: bool = False
# ============================================================================ # DrawLog: mock renderer that captures all draw calls # ============================================================================ #: Logical font size the text measurements are expressed in, as on the live surface. _LOGICAL_BASE = 16.0
[docs] class DrawLog: """Mock renderer that records draw commands for assertion. Implements the same drawing surface as ``Draw2D`` so widgets can render into it: the seven primitives (``rect``, ``circle``, ``line``, ``polygon``, ``text``, ``texture``, ``nine_patch``) and the ``transform`` / ``clip`` scopes. 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) """ 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) # The active transform, as the real renderer keeps it: calls are recorded in # the space they land in, so a node drawing in its local space reads back # where it actually is. self._xf: tuple[float, float, float, float, float, float] = _IDENTITY_XF # The canvas scope node transforms compose onto, and the active node's own affine. self._base_xf: tuple[float, float, float, float, float, float] = _IDENTITY_XF self._node_xf: tuple[float, float, float, float, float, float] = _IDENTITY_XF # True while a screen-pinned scope is open, stamped onto every call recorded # inside it. Rides the transform stack, so leaving the scope restores what # enclosed it. self._screen_scope: bool = False self._xf_stack: list[tuple] = []
[docs] def clear(self): """Discard all recorded calls.""" self.calls.clear() self._clip_stack.clear() self._xf = self._base_xf = self._node_xf = _IDENTITY_XF self._screen_scope = False self._xf_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: """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. """ if not text: return 0 line_count = text.count("\n") + 1 font = metrics_font() if font is None: return _LOGICAL_BASE * scale * 1.2 * line_count return float(font.line_height * (_LOGICAL_BASE * scale / font.size) * 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 text_ascent(self, scale: float = 1.0) -> float: """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. """ font = metrics_font() if font is None: return _LOGICAL_BASE * scale * 0.8 return float(font.ascender * _LOGICAL_BASE * scale / font.size)
[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): if self._node_xf != _IDENTITY_XF: corners = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)] a, b, c, d, tx, ty = self._node_xf xs = [a * px + b * py + tx for px, py in corners] ys = [c * px + d * py + ty for px, py in corners] x, y, w, h = min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys) self._clip_stack.append((x, y, w, h)) self._record(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._record(DrawCall("pop_clip"))
[docs] def reset_clip(self): """Drop every clip, as the live surfaces do at the end of a frame.""" self._clip_stack.clear() self._record(DrawCall("pop_clip"))
[docs] @contextmanager def transform(self, a: float, b: float, c: float, d: float, tx: float, ty: float): """Draw through an affine and restore afterwards, as the live surfaces do.""" self.push_transform(a, b, c, d, tx, ty) try: yield self finally: self.pop_transform()
[docs] @contextmanager def clip(self, rect): """Draw clipped to ``rect`` = ``(x, y, w, h)``, then restore.""" x, y, w, h = rect self.push_clip(x, y, w, h) try: yield self finally: self.pop_clip()
[docs] def push_transform(self, a: float, b: float, c: float, d: float, tx: float, ty: float): self._record(DrawCall("push_transform", x=tx, y=ty)) self._xf_stack.append((self._xf, self._base_xf, self._node_xf, self._screen_scope)) oa, ob, oc, od, otx, oty = self._xf self._xf = ( oa * a + ob * c, oa * b + ob * d, oc * a + od * c, oc * b + od * d, oa * tx + ob * ty + otx, oc * tx + od * ty + oty, ) self._base_xf, self._node_xf = self._xf, _IDENTITY_XF
[docs] def pop_transform(self): self._record(DrawCall("pop_transform")) if self._xf_stack: self._xf, self._base_xf, self._node_xf, self._screen_scope = self._xf_stack.pop()
[docs] def push_screen_transform(self, a: float, b: float, c: float, d: float, tx: float, ty: float): """Enter a screen-pinned scope: SET (not compose) the affine, as the real surface does. A ``CanvasLayer`` that 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 recorded ``push_screen_transform`` call 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`. """ self._xf_stack.append((self._xf, self._base_xf, self._node_xf, self._screen_scope)) self._xf = self._base_xf = (a, b, c, d, tx, ty) self._node_xf = _IDENTITY_XF self._screen_scope = True self._record(DrawCall("push_screen_transform", x=tx, y=ty))
[docs] def push_node_transform(self, a: float, b: float, c: float, d: float, tx: float, ty: float): """A node's own world affine, composed onto the canvas scope as the real renderer does.""" self._record(DrawCall("push_transform", x=tx, y=ty)) self._xf_stack.append((self._xf, self._base_xf, self._node_xf, self._screen_scope)) ba, bb, bc, bd, btx, bty = self._base_xf self._xf = ( ba * a + bb * c, ba * b + bb * d, bc * a + bd * c, bc * b + bd * d, ba * tx + bb * ty + btx, bc * tx + bd * ty + bty, ) self._node_xf = (a, b, c, d, tx, ty)
def _add(self, call: DrawCall) -> None: """Record *call* where it lands: the current transform maps its points and scales its sizes.""" if self._xf != _IDENTITY_XF: a, b, c, d, tx, ty = self._xf k = math.sqrt(abs(a * d - b * c)) call = dataclasses.replace( call, x=a * call.x + b * call.y + tx, y=c * call.x + d * call.y + ty, x2=a * call.x2 + b * call.y2 + tx, y2=c * call.x2 + d * call.y2 + ty, w=call.w * k, h=call.h * k, width=call.width * k, scale=call.scale * k if call.type == "text" else call.scale, ) self._record(call) def _record(self, call: DrawCall) -> None: """Append *call*, stamped with the scope it was recorded in.""" call.screen_space = self._screen_scope self.calls.append(call)
[docs] def rect(self, pos, size, *, fill=None, stroke=None, width: float = 1.0, blend: str = "alpha"): """Record a rectangle. ``pos`` and ``size`` must 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. """ if fill is None and stroke is None: raise TypeError("rect() draws nothing without paint: pass fill=<colour>, stroke=<colour>, or both") x, y = _xy(pos) w, h = _xy(size) if fill is not None: self._add(DrawCall("fill_rect", x=x, y=y, w=w, h=h, colour=_fill_colour(fill))) if stroke is not None: self._add(DrawCall("rect", x=x, y=y, w=w, h=h, colour=_colour(stroke), width=float(width)))
[docs] def line(self, *points, stroke=None, width: float = 1.0): """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. """ if stroke is None: raise TypeError("line() draws nothing without paint: pass stroke=<colour>") pts = _line_points(points) if len(pts) < 2: return for (x1, y1), (x2, y2) in zip(pts, pts[1:], strict=False): self._add(DrawCall("line", x=x1, y=y1, x2=x2, y2=y2, width=float(width), colour=_colour(stroke)))
[docs] def text( self, text: str, pos=None, *, fill=None, scale: float = 1.0, align: HAlign | str = HAlign.LEFT, valign: 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, ): if fill is None: raise TypeError("text() draws nothing without paint: pass fill=<colour>") align, valign = HAlign(align), VAlign(valign) 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 = _LOGICAL_BASE * scale n_lines = text.count("\n") + 1 th = glyph_h if n_lines == 1 else glyph_h * 1.2 * n_lines if align is HAlign.CENTRE: x = rx + (rw - tw) / 2 elif align is HAlign.RIGHT: x = rx + rw - tw else: x = rx if valign is VAlign.CENTRE: y = ry + (rh - th) / 2 elif valign is VAlign.BOTTOM: y = ry + rh - th else: y = ry elif pos is None: x, y = 0.0, 0.0 else: x, y = _xy(pos) self._add(DrawCall("text", x=x, y=y, text=text, scale=scale, colour=_colour(fill)))
[docs] def circle(self, centre, radius: float, *, fill=None, stroke=None, width: float = 1.0, segments: int = 32): if fill is None and stroke is None: raise TypeError("circle() draws nothing without paint: pass fill=<colour>, stroke=<colour>, or both") cx, cy = _xy(centre) if fill is not None: reject_gradient(fill, "circle") self._add(DrawCall("fill_circle", x=cx, y=cy, w=float(radius), h=float(radius), colour=_colour(fill))) if stroke is not None: self._add( DrawCall( "circle", x=cx, y=cy, w=float(radius), h=float(radius), colour=_colour(stroke), width=float(width) ) )
[docs] def polygon(self, points, *, fill=None, stroke=None, width: float = 1.0, blend: str = "alpha"): if fill is None and stroke is None: raise TypeError("polygon() draws nothing without paint: pass fill=<colour>, stroke=<colour>, or both") if fill is not None: reject_gradient(fill, "polygon") self._add(DrawCall("fill_polygon", colour=_colour(fill))) if stroke is not None: self._add(DrawCall("polygon", colour=_colour(stroke), width=float(width)))
[docs] def texture( self, source, pos, size, *, region=None, tint=None, rotation: float = 0.0, blend: str = "alpha", filter: str = "linear", ): """Record a textured quad. There is no texture manager here, so the source is recorded rather than resolved: an ``int`` is a slot a test can match on, and anything else (a path, bytes, an array, a resource) is recorded as an ``"image"`` call carrying its ``str()``, which is what a test asserting "this picture was drawn" has to read. """ x, y = _xy(pos) w, h = _xy(size) if isinstance(source, int) and not isinstance(source, bool): self._add(DrawCall("texture", x=x, y=y, w=w, h=h, colour=_colour(tint))) else: self._add(DrawCall("image", x=x, y=y, w=w, h=h, colour=_colour(tint), text=str(source)))
[docs] def nine_patch( self, source, pos, size, *, texture_size, margins=(0.0, 0.0, 0.0, 0.0), draw_centre: bool = True, tint=None, filter: str = "linear", ): """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. """ x, y = _xy(pos) w, h = _xy(size) self._add(DrawCall("nine_patch", x=x, y=y, w=w, h=h, colour=_colour(tint), text=str(source)))
# -- 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.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(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 # Close the frame this harness was part-way through. Per-frame input # state is spent at a frame boundary, and a harness that drove input # and never reached one would otherwise hand the edges it raised, and # the marks a claim left, to whatever runs next in the process. self.tree.input._end_frame() # Abandon pending destroys while the queued nodes are still whole: the wipe # below clears every node's ``__dict__``. self.tree.clear_delete_queue() 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._groups.clear() self.tree.overlays.close_all() self.tree._ui._focused_control = None self.tree._ui.release_grabs() # 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.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.flush_pointer_motion() 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.flush_pointer_motion() self.tree.tick(dt) Input._end_frame()
# ======================================================================== # Input helpers # ========================================================================
[docs] @property def router(self) -> InputRouter: """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. """ return self._sim.router
[docs] def click(self, target: Control | Vec2 | tuple[float, float], button: DeviceButton = Mouse.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: DeviceButton = Mouse.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: DeviceButton = Mouse.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: DeviceButton = Mouse.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 the mouse to a position, and settle the hover it leaves behind. The engine recomputes ``mouse_over`` once 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. """ pos = self._resolve_position(target) self._sim.move_mouse(pos[0], pos[1]) self.tree._ui.flush_hover(self.tree.root)
[docs] def drag(self, start: Control | Vec2, end: Control | Vec2, steps: int = 5, button: DeviceButton = Mouse.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 a text event for each character to the focused widget.""" for ch in text: self.tree.ui_text(TextEvent(text=ch))
[docs] def press_key(self, key: str, release: bool = True): """Send key press (and optionally release) event. Takes a written combo (``"escape"``, ``"ctrl+s"``), which :func:`~simvx.core.input.events.key_event_for_combo` reads into the :class:`~simvx.core.input.events.KeyEvent` the UI receives. ``InputSimulator.press_key`` takes a ``Key`` enum 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. """ event = key_event_for_combo(key) old_keys = dict(Input._keys) try: for mod, held in (("ctrl", event.ctrl), ("shift", event.shift), ("alt", event.alt)): if held: Input._keys[mod] = True # The capture phase first, as a window's router runs it, so a # global shortcut answers here as it does in the running app. if self.tree.capture_input(event) is None: self.tree.ui_key(event) if release: up = dataclasses.replace(event, pressed=False) if self.tree.capture_input(up) is None: self.tree.ui_key(up) 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): """Put a finger down on a control, through the router, which hands the UI the touch press.""" self._sim.touch_down(finger_id, self._resolve_position(target))
[docs] def touch_up(self, target: Control | Vec2 | tuple[float, float], finger_id: int = 0): """Lift a finger over a control, through the router.""" self._sim.touch_up(finger_id, self._resolve_position(target))
[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)
# ======================================================================== # 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) _, _, w, h = target._local_rect() centre = target.to_global((w / 2, h / 2)) return (float(centre.x), float(centre.y)) 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