Files
vectorcraft/crates/engine/src/tests/appearance.rs
T
dotCooCoo b5da39d7b5 Commands: true/false flags reject other values everywhere
The remaining flags under crates/engine/src/cmd that read a boolean with
get(..).and_then(Value::as_bool).unwrap_or(..) now go through switch_or,
so a value such as "false" or 1 is an error instead of the default.
Omitting a flag or giving null keeps the old behavior.

- effect.move: copy
- aligned on the appearance.*, stroke.set, transparency.set and effect.*
  commands, checked before any early return
- transparency.viewOpacityMask: on
- view.printTiling: on
- plugin.install: replace and allowHeavy, checked for every plug-in
- edit.clear: guides, and path.deleteAnchors' read of it
- text.create and text.createInPath: vertical (already refused by
  check_switches, so their behavior is unchanged)
2026-11-06 13:47:16 +07:00

1017 lines
54 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.
//! Appearance stacks: the active item that paint, stroke, gradient and transparency edits target
//! (`appearance.setActiveItem`, the `item` param).
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
use vectorcraft_doc::{AppearanceItem, FillLayer, Node};
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
/// A selected 100×100 rectangle with the default appearance `[Fill white, Stroke black]`.
fn rect(s: &mut Session, x: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 100, "height": 100})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
id
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn stroke(n: &Node, i: usize) -> &vectorcraft_doc::StrokeLayer {
n.appearance.stroke_at(Some(i)).unwrap_or_else(|| panic!("item {i} is not a stroke"))
}
fn fill_paint(n: &Node, i: usize) -> &Paint {
&n.appearance.fill_at(Some(i)).unwrap_or_else(|| panic!("item {i} is not a fill")).paint
}
#[test]
fn stroke_set_with_item_changes_only_that_stroke() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.addStroke", json!({})); // [Fill, Stroke, Stroke]
run(&mut s, "stroke.set", json!({"item": 1, "dash": [4, 2], "weight": 6}));
let n = node(&s, id);
assert_eq!(stroke(&n, 1).dash.as_ref().unwrap().pattern, vec![4.0, 2.0]);
assert_eq!(stroke(&n, 1).width, 6.0);
assert!(stroke(&n, 2).dash.is_none());
assert_eq!(stroke(&n, 2).width, 1.0);
run(&mut s, "stroke.setAdvanced", json!({"item": 1, "arrowScale": [50, 200]}));
let n = node(&s, id);
assert_eq!((stroke(&n, 1).arrow_scale, stroke(&n, 2).arrow_scale), ((50.0, 200.0), (100.0, 100.0)));
// An explicit item must be a stroke; nothing changes when it is not.
assert!(s.execute("stroke.set", &json!({"item": 0, "weight": 9})).is_err());
assert!(s.execute("stroke.set", &json!({"item": 7, "weight": 9})).is_err());
assert!(s.execute("stroke.set", &json!({"item": "top", "weight": 9})).is_err());
assert_eq!(stroke(&node(&s, id), 2).width, 1.0);
// Without an item the top stroke changes.
run(&mut s, "stroke.set", json!({"weight": 3}));
let n = node(&s, id);
assert_eq!((stroke(&n, 1).width, stroke(&n, 2).width), (6.0, 3.0));
}
#[test]
fn edit_gradient_on_the_lower_fill() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill]
run(&mut s, "paint.editGradient", json!({"item": 0, "kind": "radial"}));
let n = node(&s, id);
let Paint::Gradient(g) = fill_paint(&n, 0) else { panic!("lower fill is not a gradient") };
assert_eq!(g.gradient.kind, GradientKind::Radial);
assert_eq!(*fill_paint(&n, 2), Paint::solid(Color::WHITE));
// The gradient vector of the lower fill, through the active item (the gradient tool's path).
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "paint.setGradientGeom", json!({"start": [100, 150], "end": [200, 150]}));
let n = node(&s, id);
let Paint::Gradient(g) = fill_paint(&n, 0) else { panic!() };
assert_eq!(g.geom.unwrap().end.x, 200.0);
assert_eq!(*fill_paint(&n, 2), Paint::solid(Color::WHITE));
// A stroke row makes the gradient vector a stroke gradient.
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
run(&mut s, "paint.setGradientGeom", json!({"start": [100, 150], "end": [100, 250]}));
assert!(matches!(stroke(&node(&s, id), 1).paint, Paint::Gradient(_)));
}
#[test]
fn active_item_targets_paint_and_proxies_and_clears_on_selection_change() {
let mut s = session();
let other = rect(&mut s, 250.0);
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill]
assert!(s.execute("appearance.setActiveItem", &json!({"index": 5})).is_err());
assert_eq!(run(&mut s, "appearance.setActiveItem", json!({"index": 0}))["index"], 0);
assert_eq!(s.appearance_item(), Some(0));
assert_eq!(inspect::document(&s)["paint"]["appearanceItem"], 0);
assert!(s.fill_active);
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
let n = node(&s, id);
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
assert_eq!(*fill_paint(&n, 2), Paint::solid(Color::WHITE));
// The stroke edits fall back to the top stroke while a fill row is active.
run(&mut s, "paint.setStroke", json!({"color": "#00ff00"}));
assert_eq!(stroke(&node(&s, id), 1).paint.color().unwrap().to_hex(), "#00ff00");
// The proxies show the active row for its kind and the top one for the other.
let (f, st) = s.proxy_paints();
assert_eq!((f.color().unwrap().to_hex(), st.color().unwrap().to_hex()), ("#ff0000".to_string(), "#00ff00".to_string()));
// A stroke row brings the Stroke proxy forward.
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
assert!(!s.fill_active);
// Changing the selection clears it, also when the same object is selected again.
run(&mut s, "select.set", json!({"ids": [other.0]}));
assert_eq!(s.appearance_item(), None);
run(&mut s, "select.set", json!({"ids": [id.0]}));
assert_eq!(s.appearance_item(), None);
run(&mut s, "paint.setFill", json!({"color": "#0000ff"}));
let n = node(&s, id);
assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#0000ff");
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
// Explicit ids never use the active item, and `null` forces the top one.
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "paint.setFill", json!({"color": "#222222", "ids": [id.0]}));
assert_eq!(fill_paint(&node(&s, id), 2).color().unwrap().to_hex(), "#222222");
run(&mut s, "paint.setFill", json!({"color": "#111111", "item": null}));
let n = node(&s, id);
assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#111111");
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
run(&mut s, "appearance.setActiveItem", json!({"index": null}));
assert_eq!(s.appearance_item(), None);
}
#[test]
fn item_rows_follow_remove_and_move() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill]
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
run(&mut s, "appearance.moveItem", json!({"from": 1, "to": 2}));
assert_eq!(s.appearance_item(), Some(2));
assert!(matches!(node(&s, id).appearance.items[2], AppearanceItem::Stroke(_)));
run(&mut s, "appearance.removeItem", json!({"index": 0}));
assert_eq!(s.appearance_item(), Some(1));
run(&mut s, "appearance.removeItem", json!({"index": 1}));
assert_eq!(s.appearance_item(), None);
}
#[test]
fn transparency_goes_to_the_targeted_item() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "transparency.set", json!({"item": 1, "opacity": 40, "blend": "multiply"}));
let n = node(&s, id);
assert_eq!((stroke(&n, 1).opacity, stroke(&n, 1).blend), (0.4, BlendMode::Multiply));
assert_eq!((n.opacity, n.blend), (1.0, BlendMode::Normal));
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
// Opacity and blend go to the active item, isolate stays on the object.
run(&mut s, "transparency.set", json!({"opacity": 1, "isolate": true}));
let n = node(&s, id);
assert!((n.appearance.items[0].opacity() - 0.01).abs() < 1e-6);
assert!(n.isolate);
assert_eq!(n.opacity, 1.0);
run(&mut s, "transparency.set", json!({"item": null, "opacity": 50}));
assert_eq!(node(&s, id).opacity, 0.5);
assert!(s.execute("transparency.set", &json!({"item": 9, "opacity": 50})).is_err());
}
#[test]
fn group_rows_are_the_groups_own_stack() {
let mut s = session();
let a = rect(&mut s, 100.0);
let b = rect(&mut s, 250.0);
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "appearance.addFill", json!({}));
assert_eq!(node(&s, g).appearance.items.len(), 1);
assert_eq!(node(&s, a).appearance.items.len(), 2);
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
assert_eq!(fill_paint(&node(&s, g), 0).color().unwrap().to_hex(), "#ff0000");
assert_eq!(*fill_paint(&node(&s, a), 0), Paint::solid(Color::WHITE));
// Without a targeted item, a group's fill edit still recolours its contents.
run(&mut s, "appearance.setActiveItem", json!({"index": null}));
run(&mut s, "paint.setFill", json!({"color": "#00ff00"}));
assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00");
}
#[test]
fn text_items_take_item_paint() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Hi"}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "paint.setFill", json!({"item": 0, "color": "#ff0000"}));
let n = node(&s, t);
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
let vectorcraft_doc::NodeKind::Text(tx) = &n.kind else { panic!() };
assert_ne!(tx.first_style().fill, Paint::solid(Color::from_hex("#ff0000").unwrap()));
}
#[test]
fn effects_land_on_the_targeted_item() {
let mut s = session();
let id = rect(&mut s, 100.0);
let r = run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1}));
assert_eq!((r["index"].clone(), r["item"].clone()), (json!(0), json!(1)));
let n = node(&s, id);
assert_eq!(stroke(&n, 1).effects.len(), 1);
assert!(n.appearance.effects.is_empty() && n.appearance.items[0].effects().is_empty());
run(&mut s, "edit.undo", json!({}));
assert!(stroke(&node(&s, id), 1).effects.is_empty());
assert!(s.execute("effect.apply", &json!({"effect": "distort.roughen", "item": 9})).is_err());
assert!(s.execute("effect.apply", &json!({"effect": "distort.roughen", "item": -1})).is_err());
// The active item takes the Effect menu's effects (and its alias).
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "effect.apply", json!({"effect": "distort.twist"}));
run(&mut s, "appearance.addEffect", json!({"effect": "stylize.roundCorners"}));
let n = node(&s, id);
assert_eq!(n.appearance.items[0].effects().iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["distort.twist", "stylize.roundCorners"]);
let l = run(&mut s, "effect.list", json!({}));
assert_eq!(l["activeItem"], 0);
assert_eq!(l["applied"][0]["items"][0]["kind"], "fill");
assert_eq!(l["applied"][0]["items"][0]["effects"].as_array().unwrap().len(), 2);
// Sub-row eye, duplicate and delete address the item's list.
run(&mut s, "effect.setParams", json!({"index": 1, "visible": false}));
run(&mut s, "effect.duplicate", json!({"index": 0}));
run(&mut s, "effect.remove", json!({"index": 2}));
let fx = node(&s, id).appearance.items[0].effects().clone();
assert_eq!(fx.iter().map(|e| (e.id.as_str(), e.visible)).collect::<Vec<_>>(), [("distort.twist", true), ("distort.twist", true)]);
assert!(s.execute("effect.remove", &json!({"index": 5})).is_err());
// `item: null` addresses the object's own effects.
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": null}));
assert_eq!(node(&s, id).appearance.effects.len(), 1);
}
#[test]
fn per_fill_offset_path_renders() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "paint.setStroke", json!({"none": true}));
let render = |s: &Session| {
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default())
};
assert_eq!(render(&s).pixel(90, 150)[3], 0);
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 20}, "item": 0}));
assert_eq!(node(&s, id).appearance.items[0].effects().len(), 1);
let px = render(&s).pixel(90, 150);
assert!(px[0] > 200 && px[3] > 200, "{px:?}");
}
#[test]
fn text_fill_items_composite_with_their_opacity() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 200, "text": "HIM", "size": 150}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 0, "color": "#ff0000"}));
let render = |s: &Session| {
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default())
};
let opaque = render(&s);
// A pixel the red fill covers fully (over the black character fill).
let (x, y) = (0..400u32).flat_map(|y| (0..400u32).map(move |x| (x, y))).find(|&(x, y)| opaque.pixel(x, y) == [255, 0, 0, 255]).expect("red text");
run(&mut s, "appearance.setItem", json!({"index": 0, "opacity": 50, "blend": "multiply"}));
let px = render(&s).pixel(x, y);
// Half of red multiplied over black: black.
assert!(px[0] < 20 && px[3] == 255, "{px:?}");
run(&mut s, "appearance.setItem", json!({"index": 0, "blend": "normal"}));
let px = render(&s).pixel(x, y);
assert!((110..=145).contains(&px[0]) && px[1] < 10, "{px:?}");
}
#[test]
fn add_fill_and_stroke_copy_the_top_ones() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "stroke.set", json!({"weight": 4}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.addStroke", json!({})); // [Fill, Stroke, Fill, Stroke]
let n = node(&s, id);
assert_eq!(n.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "stroke", "fill", "stroke"]);
assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#ff0000");
assert_eq!(stroke(&n, 3).width, 4.0);
assert!(!n.appearance.is_basic());
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, id).appearance.items.len(), 3);
}
/// With a fill row active, Add New Fill puts a fill with that row's paint right above it and makes
/// the new fill the active row; the rows above it stay where they are.
#[test]
fn add_fill_copies_the_active_fill_row_right_above_it() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#0000ff"})); // [red, Stroke, blue]
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
let steps = s.doc().unwrap().history.undo.len();
run(&mut s, "appearance.addFill", json!({}));
let n = node(&s, id);
assert_eq!(n.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "fill", "stroke", "fill"]);
let hex = |i: usize| fill_paint(&n, i).color().unwrap().to_hex();
assert_eq!((hex(0), hex(1), hex(3)), ("#ff0000".to_string(), "#ff0000".to_string(), "#0000ff".to_string()));
assert_eq!(s.appearance_item(), Some(1), "the new fill is the active row");
// The next edit goes to the new fill.
run(&mut s, "paint.setFill", json!({"color": "#00ff00"}));
let n = node(&s, id);
assert_eq!((fill_paint(&n, 0).color().unwrap().to_hex(), fill_paint(&n, 1).color().unwrap().to_hex()), ("#ff0000".into(), "#00ff00".into()));
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 2);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, id).appearance.items.len(), 3);
// An active stroke row, or ids, leave the new fill on top.
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
run(&mut s, "appearance.addFill", json!({}));
assert_eq!(node(&s, id).appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "stroke", "fill", "fill"]);
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "appearance.addFill", json!({"ids": [id.0]}));
assert_eq!(fill_paint(&node(&s, id), 4).color().unwrap().to_hex(), "#0000ff");
assert_eq!(s.appearance_item(), Some(0));
}
/// With a fill row active, Add New Fill given `id` alone also puts a copy of the top fill on top,
/// and the active row stays.
#[test]
fn add_fill_with_id_leaves_the_active_fill_row_alone() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#0000ff"})); // [red, Stroke, blue]
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "appearance.addFill", json!({"id": id.0}));
let n = node(&s, id);
assert_eq!(n.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "stroke", "fill", "fill"]);
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
assert_eq!(fill_paint(&n, 3).color().unwrap().to_hex(), "#0000ff");
assert_eq!(s.appearance_item(), Some(0));
}
/// Add New Stroke copies the top stroke's weight but makes the new stroke at least 1 pt.
#[test]
fn add_stroke_is_at_least_one_point() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "stroke.set", json!({"weight": 0.5}));
run(&mut s, "appearance.addStroke", json!({})); // [Fill, Stroke, Stroke]
let n = node(&s, id);
assert_eq!(stroke(&n, 1).width, 0.5);
assert_eq!(stroke(&n, 2).width, 1.0);
}
/// The new fill above the active fill row takes only the row's paint: the row's opacity, blend
/// mode, visibility, overprint and effects are not copied (Duplicate Item copies those).
#[test]
fn add_fill_takes_only_the_active_fill_rows_paint() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "appearance.setItem", json!({"index": 0, "opacity": 40, "blend": "multiply", "visible": false}));
run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 0}));
run(&mut s, "object.setOverprint", json!({"fill": true, "item": 0}));
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "appearance.addFill", json!({}));
let n = node(&s, id);
assert_eq!(n.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "fill", "stroke"]);
let (AppearanceItem::Fill(row), AppearanceItem::Fill(copy)) = (&n.appearance.items[0], &n.appearance.items[1]) else {
panic!("the first two rows are not fills")
};
assert_eq!((row.opacity, row.blend, row.visible, row.overprint), (0.4, BlendMode::Multiply, false, true));
assert_eq!(row.effects.len(), 1);
assert_eq!(copy, &FillLayer::new(row.paint.clone()));
}
/// In a mixed selection the active fill row goes above itself in the first object; an object that
/// doesn't share that row gets a fill with its top fill's paint on top, as without an active row.
#[test]
fn add_fill_above_an_unshared_active_row_tops_the_other_objects() {
let mut s = session();
let (a, b) = mixed_circles(&mut s, true); // a [yellow, Stroke], b [red, green, Stroke]
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "appearance.addFill", json!({}));
let (na, nb) = (node(&s, a), node(&s, b));
assert_eq!(na.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "fill", "stroke"]);
assert_eq!(fill_paint(&na, 1).color().unwrap().to_hex(), "#ffff00");
assert_eq!(nb.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "fill", "stroke", "fill"]);
assert_eq!(fill_paint(&nb, 3).color().unwrap().to_hex(), "#00ff00");
assert_eq!(s.appearance_item(), Some(1));
}
/// The active row is a row of the first selected object, also when that object paints above the
/// others.
#[test]
fn add_fill_goes_above_the_active_row_of_the_first_selected_object() {
let mut s = session();
let (a, b) = mixed_circles(&mut s, true); // a [yellow, Stroke], b [red, green, Stroke]
run(&mut s, "select.set", json!({"ids": [b.0, a.0]}));
run(&mut s, "appearance.setActiveItem", json!({"index": 1})); // b's green fill
run(&mut s, "appearance.addFill", json!({}));
let (na, nb) = (node(&s, a), node(&s, b));
assert_eq!(nb.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "fill", "fill", "stroke"]);
assert_eq!(fill_paint(&nb, 2).color().unwrap().to_hex(), "#00ff00");
assert_eq!(na.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "stroke", "fill"]);
assert_eq!(fill_paint(&na, 2).color().unwrap().to_hex(), "#ffff00");
assert_eq!(s.appearance_item(), Some(2));
}
#[test]
fn set_item_and_remove_item() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 50, "blend": "screen", "weight": 3, "color": "#0000ff"}));
let n = node(&s, id);
let st = stroke(&n, 1);
assert_eq!((st.opacity, st.blend, st.width), (0.5, BlendMode::Screen, 3.0));
assert_eq!(st.paint.color().unwrap().to_hex(), "#0000ff");
run(&mut s, "appearance.setItem", json!({"index": 0, "visible": false, "opacity": 25}));
let n = node(&s, id);
assert!(!n.appearance.items[0].visible() && n.appearance.items[0].opacity() == 0.25);
assert!(s.execute("appearance.setItem", &json!({})).is_err());
run(&mut s, "appearance.removeItem", json!({"index": 0}));
let n = node(&s, id);
assert_eq!(n.appearance.items.len(), 1);
assert!(n.appearance.fill().is_none() && n.appearance.stroke().is_some());
}
#[test]
fn clear_and_reduce_to_basic() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 2, "visible": false, "color": "#00ff00"}));
run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 50}));
run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1}));
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": null}));
run(&mut s, "transparency.set", json!({"opacity": 40, "blend": "multiply"}));
// Reduce keeps the topmost visible fill and stroke, plain, and the object's transparency.
run(&mut s, "appearance.reduceToBasic", json!({}));
let n = node(&s, id);
assert!(n.appearance.is_basic(), "{:?}", n.appearance);
assert_eq!(stroke(&n, 1).paint, Paint::solid(Color::BLACK));
assert_eq!((n.opacity, n.blend), (0.4, BlendMode::Multiply));
// Clear leaves one None fill and stroke, and resets the object's Opacity row.
run(&mut s, "appearance.clear", json!({}));
let n = node(&s, id);
assert_eq!(n.appearance, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0));
assert_eq!((n.opacity, n.blend), (1.0, BlendMode::Normal));
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, id).opacity, 0.4);
}
/// Reduce to Basic Appearance keeps the Appearance panel's active fill or stroke row instead of the
/// topmost one of its kind, in each selected object with the same stack. With `ids` it keeps the
/// topmost ones.
#[test]
fn reduce_to_basic_keeps_the_active_row() {
let mut s = session();
let a = rect(&mut s, 50.0);
let b = rect(&mut s, 200.0);
let c = rect(&mut s, 300.0);
let stack = |s: &mut Session| {
run(s, "appearance.addFill", json!({}));
run(s, "appearance.addStroke", json!({})); // [Fill, Stroke, Fill, Stroke]
run(s, "appearance.setItem", json!({"index": 0, "color": "#00ff00"}));
run(s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"}));
run(s, "appearance.setItem", json!({"index": 1, "weight": 7}));
};
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
stack(&mut s);
// The lower, green fill row is active: both objects keep it and their top stroke.
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "appearance.reduceToBasic", json!({}));
for id in [a, b] {
let n = node(&s, id);
assert!(n.appearance.is_basic(), "{:?}", n.appearance);
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#00ff00");
assert_eq!(stroke(&n, 1).width, 1.0);
}
// The lower stroke row is active: it stays, with the top fill.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
run(&mut s, "appearance.reduceToBasic", json!({}));
let n = node(&s, a);
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
assert_eq!(stroke(&n, 1).width, 7.0);
// Given ids, the topmost fill and stroke stay.
run(&mut s, "select.set", json!({"ids": [c.0]}));
stack(&mut s);
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "appearance.reduceToBasic", json!({"ids": [c.0]}));
let n = node(&s, c);
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
assert_eq!(stroke(&n, 1).width, 1.0);
}
/// The active row is a row of the first selected object even when that object paints above the
/// others; another object without an equal row keeps its topmost fill.
#[test]
fn reduce_to_basic_keeps_the_active_row_of_the_first_selected_object() {
let mut s = session();
let a = rect(&mut s, 50.0);
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#0000ff"})); // [Fill, Stroke, Fill blue]
let b = rect(&mut s, 200.0);
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 0, "color": "#00ff00"}));
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"})); // [Fill green, Stroke, Fill red]
run(&mut s, "select.set", json!({"ids": [b.0, a.0]}));
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "appearance.reduceToBasic", json!({}));
assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00");
assert_eq!(fill_paint(&node(&s, a), 0).color().unwrap().to_hex(), "#0000ff");
}
/// Three objects with equal stacks, the third's lower fill changed; the lower, green fill row of
/// the first is active, selected a, b, c.
fn three_with_one_changed(s: &mut Session) -> [NodeId; 3] {
let ids = [rect(s, 50.0), rect(s, 200.0), rect(s, 300.0)];
run(s, "select.set", json!({"ids": ids.map(|id| id.0)}));
run(s, "appearance.addFill", json!({}));
run(s, "appearance.setItem", json!({"index": 0, "color": "#00ff00"}));
run(s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"})); // [Fill green, Stroke, Fill red]
run(s, "appearance.setItem", json!({"index": 0, "color": "#0000ff", "ids": [ids[2].0]}));
run(s, "appearance.setActiveItem", json!({"index": 0}));
ids
}
/// The active row is matched in each other object on its own: one object without an equal row
/// does not stop another that has one from keeping it.
#[test]
fn reduce_to_basic_keeps_the_active_row_in_each_object_that_has_it() {
let mut s = session();
let [a, b, c] = three_with_one_changed(&mut s);
run(&mut s, "appearance.reduceToBasic", json!({}));
assert_eq!(fill_paint(&node(&s, a), 0).color().unwrap().to_hex(), "#00ff00");
assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00");
assert_eq!(fill_paint(&node(&s, c), 0).color().unwrap().to_hex(), "#ff0000");
}
/// Add New Fill puts its copy of the active row right above that row in each object that has an
/// equal row, even when another selected object has none.
#[test]
fn add_fill_copies_the_active_row_in_each_object_that_has_it() {
let mut s = session();
let [a, b, _] = three_with_one_changed(&mut s);
run(&mut s, "appearance.addFill", json!({}));
for id in [a, b] {
let n = node(&s, id);
assert_eq!(fill_paint(&n, 1).color().unwrap().to_hex(), "#00ff00", "{:?}", n.appearance);
assert_eq!(fill_paint(&n, 3).color().unwrap().to_hex(), "#ff0000", "{:?}", n.appearance);
}
}
/// A paint edit of the active row changes the equal row of each object that has one, even when
/// another selected object has none; that object's row stands for itself, as with only it and the
/// first selected object selected.
#[test]
fn paint_edits_change_the_active_row_in_each_object_that_has_it() {
let mut s = session();
let [a, b, c] = [rect(&mut s, 50.0), rect(&mut s, 200.0), rect(&mut s, 300.0)];
run(&mut s, "select.set", json!({"ids": [a.0, b.0, c.0]}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 0, "color": "#00ff00"}));
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"})); // [Fill green, Stroke, Fill red]
// b gets a yellow fill below the others: [Fill yellow, Fill green, Stroke, Fill red].
run(&mut s, "appearance.addFill", json!({"ids": [b.0]}));
run(&mut s, "appearance.moveItem", json!({"ids": [b.0], "from": 3, "to": 0}));
run(&mut s, "appearance.setItem", json!({"ids": [b.0], "index": 0, "color": "#ffff00"}));
run(&mut s, "appearance.setItem", json!({"ids": [c.0], "index": 0, "color": "#0000ff"})); // [Fill blue, Stroke, Fill red]
run(&mut s, "select.set", json!({"ids": [a.0, b.0, c.0]}));
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "paint.setFill", json!({"color": "#ff00ff"}));
let (na, nb, nc) = (node(&s, a), node(&s, b), node(&s, c));
assert_eq!(fill_paint(&na, 0).color().unwrap().to_hex(), "#ff00ff");
assert_eq!(fill_paint(&nb, 1).color().unwrap().to_hex(), "#ff00ff", "{:?}", nb.appearance);
assert_eq!(fill_paint(&nb, 0).color().unwrap().to_hex(), "#ffff00", "{:?}", nb.appearance);
assert_eq!(fill_paint(&nc, 0).color().unwrap().to_hex(), "#ff00ff", "{:?}", nc.appearance);
}
/// An object-level effect is removed from each object that has an equal one, even when another
/// selected object has none.
#[test]
fn removing_an_effect_reaches_each_object_that_has_it() {
let mut s = session();
let [a, b, c] = [rect(&mut s, 50.0), rect(&mut s, 200.0), rect(&mut s, 300.0)];
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "stylize.feather", "item": null}));
run(&mut s, "effect.apply", json!({"ids": [a.0, b.0], "effect": "distort.twist", "item": null}));
// a has [feather, twist], b [twist], c none.
run(&mut s, "select.set", json!({"ids": [a.0, b.0, c.0]}));
run(&mut s, "effect.remove", json!({"index": 1, "item": null, "aligned": true}));
let ids = |id: NodeId| node(&s, id).appearance.effects.iter().map(|e| e.id.clone()).collect::<Vec<_>>();
assert_eq!(ids(a), ["stylize.feather"]);
assert!(ids(b).is_empty(), "b loses its twist: {:?}", ids(b));
assert!(ids(c).is_empty());
}
/// b `[Fill red, Fill white, Stroke]` above a `[Fill white, Stroke]`, selected b first: b's rows 1
/// and 2 equal a's rows 0 and 1.
fn reversed_pair(s: &mut Session) -> (NodeId, NodeId) {
let a = rect(s, 50.0);
let b = rect(s, 200.0);
run(s, "appearance.addFill", json!({}));
run(s, "appearance.moveItem", json!({"from": 2, "to": 0}));
run(s, "appearance.setItem", json!({"index": 0, "color": "#ff0000"}));
run(s, "select.set", json!({"ids": [b.0, a.0]}));
(a, b)
}
/// The active row is a row of the first selected object also for paint, stroke and effect edits
/// when that object paints above the others.
#[test]
fn edits_change_the_active_row_of_the_first_selected_object() {
let mut s = session();
let (a, b) = reversed_pair(&mut s);
run(&mut s, "appearance.setActiveItem", json!({"index": 0})); // b's red fill
run(&mut s, "paint.setFill", json!({"color": "#00ff00"}));
let (na, nb) = (node(&s, a), node(&s, b));
assert_eq!(fill_paint(&nb, 0).color().unwrap().to_hex(), "#00ff00");
assert_eq!(*fill_paint(&nb, 1), Paint::solid(Color::WHITE));
// a shares no red row, so its fill row 0 changes, as for an active row the objects don't share.
assert_eq!(fill_paint(&na, 0).color().unwrap().to_hex(), "#00ff00");
run(&mut s, "effect.apply", json!({"effect": "distort.twist"}));
let nb = node(&s, b);
assert_eq!((nb.appearance.items[0].effects().len(), nb.appearance.items[1].effects().len()), (1, 0));
let mut s = session();
let (a, b) = reversed_pair(&mut s);
run(&mut s, "appearance.setActiveItem", json!({"index": 1})); // b's white fill, a's row 0
run(&mut s, "paint.editGradient", json!({"kind": "radial"}));
let (na, nb) = (node(&s, a), node(&s, b));
assert!(matches!(fill_paint(&nb, 1), Paint::Gradient(_)), "b's white fill takes the gradient");
assert!(matches!(fill_paint(&na, 0), Paint::Gradient(_)), "so does a's equal fill");
assert_eq!(fill_paint(&nb, 0).color().unwrap().to_hex(), "#ff0000");
assert!(!matches!(stroke(&nb, 2).paint, Paint::Gradient(_)) && !matches!(stroke(&na, 1).paint, Paint::Gradient(_)));
// The effect goes on the equal rows, and the reply names the active row of b.
run(&mut s, "appearance.setActiveItem", json!({"index": 2})); // b's stroke, a's row 1
let r = run(&mut s, "effect.apply", json!({"effect": "distort.twist"}));
assert_eq!(r["item"], 2);
let (na, nb) = (node(&s, a), node(&s, b));
assert_eq!((stroke(&nb, 2).effects.len(), stroke(&na, 1).effects.len()), (1, 1));
// With nothing selected an aligned item is an error, not a crash.
run(&mut s, "select.set", json!({"ids": []}));
let e = s.execute("effect.remove", &json!({"index": 0, "item": 0, "aligned": true})).unwrap_err().to_string();
assert!(!e.contains("internal error"), "{e}");
}
/// `effect.apply`'s reply names the new effect by its index in the object whose row `item` is,
/// also when the objects painted after it already have effects there.
#[test]
fn apply_reports_the_index_in_the_object_whose_row_it_names() {
let mut s = session();
let (a, b) = reversed_pair(&mut s);
let c = rect(&mut s, 350.0);
run(&mut s, "effect.apply", json!({"effect": "distort.twist", "item": 0}));
// b's red fill row is not shared, so each object's row 0 takes the effect: c's second one.
run(&mut s, "select.set", json!({"ids": [b.0, a.0, c.0]}));
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
let r = run(&mut s, "effect.apply", json!({"effect": "distort.twist"}));
assert_eq!(node(&s, c).appearance.items[0].effects().len(), 2);
assert_eq!((r["item"].clone(), r["index"].clone()), (json!(0), json!(0)), "b's row 0 holds one effect");
// An explicit item is a row of the first target, a, which paints below c.
run(&mut s, "select.set", json!({"ids": [c.0, a.0]}));
let r = run(&mut s, "effect.apply", json!({"effect": "distort.twist", "item": 0}));
assert_eq!(node(&s, c).appearance.items[0].effects().len(), 3);
assert_eq!((r["item"].clone(), r["index"].clone()), (json!(0), json!(1)), "a's row 0 holds two effects");
}
/// `effect.move`'s reply names where the effect landed in the object whose row the active item is
/// (the first selected object), also when that object paints above the others.
#[test]
fn move_reports_the_index_in_the_object_whose_row_it_names() {
let mut s = session();
let (a, b) = reversed_pair(&mut s);
run(&mut s, "select.set", json!({"ids": [a.0]}));
run(&mut s, "effect.apply", json!({"effect": "distort.twist", "item": 0}));
run(&mut s, "select.set", json!({"ids": [b.0]}));
run(&mut s, "effect.apply", json!({"effect": "distort.twist", "item": 0}));
run(&mut s, "effect.apply", json!({"effect": "distort.twist", "item": 0}));
run(&mut s, "select.set", json!({"ids": [b.0, a.0]}));
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
// Row 0's first effect goes to the end of the row: index 1 in b, 0 in a, which paints first.
let r = run(&mut s, "effect.move", json!({"from": 0, "to": 9}));
assert_eq!((node(&s, a).appearance.items[0].effects().len(), node(&s, b).appearance.items[0].effects().len()), (1, 2));
assert_eq!((r["item"].clone(), r["index"].clone()), (json!(0), json!(1)), "b's row 0 holds two effects");
// An explicit item is a row of the first target, a.
let r = run(&mut s, "effect.move", json!({"from": 0, "to": 9, "fromItem": 0}));
assert_eq!((r["item"].clone(), r["index"].clone()), (json!(0), json!(0)), "a's row 0 holds one effect");
// When the first selected object has no effect to move, the reply names the first object that moved.
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
let r = run(&mut s, "effect.move", json!({"from": 1, "to": 0}));
assert_eq!(r["ids"], json!([b.0]), "only b has a second effect in row 0");
assert_eq!((r["item"].clone(), r["index"].clone()), (json!(0), json!(0)), "b's effect moved to the start of its row 0");
// From the active row to an explicit destination (here the object's effects, where b already
// has one), the reply names the first target, a.
run(&mut s, "select.set", json!({"ids": [b.0]}));
run(&mut s, "effect.apply", json!({"effect": "distort.twist", "item": null}));
run(&mut s, "select.set", json!({"ids": [b.0, a.0]}));
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
let r = run(&mut s, "effect.move", json!({"from": 0, "to": 9, "toItem": null}));
assert_eq!((node(&s, a).appearance.effects.len(), node(&s, b).appearance.effects.len()), (1, 2));
assert_eq!((r["item"].clone(), r["index"].clone()), (json!(null), json!(0)), "a's effects hold one effect");
}
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
fn history(s: &Session) -> (usize, usize, bool) {
let st = s.doc().unwrap();
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
}
/// Select `ids` with a move of them left to redo, and mark the document saved.
fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.doc_mut().unwrap().mark_saved();
}
/// Fill, stroke and gradient edits that leave every object as it was (the color, stroke options,
/// gradient or item values the objects have already) record no undo step: the step to redo stays
/// and the document stays unmodified. A fill that changes one of them is one step.
#[test]
fn fill_stroke_and_gradient_edits_that_change_nothing_record_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 50.0);
let b = rect(&mut s, 200.0);
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "paint.setStroke", json!({"color": "#0000ff"}));
run(&mut s, "stroke.set", json!({"weight": 3, "dash": [4, 2], "cap": "round"}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill, Fill], every fill red
run(&mut s, "paint.setGradientGeom", json!({"item": 0, "start": [60, 150], "end": [140, 150]}));
run(&mut s, "paint.editGradient", json!({"item": 2, "kind": "freeform"}));
run(&mut s, "paint.freeform.setPoint", json!({"item": 2, "index": 0, "color": "#00ff00"}));
select_with_a_step_to_redo(&mut s, &[a, b]);
let before = history(&s);
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
for (id, p) in [
("paint.setFill", json!({"color": "#ff0000"})),
("paint.setStroke", json!({"color": "#0000ff"})),
("paint.setFill", json!({"color": "#ff0000", "ids": [a.0]})),
("paint.lastColor", json!({"stroke": false})),
("stroke.set", json!({"weight": 3, "dash": [4, 2], "cap": "round"})),
("stroke.setAdvanced", json!({"arrowScale": [100, 100]})),
("paint.setGradientGeom", json!({"item": 0, "start": [60, 150], "end": [140, 150]})),
("paint.editGradient", json!({"item": 0, "kind": "linear"})),
("paint.freeform.setPoint", json!({"item": 2, "index": 0, "color": "#00ff00"})),
("transparency.set", json!({"item": 1, "opacity": 100, "blend": "normal"})),
] {
run(&mut s, id, p.clone());
assert_eq!(history(&s), before, "{id} {p}");
}
run(&mut s, "paint.setFill", json!({"color": "#00ff00", "ids": [b.0]}));
assert_eq!(history(&s), (before.0 + 1, 0, true));
assert_eq!(fill_paint(&node(&s, b), 3).color().unwrap().to_hex(), "#00ff00");
}
/// Appearance panel commands that leave every object as it was record no undo step: Reduce to Basic
/// Appearance on a basic appearance, an item set to the values it has or to an index no object has,
/// Remove Item past every stack, Move Item to where the item is, the Eyedropper and Move Appearance
/// between objects that look the same, and Clear Appearance on cleared objects. Clearing objects that
/// have something to clear is one step.
#[test]
fn appearance_commands_that_change_nothing_record_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 50.0);
let b = rect(&mut s, 200.0);
select_with_a_step_to_redo(&mut s, &[a, b]);
let before = history(&s);
for (id, p) in [
("appearance.reduceToBasic", json!({})),
("appearance.setItem", json!({"index": 1, "opacity": 100, "blend": "normal", "visible": true, "weight": 1})),
("appearance.setItem", json!({"index": 5, "opacity": 50})),
("appearance.removeItem", json!({"index": 5})),
("appearance.moveItem", json!({"from": 1, "to": 1})),
("appearance.copyFrom", json!({"source": b.0, "ids": [a.0]})),
("appearance.transfer", json!({"source": b.0, "target": a.0, "copy": true})),
] {
run(&mut s, id, p.clone());
assert_eq!(history(&s), before, "{id} {p}");
}
run(&mut s, "appearance.clear", json!({}));
assert_eq!(history(&s), (before.0 + 1, 0, true), "the first Clear is one step");
s.doc_mut().unwrap().mark_saved();
let cleared = history(&s);
for (id, p) in [("appearance.clear", json!({})), ("appearance.transfer", json!({"source": b.0, "target": a.0}))] {
run(&mut s, id, p.clone());
assert_eq!(history(&s), cleared, "{id} {p}");
}
}
/// The Appearance panel's commands refuse a weight or opacity that is not a number and an on/off
/// option that is not true or false, given as text or as another value of the wrong kind; the
/// objects and the undo history stay as they were. Numbers, true, false and null still work.
#[test]
fn appearance_commands_refuse_a_number_or_boolean_given_as_text() {
use crate::tests::{not_a_number, not_a_switch, not_refused};
let mut s = session();
let a = rect(&mut s, 50.0);
let b = rect(&mut s, 200.0);
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let calls = [
not_a_number("appearance.setItem", json!({"index": 1, "weight": "3"}), "weight"),
not_a_number("appearance.setItem", json!({"index": 0, "opacity": "50"}), "opacity"),
not_a_switch("appearance.setItem", json!({"index": 0, "visible": "false"}), "visible"),
not_a_switch("appearance.transfer", json!({"source": b.0, "target": a.0, "copy": "true"}), "copy"),
not_a_switch("appearance.copyFrom", json!({"source": b.0, "ids": [a.0], "reverse": "true"}), "reverse"),
not_a_switch("appearance.copyFrom", json!({"source": b.0, "ids": [a.0], "append": 1}), "append"),
];
let wrong = not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
run(&mut s, "appearance.setItem", json!({"index": 1, "weight": 3, "visible": null}));
run(&mut s, "appearance.transfer", json!({"source": b.0, "target": a.0, "copy": true}));
assert_eq!((stroke(&node(&s, a), 1).width, fill_paint(&node(&s, a), 0)), (stroke(&node(&s, b), 1).width, fill_paint(&node(&s, b), 0)));
}
#[test]
fn graphic_style_apply_replaces_the_stack() {
let mut s = session();
let a = rect(&mut s, 100.0);
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "effect.apply", json!({"effect": "distort.roughen"}));
let name = run(&mut s, "graphicStyle.new", json!({"name": "Rough"}))["name"].clone();
let b = rect(&mut s, 250.0);
run(&mut s, "graphicStyle.apply", json!({ "name": name }));
assert_eq!(node(&s, b).appearance, node(&s, a).appearance);
assert!(s.execute("graphicStyle.apply", &json!({"name": "nope"})).is_err());
}
#[test]
fn an_active_row_of_the_other_kind_leaves_edits_on_the_painted_leaves() {
let mut s = session();
let a = rect(&mut s, 100.0);
let b = rect(&mut s, 250.0);
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "appearance.addFill", json!({}));
// The group's own fill row is active: stroke edits still go to the members' strokes.
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "stroke.set", json!({"weight": 5}));
run(&mut s, "paint.setStroke", json!({"color": "#00ff00"}));
assert_eq!(node(&s, g).appearance.items.len(), 1, "no stroke row added to the group");
for id in [a, b] {
assert_eq!(stroke(&node(&s, id), 1).width, 5.0);
assert_eq!(stroke(&node(&s, id), 1).paint.color().unwrap().to_hex(), "#00ff00");
}
}
#[test]
fn the_active_row_follows_only_edits_of_its_object() {
let mut s = session();
let other = rect(&mut s, 250.0);
rect(&mut s, 100.0);
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
run(&mut s, "appearance.removeItem", json!({"index": 0, "ids": [other.0]}));
run(&mut s, "appearance.duplicateItem", json!({"index": 0, "ids": [other.0]}));
assert_eq!(s.appearance_item(), Some(1));
}
#[test]
fn item_transparency_is_one_step_and_checks_the_blend_mode() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
let steps = |s: &Session| s.doc().unwrap().history.undo.len();
let before = steps(&s);
run(&mut s, "transparency.set", json!({"knockout": true}));
assert_eq!(steps(&s), before + 1, "isolate/knockout alone do not touch the item");
assert_eq!(node(&s, id).knockout, vectorcraft_doc::Knockout::On);
assert!(s.execute("transparency.set", &json!({"blend": "nope"})).is_err());
assert_eq!(stroke(&node(&s, id), 1).blend, BlendMode::Normal);
}
#[test]
fn clear_and_reduce_keep_a_groups_stack_empty() {
let mut s = session();
let a = rect(&mut s, 100.0);
let b = rect(&mut s, 250.0);
let members = node(&s, a).appearance;
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "appearance.reduceToBasic", json!({}));
assert!(node(&s, g).appearance.items.is_empty());
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "appearance.reduceToBasic", json!({}));
assert_eq!(node(&s, g).appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["stroke"]);
run(&mut s, "transparency.set", json!({"opacity": 50}));
run(&mut s, "appearance.clear", json!({}));
let n = node(&s, g);
assert!(n.appearance.items.is_empty() && n.opacity == 1.0);
// The members keep their own appearance.
assert_eq!(node(&s, a).appearance, members);
}
/// #4174: two circles with different fills, the same 11 pt blue stroke and the same Feather,
/// both selected. Their default stacks are `[Fill, Stroke]`; the second gets a second fill under
/// its stroke when `extra_fill`, so the shared stroke sits at another index there.
fn mixed_circles(s: &mut Session, extra_fill: bool) -> (NodeId, NodeId) {
let mut circle = |x: f64, color: &str| {
let id = NodeId(run(s, "shape.ellipse", json!({"x": x, "y": 100, "width": 100, "height": 100}))["id"].as_u64().unwrap());
run(s, "paint.setFill", json!({"ids": [id.0], "color": color}));
run(s, "appearance.setItem", json!({"ids": [id.0], "index": 1, "color": "#0000ff", "weight": 11}));
run(s, "effect.apply", json!({"ids": [id.0], "effect": "stylize.feather", "item": null}));
id
};
let (a, b) = (circle(0.0, "#ffff00"), circle(150.0, "#ff0000"));
if extra_fill {
// Another fill on top of b's fill and moved under its stroke: [Fill, Fill, Stroke].
run(s, "appearance.addFill", json!({"ids": [b.0]}));
run(s, "appearance.moveItem", json!({"ids": [b.0], "from": 2, "to": 1}));
run(s, "appearance.setItem", json!({"ids": [b.0], "index": 1, "color": "#00ff00"}));
}
run(s, "select.set", json!({"ids": [a.0, b.0]}));
(a, b)
}
/// The shared stroke row edits the stroke of both circles, whatever its index in each, as one undo
/// step, leaving their fills alone; undo restores both.
#[test]
fn a_shared_row_edits_every_selected_object() {
for extra in [false, true] {
let mut s = session();
let (a, b) = mixed_circles(&mut s, extra);
let b_stroke = if extra { 2 } else { 1 };
let fills = (node(&s, a).appearance.items.clone(), node(&s, b).appearance.items.clone());
let steps = s.doc().unwrap().history.undo.len();
// The panel lists the first object's rows: the stroke is a's item 1.
run(&mut s, "appearance.setItem", json!({"index": 1, "weight": 4, "aligned": true}));
assert_eq!(stroke(&node(&s, a), 1).width, 4.0);
assert_eq!(stroke(&node(&s, b), b_stroke).width, 4.0, "extra fill: {extra}");
assert_eq!(node(&s, a).appearance.items[0], fills.0[0], "a's fill untouched");
assert_eq!(node(&s, b).appearance.items.len(), fills.1.len());
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
run(&mut s, "edit.undo", json!({}));
assert_eq!((node(&s, a).appearance.items.clone(), node(&s, b).appearance.items.clone()), fills, "undo restores both");
// The active row (a's item 1) carries paint edits from other panels to both strokes too.
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
run(&mut s, "stroke.set", json!({"weight": 7}));
assert_eq!(stroke(&node(&s, b), b_stroke).width, 7.0, "extra fill: {extra}");
}
}
/// Removing the shared effect removes it from both; adding a fill adds one to both.
#[test]
fn shared_effects_and_new_fills_reach_every_selected_object() {
let mut s = session();
let (a, b) = mixed_circles(&mut s, true);
run(&mut s, "effect.remove", json!({"index": 0, "item": null, "aligned": true}));
assert!(node(&s, a).appearance.effects.is_empty() && node(&s, b).appearance.effects.is_empty());
let (na, nb) = (node(&s, a).appearance.items.len(), node(&s, b).appearance.items.len());
run(&mut s, "appearance.addFill", json!({}));
assert_eq!((node(&s, a).appearance.items.len(), node(&s, b).appearance.items.len()), (na + 1, nb + 1));
// Rows of different stacks aren't reordered together.
assert!(s.execute("appearance.moveItem", &json!({"from": 1, "to": 0, "aligned": true})).is_err());
}
/// An effect applied to the first object's stroke row goes on the equal stroke of the other
/// object, which sits lower in a longer stack there.
#[test]
fn an_effect_on_a_shared_row_reaches_it_at_another_index() {
let mut s = session();
let (a, b) = mixed_circles(&mut s, true);
run(&mut s, "effect.apply", json!({"effect": "distort.twist", "item": 1, "aligned": true}));
assert_eq!((stroke(&node(&s, a), 1).effects.len(), stroke(&node(&s, b), 2).effects.len()), (1, 1));
}
/// Rows deleted together, one shared (the stroke) and one not (a's fill): each object loses its
/// own copy of the shared row, and the other object keeps the items at those indices of a's stack.
#[test]
fn removing_shared_and_unshared_rows_removes_only_the_shared_ones_elsewhere() {
let mut s = session();
let (a, b) = mixed_circles(&mut s, true);
// b is [Fill, Fill, Stroke]; a's rows 0 (its fill) and 1 (the shared stroke).
let fills_b: Vec<_> = node(&s, b).appearance.items.iter().take(2).cloned().collect();
run(&mut s, "appearance.removeItem", json!({"indices": [0, 1], "aligned": true}));
assert!(node(&s, a).appearance.items.is_empty(), "a loses both");
assert_eq!(node(&s, b).appearance.items, fills_b, "b loses only its stroke, keeping both fills");
}
#[test]
fn invalid_aligned_flags_leave_the_appearance_alone() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "effect.apply", json!({"effect": "distort.twist"}));
let before = node(&s, id);
let before_undo = s.doc().unwrap().history.undo.len();
for (command, params) in [
("appearance.setItem", json!({"index": 0, "opacity": 50, "aligned": "true"})),
("appearance.removeItem", json!({"index": 0, "aligned": 1})),
("appearance.duplicateItem", json!({"index": 0, "aligned": "false"})),
("appearance.moveItem", json!({"from": 0, "to": 1, "aligned": 0})),
("stroke.set", json!({"item": 1, "weight": 5, "aligned": "true"})),
("stroke.set", json!({"weight": 5, "aligned": "true"})),
("transparency.set", json!({"item": 0, "opacity": 50, "aligned": "true"})),
("effect.apply", json!({"effect": "distort.roughen", "item": 0, "aligned": "true"})),
("effect.remove", json!({"index": 0, "aligned": "true"})),
] {
assert!(s.execute(command, &params).is_err(), "{command} accepted {params}");
assert_eq!(node(&s, id), before, "{command} changed the object");
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo, "{command} recorded an undo step");
}
// Null, as for every command, is the same as leaving `aligned` out.
run(&mut s, "appearance.removeItem", json!({"index": 0, "aligned": null}));
assert_eq!(node(&s, id).appearance.items.len(), before.appearance.items.len() - 1);
// A Pathfinder effect on loose objects groups them first: the flag is checked before that.
let other = rect(&mut s, 250.0);
run(&mut s, "select.set", json!({"ids": [id.0, other.0]}));
let before_undo = s.doc().unwrap().history.undo.len();
assert!(s.execute("effect.apply", &json!({"effect": "pathfinder.add", "aligned": "true"})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo, "grouped the objects");
}