mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 11:56:50 -06:00
**A shapes and vector behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (drawing with Shift and Alt, corner radius and which layers take one, point counts and star ratios, arcs, stroke joins and the miter angle, caps per end, boolean results and the look they take, flatten and outline stroke; entering and leaving vector edit mode, the pen, selecting points and segments, deleting vs. delete and heal, handle mirroring, the bend, paint bucket, cut, join and eraser tools, nudging and undo steps, what converting a shape keeps) is a table row or a step with what the rule implies and a one-line derivation; where the documentation is silent the row says so and pins today's behaviour.
**Fixes**
- **Rounded vector points were stored but never drawn**: a point's corner radius (edit mode's Corner radius field, `vector.edit.cornerRadius`) now rounds the corner between two straight segments on the canvas, in hit testing, booleans and every export (`VectorNetwork::rounded`, used by the fill and stroke paths); the radius shrinks to fit half of each segment, like a polygon's corners. A vector layer's own corner radius rounds every point. The points stay where they are in edit mode.
- **A boolean group's look follows its operation**: union, intersect and exclude take the top layer's fills, strokes and effects, subtract the bottom layer's (it was always the bottom one).
- **Alt draws a line or an arrow from its centre**, like the other shapes (it was ignored).
- **Stars keep to 3–60 points** (they went to 120; polygons keep 120, the documentation is silent); a junk `pointCount` is refused instead of becoming 5.
- **Delete and heal recomputes the curve**: the healed segment keeps its neighbours' tangents and passes through where the deleted point was (it reused the neighbours' handles, so healing over a circle cut deep inside it). Corners between straight segments heal straight, as before.
- **Shift-click on a selected point at a corner of the points' transform box takes it out of the selection** (it started a scale); a Shift-drag there still scales in proportion.
- **Outline stroke** turns the stroke's colour style into the outline's fill style, and the outline keeps the layer's opacity, blend, effects, constraints and visibility (they were lost).
- **The stroke panel's Miter angle shows degrees** (it showed the ratio 4 under that name; the default is ~28.96°); `node.set {miterAngle}` sets it and `get_node` reports a non-default join or angle.
- **Individual corners only for rectangles and frames**: polygons, stars and vectors have one radius (the toggle showed for them and set corners that weren't drawn).
- **Option/Alt+Shift+F flattens** (Cmd/Ctrl+E still does).
- **MCP `key` in vector edit mode reaches the edit tools first**, as the canvas does: tool letters, arrows, Delete / Shift+Delete, Cmd/Ctrl+J, Enter (Delete removed the whole layer; `X` was unknown).
**Missing documented behaviour, added**
- **Corner radius and star ratio handles on the canvas**: a handle inside each corner of a selected rectangle or frame, one inside a polygon's or star's top point, a ratio handle on a star's first inner point (`shape_handles.rs`). Dragging along a corner's bisector sets the radius of every corner (Alt: only that corner of a rectangle or frame, documentation silent), stopping where the corners meet; the ratio handle sets the inner radius. A readout shows the value while dragging; the handles hide under 48 px on screen, on locked layers, with several layers selected and in edit mode. One undo step per drag; `inspect_ui` lists them under `tool.shapeHandles`.
- **Count and Ratio fields** in the Design panel for polygons and stars (there was no way to change them in the UI).
- **Cut tool drags**: dragging across paths cuts them where the line crosses; parts that come apart move to layers of their own, each keeping its piece of the fill (`vector.edit.cut {from, to}`). A click still splits at a point or segment.
- **Paint bucket drags**: a drag fills or unfills every area it crosses the way the first one went (`vector.edit.paint {points}`), one undo step.
New strings go through `tr(...)` (the new fields reuse the existing Count / Ratio / Radius / Star / Polygon entries in `en.toml` and `es.toml`); the new fields have accessible names (an icon lead with its label). Each gesture and edit stays one undo step.
310 lines
17 KiB
Rust
310 lines
17 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");
|
|
}
|