//! Transform & utility tools driven through `Session::pointer`, and their commands. use serde_json::json; use vectorcraft_color::{Color, Paint}; use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; use super::*; 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()) } fn bounds(s: &Session, id: NodeId) -> Rect { s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() } fn gesture(s: &mut Session, tool: &str, pts: &[(f64, f64)], mods: Mods) -> Vec { let v = ViewInfo::default(); s.select_tool(tool, v).unwrap(); let mut out = vec![]; for (i, (x, y)) in pts.iter().enumerate() { let kind = if i == 0 { PointerKind::Down } else if i == pts.len() - 1 { PointerKind::Up } else { PointerKind::Drag }; out.extend(s.pointer(&PointerEvent::new(kind, *x, *y).with_mods(mods), v).unwrap()); } out } fn close(a: Rect, b: Rect) -> bool { (a.x0 - b.x0).abs() < 1e-6 && (a.y0 - b.y0).abs() < 1e-6 && (a.x1 - b.x1).abs() < 1e-6 && (a.y1 - b.y1).abs() < 1e-6 } fn undo_len(s: &Session) -> usize { s.doc().unwrap().history.undo.len() } #[test] fn rotate_tool_drag_rotates_about_center_one_undo() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); let n = undo_len(&s); gesture(&mut s, "rotate", &[(250.0, 125.0), (200.0, 175.0), (150.0, 225.0), (150.0, 225.0)], Mods::default()); // 90° about (150,125): 100×50 becomes 50×100. assert!(close(bounds(&s, a), Rect::new(125.0, 75.0, 175.0, 175.0)), "{:?}", bounds(&s, a)); assert_eq!(undo_len(&s), n + 1); s.execute("edit.undo", &json!({})).unwrap(); assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 200.0, 150.0))); } #[test] fn rotate_tool_alt_release_copies() { let mut s = session(); rect(&mut s, 100.0, 100.0, 100.0, 50.0); let alt = Mods { alt: true, ..Default::default() }; let v = ViewInfo::default(); s.select_tool("rotate", v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Down, 250.0, 125.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, 150.0, 225.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 225.0).with_mods(alt), v).unwrap(); assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2); } /// Returns a session with a group of two rectangles and a rectangle to its right, both selected. fn group_and_rect() -> Session { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 40.0, 40.0); let b = rect(&mut s, 160.0, 100.0, 40.0, 40.0); s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); s.execute("object.group", &json!({})).unwrap(); rect(&mut s, 220.0, 100.0, 40.0, 40.0); s.execute("select.all", &json!({})).unwrap(); s } /// Sends `kind` at `(x, y)` to the active tool, with Alt held when `alt` is set. fn drag_at(s: &mut Session, kind: PointerKind, (x, y): (f64, f64), alt: bool) { let mods = Mods { alt, ..Mods::default() }; s.pointer(&PointerEvent::new(kind, x, y).with_mods(mods), ViewInfo::default()).unwrap(); } /// Returns the command and params of the interaction's last preview. fn previewed(s: &Session) -> (String, Value) { s.doc().unwrap().interaction.as_ref().and_then(|it| it.preview.clone()).unwrap() } /// Returns the document and the selected objects that `cmd` with `params` gives on a new /// [`group_and_rect`] session, after `first` when it is given. fn on_a_new_session(first: Option<(&str, &Value)>, cmd: &str, params: &Value) -> (Document, Vec) { let mut t = group_and_rect(); if let Some((c, p)) = first { t.execute(c, p).unwrap(); } t.execute(cmd, params).unwrap(); let st = t.doc().unwrap(); (Document::clone(&st.doc), st.selection.objects.clone()) } /// Returns true when the active document and its selected objects are `want`. fn shows(s: &Session, want: &(Document, Vec)) -> bool { let st = s.doc().unwrap(); *st.doc == want.0 && st.selection.objects == want.1 } /// An Alt-drag with the Selection tool or the Rotate tool copies the selection on its first move, /// and the moves after it transform those copies: the interaction keeps them, made once, until /// the drag ends. The Move dialog's Preview with Copy previews `object.move` with `copy` in the /// same way. #[test] fn an_alt_drag_copies_the_selection_once() { let kept = |s: &Session| s.doc().unwrap().interaction.as_ref().and_then(|it| it.copies.clone()); for (tool, [press, moves @ ..]) in [ ("selection", [(120.0, 120.0), (140.0, 120.0), (160.0, 120.0), (180.0, 120.0)]), ("rotate", [(300.0, 120.0), (300.0, 140.0), (300.0, 160.0), (300.0, 180.0)]), ] { let mut s = group_and_rect(); s.select_tool(tool, ViewInfo::default()).unwrap(); drag_at(&mut s, PointerKind::Down, press, true); let mut first = None; for at in moves { drag_at(&mut s, PointerKind::Drag, at, true); let k = kept(&s).unwrap_or_else(|| panic!("{tool}: the move to {at:?} keeps its copies")); assert!(Arc::ptr_eq(first.get_or_insert_with(|| k.clone()), &k), "{tool}: the move to {at:?} transforms the first move's copies"); } drag_at(&mut s, PointerKind::Up, moves[2], true); assert!(s.doc().unwrap().interaction.is_none()); assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 4, "{tool}: the objects and their copies"); } let mut s = group_and_rect(); s.begin_interaction("Move").unwrap(); s.preview("object.move", &json!({"dx": 10, "dy": 0, "copy": true})).unwrap(); let first = kept(&s).expect("the first preview keeps its copies"); s.preview("object.move", &json!({"dx": 20, "dy": 0, "copy": true})).unwrap(); assert!(kept(&s).is_some_and(|k| Arc::ptr_eq(&k, &first)), "the second preview transforms the first one's copies"); } /// Each move of an Alt-drag with the Selection tool leaves the document and the selected copies /// that one `object.transform` with `copy` and that move's matrix gives, with the same ids on every /// move; the release keeps the last move as one undo step named Copy. #[test] fn each_move_of_an_alt_drag_gives_what_one_transform_with_copy_gives() { let mut s = group_and_rect(); let (before, undo) = (s.doc().unwrap().doc.clone(), undo_len(&s)); s.select_tool("selection", ViewInfo::default()).unwrap(); drag_at(&mut s, PointerKind::Down, (120.0, 120.0), true); let mut copies = None; for x in [140.0, 160.0, 125.0, 190.0] { drag_at(&mut s, PointerKind::Drag, (x, 120.0), true); let (cmd, params) = previewed(&s); assert!(params["copy"] == json!(true), "{params}"); assert!(shows(&s, &on_a_new_session(None, &cmd, ¶ms)), "the move to {x}"); let ids = s.doc().unwrap().selection.objects.clone(); let first = copies.get_or_insert_with(|| ids.clone()); assert_eq!(*first, ids, "the copies keep their ids"); } drag_at(&mut s, PointerKind::Up, (190.0, 120.0), true); let (cmd, params) = s.journal.last().cloned().unwrap(); assert!(shows(&s, &on_a_new_session(None, &cmd, ¶ms))); assert_eq!(undo_len(&s), undo + 1); assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Copy")); s.execute("edit.undo", &json!({})).unwrap(); assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document"); } /// While Alt is released during a drag, the previews move the selected objects themselves; once Alt /// is pressed again, they copy the objects again, with the ids of the first copies. The release /// names the undo step for the last move. #[test] fn releasing_alt_during_a_drag_moves_the_objects_themselves() { let mut s = group_and_rect(); let originals = s.doc().unwrap().selection.objects.clone(); s.select_tool("selection", ViewInfo::default()).unwrap(); drag_at(&mut s, PointerKind::Down, (120.0, 120.0), true); drag_at(&mut s, PointerKind::Drag, (140.0, 120.0), true); let copies = s.doc().unwrap().selection.objects.clone(); for (x, alt) in [(160.0, false), (180.0, true), (200.0, false)] { drag_at(&mut s, PointerKind::Drag, (x, 120.0), alt); let (cmd, params) = previewed(&s); assert!(params["copy"] == json!(alt), "{params}"); assert!(shows(&s, &on_a_new_session(None, &cmd, ¶ms)), "the move to {x}, Alt: {alt}"); let selected = if alt { &copies } else { &originals }; assert_eq!(&s.doc().unwrap().selection.objects, selected); } drag_at(&mut s, PointerKind::Up, (200.0, 120.0), false); assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2, "no copies"); assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Move")); } /// A transform with `copy` of the objects selected when an Alt-drag started, run while the drag is /// open after its first move, copies the document as that move left it: the move's copies stay, /// and new copies go directly above the objects. #[test] fn a_copy_run_during_an_alt_drag_copies_the_document_as_the_move_left_it() { let mut s = group_and_rect(); let ids: Vec = s.doc().unwrap().selection.objects.iter().map(|i| i.0).collect(); s.select_tool("selection", ViewInfo::default()).unwrap(); drag_at(&mut s, PointerKind::Down, (120.0, 120.0), true); drag_at(&mut s, PointerKind::Drag, (140.0, 120.0), true); let (cmd, params) = previewed(&s); let again = json!({"matrix": [1, 0, 0, 1, 0, 60], "copy": true, "ids": ids}); s.execute("object.transform", &again).unwrap(); assert!(shows(&s, &on_a_new_session(Some((&cmd, ¶ms)), "object.transform", &again))); assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 6, "the objects and two copies of each"); } /// A command that changes the drag's snapshot during an Alt-drag (showing the perspective grid) /// keeps its change in the moves after it, which copy the changed snapshot. #[test] fn a_grid_shown_during_an_alt_drag_stays_shown_in_the_moves_after_it() { let mut s = group_and_rect(); s.select_tool("selection", ViewInfo::default()).unwrap(); drag_at(&mut s, PointerKind::Down, (120.0, 120.0), true); drag_at(&mut s, PointerKind::Drag, (140.0, 120.0), true); let show = json!({"visible": true}); s.execute("perspective.grid.show", &show).unwrap(); drag_at(&mut s, PointerKind::Drag, (160.0, 120.0), true); let (cmd, params) = previewed(&s); assert!(shows(&s, &on_a_new_session(Some(("perspective.grid.show", &show)), &cmd, ¶ms))); } /// Canceling an Alt-drag after a few moves puts back the document and the selection it started /// from, with no undo step, and it ends the interaction, which held the copies. #[test] fn canceling_an_alt_drag_leaves_the_document_as_it_was() { let mut s = group_and_rect(); let (before, selection, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone(), undo_len(&s)); s.select_tool("selection", ViewInfo::default()).unwrap(); drag_at(&mut s, PointerKind::Down, (120.0, 120.0), true); for x in [140.0, 160.0, 180.0] { drag_at(&mut s, PointerKind::Drag, (x, 120.0), true); } s.cancel_interaction().unwrap(); let st = s.doc().unwrap(); assert!(Arc::ptr_eq(&st.doc, &before), "the document it started from"); assert_eq!(st.selection, selection); assert_eq!(undo_len(&s), undo); assert!(s.doc().unwrap().interaction.is_none()); } #[test] fn rotate_tool_alt_click_requests_dialog() { let mut s = session(); rect(&mut s, 100.0, 100.0, 100.0, 50.0); let alt = Mods { alt: true, ..Default::default() }; let ui = gesture(&mut s, "rotate", &[(10.0, 20.0), (10.0, 20.0)], alt); assert_eq!(ui, vec![UiRequest::Dialog("rotate".into(), json!({"angle": 0, "origin": [10.0, 20.0]}))]); } #[test] fn scale_tool_click_origin_then_drag() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); gesture(&mut s, "scale", &[(100.0, 100.0), (100.0, 100.0)], Mods::default()); gesture(&mut s, "scale", &[(200.0, 200.0), (250.0, 250.0), (300.0, 300.0), (300.0, 300.0)], Mods::default()); assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 300.0, 300.0)), "{:?}", bounds(&s, a)); } #[test] fn reflect_and_shear_tools() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); // Reflect across a vertical axis through the centre (150,125): bounds unchanged, geometry mirrored. gesture(&mut s, "reflect", &[(150.0, 50.0), (150.0, 40.0), (150.0, 20.0), (150.0, 20.0)], Mods::default()); assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 200.0, 150.0))); // Shear horizontally: top edge shifts right by 25 relative to the centre row. gesture(&mut s, "shear", &[(150.0, 100.0), (160.0, 100.0), (175.0, 100.0), (175.0, 100.0)], Mods::default()); let b = bounds(&s, a); assert!((b.width() - 150.0).abs() < 1e-6, "{b:?}"); } /// Reflect and Shear return an error for an `axis` they don't take (Reflect's names it and also /// takes an angle): nothing moves and no undo step is recorded. A null `axis` counts as not given: Reflect /// then reflects across the vertical axis and Shear shears along the horizontal one. #[test] fn reflect_and_shear_reject_unknown_axes_without_changing_state() { let mut s = session(); let a = rect(&mut s, 10.0, 20.0, 30.0, 10.0); let n = undo_len(&s); let reflect = "axis must be \"vertical\", \"horizontal\" or an angle in degrees"; let shear = "`axis` must be one of horizontal, vertical"; for (cmd, p, msg) in [ ("object.reflect", json!({"axis": "diagonal", "origin": [0, 0]}), format!("{reflect}, not `diagonal`")), ("object.reflect", json!({"axis": "45", "origin": [0, 0]}), format!("{reflect}, not `45`")), ("object.reflect", json!({"axis": true, "origin": [0, 0]}), format!("{reflect}, not `true`")), ("object.shear", json!({"angle": 30, "axis": "diagonal"}), shear.to_string()), ("object.shear", json!({"angle": 30, "axis": 90}), shear.to_string()), ] { let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}")); assert_eq!(e.to_string(), format!("invalid parameters for `{cmd}`: {msg}")); } assert!(close(bounds(&s, a), Rect::new(10.0, 20.0, 40.0, 30.0)), "nothing moved: {:?}", bounds(&s, a)); assert_eq!(undo_len(&s), n, "no undo step"); s.execute("object.reflect", &json!({"axis": null, "origin": [0, 0]})).unwrap(); assert!(close(bounds(&s, a), Rect::new(-40.0, 20.0, -10.0, 30.0)), "across the vertical axis: {:?}", bounds(&s, a)); s.execute("object.reflect", &json!({"axis": 0, "origin": [0, 0]})).unwrap(); assert!(close(bounds(&s, a), Rect::new(-40.0, -30.0, -10.0, -20.0)), "an angle still reflects: {:?}", bounds(&s, a)); s.execute("object.shear", &json!({"angle": 45, "axis": null, "origin": [0, 0]})).unwrap(); let b = bounds(&s, a); assert!((b.y0 + 30.0).abs() < 1e-6 && (b.y1 + 20.0).abs() < 1e-6 && b.width() > 30.0, "along the horizontal axis: {b:?}"); } #[test] fn distort_command_maps_corners() { let mut s = session(); let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); s.execute("object.distort", &json!({"corners": [[10, 0], [90, 0], [100, 100], [0, 100]]})).unwrap(); let d = &s.doc().unwrap().doc; let pts: Vec = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect(); for want in [Point::new(10.0, 0.0), Point::new(90.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)] { assert!(pts.iter().any(|p| p.distance(want) < 1e-6), "{pts:?} missing {want:?}"); } assert!(s.execute("object.distort", &json!({"corners": [[0, 0]]})).is_err()); s.execute("edit.undo", &json!({})).unwrap(); assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0))); } #[test] fn distort_perspective_midpoint_is_projective() { // Distort a tiny probe at the centre of a 100×100 source box into a trapezoid: with a real // perspective warp the centre lands where the quad's diagonals cross (y = 100/6), not at y = 50. let mut s = session(); let probe = rect(&mut s, 49.999, 49.999, 0.002, 0.002); s.execute("object.distort", &json!({"corners": [[40, 0], [60, 0], [100, 100], [0, 100]], "from": [0, 0, 100, 100], "ids": [probe.0]})).unwrap(); let c = bounds(&s, probe).center(); assert!((c.x - 50.0).abs() < 1e-6); assert!((c.y - 100.0 / 6.0).abs() < 1e-3, "{c:?}"); } #[test] fn free_transform_distort_mode_via_pointer() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); let v = ViewInfo::default(); s.select_tool("freeTransform", v).unwrap(); s.set_tool_option("mode", &json!("distort")); s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 260.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 260.0), v).unwrap(); let d = &s.doc().unwrap().doc; let pts: Vec = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect(); assert!(pts.iter().any(|p| p.distance(Point::new(250.0, 260.0)) < 1e-6), "{pts:?}"); assert!(pts.iter().any(|p| p.distance(Point::new(100.0, 100.0)) < 1e-6)); assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort")); } #[test] fn free_transform_cmd_held_once_a_corner_drag_started_distorts_in_one_step() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); let v = ViewInfo::default(); s.select_tool("freeTransform", v).unwrap(); let before = undo_len(&s); let cmd = Mods { cmd: true, ..Default::default() }; s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, 210.0, 210.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(cmd), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 260.0).with_mods(cmd), v).unwrap(); let d = &s.doc().unwrap().doc; let pts: Vec = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect(); for p in [Point::new(250.0, 260.0), Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(100.0, 200.0)] { assert!(pts.iter().any(|q| q.distance(p) < 1e-6), "{p:?} not in {pts:?}"); } assert_eq!(undo_len(&s), before + 1); assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Distort")); } #[test] fn free_transform_side_handle_scales() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); gesture(&mut s, "freeTransform", &[(200.0, 150.0), (250.0, 150.0), (300.0, 150.0), (300.0, 150.0)], Mods::default()); assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 300.0, 200.0))); } #[test] fn eyedropper_copies_appearance_to_selection_and_defaults() { let mut s = session(); let src = rect(&mut s, 300.0, 300.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); s.execute("stroke.set", &json!({"weight": 4})).unwrap(); s.execute("object.setProps", &json!({"opacity": 50})).unwrap(); let dst = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.default", &json!({})).unwrap(); s.execute("select.set", &json!({"ids": [dst.0]})).unwrap(); gesture(&mut s, "eyedropper", &[(325.0, 325.0), (325.0, 325.0)], Mods::default()); let d = &s.doc().unwrap().doc; let n = d.node(dst).unwrap(); assert_eq!(n.appearance.fill_paint(), Paint::solid(Color::from_hex("#ff0000").unwrap())); assert_eq!(n.appearance.stroke_width(), 4.0); assert!((n.opacity - 0.5).abs() < 1e-6); assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#ff0000").unwrap())); assert_eq!(s.paint.stroke_width, 4.0); let _ = src; } #[test] fn eyedropper_shift_samples_color_into_active_proxy() { let mut s = session(); rect(&mut s, 300.0, 300.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); let dst = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.default", &json!({})).unwrap(); s.execute("select.set", &json!({"ids": [dst.0]})).unwrap(); s.execute("paint.toggleActive", &json!({})).unwrap(); // stroke active gesture(&mut s, "eyedropper", &[(325.0, 325.0), (325.0, 325.0)], Mods { shift: true, ..Default::default() }); let n = s.doc().unwrap().doc.node(dst).unwrap().clone(); assert_eq!(n.appearance.stroke_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap())); assert_eq!(n.appearance.fill_paint(), Paint::solid(Color::WHITE)); } #[test] fn gradient_tool_sets_vector_on_solid_fill() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); gesture(&mut s, "gradient", &[(100.0, 150.0), (150.0, 150.0), (200.0, 100.0), (200.0, 100.0)], Mods::default()); let n = s.doc().unwrap().doc.node(a).unwrap().clone(); let Paint::Gradient(g) = n.appearance.fill_paint() else { panic!("expected gradient") }; let geom = g.geom.unwrap(); assert_eq!(geom.start, Point::new(100.0, 150.0)); assert_eq!(geom.end, Point::new(200.0, 100.0)); assert_eq!(undo_len(&s), 2); // Changing an existing gradient keeps its stops. s.execute("paint.setGradientGeom", &json!({"start": [0, 0], "end": [10, 0]})).unwrap(); let Paint::Gradient(g2) = s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint() else { panic!() }; assert_eq!(g2.gradient, g.gradient); assert!(s.execute("paint.setGradientGeom", &json!({"start": [0, 0]})).is_err()); } #[test] fn artboard_tool_moves_with_art_creates_and_deletes() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0); let outside = rect(&mut s, 900.0, 100.0, 50.0, 50.0); s.execute("select.none", &json!({})).unwrap(); gesture(&mut s, "artboard", &[(400.0, 300.0), (420.0, 310.0), (450.0, 330.0), (450.0, 330.0)], Mods::default()); let d = &s.doc().unwrap().doc; assert_eq!(d.artboards[0].rect, Rect::new(50.0, 30.0, 850.0, 630.0)); assert!(close(bounds(&s, a), Rect::new(150.0, 130.0, 200.0, 180.0))); assert!(close(bounds(&s, outside), Rect::new(900.0, 100.0, 950.0, 150.0))); // Draw a new artboard on the pasteboard. gesture(&mut s, "artboard", &[(1000.0, 0.0), (1100.0, 50.0), (1200.0, 100.0), (1200.0, 100.0)], Mods::default()); assert_eq!(s.doc().unwrap().doc.artboards.len(), 2); assert_eq!(s.tool_options()["active"], 1); assert_eq!(s.doc().unwrap().doc.artboards[1].rect, Rect::new(1000.0, 0.0, 1200.0, 100.0)); // Delete it: the tool takes the key ahead of the Clear shortcut (the UI only hands Delete to a // tool that claims it). assert!(s.tool_claims_key(ToolKey::Delete, ViewInfo::default())); s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap(); assert_eq!(s.doc().unwrap().doc.artboards.len(), 1); // The last artboard can't be deleted, so Delete stays the shortcut's. assert!(!s.tool_claims_key(ToolKey::Delete, ViewInfo::default())); s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap(); assert_eq!(s.doc().unwrap().doc.artboards.len(), 1); } #[test] fn artboard_drag_keeps_a_flush_background_with_roundoff_and_multiple_texts() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); let (x, y) = (-35.23064566724116, -107.85371637852609); s.execute("artboard.setProps", &json!({"index": 0, "x": x, "y": y})).unwrap(); // The background's top is 2.84e-14 pt above the artboard's top. let background = rect(&mut s, x, -107.85371637852612, 600.0, 400.0); let mut ids = vec![background]; for i in 0..3 { let text = s.execute("text.create", &json!({"x": x + 100.0, "y": y + 80.0 + f64::from(i) * 60.0, "text": "Test", "size": 12})).unwrap(); ids.push(NodeId(text["id"].as_u64().unwrap())); } s.execute("select.none", &json!({})).unwrap(); let before: Vec = ids.iter().map(|id| bounds(&s, *id)).collect(); let board = s.doc().unwrap().doc.artboards[0].rect; let history = undo_len(&s); gesture(&mut s, "artboard", &[(x + 300.0, y + 200.0), (x + 400.0, y + 240.0), (x + 400.0, y + 240.0)], Mods::default()); let moved_board = s.doc().unwrap().doc.artboards[0].rect; let delta = moved_board.origin() - board.origin(); assert!(delta.hypot() > 1.0, "the artboard actually moved"); for (id, was) in ids.iter().zip(&before) { assert!(close(bounds(&s, *id), *was + delta), "{id:?} must move with the artboard"); } assert_eq!(undo_len(&s), history + 1); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.artboards[0].rect, board); for (id, was) in ids.iter().zip(&before) { assert!(close(bounds(&s, *id), *was), "Undo restores {id:?}"); } s.execute("edit.redo", &json!({})).unwrap(); for (id, was) in ids.iter().zip(&before) { assert!(close(bounds(&s, *id), *was + delta), "Redo moves {id:?}"); } } #[test] fn artboard_resize_via_handle_and_move_command() { let mut s = session(); gesture(&mut s, "artboard", &[(800.0, 300.0), (850.0, 300.0), (900.0, 300.0), (900.0, 300.0)], Mods::default()); assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, 0.0, 900.0, 600.0)); s.execute("artboard.move", &json!({"index": 0, "dx": -10, "dy": 5})).unwrap(); assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(-10.0, 5.0, 890.0, 605.0)); assert!(s.execute("artboard.move", &json!({"index": 7, "dx": 1})).is_err()); } /// Alt-dragging an artboard with the Artboard tool leaves it and its art in place and moves /// copies of both. #[test] fn artboard_alt_drag_duplicates_it_with_its_art() { let mut s = session(); let r = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("select.none", &json!({})).unwrap(); let alt = Mods { alt: true, ..Mods::default() }; gesture(&mut s, "artboard", &[(400.0, 300.0), (600.0, 300.0), (1300.0, 300.0), (1300.0, 300.0)], alt); let st = s.doc().unwrap(); let d = &st.doc; assert_eq!(d.artboards.len(), 2); assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 800.0, 600.0), "the original stays"); assert_eq!((d.artboards[1].rect, d.artboards[1].name.as_str()), (Rect::new(900.0, 0.0, 1700.0, 600.0), "Artboard 1 copy")); assert_ne!(d.artboards[1].id, d.artboards[0].id); let kids = d.layers[0].children().unwrap(); assert_eq!(kids.len(), 2, "the rectangle and its copy"); assert_eq!(d.node(r).unwrap().geometric_bounds().unwrap().x0, 100.0); let copy = kids.iter().find(|n| n.id != r).unwrap(); assert_eq!(copy.geometric_bounds().unwrap().x0, 1000.0); assert_eq!(st.history.undo.last().unwrap().label, "Duplicate Artboard"); // One undo takes the copies away. s.execute("edit.undo", &json!({})).unwrap(); let d = &s.doc().unwrap().doc; assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (1, 1)); } /// `artboard.move` with `copy` reports the copies it moved, and copies only the artboard when Move/Copy /// Artwork with Artboard is off. #[test] fn artboard_move_copy_reports_the_copies_and_follows_move_art() { let mut s = session(); let r = rect(&mut s, 100.0, 100.0, 50.0, 50.0); let out = s.execute("artboard.move", &json!({"index": 0, "dx": 900, "dy": 0, "copy": true, "moveArt": true})).unwrap(); assert_eq!(out["index"], 1); let copies: Vec = out["moved"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); assert_eq!(copies.len(), 1); assert_ne!(copies[0], r); assert_eq!(s.doc().unwrap().doc.node(copies[0]).unwrap().geometric_bounds().unwrap().x0, 1000.0); let out = s.execute("artboard.move", &json!({"index": 0, "dx": 0, "dy": 900, "copy": true})).unwrap(); assert_eq!(out["moved"], json!([])); let d = &s.doc().unwrap().doc; assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (3, 2), "an artboard alone"); assert_eq!(d.artboards[2].name, "Artboard 1 copy 2", "a name of its own"); let ids: std::collections::HashSet = d.artboards.iter().map(|a| a.id).collect(); assert_eq!(ids.len(), 3, "every artboard has an id of its own"); } fn stroke_width(s: &Session, id: NodeId) -> f64 { s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().width } fn guide_positions(s: &Session) -> Vec { s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect() } /// Scale Artwork with Artboard (#602): `artboard.setProps {scaleArt}` takes the art fully inside the /// artboard and its guides from the old rectangle onto the new one, each side by its own ratio, in /// one undo step; art partly outside and canvas guides stay. #[test] fn artboard_set_props_scales_the_art_and_guides_with_the_artboard() { let mut s = session(); let inside = rect(&mut s, 100.0, 100.0, 200.0, 100.0); s.execute("stroke.set", &json!({"weight": 4})).unwrap(); let partly = rect(&mut s, 700.0, 500.0, 200.0, 200.0); for (vertical, pos, artboard) in [(true, 400.0, json!(0)), (false, 300.0, json!(0)), (true, 400.0, json!(null))] { s.execute("guide.add", &json!({"vertical": vertical, "pos": pos, "artboard": artboard})).unwrap(); } let before = s.doc().unwrap().doc.clone(); // Half the size: half the art, half the stroke (Scale Strokes & Effects). let out = s.execute("artboard.setProps", &json!({"index": 0, "width": 400, "height": 300, "scaleArt": true, "strokes": true})).unwrap(); assert_eq!(out["scaled"], json!([inside.0])); assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, 0.0, 400.0, 300.0)); assert_eq!(bounds(&s, inside), Rect::new(50.0, 50.0, 150.0, 100.0)); assert_eq!(stroke_width(&s, inside), 2.0); assert_eq!(bounds(&s, partly), Rect::new(700.0, 500.0, 900.0, 700.0), "art partly outside stays"); assert_eq!(guide_positions(&s), vec![200.0, 150.0, 400.0], "the artboard's guides scale, the canvas guide stays"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(*s.doc().unwrap().doc, *before, "one undo step"); // Moved and stretched (left edge to -100, twice as wide), strokes kept: the art follows. s.execute("artboard.setProps", &json!({"index": 0, "x": -100, "width": 1600, "scaleArt": true, "strokes": false})).unwrap(); assert_eq!(bounds(&s, inside), Rect::new(100.0, 100.0, 500.0, 200.0)); assert_eq!(stroke_width(&s, inside), 4.0); assert_eq!(guide_positions(&s), vec![700.0, 300.0, 400.0]); } /// Without `scaleArt` a resize leaves the art and guides alone (and returns nothing, as before); /// with it, a pure move scales nothing (guides move along, as they always have). #[test] fn artboard_set_props_scales_art_only_when_asked_and_resized() { let mut s = session(); let r = rect(&mut s, 100.0, 100.0, 200.0, 100.0); s.execute("guide.add", &json!({"vertical": true, "pos": 400, "artboard": 0})).unwrap(); assert_eq!(s.execute("artboard.setProps", &json!({"index": 0, "width": 400})).unwrap(), Value::Null); assert_eq!(bounds(&s, r), Rect::new(100.0, 100.0, 300.0, 200.0)); assert_eq!(guide_positions(&s), vec![400.0]); let out = s.execute("artboard.setProps", &json!({"index": 0, "x": 50, "scaleArt": true})).unwrap(); assert_eq!(out["scaled"], json!([])); assert_eq!(bounds(&s, r), Rect::new(100.0, 100.0, 300.0, 200.0)); assert_eq!(guide_positions(&s), vec![450.0]); assert!(s.execute("artboard.setProps", &json!({"index": 9, "width": 10, "scaleArt": true})).is_err()); } /// Move Artwork is independent of scaling and takes only fully contained art and board guides. #[test] fn coordinate_moves_honor_move_art_and_keep_resize_behavior() { let mut s = session(); let inside = rect(&mut s, 100.0, 100.0, 200.0, 100.0); s.execute("stroke.set", &json!({"weight": 4})).unwrap(); let partly = rect(&mut s, 700.0, 500.0, 200.0, 200.0); s.execute("guide.add", &json!({"vertical": true, "pos": 400, "artboard": 0})).unwrap(); s.execute("guide.add", &json!({"vertical": true, "pos": 400})).unwrap(); let before = s.doc().unwrap().doc.clone(); for scale in [false, true] { let out = s.execute("artboard.setProps", &json!({"index": 0, "x": 30, "y": -10, "moveArt": true, "scaleArt": scale})).unwrap(); assert_eq!(bounds(&s, inside), Rect::new(130.0, 90.0, 330.0, 190.0)); assert_eq!(bounds(&s, partly), Rect::new(700.0, 500.0, 900.0, 700.0)); assert_eq!(stroke_width(&s, inside), 4.0); assert_eq!(guide_positions(&s), vec![430.0, 400.0]); assert_eq!(out, if scale { json!({"scaled": []}) } else { Value::Null }); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(*s.doc().unwrap().doc, *before); } for p in [json!({"index": 0, "x": 30, "moveArt": false}), json!({"index": 0, "x": 30, "width": 400, "moveArt": true})] { s.execute("artboard.setProps", &p).unwrap(); assert_eq!(bounds(&s, inside), Rect::new(100.0, 100.0, 300.0, 200.0)); s.execute("edit.undo", &json!({})).unwrap(); } } #[test] fn coordinate_moves_journal_the_locked_art_preference() { let mut s = session(); let locked = rect(&mut s, 100.0, 100.0, 200.0, 100.0); s.execute("object.lock", &json!({})).unwrap(); s.execute("artboard.setProps", &json!({"index": 0, "x": 30, "moveArt": true})).unwrap(); assert_eq!(bounds(&s, locked), Rect::new(100.0, 100.0, 300.0, 200.0)); s.execute("edit.undo", &json!({})).unwrap(); s.prefs.move_locked_with_artboard = true; s.execute("artboard.setProps", &json!({"index": 0, "x": 30, "moveArt": true})).unwrap(); assert_eq!(bounds(&s, locked), Rect::new(130.0, 100.0, 330.0, 200.0)); let (id, p) = s.journal.last().unwrap().clone(); assert_eq!(p["lockedAndHidden"], true); s.execute("edit.undo", &json!({})).unwrap(); s.prefs.move_locked_with_artboard = false; s.execute(&id, &p).unwrap(); assert_eq!(bounds(&s, locked), Rect::new(130.0, 100.0, 330.0, 200.0)); } /// Locked and hidden art scales only with Move Locked and Hidden Artwork with Artboard, as it /// moves only with it. #[test] fn scale_artwork_with_artboard_leaves_locked_art_unless_the_preference_says() { let mut s = session(); let locked = rect(&mut s, 100.0, 100.0, 200.0, 100.0); s.execute("object.lock", &json!({})).unwrap(); let scaled = |s: &mut Session| { let out = s.execute("artboard.setProps", &json!({"index": 0, "width": 400, "height": 300, "scaleArt": true})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); out["scaled"].clone() }; assert_eq!(scaled(&mut s), json!([])); s.execute("prefs.set", &json!({"key": "moveLockedWithArtboard", "value": true})).unwrap(); assert_eq!(scaled(&mut s), json!([locked.0])); // The choice is journaled: replayed with the preference off, the same art scales. s.execute("artboard.setProps", &json!({"index": 0, "width": 400, "height": 300, "scaleArt": true})).unwrap(); assert_eq!(s.journal.last().unwrap().1["lockedAndHidden"], true); let (id, p) = s.journal.last().unwrap().clone(); s.execute("edit.undo", &json!({})).unwrap(); s.execute("prefs.set", &json!({"key": "moveLockedWithArtboard", "value": false})).unwrap(); assert_eq!(s.execute(&id, &p).unwrap()["scaled"], json!([locked.0])); } /// The Artboard tool with its `scaleArt` option on resizes proportionally and the art scales with /// the artboard, in one undo step. #[test] fn artboard_tool_scales_art_with_the_artboard() { let mut s = session(); let r = rect(&mut s, 100.0, 100.0, 200.0, 100.0); let before = s.doc().unwrap().doc.clone(); s.set_tool_options(Some("artboard"), json!({"scaleArt": true}).as_object().unwrap()); // The right handle 100 pt out: 9/8 as wide, and as tall about the middle of the left side. gesture(&mut s, "artboard", &[(800.0, 300.0), (850.0, 300.0), (900.0, 300.0), (900.0, 300.0)], Mods::default()); assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, -37.5, 900.0, 637.5)); assert_eq!(bounds(&s, r), Rect::new(112.5, 75.0, 337.5, 187.5)); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(*s.doc().unwrap().doc, *before, "one undo step"); // Off again (the option is remembered like Move Artwork with Artboard): the art stays. s.set_tool_options(Some("artboard"), json!({"scaleArt": false}).as_object().unwrap()); gesture(&mut s, "artboard", &[(800.0, 300.0), (850.0, 300.0), (900.0, 300.0), (900.0, 300.0)], Mods::default()); assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, 0.0, 900.0, 600.0)); assert_eq!(bounds(&s, r), Rect::new(100.0, 100.0, 300.0, 200.0)); } #[test] fn magic_wand_selects_same_fill() { let mut s = session(); let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); let b = rect(&mut s, 50.0, 10.0, 20.0, 20.0); let c = rect(&mut s, 90.0, 10.0, 20.0, 20.0); s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [c.0]})).unwrap(); s.execute("select.none", &json!({})).unwrap(); gesture(&mut s, "magicWand", &[(20.0, 20.0), (20.0, 20.0)], Mods::default()); assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); gesture(&mut s, "magicWand", &[(100.0, 20.0), (100.0, 20.0)], Mods { shift: true, ..Default::default() }); assert_eq!(s.doc().unwrap().selection.len(), 3); } #[test] fn lasso_selects_anchor_subset() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); s.execute("select.none", &json!({})).unwrap(); gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); let st = s.doc().unwrap(); assert_eq!(st.selection.objects, vec![a]); assert_eq!(st.selection.partial(a).map(|p| p.len()), Some(2)); } /// Shift-drag a marquee with the Selection tool (#483): the selected objects it reaches are /// deselected and the others selected, the rest of the selection staying as it was. #[test] fn selection_shift_marquee_toggles_objects() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0); let b = rect(&mut s, 200.0, 100.0, 50.0, 50.0); let c = rect(&mut s, 300.0, 100.0, 50.0, 50.0); s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); let shift = Mods { shift: true, ..Default::default() }; gesture(&mut s, "selection", &[(180.0, 80.0), (300.0, 200.0), (380.0, 200.0)], shift); assert_eq!(s.doc().unwrap().selection.objects, vec![a, c]); // Again over all three: a leaves, b joins, c leaves. gesture(&mut s, "selection", &[(80.0, 80.0), (300.0, 200.0), (380.0, 200.0)], shift); assert_eq!(s.doc().unwrap().selection.objects, vec![b]); // Without Shift the marquee replaces the selection. gesture(&mut s, "selection", &[(80.0, 80.0), (300.0, 200.0), (260.0, 200.0)], Mods::default()); assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); } /// Shift-drag a marquee with Direct Selection (#483): the anchors inside toggle; a path left with /// none of them leaves the selection, one with all of them is selected whole again. #[test] fn direct_selection_shift_marquee_toggles_anchors() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); let b = rect(&mut s, 300.0, 100.0, 100.0, 100.0); s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); let shift = Mods { shift: true, ..Default::default() }; // Around a's top edge (two anchors, selected) and b's top-left anchor (not selected). let top = [(90.0, 90.0), (310.0, 110.0), (310.0, 110.0)]; gesture(&mut s, "directSelection", &top, shift); let st = s.doc().unwrap(); assert_eq!(st.selection.objects, vec![a, b]); assert_eq!(st.selection.partial(a).map(|p| p.len()), Some(2), "a's bottom anchors stay"); assert_eq!(st.selection.partial(b).map(|p| p.len()), Some(1)); // The same again: a whole once more, b out. gesture(&mut s, "directSelection", &top, shift); let st = s.doc().unwrap(); assert_eq!(st.selection.objects, vec![a]); assert_eq!(st.selection.partial(a), None); // Group Selection's marquee toggles the same way. gesture(&mut s, "groupSelection", &top, shift); assert_eq!(s.doc().unwrap().selection.partial(a).map(|p| p.len()), Some(2)); } /// The Lasso: Shift adds anchors, Alt takes them away, and neither drops the rest of the /// selection (a path selected whole stays whole when its anchors are added again). #[test] fn lasso_shift_adds_and_alt_subtracts() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); let t = s.execute("text.create", &json!({"x": 300, "y": 300, "text": "Hi"})).unwrap(); let t = NodeId(t["id"].as_u64().unwrap()); s.execute("select.set", &json!({"ids": [a.0, t.0]})).unwrap(); // A loop round a's top-left anchor. let corner = [(90.0, 90.0), (110.0, 90.0), (110.0, 110.0), (90.0, 110.0), (90.0, 110.0)]; gesture(&mut s, "lasso", &corner, Mods { shift: true, ..Default::default() }); let st = s.doc().unwrap(); assert_eq!((st.selection.objects.clone(), st.selection.partial(a)), (vec![a, t], None)); gesture(&mut s, "lasso", &corner, Mods { alt: true, ..Default::default() }); let st = s.doc().unwrap(); assert_eq!(st.selection.objects, vec![a, t], "the type stays selected"); assert_eq!(st.selection.partial(a).map(|p| p.len()), Some(3)); } #[test] fn measure_tool_leaves_document_untouched() { let mut s = session(); rect(&mut s, 100.0, 100.0, 100.0, 100.0); let rev = s.doc().unwrap().revision; let n = undo_len(&s); gesture(&mut s, "measure", &[(0.0, 0.0), (30.0, 40.0), (30.0, 40.0)], Mods::default()); assert_eq!(s.doc().unwrap().revision, rev); assert_eq!(undo_len(&s), n); assert!((s.tool_options()["distance"].as_f64().unwrap() - 50.0).abs() < 1e-9); assert!(!s.overlays(ViewInfo::default()).is_empty()); } #[test] fn copy_from_requires_source() { let mut s = session(); assert!(s.execute("appearance.copyFrom", &json!({})).is_err()); assert!(s.execute("appearance.copyFrom", &json!({"source": 9999})).is_err()); } #[test] fn distort_rejects_malformed_coordinates_and_ids_without_editing_art() { let mut s = session(); let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); let original = bounds(&s, a); let history = undo_len(&s); let corners = json!([[10, 0], [90, 0], [100, 100], [0, 100]]); for p in [ json!({"corners": [[10, 0, 50], [90, 0], [100, 100], [0, 100]]}), json!({"corners": [[10, 0], [90, 0], ["bad", 100], [0, 100]]}), json!({"corners": [[10, 0], [90, 0], [100, 100], [0, 100], ["bad"]]}), json!({"corners": corners, "from": [0, 0, 100]}), json!({"corners": corners, "from": "not bounds"}), json!({"corners": corners, "ids": [a.0, "bad"]}), json!({"corners": corners, "ids": [a.0, u64::MAX]}), ] { assert!(s.execute("object.distort", &p).is_err(), "invalid input: {p}"); assert!(close(bounds(&s, a), original)); assert_eq!(undo_len(&s), history); } s.execute("object.distort", &json!({"corners": corners, "from": [0, 0, 100, 100], "ids": [a.0]})).unwrap(); assert_eq!(undo_len(&s), history + 1); } /// `artboard.move` and `artboard.delete` take several artboards at once (#1139): one undo step /// each, the art on each moving with it, and one artboard always left. #[test] fn several_artboards_move_and_delete_together() { let mut s = session(); s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 200, "height": 200})).unwrap(); s.execute("artboard.new", &json!({"x": 1400, "y": 0, "width": 200, "height": 200})).unwrap(); let r = rect(&mut s, 1050.0, 50.0, 50.0, 50.0); let before: Vec = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); let out = s.execute("artboard.move", &json!({"indices": [2, 1], "dx": 10, "dy": 20, "moveArt": true})).unwrap(); assert_eq!(out["indices"], json!([1, 2])); let d = &s.doc().unwrap().doc; assert_eq!(d.artboards[0].rect, before[0]); assert_eq!( (d.artboards[1].rect, d.artboards[2].rect), (before[1] + vectorcraft_geom::Vec2::new(10.0, 20.0), before[2] + vectorcraft_geom::Vec2::new(10.0, 20.0)) ); assert_eq!(d.node(r).unwrap().geometric_bounds().unwrap().x0, 1060.0, "the art on artboard 2 moved with it"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.artboards[2].rect, before[2], "one undo step"); for bad in [json!({"indices": [1, 9]}), json!({"indices": []}), json!({"indices": [-1]}), json!({"indices": "1"})] { assert!(s.execute("artboard.move", &json!({"indices": bad["indices"], "dx": 1})).is_err(), "{bad}"); } assert!(s.execute("artboard.delete", &json!({"indices": [0, 1, 2]})).is_err(), "one is always left"); s.execute("artboard.delete", &json!({"indices": [2, 0]})).unwrap(); let d = &s.doc().unwrap().doc; assert_eq!(d.artboards.iter().map(|a| a.rect).collect::>(), [before[1]]); }