Merge pull request #1299 from KnottyDyes/squad/stroke-type-validation

Object: reject malformed stroke types
This commit is contained in:
Brandon Thomas
2026-10-19 02:51:41 -04:00
committed by GitHub
2 changed files with 83 additions and 3 deletions
+1
View File
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-17** · Object strokes reject malformed types and unknown type fields before changing the document; command help lists every stroke kind and its required fields. No readiness percentage change.
- **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. No readiness percentage change.
- **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change.
+82 -3
View File
@@ -259,9 +259,28 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke",
[],
None,
"{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind:…}, cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}",
"{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed|stripes|style, pattern?: [dash, gap, …] (dashed: required, points), bands?: [[start, width], …] (stripes: required, fractions of stroke weight), name?: string (style: required, document stroke style name)} (other kinds take no extra fields; unknown type fields are errors), cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}",
has_doc,
|s, p| {
let kind: Option<designcraft_doc::StrokeType> = p
.get("type")
.map(|v| {
if let Some(fields) = v.as_object() {
let field = match v.get("kind").and_then(Value::as_str) {
Some("dashed") => Some("pattern"),
Some("stripes") => Some("bands"),
Some("style") => Some("name"),
_ => None,
};
for key in fields.keys() {
if key != "kind" && Some(key.as_str()) != field {
return Err(bad("object.stroke", format!("type: unknown field `{key}`")));
}
}
}
serde_json::from_value(v.clone()).map_err(|e| bad("object.stroke", format!("type: {e}")))
})
.transpose()?;
for key in ["swatch", "gapSwatch"] {
if let Some(name) = p.get(key).and_then(Value::as_str) {
known_swatch(&s.doc()?.doc, name, "object.stroke", key)?;
@@ -314,11 +333,13 @@ pub fn specs() -> Vec<CommandSpec> {
if let Some(v) = p2.get("gapOverprint").and_then(Value::as_bool) {
st.gap_overprint = v;
}
for (k, apply) in [("align", 0), ("type", 1), ("cap", 2), ("join", 3), ("start", 4), ("end", 5)] {
if let Some(kind) = &kind {
st.kind = kind.clone();
}
for (k, apply) in [("align", 0), ("cap", 2), ("join", 3), ("start", 4), ("end", 5)] {
if let Some(v) = p2.get(k).cloned() {
let _ = match apply {
0 => serde_json::from_value(v).map(|x| st.align = x),
1 => serde_json::from_value(v).map(|x| st.kind = x),
2 => serde_json::from_value(v).map(|x| st.cap = x),
3 => serde_json::from_value(v).map(|x| st.join = x),
4 => serde_json::from_value(v).map(|x| st.start = x),
@@ -4824,6 +4845,64 @@ mod group_tests {
}
}
#[cfg(test)]
mod stroke_type_tests {
use serde_json::json;
#[test]
fn malformed_stroke_types_return_bad_params_without_editing() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].clone();
for (kind, hint) in [
(json!({"kind": "dashed", "dash": [8, 2.5]}), "dash"),
(json!({"kind": "zigzag"}), "zigzag"),
(json!({"kind": "solid", "dash": [8, 2.5]}), "dash"),
(json!({"kind": "dashed", "pattern": [8, 2.5], "dash": [1, 2]}), "dash"),
(json!({"kind": "dashed"}), "pattern"),
(json!({"kind": "dashed", "pattern": "bad"}), "type"),
(json!({"kind": "stripes", "bands": [[0, "bad"]]}), "type"),
(json!({"kind": "style"}), "name"),
(json!({}), "kind"),
(json!("solid"), "type"),
(json!(null), "type"),
] {
let before = s.doc().unwrap().doc.clone();
let error = s.execute("object.stroke", &json!({"ids": [id], "weight": 5, "type": kind})).unwrap_err();
assert!(matches!(error, crate::EngineError::BadParams { .. }), "{error}");
assert!(error.to_string().contains(hint), "{kind}: {error}");
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "{kind}: nothing changed");
}
}
#[test]
fn stroke_types_apply_all_supported_variants() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].as_u64().unwrap();
for kind in [
json!({"kind": "solid"}),
json!({"kind": "dashed", "pattern": [8, 2.5]}),
json!({"kind": "dotted"}),
json!({"kind": "thickThin"}),
json!({"kind": "thinThick"}),
json!({"kind": "thinThin"}),
json!({"kind": "thickThick"}),
json!({"kind": "thinThickThin"}),
json!({"kind": "thickThinThick"}),
json!({"kind": "wavy"}),
json!({"kind": "hashed"}),
json!({"kind": "stripes", "bands": [[0, 0.3], [0.7, 0.3]]}),
json!({"kind": "style", "name": "Rails"}),
] {
let result = s.execute("object.stroke", &json!({"ids": [id], "type": kind})).unwrap();
assert_eq!(result["changed"], 1);
let item = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap();
assert_eq!(item.stroke.kind, serde_json::from_value::<designcraft_doc::StrokeType>(kind).unwrap());
}
}
}
#[cfg(test)]
mod swatch_name_tests {
use serde_json::json;