Files
vectorcraft/crates/engine/src/tests/globaledit.rs
T

593 lines
28 KiB
Rust
Raw 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.
//! Select › Start Global Edit (`select.globalEdit`, `select.globalEditOptions`): which objects
//! count as similar, and edits made to one of them reaching the others in proportion, as one undo
//! step. Behaviour: <https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html>.
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{Affine, PathData, Rect};
use crate::*;
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 {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn bounds(s: &Session, id: NodeId) -> Rect {
node(s, id).geometric_bounds().unwrap()
}
fn sel(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn ids(v: &Value) -> Vec<NodeId> {
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
}
fn start(s: &mut Session, key: NodeId, p: Value) -> Vec<NodeId> {
sel(s, &[key]);
let r = s.execute("select.globalEdit", &p).unwrap();
assert_eq!(r["active"], true);
ids(&r)
}
fn fill(s: &Session, id: NodeId) -> String {
node(s, id).appearance.fill_paint().color().unwrap().to_hex()
}
fn stroke(s: &Session, id: NodeId) -> (String, f64) {
let n = node(s, id);
(n.appearance.stroke_paint().color().unwrap().to_hex(), n.appearance.stroke_width())
}
fn members(s: &Session) -> Option<Vec<NodeId>> {
s.doc().unwrap().global_edit.as_ref().map(|g| g.members.iter().map(|m| m.0).collect())
}
fn close(a: Rect, b: Rect) -> bool {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 1e-6)
}
/// `b`'s outline is `a`'s placed by `xf`, anchor for anchor.
fn placed(s: &Session, a: NodeId, b: NodeId, xf: Affine) -> bool {
let (pa, pb): (PathData, PathData) = (node(s, a).path_data().unwrap().transformed(xf), node(s, b).path_data().unwrap().clone());
pa.subpaths.len() == pb.subpaths.len()
&& pa.subpaths.iter().zip(&pb.subpaths).all(|(x, y)| {
x.closed == y.closed
&& x.anchors.len() == y.anchors.len()
&& x.anchors
.iter()
.zip(&y.anchors)
.all(|(m, n)| m.p.distance(n.p) < 1e-6 && m.h_in.distance(n.h_in) < 1e-6 && m.h_out.distance(n.h_out) < 1e-6)
})
}
/// A 100 pt square, a copy of it, a 20 pt square (the same shape, smaller), a 100 × 50 rectangle
/// and a 100 pt circle (other shapes).
fn squares(s: &mut Session) -> [NodeId; 5] {
[rect(s, 0.0, 0.0, 100.0, 100.0), rect(s, 300.0, 0.0, 100.0, 100.0), rect(s, 500.0, 0.0, 20.0, 20.0), rect(s, 0.0, 300.0, 100.0, 50.0), {
let r = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}]
}
#[test]
fn start_global_edit_takes_the_objects_of_the_same_shape_at_any_place_and_size() {
let mut s = session();
let [a, b, c, wide, circle] = squares(&mut s);
let members = start(&mut s, a, json!({}));
assert_eq!(members, [a, b, c], "the copy and the smaller square; not the rectangle {wide} nor the circle {circle}");
// The selected object stays the selection; the others are highlighted on the canvas.
assert_eq!(s.doc().unwrap().selection.objects, [a]);
assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2);
let r = s.execute("select.globalEdit", &json!({})).unwrap();
assert_eq!(r["active"], false, "the second run is Stop Global Edit");
assert!(s.doc().unwrap().global_edit.is_none() && crate::cmd::globaledit::highlights(&s).is_empty());
}
#[test]
fn size_and_appearance_options_narrow_the_match() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
assert_eq!(start(&mut s, a, json!({"size": true})), [a, b], "the smaller square has another size");
s.execute("select.globalEdit", &json!({})).unwrap();
sel(&mut s, &[b]);
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
assert_eq!(start(&mut s, a, json!({"appearance": true})), [a, c], "the copy has another fill now");
s.execute("select.globalEdit", &json!({})).unwrap();
// The options are kept for the session, and report how many objects they take.
sel(&mut s, &[a]);
let r = s.execute("select.globalEditOptions", &json!({"size": true, "appearance": true})).unwrap();
assert_eq!((r["size"].clone(), r["appearance"].clone(), r["count"].clone()), (json!(true), json!(true), json!(1)));
let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string();
assert!(e.contains("no similar objects"), "{e}");
s.execute("select.globalEditOptions", &json!({"size": false, "appearance": false})).unwrap();
assert_eq!(start(&mut s, a, json!({})), [a, b, c]);
}
#[test]
fn an_edit_reaches_every_member_in_proportion_as_one_undo_step() {
let mut s = session();
let [a, b, c, wide, circle] = squares(&mut s);
start(&mut s, a, json!({}));
let before = (*s.doc().unwrap().doc).clone();
let steps = s.doc().unwrap().history.undo.len();
let (wide_was, circle_was) = (node(&s, wide), node(&s, circle));
// Height 100 → 50 from the top left (the Transform panel): the 20 pt square goes to 10 pt.
s.execute("object.setBounds", &json!({"height": 50, "reference": 0})).unwrap();
assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 50.0)), "{:?}", bounds(&s, a));
assert!(close(bounds(&s, b), Rect::new(300.0, 0.0, 400.0, 50.0)), "{:?}", bounds(&s, b));
assert!(close(bounds(&s, c), Rect::new(500.0, 0.0, 520.0, 10.0)), "{:?}", bounds(&s, c));
assert_eq!((node(&s, wide), node(&s, circle)), (wide_was, circle_was), "objects of other shapes are left alone");
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
crate::tests_large_edits::undo_gives_back(&mut s, &before);
}
#[test]
fn an_anchor_moved_on_one_member_moves_on_the_others() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
start(&mut s, a, json!({}));
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
s.execute("path.moveAnchors", &json!({"dx": 10, "dy": 30})).unwrap();
assert!(placed(&s, a, b, Affine::translate((300.0, 0.0))));
assert!(placed(&s, a, c, Affine::translate((500.0, 0.0)) * Affine::scale(0.2)));
assert!(!placed(&s, a, c, Affine::translate((500.0, 0.0))), "the smaller square's anchor moves a fifth as far");
}
#[test]
fn a_drag_moves_the_members_with_each_preview_and_commits_one_step() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
start(&mut s, a, json!({}));
let before = (*s.doc().unwrap().doc).clone();
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap();
s.preview("object.move", &json!({"dx": 40, "dy": 0})).unwrap();
assert!(close(bounds(&s, b), Rect::new(340.0, 0.0, 440.0, 100.0)), "the second preview replaces the first: {:?}", bounds(&s, b));
s.commit_interaction().unwrap();
assert!(close(bounds(&s, c), Rect::new(508.0, 0.0, 528.0, 20.0)), "{:?}", bounds(&s, c));
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Move");
crate::tests_large_edits::undo_gives_back(&mut s, &before);
}
#[test]
fn any_selected_member_leads_and_fills_reach_the_others_only_when_they_change() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
sel(&mut s, &[c]);
s.execute("paint.setFill", &json!({"color": "#123456"})).unwrap();
start(&mut s, a, json!({}));
let c_fill = fill(&s, c);
// Moving the copy (selected during Global Edit) moves the others in proportion and keeps
// their own fills.
sel(&mut s, &[b]);
s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap();
assert!(close(bounds(&s, a), Rect::new(50.0, 0.0, 150.0, 100.0)), "{:?}", bounds(&s, a));
assert!(close(bounds(&s, c), Rect::new(510.0, 0.0, 530.0, 20.0)), "{:?}", bounds(&s, c));
assert_eq!(fill(&s, c), c_fill);
// A new fill reaches all of them, and the stroke weight stays the model's, not scaled.
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
for id in [a, b, c] {
assert_eq!(fill(&s, id), "#ff0000");
assert_eq!(node(&s, id).appearance.stroke_width(), 4.0);
}
// Selection changes alone edit nothing; after Stop Global Edit an edit stays on its object.
s.execute("select.globalEdit", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
assert!(close(bounds(&s, a), Rect::new(50.0, 0.0, 150.0, 100.0)));
}
#[test]
fn locked_and_hidden_objects_are_left_out() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
let locked = rect(&mut s, 0.0, 450.0, 100.0, 100.0);
let hidden = rect(&mut s, 150.0, 450.0, 100.0, 100.0);
s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap();
s.execute("object.hide", &json!({"ids": [hidden.0]})).unwrap();
assert_eq!(start(&mut s, a, json!({})), [a, b, c], "locked and hidden squares are not found");
// A member locked after the start is not edited either.
s.execute("object.lock", &json!({"ids": [b.0]})).unwrap();
let b_was = node(&s, b);
sel(&mut s, &[a]);
s.execute("object.move", &json!({"dx": 0, "dy": 10})).unwrap();
assert_eq!(node(&s, b), b_was);
assert!(close(bounds(&s, c), Rect::new(500.0, 2.0, 520.0, 22.0)), "{:?}", bounds(&s, c));
assert_eq!(bounds(&s, locked), Rect::new(0.0, 450.0, 100.0, 550.0));
assert_eq!(bounds(&s, hidden), Rect::new(150.0, 450.0, 250.0, 550.0));
}
#[test]
fn artboard_options_limit_where_similar_objects_are_found() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap();
let key = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
let same_board = rect(&mut s, 200.0, 10.0, 50.0, 50.0);
let second = rect(&mut s, 1100.0, 10.0, 50.0, 50.0);
let canvas = rect(&mut s, 2000.0, 2000.0, 50.0, 50.0);
assert_eq!(start(&mut s, key, json!({})), [key, same_board, second, canvas], "all artboards and the canvas by default");
s.execute("select.globalEdit", &json!({})).unwrap();
assert_eq!(start(&mut s, key, json!({"includeCanvas": false})), [key, same_board, second]);
s.execute("select.globalEdit", &json!({})).unwrap();
assert_eq!(start(&mut s, key, json!({"artboards": "2"})), [key, second, canvas]);
s.execute("select.globalEdit", &json!({})).unwrap();
assert_eq!(start(&mut s, key, json!({"artboards": "2", "includeCanvas": false})), [key, second]);
s.execute("select.globalEdit", &json!({})).unwrap();
let e = s.execute("select.globalEdit", &json!({"artboards": "3"})).unwrap_err().to_string();
assert!(e.contains("1 to 2"), "{e}");
}
#[test]
fn compound_paths_match_whole_and_keep_their_own_member_ids() {
let mut s = session();
let compound = |s: &mut Session, x: f64, k: f64| {
let (p, q) = (rect(s, x, 0.0, 100.0 * k, 100.0 * k), rect(s, x + 25.0 * k, 25.0 * k, 50.0 * k, 50.0 * k));
sel(s, &[p, q]);
NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap())
};
let (one, two) = (compound(&mut s, 0.0, 1.0), compound(&mut s, 300.0, 0.5));
let lone = rect(&mut s, 600.0, 0.0, 100.0, 100.0);
assert_eq!(start(&mut s, one, json!({})), [one, two], "not the square {lone} alone");
let kids = |s: &Session, id: NodeId| node(s, id).children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
let two_kids = kids(&s, two);
s.execute("object.setBounds", &json!({"width": 200, "reference": 0})).unwrap();
assert!(close(bounds(&s, two), Rect::new(300.0, 0.0, 400.0, 50.0)), "{:?}", bounds(&s, two));
assert_eq!(kids(&s, two), two_kids);
assert!(matches!(node(&s, two).kind, NodeKind::Compound { .. }));
}
#[test]
fn symbol_instances_of_one_symbol_match() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 40.0, 40.0);
let made = s.execute("symbol.new", &json!({"ids": [r.0], "name": "Tile"})).unwrap();
let first = NodeId(made["id"].as_u64().unwrap());
let placed = s.execute("symbol.place", &json!({"name": "Tile", "x": 300, "y": 300})).unwrap();
let second = NodeId(placed["id"].as_u64().unwrap());
let other = rect(&mut s, 500.0, 500.0, 40.0, 40.0);
assert_eq!(start(&mut s, first, json!({})), [first, second], "not the plain square {other}");
let xf = |s: &Session, id: NodeId| match node(s, id).kind {
NodeKind::SymbolInstance { xf, .. } => xf,
_ => panic!("not an instance"),
};
let was = xf(&s, second);
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
let (now, want) = (xf(&s, second).as_coeffs(), (Affine::translate((10.0, 0.0)) * was).as_coeffs());
assert!(now.iter().zip(want).all(|(p, q)| (p - q).abs() < 1e-6), "{now:?} vs {want:?}");
}
#[test]
fn global_edit_needs_one_supported_object() {
let mut s = session();
let [a, b, ..] = squares(&mut s);
sel(&mut s, &[a, b]);
assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "a selection of two");
let t = NodeId(s.execute("text.create", &json!({"x": 50, "y": 500, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
sel(&mut s, &[t]);
assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "type");
let lone = rect(&mut s, 0.0, 500.0, 30.0, 70.0);
// A copy turned a quarter turn (70 × 30) is not taken: rotated copies are not matched, and the
// message says so.
let turned = rect(&mut s, 200.0, 500.0, 30.0, 70.0);
sel(&mut s, &[turned]);
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
sel(&mut s, &[lone]);
let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string();
assert!(e.contains("no similar objects") && e.contains("rotated"), "{e}");
assert!(s.doc().unwrap().global_edit.is_none());
// A group holding type is refused, though another such group exists.
let group = |s: &mut Session, x: f64| {
let p = rect(s, x, 100.0, 10.0, 10.0);
let t = NodeId(s.execute("text.create", &json!({"x": x, "y": 150, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
group_of(s, &[p, t])
};
let (g, _) = (group(&mut s, 600.0), group(&mut s, 700.0));
sel(&mut s, &[g]);
// The menu only checks that it is a group; starting refuses its contents.
let e = s.execute("select.globalEdit", &json!({})).unwrap_err();
assert!(e.to_string().contains("symbol instances and groups of them"), "{e}");
assert!(s.doc().unwrap().global_edit.is_none());
}
#[test]
fn global_edit_is_not_saved() {
let mut s = session();
let [a, ..] = squares(&mut s);
start(&mut s, a, json!({}));
let saved = s.execute("document.serialize", &json!({})).unwrap().to_string();
assert!(!saved.to_ascii_lowercase().contains("globaledit"));
}
#[test]
fn a_stroke_weight_change_leaves_each_members_own_fill_and_stroke_colour() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
sel(&mut s, &[b]);
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap();
start(&mut s, a, json!({}));
let (a_fill, c_fill) = (fill(&s, a), fill(&s, c));
s.execute("stroke.set", &json!({"weight": 5})).unwrap();
assert_eq!(fill(&s, b), "#0000ff", "the copy keeps its own fill");
assert_eq!(stroke(&s, b), ("#00ff00".to_string(), 5.0), "and its own stroke colour, with the new weight");
assert_eq!(stroke(&s, c).1, 5.0, "the weight is the model's, not scaled");
assert_eq!((fill(&s, a), fill(&s, c)), (a_fill, c_fill));
}
#[test]
fn a_fill_colour_change_reaches_the_fills_and_not_the_strokes() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
sel(&mut s, &[b]);
s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap();
s.execute("stroke.set", &json!({"weight": 3})).unwrap();
start(&mut s, a, json!({}));
let (a_stroke, c_stroke) = (stroke(&s, a), stroke(&s, c));
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
for id in [a, b, c] {
assert_eq!(fill(&s, id), "#ff0000");
}
assert_eq!(stroke(&s, b), ("#00ff00".to_string(), 3.0), "the copy keeps its own stroke");
assert_eq!((stroke(&s, a), stroke(&s, c)), (a_stroke, c_stroke));
}
#[test]
fn a_fill_or_stroke_added_or_removed_reaches_the_members_that_keep_their_others() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
sel(&mut s, &[b]);
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
// The small square has two fills: not the model's fills and strokes in order.
sel(&mut s, &[c]);
s.execute("appearance.addFill", &json!({})).unwrap();
start(&mut s, a, json!({}));
let kinds = |s: &Session, id| node(s, id).appearance.items.iter().map(|i| i.kind_name()).collect::<Vec<_>>();
s.execute("appearance.addStroke", &json!({})).unwrap();
assert_eq!(kinds(&s, a), ["fill", "stroke", "stroke"]);
assert_eq!(kinds(&s, b), kinds(&s, a), "the copy gains the stroke");
assert_eq!(fill(&s, b), "#0000ff", "and keeps its own fill");
assert_eq!(kinds(&s, c), kinds(&s, a), "a member with other fills and strokes takes the model's list");
s.execute("appearance.removeItem", &json!({"index": 0})).unwrap();
assert_eq!(kinds(&s, a), ["stroke", "stroke"]);
assert_eq!(kinds(&s, b), kinds(&s, a), "the copy loses its fill");
}
#[test]
fn undo_and_redo_drop_the_members_the_document_lacks() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 300.0, 0.0, 100.0, 100.0);
let c = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
assert_eq!(start(&mut s, a, json!({})), [a, b, c]);
// Undoing the making of the small square takes it out of the Global Edit, so a square made
// under its freed id is not edited with the others.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(members(&s), Some(vec![a, b]));
let d = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
assert_eq!(d, c, "the id is used again");
let d_was = node(&s, d);
sel(&mut s, &[a]);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
assert_eq!(fill(&s, b), "#ff0000", "Global Edit goes on with the members left");
assert_eq!(node(&s, d), d_was);
// Redoing the deletion of a member takes it out too; with one left, Global Edit ends.
sel(&mut s, &[b]);
s.execute("edit.clear", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(members(&s), Some(vec![a, b]), "every member is there again");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(members(&s), None);
}
/// Two 100 pt squares and a 20 pt one made after `begin`, all three in a Global Edit; `put_back`
/// takes the document back to before the small one. The small square leaves the Global Edit, and
/// a square made under its freed id is not edited with the others.
fn put_back_drops_the_member<T>(begin: impl FnOnce(&mut Session) -> T, put_back: impl FnOnce(&mut Session, T)) {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 300.0, 0.0, 100.0, 100.0);
let t = begin(&mut s);
let c = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
assert_eq!(start(&mut s, a, json!({})), [a, b, c]);
put_back(&mut s, t);
assert_eq!(members(&s), Some(vec![a, b]));
let d = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
assert_eq!(d, c, "the id is used again");
let d_was = node(&s, d);
sel(&mut s, &[a]);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
assert_eq!(fill(&s, b), "#ff0000", "Global Edit goes on with the members left");
assert_eq!(node(&s, d), d_was);
}
#[test]
fn a_cancelled_drag_scrub_or_undo_scope_drops_the_members_it_takes_away() {
put_back_drops_the_member(|s| s.begin_interaction("Draw").unwrap(), |s, ()| s.cancel_interaction().unwrap());
put_back_drops_the_member(Session::begin_undo_group, |s, ()| s.end_undo_group(true));
put_back_drops_the_member(
|s| {
s.begin_undo_group();
s.begin_undo_scope().unwrap()
},
|s, scope| {
s.end_undo_scope(scope, true);
s.end_undo_group(false);
},
);
}
#[test]
fn an_edit_that_takes_a_member_past_the_canvas_is_refused_whole() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 2.0, 2.0);
let big = rect(&mut s, 0.0, 0.0, 2000.0, 2000.0);
assert_eq!(start(&mut s, a, json!({})), [a, big]);
let before = (node(&s, a), node(&s, big));
let steps = s.doc().unwrap().history.undo.len();
// 4000 pt keeps the small square in range; the square 1000 times its size moves 1000 times as
// far, past `MAX_COORD`.
let e = s.execute("object.move", &json!({"dx": 4000, "dy": 0})).unwrap_err().to_string();
assert!(e.contains("out of range"), "{e}");
assert_eq!((node(&s, a), node(&s, big)), before, "nothing moved");
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
s.execute("object.move", &json!({"dx": 3000, "dy": 0})).unwrap();
assert!(close(bounds(&s, big), Rect::new(3.0e6, 0.0, 3.0e6 + 2000.0, 2000.0)), "{:?}", bounds(&s, big));
}
fn group_of(s: &mut Session, objects: &[NodeId]) -> NodeId {
sel(s, objects);
NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap())
}
/// A group of a square, a circle and a bar, `k` times the size, at `x` → the group and its objects.
fn card(s: &mut Session, x: f64, k: f64) -> (NodeId, [NodeId; 3]) {
let sq = rect(s, x, 0.0, 40.0 * k, 40.0 * k);
let dot = NodeId(
s.execute("shape.ellipse", &json!({"x": x + 50.0 * k, "y": 0, "width": 40.0 * k, "height": 40.0 * k})).unwrap()["id"].as_u64().unwrap(),
);
let bar = rect(s, x, 50.0 * k, 90.0 * k, 20.0 * k);
(group_of(s, &[sq, dot, bar]), [sq, dot, bar])
}
fn kids(s: &Session, g: NodeId) -> Vec<NodeId> {
node(s, g).children().unwrap().iter().map(|c| c.id).collect()
}
#[test]
fn start_global_edit_on_a_group_takes_the_groups_like_it() {
let mut s = session();
let (g, [sq, ..]) = card(&mut s, 0.0, 1.0);
let (copy, _) = card(&mut s, 300.0, 1.0);
let (small, _) = card(&mut s, 500.0, 0.5);
// Two of the three objects; the same three with the bar moved; a lone square like the first.
let (p, q) = (rect(&mut s, 0.0, 300.0, 40.0, 40.0), rect(&mut s, 0.0, 400.0, 90.0, 20.0));
let pair = group_of(&mut s, &[p, q]);
let (moved, [.., bar]) = card(&mut s, 300.0, 1.0);
sel(&mut s, &[bar]);
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
rect(&mut s, 600.0, 300.0, 40.0, 40.0);
assert_eq!(start(&mut s, g, json!({})), [g, copy, small], "not the pair {pair} nor the group with the bar moved {moved}");
// A group alone among groups of other contents: nothing to edit with it.
sel(&mut s, &[pair]);
s.execute("select.globalEdit", &json!({})).unwrap();
let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string();
assert!(e.contains("no similar objects"), "{e}");
// Size leaves out the smaller group; Appearance looks at each object in the groups.
assert_eq!(start(&mut s, g, json!({"size": true})), [g, copy]);
s.execute("select.globalEdit", &json!({})).unwrap();
sel(&mut s, &[sq]);
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
sel(&mut s, &[g]);
let e = s.execute("select.globalEdit", &json!({"appearance": true})).unwrap_err().to_string();
assert!(e.contains("no similar objects"), "the square in the first group has another fill now: {e}");
}
#[test]
fn an_edit_inside_one_group_reaches_the_same_object_in_the_others_as_one_undo_step() {
let mut s = session();
let (g, [sq, dot, bar]) = card(&mut s, 0.0, 1.0);
let (copy, [csq, cdot, cbar]) = card(&mut s, 300.0, 1.0);
let (_, [ssq, sdot, _]) = card(&mut s, 500.0, 0.5);
start(&mut s, g, json!({}));
let before = (*s.doc().unwrap().doc).clone();
let steps = s.doc().unwrap().history.undo.len();
let (sq_fill, bar_fill) = (fill(&s, csq), fill(&s, cbar));
// The circle inside the first group, selected on its own.
sel(&mut s, &[dot]);
assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2, "the other groups are highlighted, not the one being edited");
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
assert_eq!((fill(&s, cdot), fill(&s, sdot)), ("#12ab34".to_string(), "#12ab34".to_string()));
assert_eq!((fill(&s, csq), fill(&s, cbar)), (sq_fill, bar_fill), "the other objects keep theirs");
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
// The square's height halved from the top: each copy's in proportion.
sel(&mut s, &[sq]);
s.execute("object.setBounds", &json!({"height": 20, "reference": 0})).unwrap();
assert!(close(bounds(&s, csq), Rect::new(300.0, 0.0, 340.0, 20.0)), "{:?}", bounds(&s, csq));
assert!(close(bounds(&s, ssq), Rect::new(500.0, 0.0, 520.0, 10.0)), "{:?}", bounds(&s, ssq));
assert!(placed(&s, bar, cbar, Affine::translate((300.0, 0.0))), "the bar is left alone");
assert_eq!(kids(&s, copy), [csq, cdot, cbar], "each object keeps its id");
s.execute("edit.undo", &json!({})).unwrap();
crate::tests_large_edits::undo_gives_back(&mut s, &before);
}
#[test]
fn an_object_added_to_the_group_is_added_to_the_others() {
let mut s = session();
let (g, [sq, ..]) = card(&mut s, 0.0, 1.0);
let (copy, [csq, ..]) = card(&mut s, 300.0, 1.0);
let (small, _) = card(&mut s, 500.0, 0.5);
start(&mut s, g, json!({}));
sel(&mut s, &[sq]);
let added = s
.edit("add", |d, _| {
let id = d.alloc_id();
let path = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 80.0, 10.0, 90.0));
d.insert(Some(g), usize::MAX, Node::path(id, path, vectorcraft_doc::Appearance::default_art()))
.map_err(|e| EngineError::Other(e.to_string()))
})
.unwrap();
let (theirs, smalls) = (kids(&s, copy), kids(&s, small));
assert_eq!((theirs.len(), smalls.len()), (4, 4));
assert_eq!(theirs.first(), Some(&csq), "the member keeps its own ids in order");
assert!(placed(&s, added, theirs[3], Affine::translate((300.0, 0.0))));
assert!(placed(&s, added, smalls[3], Affine::translate((500.0, 0.0)) * Affine::scale(0.5)));
}
#[test]
fn global_edit_takes_at_most_max_members() {
use crate::cmd::globaledit::MAX_MEMBERS;
let mut s = session();
let first = s
.edit("many", |d, _| {
let layer = d.default_layer();
let mut first = None;
for i in 0..MAX_MEMBERS + 5 {
let (x, y) = ((i % 70) as f64 * 10.0, (i / 70 % 50) as f64 * 10.0);
let id = d.alloc_id();
let path = vectorcraft_geom::shapes::rectangle(Rect::new(x, y, x + 8.0, y + 8.0));
d.insert(layer, usize::MAX, Node::path(id, path, vectorcraft_doc::Appearance::default_art()))
.map_err(|e| EngineError::Other(e.to_string()))?;
first.get_or_insert(id);
}
first.ok_or_else(|| EngineError::Other("none".into()))
})
.unwrap();
assert_eq!(start(&mut s, first, json!({})).len(), MAX_MEMBERS);
}
#[test]
fn groups_nested_as_deep_as_a_document_allows_are_edited_together() {
let mut s = session();
let nest = |s: &mut Session, x: f64| {
let inner = rect(s, x, 0.0, 10.0, 10.0);
let side = rect(s, x + 20.0, 0.0, 10.0, 10.0);
let mut top = group_of(s, &[inner, side]);
// Group until the document refuses to go deeper.
for _ in 0..2 * vectorcraft_doc::MAX_DEPTH {
sel(s, &[top]);
match s.execute("object.group", &json!({})) {
Ok(r) => top = NodeId(r["id"].as_u64().unwrap()),
Err(_) => break,
}
}
(top, inner)
};
let ((g, inner), (other, other_inner)) = (nest(&mut s, 0.0), nest(&mut s, 300.0));
assert_eq!(start(&mut s, g, json!({})), [g, other]);
sel(&mut s, &[inner]);
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
assert_eq!(fill(&s, other_inner), "#12ab34");
}