Deploy web Studio / deploy (push) Successful in 1m5s
Object frames turn by any angle (quarter turns stay exact, so scenes along the axes render byte for byte as before); a box in a turned frame is a prism with its own corners. Rotation is kept as given (normalised to 0..360), no longer snapped to quarter turns. Depth sort: sprites whose plan outline is not their box carry their convex outline; when boxes overlap, an outline edge that separates two sprites orders them. A long diagonal wall sorts right against both sides. Joints (line_meet, end_cut, cut_box in util.rhai): where a piece off the axes is involved, corners are mitred and T-junctions slanted; fences put a corner post or pillar in its own sprite and mitre stone bases; Y joints let the lowest id run through. Streets: every end part is a cut e + k*w, so side streets come in at any angle (slanted sidewalk gaps), two streets meet in a mitre with seam patches; fixes a curve's outer sidewalk that was skipped when its kerb and back lines had different point counts. Studio: runs and drags keep to x/y or snap to 45 degree diagonals, Ctrl gives any angle; a road run turning 45 degrees gets a 45 degree curve; Rotation is a number box. Streets demo: a diagonal lane with a hedge. MCP guidance, SCRIPTING/README/ROADMAP. Tests: Rust 76, Studio and web checks green.
606 lines
34 KiB
Python
606 lines
34 KiB
Python
"""Functional test of the isokit Studio (GUI), its MCP endpoint and the agent
|
||
window, headless Edge. The agent window runs on a mock model this script starts:
|
||
|
||
ISOKIT_LLM_URL=http://127.0.0.1:7932/v1 ISOKIT_LLM_KEY=test-key ISOKIT_CONFIG_DIR=<scratch> \
|
||
isokit studio <copy of examples/demo> --port 7890 --no-open
|
||
uv run --with playwright python tools/check_studio.py <screenshot dir> http://127.0.0.1:7890
|
||
"""
|
||
import json
|
||
import sys
|
||
import urllib.request
|
||
from pathlib import Path
|
||
from playwright.sync_api import sync_playwright
|
||
|
||
import agent_checks
|
||
|
||
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)
|
||
|
||
|
||
mock = agent_checks.start_mock()
|
||
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")
|
||
ver = pg.locator("#ver").inner_text()
|
||
check(ver == "isokit " + get("/api/meta")["engine"], "status bar shows the engine version: " + ver)
|
||
icon = urllib.request.urlopen(BASE + "/assets/studio/favicon.svg")
|
||
check(icon.headers.get_content_type() == "image/svg+xml" and b"<svg" in icon.read(), "favicon served as SVG")
|
||
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")
|
||
|
||
# build a house: a room (floor + 4 walls in one step), a window and a door snapped onto its walls,
|
||
# the wall carrying its window when dragged and deleted, furniture on the first floor, the cut-open level
|
||
to_client = """(w) => { const v = __studio.view, s = v.screenOf(w[0], w[1], w[2] || 0), svg = v.svg, pt = svg.createSVGPoint();
|
||
pt.x = s[0]; pt.y = s[1]; const q = pt.matrixTransform(svg.getScreenCTM()); return [q.x, q.y]; }"""
|
||
at = lambda x, y: pg.evaluate(to_client, [x, y, 0])
|
||
pg.evaluate("__studio.select(null)")
|
||
pg.evaluate("__studio.view.focus([25.5, 4, 0], 3)")
|
||
pg.wait_for_timeout(400)
|
||
n1 = len(doc()["objects"])
|
||
pg.locator('.st-tile[data-type="@room"]').click()
|
||
a, b2 = at(23, 2), at(28, 6)
|
||
pg.mouse.move(*a)
|
||
pg.mouse.down()
|
||
for i in range(1, 13):
|
||
pg.mouse.move(a[0] + (b2[0] - a[0]) * i / 12, a[1] + (b2[1] - a[1]) * i / 12)
|
||
pg.wait_for_timeout(15)
|
||
check(pg.locator(".st-ghost").count() == 5 and "5.00 × 4.00" in pg.locator("#hint").inner_text(), "the Room tool previews a 5 x 4 m room while dragging")
|
||
pg.screenshot(path=str(OUT / "studio-room-drag.jpg"), type="jpeg", quality=80)
|
||
pg.mouse.up()
|
||
pg.wait_for_timeout(800)
|
||
new = doc()["objects"][n1:]
|
||
kinds = sorted(o["type"] for o in new)
|
||
check(kinds == ["floor", "house_wall", "house_wall", "house_wall", "house_wall"], f"a room is a floor and four walls: {kinds}")
|
||
fl = next((o for o in new if o["type"] == "floor"), {})
|
||
check(fl.get("props") == {"w": 5, "d": 4} or fl.get("props") == {"w": 5.0, "d": 4.0}, f"the floor is 5 x 4 m: {fl.get('props')}")
|
||
south = next((o for o in new if o["type"] == "house_wall" and abs(o["y"] - 2) < 1e-6), None)
|
||
pg.locator('.st-tile[data-type="window"]').click()
|
||
pg.mouse.move(*at(25.1, 1.6))
|
||
pg.mouse.move(*at(25.2, 1.7))
|
||
check(pg.locator(".st-ghost").count() == 1, "hovering a wall with a window shows where it goes")
|
||
pg.mouse.click(*at(25.2, 1.7))
|
||
pg.wait_for_timeout(800)
|
||
win = doc()["objects"][-1]
|
||
check(win["type"] == "window" and abs(win["y"] - 2) < 1e-6 and abs(win["x"] - 25) < 1e-6, f"the window snaps onto the wall: ({win['x']}, {win['y']})")
|
||
pg.locator('.st-tile[data-type="door"]').click()
|
||
pg.mouse.click(*at(27.6, 4.2))
|
||
pg.wait_for_timeout(800)
|
||
door = doc()["objects"][-1]
|
||
check(door["type"] == "door" and abs(door["x"] - 28) < 1e-6 and door.get("rot") == 90, f"the door snaps onto the east wall and opens into the room: {door}")
|
||
# drag the south wall 1 m south: its window comes along, as one undo step
|
||
pg.evaluate("__studio.select(null)")
|
||
wb = pg.locator(f'.iso-svg g[data-obj="{south["id"]}"] path.iso-t').first.bounding_box()
|
||
cx, cy = wb["x"] + wb["width"] / 2, wb["y"] + wb["height"] / 2
|
||
d0, d1 = at(25, 2), at(25, 1)
|
||
pg.mouse.move(cx, cy)
|
||
pg.mouse.down()
|
||
for i in range(1, 11):
|
||
pg.mouse.move(cx + (d1[0] - d0[0]) * i / 10, cy + (d1[1] - d0[1]) * i / 10)
|
||
pg.wait_for_timeout(15)
|
||
pg.mouse.up()
|
||
pg.wait_for_timeout(800)
|
||
check(abs(obj(south["id"])["y"] - 1) < 1e-6 and abs(obj(win["id"])["y"] - 1) < 1e-6, "dragging the wall moves its window along")
|
||
pg.locator("#undo").click()
|
||
pg.wait_for_timeout(700)
|
||
check(abs(obj(south["id"])["y"] - 2) < 1e-6 and abs(obj(win["id"])["y"] - 2) < 1e-6, "one undo puts wall and window back")
|
||
pg.locator(f'#objects li[data-id="{south["id"]}"]').click()
|
||
pg.locator(".iso-svg").focus()
|
||
pg.keyboard.press("Delete")
|
||
pg.wait_for_timeout(800)
|
||
check(obj(south["id"]) is None and obj(win["id"]) is None, "deleting the wall deletes its window")
|
||
pg.keyboard.press("Control+z")
|
||
pg.wait_for_timeout(800)
|
||
check(obj(win["id"]) is not None, "undo brings both back")
|
||
# the cut-open level: walls in front of the room low, the others full height
|
||
rv = get("/api/render?level=gf&rot=1")
|
||
tops = sorted(round(rv["boxes"][o["id"]][5], 2) for o in new if o["type"] == "house_wall")
|
||
check(tops == [1.0, 1.0, 2.8, 2.8], f"GF cuts the two walls in front of the room low: {tops}")
|
||
# furniture placed while 1F is shown goes onto the first floor
|
||
pg.evaluate("__studio.select(null)")
|
||
pg.locator('.iso-seg button[data-level="1f"]').click()
|
||
pg.wait_for_timeout(600)
|
||
pg.locator('.st-tile[data-type="chair"]').click()
|
||
pg.mouse.click(*at(25, 4))
|
||
pg.wait_for_timeout(800)
|
||
chair = doc()["objects"][-1]
|
||
check(chair["type"] == "chair" and chair.get("z") == 3, f"a chair placed on 1F stands at z = 3: {chair.get('z')}")
|
||
pg.locator('.iso-seg button[data-level="gf"]').click()
|
||
pg.wait_for_timeout(600)
|
||
pg.screenshot(path=str(OUT / "studio-house-gf.jpg"), type="jpeg", quality=80)
|
||
pg.locator('.iso-seg button[data-level="site"]').click()
|
||
pg.evaluate("__studio.select(null)")
|
||
pg.wait_for_timeout(400)
|
||
|
||
# a fence run drawn corner by corner: a click starts it, the first corner closes it, as one undo step;
|
||
# the pieces join at their corners; a dragged fence snaps its ends onto the fences it meets
|
||
pg.evaluate("__studio.select(null)")
|
||
pg.evaluate("__studio.view.focus([29, -9.5, 0], 3)")
|
||
pg.wait_for_timeout(400)
|
||
n3 = len(doc()["objects"])
|
||
pg.locator('.st-tile[data-type="fence"]').click()
|
||
for x, y in [(24, -12), (32, -12.15), (32.1, -7), (24, -6.9)]:
|
||
pg.mouse.click(*at(x, y))
|
||
pg.wait_for_timeout(150)
|
||
pg.mouse.move(*at(24.1, -11.9))
|
||
pg.wait_for_timeout(150)
|
||
check("4 pieces" in pg.locator("#hint").inner_text() and "close" in pg.locator("#hint").inner_text(),
|
||
"drawing a run shows its pieces and offers to close it: " + pg.locator("#hint").inner_text()[:60])
|
||
pg.screenshot(path=str(OUT / "studio-fence-run.jpg"), type="jpeg", quality=80)
|
||
pg.mouse.click(*at(24.1, -11.9))
|
||
pg.wait_for_timeout(900)
|
||
run = doc()["objects"][n3:]
|
||
got = sorted((o["type"], o["x"], o["y"], o.get("rot", 0), o["props"]["w"]) for o in run)
|
||
want = sorted([("fence", 28, -12, 0, 8), ("fence", 32, -9.5, 90, 5), ("fence", 28, -7, 0, 8), ("fence", 24, -9.5, 90, 5)])
|
||
check(got == want, f"clicking the first corner closes the run: four fences {got}")
|
||
rv = get("/api/render?level=site&rot=1")
|
||
south = next((o["id"] for o in run if o["y"] == -12), None)
|
||
west = next((o["id"] for o in run if o["x"] == 24), None)
|
||
sb, wb = rv["boxes"].get(south, [0] * 6), rv["boxes"].get(west, [0] * 6)
|
||
check(abs(sb[0] - 23.96) < 1e-6 and abs(sb[3] - 32.04) < 1e-6 and abs(wb[1] + 11.96) < 1e-6,
|
||
f"the corners join (south runs through, west stops at it): {sb[0]:.2f}..{sb[3]:.2f}, {wb[1]:.2f}")
|
||
pg.locator("#undo").click()
|
||
pg.wait_for_timeout(700)
|
||
check(len(doc()["objects"]) == n3, "one undo takes the whole run back")
|
||
pg.locator("#redo").click()
|
||
pg.wait_for_timeout(700)
|
||
# a spur dragged from the run's east fence to the east boundary fence: both ends snap onto them
|
||
pg.locator('.st-tile[data-type="fence"]').click()
|
||
a, b2 = at(32.25, -9.6), at(34.3, -9.6)
|
||
pg.mouse.move(*a)
|
||
pg.mouse.down()
|
||
for i in range(1, 11):
|
||
pg.mouse.move(a[0] + (b2[0] - a[0]) * i / 10, a[1] + (b2[1] - a[1]) * i / 10)
|
||
pg.wait_for_timeout(15)
|
||
pg.mouse.up()
|
||
pg.wait_for_timeout(800)
|
||
sp = doc()["objects"][-1]
|
||
check(sp["type"] == "fence" and abs(sp["x"] - 33.3) < 1e-6 and abs(sp["props"]["w"] - 2.6) < 1e-6,
|
||
f"a dragged fence snaps its ends onto the fences it meets: x {sp['x']}, w {sp['props']['w']}")
|
||
# the design is a choice in the properties
|
||
pg.locator(f'#objects li[data-id="{south}"]').click()
|
||
pg.wait_for_timeout(300)
|
||
pg.locator('#right select[data-prop="style"]').select_option("brick")
|
||
pg.wait_for_timeout(800)
|
||
check(obj(south)["props"].get("style") == "brick", "a fence's design is picked in its properties")
|
||
pg.evaluate("__studio.select(null)")
|
||
pg.wait_for_timeout(500)
|
||
pg.screenshot(path=str(OUT / "studio-fences.jpg"), type="jpeg", quality=80)
|
||
|
||
# several objects at once: Ctrl+click in the list and on the map, a group drag as one step, Shift+drag
|
||
# draws a box; a second click on a fence selects every fence joined to it, whose design changes for all
|
||
sel = lambda: sorted(pg.evaluate("__studio.S.selIds"))
|
||
at3 = lambda x, y, z: pg.evaluate(to_client, [x, y, z])
|
||
pg.evaluate("__studio.select(null)")
|
||
pg.locator('#objects li[data-id="truck"]').click()
|
||
pg.locator('#objects li[data-id="genset"]').click(modifiers=["Control"])
|
||
pg.wait_for_timeout(300)
|
||
check(sel() == ["genset", "truck"] and "2 objects selected" in pg.locator("#right h3").inner_text(),
|
||
f"Ctrl+click in the list adds a second object: {sel()}")
|
||
check(pg.locator("#objects li[aria-selected=true]").count() == 2 and pg.locator(".iso-svg .iso-osel").count() >= 2,
|
||
"both are marked in the list and outlined on the map")
|
||
pg.evaluate("__studio.view.focus('truck', 2.5)")
|
||
pg.wait_for_timeout(400)
|
||
t0, g0 = obj("truck"), obj("genset")
|
||
tb = pg.locator('.iso-svg g[data-obj="truck"] path.iso-t').first.bounding_box()
|
||
mx, my = tb["x"] + tb["width"] / 2, tb["y"] + tb["height"] / 2
|
||
a0, a1 = at(t0["x"], t0["y"]), at(t0["x"] + 2, t0["y"])
|
||
pg.mouse.move(mx, my)
|
||
pg.mouse.down()
|
||
for i in range(1, 11):
|
||
pg.mouse.move(mx + (a1[0] - a0[0]) * i / 10, my + (a1[1] - a0[1]) * i / 10)
|
||
pg.wait_for_timeout(15)
|
||
pg.mouse.up()
|
||
pg.wait_for_timeout(800)
|
||
check(obj("truck")["x"] == t0["x"] + 2 and obj("genset")["x"] == g0["x"] + 2 and sel() == ["genset", "truck"],
|
||
f"dragging one moves both and keeps them selected: truck {obj('truck')['x']}, generator {obj('genset')['x']}")
|
||
pg.locator("#undo").click()
|
||
pg.wait_for_timeout(700)
|
||
check(obj("truck")["x"] == t0["x"] and obj("genset")["x"] == g0["x"], "one undo puts both back")
|
||
# the fence run: a click selects one piece, a second click every fence joined to it
|
||
pg.evaluate("__studio.select(null)")
|
||
pg.evaluate("__studio.view.focus([29, -9.5, 0], 3)")
|
||
pg.wait_for_timeout(400)
|
||
pg.mouse.click(*at3(28, -12, 0.9))
|
||
pg.wait_for_timeout(400)
|
||
check(sel() == [south], f"a click selects one fence: {sel()}")
|
||
pg.mouse.click(*at3(28, -12, 0.9))
|
||
pg.wait_for_timeout(400)
|
||
joined = set(o["id"] for o in run) | {sp["id"], "fence-east"}
|
||
check(set(sel()) == joined, f"a second click selects every fence joined to it ({len(sel())} pieces)")
|
||
pg.screenshot(path=str(OUT / "studio-multi.jpg"), type="jpeg", quality=80)
|
||
check(pg.locator('#right select[data-prop="style"]').input_value() == "" and pg.locator('#right input[type=number][data-prop="w"]').input_value() == "",
|
||
"where the pieces differ (design, length) the form says mixed")
|
||
pg.locator('#right select[data-prop="style"]').select_option("wood")
|
||
pg.wait_for_timeout(800)
|
||
check(all(obj(i)["props"].get("style") == "wood" for i in joined), "a design picked for the selection goes to all of them")
|
||
pg.mouse.click(*at3(28, -12, 0.9))
|
||
pg.wait_for_timeout(400)
|
||
check(sel() == [south], "a click on one of them selects just that one again")
|
||
# Shift+drag: a box around the run selects what lies wholly inside it
|
||
pg.evaluate("__studio.select(null)")
|
||
corners = [at(23, -13), at(33, -13), at(33, -6), at(23, -6)]
|
||
x0, x1 = min(c[0] for c in corners), max(c[0] for c in corners)
|
||
y0, y1 = min(c[1] for c in corners) - 40, max(c[1] for c in corners)
|
||
pg.keyboard.down("Shift")
|
||
pg.mouse.move(x0, y0)
|
||
pg.mouse.down()
|
||
for i in range(1, 11):
|
||
pg.mouse.move(x0 + (x1 - x0) * i / 10, y0 + (y1 - y0) * i / 10)
|
||
pg.wait_for_timeout(15)
|
||
check(pg.locator("#stband").count() == 1, "Shift+drag draws a selection box")
|
||
pg.mouse.up()
|
||
pg.keyboard.up("Shift")
|
||
pg.wait_for_timeout(400)
|
||
boxed = set(sel())
|
||
check(set(o["id"] for o in run) <= boxed and "site" not in boxed and pg.locator("#stband").count() == 0,
|
||
f"the box selects the run's fences, not the locked ground ({len(boxed)} objects)")
|
||
# Delete removes them all as one step
|
||
n4 = len(doc()["objects"])
|
||
pg.locator(".iso-svg").focus()
|
||
pg.keyboard.press("Delete")
|
||
pg.wait_for_timeout(800)
|
||
check(len(doc()["objects"]) == n4 - len(boxed) and sel() == [], f"Delete removes all {len(boxed)}")
|
||
pg.keyboard.press("Control+z")
|
||
pg.wait_for_timeout(800)
|
||
check(len(doc()["objects"]) == n4, "one undo brings them all back")
|
||
pg.keyboard.press("Control+a")
|
||
pg.wait_for_timeout(300)
|
||
check(len(sel()) == len([o for o in doc()["objects"] if not o.get("locked") and not o.get("hidden")]), "Ctrl+A selects everything not locked")
|
||
pg.keyboard.press("Escape")
|
||
pg.wait_for_timeout(300)
|
||
check(sel() == [], "Esc clears the selection")
|
||
# a pond: fish swim round in it (CSS animation, one rotating group per fish)
|
||
pg.evaluate("__studio.op({op: 'add', type: 'pond', x: -29, y: 13})")
|
||
pg.wait_for_timeout(900)
|
||
check(pg.locator('.iso-svg g[data-obj] .iso-swim').count() == 5 and pg.evaluate("getComputedStyle(document.querySelector('.iso-svg .iso-swim')).animationName") == "iso-spin",
|
||
"a new pond has five fish swimming round")
|
||
pg.locator("#undo").click()
|
||
pg.wait_for_timeout(600)
|
||
pg.evaluate("__studio.select(null)")
|
||
|
||
# a conveyor runs the way it was drawn; started at a producer's output it carries what the producer
|
||
# makes, spaced by its rate (changed in the producer's properties) and the belt's speed
|
||
pg.evaluate("__studio.select(null)")
|
||
pg.evaluate("__studio.view.focus([25, -4.5, 0], 3)")
|
||
pg.wait_for_timeout(400)
|
||
n4 = len(doc()["objects"])
|
||
pg.locator('.st-tile[data-type="conveyor"]').click()
|
||
a, b2 = at(27, -4.5), at(21, -4.5)
|
||
pg.mouse.move(*a)
|
||
pg.mouse.down()
|
||
for i in range(1, 11):
|
||
pg.mouse.move(a[0] + (b2[0] - a[0]) * i / 10, a[1] + (b2[1] - a[1]) * i / 10)
|
||
pg.wait_for_timeout(15)
|
||
check("running from where you started" in pg.locator("#hint").inner_text(), "the conveyor preview says which way it runs")
|
||
pg.mouse.up()
|
||
pg.wait_for_timeout(800)
|
||
cv = doc()["objects"][-1]
|
||
check(cv["type"] == "conveyor" and cv.get("rot") == 180 and abs(cv["x"] - 24) < 1e-6 and abs(cv["props"]["w"] - 6) < 1e-6,
|
||
f"a conveyor dragged right to left runs that way (rot {cv.get('rot')}, x {cv['x']})")
|
||
pg.evaluate("__studio.op({op: 'add', type: 'producer', x: 28.6, y: -4.5, rot: 180, props: {rate: 20}})")
|
||
pg.wait_for_timeout(900)
|
||
pid = doc()["objects"][-1]["id"]
|
||
rows = lambda: dict(pg.evaluate(f"(__studio.view.info('{cv['id']}') || {{}}).rows || []"))
|
||
check(f"(from {pid})" in rows().get("Carries", "") and "20 cartons a minute, 1.5 m apart" in rows().get("Carries", ""),
|
||
"the belt carries what the producer at its start makes: " + rows().get("Carries", "?"))
|
||
check(pg.evaluate(f"document.querySelectorAll('.iso-svg [data-obj=\"{cv['id']}\"] .iso-ride').length") == 1 and
|
||
pg.evaluate(f"document.querySelectorAll('.iso-svg [data-obj=\"{pid}\"] .iso-ram').length") == 1,
|
||
"goods ride the belt, the press's ram goes up and down")
|
||
pg.locator(f'#objects li[data-id="{pid}"]').click()
|
||
pg.wait_for_timeout(300)
|
||
pg.locator('#right input[type="number"][data-prop="rate"]').fill("30")
|
||
pg.locator('#right input[type="number"][data-prop="rate"]').press("Enter")
|
||
pg.wait_for_timeout(900)
|
||
check("30 cartons a minute, 1 m apart" in rows().get("Carries", ""), "a faster producer packs the belt closer: " + rows().get("Carries", "?"))
|
||
# a robot arm at the belt's end picks the goods off and stacks them on a pallet, one per item
|
||
pg.evaluate("__studio.op({op: 'add', type: 'robot_arm', x: 19.2, y: -4.5})")
|
||
pg.wait_for_timeout(900)
|
||
rid = doc()["objects"][-1]["id"]
|
||
rrows = dict(pg.evaluate(f"(__studio.view.info('{rid}') || {{}}).rows || []"))
|
||
check(rrows.get("Moves") == f"cartons from {cv['id']} to a pallet" and rrows.get("Cycle") == "2 s" and
|
||
pg.evaluate(f"document.querySelectorAll('.iso-svg [data-obj=\"{rid}\"] .iso-flip').length") == 16,
|
||
f"a robot arm at the belt's end moves its goods to a pallet in step with them: {rrows}")
|
||
pg.screenshot(path=str(OUT / "studio-conveyor.jpg"), type="jpeg", quality=80)
|
||
for _ in range(4):
|
||
pg.locator("#undo").click()
|
||
pg.wait_for_timeout(500)
|
||
check(len(doc()["objects"]) == n4, "undo takes the robot, the producer and the belt back")
|
||
pg.evaluate("__studio.select(null)")
|
||
pg.wait_for_timeout(300)
|
||
|
||
# performance: animations step about ten times a second by default (no CSS animation runs on its
|
||
# own), the toolbar button switches smooth / off / light; a pan moves the drawing as one layer and
|
||
# draws it again (viewBox) only when the drag ends
|
||
pg.evaluate("__studio.select(null)")
|
||
pg.evaluate("localStorage.removeItem('isokit-anim')")
|
||
an = lambda: pg.evaluate("__studio.view.animation()")
|
||
running = lambda: pg.evaluate("document.querySelector('.iso-svg').getAnimations({subtree: true}).filter(a => a.playState === 'running').length")
|
||
t0 = pg.evaluate("(() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true}); return a.length ? a[0].currentTime : null; })()")
|
||
pg.wait_for_timeout(600)
|
||
t1 = pg.evaluate("(() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true}); return a.length ? a[0].currentTime : null; })()")
|
||
check(an() == "light" and running() == 0 and t0 is not None and 300 < t1 - t0 < 900,
|
||
f"animations are light by default: none runs on its own, they step on ({t0} -> {t1})")
|
||
btn = pg.locator('.iso-bar [data-a="anim"]')
|
||
btn.click()
|
||
pg.wait_for_timeout(500)
|
||
check(an() == "smooth" and running() > 0 and "smooth" in btn.get_attribute("title"), f"the button switches to smooth ({running()} running)")
|
||
btn.click()
|
||
pg.wait_for_timeout(300)
|
||
check(an() == "off" and running() == 0, "then off")
|
||
btn.click()
|
||
pg.wait_for_timeout(300)
|
||
check(an() == "light" and pg.evaluate("localStorage.getItem('isokit-anim')") == "light", "then light again, remembered")
|
||
vb0 = pg.evaluate("document.querySelector('.iso-svg').getAttribute('viewBox')")
|
||
bx = pg.locator(".iso-stage").bounding_box()
|
||
cx2, cy2 = bx["x"] + bx["width"] / 2, bx["y"] + bx["height"] / 2
|
||
pg.mouse.move(cx2, cy2)
|
||
pg.mouse.down(button="middle")
|
||
for i in range(1, 9):
|
||
pg.mouse.move(cx2 + 10 * i, cy2 + 4 * i)
|
||
pg.wait_for_timeout(20)
|
||
mid = pg.evaluate("[document.querySelector('.iso-svg').getAttribute('viewBox'), document.querySelector('.iso-svg').style.transform, document.querySelector('.iso-gesture') !== null]")
|
||
pg.mouse.up(button="middle")
|
||
pg.wait_for_timeout(300)
|
||
end = pg.evaluate("[document.querySelector('.iso-svg').getAttribute('viewBox'), document.querySelector('.iso-svg').style.transform, document.querySelector('.iso-gesture') !== null]")
|
||
check(mid[0] == vb0 and "translate" in mid[1] and mid[2] and end[0] != vb0 and end[1] == "" and not end[2],
|
||
f"a pan moves the layer (viewBox kept, transform {mid[1][:40]}), the release draws it again")
|
||
check(pg.evaluate("getComputedStyle(document.getElementById('cursor')).contain").startswith("strict") or "size" in pg.evaluate("getComputedStyle(document.getElementById('cursor')).contain"),
|
||
"the pointer readout is a contained box (its text does not lay the page out again)")
|
||
|
||
# the middle mouse button only moves the camera: it never drags, selects or places anything
|
||
pg.locator('#objects li[data-id="truck"]').click()
|
||
pg.evaluate("__studio.view.focus('truck', 3)")
|
||
pg.wait_for_timeout(400)
|
||
t0 = obj("truck")
|
||
p0 = at(t0["x"], t0["y"])
|
||
tb = pg.locator('.iso-svg g[data-obj="truck"] path.iso-t').first.bounding_box()
|
||
mx, my = tb["x"] + tb["width"] / 2, tb["y"] + tb["height"] / 2
|
||
pg.mouse.move(mx, my)
|
||
pg.mouse.down(button="middle")
|
||
for i in range(1, 11):
|
||
pg.mouse.move(mx + 8 * i, my + 5 * i)
|
||
pg.wait_for_timeout(15)
|
||
pg.mouse.up(button="middle")
|
||
pg.wait_for_timeout(600)
|
||
t1, p1 = obj("truck"), at(t0["x"], t0["y"])
|
||
check((t1["x"], t1["y"]) == (t0["x"], t0["y"]) and abs(p1[0] - p0[0] - 80) < 3 and abs(p1[1] - p0[1] - 50) < 3,
|
||
f"a middle-drag on the selected truck pans the view and leaves the truck where it was (moved {p1[0] - p0[0]:.0f}, {p1[1] - p0[1]:.0f} px)")
|
||
gb = pg.locator('.iso-svg g[data-obj="genset"] path.iso-t').first.bounding_box()
|
||
pg.mouse.click(gb["x"] + gb["width"] / 2, gb["y"] + gb["height"] / 2, button="middle")
|
||
pg.wait_for_timeout(400)
|
||
check(pg.evaluate("__studio.S.sel") == "truck", "a middle click on another object does not select it")
|
||
n2 = len(doc()["objects"])
|
||
pg.locator('.st-tile[data-type="tree"]').click()
|
||
pg.mouse.move(mx, my)
|
||
pg.mouse.down(button="middle")
|
||
for i in range(1, 11):
|
||
pg.mouse.move(mx - 8 * i, my - 5 * i)
|
||
pg.wait_for_timeout(15)
|
||
pg.mouse.up(button="middle")
|
||
pg.wait_for_timeout(600)
|
||
check(len(doc()["objects"]) == n2 and pg.evaluate("__studio.S.placing") == "tree",
|
||
"while placing, a middle-drag pans and places nothing")
|
||
pg.keyboard.press("Escape")
|
||
pg.evaluate("__studio.select(null)")
|
||
pg.wait_for_timeout(300)
|
||
|
||
# a road run drawn corner by corner off the main road: a curve rounds its corner, the road it
|
||
# starts on gets a junction (its south kerb opens), and one undo step takes the run back
|
||
pg.evaluate("__studio.view.focus([6, -27, 0], 2.2)")
|
||
pg.wait_for_timeout(400)
|
||
n4 = len(doc()["objects"])
|
||
kerbs = lambda: pg.locator('.iso-svg g[data-obj="road"] .iso-kerb').count()
|
||
k0 = kerbs()
|
||
pg.locator('.st-tile[data-type="road"]').click()
|
||
for x, y in [(-2, -19.3), (-2.1, -32), (20, -32.1)]:
|
||
pg.mouse.click(*at(x, y))
|
||
pg.wait_for_timeout(150)
|
||
pg.mouse.move(*at(20, -36))
|
||
pg.wait_for_timeout(150)
|
||
check("corners rounded" in pg.locator("#hint").inner_text(), "a road run says its corners get rounded: " + pg.locator("#hint").inner_text()[:50])
|
||
pg.keyboard.press("Enter")
|
||
pg.wait_for_timeout(1200)
|
||
run = doc()["objects"][n4:]
|
||
got = sorted((o["type"], o["x"], o["y"], o.get("rot", 0), o["props"].get("w", o["props"].get("r"))) for o in run)
|
||
want = sorted([("road", -2, -20.75, 90, 2.5), ("road", 14, -32, 0, 12), ("road_curve", -2, -22, 270, 10)])
|
||
check(got == want, f"the run's corner gets a curve: {got}")
|
||
rv = get("/api/render?level=site&rot=0")
|
||
stub = next((o["id"] for o in run if o["type"] == "road" and o["x"] == -2), None)
|
||
sb = rv["boxes"].get(stub, [0] * 6)
|
||
check(abs(sb[4] + 19.5) < 1e-6 and kerbs() == k0 + 1,
|
||
f"the run starts on the main road's centre line and opens its kerb: {sb[4]:.2f}, {k0} -> {kerbs()} kerb lines")
|
||
pg.evaluate("__studio.select(null)")
|
||
pg.wait_for_timeout(300)
|
||
pg.screenshot(path=str(OUT / "studio-road-run.jpg"), type="jpeg", quality=80)
|
||
pg.locator("#undo").click()
|
||
pg.wait_for_timeout(900)
|
||
check(len(doc()["objects"]) == n4 and kerbs() == k0, "one undo takes the road run back")
|
||
|
||
# diagonals, east of the site: a fence run snaps its middle leg to 45 degrees, Ctrl frees the angle of a
|
||
# dragged road, a road run turning 45 degrees gets a 45-degree curve, the rotation can be typed
|
||
pg.evaluate("__studio.view.focus([48, 8, 0], 1.8)")
|
||
pg.wait_for_timeout(400)
|
||
n5 = len(doc()["objects"])
|
||
pg.locator('.st-tile[data-type="fence"]').click()
|
||
for x, y in [(38, -10), (44, -10.1), (49.7, -4.2), (50.1, 4)]:
|
||
pg.mouse.click(*at(x, y))
|
||
pg.wait_for_timeout(150)
|
||
check("diagonals at 45°" in pg.locator("#hint").inner_text(), "a run says how diagonals snap")
|
||
pg.keyboard.press("Enter")
|
||
pg.wait_for_timeout(1000)
|
||
got = sorted((o["x"], o["y"], o.get("rot", 0), o["props"]["w"]) for o in doc()["objects"][n5:])
|
||
check(got == [(41, -10, 0, 6), (47, -7, 45, 8.485), (50, 0, 90, 8)], f"a fence run takes a 45-degree leg: {got}")
|
||
pg.locator('.st-tile[data-type="road"]').click()
|
||
pg.keyboard.down("Control")
|
||
pg.mouse.move(*at(38, 12))
|
||
pg.mouse.down()
|
||
for i in range(1, 11):
|
||
pg.mouse.move(*at(38 + 1.2 * i, 12 + 0.6 * i))
|
||
pg.wait_for_timeout(20)
|
||
pg.mouse.up()
|
||
pg.keyboard.up("Control")
|
||
pg.wait_for_timeout(1000)
|
||
free = doc()["objects"][-1]
|
||
check(free["type"] == "road" and abs(free["rot"] - 26.5651) < 1e-6 and abs(free["props"]["w"] - 13.416) < 1e-6,
|
||
f"Ctrl draws a road at any angle: rot {free.get('rot')}, w {free['props'].get('w')}")
|
||
n6 = len(doc()["objects"])
|
||
pg.locator('.st-tile[data-type="road"]').click()
|
||
for x, y in [(38, 24), (52, 24), (62.2, 34.1)]:
|
||
pg.mouse.click(*at(x, y))
|
||
pg.wait_for_timeout(150)
|
||
pg.keyboard.press("Enter")
|
||
pg.wait_for_timeout(1000)
|
||
got = sorted((o["type"], o["x"], o["y"], o.get("rot", 0), o["props"].get("w", o["props"].get("r")), o["props"].get("angle")) for o in doc()["objects"][n6:])
|
||
want = sorted([("road", 42.929, 24, 0, 9.858, None), ("road", 58.464, 30.464, 45, 10, None), ("road_curve", 47.858, 24, 0, 10, 45)])
|
||
check(got == want, f"a road run turning 45 degrees gets a 45-degree curve: {got}")
|
||
pg.evaluate(f"__studio.select('{free['id']}')")
|
||
pg.wait_for_timeout(300)
|
||
rin = pg.locator('#right input[data-f="rot"]')
|
||
rin.fill("30")
|
||
rin.press("Enter")
|
||
pg.wait_for_timeout(800)
|
||
check(obj(free["id"])["rot"] == 30, f"the rotation can be typed: {obj(free['id'])['rot']}")
|
||
pg.evaluate("__studio.select(null)")
|
||
pg.wait_for_timeout(300)
|
||
pg.screenshot(path=str(OUT / "studio-diagonals.jpg"), type="jpeg", quality=80)
|
||
for _ in range(4):
|
||
pg.locator("#undo").click()
|
||
pg.wait_for_timeout(700)
|
||
check(len(doc()["objects"]) == n5, "four undos take the diagonals back")
|
||
|
||
# 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)
|
||
# the agent window, on the mock model (the Studio must run with ISOKIT_LLM_URL=<mock>/v1, see the docstring)
|
||
cfg = get("/api/agent/config")
|
||
if agent_checks.MOCK not in cfg["url"]:
|
||
check(False, "agent window checks need the Studio started with ISOKIT_LLM_URL=" + agent_checks.MOCK + "/v1 (found " + cfg["url"] + ")")
|
||
else:
|
||
check(not cfg.get("key") and cfg["has_key"] and cfg["key_from"] == "env", "/api/agent/config says a key is set, without the key")
|
||
pg.locator('.iso-seg button[data-level="site"]').click()
|
||
agent_checks.run(pg, lambda: doc()["objects"], lambda n: pg.screenshot(path=str(OUT / n), type="jpeg", quality=80), check, can_set_key=False)
|
||
check(not errs, f"no script errors {errs}")
|
||
b.close()
|
||
|
||
mock.terminate()
|
||
print("FAILED:" if fails else "ALL PASSED", fails)
|
||
sys.exit(1 if fails else 0)
|