mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 11:13:24 +00:00
Merge main into fix/save-screens-unknown-options
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
//! Blend Options (defaults with nothing selected, `object.blend.info`) and the Blend tool:
|
||||
//! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18), and
|
||||
//! Escape dropping the first pick.
|
||||
//! Escape, or an edit made outside the tool, dropping the first pick.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec};
|
||||
@@ -233,6 +233,102 @@ fn escape_drops_the_blend_tools_first_pick() {
|
||||
assert_eq!(spec(&s, g).0, vec![b, c], "a was not added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_undo_or_redo_made_outside_the_blend_tool_drops_its_first_pick() {
|
||||
for case in ["edit", "undo", "redo", "group"] {
|
||||
let undo = case == "undo";
|
||||
let mut s = session();
|
||||
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
|
||||
if case == "redo" {
|
||||
s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
// In an open undo group, the edit after the pick is merged into the group's step.
|
||||
if case == "group" {
|
||||
s.begin_undo_group();
|
||||
s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#00ff00"})).unwrap();
|
||||
}
|
||||
s.select_tool("blend", ViewInfo::default()).unwrap();
|
||||
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
|
||||
// Undo takes away c, the object picked first; an edit or a redo recolors c, which is not picked.
|
||||
click(&mut s, if undo { 420.0 } else { 20.0 }, 20.0);
|
||||
assert!(!s.overlays(ViewInfo::default()).is_empty());
|
||||
match case {
|
||||
"undo" => s.execute("edit.undo", &json!({})).unwrap(),
|
||||
"redo" => s.execute("edit.redo", &json!({})).unwrap(),
|
||||
_ => s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap(),
|
||||
};
|
||||
assert!(s.overlays(ViewInfo::default()).is_empty(), "{case}: the pick is not marked");
|
||||
let (layers, depth) = (s.doc().unwrap().doc.layers.clone(), s.doc().unwrap().history.undo.len());
|
||||
click(&mut s, 220.0, 20.0);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b], "{case}: b is picked first");
|
||||
assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "{case}: the click changes no art");
|
||||
click(&mut s, if undo { 20.0 } else { 420.0 }, 20.0);
|
||||
let g = s.doc().unwrap().selection.objects[0];
|
||||
assert_eq!(spec(&s, g).0, if undo { vec![a, b] } else { vec![b, c] }, "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_picks_with_no_edit_between_them_blend() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0));
|
||||
s.select_tool("blend", ViewInfo::default()).unwrap();
|
||||
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
|
||||
click(&mut s, 20.0, 20.0);
|
||||
// Drawing the overlays between the picks does not drop the first one.
|
||||
assert!(!s.overlays(ViewInfo::default()).is_empty());
|
||||
click(&mut s, 220.0, 20.0);
|
||||
let g = s.doc().unwrap().selection.objects[0];
|
||||
assert_eq!(spec(&s, g).0, vec![a, b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_blend_the_blend_tool_adds_to_ends_adding() {
|
||||
let mut s = session();
|
||||
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
|
||||
let (d, e) = (rect(&mut s, 0.0, 300.0, 40.0), rect(&mut s, 200.0, 300.0, 40.0));
|
||||
let other = id_of(&s.execute("object.blend.make", &json!({"ids": [d.0, e.0]})).unwrap());
|
||||
s.select_tool("blend", ViewInfo::default()).unwrap();
|
||||
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
|
||||
click(&mut s, 20.0, 20.0);
|
||||
click(&mut s, 220.0, 20.0);
|
||||
let g = s.doc().unwrap().selection.objects[0];
|
||||
s.execute("edit.clear", &json!({"ids": [g.0]})).unwrap();
|
||||
sel(&mut s, &[other]);
|
||||
click(&mut s, 420.0, 20.0);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![c], "c is picked first");
|
||||
assert_eq!(spec(&s, other).0, vec![d, e], "c is not added to the selected blend");
|
||||
assert!([a, b, g].iter().all(|id| s.doc().unwrap().doc.node(*id).is_none()), "the blend went with its keys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_change_keeps_the_blend_tools_first_pick() {
|
||||
for drag in [false, true] {
|
||||
let mut s = session();
|
||||
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
|
||||
let view = ViewInfo::default();
|
||||
s.select_tool("blend", view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 20.0, 20.0), view).unwrap();
|
||||
sel(&mut s, &[c]);
|
||||
// Cmd lends the selection tool: a click on c selects it, a drag moves it (an edit).
|
||||
let cmd = Mods { cmd: true, ..Mods::default() };
|
||||
let to = if drag { 440.0 } else { 420.0 };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 420.0, 20.0).with_mods(cmd), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to, 20.0).with_mods(cmd), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to, 20.0).with_mods(cmd), view).unwrap();
|
||||
assert_eq!(s.tool_id(), "blend");
|
||||
assert_eq!(s.overlays(view).is_empty(), drag, "drag {drag}");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 220.0, 20.0), view).unwrap();
|
||||
if drag {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b], "the drag dropped the pick");
|
||||
} else {
|
||||
let g = s.doc().unwrap().selection.objects[0];
|
||||
assert_eq!(spec(&s, g).0, vec![a, b]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_points_round_trip_and_old_blends_load() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -180,6 +180,47 @@ fn redo_during_an_interaction_keeps_the_history_and_the_document_in_step() {
|
||||
assert_eq!(first_x(&s), start);
|
||||
}
|
||||
|
||||
/// `History::step` changes with every new step, Undo and Redo, also when an undo and a new edit
|
||||
/// leave as many steps as before, and with an edit merged into an undo group's step. It stays the
|
||||
/// same for a selection change.
|
||||
#[test]
|
||||
fn the_history_step_changes_with_each_step_undo_and_redo() {
|
||||
let mut s = session_with_rects(2);
|
||||
let step = |s: &Session| s.doc().unwrap().history.step();
|
||||
let start = step(&s);
|
||||
assert!(start.0 != 0 && start.1 == 0, "the Add step, and nothing to redo: {start:?}");
|
||||
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
|
||||
let moved = step(&s);
|
||||
assert!(moved.0 != start.0 && moved.1 == 0, "{moved:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let undone = step(&s);
|
||||
assert!(undone.0 == start.0 && undone.1 != 0, "{undone:?}");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
let redone = step(&s);
|
||||
assert!(![start, moved, undone].contains(&redone) && redone.1 == 0, "{redone:?}");
|
||||
// An undo and a new edit: as many undo steps as after the move, at a different step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let depth = s.doc().unwrap().history.undo.len();
|
||||
s.execute("object.move", &json!({"dx": 0, "dy": 1})).unwrap();
|
||||
let other = step(&s);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), depth + 1);
|
||||
assert!(![start, moved, undone, redone].contains(&other), "{other:?}");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(step(&s), other, "a selection change is no step");
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.begin_undo_group();
|
||||
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
|
||||
let (grouped, depth) = (step(&s), s.doc().unwrap().history.undo.len());
|
||||
assert_ne!(grouped, other);
|
||||
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), depth, "the second move is merged into the first");
|
||||
let merged = step(&s);
|
||||
assert!(merged != grouped && merged.1 == 0, "a merged edit moves the step: {merged:?}");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(step(&s), merged);
|
||||
s.end_undo_group(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_mode_paint_snapshots_count_toward_the_history_budget() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -141,6 +141,136 @@ fn a_graphic_style_clicked_with_nothing_selected_styles_the_next_object() {
|
||||
assert!(s.execute("graphicStyle.apply", &json!({"name": name, "ids": []})).is_err(), "explicit ids that match nothing");
|
||||
}
|
||||
|
||||
/// A rectangle with this fill, stroke and weight, selected.
|
||||
fn painted(s: &mut Session, x: f64, fill: &str, stroke: &str, weight: f64) -> NodeId {
|
||||
let id = rect(s, x);
|
||||
run(s, "paint.setFill", json!({"color": fill}));
|
||||
run(s, "paint.setStroke", json!({"color": stroke}));
|
||||
run(s, "stroke.set", json!({"weight": weight}));
|
||||
id
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
run(s, "select.set", json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_an_object_gives_new_art_its_fill_stroke_and_weight_with_basic_appearance_on() {
|
||||
let mut s = session();
|
||||
assert!(s.prefs.new_art_basic, "on by default");
|
||||
let green = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
|
||||
painted(&mut s, 120.0, "#0000ff", "#000000", 2.0);
|
||||
select(&mut s, &[green]);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let id = rect(&mut s, 220.0);
|
||||
let (n, from) = (node(&s, id).appearance, node(&s, green).appearance);
|
||||
assert_eq!((n.fill_paint(), n.stroke_paint(), n.stroke_width()), (from.fill_paint(), from.stroke_paint(), 5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_basic_appearance_on_only_the_basic_fill_and_stroke_follow_the_selection() {
|
||||
let mut s = session();
|
||||
let src = fancy(&mut s);
|
||||
painted(&mut s, 120.0, "#00ff00", "#000000", 2.0);
|
||||
select(&mut s, &[src]);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let id = rect(&mut s, 220.0);
|
||||
let n = node(&s, id);
|
||||
assert_eq!(n.appearance.fill_paint(), node(&s, src).appearance.fill_paint(), "the top fill");
|
||||
assert_eq!((n.appearance.items.len(), n.appearance.effects.len(), n.opacity), (2, 0, 1.0), "one fill and stroke, nothing else");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paint_the_selected_objects_do_not_share_stays_as_it_was() {
|
||||
let mut s = session();
|
||||
let a = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
|
||||
let b = painted(&mut s, 120.0, "#0000ff", "#00ff00", 5.0);
|
||||
painted(&mut s, 220.0, "#ffff00", "#000000", 1.0);
|
||||
let before = s.paint.fill.clone();
|
||||
select(&mut s, &[a, b]);
|
||||
assert_eq!(s.paint.fill, before, "the fills differ");
|
||||
assert_eq!((s.paint.stroke.clone(), s.paint.stroke_width), (node(&s, a).appearance.stroke_paint(), 5.0), "the strokes are shared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_without_paint_leaves_the_proxies_as_they_were() {
|
||||
let mut s = session();
|
||||
painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
|
||||
let (fill, stroke) = (s.paint.fill.clone(), s.paint.stroke.clone());
|
||||
let dir = super::links::Folder::new("new-art-image");
|
||||
let path = dir.file("pic.png");
|
||||
super::links::write(&path, &super::links::png(4, 4, super::links::RED));
|
||||
let image = super::links::place(&mut s, &path);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
select(&mut s, &[image]);
|
||||
assert_eq!((s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width), (fill, stroke, 5.0), "new art stays visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_without_a_stroke_clears_the_stroke_and_keeps_the_weight() {
|
||||
let mut s = session();
|
||||
let bare = rect(&mut s, 20.0);
|
||||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||||
painted(&mut s, 120.0, "#0000ff", "#000000", 7.0);
|
||||
select(&mut s, &[bare]);
|
||||
assert_eq!((s.paint.stroke.clone(), s.paint.stroke_width), (Paint::None, 7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_gradient_loads_without_its_place_on_the_object() {
|
||||
let mut s = session();
|
||||
let src = rect(&mut s, 20.0);
|
||||
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]);
|
||||
run(&mut s, "paint.setFill", json!({"gradient": {"stops": stops, "start": [20, 40], "end": [50, 40]}}));
|
||||
painted(&mut s, 120.0, "#0000ff", "#000000", 2.0);
|
||||
select(&mut s, &[src]);
|
||||
assert!(matches!(s.paint.fill, Paint::Gradient(_)), "{:?}", s.paint.fill);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let id = rect(&mut s, 220.0);
|
||||
assert!(matches!(node(&s, id).appearance.fill_paint(), Paint::Gradient(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_leaves_the_stroke_panels_options_for_new_art_alone() {
|
||||
let mut s = session();
|
||||
let plain = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
run(&mut s, "stroke.set", json!({"cap": "round", "dash": [4, 2]}));
|
||||
select(&mut s, &[plain]);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let id = rect(&mut s, 120.0);
|
||||
let st = node(&s, id).appearance.stroke().unwrap().clone();
|
||||
assert_eq!((st.paint.clone(), st.width), (node(&s, plain).appearance.stroke_paint(), 5.0));
|
||||
assert_eq!((st.cap, st.dash.map(|d| d.pattern)), (LineCap::Round, Some(vec![4.0, 2.0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sampled_paint_stays_until_the_selection_changes() {
|
||||
let mut s = session();
|
||||
let a = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
|
||||
let sampled = Paint::solid(vectorcraft_color::Color::WHITE);
|
||||
s.paint.fill = sampled.clone();
|
||||
run(&mut s, "transparency.set", json!({"opacity": 50}));
|
||||
assert_eq!(s.paint.fill, sampled, "not a selection command");
|
||||
select(&mut s, &[a]);
|
||||
assert_eq!(s.paint.fill, sampled, "the same selection again");
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert_eq!(s.paint.fill, sampled, "nothing selected shows nothing to load");
|
||||
select(&mut s, &[a]);
|
||||
assert_eq!(s.paint.fill, node(&s, a).appearance.fill_paint(), "selected anew");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeting_a_layer_loads_the_paints_of_its_contents() {
|
||||
let mut s = session();
|
||||
let a = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
run(&mut s, "select.none", json!({}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#0000ff"}));
|
||||
run(&mut s, "layer.target", json!({"id": layer.0}));
|
||||
assert_eq!(s.paint.fill, node(&s, a).appearance.fill_paint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_option_round_trips_with_the_preferences() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -164,6 +164,33 @@ fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient fitted to the object turns with a Transform effect on the canvas, and Object › Expand
|
||||
/// of the fill makes strips that draw the same.
|
||||
#[test]
|
||||
fn a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it() {
|
||||
// The gray level at (x, y).
|
||||
let gray = |r: &vectorcraft_render::Rendered, x: usize, y: usize| r.pixels[(y * 200 + x) * 4] as i32;
|
||||
// The object's own effect, and the fill's (the first appearance item).
|
||||
for (params, item) in
|
||||
[(json!({"rotate": 90}), json!(null)), (json!({"rotate": 90}), json!(0)), (json!({"rotate": 30, "scaleH": 60}), json!(null))]
|
||||
{
|
||||
let mut s = session();
|
||||
let id = gradient_rect(&mut s, Rect::new(60.0, 60.0, 140.0, 140.0), white_black(), false);
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": params, "ids": [id.0], "item": item}));
|
||||
let before = render(&s);
|
||||
if params["rotate"] == 90 {
|
||||
// The white to black ramp runs up or down the turned square, not across it.
|
||||
let (top, bottom) = (gray(&before, 100, 70), gray(&before, 100, 130));
|
||||
let (left, right) = (gray(&before, 70, 100), gray(&before, 130, 100));
|
||||
assert!((top - bottom).abs() > 100 && (left - right).abs() < 20, "{params} on item {item}: {top} {bottom} {left} {right}");
|
||||
}
|
||||
run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64}));
|
||||
let after = render(&s);
|
||||
let diff = mean_diff(&before, &after);
|
||||
assert!(diff < 1.0, "{params} on item {item}: mean difference {diff}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() {
|
||||
// The object's own effect, and the fill's (the first appearance item).
|
||||
@@ -947,19 +974,48 @@ fn expanded_colours_keep_the_stops_colour_model() {
|
||||
assert!((c - 0.625).abs() < 1e-5 && (m - 0.375).abs() < 1e-5, "interpolated in CMYK: {c} {m}");
|
||||
}
|
||||
|
||||
/// A new 200 × 200 CMYK document.
|
||||
fn cmyk_session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The selection's freeform gradient expanded: the mesh's colors, after checking that it draws
|
||||
/// within `most` (mean channel difference) of the gradient.
|
||||
fn expanded(s: &mut Session, most: f64) -> Vec<Color> {
|
||||
let before = render(s);
|
||||
run(s, "object.expand", json!({}));
|
||||
let diff = mean_diff(&before, &render(s));
|
||||
assert!(diff < most, "looks the same: {diff}");
|
||||
let NodeKind::Mesh(m) = selected(s).kind else { panic!("a mesh: {:?}", selected(s).kind) };
|
||||
m.points.into_iter().map(|p| p.color).collect()
|
||||
}
|
||||
|
||||
/// Cyan and black points mix as inks: cyan and black only, adding up to one. The gradient and the
|
||||
/// mesh both mix inks, so the two draw alike.
|
||||
fn cyan_and_black_points_expand_as_they_draw(mut s: Session) {
|
||||
let (cyan, black) = (json!({"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}), json!({"c": 0.0, "m": 0.0, "y": 0.0, "k": 1.0}));
|
||||
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan, black.clone(), black]);
|
||||
for c in expanded(&mut s, 2.5) {
|
||||
let [c, m, y, k] = inks(c);
|
||||
assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyan_and_black_freeform_points_expand_as_they_draw_in_an_rgb_document() {
|
||||
cyan_and_black_points_expand_as_they_draw(session());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyan_and_black_freeform_points_expand_as_they_draw_in_a_cmyk_document() {
|
||||
cyan_and_black_points_expand_as_they_draw(cmyk_session());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_freeform_gradient_expands_with_the_inks_of_its_points() {
|
||||
let ink = |c: f32, m: f32, y: f32, k: f32| json!({"c": c, "m": m, "y": y, "k": k});
|
||||
// The mesh's colors, after checking that it draws within `most` (mean channel difference) of
|
||||
// the gradient.
|
||||
let expanded = |s: &mut Session, most: f64| {
|
||||
let before = render(s);
|
||||
run(s, "object.expand", json!({}));
|
||||
let diff = mean_diff(&before, &render(s));
|
||||
assert!(diff < most, "looks the same: {diff}");
|
||||
let NodeKind::Mesh(m) = selected(s).kind else { panic!("a mesh: {:?}", selected(s).kind) };
|
||||
m.points.into_iter().map(|p| p.color).collect::<Vec<_>>()
|
||||
};
|
||||
// Points of one ink set give every mesh point that ink set.
|
||||
let mut s = session();
|
||||
let one = ink(0.5, 0.2, 0.0, 0.1);
|
||||
@@ -967,19 +1023,9 @@ fn a_freeform_gradient_expands_with_the_inks_of_its_points() {
|
||||
for c in expanded(&mut s, 1.0) {
|
||||
assert!(close_inks(inks(c), [0.5, 0.2, 0.0, 0.1]), "{c:?}");
|
||||
}
|
||||
// Cyan and black points mix as inks: cyan and black only, adding up to one. The gradient blends
|
||||
// the points' display colors and the mesh blends their inks, so the two draw a little apart in between.
|
||||
let mut s = session();
|
||||
let (cyan, black) = (ink(1.0, 0.0, 0.0, 0.0), ink(0.0, 0.0, 0.0, 1.0));
|
||||
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan.clone(), black.clone(), black]);
|
||||
for c in expanded(&mut s, 7.5) {
|
||||
let [c, m, y, k] = inks(c);
|
||||
assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}");
|
||||
}
|
||||
// Points in different models mix in display RGB, stored in a CMYK document as CMYK.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk"})).unwrap();
|
||||
let gray = json!({"gray": 0.5});
|
||||
let (cyan, gray) = (ink(1.0, 0.0, 0.0, 0.0), json!({"gray": 0.5}));
|
||||
let mut s = cmyk_session();
|
||||
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan, gray.clone(), gray]);
|
||||
for c in expanded(&mut s, 2.5) {
|
||||
inks(c);
|
||||
|
||||
@@ -579,9 +579,9 @@ fn asking(id: &str, permissions: &str, output: &Value) -> Vec<u8> {
|
||||
|
||||
/// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`),
|
||||
/// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's
|
||||
/// hash. The grant is recorded with that hash: replacing the plug-in (`replace: true`) with one
|
||||
/// that lists the same permissions needs no new grant, and one that lists another needs one. Removing it forgets the
|
||||
/// grant. A plug-in without `permissions` installs as before, with the ones its kind implies.
|
||||
/// hash. The grant is recorded with that hash and covers only that module: replacing the plug-in
|
||||
/// (`replace: true`) with one with other bytes needs a new grant. Removing it forgets the grant. A
|
||||
/// plug-in without `permissions` installs as before, with the ones its kind implies.
|
||||
#[test]
|
||||
fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
|
||||
let mut s = session();
|
||||
@@ -617,11 +617,11 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
|
||||
assert_eq!(installed["permissions"], json!(["doc.read"]));
|
||||
let g = vectorcraft_plugins::registry::granted(id).unwrap();
|
||||
assert_eq!((g.hash.as_str(), g.permissions.as_slice()), (hash.as_str(), &[vectorcraft_plugins::permissions::Permission::DocRead][..]));
|
||||
// Replaced with another module that lists the same permissions: no new grant needed, and the
|
||||
// grant now records the new module's hash.
|
||||
// Replaced with another module that lists the same permissions: granted again, the grant now
|
||||
// records the new module's hash.
|
||||
let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false);
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "replace": true}));
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], true);
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": ["doc.read"], "replace": true}));
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other));
|
||||
// One that asks for more is refused until it is granted all of them.
|
||||
let more = data(r#"["doc.read","doc.write"]"#);
|
||||
@@ -641,6 +641,221 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)}))["needsApproval"], false);
|
||||
}
|
||||
|
||||
/// A module with other bytes under an id that was granted is not covered, even when it lists the
|
||||
/// same permissions: `plugin.inspect` says so, `plugin.install` refuses it and leaves the installed
|
||||
/// module and its grant alone, and a new `grant` installs it and records its hash. The same bytes
|
||||
/// install again without asking, and a module that lists no permissions never asks.
|
||||
#[test]
|
||||
fn a_grant_covers_only_the_module_that_was_approved() {
|
||||
use vectorcraft_plugins::permissions::sha256_hex;
|
||||
let mut s = session();
|
||||
let id = "test.engine.permissions.bytes";
|
||||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
|
||||
let (first_data, changed_data) = (vectorcraft_format::base64_encode(&first), vectorcraft_format::base64_encode(&changed));
|
||||
let (first_hash, changed_hash) = (sha256_hex(&first), sha256_hex(&changed));
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": changed_data}))["needsApproval"], true);
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "replace": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("doc.read") && e.contains("`grant`"), "{e}");
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "replace": true, "hash": changed_hash})).unwrap_err().to_string();
|
||||
assert!(e.contains("`grant`"), "`hash` is not an approval: {e}");
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), first_hash, "the installed module stays");
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, first_hash, "its grant stays");
|
||||
// The same bytes need no new grant.
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], false);
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": first_data}));
|
||||
// A new grant installs the changed module, and the old bytes are no longer covered.
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": changed_data, "grant": ["doc.read"], "hash": changed_hash, "replace": true}));
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), changed_hash);
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, changed_hash);
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], true);
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
|
||||
// A manifest without a list, or with an empty one, asks for nothing, whatever its bytes.
|
||||
let exempt = "test.engine.permissions.exempt";
|
||||
for (permissions, out) in [("null", json!({"objects": []})), ("null", json!({"objects": [], "error": null})), ("[]", json!({"objects": []}))] {
|
||||
let data = vectorcraft_format::base64_encode(&asking(exempt, permissions, &out));
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], false, "{permissions}");
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": data, "replace": true}));
|
||||
}
|
||||
run(&mut s, "plugin.remove", json!({"id": exempt}));
|
||||
}
|
||||
|
||||
/// A stored module whose bytes changed is skipped at the next start and stays in the store with its
|
||||
/// grant, and the original bytes load again. A stored grant whose hash is malformed covers nothing.
|
||||
/// With a store attached, a matching grant held only by the process doesn't count.
|
||||
#[test]
|
||||
fn a_stored_grant_covers_only_the_stored_module_it_recorded() {
|
||||
use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex};
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-recorded-module");
|
||||
let id = "test.engine.permissions.storedbytes";
|
||||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
|
||||
let grants = folder.0.join("grants.json");
|
||||
let recorded = std::fs::read(&grants).unwrap();
|
||||
let module = std::fs::read_dir(&folder.0)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.find(|p| p.extension().is_some_and(|x| x == "wasm"))
|
||||
.expect("the stored module");
|
||||
let forget = || {
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
vectorcraft_plugins::registry::revoke(id);
|
||||
};
|
||||
let starts = || {
|
||||
forget();
|
||||
let mut next = session();
|
||||
next.attach_plugin_store(folder.0.clone());
|
||||
vectorcraft_plugins::registry::get(id).is_some()
|
||||
};
|
||||
assert!(starts(), "the module that was approved loads");
|
||||
std::fs::write(&module, &changed).unwrap();
|
||||
assert!(!starts(), "other bytes under the old grant are skipped");
|
||||
assert_eq!(std::fs::read(&module).unwrap(), changed, "the module stays in the store");
|
||||
assert_eq!(std::fs::read(&grants).unwrap(), recorded, "its grant stays");
|
||||
std::fs::write(&module, &first).unwrap();
|
||||
assert!(starts(), "the approved bytes load again");
|
||||
|
||||
// A malformed hash is no wildcard, even for the module on disk.
|
||||
let upper = sha256_hex(&first).to_uppercase();
|
||||
for hash in ["", "00", upper.as_str()] {
|
||||
std::fs::write(&grants, serde_json::to_vec(&json!([{"id": id, "hash": hash, "permissions": ["doc.read"]}])).unwrap()).unwrap();
|
||||
assert!(!starts(), "hash {hash:?}");
|
||||
}
|
||||
|
||||
// The store's grants are the ones that count, even when the process holds one for these bytes.
|
||||
std::fs::write(&grants, &recorded).unwrap();
|
||||
assert!(starts());
|
||||
let mut attached = session();
|
||||
attached.attach_plugin_store(folder.0.clone());
|
||||
vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&changed), permissions: vec![Permission::DocRead] });
|
||||
let inspected = run(&mut attached, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&changed)}));
|
||||
assert_eq!(inspected["needsApproval"], true, "a grant held only by the process doesn't count with a store attached");
|
||||
run(&mut attached, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A write to the plug-in store that fails leaves the store's module beside the grant that approved
|
||||
/// it, so the old module loads at the next start: a module that can't be written doesn't update the
|
||||
/// grant, and a grant that can't be written puts the old module back. On a first install, it takes
|
||||
/// the new module out again.
|
||||
#[test]
|
||||
fn a_failed_store_write_keeps_the_stored_module_with_its_grant() {
|
||||
use vectorcraft_plugins::permissions::sha256_hex;
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-failed-write");
|
||||
let id = "test.engine.permissions.failedwrite";
|
||||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []}));
|
||||
let first_data = vectorcraft_format::base64_encode(&first);
|
||||
let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true});
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
|
||||
let grants = folder.0.join("grants.json");
|
||||
let recorded = std::fs::read(&grants).unwrap();
|
||||
let module = std::path::PathBuf::from(s.stored_plugin_file(id).unwrap());
|
||||
let starts = || {
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
vectorcraft_plugins::registry::revoke(id);
|
||||
let mut next = session();
|
||||
next.attach_plugin_store(folder.0.clone());
|
||||
vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string())
|
||||
};
|
||||
let block = |path: &std::path::Path| {
|
||||
std::fs::remove_file(path).unwrap();
|
||||
std::fs::create_dir_all(path.join("block")).unwrap();
|
||||
};
|
||||
|
||||
// The module can't be written: the grant stays the old module's.
|
||||
block(&module);
|
||||
run(&mut s, "plugin.install", update.clone());
|
||||
assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the grant isn't updated for a module that wasn't stored");
|
||||
std::fs::remove_dir_all(&module).unwrap();
|
||||
std::fs::write(&module, &first).unwrap();
|
||||
assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed module write");
|
||||
|
||||
// The grants file is full: the new grant, which is longer, doesn't fit.
|
||||
let cap = 1 << 20;
|
||||
let mut entries: Vec<Value> = serde_json::from_slice(&recorded).unwrap();
|
||||
entries.push(json!({"id": "", "pad": ""}));
|
||||
let room = cap - serde_json::to_vec(&entries).unwrap().len();
|
||||
*entries.last_mut().unwrap() = json!({"id": "", "pad": "x".repeat(room)});
|
||||
let full = serde_json::to_vec(&entries).unwrap();
|
||||
assert_eq!(full.len(), cap);
|
||||
std::fs::write(&grants, &full).unwrap();
|
||||
run(&mut s, "plugin.install", update.clone());
|
||||
assert_eq!(std::fs::read(&grants).unwrap(), full, "not written past the cap");
|
||||
assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back");
|
||||
assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed grant write");
|
||||
|
||||
// The grants file can't be read, so nothing can be recorded in it.
|
||||
block(&grants);
|
||||
run(&mut s, "plugin.install", update.clone());
|
||||
assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back");
|
||||
std::fs::remove_dir_all(&grants).unwrap();
|
||||
std::fs::write(&grants, &recorded).unwrap();
|
||||
assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads once the grants are readable");
|
||||
|
||||
// A stored module that can't be read back isn't replaced, so a failed grant write can't lose it.
|
||||
let oversized = vectorcraft_plugins::Limits::default().max_module_bytes as u64 + 1;
|
||||
std::fs::File::options().write(true).open(&module).unwrap().set_len(oversized).unwrap();
|
||||
block(&grants);
|
||||
run(&mut s, "plugin.install", update);
|
||||
assert_eq!(std::fs::metadata(&module).unwrap().len(), oversized, "the stored module stays");
|
||||
std::fs::remove_dir_all(&grants).unwrap();
|
||||
std::fs::write(&grants, &recorded).unwrap();
|
||||
std::fs::write(&module, &first).unwrap();
|
||||
|
||||
// A first install whose grant can't be written leaves no module without a grant in the store.
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
assert!(!module.exists() && !grants.exists());
|
||||
std::fs::create_dir_all(grants.join("block")).unwrap();
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
|
||||
assert!(!module.exists(), "the store holds no module its grants don't cover");
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
vectorcraft_plugins::registry::revoke(id);
|
||||
}
|
||||
|
||||
/// A plug-in folder reports a changed module as needing approval and still loads the other files;
|
||||
/// granted on its own, the changed module loads from the folder. A session without a store uses the
|
||||
/// grants held by the process.
|
||||
#[test]
|
||||
fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() {
|
||||
let mut s = session();
|
||||
let id = "test.engine.permissions.folderbytes";
|
||||
let plain = "test.engine.permissions.folderplain";
|
||||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
|
||||
let dir = crate::tests_links::Folder::new("plugin-folder-changed-module");
|
||||
let file = dir.0.join("asks.wasm");
|
||||
std::fs::write(&file, &first).unwrap();
|
||||
std::fs::write(dir.0.join("plain.wasm"), asking(plain, "null", &json!({"objects": []}))).unwrap();
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
|
||||
let path = json!({"path": dir.0.display().to_string()});
|
||||
let r = run(&mut s, "plugin.reload", path.clone());
|
||||
assert_eq!((r["loaded"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (2, 0), "{r}");
|
||||
|
||||
std::fs::write(&file, &changed).unwrap();
|
||||
let r = run(&mut s, "plugin.reload", path.clone());
|
||||
assert_eq!(r["loaded"], json!([plain]), "the other file still loads");
|
||||
let failed = r["failed"].as_array().unwrap();
|
||||
assert_eq!(failed.len(), 1, "{r}");
|
||||
assert!(failed[0]["file"].as_str().unwrap().ends_with("asks.wasm"), "{r}");
|
||||
let why = failed[0]["error"].as_str().unwrap();
|
||||
assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}");
|
||||
let hash = vectorcraft_plugins::permissions::sha256_hex(&first);
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), hash, "the module installed earlier stays");
|
||||
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read"], "replace": true}));
|
||||
let r = run(&mut s, "plugin.reload", path);
|
||||
assert_eq!(r["failed"], json!([]), "approved on its own, it loads: {r}");
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
run(&mut s, "plugin.remove", json!({"id": plain}));
|
||||
}
|
||||
|
||||
/// The host enforces what a filter was granted: without `doc.write` an output that changes the
|
||||
/// document is refused and nothing changes, without `doc.read` it is given no objects, and without
|
||||
/// `state` it is given no data kept with the document and may not write any.
|
||||
@@ -815,18 +1030,25 @@ fn a_grant_covers_only_the_module_it_was_given_to() {
|
||||
std::fs::write(&stored[0], &other).unwrap();
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
vectorcraft_plugins::registry::revoke(id);
|
||||
vectorcraft_plugins::registry::set_storage(id, None);
|
||||
let no_storage = |how: &str| {
|
||||
assert!(vectorcraft_plugins::registry::storage(id).is_none(), "{how} gives another module the id's settings and cache");
|
||||
};
|
||||
let mut next = session();
|
||||
next.attach_plugin_store(folder.0.clone());
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "another module isn't loaded under the grant");
|
||||
no_storage("the next start");
|
||||
let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash");
|
||||
std::fs::write(dir.0.join("other.wasm"), &other).unwrap();
|
||||
let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()}));
|
||||
assert_eq!(r["loaded"], json!([]));
|
||||
let why = r["failed"][0]["error"].as_str().unwrap();
|
||||
assert!(why.contains("needs approval"), "{why}");
|
||||
no_storage("a folder load");
|
||||
let data = vectorcraft_format::base64_encode(&other);
|
||||
let e = next.execute("plugin.install", &json!({"dataBase64": data, "replace": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("`grant`"), "{e}");
|
||||
no_storage("a refused install");
|
||||
// Granted, it reads the settings the first module set.
|
||||
run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true}));
|
||||
run(&mut next, "plugin.run", json!({"id": id, "ids": []}));
|
||||
|
||||
@@ -241,6 +241,33 @@ fn options_are_saved_with_the_preferences() {
|
||||
assert_eq!(t.prefs.tool_settings["polygon"]["sides"], json!(9));
|
||||
}
|
||||
|
||||
/// Escape on a polygon or rounded rectangle drag puts back the side count or corner radius its
|
||||
/// arrows changed, in the saved options and the Corner Radius preference too.
|
||||
#[test]
|
||||
fn escape_on_a_shape_drag_saves_the_option_it_put_back() {
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
let mut s = session();
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
let drag = |s: &mut Session| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 180.0), v).unwrap();
|
||||
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
|
||||
// Saves every option, the one the arrow changed included.
|
||||
s.set_tool_option("length", &json!(50));
|
||||
};
|
||||
select(&mut s, "polygon");
|
||||
drag(&mut s);
|
||||
assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(7));
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(6));
|
||||
select(&mut s, "roundedRectangle");
|
||||
let radius = s.prefs.corner_radius;
|
||||
drag(&mut s);
|
||||
assert_eq!(s.prefs.corner_radius, radius + 1.0);
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.prefs.corner_radius, radius);
|
||||
}
|
||||
|
||||
/// The Fidelity an Alt smoothing drag uses on a selected path.
|
||||
fn alt_smooth_fidelity(s: &mut Session) -> Option<f64> {
|
||||
use vectorcraft_tools::{Action, Mods, PointerEvent, PointerKind};
|
||||
|
||||
@@ -1024,6 +1024,19 @@ fn lasso_picks_gradient_mesh_points() {
|
||||
|
||||
/// Shift-drag a marquee with the Selection tool (#483): the selected objects it reaches are
|
||||
/// deselected and the others selected, the rest of the selection staying as it was.
|
||||
#[test]
|
||||
fn a_selection_tool_click_gives_new_art_the_objects_fill() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
|
||||
rect(&mut s, 200.0, 100.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
gesture(&mut s, "selection", &[(125.0, 125.0), (125.0, 125.0)], Mods::default());
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(s.paint.fill, s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_shift_marquee_toggles_objects() {
|
||||
let mut s = session();
|
||||
|
||||
Reference in New Issue
Block a user