Files
vectorcraft/crates/engine/src/tests_menucmds.rs
T
bflatasticandClaude Opus 5.5 2f710d3acc Fix #414: select, move and delete ruler guides with the selection tools
Guides dragged out of the rulers could not be touched again: no tool hit-tested
them, so a click on a guide went to the art under it.

- The Selection, Direct Selection and Group Selection tools pick a ruler guide
  within the selection tolerance (over the art; Direct Selection takes an
  anchor on it first) through a shared tools::rulerguide::GuideEdit. A drag
  moves the selected guides in one undo step (Alt copies them, Shift snaps the
  dragged one to the ruler's ticks, otherwise it snaps to pixels, the grid or,
  with Smart Guides, the art's edges, centres and anchors); dropped off the
  canvas onto its ruler, the guide is deleted. Hidden or locked guides can't be
  picked; the pointer shows which way a guide moves.
- Selection::guides holds the selected guides (on their own, like slices); the
  canvas draws them in the selection colour. Delete/Backspace (edit.clear)
  delete them, the arrow keys (object.nudge) nudge them, Lock Guides and Hide
  Guides deselect them.
- Commands for agents: guide.list, guide.select {indexes, toggle?},
  guide.move {dx?, dy?, copy?} (besides {index, pos}) and guide.remove {index?};
  guide positions out of range are refused. Documented in docs/mcp.md.
- Shift while dragging a new guide out of a ruler snaps it to the ticks too
  (Unit::ruler_step, shared with the rulers' drawing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:55:51 +07:00

902 lines
38 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.
//! Menu long-tail commands (Object/Edit/Select/Type/View/File), driven through `Session::execute`.
use serde_json::{Value, json};
use vectorcraft_color::Color;
use vectorcraft_doc::{ColorMode, LiveShape, Node, NodeKind, TextKind};
use vectorcraft_geom::Rect;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
}
fn text(s: &mut Session, t: &str) -> NodeId {
id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": t})).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 fill(s: &mut Session, id: NodeId, hex: &str) {
s.execute("paint.setFill", &json!({"ids": [id.0], "color": hex})).unwrap();
}
fn fill_of(s: &Session, id: NodeId) -> Color {
node(s, id).appearance.fill_paint().color().unwrap()
}
fn plain(s: &Session, id: NodeId) -> String {
match &node(s, id).kind {
NodeKind::Text(t) => t.plain_text(),
_ => panic!("not text"),
}
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn selected(s: &Session) -> Vec<NodeId> {
s.doc().unwrap().selection.objects.clone()
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
// ---------- Object → Lock / Hide ----------
#[test]
fn lock_all_artwork_above_only_overlapping_objects_above() {
let mut s = session();
let below = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
let over = rect(&mut s, 30.0, 30.0, 50.0, 50.0);
let far = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
sel(&mut s, &[a]);
let n = undo_len(&s);
let r = s.execute("object.lock.above", &json!({})).unwrap();
assert_eq!(r["count"], 1);
assert!(node(&s, over).locked);
assert!(!node(&s, below).locked && !node(&s, far).locked && !node(&s, a).locked);
assert_eq!(undo_len(&s), n + 1);
}
#[test]
fn hide_all_artwork_above() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
let over = rect(&mut s, 30.0, 30.0, 50.0, 50.0);
sel(&mut s, &[a]);
s.execute("object.hide.above", &json!({})).unwrap();
assert!(!node(&s, over).visible);
s.execute("edit.undo", &json!({})).unwrap();
assert!(node(&s, over).visible);
}
#[test]
fn lock_and_hide_other_layers() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let l2 = id_of(&s.execute("layer.new", &json!({})).unwrap_or(json!({"id": 0})));
let l1 = s.doc().unwrap().doc.layers[0].id;
assert_ne!(l1, l2);
sel(&mut s, &[a]);
s.execute("object.lock.otherLayers", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(d.node(l2).unwrap().locked);
assert!(!d.node(l1).unwrap().locked);
s.execute("object.hide.otherLayers", &json!({})).unwrap();
assert!(!s.doc().unwrap().doc.node(l2).unwrap().visible);
}
// ---------- Transform Each ----------
#[test]
fn transform_each_scales_each_about_its_own_center() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0);
sel(&mut s, &[a, b]);
let n = undo_len(&s);
s.execute("object.transformEach", &json!({"scaleH": 50, "scaleV": 50})).unwrap();
assert_eq!(bounds(&s, a), Rect::new(25.0, 25.0, 75.0, 75.0));
assert_eq!(bounds(&s, b), Rect::new(225.0, 25.0, 275.0, 75.0));
assert_eq!(undo_len(&s), n + 1);
}
#[test]
fn transform_each_move_reference_and_copy() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
sel(&mut s, &[a]);
// Top-left reference: scaling keeps the top-left corner.
let r = s.execute("object.transformEach", &json!({"scaleH": 50, "scaleV": 50, "moveH": 10, "moveV": 5, "reference": 0, "copy": true})).unwrap();
let copy = NodeId(r["ids"][0].as_u64().unwrap());
assert_ne!(copy, a);
assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0));
assert_eq!(bounds(&s, copy), Rect::new(10.0, 5.0, 60.0, 55.0));
}
#[test]
fn transform_each_random_is_deterministic_with_seed() {
let run = || {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
sel(&mut s, &[a]);
s.execute("object.transformEach", &json!({"moveH": 50, "random": true, "seed": 7})).unwrap();
bounds(&s, a)
};
let (x, y) = (run(), run());
assert_eq!(x, y);
assert!(x.x0.abs() <= 50.0 && x.width() == 100.0);
}
#[test]
fn reset_bounding_box_is_a_noop() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[a]);
let n = undo_len(&s);
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 0);
assert_eq!(undo_len(&s), n);
}
// ---------- Expand / Rasterize / Crop / Trim marks ----------
#[test]
fn expand_live_shape_and_text_in_one_undo_step() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let t = text(&mut s, "Hi");
sel(&mut s, &[a, t]);
let n = undo_len(&s);
// Stroke off: the rectangle stays one path (its stroke isn't outlined).
s.execute("object.expand", &json!({"stroke": false})).unwrap();
assert!(matches!(node(&s, a).kind, NodeKind::Path { live: None, .. }));
assert!(s.doc().unwrap().doc.node(t).is_none(), "text became outlines");
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Expand");
s.execute("edit.undo", &json!({})).unwrap();
assert!(matches!(node(&s, a).kind, NodeKind::Path { live: Some(_), .. }));
assert!(s.doc().unwrap().doc.node(t).is_some());
}
#[test]
fn expand_nothing_errors() {
let mut s = session();
let p = id_of(&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 10, "y": 10}]})).unwrap());
sel(&mut s, &[p]);
// A plain path: nothing to expand for object/fill.
let r = s.execute("object.expand", &json!({"stroke": false}));
assert!(r.is_err());
}
#[test]
fn rasterize_replaces_selection_with_image() {
let mut s = session();
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
fill(&mut s, a, "#ff0000");
sel(&mut s, &[a]);
let vb = s.doc().unwrap().doc.bounds_of(&[a], true).unwrap();
let r = s.execute("object.rasterize", &json!({"ppi": 144, "padding": 0})).unwrap();
let img = id_of(&r);
assert!(s.doc().unwrap().doc.node(a).is_none());
let n = node(&s, img);
let NodeKind::Image(im) = &n.kind else { panic!() };
// Visual bounds (stroke included) at 2 px/pt.
assert_eq!((im.width as f64, im.height as f64), ((vb.width() * 2.0).ceil(), (vb.height() * 2.0).ceil()));
assert!(s.doc().unwrap().doc.images.contains_key(&im.key));
let b = n.geometric_bounds().unwrap();
assert!(close(b.x0, vb.x0) && close(b.y0, vb.y0) && b.width() >= vb.width());
assert_eq!(selected(&s), vec![img]);
}
#[test]
fn crop_image_to_rect() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
sel(&mut s, &[a]);
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
let r = s.execute("object.cropImage", &json!({"rect": [10, 10, 50, 40]})).unwrap();
assert_eq!((r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap()), (50, 40));
let b = bounds(&s, img);
assert!(close(b.x0, 10.0) && close(b.y0, 10.0) && close(b.width(), 50.0) && close(b.height(), 40.0), "{b:?}");
// Image fully inside the artboard: default crop has nothing to do.
assert!(s.execute("object.cropImage", &json!({})).is_err());
}
#[test]
fn crop_image_disabled_without_image() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[a]);
assert!(matches!(s.execute("object.cropImage", &json!({})), Err(EngineError::Disabled(..))));
}
#[test]
fn trim_marks_around_selection() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
fill(&mut s, a, "#00ff00");
s.execute("stroke.set", &json!({"ids": [a.0], "weight": 0})).unwrap();
sel(&mut s, &[a]);
let g = id_of(&s.execute("object.createTrimMarks", &json!({"offset": 10, "length": 20})).unwrap());
let n = node(&s, g);
assert_eq!(n.children().unwrap().len(), 8);
assert_eq!(n.name.as_deref(), Some("Trim Marks"));
let b = n.geometric_bounds().unwrap();
let vb = s.doc().unwrap().doc.bounds_of(&[a], true).unwrap();
assert!(close(b.x0, vb.x0 - 30.0) && close(b.y1, vb.y1 + 30.0), "{b:?} {vb:?}");
}
// ---------- Convert to Shape ----------
#[test]
fn convert_rectangle_path_to_live_shape() {
let mut s = session();
let p = id_of(
&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 40, "y": 0}, {"x": 40, "y": 20}, {"x": 0, "y": 20}], "closed": true}))
.unwrap(),
);
sel(&mut s, &[p]);
assert_eq!(s.execute("object.shape.convertToShape", &json!({})).unwrap()["converted"], 1);
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, .. }), path, .. } = &node(&s, p).kind else { panic!() };
assert!(close(*w, 40.0) && close(*h, 20.0));
assert_eq!(path.bounds(), Some(Rect::new(0.0, 0.0, 40.0, 20.0)));
}
#[test]
fn convert_rotated_rectangle_and_ellipse() {
let mut s = session();
let p = id_of(
&s.execute(
"path.create",
&json!({"anchors": [{"x": 50, "y": 0}, {"x": 100, "y": 50}, {"x": 50, "y": 100}, {"x": 0, "y": 50}], "closed": true}),
)
.unwrap(),
);
let e = id_of(&s.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 80, "height": 40})).unwrap());
sel(&mut s, &[e]);
s.execute("object.expandShape", &json!({})).unwrap();
sel(&mut s, &[p, e]);
assert_eq!(s.execute("object.shape.convertToShape", &json!({})).unwrap()["converted"], 2);
let b = bounds(&s, p);
assert!(close(b.x0, 0.0) && close(b.x1, 100.0) && close(b.y1, 100.0), "{b:?}");
assert!(matches!(node(&s, e).kind, NodeKind::Path { live: Some(LiveShape::Ellipse { .. }), .. }));
}
#[test]
fn convert_to_shape_rejects_other_paths() {
let mut s = session();
let p = id_of(&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 40, "y": 0}, {"x": 10, "y": 20}], "closed": true})).unwrap());
sel(&mut s, &[p]);
assert!(s.execute("object.shape.convertToShape", &json!({})).is_err());
}
// ---------- Blend ----------
#[test]
fn blend_make_release_options() {
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, 30.0, 30.0);
fill(&mut s, a, "#000000");
fill(&mut s, b, "#ffffff");
sel(&mut s, &[a, b]);
let n = undo_len(&s);
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap());
assert_eq!(undo_len(&s), n + 1);
let gn = node(&s, g);
assert!(matches!(gn.kind, NodeKind::Blend { .. }), "live blend");
let ch = vectorcraft_doc::live::expand_live(&gn);
assert_eq!(ch.len(), 5);
// Middle step: halfway in position, size and colour.
let mid = &ch[2];
let mb = mid.geometric_bounds().unwrap();
assert!(close(mb.x0, 50.0) && close(mb.width(), 20.0), "{mb:?}");
let c = mid.appearance.fill_paint().color().unwrap().to_rgb();
assert!((c[0] - 0.5).abs() < 1e-3);
// Options: 1 step.
s.execute("object.blend.options", &json!({"steps": 1})).unwrap();
assert_eq!(vectorcraft_doc::live::expand_live(&node(&s, g)).len(), 3);
// Release: keys only, back in the layer.
let r = s.execute("object.blend.release", &json!({})).unwrap();
assert_eq!(r["ids"].as_array().unwrap().len(), 2);
assert!(s.doc().unwrap().doc.node(g).is_none());
assert!(s.doc().unwrap().doc.node(a).is_some() && s.doc().unwrap().doc.node(b).is_some());
}
#[test]
fn blend_expand_and_reverse() {
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, 10.0, 10.0);
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 2})).unwrap());
s.execute("object.blend.reverseFrontToBack", &json!({})).unwrap();
assert_eq!(node(&s, g).children().unwrap().last().unwrap().id, a);
s.execute("object.blend.reverseSpine", &json!({})).unwrap();
assert!(close(bounds(&s, a).x0, 100.0));
s.execute("object.blend.expand", &json!({})).unwrap();
assert!(matches!(node(&s, g).kind, NodeKind::Group { .. }));
assert!(s.execute("object.blend.release", &json!({})).is_err(), "no longer a blend");
}
// ---------- Artboards ----------
#[test]
fn convert_to_artboards_and_rearrange() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 200.0, 100.0);
sel(&mut s, &[a]);
assert_eq!(s.execute("artboard.convertToArtboards", &json!({})).unwrap()["artboards"], 1);
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards.len(), 2);
assert_eq!(d.artboards[1].rect, Rect::new(900.0, 0.0, 1100.0, 100.0));
assert!(d.node(a).is_none());
// Art on artboard 2 moves with it.
let b = rect(&mut s, 950.0, 25.0, 10.0, 10.0);
s.execute("artboard.rearrange", &json!({"columns": 1, "spacing": 50})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards[1].rect, Rect::new(0.0, 650.0, 200.0, 750.0));
assert_eq!(bounds(&s, b), Rect::new(50.0, 675.0, 60.0, 685.0));
}
// ---------- Edit Colors ----------
#[test]
fn invert_and_convert_colors() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
fill(&mut s, a, "#ff0000");
sel(&mut s, &[a]);
s.execute("edit.colors.invert", &json!({"stroke": false})).unwrap();
assert_eq!(fill_of(&s, a).to_hex(), "#00ffff");
s.execute("edit.colors.toCMYK", &json!({})).unwrap();
assert!(matches!(fill_of(&s, a), Color::Cmyk { .. }));
s.execute("edit.colors.toGrayscale", &json!({})).unwrap();
assert!(matches!(fill_of(&s, a), Color::Gray { .. }));
s.execute("edit.colors.toRGB", &json!({})).unwrap();
assert!(matches!(fill_of(&s, a), Color::Rgb { .. }));
}
#[test]
fn saturate_and_adjust_balance() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("paint.setFill", &json!({"ids": [a.0], "color": [0.8, 0.4, 0.4]})).unwrap();
sel(&mut s, &[a]);
s.execute("edit.colors.saturate", &json!({"intensity": -100})).unwrap();
let [_, sat, _] = fill_of(&s, a).to_hsb();
assert!(sat < 1e-4);
s.execute("edit.colors.adjustBalance", &json!({"r": 10, "g": -10})).unwrap();
let [r, g, _] = fill_of(&s, a).to_rgb();
assert!((r - 0.9).abs() < 1e-3 && (g - 0.7).abs() < 1e-3, "{r} {g}");
assert!(s.execute("edit.colors.adjustBalance", &json!({})).is_err());
}
#[test]
fn blend_colors_horizontally() {
let mut s = session();
let ids: Vec<NodeId> = (0..3).map(|i| rect(&mut s, 200.0 - i as f64 * 100.0, 0.0, 10.0, 10.0)).collect();
fill(&mut s, ids[2], "#000000"); // leftmost
fill(&mut s, ids[0], "#ffffff"); // rightmost
fill(&mut s, ids[1], "#ff0000");
sel(&mut s, &ids);
s.execute("edit.colors.blendHorizontally", &json!({})).unwrap();
assert_eq!(fill_of(&s, ids[1]).to_hex(), "#808080");
sel(&mut s, &ids[..2]);
assert!(s.execute("edit.colors.blendVertically", &json!({})).is_err(), "needs three");
}
#[test]
fn blend_front_to_back_uses_stacking() {
let mut s = session();
let ids: Vec<NodeId> = (0..3).map(|_| rect(&mut s, 0.0, 0.0, 10.0, 10.0)).collect();
fill(&mut s, ids[0], "#000000");
fill(&mut s, ids[2], "#ffffff");
sel(&mut s, &ids);
s.execute("edit.colors.blendFrontToBack", &json!({})).unwrap();
assert_eq!(fill_of(&s, ids[1]).to_hex(), "#808080");
}
// ---------- Paste without formatting / Find ----------
#[test]
fn paste_without_formatting_resets_text_style() {
let mut s = session();
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Bold", "size": 40})).unwrap());
sel(&mut s, &[t]);
s.execute("edit.copy", &json!({})).unwrap();
let n = undo_len(&s);
let r = s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap();
let p = NodeId(r["ids"][0].as_u64().unwrap());
let NodeKind::Text(tt) = &node(&s, p).kind else { panic!() };
assert_eq!(tt.plain_text(), "Bold");
assert_eq!(tt.first_style().size, 12.0);
assert_eq!(undo_len(&s), n + 1);
// Clipboard keeps its formatting.
let NodeKind::Text(ct) = &s.clipboard.nodes[0].kind else { panic!() };
assert_eq!(ct.first_style().size, 40.0);
}
#[test]
fn find_and_replace_options() {
let mut s = session();
let a = text(&mut s, "Cat cat catalog");
let b = text(&mut s, "the CAT");
let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "dog", "wholeWord": true})).unwrap();
assert_eq!(r["count"], 3);
assert_eq!(plain(&s, a), "dog dog catalog");
assert_eq!(plain(&s, b), "the dog");
let r = s.execute("edit.findReplace", &json!({"find": "Dog", "replace": "x", "matchCase": true})).unwrap();
assert_eq!(r["count"], 0);
assert!(s.execute("edit.findReplace", &json!({"find": ""})).is_err());
}
#[test]
fn find_next_cycles_through_text_objects() {
let mut s = session();
let a = text(&mut s, "apple");
let _ = text(&mut s, "banana");
let c = text(&mut s, "pineapple");
s.execute("select.none", &json!({})).unwrap();
assert_eq!(id_of(&s.execute("edit.findNext", &json!({"find": "APPLE"})).unwrap()), a);
assert_eq!(id_of(&s.execute("edit.findNext", &json!({"find": "apple"})).unwrap()), c);
assert_eq!(id_of(&s.execute("edit.findNext", &json!({"find": "apple"})).unwrap()), a);
assert!(s.execute("edit.findNext", &json!({"find": "zzz"})).unwrap()["id"].is_null());
}
// ---------- Select ----------
#[test]
fn select_same_font_size_and_fill() {
let mut s = session();
let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 20, "text": "a", "size": 20})).unwrap());
let b = id_of(&s.execute("text.create", &json!({"x": 0, "y": 60, "text": "b", "size": 20})).unwrap());
let c = id_of(&s.execute("text.create", &json!({"x": 0, "y": 90, "text": "c", "size": 30, "font": "Inter"})).unwrap());
sel(&mut s, &[a]);
assert_eq!(s.execute("select.same.fontSize", &json!({})).unwrap()["count"], 2);
assert_eq!(selected(&s), vec![a, b]);
sel(&mut s, &[c]);
assert_eq!(s.execute("select.same.fontFamily", &json!({})).unwrap()["count"], 1);
sel(&mut s, &[a]);
assert_eq!(s.execute("select.same.textFillColor", &json!({})).unwrap()["count"], 3);
assert_eq!(s.execute("select.same.fontFamilyStyle", &json!({})).unwrap()["count"], 2);
let r = rect(&mut s, 0.0, 0.0, 5.0, 5.0);
sel(&mut s, &[r]);
assert!(s.execute("select.same.fontSize", &json!({})).is_err());
}
#[test]
fn select_direction_handles_selects_all_anchors() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[a]);
assert_eq!(s.execute("select.object.directionHandles", &json!({})).unwrap()["anchors"], 4);
assert_eq!(s.doc().unwrap().selection.anchors.get(&a).unwrap().len(), 4);
}
#[test]
fn select_point_and_area_text() {
let mut s = session();
let p = text(&mut s, "point");
let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 0, "text": "area", "area": {"width": 100, "height": 50}})).unwrap());
let _ = rect(&mut s, 0.0, 0.0, 5.0, 5.0);
assert_eq!(s.execute("select.object.pointText", &json!({})).unwrap()["count"], 1);
assert_eq!(selected(&s), vec![p]);
s.execute("select.object.areaText", &json!({})).unwrap();
assert_eq!(selected(&s), vec![a]);
assert_eq!(s.execute("select.object.brushStrokes", &json!({})).unwrap()["count"], 0);
}
#[test]
fn save_and_recall_selection() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
sel(&mut s, &[a, b]);
assert_eq!(s.execute("select.save", &json!({})).unwrap()["name"], "Selection 1");
s.execute("select.none", &json!({})).unwrap();
assert_eq!(s.execute("select.recall", &json!({"name": "Selection 1"})).unwrap()["count"], 2);
s.execute("select.editSaved", &json!({"name": "Selection 1", "newName": "Pair"})).unwrap();
assert_eq!(s.execute("select.savedList", &json!({})).unwrap(), json!(["Pair"]));
s.execute("select.editSaved", &json!({"name": "Pair", "delete": true})).unwrap();
assert!(s.execute("select.recall", &json!({"name": "Pair"})).is_err());
}
#[test]
fn edit_selection_needs_a_saved_selection_and_a_free_name() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[a]);
// Edit Selection… is off until something is saved.
assert!(s.execute("select.editSaved", &json!({"name": "Selection 1", "newName": "A"})).is_err());
s.execute("select.save", &json!({"name": "One"})).unwrap();
s.execute("select.save", &json!({"name": "Two"})).unwrap();
// A name already saved can't be taken; both selections keep theirs.
assert!(s.execute("select.editSaved", &json!({"name": "One", "newName": " Two "})).is_err());
assert_eq!(s.execute("select.savedList", &json!({})).unwrap(), json!(["One", "Two"]));
// Renaming to its own name, or a blank name, changes nothing; a free name renames.
s.execute("select.editSaved", &json!({"name": "One", "newName": "One"})).unwrap();
s.execute("select.editSaved", &json!({"name": "One", "newName": " "})).unwrap();
s.execute("select.editSaved", &json!({"name": "One", "newName": " Uno "})).unwrap();
assert_eq!(s.execute("select.savedList", &json!({})).unwrap(), json!(["Uno", "Two"]));
}
#[test]
fn saved_selections_live_in_the_document() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
let list = |s: &mut Session| s.execute("select.savedList", &json!({})).unwrap();
sel(&mut s, &[a]);
s.execute("select.save", &json!({"name": "A"})).unwrap();
sel(&mut s, &[a, b]);
s.execute("select.save", &json!({"name": "Both"})).unwrap();
// They travel with the file.
let loaded = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap();
assert_eq!(loaded.saved_selections.iter().map(|x| x.name.as_str()).collect::<Vec<_>>(), ["A", "Both"]);
// Saving is an undo step.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(list(&mut s), json!(["A"]));
// An existing name is replaced by the current selection.
sel(&mut s, &[b]);
s.execute("select.save", &json!({"name": "A"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
assert_eq!(s.execute("select.recall", &json!({"name": "A"})).unwrap()["count"], 1);
assert_eq!(selected(&s), vec![b]);
// Objects deleted since are left out of a recall.
sel(&mut s, &[a, b]);
s.execute("select.save", &json!({"name": "Pair"})).unwrap();
sel(&mut s, &[a]);
s.execute("edit.clear", &json!({})).unwrap();
assert_eq!(s.execute("select.recall", &json!({"name": "Pair"})).unwrap()["count"], 1);
}
#[test]
fn edit_selection_applies_several_edits_as_one_step() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[a]);
for n in ["A", "B", "C"] {
s.execute("select.save", &json!({ "name": n })).unwrap();
}
let list = |s: &mut Session| s.execute("select.savedList", &json!({})).unwrap();
// Edits name the selections as they are, so two can swap names; a delete goes with them.
s.execute("select.editSaved", &json!({"edits": [{"name": "A", "newName": "B"}, {"name": "B", "newName": "A"}, {"name": "C", "delete": true}]}))
.unwrap();
assert_eq!(list(&mut s), json!(["B", "A"]));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(list(&mut s), json!(["A", "B", "C"]));
// Two ending with one name, or an unknown name, change nothing.
assert!(s.execute("select.editSaved", &json!({"edits": [{"name": "A", "newName": "B"}]})).is_err());
assert!(s.execute("select.editSaved", &json!({"edits": [{"name": "Z", "delete": true}]})).is_err());
assert_eq!(list(&mut s), json!(["A", "B", "C"]));
}
#[test]
fn a_document_keeps_at_most_25_saved_selections() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[a]);
for i in 0..25 {
s.execute("select.save", &json!({ "name": format!("S{i}") })).unwrap();
}
assert!(s.execute("select.save", &json!({"name": "One too many"})).is_err());
// A name already saved is replaced even when the list is full.
s.execute("select.save", &json!({"name": "S3"})).unwrap();
// Unnamed saves take the first free "Selection N".
s.execute("select.editSaved", &json!({"name": "S0", "delete": true})).unwrap();
assert_eq!(s.execute("select.save", &json!({})).unwrap()["name"], "Selection 1");
}
// ---------- Type ----------
#[test]
fn change_case_variants() {
let mut s = session();
let t = text(&mut s, "hello wORLD. again here");
sel(&mut s, &[t]);
s.execute("type.changeCase", &json!({"case": "upper"})).unwrap();
assert_eq!(plain(&s, t), "HELLO WORLD. AGAIN HERE");
s.execute("type.changeCase", &json!({"case": "lower"})).unwrap();
assert_eq!(plain(&s, t), "hello world. again here");
s.execute("type.changeCase", &json!({"case": "title"})).unwrap();
assert_eq!(plain(&s, t), "Hello World. Again Here");
s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap();
assert_eq!(plain(&s, t), "Hello world. Again here");
assert!(s.execute("type.changeCase", &json!({"case": "weird"})).is_err());
}
#[test]
fn smart_punctuation_quotes_dashes_ellipsis() {
let mut s = session();
let t = text(&mut s, "\"Hi\" it's 1--2 and 3---4...");
sel(&mut s, &[t]);
assert_eq!(s.execute("type.smartPunctuation", &json!({})).unwrap()["changed"], 1);
assert_eq!(plain(&s, t), "“Hi” it’s 1–2 and 3—4…");
}
#[test]
fn convert_point_area_roundtrip() {
let mut s = session();
let t = text(&mut s, "Some words");
sel(&mut s, &[t]);
s.execute("type.convertToAreaType", &json!({})).unwrap();
let NodeKind::Text(tt) = &node(&s, t).kind else { panic!() };
assert!(matches!(tt.kind, TextKind::Area { .. }));
assert_eq!(tt.plain_text(), "Some words");
s.execute("type.convertToPointType", &json!({})).unwrap();
let NodeKind::Text(tt) = &node(&s, t).kind else { panic!() };
assert!(matches!(tt.kind, TextKind::Point));
assert_eq!(tt.plain_text(), "Some words");
}
#[test]
fn area_to_point_turns_wraps_into_breaks() {
let mut s = session();
let a = id_of(
&s.execute("text.create", &json!({"x": 0, "y": 0, "text": "one two three four five six", "area": {"width": 40, "height": 200}})).unwrap(),
);
sel(&mut s, &[a]);
s.execute("type.convertToPointType", &json!({})).unwrap();
let txt = plain(&s, a);
assert!(txt.contains('\n'), "{txt:?}");
assert_eq!(txt.replace('\n', " "), "one two three four five six");
}
#[test]
fn placeholder_and_insert_characters() {
let mut s = session();
let t = text(&mut s, "x");
sel(&mut s, &[t]);
s.execute("type.fillPlaceholder", &json!({})).unwrap();
assert!(plain(&s, t).len() > 10);
s.execute("text.setText", &json!({"text": "A"})).unwrap();
s.execute("type.insert", &json!({"char": "emDash"})).unwrap();
s.execute("type.insert", &json!({"char": "copyright"})).unwrap();
s.execute("type.insert", &json!({"text": "z"})).unwrap();
assert_eq!(plain(&s, t), "A—©z");
assert!(s.execute("type.insert", &json!({"char": "nope"})).is_err());
}
#[test]
fn area_placeholder_fills_frame() {
let mut s = session();
let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 0, "text": "", "area": {"width": 300, "height": 300}})).unwrap());
sel(&mut s, &[a]);
s.execute("type.fillPlaceholder", &json!({})).unwrap();
assert!(plain(&s, a).len() > 400);
}
#[test]
fn font_and_size_menu_items_use_set_style() {
let mut s = session();
let t = text(&mut s, "x");
sel(&mut s, &[t]);
s.execute("text.setStyle", &json!({"size": 36})).unwrap();
s.execute("text.setStyle", &json!({"font": "Inter"})).unwrap();
let NodeKind::Text(tt) = &node(&s, t).kind else { panic!() };
assert_eq!(tt.first_style().size, 36.0);
assert_eq!(tt.first_style().font_family, "Inter");
}
// ---------- View → Guides ----------
#[test]
fn make_release_clear_guides() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[a]);
assert_eq!(s.execute("view.guides.make", &json!({})).unwrap()["count"], 1);
assert!(matches!(node(&s, a).kind, NodeKind::Path { guide: true, .. }));
assert!(selected(&s).is_empty());
assert_eq!(s.execute("view.guides.release", &json!({})).unwrap()["count"], 1);
assert!(matches!(node(&s, a).kind, NodeKind::Path { guide: false, .. }));
sel(&mut s, &[a]);
s.execute("view.guides.make", &json!({})).unwrap();
s.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap();
let n = undo_len(&s);
assert_eq!(s.execute("view.guides.clear", &json!({})).unwrap()["count"], 2);
assert!(s.doc().unwrap().doc.guides.is_empty());
assert!(s.doc().unwrap().doc.node(a).is_none());
assert_eq!(undo_len(&s), n + 1);
}
#[test]
fn ruler_guides_and_lock() {
let mut s = session();
assert_eq!(s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap()["index"], 0);
s.execute("guide.move", &json!({"index": 0, "pos": 70})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides[0].pos, 70.0);
assert_eq!(s.execute("view.guides.lock", &json!({})).unwrap()["locked"], true);
assert!(s.guides_locked());
assert!(matches!(s.execute("guide.remove", &json!({"index": 0})), Err(EngineError::Disabled(..))));
s.execute("view.guides.lock", &json!({})).unwrap();
s.execute("guide.remove", &json!({"index": 0})).unwrap();
assert!(s.doc().unwrap().doc.guides.is_empty());
assert!(s.execute("guide.remove", &json!({"index": 0})).is_err());
}
fn guides(s: &Session) -> Vec<(bool, f64)> {
s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect()
}
fn selected_guides(s: &Session) -> Vec<usize> {
s.doc().unwrap().selection.guides.clone()
}
/// #414: ruler guides are selected, moved, copied, nudged and deleted like art, over commands.
#[test]
fn ruler_guides_select_move_and_delete() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
for (vertical, pos) in [(true, 100.0), (false, 50.0), (true, 300.0)] {
s.execute("guide.add", &json!({"vertical": vertical, "pos": pos})).unwrap();
}
sel(&mut s, &[a]);
// Selecting guides deselects the art; toggle adds and removes.
assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([0]));
assert!(selected(&s).is_empty());
assert_eq!(s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap()["selected"], json!([0, 1]));
assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1], json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true}));
assert!(s.execute("guide.select", &json!({"indexes": [7]})).is_err());
// A move takes each selected guide along its own axis, in one undo step.
let n = undo_len(&s);
s.execute("guide.move", &json!({"dx": 10, "dy": -5})).unwrap();
assert_eq!(guides(&s), [(true, 110.0), (false, 45.0), (true, 300.0)]);
assert_eq!(undo_len(&s), n + 1);
// Alt: copies, which are then the selection.
s.execute("guide.move", &json!({"dx": 20, "copy": true})).unwrap();
assert_eq!(guides(&s).len(), 5);
assert_eq!(&guides(&s)[3..], [(true, 130.0), (false, 45.0)]);
assert_eq!(selected_guides(&s), [3, 4]);
// The arrow keys nudge them.
s.execute("object.nudge", &json!({"dx": 1, "dy": 0, "big": true})).unwrap();
assert_eq!(guides(&s)[3], (true, 140.0));
// Delete (edit.clear) deletes the selected guides; the others keep their places.
s.execute("guide.select", &json!({"indexes": [1, 3]})).unwrap();
assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 5);
s.execute("edit.clear", &json!({})).unwrap();
assert_eq!(guides(&s), [(true, 110.0), (true, 300.0), (false, 45.0)]);
assert!(selected_guides(&s).is_empty());
assert!(s.doc().unwrap().doc.node(a).is_some(), "the art stays");
// Removing one keeps the rest of the selection pointing at the same guides.
s.execute("guide.select", &json!({"indexes": [1, 2]})).unwrap();
assert_eq!(s.execute("guide.remove", &json!({"index": 0})).unwrap()["count"], 1);
assert_eq!((guides(&s), selected_guides(&s)), (vec![(true, 300.0), (false, 45.0)], vec![0, 1]));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![1, 2]));
// Out of range positions are refused.
assert!(s.execute("guide.move", &json!({"index": 0, "pos": 1e300})).is_err());
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": -1e300})).is_err());
// Locking deselects them, and they can't be picked again until unlocked.
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
assert!(selected_guides(&s).is_empty());
assert!(matches!(s.execute("guide.select", &json!({"indexes": [0]})), Err(EngineError::Disabled(..))));
assert!(matches!(s.execute("edit.clear", &json!({})), Err(EngineError::Disabled(..))));
}
/// #414: the Selection tool picks a ruler guide over the art and drags it in one undo step; with
/// guides hidden or locked the press goes to the art.
#[test]
fn selection_tool_drags_a_ruler_guide() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("guide.add", &json!({"vertical": true, "pos": 150})).unwrap();
s.execute("select.none", &json!({})).unwrap();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("selection", v).unwrap();
let drag = |s: &mut Session, v: ViewInfo, from: (f64, f64), to: (f64, f64)| {
s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap();
};
let n = undo_len(&s);
drag(&mut s, v, (151.0, 120.0), (171.0, 140.0));
assert_eq!(guides(&s), [(true, 170.0)]);
assert_eq!(selected_guides(&s), [0]);
assert!(selected(&s).is_empty());
assert_eq!(bounds(&s, a).x0, 100.0, "the art stays");
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Move Guide");
// Hidden guides: the press takes the art.
let hidden = ViewInfo { guides: false, ..v };
drag(&mut s, hidden, (170.0, 120.0), (180.0, 120.0));
assert_eq!((guides(&s), bounds(&s, a).x0), (vec![(true, 170.0)], 110.0));
// Locked guides too.
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
drag(&mut s, v, (170.0, 120.0), (180.0, 120.0));
assert_eq!((guides(&s), bounds(&s, a).x0), (vec![(true, 170.0)], 120.0));
}
// ---------- File ----------
#[test]
fn close_all_documents() {
let mut s = session();
s.execute("file.new", &json!({})).unwrap();
assert_eq!(s.execute("file.closeAll", &json!({})).unwrap()["closed"], 2);
assert!(s.active().is_none());
assert!(s.execute("file.closeAll", &json!({})).is_err());
}
#[test]
fn document_color_mode_converts_colours() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
fill(&mut s, a, "#ff0000");
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Cmyk);
// Converted through the colour settings: a press red, mostly magenta and yellow.
let Some(Color::Cmyk { c, m, y, k }) = node(&s, a).appearance.fill_paint().color() else { panic!("not CMYK") };
assert!(m > 0.8 && y > 0.8 && c < 0.05 && k < 0.05, "{c} {m} {y} {k}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Rgb);
assert!(s.execute("file.documentColorMode", &json!({"mode": "lab"})).is_err());
}
#[test]
fn file_info_sets_title() {
let mut s = session();
let r = s.execute("file.info", &json!({"title": "Poster"})).unwrap();
assert_eq!(r["title"], "Poster");
assert_eq!(s.doc().unwrap().doc.title, "Poster");
assert_eq!(s.execute("file.info", &json!({})).unwrap()["colorMode"], "rgb");
}
#[test]
fn every_new_command_has_params_doc_and_menu() {
for id in [
"object.lock.above",
"object.transformEach",
"object.rasterize",
"object.blend.make",
"edit.colors.invert",
"edit.findReplace",
"select.same.fontSize",
"type.changeCase",
"view.guides.make",
"file.closeAll",
] {
let c = find_command(id).unwrap_or_else(|| panic!("{id}"));
assert!(!c.params.is_empty() && !c.menu.is_empty(), "{id}");
}
}
#[test]
fn saved_selection_names_are_trimmed_and_capped() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[a]);
let long = "n".repeat(1000);
let name = s.execute("select.save", &json!({ "name": format!(" {long} ") })).unwrap()["name"].clone();
assert_eq!(name.as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME));
s.execute("select.editSaved", &json!({"name": name, "newName": format!("{long}é")})).unwrap();
let list = s.execute("select.savedList", &json!({})).unwrap();
assert_eq!(list[0].as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME));
}