diff --git a/.gitignore b/.gitignore index b111ed7..184bbc3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,20 @@ -install-prefix/ +# Build artifacts build/ +bridge/build/ +install-prefix/ + +# Python +__pycache__/ +.pytest_cache/ +*.pyc +*.pyo +*.egg-info/ +venv/ +.venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f992a5f --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# QML SceneGraph MCP Bridge + +A [MCP](https://modelcontextprotocol.io/) server that bridges [GammaRay](https://github.com/KDAB/GammaRay) probe introspection data into MCP tools, enabling LLMs to inspect and debug Qt Quick / QML scene graphs, items, geometry, and materials. + +## Architecture + +``` +Target Qt/QML App ──TCP──► GammaRay MCP Bridge ──stdio──► LLM / AI Agent + (GammaRay probe (GammaRay client + (opencode, + injected) qtmcp MCP server) Claude Desktop, etc.) +``` + +The bridge is a GammaRay **client**: it connects to a probe injected into a target Qt/QML app, reads the same models the GammaRay GUI uses, and translates them into MCP JSON-RPC tool calls over stdio. + +## Prerequisites + +- Qt 6.8+ (system) +- C++20 compiler (GCC 12+, Clang 16+) +- GammaRay 3.4.0+ built from source (see [build instructions](#building-gammaray)) +- [qtmcp](https://github.com/signal-slot/qtmcp) (consumed via FetchContent — no manual install) +- Python 3.10+ with `pytest` (for running the test suite) + +## Building + +### 1. Build and install GammaRay + +```bash +cmake -S /path/to/GammaRay -B /path/to/GammaRay/build \ + -DCMAKE_INSTALL_PREFIX=$(pwd)/install-prefix +cmake --build /path/to/GammaRay/build +cmake --install /path/to/GammaRay/build --prefix $(pwd)/install-prefix +``` + +### 2. Build the bridge + +```bash +cd bridge +cmake -S . -B build -G Ninja -DCMAKE_PREFIX_PATH=$(pwd)/../install-prefix +cmake --build build +``` + +### 3. Run + +```bash +# Start a probe (inject into a QML app) +install-prefix/bin/gammaray --inject-only --listen tcp://127.0.0.1:11732 \ + --injector preload /usr/lib/qt6/bin/qml /path/to/app.qml -platform offscreen + +# Start the bridge (stdio MCP server) +./run.sh +``` + +The bridge starts in lazy-connect mode. Call `connectProbe("127.0.0.1", 11732)` from your MCP client once the probe is up. + +## MCP Tools + +### Connection management +| Tool | Description | +|---|---| +| `connectProbe(host, port)` | Connect to a GammaRay probe (defaults: 127.0.0.1:11732) | +| `connectProbeDefault()` | Convenience: connect to 127.0.0.1:11732 | +| `disconnectProbe()` | Drop connection and forget URL | +| `probeStatus()` | Report connection state | + +### Navigation +| Tool | Description | +|---|---| +| `listQuickWindows()` | List QQuickWindows in the target app | +| `selectQuickWindow(index)` | Select a window for scene graph introspection | +| `listQuickItems()` | Recursive QQuickItem tree with types and flags | +| `listScenegraphNodes()` | Recursive QSGNode tree (all node types) | + +### QML Item inspection +| Tool | Description | +|---|---| +| `selectQuickItem(address)` | Select a QML item by address, populating its properties model | +| `getItemProperties(address)` | Get all Q_PROPERTY values (x, y, width, height, opacity, visible, z, anchors, text, font, etc.) | + +### SG Node inspection +| Tool | Description | +|---|---| +| `selectScenegraphNode(address)` | Select a SG node, populating geometry/material sub-models | +| `getNodeVertices(address)` | Read vertex data of a GeometryNode | +| `getNodeAdjacency(address)` | Read adjacency/drawing mode of a GeometryNode | +| `getMaterialShaders(address)` | List shader stages for a node's material | +| `getShaderSource(row)` | Get shader source code (async via MaterialExtensionInterface) | +| `getMaterialProperties(address)` | Get material property name/value pairs | + +### Rendering visualization +| Tool | Description | +|---|---| +| `setRenderMode(mode)` | Set render mode (NormalRendering, VisualizeOverdraw, etc.) | +| `setSlowMode(enabled)` | Toggle continuous rendering | + +## Testing + +```bash +# Unit tests only (no probe needed): +./tests/run_tests.sh --no-probe + +# Full integration test suite (probe required): +./tests/run_tests.sh --no-probe # skips probe-dependent tests +./tests/run_tests.sh -v # verbose +``` + +See `tests/README.md` for details. + +## License + +GPL-2.0-or-later. The bridge links GammaRay libraries (GPL-2.0-or-later) and uses qtmcp (available under GPL-2.0-only). \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9ee3545 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +markers = [ + "probe: tests that require a running GammaRay probe", +] +filterwarnings = ["ignore::DeprecationWarning"] \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..55e182f --- /dev/null +++ b/tests/README.md @@ -0,0 +1,55 @@ +# Test Suite + +Integration tests for the gammaray-mcp bridge, written in Python using `pytest`. Tests communicate with the bridge over stdio JSON-RPC (the same protocol MCP clients use), exercising every tool end-to-end against a real GammaRay probe. + +## Prerequisites + +- The bridge must be built: `cmake --build bridge/build` +- `pytest` (install via `pip3 install pytest` or `pip install pytest` in a venv) +- For probe-dependent tests: a QML test app at `~/Sources/testqml/relayout.qml` (configurable via `TEST_QML` env) + +## Running + +```bash +# From the project root: +./tests/run_tests.sh --no-probe # unit tests only (no probe needed) +./tests/run_tests.sh -v # full suite, verbose +./tests/run_tests.sh -k "item" # run only item-related tests +./tests/run_tests.sh --no-probe -x # stop on first failure, no probe +``` + +Or directly with pytest: + +```bash +cd tests +LD_LIBRARY_PATH=../install-prefix/lib \ +QT_QPA_PLATFORM=offscreen \ +QT_PLUGIN_PATH=../bridge/build/lib/x86_64-linux-gnu/qt6/plugins \ +pytest -v +``` + +## Test files + +| File | Tests | Probe required | +|---|---|---| +| `test_connection.py` | `connectProbe`, `probeStatus`, `disconnectProbe`, reconnect | Yes (except `test_tools_listed`, `test_probe_status_disconnected`) | +| `test_navigation.py` | `listQuickWindows`, `selectQuickWindow`, `listQuickItems`, `listScenegraphNodes` | Yes | +| `test_items.py` | `selectQuickItem`, `getItemProperties`, position, visual properties, groups | Yes | +| `test_scenegraph.py` | `selectScenegraphNode`, `getNodeVertices`, `getNodeAdjacency`, `getMaterialShaders`, `getMaterialProperties`, `setRenderMode`, `setSlowMode` | Yes | + +## Markers + +- `@pytest.mark.probe` — test requires a running GammaRay probe. Automatically skipped with `--no-probe`. + +## Fixtures + +- `bridge` — fresh `McpClient` instance per test (initialized, not connected) +- `connected_bridge` — bridge connected to a probe (via `connectProbeDefault`) +- `probe_process` — session-scoped, starts a probe via `systemd-run` (fallback: direct spawn) and waits for `ready()` + +## Options + +| Flag | Default | Description | +|---|---|---| +| `--no-probe` | `false` | Skip all probe-dependent tests | +| `--probe-timeout` | 15s | Seconds to wait for probe to become ready | \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..28453af --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# gammaray-mcp test suite \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bf835e0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,170 @@ +"""pytest fixtures for gammaray-mcp bridge tests.""" + +import os +import subprocess +import time +from pathlib import Path + +import pytest + +from mcp_client import McpClient + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +BRIDGE_EXE = PROJECT_ROOT / "bridge" / "build" / "qml-sg-mcp-bridge" +RUN_SCRIPT = PROJECT_ROOT / "bridge" / "run.sh" +PROBE_BIN = PROJECT_ROOT / "install-prefix" / "bin" / "gammaray" +TEST_QML = Path(os.environ.get("TEST_QML", str(Path.home() / "Sources" / "testqml" / "relayout.qml"))) +QML_RUNNER = "/usr/lib/qt6/bin/qml" + + +def pytest_addoption(parser): + parser.addoption( + "--no-probe", + action="store_true", + default=False, + help="Skip tests that need a running probe", + ) + parser.addoption( + "--probe-timeout", + type=int, + default=15, + help="Seconds to wait for probe to be ready", + ) + + +def pytest_collection_modifyitems(config, items): + if config.getoption("--no-probe"): + skip_probe = pytest.mark.skip(reason="Skipping probe-dependent tests (--no-probe)") + for item in items: + if "probe" in item.keywords: + item.add_marker(skip_probe) + + +@pytest.fixture(scope="session") +def bridge_path() -> Path: + """Path to the bridge executable.""" + if not BRIDGE_EXE.exists(): + pytest.skip(f"Bridge not built at {BRIDGE_EXE}") + return BRIDGE_EXE + + +@pytest.fixture(scope="session") +def run_script_path() -> Path: + """Path to the run.sh wrapper.""" + if not RUN_SCRIPT.exists(): + pytest.skip(f"run.sh not found at {RUN_SCRIPT}") + return RUN_SCRIPT + + +@pytest.fixture(scope="session") +def probe_process(request) -> subprocess.Popen | None: + """Start a GammaRay probe process (session-scoped, one per test run).""" + if request.config.getoption("--no-probe"): + return None + + if not PROBE_BIN.exists(): + pytest.skip(f"GammaRay probe binary not found at {PROBE_BIN}") + if not TEST_QML.exists(): + pytest.skip(f"Test QML file not found at {TEST_QML}") + if not QML_RUNNER.exists(): + pytest.skip(f"QML runner not found at {QML_RUNNER}") + + env = os.environ.copy() + env["QT_QPA_PLATFORM"] = "offscreen" + env["LD_LIBRARY_PATH"] = str(PROJECT_ROOT / "install-prefix" / "lib") + + proc = None + used_systemd = False + + # Try systemd-run first for clean detachment + subprocess.run( + ["systemctl", "--user", "stop", "gammaray-probe-test.service"], + capture_output=True, timeout=5, + ) + subprocess.run( + ["systemctl", "--user", "reset-failed", "gammaray-probe-test.service"], + capture_output=True, timeout=5, + ) + + result = subprocess.run( + [ + "systemd-run", "--user", "--no-block", + "--unit=gammaray-probe-test", + "--same-dir", + "--working-directory", str(TEST_QML.parent), + "-E", f"QT_QPA_PLATFORM={env['QT_QPA_PLATFORM']}", + "-E", f"LD_LIBRARY_PATH={env['LD_LIBRARY_PATH']}", + str(PROBE_BIN), + "--inject-only", "--listen", "tcp://127.0.0.1:11732", + "--injector", "preload", + QML_RUNNER, str(TEST_QML), + ], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0: + used_systemd = True + else: + # Fallback: direct spawn + proc = subprocess.Popen( + [str(PROBE_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732", + "--injector", "preload", QML_RUNNER, str(TEST_QML)], + env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + def cleanup(): + if proc: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + if used_systemd: + subprocess.run( + ["systemctl", "--user", "stop", "gammaray-probe-test.service"], + capture_output=True, timeout=5, + ) + subprocess.run( + ["systemctl", "--user", "reset-failed", "gammaray-probe-test.service"], + capture_output=True, timeout=5, + ) + + request.addfinalizer(cleanup) + + # Wait for probe to be ready + timeout = request.config.getoption("--probe-timeout") + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + c = McpClient(bridge_path=BRIDGE_EXE) + c.initialize() + result = c.call_tool("connectProbeDefault") + c.close() + if isinstance(result, dict) and result.get("connected"): + break + except Exception: + pass + time.sleep(1) + else: + pytest.fail(f"Probe did not become ready within {timeout}s") + + return proc + + +@pytest.fixture +def bridge(run_script_path) -> McpClient: + """Start a fresh bridge instance per test.""" + c = McpClient(bridge_path=run_script_path) + c.initialize() + yield c + c.close() + + +@pytest.fixture +def connected_bridge(bridge, probe_process) -> McpClient: + """Bridge connected to a running probe.""" + if probe_process is None: + pytest.skip("No probe available") + result = bridge.call_tool("connectProbeDefault") + assert isinstance(result, dict), f"connectProbeDefault failed: {result}" + assert result.get("connected"), f"Not connected: {result}" + return bridge \ No newline at end of file diff --git a/tests/mcp_client.py b/tests/mcp_client.py new file mode 100644 index 0000000..9e7f8ba --- /dev/null +++ b/tests/mcp_client.py @@ -0,0 +1,90 @@ +"""MCP client helper for testing the gammaray-mcp bridge.""" + +import json +import subprocess +import sys +import time +from pathlib import Path + + +class McpClient: + """A minimal JSON-RPC MCP client communicating over stdio.""" + + def __init__(self, bridge_path: Path): + self._proc = subprocess.Popen( + [str(bridge_path)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + self._next_id = 0 + self._initialized = False + + def close(self): + if self._proc and self._proc.poll() is None: + self._proc.terminate() + try: + self._proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self._proc.kill() + + @property + def stderr(self): + return self._proc.stderr + + def send_request(self, method: str, params: dict | None = None) -> dict: + self._next_id += 1 + req = { + "jsonrpc": "2.0", + "id": self._next_id, + "method": method, + "params": params or {}, + } + line = json.dumps(req) + self._proc.stdin.write(line + "\n") + self._proc.stdin.flush() + return self._read_response(self._next_id) + + def _read_response(self, expected_id: int, timeout: float = 60.0) -> dict: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + line = self._proc.stdout.readline() + if not line: + raise RuntimeError("Bridge process terminated") + resp = json.loads(line) + rid = resp.get("id") + if rid == expected_id: + return resp + # Could be a notification; ignore and keep reading + raise TimeoutError(f"No response for id={expected_id} within {timeout}s") + + def initialize(self, protocol_version: str = "2025-03-26") -> dict: + resp = self.send_request("initialize", { + "protocolVersion": protocol_version, + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1.0"}, + }) + # Send initialized notification + self._proc.stdin.write( + json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}) + "\n" + ) + self._proc.stdin.flush() + self._initialized = True + return resp + + def call_tool(self, name: str, arguments: dict | None = None) -> dict: + resp = self.send_request("tools/call", {"name": name, "arguments": arguments or {}}) + content = resp.get("result", {}).get("content", []) + if content and content[0].get("type") == "text": + text = content[0]["text"] + try: + return json.loads(text) + except (json.JSONDecodeError, TypeError): + return {"_raw": text} + return resp.get("result", {}) + + def list_tools(self) -> list[dict]: + resp = self.send_request("tools/list") + return resp.get("result", {}).get("tools", []) \ No newline at end of file diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100755 index 0000000..48768f7 --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Run the gammaray-mcp test suite. +# Usage: +# ./run_tests.sh # probe-required tests (default) +# ./run_tests.sh --no-probe # only unit tests (no probe needed) +# ./run_tests.sh -v # verbose + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Ensure the bridge is built +if [ ! -f "$PROJECT_ROOT/bridge/build/qml-sg-mcp-bridge" ]; then + echo "Building bridge first..." + cmake -S "$PROJECT_ROOT/bridge" -B "$PROJECT_ROOT/bridge/build" -G Ninja \ + -DCMAKE_PREFIX_PATH="$PROJECT_ROOT/install-prefix" + cmake --build "$PROJECT_ROOT/bridge/build" +fi + +# Use the same env as run.sh +export LD_LIBRARY_PATH="$PROJECT_ROOT/install-prefix/lib" +export QT_QPA_PLATFORM=offscreen +export QT_PLUGIN_PATH="$PROJECT_ROOT/bridge/build/lib/x86_64-linux-gnu/qt6/plugins" + +cd "$SCRIPT_DIR" + +# Install test dependencies if needed +python3 -c "import pytest" 2>/dev/null || { + echo "pytest not found. Installing..." + pip3 install pytest --user +} + +exec python3 -m pytest "$@" . \ No newline at end of file diff --git a/tests/test_connection.py b/tests/test_connection.py new file mode 100644 index 0000000..7020627 --- /dev/null +++ b/tests/test_connection.py @@ -0,0 +1,50 @@ +"""Tests for connection management tools.""" + +import pytest + + +class TestConnection: + """Verify connectProbe, probeStatus, disconnectProbe work.""" + + def test_tools_listed(self, bridge): + tools = bridge.list_tools() + names = [t["name"] for t in tools] + for expected in ["connectProbe", "connectProbeDefault", + "disconnectProbe", "probeStatus"]: + assert expected in names, f"Missing tool: {expected}" + + def test_probe_status_disconnected(self, bridge): + status = bridge.call_tool("probeStatus") + assert isinstance(status, dict) + assert status.get("state") in ("disconnected", "connecting") + assert status.get("ready") is False + + def test_connect_and_disconnect(self, connected_bridge): + # connectProbeDefault is already called by connected_bridge fixture + status = connected_bridge.call_tool("probeStatus") + assert isinstance(status, dict) + assert status.get("state") == "ready" + assert status.get("ready") is True + assert "tcp://" in status.get("url", "") + + # Now disconnect + result = connected_bridge.call_tool("disconnectProbe") + assert isinstance(result, dict) + assert result.get("state") == "disconnected" + + def test_reconnect(self, connected_bridge): + # Disconnect + connected_bridge.call_tool("disconnectProbe") + + # Reconnect with explicit args + result = connected_bridge.call_tool("connectProbe", + {"host": "127.0.0.1", "port": 11732}) + assert isinstance(result, dict) + assert result.get("connected") is True + assert result.get("state") == "ready" + + def test_connect_default_after_disconnect(self, connected_bridge): + connected_bridge.call_tool("disconnectProbe") + result = connected_bridge.call_tool("connectProbeDefault") + assert isinstance(result, dict) + assert result.get("connected") is True \ No newline at end of file diff --git a/tests/test_items.py b/tests/test_items.py new file mode 100644 index 0000000..1479de5 --- /dev/null +++ b/tests/test_items.py @@ -0,0 +1,150 @@ +"""Tests for QML item selection and property inspection.""" + +import pytest +import time + + +class TestItemProperties: + """Verify selectQuickItem and getItemProperties.""" + + @pytest.mark.probe + def test_tools_listed(self, bridge): + tools = bridge.list_tools() + names = [t["name"] for t in tools] + for expected in ["selectQuickItem", "getItemProperties"]: + assert expected in names + + @pytest.mark.probe + def test_select_quick_item(self, connected_bridge): + # First get items + connected_bridge.call_tool("selectQuickWindow", {"index": 0}) + time.sleep(1) + items = connected_bridge.call_tool("listQuickItems") + assert isinstance(items, list) and len(items) > 0, "No items found" + + # Find the first Button-like item + def find_buttons(items_list): + buttons = [] + for item in items_list: + if "Button" in (item.get("type", "")): + buttons.append(item.get("name", "")) + buttons.extend(find_buttons(item.get("children", []))) + return buttons + + buttons = find_buttons(items) + if not buttons: + # Try any item + def first_addr(items_list): + for item in items_list: + return item.get("name", "") + addr = first_addr(items) + else: + addr = buttons[0] + + result = connected_bridge.call_tool("selectQuickItem", {"address": addr}) + assert isinstance(result, dict), f"selectQuickItem failed: {result}" + assert result.get("propertyCount", 0) > 0, \ + f"No properties for item {addr}: {result}" + + @pytest.mark.probe + def test_get_item_properties(self, connected_bridge): + connected_bridge.call_tool("selectQuickWindow", {"index": 0}) + time.sleep(1) + items = connected_bridge.call_tool("listQuickItems") + + def find_any_item(items_list): + for item in items_list: + addr = item.get("name", "") + if addr and not addr.startswith("Loading"): + return addr + found = find_any_item(item.get("children", [])) + if found: + return found + return None + + addr = find_any_item(items) + if not addr: + pytest.skip("No usable item address found") + + props = connected_bridge.call_tool("getItemProperties", {"address": addr}) + assert isinstance(props, dict), f"getItemProperties failed: {props}" + assert "properties" in props, f"No 'properties' key: {list(props.keys())}" + + @pytest.mark.probe + def test_item_has_position(self, connected_bridge): + """Verify that a Button item has x, y, width, height properties.""" + connected_bridge.call_tool("selectQuickWindow", {"index": 0}) + time.sleep(1) + items = connected_bridge.call_tool("listQuickItems") + + # Find a Button_QMLTYPE item + def find_button(items_list): + for item in items_list: + if "Button" in (item.get("type", "")): + return item.get("name", "") + found = find_button(item.get("children", [])) + if found: + return found + return None + + addr = find_button(items) + if not addr: + pytest.skip("No Button item found") + + props = connected_bridge.call_tool("getItemProperties", {"address": addr}) + simple = props.get("properties", {}) + for key in ("x", "y", "width", "height"): + assert key in simple, f"Button missing '{key}' property" + val = simple[key].get("value", "") + assert val != "", f"Button.{key} is empty" + + @pytest.mark.probe + def test_item_has_visual_properties(self, connected_bridge): + """Verify items have opacity, visible, z, rotation, scale.""" + connected_bridge.call_tool("selectQuickWindow", {"index": 0}) + time.sleep(1) + items = connected_bridge.call_tool("listQuickItems") + + def find_addr(items_list): + for item in items_list: + addr = item.get("name", "") + if addr and not addr.startswith("Loading") and addr != "ApplicationWindow": + return addr + found = find_addr(item.get("children", [])) + if found: + return found + return None + + addr = find_addr(items) + if not addr: + pytest.skip("No suitable item found") + + props = connected_bridge.call_tool("getItemProperties", {"address": addr}) + simple = props.get("properties", {}) + for key in ("opacity", "visible", "z"): + assert key in simple, f"Item missing '{key}'" + + @pytest.mark.probe + def test_property_model_has_groups(self, connected_bridge): + """Verify items have nested property groups (background, contentItem, etc.).""" + connected_bridge.call_tool("selectQuickWindow", {"index": 0}) + time.sleep(1) + items = connected_bridge.call_tool("listQuickItems") + + def find_button(items_list): + for item in items_list: + if "Button" in (item.get("type", "")): + return item.get("name", "") + found = find_button(item.get("children", [])) + if found: + return found + return None + + addr = find_button(items) + if not addr: + pytest.skip("No Button item found") + + props = connected_bridge.call_tool("getItemProperties", {"address": addr}) + groups = props.get("groups", {}) + # Buttons typically have [background], [contentItem], [parent], [window] + assert len(groups) > 0, f"No property groups found: {list(props.keys())}" \ No newline at end of file diff --git a/tests/test_navigation.py b/tests/test_navigation.py new file mode 100644 index 0000000..0e691a7 --- /dev/null +++ b/tests/test_navigation.py @@ -0,0 +1,93 @@ +"""Tests for navigation tools (windows, items, scene graph tree).""" + +import pytest + + +class TestNavigation: + """Verify listQuickWindows, selectQuickWindow, listQuickItems, listScenegraphNodes.""" + + def test_tools_listed(self, bridge): + tools = bridge.list_tools() + names = [t["name"] for t in tools] + for expected in ["listQuickWindows", "selectQuickWindow", + "listQuickItems", "listScenegraphNodes"]: + assert expected in names + + @pytest.mark.probe + def test_list_windows(self, connected_bridge): + windows = connected_bridge.call_tool("listQuickWindows") + assert isinstance(windows, list) + assert len(windows) >= 1 + for w in windows: + assert "address" in w + assert "type" in w + + @pytest.mark.probe + def test_select_window(self, connected_bridge): + result = connected_bridge.call_tool("selectQuickWindow", {"index": 0}) + assert isinstance(result, dict) + assert result.get("status") == "selected" + + @pytest.mark.probe + def test_list_items(self, connected_bridge): + # Select window first so item model populates + connected_bridge.call_tool("selectQuickWindow", {"index": 0}) + import time + time.sleep(1) + items = connected_bridge.call_tool("listQuickItems") + assert isinstance(items, list) + # May be empty if model hasn't populated yet; that's OK + if items: + assert isinstance(items[0], dict) + assert "type" in items[0] + + @pytest.mark.probe + def test_list_items_with_window_count(self, connected_bridge): + """Collect all items and verify at least some are found.""" + connected_bridge.call_tool("selectQuickWindow", {"index": 0}) + import time + time.sleep(1) + items = connected_bridge.call_tool("listQuickItems") + + def count_all(items_list): + count = 0 + for item in items_list: + count += 1 + count += count_all(item.get("children", [])) + return count + + if items: + total = count_all(items) + assert total > 0 + + @pytest.mark.probe + def test_list_scenegraph_nodes(self, connected_bridge): + connected_bridge.call_tool("selectQuickWindow", {"index": 0}) + import time + time.sleep(1) + + nodes = connected_bridge.call_tool("listScenegraphNodes") + assert isinstance(nodes, list) + if nodes: + assert isinstance(nodes[0], dict) + # Root should be "Root Node" + assert nodes[0].get("type") == "Root Node" + + @pytest.mark.probe + def test_scenegraph_has_geometry_nodes(self, connected_bridge): + connected_bridge.call_tool("selectQuickWindow", {"index": 0}) + import time + time.sleep(1) + nodes = connected_bridge.call_tool("listScenegraphNodes") + + def find_geometry(items_list): + for item in items_list: + if item.get("type") == "Geometry Node": + return item + found = find_geometry(item.get("children", [])) + if found: + return found + return None + + geo = find_geometry(nodes) + assert geo is not None, "No Geometry Node found in SG tree" \ No newline at end of file diff --git a/tests/test_scenegraph.py b/tests/test_scenegraph.py new file mode 100644 index 0000000..77dd24c --- /dev/null +++ b/tests/test_scenegraph.py @@ -0,0 +1,157 @@ +"""Tests for SceneGraph node selection and geometry/material tools.""" + +import pytest +import time + + +class TestSceneGraph: + """Verify selectScenegraphNode, getNodeVertices, getNodeAdjacency, etc.""" + + @pytest.mark.probe + def test_tools_listed(self, bridge): + tools = bridge.list_tools() + names = [t["name"] for t in tools] + for expected in [ + "selectScenegraphNode", "getNodeVertices", "getNodeAdjacency", + "getMaterialShaders", "getShaderSource", "getMaterialProperties", + "setRenderMode", "setSlowMode", + ]: + assert expected in names, f"Missing tool: {expected}" + + @pytest.mark.probe + def test_select_scenegraph_node(self, connected_bridge): + connected_bridge.call_tool("selectQuickWindow", {"index": 0}) + time.sleep(1) + nodes = connected_bridge.call_tool("listScenegraphNodes") + + # Find first Geometry Node + def find_geo(items_list): + for item in items_list: + if item.get("type") == "Geometry Node": + return item.get("address", "") + found = find_geo(item.get("children", [])) + if found: + return found + return None + + addr = find_geo(nodes) + if not addr: + pytest.skip("No Geometry Node found") + + result = connected_bridge.call_tool("selectScenegraphNode", {"address": addr}) + assert isinstance(result, dict), f"selectScenegraphNode failed: {result}" + assert result.get("type") == "Geometry Node" + assert "hasGeometry" in result + assert "hasMaterial" in result + + @pytest.mark.probe + def test_get_node_vertices(self, connected_bridge): + connected_bridge.call_tool("selectQuickWindow", {"index": 0}) + time.sleep(1) + nodes = connected_bridge.call_tool("listScenegraphNodes") + + def find_geo(items_list): + for item in items_list: + if item.get("type") == "Geometry Node": + return item.get("address", "") + found = find_geo(item.get("children", [])) + if found: + return found + return None + + addr = find_geo(nodes) + if not addr: + pytest.skip("No Geometry Node found") + + result = connected_bridge.call_tool("getNodeVertices", {"address": addr}) + assert isinstance(result, dict) + # May return "no vertex data" for software renderer -- that's OK + assert "error" in result or "vertexCount" in result + + @pytest.mark.probe + def test_get_node_adjacency(self, connected_bridge): + connected_bridge.call_tool("selectQuickWindow", {"index": 0}) + time.sleep(1) + nodes = connected_bridge.call_tool("listScenegraphNodes") + + def find_geo(items_list): + for item in items_list: + if item.get("type") == "Geometry Node": + return item.get("address", "") + found = find_geo(item.get("children", [])) + if found: + return found + return None + + addr = find_geo(nodes) + if not addr: + pytest.skip("No Geometry Node found") + + result = connected_bridge.call_tool("getNodeAdjacency", {"address": addr}) + assert isinstance(result, dict) + assert "error" in result or "drawingMode" in result + + @pytest.mark.probe + def test_get_material_shaders(self, connected_bridge): + connected_bridge.call_tool("selectQuickWindow", {"index": 0}) + time.sleep(1) + nodes = connected_bridge.call_tool("listScenegraphNodes") + + def find_geo(items_list): + for item in items_list: + if item.get("type") == "Geometry Node": + return item.get("address", "") + found = find_geo(item.get("children", [])) + if found: + return found + return None + + addr = find_geo(nodes) + if not addr: + pytest.skip("No Geometry Node found") + + result = connected_bridge.call_tool("getMaterialShaders", {"address": addr}) + assert isinstance(result, dict) + # Software renderer -> no shaders; that's expected + assert "error" in result or isinstance(result, list) + + @pytest.mark.probe + def test_get_material_properties(self, connected_bridge): + connected_bridge.call_tool("selectQuickWindow", {"index": 0}) + time.sleep(1) + nodes = connected_bridge.call_tool("listScenegraphNodes") + + def find_geo(items_list): + for item in items_list: + if item.get("type") == "Geometry Node": + return item.get("address", "") + found = find_geo(item.get("children", [])) + if found: + return found + return None + + addr = find_geo(nodes) + if not addr: + pytest.skip("No Geometry Node found") + + result = connected_bridge.call_tool("getMaterialProperties", {"address": addr}) + assert isinstance(result, dict) + assert "error" in result or "properties" in result + + @pytest.mark.probe + def test_set_render_mode(self, connected_bridge): + result = connected_bridge.call_tool("setRenderMode", + {"mode": "VisualizeOverdraw"}) + assert isinstance(result, dict) + assert result.get("renderMode") == "VisualizeOverdraw" + + # Reset + connected_bridge.call_tool("setRenderMode", {"mode": "NormalRendering"}) + + @pytest.mark.probe + def test_set_slow_mode(self, connected_bridge): + result = connected_bridge.call_tool("setSlowMode", {"enabled": True}) + assert isinstance(result, dict) + assert result.get("slowMode") is True + + connected_bridge.call_tool("setSlowMode", {"enabled": False}) \ No newline at end of file