Source code for simvx.editor.testing.demo_steps.handlers.nodes

"""Scene-tree and property manipulation handlers."""

import logging

from simvx.core import Node
from simvx.core.scripted_demo import DemoRunner

from .._helpers import (
    _drive_click,
    _find_tree_item,
    _get_editor,
    _get_state,
    _init_click,
    _resolve_node,
)
from ..steps import (
    AddNode,
    AssertConfig,
    AssertProperty,
    AssertTree,
    ClickTreeItem,
    ConfigureNode,
    PlaceNode,
    PlayScene,
    RenameNode,
    SelectNode,
    SetProperty,
    SetScript,
    StopScene,
    SwitchViewport,
)

log = logging.getLogger(__name__)


def _handle_add_node(runner: DemoRunner, step: AddNode, dt: float):
    state = _get_state(runner)
    if not state:
        runner._advance()
        return
    cls = Node._registry.get(step.type_name)
    if not cls:
        raise ValueError(f"Unknown node type: {step.type_name}")
    node = cls(name=step.name or step.type_name)
    parent = state.find_node(step.parent_path)
    state.add_node(node, parent)
    # Rebuild the scene tree panel if available
    editor = _get_editor(runner)
    if editor and editor.scene_tree_panel and hasattr(editor.scene_tree_panel, "_rebuild_tree"):
        editor.scene_tree_panel._rebuild_tree()
    runner._advance()

def _handle_rename_node(runner: DemoRunner, step: RenameNode, dt: float):
    state = _get_state(runner)
    if not state:
        runner._advance()
        return
    node = state.find_node(step.path)
    if not node:
        raise ValueError(f"Node not found: {step.path}")
    state.rename_node(node, step.new_name)
    editor = _get_editor(runner)
    if editor and editor.scene_tree_panel and hasattr(editor.scene_tree_panel, "_rebuild_tree"):
        editor.scene_tree_panel._rebuild_tree()
    runner._advance()

def _handle_select_node(runner: DemoRunner, step: SelectNode, dt: float):
    state = _get_state(runner)
    if not state:
        runner._advance()
        return
    node = state.find_node(step.path)
    if node:
        state.selection.select(node)
    editor = _get_editor(runner)
    if editor and editor.scene_tree_panel and hasattr(editor.scene_tree_panel, "_rebuild_tree"):
        editor.scene_tree_panel._rebuild_tree()
    runner._advance()

def _handle_set_property(runner: DemoRunner, step: SetProperty, dt: float):
    state = _get_state(runner)
    if not state:
        runner._advance()
        return
    node = state.find_node(step.path)
    if not node:
        raise ValueError(f"Node not found: {step.path}")
    state.set_node_property(node, step.prop, step.value)
    runner._advance()

def _handle_assert_property(runner: DemoRunner, step: AssertProperty, dt: float):
    state = _get_state(runner)
    node = _resolve_node(runner, step.path) if state else None
    if not node:
        msg = step.message or f"AssertProperty: node not found: {step.path}"
        runner._failures.append(msg)
        runner._failed = True
        if runner._test_mode:
            raise AssertionError(msg)
        runner._advance()
        return
    actual = getattr(node, step.prop, None)
    ok = False
    if isinstance(step.expected, float) and actual is not None:
        ok = abs(actual - step.expected) < step.tolerance
    else:
        ok = actual == step.expected
    if not ok:
        msg = step.message or f"{step.path}.{step.prop}: expected {step.expected!r}, got {actual!r}"
        runner._failures.append(msg)
        runner._failed = True
        if runner._test_mode:
            raise AssertionError(msg)
    runner._advance()

def _handle_set_script(runner: DemoRunner, step: SetScript, dt: float):
    state = _get_state(runner)
    if state:
        state.set_script_text(step.text)
    runner._advance()

def _handle_play_scene(runner: DemoRunner, step: PlayScene, dt: float):
    state = _get_state(runner)
    if state:
        state.play_scene()
    runner._advance()

def _handle_stop_scene(runner: DemoRunner, step: StopScene, dt: float):
    state = _get_state(runner)
    if state:
        state.stop_scene()
    runner._advance()

def _handle_switch_viewport(runner: DemoRunner, step: SwitchViewport, dt: float):
    editor = _get_editor(runner)
    if editor:
        editor._on_mode(step.mode)
    runner._advance()

