test suite
This commit is contained in:
20
.gitignore
vendored
20
.gitignore
vendored
@@ -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
110
README.md
Normal 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
7
pyproject.toml
Normal 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
55
tests/README.md
Normal 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
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# gammaray-mcp test suite
|
||||
170
tests/conftest.py
Normal file
170
tests/conftest.py
Normal 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
90
tests/mcp_client.py
Normal 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
34
tests/run_tests.sh
Executable 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
50
tests/test_connection.py
Normal 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
150
tests/test_items.py
Normal 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
93
tests/test_navigation.py
Normal 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
157
tests/test_scenegraph.py
Normal 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})
|
||||
Reference in New Issue
Block a user