vectorcraft/crates/engine/src/tests_pattern.rs

533 lines
22 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.

//! Pattern swatches, pattern editing mode and Repeat, driven through `Session::execute`.
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{Point, Rect};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).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 node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn sel(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn doc(s: &Session) -> &vectorcraft_doc::Document {
&s.doc().unwrap().doc
}
/// A red 10×10 square made into pattern "Dots" (editing finished).
fn make_dots(s: &mut Session) -> NodeId {
let r = rect(s, 0.0, 0.0, 10.0, 10.0);
s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#ff0000"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [r.0], "none": true})).unwrap();
sel(s, &[r]);
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
r
}
fn close(a: Point, b: Point) -> bool {
(a - b).hypot() < 1e-6
}
// ---------- patterns ----------
#[test]
fn make_creates_definition_swatch_and_edit_mode() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[r]);
let v = s.execute("object.pattern.make", &json!({})).unwrap();
assert_eq!(v["name"], "New Pattern 1");
let d = doc(&s);
let def = d.pattern("New Pattern 1").unwrap();
assert_eq!(def.tile, Rect::new(0.0, 0.0, 10.0, 10.0));
assert!(d.swatch("New Pattern 1").is_some_and(|sw| matches!(sw.paint, Paint::Pattern { .. })));
let pe = d.pattern_edit.as_ref().unwrap();
assert_eq!(d.node(pe.layer).unwrap().name.as_deref(), Some(PATTERN_EDIT_LAYER));
assert_eq!(d.node(pe.layer).unwrap().children().unwrap().len(), 1);
// The original art stays in the document; new art goes into the edit layer.
assert!(d.node(r).is_some());
assert_eq!(s.doc().unwrap().insertion_parent(), Some(pe.layer));
}
#[test]
fn done_saves_edited_art_and_leaves_mode() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[r]);
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
// Draw another shape inside the pattern.
rect(&mut s, 2.0, 2.0, 3.0, 3.0);
s.execute("object.pattern.done", &json!({})).unwrap();
let d = doc(&s);
assert!(d.pattern_edit.is_none());
assert_eq!(d.pattern("P").unwrap().art.len(), 2);
assert!(d.layers.iter().all(|l| l.name.as_deref() != Some(PATTERN_EDIT_LAYER)));
assert!(s.doc().unwrap().isolation.is_none());
}
#[test]
fn cancel_new_pattern_removes_it() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[r]);
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
s.execute("object.pattern.cancel", &json!({})).unwrap();
assert!(doc(&s).pattern("P").is_none());
assert!(doc(&s).swatch("P").is_none());
assert_eq!(doc(&s).layers.len(), 1);
}
#[test]
fn edit_then_cancel_restores_definition() {
let mut s = session();
make_dots(&mut s);
s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap();
s.execute("pattern.options", &json!({"tileType": "hexByRow", "width": 40})).unwrap();
assert_eq!(doc(&s).pattern("Dots").unwrap().tile_type, TileType::HexByRow);
s.execute("object.pattern.cancel", &json!({})).unwrap();
let def = doc(&s).pattern("Dots").unwrap();
assert_eq!(def.tile_type, TileType::Grid);
assert_eq!(def.tile.width(), 20.0);
}
#[test]
fn edit_defaults_to_selected_objects_pattern() {
let mut s = session();
make_dots(&mut s);
let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
sel(&mut s, &[big]);
let v = s.execute("object.pattern.edit", &json!({})).unwrap();
assert_eq!(v["name"], "Dots");
assert!(doc(&s).pattern_edit.is_some());
// Edit again while editing is refused.
assert!(s.execute("object.pattern.edit", &json!({"name": "Dots"})).is_err());
}
#[test]
fn set_fill_with_pattern_swatch() {
let mut s = session();
make_dots(&mut s);
let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
assert!(matches!(node(&s, big).appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Dots"));
}
#[test]
fn set_fill_with_pattern_name_without_swatch() {
let mut s = session();
make_dots(&mut s);
Arc::make_mut(&mut s.doc_mut().unwrap().doc).swatches.retain(|sw| sw.name != "Dots");
let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
assert!(matches!(node(&s, big).appearance.fill_paint(), Paint::Pattern { .. }));
}
#[test]
fn options_set_tile_type_spacing_and_overlap() {
let mut s = session();
make_dots(&mut s);
s.execute(
"pattern.options",
&json!({"name": "Dots", "tileType": "brickByColumn", "brickOffset": 0.25, "overlap": {"h": "right", "v": "bottom"}, "copies": 7, "dimCopies": 40}),
)
.unwrap();
let def = doc(&s).pattern("Dots").unwrap();
assert_eq!(def.tile_type, TileType::BrickByColumn { offset: 0.25 });
assert!(def.overlap.right_in_front && def.overlap.bottom_in_front);
assert_eq!(def.copies, 7);
assert_eq!(def.dim_copies, 40.0);
// Size tile to art with spacing.
s.execute("pattern.options", &json!({"name": "Dots", "sizeTileToArt": true, "hSpacing": 4, "vSpacing": 6})).unwrap();
let def = doc(&s).pattern("Dots").unwrap();
assert!((def.tile.width() - 14.0).abs() < 1e-6 && (def.tile.height() - 16.0).abs() < 1e-6, "{:?}", def.tile);
assert!(s.execute("pattern.options", &json!({"name": "Dots", "tileType": "zigzag"})).is_err());
assert!(s.execute("pattern.options", &json!({"name": "Dots", "width": -1})).is_err());
}
#[test]
fn options_rename_updates_swatch_and_paints() {
let mut s = session();
make_dots(&mut s);
let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
s.execute("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap();
assert!(doc(&s).pattern("Spots").is_some() && doc(&s).pattern("Dots").is_none());
assert!(doc(&s).swatch("Spots").is_some());
assert!(matches!(node(&s, big).appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Spots"));
}
#[test]
fn delete_pattern_unpaints_objects() {
let mut s = session();
make_dots(&mut s);
let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
let v = s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap();
assert_eq!(v["unpainted"], 1);
assert!(doc(&s).pattern("Dots").is_none() && doc(&s).swatch("Dots").is_none());
assert!(node(&s, big).appearance.fill_paint().is_none());
assert!(s.execute("pattern.delete", &json!({"name": "Dots"})).is_err());
}
#[test]
fn transform_patterns_only_moves_placement() {
let mut s = session();
make_dots(&mut s);
let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
let before = node(&s, big).geometric_bounds();
s.execute("pattern.transform", &json!({"ids": [big.0], "matrix": [2, 0, 0, 2, 5, 0]})).unwrap();
let n = node(&s, big);
assert_eq!(n.geometric_bounds(), before);
let Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!() };
assert_eq!(xf.as_coeffs(), [2.0, 0.0, 0.0, 2.0, 5.0, 0.0]);
}
/// #1448, #1460: with Transform Objects off, the transform commands leave the art where it is and
/// move only its pattern tiles (Transform Patterns on), or nothing at all; Transform Again repeats
/// that.
#[test]
fn transforms_without_transform_objects_move_only_the_pattern() {
let mut s = session();
make_dots(&mut s);
let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
s.execute("select.set", &json!({"ids": [big.0]})).unwrap();
let before = node(&s, big).geometric_bounds();
let tiles = |s: &Session| {
let n = node(s, big);
let Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!() };
xf.as_coeffs()
};
for (cmd, p, want) in [
("object.move", json!({"dx": 5, "dy": 0}), [1.0, 0.0, 0.0, 1.0, 5.0, 0.0]),
("object.transformAgain", json!({}), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0]),
("object.reflect", json!({"axis": "vertical", "origin": [0, 0]}), [-1.0, 0.0, 0.0, 1.0, -10.0, 0.0]),
("object.transformEach", json!({"moveH": 10, "reference": 0}), [-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]),
] {
// Transform Again repeats the move as it was made, options included.
if cmd != "object.transformAgain" {
let mut off = p.clone();
off["objects"] = json!(false);
off["patterns"] = json!(false);
s.execute(cmd, &off).unwrap();
assert_eq!(node(&s, big).geometric_bounds(), before, "{cmd}: neither transforms");
off["patterns"] = json!(true);
s.execute(cmd, &off).unwrap();
} else {
s.execute(cmd, &p).unwrap();
}
assert_eq!(node(&s, big).geometric_bounds(), before, "{cmd}");
assert_eq!(tiles(&s), want, "{cmd}");
}
}
#[test]
fn list_reports_patterns_and_edit_state() {
let mut s = session();
make_dots(&mut s);
let v = s.execute("pattern.list", &json!({})).unwrap();
assert_eq!(v["patterns"][0]["name"], "Dots");
assert_eq!(v["patterns"][0]["tileType"], "grid");
assert_eq!(v["patterns"][0]["width"], 20.0);
assert!(v["editing"].is_null());
s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap();
assert_eq!(s.execute("pattern.list", &json!({})).unwrap()["editing"]["name"], "Dots");
}
#[test]
fn save_a_copy_while_editing() {
let mut s = session();
make_dots(&mut s);
s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap();
let v = s.execute("object.pattern.saveCopy", &json!({})).unwrap();
assert_eq!(v["name"], "Dots copy");
assert!(doc(&s).pattern("Dots copy").is_some());
}
#[test]
fn undo_make_pattern() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[r]);
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(doc(&s).pattern("P").is_none() && doc(&s).pattern_edit.is_none());
}
#[test]
fn hit_testing_in_edit_mode_only_reaches_tile_layer() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let other = rect(&mut s, 200.0, 200.0, 50.0, 50.0);
sel(&mut s, &[r]);
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
let d = doc(&s);
let opt = vectorcraft_doc::hit::HitOptions::default();
assert!(vectorcraft_doc::hit::hit_test(d, Point::new(220.0, 220.0), opt).is_none());
let h = vectorcraft_doc::hit::hit_test(d, Point::new(5.0, 5.0), opt).unwrap();
assert_ne!(h.leaf, r);
let _ = other;
}
#[test]
fn save_load_roundtrip_keeps_patterns_and_repeats() {
let mut s = session();
make_dots(&mut s);
let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
let r = rect(&mut s, 300.0, 50.0, 10.0, 10.0);
sel(&mut s, &[r]);
s.execute("object.repeat.radial", &json!({"instances": 6})).unwrap();
let bytes = vectorcraft_format::save(doc(&s), false);
let back = vectorcraft_format::load(&bytes).unwrap();
assert_eq!(back.patterns, doc(&s).patterns);
assert_eq!(back.node(big).unwrap().appearance.fill_paint(), node(&s, big).appearance.fill_paint());
assert!(back.layers[0].children().unwrap().iter().any(|n| matches!(n.kind, NodeKind::Repeat(_))));
}
#[test]
fn svg_export_writes_pattern_def() {
let mut s = session();
make_dots(&mut s);
let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
let svg = vectorcraft_svg::export(doc(&s), &vectorcraft_svg::ExportOptions::default());
assert!(svg.contains("<pattern id=\"pattern-1\""), "{svg}");
assert!(svg.contains("patternUnits=\"userSpaceOnUse\""));
assert!(svg.contains("width=\"20\" height=\"20\""));
assert!(svg.contains("fill=\"url(#pattern-1)\"") || svg.contains("fill:url(#pattern-1)"), "{svg}");
// The tile art is inside the pattern.
let start = svg.find("<pattern").unwrap();
let end = svg.find("</pattern>").unwrap();
assert!(svg[start..end].contains("<path"));
}
#[test]
fn pdf_export_expands_pattern_fills() {
let mut s = session();
make_dots(&mut s);
let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
let rep = vectorcraft_pdf::export_with_report(doc(&s), &Default::default()).unwrap();
assert!(rep.warnings.iter().all(|w| !w.contains("pattern")), "{:?}", rep.warnings);
assert!(rep.bytes.len() > 500);
}
// ---------- repeat ----------
#[test]
fn radial_repeat_wraps_selection() {
let mut s = session();
let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0);
sel(&mut s, &[r]);
let v = s.execute("object.repeat.radial", &json!({"instances": 8, "radius": 50})).unwrap();
let n = node(&s, id_of(&v));
let NodeKind::Repeat(spec) = &n.kind else { panic!("{:?}", n.kind) };
assert_eq!(spec.source.len(), 1);
let RepeatKind::Radial { instances, radius, center, .. } = spec.kind else { panic!() };
assert_eq!((instances, radius), (8, 50.0));
assert!(close(center, Point::new(200.0, 150.0)));
assert_eq!(spec.transforms().len(), 8);
assert_eq!(n.kind_label(), "Radial Repeat");
}
#[test]
fn radial_instance_transforms_rotate_around_center() {
let mut s = session();
let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0);
sel(&mut s, &[r]);
let v = s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 50})).unwrap();
let NodeKind::Repeat(spec) = node(&s, id_of(&v)).kind else { panic!() };
let t = spec.transforms();
let c = Point::new(200.0, 100.0);
assert!(close(t[0] * c, Point::new(200.0, 100.0)));
assert!(close(t[1] * c, Point::new(250.0, 150.0)));
assert!(close(t[2] * c, Point::new(200.0, 200.0)));
assert!(close(t[3] * c, Point::new(150.0, 150.0)));
}
#[test]
fn expand_yields_n_copies() {
let mut s = session();
let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0);
sel(&mut s, &[r]);
let v = s.execute("object.repeat.radial", &json!({"instances": 5})).unwrap();
let id = id_of(&v);
s.execute("object.repeat.expand", &json!({})).unwrap();
let g = node(&s, id);
assert!(matches!(g.kind, NodeKind::Group { .. }));
assert_eq!(g.children().unwrap().len(), 5);
// Every generated node has a real, unique id.
let mut ids = vec![];
g.walk(&mut |n| ids.push(n.id));
assert!(ids.iter().all(|i| i.0 != 0));
let mut u = ids.clone();
u.sort();
u.dedup();
assert_eq!(u.len(), ids.len());
}
/// #1756: Object → Expand turns radial, grid and mirror repeats into groups of their instances, in
/// one undo step, as Expand Repeat does.
#[test]
fn object_expand_expands_repeats() {
for (cmd, opts) in [("object.repeat.radial", json!({"instances": 5})), ("object.repeat.grid", json!({})), ("object.repeat.mirror", json!({}))] {
let mut s = session();
let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0);
sel(&mut s, &[r]);
let id = id_of(&s.execute(cmd, &opts).unwrap());
let NodeKind::Repeat(spec) = node(&s, id).kind else { panic!("{cmd}") };
let n = spec.expand().len();
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(true), "{cmd}");
let undo = s.doc().unwrap().history.undo.len();
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
let g = node(&s, id);
assert!(matches!(g.kind, NodeKind::Group { .. }), "{cmd}");
assert_eq!(g.children().unwrap().len(), n, "{cmd}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{cmd}");
assert_eq!(s.doc().unwrap().selection.objects, vec![id], "{cmd}");
// Nothing left to expand: the instances are plain rectangles.
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(false), "{cmd}");
}
}
/// Expand reaches repeats inside a selected group, and with every option on also outlines the
/// instances' strokes.
#[test]
fn object_expand_expands_repeats_in_groups() {
let mut s = session();
let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0);
sel(&mut s, &[r]);
let rep = id_of(&s.execute("object.repeat.radial", &json!({"instances": 4})).unwrap());
let other = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[rep, other]);
s.execute("object.group", &json!({})).unwrap();
s.execute("object.expand", &json!({})).unwrap();
let mut repeats = 0;
for l in &doc(&s).layers {
l.walk(&mut |c| repeats += usize::from(matches!(c.kind, NodeKind::Repeat(_))));
}
assert_eq!(repeats, 0);
let g = node(&s, rep);
assert_eq!(g.children().unwrap().len(), 4);
assert!(g.children().unwrap().iter().all(|c| c.appearance.stroke_paint().is_none()));
}
#[test]
fn grid_repeat_and_options() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[r]);
let v = s.execute("object.repeat.grid", &json!({"hSpacing": 5, "vSpacing": 5})).unwrap();
let id = id_of(&v);
assert_eq!(node(&s, id).geometric_bounds(), Some(Rect::new(0.0, 0.0, 40.0, 40.0)));
s.execute("object.repeat.options", &json!({"rows": 2, "cols": 4, "flipCols": true, "gridType": "brickByRow"})).unwrap();
let NodeKind::Repeat(spec) = node(&s, id).kind else { panic!() };
let RepeatKind::Grid { rows, cols, flip_cols, grid_type, .. } = spec.kind else { panic!() };
assert_eq!((rows, cols, flip_cols), (2, 4, true));
assert_eq!(grid_type, TileType::BrickByRow { offset: 0.5 });
assert_eq!(spec.expand().len(), 8);
}
#[test]
fn mirror_repeat_reflects() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[r]);
let v = s.execute("object.repeat.mirror", &json!({"offset": 5})).unwrap();
let n = node(&s, id_of(&v));
let b = n.geometric_bounds().unwrap();
assert!((b.x1 - 30.0).abs() < 1e-9 && b.x0.abs() < 1e-9, "{b:?}");
s.execute("object.repeat.options", &json!({"angle": 0})).unwrap();
let b = node(&s, id_of(&v)).geometric_bounds().unwrap();
assert!((b.width() - 10.0).abs() < 1e-9, "{b:?}");
}
#[test]
fn switching_repeat_kind_keeps_source() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[r]);
let v = s.execute("object.repeat.radial", &json!({})).unwrap();
let id = id_of(&v);
let v2 = s.execute("object.repeat.grid", &json!({})).unwrap();
assert_eq!(id_of(&v2), id);
let NodeKind::Repeat(spec) = node(&s, id).kind else { panic!() };
assert!(matches!(spec.kind, RepeatKind::Grid { .. }));
assert_eq!(spec.source[0].id, r);
}
#[test]
fn release_restores_source() {
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]);
s.execute("object.repeat.mirror", &json!({})).unwrap();
assert!(doc(&s).layers[0].children().unwrap().len() == 1);
let v = s.execute("object.repeat.release", &json!({})).unwrap();
assert_eq!(v["ids"], json!([a.0, b.0]));
assert_eq!(node(&s, a).geometric_bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0)));
assert!(s.execute("object.repeat.release", &json!({})).is_err());
}
#[test]
fn repeat_moves_with_transform() {
let mut s = session();
let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0);
sel(&mut s, &[r]);
let v = s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 50})).unwrap();
let id = id_of(&v);
let before = node(&s, id).geometric_bounds().unwrap();
s.execute("object.transform", &json!({"ids": [id.0], "matrix": [1, 0, 0, 1, 10, 20]})).unwrap();
let after = node(&s, id).geometric_bounds().unwrap();
assert!(after.x0 > before.x0 && after.y0 > before.y0, "{before:?} {after:?}");
}
#[test]
fn repeat_hit_test_uses_instances() {
let mut s = session();
let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0);
sel(&mut s, &[r]);
s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 50})).unwrap();
let d = doc(&s);
let opt = vectorcraft_doc::hit::HitOptions::default();
assert!(vectorcraft_doc::hit::hit_test(d, Point::new(250.0, 150.0), opt).is_some());
// The empty centre of the ring is not the repeat.
assert!(vectorcraft_doc::hit::hit_test(d, Point::new(200.0, 150.0), opt).is_none());
}
#[test]
fn repeat_commands_validate() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[r]);
assert!(s.execute("object.repeat.radial", &json!({"instances": 0})).is_err());
assert!(s.execute("object.repeat.options", &json!({"instances": 4})).is_err(), "no repeat selected");
s.execute("select.none", &json!({})).ok();
assert!(s.execute("object.pattern.make", &json!({})).is_err());
}