def _handle_assert_tree(runner: DemoRunner, step: AssertTree, dt: float):
    state = _get_state(runner)
    root = state.find_node("") if state else None
    if not root:
        msg = step.message or "AssertTree: no scene root"
        runner._failures.append(msg)
        runner._failed = True
        if runner._test_mode:
            raise AssertionError(msg)
        runner._advance()
        return
    for path, spec in step.expected.items():
        try:
            n = root[path] if path else root
        except (KeyError, ValueError):
            msg = step.message or f"AssertTree: node not found: {path}"
            runner._failures.append(msg)
            runner._failed = True
            if runner._test_mode:
                raise AssertionError(msg) from None
            runner._advance()
            return
        if isinstance(spec, tuple):
            type_name, child_count = spec
            actual_type = type(n).__name__
            actual_count = len(list(n.children))
            if actual_type != type_name or actual_count != child_count:
                msg = step.message or (
                    f"AssertTree: {path or 'root'}: expected ({type_name}, {child_count}), "
                    f"got ({actual_type}, {actual_count})"
                )
                runner._failures.append(msg)
                runner._failed = True
                if runner._test_mode:
                    raise AssertionError(msg)
                runner._advance()
                return
        else:
            actual_type = type(n).__name__
            if actual_type != spec:
                msg = step.message or f"AssertTree: {path or 'root'}: expected {spec}, got {actual_type}"
                runner._failures.append(msg)
                runner._failed = True
                if runner._test_mode:
                    raise AssertionError(msg)
                runner._advance()
                return
    runner._advance()

def _handle_place_node(runner: DemoRunner, step: PlaceNode, dt: float):
    state = _get_state(runner)
    if not state:
        runner._advance()
        return
    cls = Node._registry.get(step.type_name)
    if not cls:
        raise ValueError(f"Unknown node type: {step.type_name}")
    parent = state.find_node(step.parent_path)
    # Use place_node_at for position-aware placement
    state.pending_place_type = cls
    node = state.place_node_at(step.x, step.y, parent)
    if node and step.name:
        node.name = step.name
    # Rebuild the scene tree panel if available
    editor = _get_editor(runner)
    if editor and editor.scene_tree_panel and hasattr(editor.scene_tree_panel, "_rebuild_tree"):
        editor.scene_tree_panel._rebuild_tree()
    runner._advance()

def _handle_configure_node(runner: DemoRunner, step: ConfigureNode, dt: float):
    node = _resolve_node(runner, step.path)
    if not node:
        raise ValueError(f"ConfigureNode: node not found: {step.path}")
    for k, v in step.props.items():
        setattr(node, k, v)
    runner._advance()

def _handle_assert_config(runner: DemoRunner, step: AssertConfig, dt: float):
    node = _resolve_node(runner, step.path)
    if not node:
        msg = step.message or f"AssertConfig: node not found: {step.path}"
        runner._failures.append(msg)
        runner._failed = True
        if runner._test_mode:
            raise AssertionError(msg)
        runner._advance()
        return
    for k, expected in step.props.items():
        actual = getattr(node, k, None)
        ok = True
        if actual is None:
            ok = False
        elif isinstance(expected, float):
            ok = abs(actual - expected) < 0.5
        elif isinstance(expected, tuple):
            ok = len(actual) == len(expected) and all(abs(a - e) < 0.02 for a, e in zip(actual, expected, strict=True))
        else:
            ok = actual == expected
        if not ok:
            msg = step.message or f"AssertConfig: {step.path}.{k}: expected {expected!r}, got {actual!r}"
            runner._failures.append(msg)
            runner._failed = True
            if runner._test_mode:
                raise AssertionError(msg)
            runner._advance()
            return
    runner._advance()

def _handle_click_tree_item(runner: DemoRunner, step: ClickTreeItem, dt: float):
    runner._action_desc = f"Select: {step.path.rsplit('/', 1)[-1] or 'root'}"
    editor = _get_editor(runner)
    if step._phase == 0:
        if not editor or not editor.scene_tree_panel:
            runner._advance()
            return
        target_node = editor.state.find_node(step.path)
        if not target_node:
            runner._advance()
            return
        tree_view = editor.scene_tree_panel._tree_view
        for item, rx, ry, depth in tree_view._row_map:
            if item.data is target_node:
                click_x = rx + depth * tree_view.indent + tree_view.row_height + 20
                click_y = ry + tree_view.row_height / 2
                _init_click(step, runner, (click_x, click_y))
                step._phase = 1
                return
        # Not visible in _row_map, find the TreeItem and expand ancestors
        tree_item = _find_tree_item(tree_view.root, target_node) if tree_view.root else None
        if not tree_item:
            raise ValueError(f"ClickTreeItem: node not found in tree: {step.path}")
        parent = tree_item.parent
        while parent:
            parent.expanded = True
            parent = parent.parent
        editor.scene_tree_panel._rebuild_tree()
        # If _row_map is empty (headless, draw never runs), select through TreeView
        if not tree_view._row_map:
            tree_view.selected = tree_item
            tree_view.item_selected.emit(tree_item)
            runner._advance()
            return
        # Otherwise retry once on next frame (draw will populate _row_map)
        if step._retried:
            raise ValueError(f"ClickTreeItem: node not visible after expanding ancestors: {step.path}")
        step._retried = True
    elif step._phase == 1:
        if _drive_click(runner, step, dt):
            runner._advance()