vectorcraft/crates/engine/src/tests_appearance.rs

500 lines
25 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, Node};
use super::*;
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);
}
#[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);
}
/// 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}");
}
}
#[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);
}