Source code for simvx.core.testing.diagnostics

"""Diagnostics -- scene comparison, description, and performance measurement."""

from __future__ import annotations

import time

import numpy as np

from ..node import Node

__all__ = [
    "scene_diff",
    "NodeCounter",
    "FrameTimer",
    "scene_describe",
    "ui_describe",
]


# ============================================================================
# scene_diff -- compare two scene snapshots
# ============================================================================


[docs] def scene_diff(before: dict, after: dict, _path: str = "") -> list[str]: """Compare two scene snapshots and return a list of human-readable differences. Each entry describes a single change, e.g.: "Root/Player.position: (0, 0) -> (10, 5)" "Root/Enemy: REMOVED" "Root/PowerUp: ADDED (Node2D)" """ if not before and not after: return [] if not before: return [f"{_path or '?'}: ADDED ({after.get('type', '?')})"] if not after: return [f"{_path or '?'}: REMOVED"] diffs: list[str] = [] current = f"{_path}/{before['name']}" if _path else before.get("name", "?") # Compare settings bs = before.get("settings", {}) as_ = after.get("settings", {}) for key in sorted(set(bs) | set(as_)): bv = bs.get(key) av = as_.get(key) if bv != av: diffs.append(f"{current}.{key}: {bv!r} -> {av!r}") # Compare properties (position, rotation, etc.) bp = before.get("properties", {}) ap = after.get("properties", {}) for key in sorted(set(bp) | set(ap)): bv = bp.get(key) av = ap.get(key) if bv != av: diffs.append(f"{current}.{key}: {bv!r} -> {av!r}") # Compare children by name before_children = {c["name"]: c for c in before.get("children", [])} after_children = {c["name"]: c for c in after.get("children", [])} for name in sorted(set(before_children) | set(after_children)): if name not in after_children: diffs.append(f"{current}/{name}: REMOVED") elif name not in before_children: diffs.append(f"{current}/{name}: ADDED ({after_children[name].get('type', '?')})") else: diffs.extend(scene_diff(before_children[name], after_children[name], current)) return diffs
# ============================================================================ # NodeCounter -- count nodes by type # ============================================================================
[docs] class NodeCounter: """Count nodes by type in a scene tree. Useful for debugging and assertions."""
[docs] @staticmethod def count(root: Node) -> dict[str, int]: """Return a dict mapping type name to count.""" counts: dict[str, int] = {} _walk_count(root, counts) return counts
[docs] @staticmethod def total(root: Node) -> int: """Return total number of nodes in the tree.""" return sum(NodeCounter.count(root).values())
def _walk_count(node: Node, counts: dict[str, int]) -> None: type_name = type(node).__name__ counts[type_name] = counts.get(type_name, 0) + 1 for child in node.children: _walk_count(child, counts) # ============================================================================ # FrameTimer -- measure frame timing for performance testing # ============================================================================
[docs] class FrameTimer: """Measure frame timing for performance testing. Usage: timer = FrameTimer() for _ in range(100): timer.begin_frame() runner.advance_frames(1) timer.end_frame() print(f"Average: {timer.average_ms:.2f}ms, FPS: {timer.fps:.0f}") """ def __init__(self): self._times: list[float] = [] self._start: float | None = None
[docs] def begin_frame(self) -> None: self._start = time.perf_counter()
[docs] def end_frame(self) -> None: if self._start is not None: self._times.append(time.perf_counter() - self._start) self._start = None
@property def average_ms(self) -> float: return (sum(self._times) / len(self._times) * 1000) if self._times else 0.0 @property def max_ms(self) -> float: return max(self._times) * 1000 if self._times else 0.0 @property def min_ms(self) -> float: return min(self._times) * 1000 if self._times else 0.0 @property def fps(self) -> float: avg = sum(self._times) / len(self._times) if self._times else 0 return 1.0 / avg if avg > 0 else 0.0 @property def frame_count(self) -> int: return len(self._times)
[docs] def reset(self) -> None: self._times.clear() self._start = None
# ============================================================================ # scene_describe -- LLM/human-readable scene tree description # ============================================================================
[docs] def scene_describe(root: Node, include_properties: bool = True, include_layout: bool = True) -> str: """Return a tree-formatted text description of the scene hierarchy. Uses box-drawing chars for hierarchy. Each line shows the node name, type, and relevant state (position, rotation, scale, visibility, Property values). Designed for LLM consumption -- an LLM can read this to understand what's on screen. """ lines: list[str] = [] _describe_node(root, lines, "", True, include_properties, include_layout, is_ui=False) return "\n".join(lines)
# ============================================================================ # ui_describe -- LLM/human-readable UI tree description # ============================================================================
[docs] def ui_describe(root, include_layout: bool = True) -> str: """Return a tree-formatted text description of a UI widget hierarchy. Focused on UI-specific info: widget type, text content, rect, focus, visibility. Designed for LLM consumption -- an LLM can read this to understand the UI state. """ lines: list[str] = [] _describe_node(root, lines, "", True, include_properties=False, include_layout=include_layout, is_ui=True) return "\n".join(lines)
def _format_vec(v) -> str: """Format a Vec2/Vec3/ndarray compactly.""" if hasattr(v, "x") and hasattr(v, "y"): if hasattr(v, "z"): return f"({v.x:.3g}, {v.y:.3g}, {v.z:.3g})" return f"({v.x:.3g}, {v.y:.3g})" if isinstance(v, np.ndarray): return "(" + ", ".join(f"{float(x):.3g}" for x in v) + ")" return repr(v) def _is_default_position(v) -> bool: """Check if a position vector is at origin.""" try: if hasattr(v, "x") and hasattr(v, "y"): if hasattr(v, "z"): return float(v.x) == 0.0 and float(v.y) == 0.0 and float(v.z) == 0.0 return float(v.x) == 0.0 and float(v.y) == 0.0 if isinstance(v, np.ndarray): return all(float(x) == 0.0 for x in v) except (TypeError, ValueError): pass return False def _is_default_scale(v) -> bool: """Check if a scale vector is (1, 1) or (1, 1, 1).""" try: if hasattr(v, "x") and hasattr(v, "y"): if hasattr(v, "z"): return float(v.x) == 1.0 and float(v.y) == 1.0 and float(v.z) == 1.0 return float(v.x) == 1.0 and float(v.y) == 1.0 if isinstance(v, np.ndarray): return all(float(x) == 1.0 for x in v) except (TypeError, ValueError): pass return False def _is_default_rotation(v) -> bool: """Check if rotation is at default (0 for float, identity for Quat).""" if isinstance(v, (int, float)): return float(v) == 0.0 if hasattr(v, "w") and hasattr(v, "x"): return float(v.w) == 1.0 and float(v.x) == 0.0 and float(v.y) == 0.0 and float(v.z) == 0.0 return False def _describe_node( node: Node, lines: list[str], prefix: str, is_last: bool, include_properties: bool, include_layout: bool, is_ui: bool, is_root: bool = True, ) -> None: """Recursively build tree description lines for a node.""" if is_root: connector = "" child_prefix = "" else: connector = "\u2514\u2500 " if is_last else "\u251c\u2500 " child_prefix = prefix + (" " if is_last else "\u2502 ") type_name = type(node).__name__ parts: list[str] = [f"{node.name} ({type_name})"] if not node.visible: parts.append("visible=False") if is_ui: if hasattr(node, "text") and node.text: text_val = node.text if len(text_val) > 40: text_val = text_val[:37] + "..." parts.append(f'text="{text_val}"') if hasattr(node, "focused") and node.focused: parts.append("focused") if include_layout and hasattr(node, "get_global_rect"): try: x, y, w, h = node.get_global_rect() parts.append(f"rect=({x:.3g}, {y:.3g}, {w:.3g}, {h:.3g})") except Exception: pass else: if include_layout: if hasattr(node, "position") and not _is_default_position(node.position): parts.append(f"pos={_format_vec(node.position)}") if hasattr(node, "rotation") and not _is_default_rotation(node.rotation): v = node.rotation if isinstance(v, (int, float)): parts.append(f"rot={v:.3g}") else: parts.append(f"rot={_format_vec(v)}") if hasattr(node, "scale") and not _is_default_scale(node.scale): parts.append(f"scale={_format_vec(node.scale)}") if include_properties: props = type(node).get_properties() for pname, pdesc in sorted(props.items()): try: val = getattr(node, pname) if val != pdesc.default: parts.append(f"{pname}={val!r}") except Exception: pass line = prefix + connector + " ".join(parts) lines.append(line) child_list = list(node.children) for i, child in enumerate(child_list): _describe_node( child, lines, child_prefix, i == len(child_list) - 1, include_properties, include_layout, is_ui, is_root=False, )