Files
vectorcraft/crates/engine/src/tests_clipboard.rs
T
bflatasticandClaude Opus 5.5 f346727dd8 M4.23: paste placement, Paste Remembers Layers and the Paste menu's enable rule
Paste (also without formatting) goes to the centre of the view
(edit.paste {center}); Paste in Front/Back with nothing selected go to
the top/bottom of the current layer, in order; Paste on All Artboards
keeps the objects' offset to the artboard they were copied from.
layer.pasteRemembersLayers {on?} (document flag paste_remembers_layers,
saved, document.inspect) pastes objects back into the layers they came
from by name, making missing ones; the Layers panel now has its menu
with the layer commands and the check item. The Paste menu items are
enabled while the system clipboard holds SVG (looked at once per frame
at most, a few times a second).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:45:34 +07:00

599 lines
28 KiB
Rust

//! Copy and paste between documents (resources, swatch conflicts) and paste placement (M4.22,
//! M4.23).
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Document, 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": 300})).unwrap();
s
}
/// A second document in the same session (as when copying between windows); it becomes active.
fn new_doc(s: &mut Session) {
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn ids_of(v: &Value) -> Vec<NodeId> {
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
}
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 ellipse(s: &mut Session) -> NodeId {
id_of(&s.execute("shape.ellipse", &json!({"x": 300, "y": 200, "width": 40, "height": 20})).unwrap())
}
fn sel(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn copy(s: &mut Session, ids: &[NodeId]) {
sel(s, ids);
s.execute("edit.copy", &json!({})).unwrap();
}
fn doc(s: &Session) -> &Document {
&s.doc().unwrap().doc
}
fn node(s: &Session, id: NodeId) -> Node {
doc(s).node(id).unwrap().clone()
}
fn fill(s: &Session, id: NodeId) -> Paint {
node(s, id).appearance.fill_paint()
}
fn linked(color: Color, swatch: &str, tint: f32) -> Paint {
Paint::Solid { color, swatch: Some(swatch.into()), tint }
}
fn hex(h: &str) -> Color {
Color::from_hex(h).unwrap()
}
fn render(s: &Session) -> Vec<u8> {
vectorcraft_render::Renderer::new().render_region(doc(s), Rect::new(0.0, 0.0, 400.0, 300.0), 1.0, true).pixels
}
/// A global swatch `name` of `hex` and a rectangle filled with it.
fn swatched_rect(s: &mut Session, name: &str, hex: &str) -> NodeId {
s.execute("swatch.new", &json!({"name": name, "color": hex, "global": true})).unwrap();
let r = rect(s, 20.0, 20.0, 40.0, 40.0);
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": name})).unwrap();
r
}
fn renamed(v: &Value) -> Vec<(String, String, String)> {
let s = |r: &Value, k: &str| r[k].as_str().unwrap().to_string();
v["renamed"].as_array().unwrap().iter().map(|r| (s(r, "kind"), s(r, "from"), s(r, "to"))).collect()
}
// ---------- M4.22: resources ----------
#[test]
fn an_image_symbol_and_pattern_rect_pasted_into_a_new_document_render_the_same() {
let mut s = session();
// Pattern "Dots" painting a rectangle.
let dot = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("paint.setFill", &json!({"ids": [dot.0], "color": "#ff0000"})).unwrap();
sel(&mut s, &[dot]);
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
sel(&mut s, &[dot]);
s.execute("edit.clear", &json!({})).unwrap();
let patterned = rect(&mut s, 20.0, 20.0, 100.0, 80.0);
s.execute("paint.setFill", &json!({"ids": [patterned.0], "swatch": "Dots"})).unwrap();
// Symbol "Badge" (a green square) placed once.
let badge = rect(&mut s, 150.0, 20.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"ids": [badge.0], "color": "#00aa44"})).unwrap();
sel(&mut s, &[badge]);
s.execute("symbol.new", &json!({"name": "Badge"})).unwrap();
// An embedded image (a rasterized blue ellipse).
let e = id_of(&s.execute("shape.ellipse", &json!({"x": 240, "y": 40, "width": 90, "height": 60})).unwrap());
s.execute("paint.setFill", &json!({"ids": [e.0], "color": "#2040ff"})).unwrap();
sel(&mut s, &[e]);
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(s.clipboard.nodes.len(), 3);
assert_eq!((s.clipboard.symbols.len(), s.clipboard.patterns.len(), s.clipboard.images.len()), (1, 1, 1));
let before = render(&s);
new_doc(&mut s);
assert!(before != render(&s));
let r = s.execute("edit.pasteInPlace", &json!({})).unwrap();
// The pattern (its swatch comes along), the symbol and the image blob.
assert_eq!((r["added"].as_u64(), r["merged"].as_u64()), (Some(3), Some(0)), "{r}");
let d = doc(&s);
assert!(d.pattern("Dots").is_some() && d.swatch("Dots").is_some());
assert!(d.symbols.iter().any(|x| x.name == "Badge"));
assert_eq!(d.images.len(), 1);
assert!(before == render(&s), "the pasted art renders as it did in its own document");
}
#[test]
fn a_spot_swatch_brings_its_plate() {
let mut s = session();
let r = swatched_rect(&mut s, "Spot Teal", "#008080");
s.execute("swatch.setSpot", &json!({"name": "Spot Teal"})).unwrap();
copy(&mut s, &[r]);
new_doc(&mut s);
let plates = |s: &Session| vectorcraft_render::proof::plates(doc(s)).iter().filter(|p| p.spot).map(|p| p.name.clone()).collect::<Vec<_>>();
assert!(plates(&s).is_empty());
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(plates(&s), ["Spot Teal"]);
let sw = doc(&s).swatch("Spot Teal").unwrap();
assert!(sw.spot && sw.global);
}
#[test]
fn swatch_conflicts_merge_relinks_and_add_renames() {
let mut s = session();
let r = swatched_rect(&mut s, "Brand", "#ff0000");
copy(&mut s, &[r]);
new_doc(&mut s);
s.execute("swatch.new", &json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap();
let c = s.execute("clipboard.conflicts", &json!({})).unwrap();
assert_eq!(c["swatches"], json!([{"name": "Brand", "document": "#0000ff", "clipboard": "#ff0000", "spot": false}]));
let swatches = doc(&s).swatches.len();
// Merge (the default): the pasted object takes the document's Brand.
let v = s.execute("edit.pasteInPlace", &json!({"swatchConflict": "merge"})).unwrap();
assert_eq!((v["merged"].as_u64(), v["added"].as_u64()), (Some(1), Some(0)));
assert_eq!(fill(&s, ids_of(&v)[0]), linked(hex("#0000ff"), "Brand", 1.0));
assert_eq!(doc(&s).swatches.len(), swatches);
s.execute("edit.undo", &json!({})).unwrap();
// Add: the pasted swatch joins under a new name and the object follows it.
let v = s.execute("edit.pasteInPlace", &json!({"swatchConflict": "add"})).unwrap();
assert_eq!(v["renamed"], json!([{"kind": "swatch", "from": "Brand", "to": "Brand 2"}]));
assert_eq!(fill(&s, ids_of(&v)[0]), linked(hex("#ff0000"), "Brand 2", 1.0));
let added = doc(&s).swatch("Brand 2").unwrap();
assert!(added.global && added.paint.color() == Some(hex("#ff0000")));
s.execute("edit.undo", &json!({})).unwrap();
// Per swatch name; the others merge.
let v = s.execute("edit.paste", &json!({"swatchConflict": {"Other": "add"}})).unwrap();
assert_eq!(v["merged"], 1);
assert!(s.execute("edit.paste", &json!({"swatchConflict": "keep"})).is_err());
assert!(s.execute("edit.paste", &json!({"swatchConflict": {"Brand": 1}})).is_err());
}
#[test]
fn tints_and_their_tint_swatches_follow_the_base() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 0, "m": 100, "y": 100, "k": 0}, "spot": true})).unwrap();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Ink", "tint": 40})).unwrap();
// The 40% tint swatch, and one of another tint the art doesn't use.
assert_eq!(s.execute("swatch.new", &json!({})).unwrap()["name"], "Ink 40%");
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Ink", "tint": 70})).unwrap();
s.execute("swatch.new", &json!({})).unwrap();
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Ink", "tint": 40})).unwrap();
copy(&mut s, &[r]);
assert_eq!(s.clipboard.swatches.iter().map(|w| w.name.as_str()).collect::<Vec<_>>(), ["Ink", "Ink 40%"]);
// Into a document whose Ink is cyan: merged, the tint is 40% of the document's Ink.
new_doc(&mut s);
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 100, "m": 0, "y": 0, "k": 0}, "spot": true})).unwrap();
let v = s.execute("edit.pasteInPlace", &json!({})).unwrap();
let cyan = Color::cmyk(1.0, 0.0, 0.0, 0.0);
assert_eq!(fill(&s, ids_of(&v)[0]), linked(cyan.tinted(0.4), "Ink", 0.4));
let tint = doc(&s).swatch("Ink 40%").unwrap();
assert_eq!(tint.paint, linked(cyan.tinted(0.4), "Ink", 0.4), "the tint swatch links to the document's Ink");
assert!(doc(&s).swatch("Ink 70%").is_none(), "unused tints stay behind");
s.execute("edit.undo", &json!({})).unwrap();
// Added: the tint and its tint swatch follow the renamed base.
let v = s.execute("edit.pasteInPlace", &json!({"swatchConflict": "add"})).unwrap();
let magenta = Color::cmyk(0.0, 1.0, 1.0, 0.0);
assert_eq!(fill(&s, ids_of(&v)[0]), linked(magenta.tinted(0.4), "Ink 2", 0.4));
assert_eq!(doc(&s).swatch("Ink 40%").unwrap().paint, linked(magenta.tinted(0.4), "Ink 2", 0.4));
}
#[test]
fn gradient_stops_stay_linked_to_their_swatches() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "Sky", "color": "#3080ff", "global": true})).unwrap();
let r = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"ids": [r.0], "gradient": {"kind": "linear"}})).unwrap();
s.edit("link stop", |d, _| {
let n = d.node_mut(r).unwrap();
let Paint::Gradient(mut g) = n.appearance.fill_paint() else { panic!() };
g.gradient.stops[0].set_color(Color::from_hex("#3080ff").unwrap(), Some(("Sky".into(), 1.0)));
n.appearance.set_fill(Paint::Gradient(g));
Ok(())
})
.unwrap();
copy(&mut s, &[r]);
assert!(s.clipboard.swatches.iter().any(|w| w.name == "Sky"));
new_doc(&mut s);
s.execute("swatch.new", &json!({"name": "Sky", "color": "#000080", "global": true})).unwrap();
let v = s.execute("edit.paste", &json!({"swatchConflict": "add"})).unwrap();
let Paint::Gradient(g) = fill(&s, ids_of(&v)[0]) else { panic!() };
assert_eq!((g.gradient.stops[0].swatch.as_deref(), g.gradient.stops[0].color), (Some("Sky 2"), hex("#3080ff")));
assert!(doc(&s).swatch("Sky 2").is_some());
}
#[test]
fn gradient_swatches_come_along_with_their_stop_links() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "Sky", "color": "#3080ff", "global": true})).unwrap();
let glow = json!({"stops": [{"offset": 0, "swatch": "Sky"}, {"offset": 1, "color": "#ffffff"}]});
s.execute("swatch.new", &json!({"name": "Glow", "gradient": glow})).unwrap();
let r = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Glow"})).unwrap();
copy(&mut s, &[r]);
assert_eq!(s.clipboard.swatches.iter().map(|w| w.name.as_str()).collect::<Vec<_>>(), ["Sky", "Glow"]);
// A document with another Glow and another Sky.
new_doc(&mut s);
s.execute("swatch.new", &json!({"name": "Sky", "color": "#000080", "global": true})).unwrap();
s.execute("swatch.new", &json!({"name": "Glow", "gradient": {"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#000000"}]}}))
.unwrap();
let v = s.execute("edit.paste", &json!({"swatchConflict": "add"})).unwrap();
let Paint::Gradient(g) = fill(&s, ids_of(&v)[0]) else { panic!() };
assert_eq!((g.swatch.as_deref(), g.gradient.stops[0].swatch.as_deref()), (Some("Glow 2"), Some("Sky 2")));
let Paint::Gradient(sw) = doc(&s).swatch("Glow 2").unwrap().paint.clone() else { panic!() };
assert_eq!(sw.gradient, g.gradient, "the added gradient swatch links its stop to the added Sky too");
}
#[test]
fn lab_spot_colours_show_as_the_target_documents_spot_option_says() {
let mut s = session();
let lab = Color::lab(60.0, 50.0, 20.0);
s.execute("swatch.new", &json!({"name": "Lab Ink", "color": {"l": 60, "a": 50, "b": 20}, "spot": true})).unwrap();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Lab Ink"})).unwrap();
assert_eq!(fill(&s, r).color(), Some(lab));
copy(&mut s, &[r]);
new_doc(&mut s);
s.execute("swatch.spotOptions", &json!({"useLab": false})).unwrap();
let id = ids_of(&s.execute("edit.paste", &json!({})).unwrap())[0];
let cmyk = doc(&s).global_color("Lab Ink").unwrap();
assert!(matches!(cmyk, Color::Cmyk { .. }));
assert_eq!(fill(&s, id), linked(cmyk, "Lab Ink", 1.0), "the CMYK equivalent, still linked");
assert_eq!(doc(&s).swatch("Lab Ink").unwrap().paint.color(), Some(lab), "the swatch keeps its Lab definition");
}
#[test]
fn registration_is_never_copied_as_a_swatch() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.edit("registration", |d, _| {
d.node_mut(r).unwrap().appearance.set_fill(Paint::registration());
Ok(())
})
.unwrap();
copy(&mut s, &[r]);
assert!(s.clipboard.swatches.is_empty());
new_doc(&mut s);
let swatches = doc(&s).swatches.len();
let v = s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(v["added"], 0);
assert_eq!(doc(&s).swatches.len(), swatches);
assert!(fill(&s, ids_of(&v)[0]).is_registration());
}
#[test]
fn graphic_style_links_follow_the_style_into_the_document() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 30.0, 30.0);
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ffcc00"})).unwrap();
sel(&mut s, &[a]);
s.execute("graphicStyle.new", &json!({"name": "Sun"})).unwrap();
let src_id = node(&s, a).graphic_style.unwrap();
copy(&mut s, &[a]);
assert_eq!(s.clipboard.graphic_styles.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["Sun"]);
// A document with another "Sun" and styles taking up the source id.
new_doc(&mut s);
let b = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#0000ff"})).unwrap();
sel(&mut s, &[b]);
s.execute("graphicStyle.new", &json!({"name": "Sun"})).unwrap();
let v = s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(renamed(&v), [("graphicStyle".to_string(), "Sun".to_string(), "Sun 2".to_string())]);
let d = doc(&s);
let linked_to = d.graphic_style_by_id(node(&s, ids_of(&v)[0]).graphic_style.unwrap()).unwrap().clone();
assert_eq!(linked_to.name, "Sun 2");
assert_eq!(linked_to.appearance.fill_paint(), Paint::solid(hex("#ffcc00")));
assert!(d.graphic_styles.iter().filter(|g| g.id == linked_to.id).count() == 1, "a fresh id");
// Pasting again finds the same style.
let v = s.execute("edit.paste", &json!({})).unwrap();
assert_eq!((v["added"].as_u64(), node(&s, ids_of(&v)[0]).graphic_style), (Some(0), Some(linked_to.id)));
// Back in the source document the link is kept as it was.
s.execute("document.activate", &json!({"index": 0})).unwrap();
let v = s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(node(&s, ids_of(&v)[0]).graphic_style, Some(src_id));
}
#[test]
fn undo_removes_everything_the_paste_added() {
let mut s = session();
let r = swatched_rect(&mut s, "Brand", "#ff0000");
sel(&mut s, &[r]);
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
new_doc(&mut s);
let before = doc(&s).clone();
let v = s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(v["added"], 2, "{v}");
assert!(doc(&s).swatch("Brand").is_some() && !doc(&s).symbols.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert!(*doc(&s) == before);
}
#[test]
fn styles_symbols_and_images_of_the_same_name_are_renamed() {
let mut s = session();
// A character style, a symbol and an image named like the target's own.
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hello"})).unwrap());
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0}})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": t.0})).unwrap();
let m = rect(&mut s, 200.0, 100.0, 30.0, 30.0);
sel(&mut s, &[m]);
let mark = id_of(&s.execute("symbol.new", &json!({"name": "Mark"})).unwrap());
let e = rect(&mut s, 300.0, 100.0, 20.0, 20.0);
sel(&mut s, &[e]);
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
let key = |s: &Session, id: NodeId| match node(s, id).kind {
NodeKind::Image(im) => im.key,
_ => panic!("not an image"),
};
let src_key = key(&s, img);
copy(&mut s, &[t, mark, img]);
new_doc(&mut s);
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 8.0}})).unwrap();
let m2 = ellipse(&mut s);
sel(&mut s, &[m2]);
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
// Another image under the same key.
let e2 = ellipse(&mut s);
sel(&mut s, &[e2]);
let img2 = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
let other_key = key(&s, img2);
s.edit("same key", |d, _| {
let blob = d.images.remove(&other_key).unwrap();
d.images.insert(src_key.clone(), blob);
let NodeKind::Image(im) = &mut d.node_mut(img2).unwrap().kind else { panic!() };
im.key = src_key.clone();
Ok(())
})
.unwrap();
let v = s.execute("edit.pasteInPlace", &json!({})).unwrap();
let kinds: Vec<(String, String)> = renamed(&v).into_iter().map(|(k, _, to)| (k, to)).collect();
for want in [("charStyle", "Emphasis 2"), ("symbol", "Mark 2"), ("image", &*format!("{src_key} 2"))] {
assert!(kinds.contains(&(want.0.to_string(), want.1.to_string())), "{want:?} in {kinds:?}");
}
let ids = ids_of(&v);
let NodeKind::Text(tx) = node(&s, ids[0]).kind else { panic!() };
assert_eq!(tx.runs[0].style.style_name.as_deref(), Some("Emphasis 2"));
assert!(matches!(node(&s, ids[1]).kind, NodeKind::SymbolInstance { ref symbol, .. } if symbol == "Mark 2"));
let new_key = key(&s, ids[2]);
assert_ne!(new_key, src_key, "a different image under the same key is re-keyed");
assert!(doc(&s).images[&new_key].bytes != doc(&s).images[&src_key].bytes);
// Their own objects are untouched.
assert_eq!(key(&s, img2), src_key);
}
#[test]
fn pasting_again_reuses_what_an_earlier_paste_added() {
let mut s = session();
let r = swatched_rect(&mut s, "Brand", "#ff0000");
// A character style whose fill links to Brand.
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap());
let red = serde_json::to_value(linked(hex("#ff0000"), "Brand", 1.0)).unwrap();
s.execute("charStyle.new", &json!({"name": "Loud", "attrs": {"fill": red}})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Loud", "id": t.0})).unwrap();
copy(&mut s, &[r, t]);
new_doc(&mut s);
s.execute("swatch.new", &json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap();
let v = s.execute("edit.paste", &json!({"swatchConflict": "add"})).unwrap();
assert_eq!(v["added"], 2, "Brand 2 and Loud: {v}");
let style_fill = |s: &Session| serde_json::from_value::<Paint>(doc(s).char_styles[0].attrs["fill"].clone()).unwrap();
assert_eq!(style_fill(&s), linked(hex("#ff0000"), "Brand 2", 1.0), "the style's colour follows the renamed swatch");
// Again: Brand 2 and Loud are the pasted ones, nothing piles up.
let v = s.execute("edit.paste", &json!({"swatchConflict": "add"})).unwrap();
assert_eq!(v["added"], 0, "{v}");
assert_eq!(renamed(&v), [("swatch".to_string(), "Brand".to_string(), "Brand 2".to_string())]);
assert_eq!(fill(&s, ids_of(&v)[0]), linked(hex("#ff0000"), "Brand 2", 1.0));
assert!(doc(&s).swatch("Brand 3").is_none() && doc(&s).char_styles.len() == 1);
}
#[test]
fn brushes_travel_with_their_strokes() {
let mut s = session();
let name = s
.execute("brush.new", &json!({"type": "calligraphic", "name": "Wide Nib", "params": {"angle": 30, "roundness": 20, "size": 9}}))
.unwrap()["name"]
.as_str()
.unwrap()
.to_string();
let r = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
s.execute("brush.apply", &json!({"name": name, "ids": [r.0]})).unwrap();
copy(&mut s, &[r]);
assert_eq!(s.clipboard.brushes.len(), 1);
new_doc(&mut s);
assert!(vectorcraft_brush::find(doc(&s), &name).is_none());
s.execute("edit.paste", &json!({})).unwrap();
assert!(vectorcraft_brush::find(doc(&s), &name).is_some());
}
#[test]
fn pasting_back_into_the_source_document_adds_nothing() {
let mut s = session();
let r = swatched_rect(&mut s, "Brand", "#ff0000");
copy(&mut s, &[r]);
// Redefining the swatch afterwards is no conflict in its own document.
s.execute("swatch.edit", &json!({"name": "Brand", "color": "#00ff00"})).unwrap();
assert_eq!(s.execute("clipboard.conflicts", &json!({})).unwrap()["swatches"], json!([]));
let v = s.execute("edit.paste", &json!({})).unwrap();
assert_eq!((v["added"].as_u64(), v["renamed"].as_array().map(Vec::len)), (Some(0), Some(0)));
assert_eq!(fill(&s, ids_of(&v)[0]), linked(hex("#00ff00"), "Brand", 1.0), "it shows the swatch as it is now");
}
#[test]
fn paste_without_formatting_leaves_text_styles_behind() {
let mut s = session();
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hello"})).unwrap());
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0}})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": t.0})).unwrap();
copy(&mut s, &[t]);
new_doc(&mut s);
s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap();
assert!(doc(&s).char_styles.is_empty());
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(doc(&s).char_styles.len(), 1);
}
#[test]
fn clipboard_svg_carries_its_own_patterns() {
let mut s = session();
let dot = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[dot]);
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
let r = rect(&mut s, 20.0, 20.0, 100.0, 80.0);
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Dots"})).unwrap();
copy(&mut s, &[r]);
// Another document is active when the clipboard is published.
new_doc(&mut s);
let svg = s.clipboard_svg().unwrap();
assert!(svg.contains("<pattern"), "{svg}");
}
#[test]
fn a_failed_batch_puts_the_clipboard_back() {
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);
copy(&mut s, &[a]);
let steps = json!([
{"command": "select.set", "params": {"ids": [a.0, b.0]}},
{"command": "edit.copy"},
{"command": "no.such.command"},
]);
assert!(s.execute("command.batch", &json!({"commands": steps})).is_err());
assert_eq!(s.clipboard.nodes.len(), 1);
}
// ---------- M4.23: placement ----------
#[test]
fn paste_centres_on_the_given_point() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 40.0, 20.0);
copy(&mut s, &[r]);
let v = s.execute("edit.paste", &json!({"center": [300, 200]})).unwrap();
let b = doc(&s).bounds_of(&ids_of(&v), false).unwrap();
assert!((b.center() - Point::new(300.0, 200.0)).hypot() < 1e-9, "{b:?}");
// Without a centre, the offset applies.
let v = s.execute("edit.paste", &json!({"dx": 5, "dy": 7})).unwrap();
assert_eq!(doc(&s).bounds_of(&ids_of(&v), false).unwrap().origin(), Point::new(5.0, 7.0));
}
#[test]
fn front_and_back_without_a_selection_use_the_current_layers_ends() {
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);
copy(&mut s, &[a, b]);
let layer = |s: &Session| doc(s).layers[0].children().unwrap().iter().map(|n| n.id).collect::<Vec<_>>();
s.execute("select.none", &json!({})).unwrap();
let back = ids_of(&s.execute("edit.pasteInBack", &json!({})).unwrap());
assert_eq!(&layer(&s)[..2], &back[..], "pasted at the bottom (index 0), in order");
s.execute("select.none", &json!({})).unwrap();
let front = ids_of(&s.execute("edit.pasteInFront", &json!({})).unwrap());
let l = layer(&s);
assert_eq!(&l[l.len() - 2..], &front[..], "pasted on top, in order");
// With a selection: right above / below it.
sel(&mut s, &[a]);
let above = ids_of(&s.execute("edit.pasteInFront", &json!({})).unwrap());
let l = layer(&s);
let i = l.iter().position(|id| *id == a).unwrap();
assert_eq!(&l[i + 1..i + 3], &above[..]);
sel(&mut s, &[a]);
let below = ids_of(&s.execute("edit.pasteInBack", &json!({})).unwrap());
let l = layer(&s);
let i = l.iter().position(|id| *id == a).unwrap();
assert_eq!(&l[i - 2..i], &below[..]);
}
#[test]
fn paste_on_all_artboards_keeps_the_offset_to_the_source_artboard() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
let boards: Vec<Rect> = doc(&s).artboards.iter().map(|a| a.rect).collect();
// On artboard 2, 10 pt right and 15 pt down from its corner.
let r = rect(&mut s, boards[1].x0 + 10.0, boards[1].y0 + 15.0, 20.0, 20.0);
copy(&mut s, &[r]);
assert_eq!(s.clipboard.source_artboard, Some(boards[1]));
let ids = ids_of(&s.execute("edit.pasteOnAllArtboards", &json!({})).unwrap());
assert_eq!(ids.len(), 3);
for (id, b) in ids.iter().zip(&boards) {
assert_eq!(doc(&s).bounds_of(&[*id], false).unwrap().origin(), Point::new(b.x0 + 10.0, b.y0 + 15.0));
}
}
#[test]
fn paste_remembers_layers_restores_the_source_layers() {
let mut s = session();
let first = doc(&s).layers[0].id;
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let art = id_of(&s.execute("layer.new", &json!({"name": "Art"})).unwrap());
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
copy(&mut s, &[a, b]);
assert_eq!(s.clipboard.source_layers, [Some("Layer 1".to_string()), Some("Art".to_string())]);
let other = id_of(&s.execute("layer.new", &json!({"name": "Other"})).unwrap());
assert_eq!(s.execute("layer.pasteRemembersLayers", &json!({})).unwrap()["on"], true);
assert!(doc(&s).paste_remembers_layers);
assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["pasteRemembersLayers"], true);
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
let parents: Vec<_> = ids.iter().map(|id| doc(&s).parent_of(*id)).collect();
assert_eq!(parents, [Some(first), Some(art)]);
// In a document without those layers they are made.
new_doc(&mut s);
s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap();
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
let names: Vec<_> = ids.iter().map(|id| doc(&s).node(doc(&s).parent_of(*id).unwrap()).unwrap().name.clone().unwrap()).collect();
assert_eq!(names, ["Layer 1", "Art"]);
assert_eq!(doc(&s).layers.len(), 2, "Layer 1 exists; Art is new");
// The option is an undoable document setting.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(!doc(&s).paste_remembers_layers);
// Off: everything goes to the current layer.
s.execute("document.activate", &json!({"index": 0})).unwrap();
assert_eq!(s.execute("layer.pasteRemembersLayers", &json!({"on": false})).unwrap()["on"], false);
s.execute("layer.setCurrent", &json!({"id": other.0})).unwrap();
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
assert!(ids.iter().all(|id| doc(&s).parent_of(*id) == Some(other)));
}
#[test]
fn paste_remembers_layers_survives_save_and_old_files_load_without_it() {
let mut s = session();
s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap();
let bytes = vectorcraft_format::save(doc(&s), false);
assert!(vectorcraft_format::load(&bytes).unwrap().paste_remembers_layers);
let v: Value = serde_json::from_slice(&vectorcraft_format::save(&Document::new(10.0, 10.0), false)).unwrap();
assert!(v.get("paste_remembers_layers").is_none(), "off is not written");
assert!(!vectorcraft_format::load(v.to_string().as_bytes()).unwrap().paste_remembers_layers);
}