vectorcraft/crates/engine/src/tests_xform.rs

429 lines
21 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<UiRequest> {
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);
}
#[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:?}");
}
#[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<Point> = 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<Point> = 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<Point> = 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_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<NodeId> = 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<u32> = d.artboards.iter().map(|a| a.id).collect();
assert_eq!(ids.len(), 3, "every artboard has an id of its own");
}
#[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());
}