vectorcraft/crates/engine/src/tests.rs

683 lines
33 KiB
Rust

use serde_json::json;
use super::*;
use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
#[test]
fn create_and_undo_redo() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
assert!(s.doc().unwrap().doc.node(a).is_some());
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(a).is_none());
s.execute("edit.redo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(a).is_some());
assert!(s.execute("edit.redo", &json!({})).is_err());
}
#[test]
fn unknown_and_disabled() {
let mut s = Session::new();
assert!(matches!(s.execute("nope", &json!({})), Err(EngineError::UnknownCommand(_))));
assert!(matches!(s.execute("object.group", &json!({})), Err(EngineError::Disabled(..))));
}
#[test]
fn group_ungroup() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
assert_eq!(d.parent_of(a), Some(g));
assert_eq!(d.node(g).unwrap().children().unwrap().len(), 2);
s.execute("object.ungroup", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(d.node(g).is_none());
assert_eq!(d.parent_of(a), d.layers.first().map(|l| l.id));
assert_eq!(s.doc().unwrap().selection.len(), 2);
}
#[test]
fn arrange_order() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let c = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.arrange.bringToFront", &json!({})).unwrap();
let order = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::<Vec<_>>();
assert_eq!(order(&s), vec![b, c, a]);
s.execute("object.arrange.sendBackward", &json!({})).unwrap();
assert_eq!(order(&s), vec![b, a, c]);
s.execute("object.arrange.sendToBack", &json!({})).unwrap();
assert_eq!(order(&s), vec![a, b, c]);
s.execute("object.arrange.bringForward", &json!({})).unwrap();
assert_eq!(order(&s), vec![b, a, c]);
}
#[test]
fn transform_and_again() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.execute("object.transformAgain", &json!({})).unwrap();
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
assert_eq!(b.x0, 10.0);
s.execute("object.move", &json!({"dx": 0, "dy": 20, "copy": true})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
}
#[test]
fn rotate_and_scale() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0);
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
assert!((b.width() - 10.0).abs() < 1e-9 && (b.height() - 20.0).abs() < 1e-9);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
assert!((b.width() - 20.0).abs() < 1e-9);
// Scale Strokes & Effects is off by default: the stroke keeps its weight
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().appearance.stroke_width(), 1.0);
// and scales with the object when asked
s.execute("object.scale", &json!({"sx": 200, "strokes": true})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().appearance.stroke_width(), 2.0);
}
#[test]
fn copy_paste_front_back() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let _b = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let r = s.execute("edit.pasteInFront", &json!({})).unwrap();
let c = NodeId(r["ids"][0].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
assert_eq!(d.index_path(c).unwrap()[1], 1);
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 4);
s.execute("edit.cut", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 3);
}
#[test]
fn fill_and_stroke_commands() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 4, "cap": "round", "dash": [3, 2]})).unwrap();
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
assert_eq!(n.appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
let st = n.appearance.stroke().unwrap();
assert_eq!(st.width, 4.0);
assert_eq!(st.cap, vectorcraft_doc::LineCap::Round);
assert_eq!(st.dash.as_ref().unwrap().pattern, vec![3.0, 2.0]);
s.execute("paint.swap", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().appearance.stroke_paint().color().unwrap().to_hex(), "#ff0000");
s.execute("paint.setFill", &json!({"swatch": "Cyan"})).unwrap();
s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap();
}
#[test]
fn select_same_fill() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
let _c = {
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
rect(&mut s, 40.0, 0.0, 10.0, 10.0)
};
// Setting a fill also recolours the current selection (b), so b and c are blue, a green.
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
let r = s.execute("select.same.fillColor", &json!({})).unwrap();
assert_eq!(r["count"], 2);
assert!(!s.doc().unwrap().selection.contains(a));
}
#[test]
fn layers_and_lock_hide() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let l2 = NodeId(s.execute("layer.new", &json!({"name": "Ink"})).unwrap()["id"].as_u64().unwrap());
let b = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
assert_eq!(s.doc().unwrap().doc.layer_of(b), Some(l2));
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.lock", &json!({})).unwrap();
s.execute("select.all", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
s.execute("object.unlockAll", &json!({})).unwrap();
s.execute("object.hide", &json!({})).unwrap();
s.execute("object.showAll", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(a).unwrap().visible);
s.execute("layer.delete", &json!({"id": l2.0})).unwrap();
assert!(s.doc().unwrap().doc.node(b).is_none());
}
#[test]
fn clipping_and_compound() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 25.0, 25.0, 50.0, 50.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
assert_eq!(s.doc().unwrap().doc.node(NodeId(c)).unwrap().kind_label(), "Compound Path");
s.execute("object.compoundPath.release", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.len(), 2);
let g = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap();
assert_eq!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().kind_label(), "Clip Group");
// Edit Contents selects the clipped art; Edit Clipping Path the mask (also from inside the group).
s.execute("object.clippingMask.editContents", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
s.execute("object.clippingMask.editMask", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
}
#[test]
fn align_left() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 50.0, 30.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.align", &json!({"horizontal": "left"})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 0.0);
}
#[test]
fn selection_tool_drag_is_one_undo_step() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let undo_before = s.doc().unwrap().history.undo.len();
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 150.0, 150.0), v).unwrap();
for x in [155.0, 160.0, 170.0, 180.0] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, 150.0), v).unwrap();
}
s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 150.0), v).unwrap();
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
assert_eq!(b.x0, 130.0);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1);
assert_eq!(s.journal.last().unwrap().0, "object.transform");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0, 100.0);
}
#[test]
fn rectangle_tool_draws() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("rectangle", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 40.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 110.0, 60.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 110.0, 60.0), v).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.layers[0].children().unwrap().len(), 1);
let b = d.layers[0].children().unwrap()[0].geometric_bounds().unwrap();
assert_eq!((b.width(), b.height()), (100.0, 50.0));
assert_eq!(s.doc().unwrap().history.undo.len(), 1);
}
#[test]
fn pen_tool_draws_closed_path() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("pen", v).unwrap();
for (x, y) in [(10.0, 10.0), (100.0, 10.0), (100.0, 100.0)] {
s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap();
}
s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 10.0, 10.0), v).unwrap();
let d = &s.doc().unwrap().doc;
let n = &d.layers[0].children().unwrap()[0];
let p = n.path_data().unwrap();
assert_eq!(p.anchor_count(), 3);
assert!(p.is_closed());
}
/// Auto Add/Delete: a Pen click on a selected path's segment adds an anchor, unless General →
/// Disable Auto Add/Delete is on.
#[test]
fn pen_click_on_a_selected_path_adds_an_anchor_unless_disabled() {
let mut s = session();
let v = ViewInfo::default();
let r = s.execute("path.create", &json!({"anchors": [{"x": 10, "y": 100}, {"x": 110, "y": 100}, {"x": 210, "y": 100}]})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("select.all", &json!({})).unwrap();
s.select_tool("pen", v).unwrap();
let click = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::Down, 60.0, 101.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 101.0), v).unwrap();
};
click(&mut s);
let d = &s.doc().unwrap().doc;
assert_eq!(d.layers[0].children().unwrap().len(), 1);
assert_eq!(d.node(id).unwrap().path_data().unwrap().anchor_count(), 4);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("prefs.set", &json!({"key": "disableAutoAddDelete", "value": true})).unwrap();
s.execute("select.all", &json!({})).unwrap();
click(&mut s);
let d = &s.doc().unwrap().doc;
assert_eq!(d.layers[0].children().unwrap().len(), 2, "a new path starts");
assert_eq!(d.node(id).unwrap().path_data().unwrap().anchor_count(), 3);
}
#[test]
fn direct_selection_moves_one_anchor() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("select.none", &json!({})).unwrap();
let v = ViewInfo::default();
s.select_tool("directSelection", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 90.0, 90.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 90.0, 90.0), v).unwrap();
let p = s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().clone();
assert_eq!(p.subpaths[0].anchors[0].p, vectorcraft_geom::Point::new(90.0, 90.0));
assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(200.0, 100.0));
}
/// Dragging a curved segment with Direct Selection bends it (its anchors stay), and dragging a
/// straight one moves its two anchors, not the whole path; each is one undo step.
#[test]
fn direct_selection_drags_segments() {
let mut s = session();
let v = ViewInfo::default();
let drag = |s: &mut Session, from: (f64, f64), to: (f64, f64)| {
s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap();
};
let arch = &json!({"anchors": [{"x": 100, "y": 200, "out": [100, 100]}, {"x": 300, "y": 200, "in": [300, 100]}]});
let arch = NodeId(s.execute("path.create", arch).unwrap()["id"].as_u64().unwrap());
let square = &json!({"anchors": [{"x": 150, "y": 400}, {"x": 150, "y": 300}, {"x": 250, "y": 300}, {"x": 250, "y": 400}], "closed": true});
let square = NodeId(s.execute("path.create", square).unwrap()["id"].as_u64().unwrap());
s.execute("select.none", &json!({})).unwrap();
s.select_tool("directSelection", v).unwrap();
let steps = |s: &Session| s.doc().unwrap().history.undo.len();
let before = steps(&s);
// The arch's middle, 40 up: the curve bends, its two anchors stay.
drag(&mut s, (200.0, 125.0), (200.0, 85.0));
let sp = s.doc().unwrap().doc.node(arch).unwrap().path_data().unwrap().subpaths[0].clone();
let (a, b) = (sp.anchors[0], sp.anchors[1]);
assert_eq!((a.p, b.p), (vectorcraft_geom::Point::new(100.0, 200.0), vectorcraft_geom::Point::new(300.0, 200.0)), "the anchors stay");
assert!(a.h_out.y < 100.0 && b.h_in.y < 100.0, "the curve bent up: {a:?} {b:?}");
assert_eq!(steps(&s), before + 1);
// The square's top edge, 40 up: its two anchors move, the bottom two stay.
drag(&mut s, (200.0, 300.0), (200.0, 260.0));
let sq = s.doc().unwrap().doc.node(square).unwrap().geometric_bounds().unwrap();
assert_eq!((sq.y0, sq.y1, sq.x0, sq.x1), (260.0, 400.0, 150.0, 250.0), "the edge moved, the rest stayed");
assert_eq!(steps(&s), before + 2);
// Its fill still moves the whole square.
drag(&mut s, (200.0, 350.0), (200.0, 330.0));
let sq = s.doc().unwrap().doc.node(square).unwrap().geometric_bounds().unwrap();
assert_eq!((sq.y0, sq.y1), (240.0, 380.0), "the whole path moved");
}
#[test]
fn text_create_has_bounds() {
let mut s = session();
let r = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hello VectorCraft", "size": 24})).unwrap();
let n = s.doc().unwrap().doc.node(NodeId(r["id"].as_u64().unwrap())).unwrap().clone();
let b = n.geometric_bounds().unwrap();
assert!(b.width() > 100.0, "{b:?}");
}
#[test]
fn inspect_lists_everything() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let v = s.execute("document.inspect", &json!({})).unwrap();
assert_eq!(v["layers"][0]["children"][0]["kind"], "Rectangle");
assert!(s.commands().len() > 100);
}
/// `document.node {summary: true}` answers the `document.inspect` shape for one node:
/// the same fields the full-tree summary carries, without serializing the whole tree.
#[test]
fn document_node_summary_matches_inspect() {
let mut s = session();
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0;
let full = s.execute("document.node", &json!({"id": id})).unwrap();
assert!(full.get("bounds").is_none(), "full object JSON carries geometry, not bounds: {full}");
let summary = s.execute("document.node", &json!({"id": id, "summary": true})).unwrap();
let v = s.execute("document.inspect", &json!({})).unwrap();
let node = v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == id).cloned().unwrap();
assert_eq!(summary, node, "{summary} vs {node}");
assert!(s.execute("document.node", &json!({"id": 999999, "summary": true})).is_err());
}
/// `depth` and `childLimit` slice a summary read; a level that shows fewer children
/// than it has reports `childCount`, and invalid values are rejected, not reinterpreted.
#[test]
fn document_node_summary_slices_with_depth_and_limit() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 10.0, 10.0);
rect(&mut s, 20.0, 0.0, 10.0, 10.0);
s.execute("select.all", &json!({})).unwrap();
let inner = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
rect(&mut s, 40.0, 0.0, 10.0, 10.0);
s.execute("select.all", &json!({})).unwrap();
let outer = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
let mut read = |p: Value| s.execute("document.node", &p).unwrap();
let g = read(json!({"id": outer, "summary": true}));
assert_eq!(g["children"].as_array().map(Vec::len), Some(2));
assert!(g.get("childCount").is_none(), "nothing truncated: {g}");
let one = read(json!({"id": outer, "summary": true, "childLimit": 1}));
assert_eq!(one["children"].as_array().map(Vec::len), Some(1));
assert_eq!(one["childCount"], 2);
let flat = read(json!({"id": outer, "summary": true, "depth": 0}));
assert!(flat.get("children").is_none(), "{flat}");
assert_eq!(flat["childCount"], 2);
// Limits compose per level: the outer children show; the inner group's own
// children don't, and it says so.
let shallow = read(json!({"id": outer, "summary": true, "depth": 1}));
let kids = shallow["children"].as_array().unwrap();
assert_eq!(kids.len(), 2);
assert!(shallow.get("childCount").is_none(), "{shallow}");
let nested = kids.iter().find(|n| n["id"] == inner).unwrap();
assert!(nested.get("children").is_none(), "{nested}");
assert_eq!(nested["childCount"], 2);
for bad in [json!({"id": outer, "summary": true, "depth": -1}), json!({"id": outer, "summary": true, "childLimit": "many"})] {
assert!(s.execute("document.node", &bad).is_err(), "{bad}");
}
}
/// Type reports the paint its characters show, and its own object-level paint apart from it.
#[test]
fn inspect_reports_the_paint_of_types_characters() {
let mut s = session();
let id = s.execute("text.create", &json!({"x": 50, "y": 60, "text": "HI", "size": 24})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id]})).unwrap();
let node = |s: &mut Session| {
let v = s.execute("document.inspect", &json!({})).unwrap();
v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == id).cloned().unwrap()
};
let t = node(&mut s);
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#ff0000"), Some("None"), Some(0.0)), "{t}");
assert!(t.get("objectFill").is_none(), "no object-level paint: {t}");
s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id]})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#00ff00", "ids": [id]})).unwrap();
s.execute("stroke.set", &json!({"weight": 3, "ids": [id]})).unwrap();
let t = node(&mut s);
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}");
// An object-level fill on the type stays distinguishable from its characters' paint.
s.execute("appearance.addFill", &json!({"ids": [id]})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ffff00", "item": 0, "ids": [id]})).unwrap();
let t = node(&mut s);
assert_eq!(t["objectFill"], "#ffff00", "{t}");
assert_eq!(t["fill"], "#0000ff", "{t}");
// A path keeps reporting its own appearance.
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r]})).unwrap();
let v = s.execute("document.inspect", &json!({})).unwrap();
let rect = v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == r).cloned().unwrap();
assert_eq!(rect["fill"], "#ff0000");
assert!(rect.get("objectFill").is_none());
}
#[test]
fn every_command_has_unique_id_and_doc() {
let mut ids: Vec<&str> = command_specs().iter().map(|c| c.id).collect();
let n = ids.len();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), n);
assert!(command_specs().iter().all(|c| !c.params.is_empty() && !c.label.is_empty()));
}
#[test]
fn every_command_survives_empty_params() {
// Robustness: no command may panic on {} (with and without a selection).
// The view toggles among them flip the process-wide proof view.
let _proof_view = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
for sel in [false, true] {
for c in command_specs() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
rect(&mut s, 20.0, 0.0, 10.0, 10.0);
s.execute("edit.copy", &json!({})).ok();
if sel {
s.execute("select.all", &json!({})).unwrap();
} else {
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
}
let _ = s.execute(c.id, &json!({}));
}
}
}
#[test]
fn flare_tool_two_step_gesture_is_one_undo() {
let mut s = session();
let v = ViewInfo::default();
let undo_before = s.doc().unwrap().history.undo.len();
s.select_tool("flare", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 240.0, 200.0), v).unwrap();
// ↓ twice: 13 rays (one compound path with a subpath per ray).
s.tool_key(ToolKey::Down, Default::default(), v).unwrap();
s.tool_key(ToolKey::Down, Default::default(), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 240.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Move, 500.0, 400.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 500.0, 400.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 500.0, 400.0), v).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), undo_before + 1);
let g = st.doc.node(st.selection.objects[0]).unwrap();
assert_eq!(g.name.as_deref(), Some("Flare"));
// Halo + rays + centre + 10 rings; the rings reach towards the end point.
let NodeKind::Group { children, .. } = &g.kind else { panic!("flare is a group") };
assert_eq!(children.len(), 13);
let b = g.geometric_bounds().unwrap();
assert!(b.x1 > 400.0 && b.y1 > 300.0, "{b:?}");
let centre = children.iter().find(|c| c.name.as_deref() == Some("Center")).unwrap().geometric_bounds().unwrap();
assert!((centre.width() - 80.0).abs() < 0.5);
let rays = children.iter().find(|c| c.name.as_deref() == Some("Rays")).unwrap();
assert_eq!(rays.path_data().unwrap().subpaths.len(), 13);
}
#[test]
fn reshape_moves_the_grabbed_point_and_its_neighbourhood() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
// Grab the middle of the top edge: a new anchor appears there and moves the full delta.
s.execute("path.reshape", &json!({"id": r.0, "x": 50.0, "y": 0.0, "dx": 0.0, "dy": -40.0})).unwrap();
let st = s.doc().unwrap();
let pd = st.doc.node(r).unwrap().path_data().unwrap().clone();
assert_eq!(pd.anchor_count(), 5);
let pts: Vec<_> = pd.anchors().map(|(_, _, a)| a.p).collect();
assert!(pts.iter().any(|p| (p.x - 50.0).abs() < 1e-6 && (p.y + 40.0).abs() < 1e-6), "{pts:?}");
// Top corners follow partially, bottom corners (far away) stay put.
let tl = pts.iter().find(|p| p.x < 1.0 && p.y < 0.0).expect("top-left moved up");
assert!(tl.y > -40.0);
assert!(pts.iter().filter(|p| (p.y - 100.0).abs() < 1e-9).count() == 2);
}
#[test]
fn graph_tool_drag_creates_a_graph_and_asks_for_data() {
let mut s = session();
let v = ViewInfo::default();
let undo_before = s.doc().unwrap().history.undo.len();
s.select_tool("pieGraph", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 300.0, 250.0), v).unwrap();
let ui = s.pointer(&PointerEvent::new(PointerKind::Up, 300.0, 250.0), v).unwrap();
assert!(ui.iter().any(|r| matches!(r, crate::UiRequest::Dialog(k, _) if k == "graphData")), "{ui:?}");
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), undo_before + 1);
let g = st.doc.node(st.selection.objects[0]).unwrap();
assert_eq!(g.graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie);
assert_eq!(g.graph.as_ref().unwrap().rect, vectorcraft_geom::Rect::new(100.0, 100.0, 300.0, 250.0));
}
#[test]
fn stale_current_layer_id_never_targets_a_reused_id() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 5.0, 5.0, 10.0, 10.0);
let layer = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("edit.undo", &json!({})).unwrap();
// The undone layer's id is handed out again, here to a compound path.
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
assert_eq!(c, layer, "precondition: id reused");
let d = rect(&mut s, 50.0, 50.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [d.0]})).unwrap();
s.execute("object.arrange.sendToCurrentLayer", &json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
assert!(doc.node(c).unwrap().children().unwrap().iter().all(|n| n.path_data().is_some()));
assert!(doc.parent_of(d).is_some_and(|p| doc.node(p).unwrap().is_layer()));
// layer.delete without an id must not delete the compound path.
s.execute("layer.delete", &json!({})).ok();
assert!(s.doc().unwrap().doc.node(c).is_some());
}
#[test]
fn object_mosaic_tiles_follow_the_image_colours() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [a.0]})).unwrap();
s.execute("paint.setStroke", &json!({"none": true, "ids": [a.0]})).unwrap();
let b = rect(&mut s, 50.0, 0.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [b.0]})).unwrap();
s.execute("paint.setStroke", &json!({"none": true, "ids": [b.0]})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
let r = s.execute("object.createObjectMosaic", &json!({"columns": 2, "rows": 1, "spacingX": 4, "deleteRaster": true})).unwrap();
assert_eq!(r["tiles"], json!(2));
let st = s.doc().unwrap();
let g = st.doc.node(NodeId(r["id"].as_u64().unwrap())).unwrap();
let tiles = g.children().unwrap();
let fill = |n: &vectorcraft_doc::Node| n.appearance.fill_paint();
assert_eq!(fill(&tiles[0]), vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)));
assert_eq!(fill(&tiles[1]), vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)));
// 4 pt spacing: each 50 pt tile shrinks to 46 pt; the image is gone.
assert!((tiles[0].geometric_bounds().unwrap().width() - 46.0).abs() < 1e-6);
assert!(!st.doc.layers[0].children().unwrap().iter().any(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))));
}
#[test]
fn snap_to_pixel_rounds_drawing_and_moves() {
let mut s = session();
let v = ViewInfo { snap_to_pixel: true, smart_guides: false, ..Default::default() };
s.select_tool("rectangle", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 10.3, 10.6), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 60.4, 40.2), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 60.4, 40.2), v).unwrap();
let id = s.doc().unwrap().selection.objects[0];
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 11.0, 60.0, 40.0));
s.select_tool("selection", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 30.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 37.3, 34.8), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 37.3, 34.8), v).unwrap();
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
assert_eq!((b.x0, b.y0), (17.0, 16.0));
}
#[test]
fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_delete() {
let mut s = session();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
let stroke = |s: &mut Session, pts: &[(f64, f64)]| {
s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1), v).unwrap();
for p in &pts[1..] {
s.pointer(&PointerEvent::new(PointerKind::Drag, p.0, p.1), v).unwrap();
}
let l = pts[pts.len() - 1];
s.pointer(&PointerEvent::new(PointerKind::Up, l.0, l.1), v).unwrap();
};
// A rough rectangle.
let mut rect = vec![];
for (a, b) in
[((100.0, 100.0), (300.0, 102.0)), ((300.0, 102.0), (298.0, 200.0)), ((298.0, 200.0), (101.0, 199.0)), ((101.0, 199.0), (103.0, 104.0))]
{
for i in 0..20 {
let t = i as f64 / 20.0;
rect.push((a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t));
}
}
stroke(&mut s, &rect);
let id = s.doc().unwrap().selection.objects[0];
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(
matches!(n.kind, vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Rectangle { .. }), .. }),
"{:?}",
n.kind_label()
);
// A zig-zag over it deletes it.
let zig: Vec<(f64, f64)> = (0..30).map(|i| (150.0 + (i % 2) as f64 * 80.0, 120.0 + i as f64 * 2.0)).collect();
stroke(&mut s, &zig);
assert!(s.doc().unwrap().doc.node(id).is_none());
}
#[test]
fn a_panicking_command_rolls_back_instead_of_crashing() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
let before = s.doc().unwrap().doc.clone();
let undo_steps = s.doc().unwrap().history.undo.len();
// A bug halfway through a command: the document was already changed in place, inside a
// nested command.
let r = s.run_guarded("test.panic", |s| {
let st = s.doc_mut()?;
Arc::make_mut(&mut st.doc).remove(a)?;
st.selection = Selection::default();
s.run_nested(|_| panic!("boom"))
});
match r {
Err(EngineError::Internal { cmd, msg }) => assert_eq!((cmd.as_str(), msg.as_str()), ("test.panic", "boom")),
other => panic!("expected an internal error, got {other:?}"),
}
let st = s.doc().unwrap();
assert!(Arc::ptr_eq(&st.doc, &before), "document rolled back");
assert_eq!(st.selection.objects, vec![a]);
assert_eq!(st.history.undo.len(), undo_steps);
assert_eq!(s.depth, 0);
// The session keeps working (and journaling top-level commands).
let b = rect(&mut s, 200.0, 10.0, 50.0, 50.0);
assert!(s.doc().unwrap().doc.node(b).is_some());
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("shape.rectangle"));
}
/// New layers and sublayers share one "Layer N" numbering, whatever art the document holds, and
/// never repeat a name.
#[test]
fn new_layers_and_sublayers_are_numbered_together() {
let name = |s: &Session, id: &Value| s.doc().unwrap().doc.node(NodeId(id["id"].as_u64().unwrap())).unwrap().display_name().to_string();
let mut s = session();
let sub = s.execute("layer.newSublayer", &json!({})).unwrap();
assert_eq!(name(&s, &sub), "Layer 2", "not its parent's name");
for x in [0.0, 60.0, 120.0, 180.0, 240.0] {
rect(&mut s, x, 0.0, 50.0, 50.0);
}
let layer = s.execute("layer.new", &json!({})).unwrap();
assert_eq!(name(&s, &layer), "Layer 3", "after the sublayer, not a repeat of it");
let sub = s.execute("layer.newSublayer", &json!({})).unwrap();
assert_eq!(name(&s, &sub), "Layer 4", "the art doesn't count");
}