otko/tests/gui/test_draw_node_tool.py
smillmorel ba783718d4 chore: adopt remaining local development state
Catch-all for the intermixed residue of the unpushed otko-development
work ported into this tree: combinations/console-dock/quick-guide wiring
across commands, core, services, views and tests; repo-wide ruff-format
normalization; README/CONTRIBUTING updates; and the toolbar default
(both toolbars now open in the top area, quick guide text updated).

Splitting this further would require hunk-level surgery with low
confidence; the preceding commits in this branch isolate the
self-contained features.
2026-09-16 12:03:22 -04:00

149 lines
5.4 KiB
Python

"""Unit tests for DrawNodeTool.
The tool only uses ``vm.project`` and ``vm.apply_command``; it does
not touch the canvas except through the stub selection interface.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6")
from otko.core import GridSystem, Node, Project # noqa: E402
from otko.viewmodels import ProjectViewModel # noqa: E402
from otko.views.canvas3d.selection import SelectionState # noqa: E402
from otko.views.tools.draw_node import ( # noqa: E402
DrawNodeTool,
_snap_to_grid,
)
class _CanvasStub:
def __init__(self) -> None:
self.selection = SelectionState()
def view_xy(self) -> None:
# No-op: real canvas switches camera; tool tests don't care.
pass
def set_snap_preview_enabled(self, _enabled: bool) -> None:
# Hover-snap preview is a real-canvas feature; tool tests ignore it.
pass
def _vm_with_grid() -> ProjectViewModel:
vm = ProjectViewModel()
vm.new_project()
vm.project.grid_system = GridSystem( # type: ignore[union-attr]
x_lines=[0.0, 3.0, 6.0],
y_lines=[0.0, 4.0],
z_lines=[0.0],
)
return vm
# ────────────────────────── snap helper ─────────────────────────────
def test_snap_to_grid_picks_nearest_lines() -> None:
x, y, z = _snap_to_grid(
2.2,
3.7,
0.6,
x_lines=[0.0, 3.0],
y_lines=[0.0, 4.0],
z_lines=[0.0, 3.0],
)
assert (x, y, z) == (3.0, 4.0, 0.0)
def test_snap_to_grid_identity_on_empty_axes() -> None:
x, y, z = _snap_to_grid(1.5, 2.5, 3.5, x_lines=[], y_lines=[], z_lines=[])
assert (x, y, z) == (1.5, 2.5, 3.5)
# ────────────────────────── tool behaviour ──────────────────────────
# NB: the canvas now does pixel-space snap BEFORE emitting emptyClicked;
# the tool receives only valid grid-intersection coordinates. These tests
# simulate that by passing exact intersection coords.
@pytest.mark.gui
def test_empty_click_creates_node_at_exact_intersection(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_grid()
tool = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
# Canvas guarantees the coords ARE an intersection.
tool.on_empty_clicked(0.0, 0.0, 0.0)
assert len(vm.project.nodes) == 1 # type: ignore[union-attr]
assert vm.project.nodes[0].coords == (0.0, 0.0, 0.0)
tool.on_empty_clicked(3.0, 4.0, 0.0)
assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
assert vm.project.nodes[1].coords == (3.0, 4.0, 0.0) # type: ignore[union-attr]
@pytest.mark.gui
def test_empty_click_duplicate_is_rejected(qtbot) -> None: # type: ignore[no-untyped-def]
"""Clicking exactly on an existing node's coords must not duplicate it."""
vm = _vm_with_grid()
vm.project.nodes.append(Node(id=42, coords=(3.0, 4.0, 0.0))) # type: ignore[union-attr]
tool = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
status_msgs: list[str] = []
tool.statusChanged.connect(status_msgs.append)
tool.on_empty_clicked(3.0, 4.0, 0.0)
assert len(vm.project.nodes) == 1 # type: ignore[union-attr] (unchanged)
assert any("already exists" in m for m in status_msgs)
@pytest.mark.gui
def test_grid_less_canvas_never_emits_to_tool(qtbot) -> None: # type: ignore[no-untyped-def]
"""Documents the new contract: tool simply trusts canvas.
The canvas only emits emptyClicked when it has a valid snap
target. For a grid-less project, the canvas never emits, so the
tool is never invoked. We therefore don't test rejection here.
"""
vm = ProjectViewModel()
vm.new_project()
tool = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
assert vm.project.nodes == [] # type: ignore[union-attr]
@pytest.mark.gui
def test_consecutive_clicks_allocate_unique_ids(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_grid()
tool = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
tool.on_empty_clicked(0.0, 0.0, 0.0)
tool.on_empty_clicked(3.0, 0.0, 0.0)
tool.on_empty_clicked(6.0, 0.0, 0.0)
ids = [n.id for n in vm.project.nodes] # type: ignore[union-attr]
assert len(ids) == 3
assert len(set(ids)) == 3 # all unique
@pytest.mark.gui
def test_picking_existing_node_emits_status_only(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_grid()
vm.project.nodes.append(Node(id=1, coords=(0, 0, 0))) # type: ignore[union-attr]
tool = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
status_msgs: list[str] = []
tool.statusChanged.connect(status_msgs.append)
tool.on_node_picked(1)
# No new nodes; user was told about the existing one.
assert len(vm.project.nodes) == 1 # type: ignore[union-attr]
assert any("already exists" in m for m in status_msgs)
@pytest.mark.gui
def test_tool_is_active_after_activate(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_grid()
tool = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
assert tool.is_active is False
tool.activate()
assert tool.is_active is True
tool.deactivate()
assert tool.is_active is False