Files
isokit/tools/check_studio.py
T
grabowski c8becc2e59
Deploy web Studio / deploy (push) Successful in 1m4s
Point handles for routes and outlines; Area corners; bus goes round right
Studio: one selected object with pts (a route, an outlined area) shows a
handle on each point and a + in the middle of each side: drag a point to
move it, drag a + to add one there, double-click a point to remove it
(open routes keep 2, outlines 3). Points snap to the grid and onto wall,
fence and road ends, at any angle; one undo step each. Handles keep their
screen size (new viewer callback onView on every redraw) and follow
re-renders and selection. The viewer captures the pointer, so a double-
click targets the drawing: the last point pressed is remembered instead.

Prefab meta `outline`: the prefab draws pts as its outline. Area has it:
Edit corners turns its rectangle into four points, Rectangle again
removes them.

Routes: where a path doubles back, the lane offset puts a point on each
side, so it rounds into a U-turn instead of a diagonal across the lanes.
The streets demo bus now runs east, three quarters round the roundabout
(clockwise, left-hand traffic), down south street, turns and comes back
by the short way, instead of going round the wrong way on its way out.

Tests: Rust 81 (a doubling-back route: each mover once, no sort cycles),
Studio 134 (corners, move, add, remove, undo), web 100.
2026-10-25 18:46:47 +07:00

