test suite

This commit is contained in:
2026-07-07 20:40:29 +08:00
parent 316d52bfe5
commit f7d6a51220
12 changed files with 936 additions and 1 deletions

20
.gitignore vendored
View File

@@ -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
*~

110
README.md Normal file
View File

@@ -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).

7
pyproject.toml Normal file
View File

@@ -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"]

55
tests/README.md Normal file
View File

@@ -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 |

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# gammaray-mcp test suite

170
tests/conftest.py Normal file
View File

@@ -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

90
tests/mcp_client.py Normal file
View File

@@ -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", [])

34
tests/run_tests.sh Executable file
View File

@@ -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 "$@" .

50
tests/test_connection.py Normal file
View File

@@ -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

150
tests/test_items.py Normal file
View File

@@ -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())}"

93
tests/test_navigation.py Normal file
View File

@@ -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"

157
tests/test_scenegraph.py Normal file
View File

@@ -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})