Add empty test level and Unreal MCP tooling
- L_TestFlight: empty, zero-gravity level for Phase 0 flight testing, set as editor/game default map. - Enable UnrealMCPython + Python Editor Script Plugin, third-party MCP plugin (GenOrca/unreal-mcp v2.2.0) giving direct editor control (actors, blueprints, levels, materials, etc.) alongside Epic's built-in Unreal MCP plugin. - Vendor the mcp-server Python source used to bridge to the plugin. - .mcp.json intentionally gitignored (machine-specific absolute paths).
This commit is contained in:
45
mcp-server/tests/test_core.py
Normal file
45
mcp-server/tests/test_core.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""Unit tests for the TCP response unwrapping (offline, no Unreal)."""
|
||||
|
||||
from unreal_mcp.core import _unwrap_result
|
||||
|
||||
|
||||
def test_unwraps_action_dict_from_result_string():
|
||||
wire = {
|
||||
"success": True, # python-exec success, NOT action success
|
||||
"message": "Python command executed successfully.",
|
||||
"result": '{"success": true, "actor_label": "PointLight_2"}',
|
||||
}
|
||||
out = _unwrap_result(wire)
|
||||
assert out == {"success": True, "actor_label": "PointLight_2"}
|
||||
|
||||
|
||||
def test_unwrapped_inner_failure_is_surfaced():
|
||||
"""Action failure (inner success=False) must survive unwrapping as data."""
|
||||
wire = {
|
||||
"success": True, # python ran fine...
|
||||
"result": '{"success": false, "message": "Actor not found"}',
|
||||
}
|
||||
out = _unwrap_result(wire)
|
||||
assert out["success"] is False
|
||||
assert out["message"] == "Actor not found"
|
||||
|
||||
|
||||
def test_unwraps_list_result():
|
||||
wire = {"success": True, "result": '[1, 2, 3]'}
|
||||
assert _unwrap_result(wire) == [1, 2, 3]
|
||||
|
||||
|
||||
def test_non_json_result_returns_original():
|
||||
wire = {"success": True, "result": "not json at all"}
|
||||
assert _unwrap_result(wire) == wire
|
||||
|
||||
|
||||
def test_missing_result_returns_original():
|
||||
wire = {"success": True, "message": "ok"}
|
||||
assert _unwrap_result(wire) == wire
|
||||
|
||||
|
||||
def test_non_dict_passthrough():
|
||||
assert _unwrap_result("raw") == "raw"
|
||||
88
mcp-server/tests/test_coverage.py
Normal file
88
mcp-server/tests/test_coverage.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Action coverage enforcement (offline, no Unreal needed).
|
||||
|
||||
Every catalog action must be exercised by an in-editor unittest
|
||||
(Plugins/.../tests/test_<domain>.py references "ue_<action>"), OR be listed
|
||||
in KNOWN_UNTESTED below as acknowledged technical debt.
|
||||
|
||||
Why: the dispatcher auto-exposes any ue_* function. Without this gate, a new
|
||||
action could ship with zero behavior test and all other gates stay green.
|
||||
This makes "add an action without a test" a conscious, reviewable choice
|
||||
(you must edit KNOWN_UNTESTED) rather than a silent gap.
|
||||
|
||||
KNOWN_UNTESTED is debt to shrink, not to grow. The stale-entry check fails if
|
||||
an allowlisted action becomes tested or disappears, so the list self-cleans.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from unreal_mcp.dispatchers._catalog import CATALOG
|
||||
|
||||
PLUGIN_TESTS = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "Plugins" / "UnrealMCPython" / "Content" / "Python" / "UnrealMCPython" / "tests"
|
||||
)
|
||||
|
||||
# Actions not routed as ue_<action> over TCP — covered by mcp-server pytest instead.
|
||||
SPECIAL = {"util": {"execute_python", "livecoding_compile"}}
|
||||
|
||||
# ── Technical debt: actions with no in-editor behavior test yet. SHRINK over time. ──
|
||||
# Adding a new action? Write a test in test_<domain>.py instead of adding it here.
|
||||
KNOWN_UNTESTED: dict[str, set[str]] = {
|
||||
# PIE start/stop change the editor play mode asynchronously; running them in the
|
||||
# headless suite (which never ticks between calls) would leave the editor in PIE.
|
||||
# Verified manually through the MCP chain instead.
|
||||
"util": {"start_pie", "stop_pie"},
|
||||
# save_current_level can raise a modal Save-As dialog on an untitled level,
|
||||
# which would hang the headless suite; save_all_levels is grouped with it.
|
||||
# Verified manually instead.
|
||||
"level": {"save_current_level", "save_all_levels"},
|
||||
}
|
||||
|
||||
|
||||
def _referenced(domain: str) -> set[str]:
|
||||
tf = PLUGIN_TESTS / f"test_{domain}.py"
|
||||
if not tf.exists():
|
||||
return set()
|
||||
return set(re.findall(r"ue_(\w+)", tf.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def _allowed(domain: str) -> set[str]:
|
||||
return SPECIAL.get(domain, set()) | KNOWN_UNTESTED.get(domain, set())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("domain", sorted(CATALOG))
|
||||
def test_every_action_is_tested_or_allowlisted(domain):
|
||||
referenced = _referenced(domain)
|
||||
untested = [
|
||||
a for a in CATALOG[domain]
|
||||
if a not in referenced and a not in _allowed(domain)
|
||||
]
|
||||
assert not untested, (
|
||||
f"{domain}: these actions have no in-editor test and are not allowlisted: "
|
||||
f"{untested}. Add a test in test_{domain}.py, or (consciously) add them to "
|
||||
f"KNOWN_UNTESTED in test_coverage.py."
|
||||
)
|
||||
|
||||
|
||||
def test_no_stale_allowlist_entries():
|
||||
"""KNOWN_UNTESTED must not contain actions that are now tested or no longer exist."""
|
||||
stale = []
|
||||
for domain, actions in KNOWN_UNTESTED.items():
|
||||
referenced = _referenced(domain)
|
||||
for a in actions:
|
||||
if a not in CATALOG.get(domain, {}):
|
||||
stale.append(f"{domain}.{a} (not in catalog)")
|
||||
elif a in referenced:
|
||||
stale.append(f"{domain}.{a} (now tested — remove from allowlist)")
|
||||
assert not stale, f"Stale KNOWN_UNTESTED entries: {stale}"
|
||||
|
||||
|
||||
def test_plugin_tests_dir_exists():
|
||||
"""Guard: if the layout moves, fail loudly instead of silently passing coverage."""
|
||||
assert PLUGIN_TESTS.is_dir(), f"Plugin tests dir not found: {PLUGIN_TESTS}"
|
||||
177
mcp-server/tests/test_dispatcher.py
Normal file
177
mcp-server/tests/test_dispatcher.py
Normal file
@@ -0,0 +1,177 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Offline routing tests for the namespace dispatcher.
|
||||
|
||||
These do NOT need Unreal running. They mock the TCP layer (send_to_unreal /
|
||||
send_python_exec / send_livecoding_compile) and assert that each domain tool
|
||||
routes (action, params) to the correct module + ue_<action> + params.
|
||||
|
||||
Run:
|
||||
cd mcp-server && uv run pytest
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
import unreal_mcp.dispatcher as disp
|
||||
from unreal_mcp.dispatchers._catalog import CATALOG
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recorder(monkeypatch):
|
||||
"""Replace send_to_unreal with an async recorder; return the call log."""
|
||||
calls = []
|
||||
|
||||
async def fake_send_to_unreal(module, fn, params):
|
||||
calls.append((module, fn, params))
|
||||
return {"success": True, "echo": {"module": module, "fn": fn, "params": params}}
|
||||
|
||||
monkeypatch.setattr(disp, "send_to_unreal", fake_send_to_unreal)
|
||||
return calls
|
||||
|
||||
|
||||
# ─── entrypoint smoke ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_entrypoints_import():
|
||||
"""main.py and server.py must import cleanly (catches removed public names)."""
|
||||
import unreal_mcp.server as server
|
||||
import unreal_mcp.main as main
|
||||
assert hasattr(server, "main_mcp")
|
||||
assert hasattr(server, "run_server")
|
||||
assert callable(main.main)
|
||||
|
||||
|
||||
# ─── tool registration ────────────────────────────────────────────────────────
|
||||
|
||||
def test_domain_tools_match_catalog():
|
||||
"""Exactly one MCP tool per catalog domain — no more, no less."""
|
||||
tools = disp.dispatcher_mcp._tool_manager.list_tools()
|
||||
names = sorted(t.name for t in tools)
|
||||
assert names == sorted(CATALOG.keys())
|
||||
|
||||
|
||||
# ─── standard routing ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_standard_routing_module_fn_params(recorder):
|
||||
result = run(disp._dispatch("actor", "set_location",
|
||||
{"actor_label": "Cube", "location": [0, 0, 100]}))
|
||||
assert result["success"] is True
|
||||
assert recorder == [
|
||||
("UnrealMCPython.actor_actions", "ue_set_location",
|
||||
{"actor_label": "Cube", "location": [0, 0, 100]})
|
||||
]
|
||||
|
||||
|
||||
def _standard_action_pairs():
|
||||
"""Every (domain, action) routed via the standard path (excludes hand-written domains)."""
|
||||
pairs = []
|
||||
for domain in CATALOG:
|
||||
if domain in ("util", "vision"):
|
||||
continue # special-cased handlers
|
||||
for action in CATALOG[domain]:
|
||||
pairs.append((domain, action))
|
||||
return pairs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("domain,action", _standard_action_pairs())
|
||||
def test_every_catalog_action_routes(domain, action, recorder):
|
||||
"""Full coverage: each catalog action routes to its module + ue_<action>."""
|
||||
run(disp._dispatch(domain, action, {}))
|
||||
module, fn, _ = recorder[0]
|
||||
assert module == f"UnrealMCPython.{domain}_actions"
|
||||
assert fn == f"ue_{action}"
|
||||
|
||||
|
||||
def test_params_passed_through_untouched(recorder):
|
||||
payload = {"asset_path": "/Game/BP", "graph_name": "EventGraph", "node_json": {"type": "Branch"}}
|
||||
run(disp._dispatch("blueprint", "add_blueprint_node", payload))
|
||||
assert recorder[0][2] == payload # dispatcher must not mutate/translate params
|
||||
|
||||
|
||||
# ─── list_actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_list_actions_returns_catalog(recorder):
|
||||
result = run(disp._dispatch("material", "list_actions", {}))
|
||||
assert result["success"] is True
|
||||
assert result["domain"] == "material"
|
||||
assert result["actions"] == CATALOG["material"]
|
||||
assert recorder == [] # list_actions must NOT hit the TCP layer
|
||||
|
||||
|
||||
def test_list_actions_entries_have_params_and_doc():
|
||||
for domain, actions in CATALOG.items():
|
||||
for action, info in actions.items():
|
||||
assert "params" in info and "doc" in info, f"{domain}.{action} missing keys"
|
||||
|
||||
|
||||
# ─── error handling ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_unknown_action_errors_without_tcp(recorder):
|
||||
result = run(disp._dispatch("actor", "fly_to_the_moon", {}))
|
||||
assert result["success"] is False
|
||||
assert "Unknown action" in result["message"]
|
||||
assert recorder == [] # never reaches the TCP layer
|
||||
|
||||
|
||||
# ─── util special routing ─────────────────────────────────────────────────────
|
||||
|
||||
def test_util_execute_python(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_exec(code):
|
||||
seen["code"] = code
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(disp, "send_python_exec", fake_exec)
|
||||
result = run(disp.util.fn(action="execute_python", params={"code": "import unreal"}))
|
||||
assert result["success"] is True
|
||||
assert seen["code"] == "import unreal"
|
||||
|
||||
|
||||
def test_util_execute_python_requires_code(monkeypatch):
|
||||
async def fake_exec(code):
|
||||
raise AssertionError("should not be called when code is missing")
|
||||
|
||||
monkeypatch.setattr(disp, "send_python_exec", fake_exec)
|
||||
result = run(disp.util.fn(action="execute_python", params={}))
|
||||
assert result["success"] is False
|
||||
assert "code is required" in result["message"]
|
||||
|
||||
|
||||
def test_util_livecoding_compile(monkeypatch):
|
||||
called = {"n": 0}
|
||||
|
||||
async def fake_compile():
|
||||
called["n"] += 1
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(disp, "send_livecoding_compile", fake_compile)
|
||||
result = run(disp.util.fn(action="livecoding_compile", params={}))
|
||||
assert result["success"] is True
|
||||
assert called["n"] == 1
|
||||
|
||||
|
||||
def test_util_get_output_log_routes_to_ue_function(recorder):
|
||||
run(disp.util.fn(action="get_output_log", params={"line_count": 20}))
|
||||
assert recorder == [
|
||||
("UnrealMCPython.util_actions", "ue_get_output_log", {"line_count": 20})
|
||||
]
|
||||
|
||||
|
||||
def test_util_list_actions(recorder):
|
||||
result = run(disp.util.fn(action="list_actions", params={}))
|
||||
assert result["actions"] == CATALOG["util"]
|
||||
assert recorder == []
|
||||
|
||||
|
||||
def test_util_unknown_action(recorder):
|
||||
result = run(disp.util.fn(action="nope", params={}))
|
||||
assert result["success"] is False
|
||||
assert "Unknown action" in result["message"]
|
||||
222
mcp-server/tests/test_e2e.py
Normal file
222
mcp-server/tests/test_e2e.py
Normal file
@@ -0,0 +1,222 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
End-to-end tests: MCP server dispatcher -> real TCP -> C++ server -> ue_* -> back.
|
||||
|
||||
Unlike test_dispatcher.py (which mocks the TCP layer), these run the FULL chain
|
||||
with NO mocks. They prove the actual scenario an MCP client triggers:
|
||||
routing + socket round-trip + C++ dispatch + response unwrapping.
|
||||
|
||||
Requires a running Unreal editor with the UnrealMCPython TCP server on :12029.
|
||||
If the port is not reachable, the whole module is skipped (so CI / offline runs
|
||||
stay green; run locally with the editor open to exercise these).
|
||||
|
||||
Run:
|
||||
cd mcp-server && uv run --extra dev pytest tests/test_e2e.py -v
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
import unreal_mcp.dispatcher as disp
|
||||
from unreal_mcp.dispatchers._catalog import CATALOG
|
||||
|
||||
HOST, PORT = "127.0.0.1", 12029
|
||||
|
||||
# Actions excluded from the exhaustive empty-param round-trip:
|
||||
# execute_python — needs {code}; empty-param hits a dispatcher guard, not TCP
|
||||
# livecoding_compile — triggers a real C++ compile (slow, side-effecting)
|
||||
# start_pie/stop_pie — change editor PIE mode (no params to guard); would leave
|
||||
# the editor in PIE during the sweep
|
||||
_EXCLUDE = {
|
||||
("util", "execute_python"),
|
||||
("util", "livecoding_compile"),
|
||||
("util", "start_pie"),
|
||||
("util", "stop_pie"),
|
||||
# save_current_level can raise a modal Save-As dialog on an untitled level.
|
||||
("level", "save_current_level"),
|
||||
("level", "save_all_levels"),
|
||||
# vision returns an MCP Image (not a dict) — covered by a dedicated E2E test below.
|
||||
("vision", "capture_viewport"),
|
||||
}
|
||||
|
||||
|
||||
def _editor_reachable() -> bool:
|
||||
try:
|
||||
with socket.create_connection((HOST, PORT), timeout=0.5):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _editor_reachable(),
|
||||
reason=f"Unreal TCP server not reachable on {HOST}:{PORT} (open the editor to run E2E).",
|
||||
)
|
||||
|
||||
# Editor-crash guard: if the editor was up when the module started but dies
|
||||
# mid-suite, every remaining test must FAIL (not skip, and not "pass" via a
|
||||
# connection-error dict that happens to carry a 'success' key). A green E2E run
|
||||
# must mean the editor survived the whole sweep — otherwise a `pytest && ...`
|
||||
# release chain would happily commit/PR on top of a crash.
|
||||
_EDITOR_WAS_UP = _editor_reachable()
|
||||
_CONNECTION_ERROR_MARKERS = ("Connection refused", "ConnectionReset", "Socket timeout",
|
||||
"No response received", "[WinError")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fail_if_editor_crashed():
|
||||
if _EDITOR_WAS_UP and not _editor_reachable():
|
||||
pytest.fail(f"Unreal editor crashed during the E2E suite "
|
||||
f"({HOST}:{PORT} no longer reachable). Investigate before merging.")
|
||||
yield
|
||||
|
||||
|
||||
def _assert_not_connection_error(r, label):
|
||||
if isinstance(r, dict):
|
||||
msg = str(r.get("message", ""))
|
||||
assert not any(m in msg for m in _CONNECTION_ERROR_MARKERS), \
|
||||
f"{label}: editor connection lost mid-call: {msg}"
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_list_actors_round_trip():
|
||||
"""A read action proves unwrapping: 'actors' is an INNER field of the action result."""
|
||||
r = run(disp._dispatch("actor", "list_all_with_locations", {}))
|
||||
assert r.get("success") is True
|
||||
assert "actors" in r, f"inner field missing — response not unwrapped: {r}"
|
||||
assert isinstance(r["actors"], list)
|
||||
|
||||
|
||||
def test_spawn_and_delete_round_trip():
|
||||
spawn = run(disp._dispatch("actor", "spawn_from_class",
|
||||
{"class_path": "/Script/Engine.PointLight",
|
||||
"location": [0, 0, 700]}))
|
||||
assert spawn.get("success") is True, spawn
|
||||
label = spawn.get("actor_label")
|
||||
assert label, f"actor_label missing — not unwrapped: {spawn}"
|
||||
# cleanup through the same chain
|
||||
deleted = run(disp._dispatch("actor", "delete_by_label", {"actor_label": label}))
|
||||
assert deleted.get("success") is True, deleted
|
||||
|
||||
|
||||
def test_inner_action_failure_is_surfaced():
|
||||
"""Action failure must come back as success=False (not buried in a result string)."""
|
||||
r = run(disp._dispatch("actor", "set_transform",
|
||||
{"actor_label": "NoSuchActor_E2E_XYZ", "location": [0, 0, 0]}))
|
||||
assert r.get("success") is False
|
||||
assert "message" in r
|
||||
|
||||
|
||||
def test_list_actions_offline_path_still_works():
|
||||
"""list_actions must not touch TCP even when the editor is up."""
|
||||
r = run(disp._dispatch("material", "list_actions", {}))
|
||||
assert r["success"] is True
|
||||
assert r["domain"] == "material"
|
||||
|
||||
|
||||
def test_execute_python_round_trip():
|
||||
"""execute_python runs real Unreal Python through the chain."""
|
||||
r = run(disp.util.fn(action="execute_python",
|
||||
params={"code": "print('e2e_marker_42')"}))
|
||||
# send_python_exec returns the raw wrapper; the printed marker is in 'result'.
|
||||
blob = r.get("result", "") + r.get("message", "")
|
||||
assert "e2e_marker_42" in blob, f"execute_python did not echo marker: {r}"
|
||||
|
||||
|
||||
def test_vision_capture_returns_image():
|
||||
"""vision capture_viewport returns an MCP Image (PNG) through the full chain."""
|
||||
from fastmcp import Image
|
||||
r = run(disp.vision.fn(action="capture_viewport", params={"width": 320, "height": 180}))
|
||||
assert isinstance(r, Image), f"expected an Image, got {type(r).__name__}: {r}"
|
||||
# the PNG bytes should carry a valid signature
|
||||
data = getattr(r, "data", b"")
|
||||
assert data[:4] == b"\x89PNG", "capture did not return a PNG"
|
||||
|
||||
|
||||
def test_gltf_import_round_trip():
|
||||
"""glTF import via the deferred-tick path: export the engine Cube to .glb, import it,
|
||||
then poll get_gltf_import_status until done. Each dispatch is its own game-thread task,
|
||||
so the editor ticks between calls and the Interchange async import can run + complete."""
|
||||
import time
|
||||
|
||||
# 1. export /Engine/BasicShapes/Cube to a temp .glb (via execute_python)
|
||||
export_code = (
|
||||
"import unreal, os\n"
|
||||
"glb = os.path.join(unreal.Paths.project_saved_dir(), 'MCP_E2E_gltf.glb').replace(chr(92), '/')\n"
|
||||
"cube = unreal.EditorAssetLibrary.load_asset('/Engine/BasicShapes/Cube')\n"
|
||||
"t = unreal.AssetExportTask(); t.object = cube; t.filename = glb\n"
|
||||
"t.automated = True; t.replace_identical = True; t.prompt = False\n"
|
||||
"ok = unreal.Exporter.run_asset_export_task(t)\n"
|
||||
"print('GLBPATH=' + glb if (ok and os.path.isfile(glb)) else 'GLBFAIL')\n"
|
||||
)
|
||||
r = run(disp.util.fn(action="execute_python", params={"code": export_code}))
|
||||
blob = (r.get("result", "") or "") + (r.get("message", "") or "")
|
||||
assert "GLBPATH=" in blob, f"glb export failed: {blob[:300]}"
|
||||
glb = blob.split("GLBPATH=", 1)[1].split()[0].strip().strip('"')
|
||||
|
||||
dest = "/Game/Tests/MCP_E2E/glb"
|
||||
try:
|
||||
imp = run(disp._dispatch("asset", "import_gltf",
|
||||
{"file_path": glb, "destination_path": dest}))
|
||||
assert imp.get("success") and imp.get("pending"), f"schedule failed: {imp}"
|
||||
|
||||
st = {}
|
||||
for _ in range(40): # ~20s budget for the async Interchange import
|
||||
st = run(disp._dispatch("asset", "get_gltf_import_status",
|
||||
{"destination_path": dest}))
|
||||
_assert_not_connection_error(st, "get_gltf_import_status")
|
||||
if st.get("done"):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
assert st.get("success") and st.get("done"), f"import did not complete: {st}"
|
||||
classes = [a["class"] for a in st["imported_assets"]]
|
||||
assert "StaticMesh" in classes, f"no StaticMesh among imported: {st}"
|
||||
finally:
|
||||
run(disp.util.fn(action="execute_python", params={
|
||||
"code": f"import unreal; unreal.EditorAssetLibrary.delete_directory('{dest}')"}))
|
||||
|
||||
|
||||
# ── exhaustive: every catalog action survives the full chain ───────────────────
|
||||
|
||||
def _all_action_pairs():
|
||||
pairs = []
|
||||
for domain, actions in CATALOG.items():
|
||||
for action in actions:
|
||||
if (domain, action) in _EXCLUDE:
|
||||
continue
|
||||
pairs.append((domain, action))
|
||||
return pairs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("domain,action", _all_action_pairs())
|
||||
def test_every_action_round_trips(domain, action):
|
||||
"""
|
||||
Drive every action through the real chain with empty params and assert we get
|
||||
back an unwrapped dict containing 'success'. This proves routing + TCP +
|
||||
C++ dispatch + result unwrapping work for the action — independent of whether
|
||||
the action *succeeds* with no args (validation failures still return a dict).
|
||||
|
||||
Empty params are safe: execute_action wraps any exception (incl. missing-arg
|
||||
TypeError) as {"success": false, ...}, and ue_* functions validate required
|
||||
params before doing work.
|
||||
"""
|
||||
if domain == "util":
|
||||
r = run(disp.util.fn(action=action, params={}))
|
||||
elif domain == "vision":
|
||||
r = run(disp.vision.fn(action=action, params={}))
|
||||
else:
|
||||
r = run(disp._dispatch(domain, action, {}))
|
||||
assert isinstance(r, dict), f"{domain}.{action} returned non-dict: {r!r}"
|
||||
assert "success" in r, f"chain/unwrap failed for {domain}.{action}: {r!r}"
|
||||
_assert_not_connection_error(r, f"{domain}.{action}")
|
||||
|
||||
|
||||
def test_zzz_editor_survived_suite():
|
||||
"""Last test in the file: the editor must still be alive after the full sweep."""
|
||||
assert _editor_reachable(), "Unreal editor is no longer reachable after the E2E suite (it crashed mid-run)."
|
||||
Reference in New Issue
Block a user