747 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")
# animals and an apiary from the palette: a deer herd grazes, the hives get bees
pal = pg.evaluate("() => [...document.querySelectorAll('.st-cat')].map(e => e.dataset.cat)")
check("Animals" in pal, "the palette has an Animals category")
pg.locator('.st-tile[data-type="deer"]').click()
pg.mouse.click(*at(44, 28))
pg.wait_for_timeout(900)
deer = doc()["objects"][-1]
pg.locator('.st-tile[data-type="beehive"]').click()
pg.mouse.click(*at(52, 18))
pg.wait_for_timeout(900)
hives = doc()["objects"][-1]
grazing = pg.evaluate(f"() => document.querySelectorAll('[data-obj=\"{deer['id']}\"] .iso-pose2.iso-pose0').length")
bees = pg.evaluate(f"() => document.querySelectorAll('[data-obj=\"{hives['id']}\"] .iso-bee').length")
herd = pg.evaluate(f"() => Object.fromEntries((__studio.view.info('{deer['id']}') || {{rows: []}}).rows)['Herd'] || ''")
check(deer["type"] == "deer" and hives["type"] == "beehive" and grazing == 4 and bees == 24 and herd == "1 stag, 2 does, 1 fawn",
f"a deer herd and beehives placed from the palette: {grazing} grazing, {bees} bees, herd {herd}")
pg.screenshot(path=str(OUT / "studio-wildlife.jpg"), type="jpeg", quality=80)
for _ in range(2):
pg.locator("#undo").click()
pg.wait_for_timeout(700)
check(len(doc()["objects"]) == n5, "two undos take the animals back")
# a route drawn point by point: four corners and the first again make a loop of cars; two
# points and Enter a there-and-back walk. Each is one object whose pts are the points
pg.locator('.st-tile[data-type="route"]').click()
hint = pg.locator("#hint").inner_text()
for x, y in [(42, 14), (56, 14), (56, 24), (42, 24), (42, 14)]:
pg.mouse.click(*at(x, y))
pg.wait_for_timeout(150)
pg.wait_for_timeout(1000)
loop = doc()["objects"][-1]
trav = pg.evaluate(f"() => document.querySelectorAll(`[data-obj='{loop['id']}'] .iso-travel`).length")
rows = pg.evaluate(f"() => Object.fromEntries((__studio.view.info('{loop['id']}') || {{rows: []}}).rows)")
check("point after point" in hint and loop["type"] == "route" and loop["pts"] == [[-7, -5], [7, -5], [7, 5], [-7, 5]] and loop["x"] == 49 and loop["y"] == 19
and loop["props"].get("closed") is True and trav > 4 and rows.get("Route") == "loop, 48 m",
f"a route drawn point by point loops: {loop.get('pts')} at ({loop['x']}, {loop['y']}), {trav} pieces, {rows}")
pg.locator('.st-tile[data-type="route"]').click()
for x, y in [(42, 28), (56, 28)]:
pg.mouse.click(*at(x, y))
pg.wait_for_timeout(150)
pg.keyboard.press("Enter")
pg.wait_for_timeout(1000)
walk = doc()["objects"][-1]
check(walk["type"] == "route" and walk["pts"] == [[-7, 0], [7, 0]] and walk["props"].get("closed") is False,
f"two points and Enter: there and back {walk.get('pts')}")
pg.screenshot(path=str(OUT / "studio-route.jpg"), type="jpeg", quality=80)
for _ in range(2):
pg.locator("#undo").click()
pg.wait_for_timeout(700)
check(len(doc()["objects"]) == n5, "two undos take the routes back")
# groups and the drawing order in the object list: two trees grouped (Ctrl+G), the group hidden
# by its eye, the yard (a ground area) to the front, a lamp dragged into the group, ungrouped
order0 = [o["id"] for o in doc()["objects"]]
row = lambda id: pg.locator(f'#objects li[data-id="{id}"]')
row("tree-1").scroll_into_view_if_needed()
row("tree-1").click()
row("tree-2").click(modifiers=["Control"])
pg.keyboard.press("Control+g")
pg.wait_for_timeout(900)
d = doc()
grp = [o for o in d["objects"] if o["type"] == "group"]
gid = grp[0]["id"] if grp else "?"
ids1 = [o["id"] for o in d["objects"]]
mem = sorted(o["id"] for o in d["objects"] if o.get("group") == gid)
depth = pg.evaluate("() => getComputedStyle(document.querySelector('#objects li[data-id=\"tree-1\"]')).getPropertyValue('--d').trim()")
check(len(grp) == 1 and mem == ["tree-1", "tree-2"] and ids1.index(gid) == ids1.index("tree-2") + 1 and depth == "1"
and pg.locator("#right h3").inner_text().strip().endswith("Group 1"),
f"Ctrl+G groups two trees: {gid} holds {mem}, indented ({depth}), just in front of them")
drawn = "() => ['tree-1', 'tree-2'].map(id => document.querySelectorAll(`.iso-svg [data-obj='${id}']`).length)"
row(gid).hover()
row(gid).locator("[data-eye]").click()
pg.wait_for_timeout(900)
gone = pg.evaluate(drawn)
row(gid).locator("[data-eye]").click()
pg.wait_for_timeout(900)
back = pg.evaluate(drawn)
check(gone == [0, 0] and back[0] > 0 and back[1] > 0, f"the group's eye hides what is in it: {gone} -> {back}")
row("yard").scroll_into_view_if_needed()
row("yard").click()
pg.keyboard.press("Control+Shift+BracketRight")
pg.wait_for_timeout(900)
check([o["id"] for o in doc()["objects"]][-1] == "yard", "Ctrl+Shift+] puts the yard (ground) in front: last in the drawing order")
row(gid).scroll_into_view_if_needed()
src, dst = row("lamp-1").bounding_box(), row(gid).bounding_box()
pg.mouse.move(src["x"] + 60, src["y"] + src["height"] / 2)
pg.mouse.down()
pg.mouse.move(dst["x"] + 60, dst["y"] + dst["height"] / 2, steps=8)
pg.mouse.up()
pg.wait_for_timeout(900)
check(obj("lamp-1").get("group") == gid, "a row dragged onto a group row goes into the group")
row(gid).click()
pg.keyboard.press("Control+Shift+g")
pg.wait_for_timeout(900)
d = doc()
check(not any(o["type"] == "group" for o in d["objects"]) and not any(o.get("group") for o in d["objects"]) and len(d["objects"]) == n5,
"Ctrl+Shift+G ungroups: the group goes, what was in it stays")
pg.screenshot(path=str(OUT / "studio-groups.jpg"), type="jpeg", quality=80)
for _ in range(6):
pg.locator("#undo").click()
pg.wait_for_timeout(700)
check([o["id"] for o in doc()["objects"]] == order0 and not any(o.get("group") for o in doc()["objects"]),
"six undos restore the order and take the group back out")
pg.evaluate("__studio.select(null)")
pg.wait_for_timeout(300)
# point handles: the yard (an area, 24 x 18 m at 14, 4) gets corners; one is dragged, a point is
# added by dragging the + of a side and removed again by a double-click; undos restore the rectangle
pg.evaluate("__studio.view.focus([14, 4, 0], 1.6)")
pg.wait_for_timeout(400)
pg.evaluate("__studio.select('yard')")
pg.wait_for_timeout(400)
pg.locator('#right button[data-act="pts"]').click()
pg.wait_for_timeout(900)
y0 = obj("yard").get("pts")
nh = pg.evaluate("() => [document.querySelectorAll('.st-hpt').length, document.querySelectorAll('.st-hmid').length]")
check(y0 == [[-12, -9], [12, -9], [12, 9], [-12, 9]] and nh == [4, 4], f"Edit corners: the yard becomes 4 points with handles: {y0}, {nh}")
def handle_drag(a, b):
pg.mouse.move(*at(*a))
pg.mouse.down()
pg.mouse.move(*at((a[0] + b[0]) / 2, (a[1] + b[1]) / 2), steps=4)
pg.mouse.move(*at(*b), steps=4)
pg.mouse.up()
pg.wait_for_timeout(900)
handle_drag((26, 13), (24, 16))
y1 = obj("yard")["pts"]
handle_drag((14, -5), (14, -8))
y2 = obj("yard")["pts"]
pg.mouse.dblclick(*at(14, -8))
pg.wait_for_timeout(900)
y3 = obj("yard")["pts"]
check(y1[2] == [10, 12] and y2 == [[-12, -9], [0, -12], [12, -9], [10, 12], [-12, 9]] and y3 == [[-12, -9], [12, -9], [10, 12], [-12, 9]],
f"handles move a corner {y1[2]}, add a point on a side {y2[1] if len(y2) > 4 else y2}, a double-click removes it ({len(y3)} left)")
pg.screenshot(path=str(OUT / "studio-handles.jpg"), type="jpeg", quality=80)
for _ in range(4):
pg.locator("#undo").click()
pg.wait_for_timeout(700)
check("pts" not in obj("yard"), "four undos give the yard its rectangle back")
pg.evaluate("__studio.select(null)")
pg.wait_for_timeout(300)
# 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)