isokit: project layer, Studio GUI, MCP server, viewer, CLI, demo project and docs
This commit is contained in:
1 parent
95c80ec6b1
commit
564aeb75f6
47 files changed
+5978
-366
No files matched your search
@@ -0,0 +1,143 @@
|
||||
"""Functional test of the isokit Studio (GUI) and its MCP endpoint, headless Edge."""
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = sys.argv[2] if len(sys.argv) > 2 else "http://127.0.0.1:7878"
|
||||
OUT = Path(sys.argv[1])
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
fails = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
print(("ok " if cond else "FAIL ") + msg)
|
||||
if not cond:
|
||||
fails.append(msg)
|
||||
|
||||
|
||||
def get(path):
|
||||
return json.loads(urllib.request.urlopen(BASE + path).read())
|
||||
|
||||
|
||||
def mcp(msg):
|
||||
req = urllib.request.Request(BASE + "/mcp", data=json.dumps(msg).encode(), headers={"Content-Type": "application/json"})
|
||||
r = urllib.request.urlopen(req)
|
||||
body = r.read()
|
||||
return json.loads(body) if body else None
|
||||
|
||||
|
||||
def doc():
|
||||
return get("/api/doc")["doc"]
|
||||
|
||||
|
||||
def obj(id_):
|
||||
return next((o for o in doc()["objects"] if o["id"] == id_), None)
|
||||
|
||||
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(channel="msedge", headless=True)
|
||||
pg = b.new_page(viewport={"width": 1500, "height": 950})
|
||||
errs = []
|
||||
pg.on("pageerror", lambda e: errs.append(str(e)))
|
||||
pg.on("console", lambda m: errs.append(m.text) if m.type == "error" else None)
|
||||
pg.goto(BASE + "/")
|
||||
pg.wait_for_selector(".iso-svg [data-obj]", timeout=10000)
|
||||
pg.wait_for_timeout(500)
|
||||
n0 = len(doc()["objects"])
|
||||
check(pg.locator("#objects li").count() == n0, f"object list shows {n0} objects")
|
||||
check(pg.locator(".st-tile").count() >= 20, "palette lists the prefabs")
|
||||
pg.screenshot(path=str(OUT / "studio-start.jpg"), type="jpeg", quality=80)
|
||||
|
||||
# place a tree: pick it in the palette, click an empty spot of the map
|
||||
pg.locator('.st-tile[data-type="tree"]').click()
|
||||
check(pg.locator("#hint").is_visible(), "placing hint shows")
|
||||
stage = pg.locator(".iso-stage").bounding_box()
|
||||
pg.mouse.click(stage["x"] + stage["width"] * 0.30, stage["y"] + stage["height"] * 0.62)
|
||||
pg.wait_for_timeout(700)
|
||||
d = doc()
|
||||
check(len(d["objects"]) == n0 + 1, "click on the map adds an object")
|
||||
new = d["objects"][-1]
|
||||
check(new["type"] == "tree", f"the new object is a tree at ({new['x']}, {new['y']})")
|
||||
check(pg.locator("#right h3").inner_text().strip().endswith("Tree"), "the new object is selected in the panel")
|
||||
|
||||
# drag the generator
|
||||
g = obj("genset")
|
||||
# aim at the generator body (its group's box also spans the exhaust puffs above it)
|
||||
box = pg.locator('.iso-svg g[data-obj="genset"] path.iso-t').nth(1).bounding_box()
|
||||
cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
|
||||
hit = pg.evaluate("([x, y]) => { const e = document.elementFromPoint(x, y); const g = e && e.closest('[data-obj]'); return g && g.getAttribute('data-obj'); }", [cx, cy])
|
||||
check(hit == "genset", f"pointer is on the generator ({hit})")
|
||||
pg.mouse.move(cx, cy)
|
||||
pg.mouse.down()
|
||||
for i in range(1, 11):
|
||||
pg.mouse.move(cx + 9 * i, cy + 3 * i)
|
||||
pg.wait_for_timeout(20)
|
||||
pg.mouse.up()
|
||||
pg.wait_for_timeout(700)
|
||||
g2 = obj("genset")
|
||||
check((g2["x"], g2["y"]) != (g["x"], g["y"]), f"drag moves the generator ({g['x']},{g['y']}) -> ({g2['x']},{g2['y']})")
|
||||
check(abs(g2["x"] * 2 - round(g2["x"] * 2)) < 1e-9, "the move snaps to 0.5 m")
|
||||
|
||||
# edit a property of the building in the form
|
||||
pg.locator('#objects li[data-id="hall"]').click()
|
||||
pg.wait_for_timeout(300)
|
||||
inp = pg.locator('#right input[type=number][data-prop="floors"]')
|
||||
inp.fill("3")
|
||||
inp.press("Enter")
|
||||
pg.wait_for_timeout(700)
|
||||
check(obj("hall")["props"].get("floors") == 3, "the form sets floors = 3")
|
||||
pg.screenshot(path=str(OUT / "studio-selected.jpg"), type="jpeg", quality=80)
|
||||
|
||||
# undo the floors change
|
||||
pg.locator("#undo").click()
|
||||
pg.wait_for_timeout(700)
|
||||
check(obj("hall")["props"].get("floors") == 2, "undo restores floors = 2")
|
||||
|
||||
# keyboard: rotate and delete the new tree
|
||||
pg.locator(f'#objects li[data-id="{new["id"]}"]').click()
|
||||
pg.locator(".iso-svg").focus()
|
||||
pg.keyboard.press("Delete")
|
||||
pg.wait_for_timeout(700)
|
||||
check(obj(new["id"]) is None, "Delete removes the selected object")
|
||||
|
||||
# an agent edits over MCP: the open Studio follows
|
||||
init = mcp({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}})
|
||||
check(init["result"]["serverInfo"]["name"] == "isokit", "MCP initialize")
|
||||
check(mcp({"jsonrpc": "2.0", "method": "notifications/initialized"}) is None, "MCP notification gets no body")
|
||||
tools = mcp({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})["result"]["tools"]
|
||||
check(len(tools) >= 14, f"MCP lists {len(tools)} tools")
|
||||
r = mcp({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "add_object", "arguments": {"type": "container", "x": 20, "y": -1, "rot": 90, "name": "Spare parts", "props": {"stack": 2}}}})
|
||||
res = json.loads(r["result"]["content"][0]["text"])
|
||||
check(not r["result"]["isError"] and res["id"] == "container", f"MCP add_object -> {res.get('id')}, box {res.get('object', {}).get('box')}")
|
||||
bad = mcp({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "add_object", "arguments": {"type": "ufo", "x": 0, "y": 0}}})
|
||||
check(bad["result"]["isError"] and "available" in bad["result"]["content"][0]["text"], "MCP explains a bad type")
|
||||
pg.wait_for_timeout(2000)
|
||||
check(pg.locator('#objects li[data-id="container"]').count() == 1, "the Studio shows the agent's container")
|
||||
check(pg.locator('.iso-svg g[data-obj="container"]').count() >= 1, "the map draws the agent's container")
|
||||
summ = json.loads(mcp({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "render_view", "arguments": {"level": "gf"}}})["result"]["content"][0]["text"])
|
||||
check(summ["sprites"] > 20 and "container" in summ["boxes"], f"MCP render_view summary: {summ['sprites']} sprites, {summ['nodes']} nodes")
|
||||
chk = json.loads(mcp({"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "check_project", "arguments": {}}})["result"]["content"][0]["text"])
|
||||
check(chk["ok"], f"MCP check_project ok ({len(chk['findings'])} findings)")
|
||||
|
||||
# cross-origin edits are refused
|
||||
req = urllib.request.Request(BASE + "/api/op", data=b'{"op":"remove","id":"hall"}', headers={"Content-Type": "application/json", "Origin": "http://evil.example"})
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
check(False, "cross-origin POST refused")
|
||||
except urllib.error.HTTPError as e:
|
||||
check(e.code == 403, "cross-origin POST refused")
|
||||
|
||||
# view controls still work in the Studio
|
||||
pg.locator('.iso-seg button[data-level="gf"]').click()
|
||||
pg.wait_for_timeout(600)
|
||||
check(pg.locator('.iso-seg button[data-level="gf"]').get_attribute("aria-pressed") == "true", "level switch")
|
||||
pg.locator('[data-a="rotr"]').click()
|
||||
pg.wait_for_timeout(600)
|
||||
pg.screenshot(path=str(OUT / "studio-gf.jpg"), type="jpeg", quality=80)
|
||||
check(not errs, f"no script errors {errs}")
|
||||
b.close()
|
||||
|
||||
print("FAILED:" if fails else "ALL PASSED", fails)
|
||||
sys.exit(1 if fails else 0)
|
||||
Reference in new issue
Block a user