mirror of
https://github.com/hansjone/netx.git
synced 2026-10-19 06:53:19 +08:00
Converge netx-topology MCP to dual-unit drain + orbit/polish mainline.
Trim public tools to 14, expose only kept layout actions/recipes, merge Fabric queries, drop createTopologyView/listTopologyViews, and align skill/docs with the live catalog. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9bcb8ee0e3
commit
79f929de0c
86 changed files with 31578 additions and 240 deletions
180
packages/netx-topology-mcp/tests/test_layout_jobs.py
Normal file
180
packages/netx-topology-mcp/tests/test_layout_jobs.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""Background layout jobs + slim payload helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from netx_topology_mcp.http_tools import _layout_topology_view, _slim_layout_payload
|
||||
from netx_topology_mcp.layout_jobs import (
|
||||
cancel_job,
|
||||
get_job,
|
||||
is_cancelled,
|
||||
job_public,
|
||||
report_progress,
|
||||
start_job,
|
||||
)
|
||||
|
||||
|
||||
def test_start_job_and_poll() -> None:
|
||||
def runner() -> dict:
|
||||
report_progress("mid", pct=50.0, message="halfway", step=1, total_steps=2)
|
||||
time.sleep(0.05)
|
||||
return {"ok": True, "action": "compose_views", "applied": True, "node_count": 3}
|
||||
|
||||
jid = start_job(action="compose_views", view_id="v1", runner=runner, meta={"n": 1})
|
||||
assert jid
|
||||
saw_progress = False
|
||||
for _ in range(40):
|
||||
pub = job_public(jid)
|
||||
assert pub is not None
|
||||
assert "elapsed_ms" in pub
|
||||
assert "progress" in pub
|
||||
assert "heartbeat_age_ms" in pub
|
||||
if (pub.get("progress") or {}).get("phase") == "mid":
|
||||
saw_progress = True
|
||||
if pub["status"] != "running":
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert saw_progress
|
||||
job = get_job(jid)
|
||||
assert job is not None
|
||||
assert job["status"] == "done"
|
||||
assert job["result"]["applied"] is True
|
||||
|
||||
st = _layout_topology_view({"action": "job_status", "params": {"job_id": jid}})
|
||||
assert st.get("ok") is True
|
||||
assert st.get("status") == "done"
|
||||
assert st.get("result", {}).get("applied") is True
|
||||
assert isinstance(st.get("progress"), dict)
|
||||
|
||||
|
||||
def test_cancel_job_cooperative() -> None:
|
||||
started = time.time()
|
||||
|
||||
def runner() -> dict:
|
||||
report_progress("work", pct=10.0, message="before sleep")
|
||||
for _ in range(40):
|
||||
if is_cancelled():
|
||||
from netx_topology_mcp.layout_jobs import raise_if_cancelled
|
||||
|
||||
raise_if_cancelled()
|
||||
time.sleep(0.05)
|
||||
return {"ok": True, "applied": True}
|
||||
|
||||
jid = start_job(action="polish_crossings", view_id="v1", runner=runner)
|
||||
time.sleep(0.08)
|
||||
out = _layout_topology_view({"action": "job_cancel", "params": {"job_id": jid}})
|
||||
assert out.get("ok") is True
|
||||
assert out.get("cancel_requested") is True
|
||||
for _ in range(40):
|
||||
pub = job_public(jid)
|
||||
assert pub is not None
|
||||
if pub["status"] == "cancelled":
|
||||
break
|
||||
time.sleep(0.05)
|
||||
pub = job_public(jid)
|
||||
assert pub is not None
|
||||
assert pub["status"] == "cancelled"
|
||||
assert time.time() - started < 3.0
|
||||
|
||||
|
||||
def test_slim_layout_payload_drops_guide() -> None:
|
||||
fat = {
|
||||
"ok": True,
|
||||
"action": "compose_views",
|
||||
"node_count": 1200,
|
||||
"guide": {"how_to_read": "x" * 200},
|
||||
"tried": [1, 2, 3],
|
||||
"params_used": {"lane": 1},
|
||||
"crossing": {
|
||||
"status": "fail",
|
||||
"score": 0,
|
||||
"edge_crossings": 9,
|
||||
"crossings_per_link": 0.1,
|
||||
"top_nodes": [{"id": "a"}] * 20,
|
||||
"top_edges": [{"id": "e"}] * 20,
|
||||
"tip": "long",
|
||||
},
|
||||
"summary": {"total": 40},
|
||||
}
|
||||
slim = _slim_layout_payload(fat)
|
||||
assert slim.get("slim") is True
|
||||
assert "guide" not in slim
|
||||
assert "tried" not in slim
|
||||
assert len(slim["crossing"]["top_nodes"]) <= 5
|
||||
|
||||
|
||||
def test_compose_background_returns_job_id() -> None:
|
||||
src = [f"s{i:02d}" for i in range(12)]
|
||||
|
||||
def fake_start(**kwargs):
|
||||
assert kwargs["action"] == "compose_views"
|
||||
assert kwargs["view_id"] == "full"
|
||||
assert isinstance(kwargs.get("tool_args"), dict)
|
||||
assert kwargs["tool_args"].get("action") == "compose_views"
|
||||
return "abc123job"
|
||||
|
||||
with patch("netx_topology_mcp.http_tools.start_job", side_effect=fake_start):
|
||||
out = _layout_topology_view(
|
||||
{
|
||||
"view_id": "full",
|
||||
"action": "compose_views",
|
||||
"mode": "apply",
|
||||
"params": {"source_view_ids": src},
|
||||
}
|
||||
)
|
||||
assert out.get("ok") is True
|
||||
assert out.get("status") == "running"
|
||||
assert out.get("job_id") == "abc123job"
|
||||
assert out.get("applied") is False
|
||||
|
||||
|
||||
def test_job_status_missing() -> None:
|
||||
out = _layout_topology_view({"action": "job_status", "params": {}})
|
||||
assert out.get("ok") is False
|
||||
assert out.get("error") == "job_id_required"
|
||||
|
||||
|
||||
def test_job_status_unknown() -> None:
|
||||
out = _layout_topology_view(
|
||||
{"action": "job_status", "params": {"job_id": "does-not-exist"}}
|
||||
)
|
||||
assert out.get("ok") is False
|
||||
assert out.get("error") == "job_not_found"
|
||||
|
||||
|
||||
def test_cancel_missing() -> None:
|
||||
out = cancel_job("missing-job")
|
||||
assert out.get("ok") is False
|
||||
assert out.get("error") == "job_not_found"
|
||||
|
||||
|
||||
def test_soft_stale_keeps_running_and_cancelable() -> None:
|
||||
"""stale is a poll warning only — must not freeze status or block cancel."""
|
||||
from netx_topology_mcp import layout_jobs as lj
|
||||
|
||||
def runner() -> dict:
|
||||
time.sleep(0.3)
|
||||
return {"ok": True, "applied": False}
|
||||
|
||||
jid = start_job(action="polish_crossings", view_id="v1", runner=runner)
|
||||
with lj._LOCK: # noqa: SLF001 — test injects silent heartbeat on disk
|
||||
job = lj._read_job_disk(jid) or lj._JOBS[jid]
|
||||
job["heartbeat_at"] = time.time() - (lj._STALE_AFTER_S + 5)
|
||||
lj._JOBS[jid] = job
|
||||
lj._write_job_disk(job)
|
||||
pub = job_public(jid)
|
||||
assert pub is not None
|
||||
assert pub["stale"] is True
|
||||
assert pub["status"] == "running"
|
||||
cancelled = cancel_job(jid)
|
||||
assert cancelled.get("ok") is True
|
||||
assert cancelled.get("status") == "cancelling"
|
||||
for _ in range(40):
|
||||
pub2 = job_public(jid)
|
||||
assert pub2 is not None
|
||||
if pub2["status"] in {"cancelled", "done", "error"}:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert job_public(jid)["status"] == "cancelled"
|
||||
Loading…
Add table
Add a link
Reference in a new issue