diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index d397056ad..de4051e9a 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -100,7 +100,12 @@ impl StrokeChange { let dash = match p.get("dash") { None => None, Some(Value::Null) => Some(None), - Some(Value::Array(a)) => Some(Some(a.iter().filter_map(Value::as_f64).collect::>()).filter(|d| !d.is_empty())), + Some(Value::Array(a)) => { + // Do not silently omit malformed segments and change the user's dash pattern. + let values: Vec = + a.iter().map(|v| v.as_f64().ok_or_else(|| bad(cmd, format!("dash lengths must be numbers, got {v}")))).collect::>()?; + Some(Some(values).filter(|d| !d.is_empty())) + } Some(v) => return Err(bad(cmd, format!("dash must be a list of lengths or null, got {v}"))), }; Ok(Self { @@ -167,9 +172,6 @@ fn no_targets(ids: &[vectorcraft_doc::NodeId], p: &Value) -> bool { fn stroke_set(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.set"; let change = StrokeChange::parse(p, C)?; - if let Some(w) = change.weight { - s.paint.stroke_width = w; - } let item = item_target(s, p, C)?.of_kind(s, false); let mut ids = item.targets(s, p)?; let new_art = no_targets(&ids, p); @@ -216,10 +218,6 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { st.profile = pr.clone(); } }; - // With nothing selected the Stroke panel sets up the next object drawn. - if new_art && let Some(st) = s.new_art_stroke_mut() { - set(st); - } edit_items(s, &ids, item, C, "Stroke", false, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind @@ -235,6 +233,14 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; + // Commit the new-art template only after the complete request is validated + // and the selected artwork edit has succeeded. + if let Some(w) = change.weight { + s.paint.stroke_width = w; + } + if new_art && let Some(st) = s.new_art_stroke_mut() { + set(st); + } ok() } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 69c4ce756..1a45a94e1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -134,6 +134,41 @@ fn fill_and_stroke_commands() { s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap(); } +#[test] +fn stroke_rejects_invalid_dash_components_and_preserves_defaults_on_error() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 40.0, 30.0); + s.execute("stroke.set", &json!({"weight": 3, "dash": [6, 3]})).unwrap(); + let previous_weight = s.paint.stroke_width; + let previous_stroke = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().cloned(); + + for params in [ + json!({"weight": 10, "dash": [6, "not a number", 3]}), + json!({"weight": 10, "ids": "not an array"}), + json!({"weight": 10, "item": "invalid"}), + json!({"weight": 10, "arrowAlign": "unknown"}), + ] { + assert!(s.execute("stroke.set", ¶ms).is_err(), "{params} must fail"); + assert_eq!(s.paint.stroke_width, previous_weight, "failed stroke edit changed new-art defaults"); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().cloned(), previous_stroke); + } + + // A valid dash list still updates the selected stroke and new-art default width. + s.execute("stroke.set", &json!({"weight": 7, "dash": [4, 2]})).unwrap(); + assert_eq!(s.paint.stroke_width, 7.0); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().width, 7.0); + + // With nothing selected the panel sets up the next object drawn: a rejected edit doesn't. + s.execute("select.none", &json!({})).unwrap(); + let next = s.new_art(); + assert!(s.execute("stroke.set", &json!({"weight": 12, "dash": [2, 2], "arrowAlign": "unknown"})).is_err()); + assert_eq!(s.new_art(), next); + s.execute("stroke.set", &json!({"weight": 12, "dash": [2, 2]})).unwrap(); + let next = s.new_art(); + let stroke = next.stroke().unwrap(); + assert_eq!((stroke.width, stroke.dash.as_ref().map(|d| d.pattern.clone())), (12.0, Some(vec![2.0, 2.0]))); +} + #[test] fn rejected_paint_edits_leave_new_art_defaults_and_focus_unchanged() { let mut s = session();