Files
vectorcraft/crates/engine/src/cmd/addons/arrange/tests.rs
T
bflatastic ca0785573d Add-ons: Arrange Tools — Arrange in Grid, Swap, Replace with Top Object, Set Size of Each, Zoom to Selection
A built-in add-on in Object › Plug-ins › Arrange Tools (#4830, #4831, #4832, #4833, #4834):

- addon.arrange.grid lays the top-level selected objects out in rows and
  columns, a row or a column (stacking order from the back, visual bounds,
  each object centred in its cell), from the selection's top-left or
  centred on the artboard.
- addon.arrange.swap swaps two objects' bounds centres, and with `size`
  their widths and heights.
- addon.arrange.replace puts a copy of the topmost selected object centred
  on each of the others, in its stacking place, kept, stretched or fitted
  in proportion; the replaced objects are deleted unless `keep`.
- addon.arrange.size gives each selected object an exact width and/or
  height about a reference point.
- addon.arrange.zoomSelection (a UI command) fits the active view to the
  selection.

Places and sizes are measured as object.setBounds measures them (its
transform, now also available per object), strokes scale as Scale Strokes
& Effects says, each command is one undo step, and Arrange in Grid,
Replace with Top Object and Set Size of Each open previewed dialogs.
Catalog rows, command sweep cases, docs/mcp.md, parity docs and the
ROADMAP progress log.
2026-10-31 22:22:15 +07:00

264 lines
12 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.
//! Arrange Tools' commands: the geometry each one leaves, one undo step each, when they are off,
//! and bad params refused without a change.
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_geom::Rect;
use crate::Session;
/// A new document whose new art has no stroke, so visual and geometric bounds agree.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).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 select(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
fn near(a: Rect, b: (f64, f64, f64, f64)) -> bool {
[(a.x0, b.0), (a.y0, b.1), (a.width(), b.2), (a.height(), b.3)].iter().all(|(x, y)| (x - y).abs() < 1e-6)
}
fn steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// Each of `bad` is refused and leaves the document as it was.
fn refuses(s: &mut Session, cmd: &str, bad: &[Value]) {
let (before, n) = (s.doc().unwrap().doc.clone(), steps(s));
for p in bad {
assert!(s.execute(cmd, p).is_err(), "{cmd} {p}");
}
assert_eq!(steps(s), n, "{cmd}");
assert!(*s.doc().unwrap().doc == *before, "{cmd} changed the document");
}
fn enabled(s: &Session, cmd: &str) -> bool {
s.commands().into_iter().find(|c| c.id == cmd).unwrap().enabled
}
#[test]
fn grid_lays_objects_out_in_rows_and_columns_from_the_back() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 20.0, 10.0);
let b = rect(&mut s, 300.0, 50.0, 40.0, 30.0);
let c = rect(&mut s, 10.0, 200.0, 10.0, 10.0);
let d = rect(&mut s, 50.0, 5.0, 30.0, 20.0);
select(&mut s, &[d, c, b, a]);
let n = steps(&s);
// Four objects: two columns. The grid starts at the selection's top-left (10, 5).
let r = s.execute("addon.arrange.grid", &json!({"gapX": 5, "gapY": 2})).unwrap();
assert_eq!(r["count"], json!(4));
assert_eq!(steps(&s), n + 1, "one undo step");
// Columns 20 and 40 wide (the widest in each), rows 30 and 20 tall; each object centred.
assert!(near(bounds(&s, a), (10.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (35.0, 5.0, 40.0, 30.0)), "{:?}", bounds(&s, b));
assert!(near(bounds(&s, c), (15.0, 42.0, 10.0, 10.0)), "{:?}", bounds(&s, c));
assert!(near(bounds(&s, d), (40.0, 37.0, 30.0, 20.0)), "{:?}", bounds(&s, d));
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(bounds(&s, a), (100.0, 100.0, 20.0, 10.0)));
}
#[test]
fn grid_lays_out_a_row_or_a_column_and_centres_on_the_artboard() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let b = rect(&mut s, 100.0, 100.0, 20.0, 20.0);
select(&mut s, &[a, b]);
s.execute("addon.arrange.grid", &json!({"layout": "row"})).unwrap();
assert!(near(bounds(&s, b), (30.0, 0.0, 20.0, 20.0)), "gapX defaults to 10");
s.execute("addon.arrange.grid", &json!({"layout": "column", "gapX": 4})).unwrap();
assert!(near(bounds(&s, b), (0.0, 24.0, 20.0, 20.0)), "gapY follows gapX");
// 20 × 44 centred on the 400 × 300 artboard.
s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap();
assert!(near(bounds(&s, a), (190.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, a));
// In place already: no step.
let n = steps(&s);
s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap();
assert_eq!(steps(&s), n);
// More columns than objects: one row.
s.execute("addon.arrange.grid", &json!({"columns": 50, "gapX": 0})).unwrap();
assert!(near(bounds(&s, b), (210.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, b));
}
#[test]
fn grid_measures_visual_bounds() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0);
select(&mut s, &[a, b]);
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
s.execute("addon.arrange.grid", &json!({"layout": "row", "gapX": 0})).unwrap();
// Visual boxes 24 wide, touching.
assert!(near(bounds(&s, b), (24.0, 0.0, 20.0, 20.0)), "{:?}", bounds(&s, b));
}
#[test]
fn swap_trades_places_and_optionally_sizes() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0);
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
let c = rect(&mut s, 200.0, 200.0, 5.0, 5.0);
select(&mut s, &[a]);
assert!(!enabled(&s, "addon.arrange.swap"));
select(&mut s, &[a, b, c]);
assert!(!enabled(&s, "addon.arrange.swap"));
refuses(&mut s, "addon.arrange.swap", &[json!({})]);
select(&mut s, &[a, b]);
assert!(enabled(&s, "addon.arrange.swap"));
let n = steps(&s);
s.execute("addon.arrange.swap", &json!({})).unwrap();
assert_eq!(steps(&s), n + 1);
// Centres (10, 5) and (120, 120) swap; sizes stay.
assert!(near(bounds(&s, a), (110.0, 115.0, 20.0, 10.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (-10.0, -15.0, 40.0, 40.0)), "{:?}", bounds(&s, b));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("addon.arrange.swap", &json!({"size": true})).unwrap();
assert!(near(bounds(&s, a), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (0.0, 0.0, 20.0, 10.0)), "{:?}", bounds(&s, b));
}
#[test]
fn swap_with_sizes_scales_strokes_as_asked() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0);
select(&mut s, &[a, b]);
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 2})).unwrap();
let weight = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_width();
s.execute("addon.arrange.swap", &json!({"size": true})).unwrap();
assert_eq!((weight(&s, a), weight(&s, b)), (2.0, 2.0), "off by default (the preference)");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("addon.arrange.swap", &json!({"size": true, "scaleStrokes": true})).unwrap();
assert!((weight(&s, a) - 4.0).abs() < 1e-9 && (weight(&s, b) - 1.0).abs() < 1e-9, "{} {}", weight(&s, a), weight(&s, b));
}
#[test]
fn replace_puts_a_copy_of_the_top_object_in_each_place() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0);
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0);
select(&mut s, &[top]);
assert!(!enabled(&s, "addon.arrange.replace"));
refuses(&mut s, "addon.arrange.replace", &[json!({})]);
select(&mut s, &[a, b, top]);
let n = steps(&s);
let r = s.execute("addon.arrange.replace", &json!({})).unwrap();
assert_eq!(steps(&s), n + 1);
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(ids.len(), 2);
let st = s.doc().unwrap();
assert!(st.doc.node(a).is_none() && st.doc.node(b).is_none(), "replaced objects deleted");
assert!(st.doc.node(top).is_some(), "the top object stays");
assert_eq!(st.selection.objects, ids, "the copies are selected");
// Kept size, centred on (10, 20) and (120, 120); each in its object's place (bottom, then middle).
assert!(near(bounds(&s, ids[0]), (5.0, 17.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[0]));
assert!(near(bounds(&s, ids[1]), (115.0, 117.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[1]));
let layer = &s.doc().unwrap().doc.layers[0];
let order: Vec<NodeId> = layer.children().unwrap().iter().map(|n| n.id).collect();
assert_eq!(order, [ids[0], ids[1], top]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(a).is_some());
}
#[test]
fn replace_fits_the_copies_and_can_keep_the_originals() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0);
let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0);
select(&mut s, &[a, top]);
let r = s.execute("addon.arrange.replace", &json!({"fit": "bounds", "keep": true})).unwrap();
let copy = NodeId(r["ids"][0].as_u64().unwrap());
assert!(near(bounds(&s, copy), (0.0, 0.0, 20.0, 40.0)), "{:?}", bounds(&s, copy));
let order: Vec<NodeId> = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
assert_eq!(order, [a, copy, top], "kept: the copy goes right above it");
s.execute("edit.undo", &json!({})).unwrap();
select(&mut s, &[a, top]);
let r = s.execute("addon.arrange.replace", &json!({"fit": "proportional"})).unwrap();
let copy = NodeId(r["ids"][0].as_u64().unwrap());
// 10 × 5 into 20 × 40: twice as large, centred on (10, 20).
assert!(near(bounds(&s, copy), (0.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, copy));
}
#[test]
fn size_gives_each_object_a_width_and_height() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0);
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
select(&mut s, &[a, b]);
let n = steps(&s);
s.execute("addon.arrange.size", &json!({"width": 30})).unwrap();
assert_eq!(steps(&s), n + 1);
// About each centre; the height stays.
assert!(near(bounds(&s, a), (-5.0, 0.0, 30.0, 10.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (105.0, 100.0, 30.0, 40.0)), "{:?}", bounds(&s, b));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("addon.arrange.size", &json!({"width": 40, "proportional": true, "reference": 0})).unwrap();
assert!(near(bounds(&s, a), (0.0, 0.0, 40.0, 20.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, b));
s.execute("addon.arrange.size", &json!({"width": 10, "height": 12, "proportional": true, "reference": 8})).unwrap();
assert!(near(bounds(&s, a), (30.0, 8.0, 10.0, 12.0)), "both given: both set, {:?}", bounds(&s, a));
}
#[test]
fn bad_params_are_refused() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0);
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
select(&mut s, &[a, b]);
refuses(
&mut s,
"addon.arrange.grid",
&[
json!({"layout": "spiral"}),
json!({"columns": 0}),
json!({"columns": 1.5}),
json!({"columns": 1e12}),
json!({"gapX": 1e308}),
json!({"gapY": "10"}),
json!({"centerOnArtboard": "yes"}),
],
);
refuses(&mut s, "addon.arrange.swap", &[json!({"size": 1}), json!({"scaleStrokes": "no"})]);
refuses(&mut s, "addon.arrange.replace", &[json!({"fit": "stretch"}), json!({"keep": 0})]);
refuses(
&mut s,
"addon.arrange.size",
&[
json!({}),
json!({"width": 0}),
json!({"height": -5}),
json!({"width": 1e308}),
json!({"width": "10"}),
json!({"width": 10, "reference": 9}),
json!({"width": 10, "proportional": "yes"}),
],
);
}
#[test]
fn off_without_a_selection() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 20.0, 10.0);
s.execute("select.none", &json!({})).unwrap();
for c in ["addon.arrange.grid", "addon.arrange.swap", "addon.arrange.replace", "addon.arrange.size"] {
assert!(!enabled(&s, c), "{c}");
}
assert!(!enabled(&Session::new(), "addon.arrange.swap"));
}