mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 10:56:48 -06:00
Inside command.batch, `{"$step": n}` anywhere in a step's params is
replaced by the `id` in step n's result before the step runs
(`{"$step": n, "field": "..."}` picks another field). A nested tree,
such as a frame with children, now takes one batch and one undo step,
not one round-trip per level with ids copied between calls by hand.
Malformed references (forward or self, negative or fractional index,
missing or non-string field, extra keys) fail the batch with BadParams
naming the step, and the existing group machinery rolls it back. The
params walk is depth-capped. Steps are journalled with resolved params.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
347 lines
19 KiB
Rust
347 lines
19 KiB
Rust
//! End-to-end agent tasks: an in-process MCP client completes realistic design tasks only through
|
|
//! MCP tool calls, and checks every result through inspect_document / get_node like an agent would.
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
mod common;
|
|
|
|
use common::{Client, approx, png_dims};
|
|
use serde_json::{Value, json};
|
|
|
|
fn ids(v: &Value) -> Vec<Value> {
|
|
v.as_array().cloned().unwrap_or_default()
|
|
}
|
|
|
|
/// Task 1: a profile card with vertical auto layout, padding, gap, hug height, fill-width children.
|
|
#[test]
|
|
fn task_card_with_auto_layout() {
|
|
let mut c = Client::new();
|
|
let card = c.create(json!({"type": "frame", "name": "Card", "x": 100, "y": 100, "width": 320, "height": 50,
|
|
"props": {"fill": "#FFFFFF", "cornerRadius": 16, "layout": "vertical", "padding": 24, "gap": 12, "sizingV": "hug"}}));
|
|
let title = c.create(json!({"type": "text", "parent": card, "text": "Ada Lovelace", "props": {"fontSize": 24}}));
|
|
let body = c.create(json!({"type": "text", "parent": card, "text": "First programmer", "props": {"fontSize": 14, "fill": "#666666"}}));
|
|
let button = c.create(json!({"type": "frame", "parent": card, "name": "Button", "height": 44,
|
|
"props": {"fill": "#3B5BDB", "cornerRadius": 8, "layout": "horizontal", "mainAlign": "center", "crossAlign": "center", "sizingH": "fill"}}));
|
|
c.create(json!({"type": "text", "parent": button, "text": "Follow", "props": {"fill": "#FFFFFF"}}));
|
|
|
|
let n = c.node(&card);
|
|
assert_eq!(n["layout"]["mode"], "vertical", "{}", n["layout"]);
|
|
assert_eq!((n["layout"]["gap"].as_f64(), n["layout"]["padding"][0].as_f64()), (Some(12.0), Some(24.0)));
|
|
let kids = ids(&n["children"]);
|
|
assert_eq!(kids.len(), 3);
|
|
// Children stack top to bottom: padding 24, gap 12.
|
|
let t = c.node(&title);
|
|
let b = c.node(&body);
|
|
assert!(approx(&t["x"], 24.0) && approx(&t["y"], 24.0), "title at {} {}", t["x"], t["y"]);
|
|
let want_body_y = 24.0 + t["height"].as_f64().unwrap() + 12.0;
|
|
assert!(approx(&b["y"], want_body_y), "body y {} want {want_body_y}", b["y"]);
|
|
// The button fills the width (320 - 2*24) and the card hugs its content height.
|
|
let btn = c.node(&button);
|
|
assert!(approx(&btn["width"], 272.0), "button width {}", btn["width"]);
|
|
let bottom = btn["y"].as_f64().unwrap() + btn["height"].as_f64().unwrap() + 24.0;
|
|
assert!(approx(&n["height"], bottom), "card height {} want {bottom}", n["height"]);
|
|
assert_eq!(n["cornerRadius"], json!(16.0));
|
|
}
|
|
|
|
/// Task 2: a component with two instances; override one instance's text, then edit the main.
|
|
#[test]
|
|
fn task_component_instances_and_overrides() {
|
|
let mut c = Client::new();
|
|
let frame = c.create(json!({"type": "frame", "name": "Chip", "x": 0, "y": 0, "width": 120, "height": 40, "props": {"fill": "#EEEEEE", "layout": "horizontal", "padding": 8}}));
|
|
let label = c.create(json!({"type": "text", "parent": frame, "text": "Label"}));
|
|
let comp = c.run("component.create", json!({"ids": [frame]}))["id"].clone();
|
|
assert_eq!(c.node(&comp)["type"].as_str().unwrap().to_lowercase(), "component");
|
|
|
|
let i1 = c.run("instance.create", json!({"component": comp, "x": 0, "y": 100}))["id"].clone();
|
|
let i2 = c.run("instance.create", json!({"component": comp, "x": 200, "y": 100}))["id"].clone();
|
|
for i in [&i1, &i2] {
|
|
let n = c.node(i);
|
|
assert_eq!(n["type"].as_str().unwrap().to_lowercase(), "instance");
|
|
assert_eq!(n["mainComponent"], comp);
|
|
assert_eq!(n["children"].as_array().unwrap().len(), 1);
|
|
}
|
|
// Override the text in the first instance.
|
|
let inner1 = c.node(&i1)["children"][0]["id"].clone();
|
|
c.ok("type_text", json!({"id": inner1, "text": "Design"}));
|
|
let n1 = c.node(&i1);
|
|
assert_eq!(n1["children"][0]["characters"], "Design");
|
|
assert!(n1["children"][0]["overrides"].to_string().contains("characters"), "override recorded: {}", n1["children"][0]);
|
|
// Editing the main component's fill propagates to both instances; the text override survives.
|
|
c.ok("set_properties", json!({"ids": [comp], "props": {"fill": "#FFD43B"}}));
|
|
c.ok("set_properties", json!({"ids": [label], "props": {"characters": "Tag"}}));
|
|
let n1 = c.node(&i1);
|
|
let n2 = c.node(&i2);
|
|
assert_eq!(n1["fills"][0]["color"].as_str().unwrap().to_uppercase(), "#FFD43B");
|
|
assert_eq!(n2["fills"][0]["color"].as_str().unwrap().to_uppercase(), "#FFD43B");
|
|
assert_eq!(n1["children"][0]["characters"], "Design");
|
|
assert_eq!(n2["children"][0]["characters"], "Tag");
|
|
}
|
|
|
|
/// Task 3: a gradient fill and a drop shadow, given in agent-friendly JSON (hex colours).
|
|
#[test]
|
|
fn task_gradient_and_drop_shadow() {
|
|
let mut c = Client::new();
|
|
let r = c.create(json!({"type": "rectangle", "name": "Hero bg", "x": 0, "y": 0, "width": 400, "height": 200}));
|
|
c.ok(
|
|
"set_properties",
|
|
json!({"ids": [r], "props": {
|
|
"fills": [{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}],
|
|
"effects": [{"type": "dropShadow", "color": "#00000040", "offset": [0, 8], "radius": 24}],
|
|
}}),
|
|
);
|
|
let n = c.node(&r);
|
|
assert_eq!(n["fills"][0]["type"], "gradient", "{}", n["fills"]);
|
|
assert_eq!(n["fills"][0]["kind"], "linear");
|
|
assert_eq!(n["fills"][0]["stops"].as_array().unwrap().len(), 2);
|
|
assert!(approx(&n["fills"][0]["stops"][1]["position"], 1.0));
|
|
let e = &n["effects"][0];
|
|
assert_eq!(e["type"], "dropShadow", "{e}");
|
|
assert_eq!(e["radius"], json!(24.0));
|
|
assert_eq!(e["offset"], json!([0.0, 8.0]));
|
|
assert!((e["color"]["a"].as_f64().unwrap() - 0x40 as f64 / 255.0).abs() < 0.01);
|
|
// The shadow makes the render bigger than the box.
|
|
let shot = c.call("screenshot", json!({"ids": [r]}));
|
|
let (w, h) = png_dims(shot["content"][0]["data"].as_str().unwrap());
|
|
assert!(w > 400 && h > 200, "shadow extends the render: {w}x{h}");
|
|
}
|
|
|
|
/// Task 4: a paint style created from a layer and applied to others; editing the style updates them.
|
|
#[test]
|
|
fn task_paint_style() {
|
|
let mut c = Client::new();
|
|
let swatch = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "props": {"fill": "#E8590C"}}));
|
|
let a = c.create(json!({"type": "ellipse", "x": 200, "y": 0}));
|
|
let b = c.create(json!({"type": "rectangle", "x": 400, "y": 0}));
|
|
let style = c.run("style.create", json!({"type": "paint", "name": "Brand/Orange", "from": swatch}))["id"].clone();
|
|
c.run("style.apply", json!({"ids": [a, b], "style": "Brand/Orange"}));
|
|
for id in [&a, &b] {
|
|
let n = c.node(id);
|
|
assert_eq!(n["fills"][0]["color"].as_str().unwrap().to_uppercase(), "#E8590C");
|
|
assert!(n["styles"].to_string().contains(style.as_str().unwrap()), "style link on {}: {}", id, n["styles"]);
|
|
}
|
|
let doc = c.doc();
|
|
assert!(doc["styles"].as_array().unwrap().iter().any(|s| s["name"] == "Brand/Orange"));
|
|
// Redefine the style: linked layers follow.
|
|
c.run("style.update", json!({"style": style, "value": [{"type": "solid", "color": {"r": 0.0, "g": 0.0, "b": 1.0}}]}));
|
|
assert_eq!(c.node(&a)["fills"][0]["color"].as_str().unwrap().to_uppercase(), "#0000FF");
|
|
assert_eq!(c.node(&b)["fills"][0]["color"].as_str().unwrap().to_uppercase(), "#0000FF");
|
|
}
|
|
|
|
/// Task 5: colour variables with Light/Dark modes bound to a frame; switching modes recolours it.
|
|
#[test]
|
|
fn task_variables_light_dark() {
|
|
let mut c = Client::new();
|
|
let coll = c.run("variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}))["id"].clone();
|
|
let bg = c
|
|
.run("variable.create", json!({"collection": coll, "name": "color/bg", "type": "color", "values": {"Light": "#FFFFFF", "Dark": "#111111"}}))
|
|
["id"]
|
|
.clone();
|
|
let frame = c.create(json!({"type": "frame", "name": "Screen", "width": 300, "height": 200}));
|
|
c.run("variable.bind", json!({"ids": [frame], "field": "fill", "variable": bg}));
|
|
let n = c.node(&frame);
|
|
assert_eq!(n["fills"][0]["color"].as_str().unwrap().to_uppercase(), "#FFFFFF");
|
|
assert!(n["boundVariables"].to_string().contains(bg.as_str().unwrap()));
|
|
c.run("variable.setMode", json!({"ids": [frame], "collection": coll, "mode": "Dark"}));
|
|
assert_eq!(c.node(&frame)["fills"][0]["color"].as_str().unwrap().to_uppercase(), "#111111");
|
|
c.run("variable.setMode", json!({"ids": [frame], "collection": coll, "mode": "Light"}));
|
|
assert_eq!(c.node(&frame)["fills"][0]["color"].as_str().unwrap().to_uppercase(), "#FFFFFF");
|
|
let list = c.run("variable.list", json!({}));
|
|
assert!(list.to_string().contains("color/bg"));
|
|
}
|
|
|
|
/// Task 6: align three shapes, distribute them, group them.
|
|
#[test]
|
|
fn task_group_align_distribute() {
|
|
let mut c = Client::new();
|
|
let a = c.create(json!({"type": "rectangle", "x": 10, "y": 50, "width": 40, "height": 40}));
|
|
let b = c.create(json!({"type": "rectangle", "x": 100, "y": 0, "width": 60, "height": 60}));
|
|
let d = c.create(json!({"type": "ellipse", "x": 300, "y": 120, "width": 20, "height": 20}));
|
|
c.ok(
|
|
"batch",
|
|
json!({"label": "Align + distribute", "commands": [
|
|
{"command": "align.top", "params": {"ids": [a, b, d]}},
|
|
{"command": "distribute.horizontal", "params": {"ids": [a, b, d]}},
|
|
]}),
|
|
);
|
|
let (na, nb, nd) = (c.node(&a), c.node(&b), c.node(&d));
|
|
for n in [&na, &nb, &nd] {
|
|
assert!(approx(&n["y"], 0.0), "aligned top: {}", n["y"]);
|
|
}
|
|
// Equal gaps: (span 10..320 = 310, minus widths 120) / 2 = 95.
|
|
let gap1 = nb["x"].as_f64().unwrap() - (na["x"].as_f64().unwrap() + 40.0);
|
|
let gap2 = nd["x"].as_f64().unwrap() - (nb["x"].as_f64().unwrap() + 60.0);
|
|
assert!((gap1 - gap2).abs() < 0.5 && (gap1 - 95.0).abs() < 0.5, "gaps {gap1} {gap2}");
|
|
// One undo step for the batch.
|
|
let hist = c.doc()["history"]["undo"].clone();
|
|
assert_eq!(hist[0], "Align + distribute");
|
|
let g = c.run("object.group", json!({"ids": [a, b, d], "name": "Row"}))["id"].clone();
|
|
let ng = c.node(&g);
|
|
assert_eq!(ng["type"].as_str().unwrap().to_lowercase(), "group");
|
|
assert_eq!(ng["children"].as_array().unwrap().len(), 3);
|
|
assert!(approx(&ng["absoluteBounds"]["x"], 10.0) && approx(&ng["absoluteBounds"]["width"], 310.0), "{}", ng["absoluteBounds"]);
|
|
assert_eq!(c.page_children().len(), 1);
|
|
}
|
|
|
|
/// Task 7: boolean subtract (a ring), then flatten it to a vector.
|
|
#[test]
|
|
fn task_boolean_subtract_then_flatten() {
|
|
let mut c = Client::new();
|
|
let outer = c.create(json!({"type": "ellipse", "x": 0, "y": 0, "width": 100, "height": 100, "props": {"fill": "#000000"}}));
|
|
let inner = c.create(json!({"type": "ellipse", "x": 25, "y": 25, "width": 50, "height": 50}));
|
|
let bool_id = c.run("bool.subtract", json!({"ids": [outer, inner]}))["id"].clone();
|
|
let n = c.node(&bool_id);
|
|
assert_eq!(n["booleanOperation"].as_str().unwrap().to_lowercase(), "subtract", "{n}");
|
|
assert_eq!(n["children"].as_array().unwrap().len(), 2);
|
|
assert!(approx(&n["width"], 100.0));
|
|
let flat = c.run("bool.flatten", json!({"ids": [bool_id]}));
|
|
let vid = flat.get("id").cloned().or_else(|| flat["ids"].get(0).cloned()).unwrap();
|
|
let v = c.node(&vid);
|
|
assert_eq!(v["type"].as_str().unwrap().to_lowercase(), "vector", "{v}");
|
|
assert!(approx(&v["width"], 100.0) && approx(&v["height"], 100.0));
|
|
assert_eq!(c.page_children().len(), 1);
|
|
// The hole is really there: the centre pixel is transparent, the ring is painted.
|
|
let shot = c.call("export", json!({"ids": [vid], "format": "png"}));
|
|
use base64::Engine as _;
|
|
let bytes = base64::engine::general_purpose::STANDARD.decode(shot["content"][0]["data"].as_str().unwrap()).unwrap();
|
|
let (w, h) = figcraft_mcp::png_size(&bytes).unwrap();
|
|
assert!((100..=101).contains(&w) && (100..=101).contains(&h), "{w}x{h}");
|
|
let img = image::load_from_memory(&bytes).unwrap().to_rgba8();
|
|
assert_eq!(img.get_pixel(w / 2, h / 2)[3], 0, "the hole is transparent");
|
|
assert_eq!(img.get_pixel(w / 2, 10)[3], 255, "the ring is painted");
|
|
}
|
|
|
|
/// Task 8: export a frame as PNG at 2x (inline image and to a file) and as SVG.
|
|
#[test]
|
|
fn task_export_png_and_svg() {
|
|
let mut c = Client::new();
|
|
let f = c.create(json!({"type": "frame", "name": "Banner", "x": 50, "y": 50, "width": 160, "height": 90, "props": {"fill": "#228BE6"}}));
|
|
c.create(json!({"type": "text", "parent": f, "x": 10, "y": 10, "text": "Hello"}));
|
|
let r = c.call("export", json!({"ids": [f], "format": "png", "scale": 2}));
|
|
assert_eq!(r["isError"], false);
|
|
assert_eq!(r["content"][0]["type"], "image");
|
|
assert_eq!(r["content"][0]["mimeType"], "image/png");
|
|
assert_eq!(png_dims(r["content"][0]["data"].as_str().unwrap()), (320, 180));
|
|
let dir = std::env::temp_dir().join(format!("figcraft-mcp-test-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("banner.png");
|
|
c.ok("export", json!({"ids": [f], "path": path.to_string_lossy(), "scale": 1}));
|
|
assert_eq!(figcraft_mcp::png_size(&std::fs::read(&path).unwrap()), Some((160, 90)));
|
|
let svg = c.ok("export", json!({"ids": [f], "format": "svg"}));
|
|
let svg = svg.as_str().unwrap();
|
|
assert!(svg.starts_with("<svg") || svg.starts_with("<?xml"), "{}", &svg[..svg.len().min(80)]);
|
|
// Whole-page screenshot respects maxSize.
|
|
let shot = c.call("screenshot", json!({"maxSize": 80}));
|
|
let (w, h) = png_dims(shot["content"][0]["data"].as_str().unwrap());
|
|
assert!(w <= 80 && h <= 80 && w > 0, "{w}x{h}");
|
|
// Save and reopen round-trip.
|
|
let file = dir.join("banner.figcraft");
|
|
c.ok("save_file", json!({"path": file.to_string_lossy()}));
|
|
let opened = c.ok("open_file", json!({"path": file.to_string_lossy()}));
|
|
assert!(opened["document"]["pages"][0]["children"].to_string().contains("Banner"));
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// Task 9: undo/redo across single commands, a batch and a pointer gesture.
|
|
#[test]
|
|
fn task_undo_redo() {
|
|
let mut c = Client::new();
|
|
let r = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 100, "height": 100}));
|
|
c.ok("set_properties", json!({"ids": [r], "props": {"x": 50, "fill": "#00FF00"}}));
|
|
assert!(approx(&c.node(&r)["x"], 50.0));
|
|
// Drag it with the move tool: one gesture = one undo step.
|
|
c.ok("pointer", json!({"tool": "move", "kind": "drag", "x": 100, "y": 50, "to": [140, 70]}));
|
|
let n = c.node(&r);
|
|
assert!(approx(&n["x"], 90.0) && approx(&n["y"], 20.0), "dragged to {} {}", n["x"], n["y"]);
|
|
assert_eq!(c.ok("undo", json!({}))["undone"], true);
|
|
assert!(approx(&c.node(&r)["x"], 50.0));
|
|
assert_eq!(c.ok("undo", json!({}))["undone"], true);
|
|
let n = c.node(&r);
|
|
assert!(approx(&n["x"], 0.0) && n["fills"][0]["color"].as_str().unwrap().to_uppercase() != "#00FF00");
|
|
assert_eq!(c.ok("redo", json!({}))["redone"], true);
|
|
assert!(approx(&c.node(&r)["x"], 50.0));
|
|
assert_eq!(c.ok("undo", json!({}))["undone"], true);
|
|
assert_eq!(c.ok("undo", json!({}))["undone"], true);
|
|
assert_eq!(c.page_children().len(), 0, "creation undone");
|
|
assert_eq!(c.ok("undo", json!({}))["undone"], false);
|
|
assert_eq!(c.ok("redo", json!({}))["redone"], true);
|
|
assert_eq!(c.page_children().len(), 1);
|
|
}
|
|
|
|
/// Task 10: draw with tools and type through the keyboard, like a user.
|
|
#[test]
|
|
fn task_draw_and_type_with_tools() {
|
|
let mut c = Client::new();
|
|
c.ok("key", json!({"key": "R"}));
|
|
assert_eq!(c.ok("inspect_ui", json!({}))["tool"]["tool"], "rectangle");
|
|
let r = c.ok("pointer", json!({"kind": "drag", "x": 10, "y": 10, "to": [110, 60]}));
|
|
let id = r["selection"][0].clone();
|
|
let n = c.node(&id);
|
|
assert!(approx(&n["width"], 100.0) && approx(&n["height"], 50.0), "{n}");
|
|
// Text tool: click, type, escape.
|
|
c.ok("pointer", json!({"tool": "text", "kind": "click", "x": 200, "y": 200}));
|
|
c.ok("type_text", json!({"text": "Hi there"}));
|
|
c.ok("key", json!({"key": "Escape"}));
|
|
let kids = c.page_children();
|
|
let t = kids.iter().find(|k| k["type"].as_str().unwrap().eq_ignore_ascii_case("text")).expect("a text layer");
|
|
assert_eq!(t["characters"], "Hi there");
|
|
// Cmd+A selects all, Delete removes everything.
|
|
c.ok("key", json!({"key": "A", "cmd": true}));
|
|
c.ok("key", json!({"key": "Delete"}));
|
|
assert_eq!(c.page_children().len(), 0);
|
|
}
|
|
|
|
/// Keys in vector edit mode reach the edit tools first, as on the canvas (Delete took the whole
|
|
/// layer and the tool letters were unknown).
|
|
#[test]
|
|
fn task_vector_edit_keys() {
|
|
let mut c = Client::new();
|
|
let id = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 100, "height": 100}));
|
|
c.run("vector.edit.begin", json!({"id": id}));
|
|
c.ok("key", json!({"key": "X"}));
|
|
assert_eq!(c.run("vector.edit.state", json!({}))["tool"], "cut");
|
|
c.ok("key", json!({"key": "V"}));
|
|
c.run("vector.edit.select", json!({"vertices": [1]}));
|
|
c.ok("key", json!({"key": "Delete"}));
|
|
let state = c.run("vector.edit.state", json!({}));
|
|
assert_eq!(state["network"]["vertices"].as_array().map(Vec::len), Some(3), "Delete removed the point, not the layer");
|
|
c.ok("key", json!({"key": "Enter"}));
|
|
assert_eq!(c.page_children().len(), 1);
|
|
assert!(c.call("run_command", json!({"command": "vector.edit.state", "params": {}}))["isError"] == true, "Enter left edit mode");
|
|
}
|
|
|
|
/// A whole card in one `batch` call: later steps reach the frames earlier steps created through
|
|
/// `{"$step": n}`, so the tree is built in one round-trip and one undo step.
|
|
#[test]
|
|
fn task_card_in_one_batch() {
|
|
let mut c = Client::new();
|
|
let out = c.ok(
|
|
"batch",
|
|
json!({"label": "Card", "commands": [
|
|
{"command": "node.create", "params": {"type": "frame", "name": "Card", "width": 320, "height": 50,
|
|
"props": {"layout": "vertical", "padding": 24, "gap": 12, "sizingV": "hug"}}},
|
|
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}, "text": "Ada Lovelace"}},
|
|
{"command": "node.create", "params": {"type": "frame", "parent": {"$step": 0}, "name": "Button", "height": 44,
|
|
"props": {"layout": "horizontal", "sizingH": "fill"}}},
|
|
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 2}, "text": "Follow"}},
|
|
{"command": "node.set", "params": {"ids": [{"$step": 2}], "props": {"fill": "#3B5BDB"}}},
|
|
]}),
|
|
);
|
|
let results = ids(&out);
|
|
assert_eq!(results.len(), 5);
|
|
let (card, button) = (results[0]["id"].clone(), results[2]["id"].clone());
|
|
let n = c.node(&card);
|
|
assert_eq!(ids(&n["children"]).len(), 2, "title and button are inside the card");
|
|
let b = c.node(&button);
|
|
assert_eq!(ids(&b["children"]).len(), 1, "the label is inside the button");
|
|
assert_eq!(b["fills"][0]["color"].as_str().unwrap().to_ascii_uppercase(), "#3B5BDB");
|
|
assert_eq!(c.doc()["history"]["undo"][0], "Card", "one undo step");
|
|
// A forward reference fails the whole batch and names the step.
|
|
let e = c.err(
|
|
"batch",
|
|
json!({"commands": [
|
|
{"command": "node.create", "params": {"type": "frame", "parent": {"$step": 1}}},
|
|
{"command": "node.create", "params": {"type": "frame"}},
|
|
]}),
|
|
);
|
|
assert!(e.contains("step 0") && e.contains("earlier step"), "{e}");
|
|
}
|