Files
solvecraft/crates/engine/src/cmd/parameters_tests.rs
T
Dahak Builds ef939b52eb Parameters: one meaning for value in every reply; round reported numbers (#10747) (#10812)
* claim: #10747

* Parameters: value is mm/degrees in every reply; round reported numbers (#10747)

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-11-02 08:26:24 +00:00

1249 lines
70 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.
use serde_json::{Value, json};
use crate::{EngineError, Session};
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn err(s: &mut Session, id: &str, p: Value) -> String {
match s.execute(id, &p) {
Ok(v) => panic!("{id} {p} should fail, gave {v}"),
Err(e) => e.to_string(),
}
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
fn row<'a>(list: &'a Value, name: &str) -> &'a Value {
list["parameters"].as_array().and_then(|a| a.iter().find(|p| p["name"] == name)).unwrap_or(&Value::Null)
}
fn expr_of(s: &mut Session, name: &str) -> String {
let l = run(s, "parameters.list", json!({}));
row(&l, name)["expression"].as_str().unwrap_or_default().to_string()
}
/// A sketched block 40 x (len / 2) x 10 whose width is an expression of a user parameter.
fn block(s: &mut Session) {
run(s, "parameters.add", json!({"name": "len", "expression": "60 mm", "comment": "overall length", "favorite": true}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": 40}));
run(s, "sketch.dimension", json!({"entities": ["l2"], "value": "len/2"}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 10, "body_name": "Block"}));
}
#[test]
fn width_follows_an_expression() {
let mut s = Session::default();
block(&mut s);
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
assert!(rel(volume(&mut s), 40.0 * 50.0 * 10.0) < 1e-9);
// Units convert: 4 in = 101.6 mm.
run(&mut s, "parameters.change", json!({"name": "len", "expression": "4 in"}));
assert!(rel(volume(&mut s), 40.0 * 50.8 * 10.0) < 1e-9);
// A parameter in inches reads bare numbers as inches.
run(&mut s, "parameters.change", json!({"name": "len", "expression": "5", "unit": "in"}));
assert!(rel(volume(&mut s), 40.0 * 63.5 * 10.0) < 1e-9);
let l = run(&mut s, "parameters.list", json!({}));
// `value` is mm in every reply; the own-unit number is `display_value` (#10747).
assert_eq!(row(&l, "len")["value"], 127.0, "{l}");
assert_eq!(row(&l, "len")["display_value"], 5.0, "{l}");
assert_eq!(row(&l, "len")["unit"], "in");
// A failing parameter fails the features using it, with its error.
let e = err(&mut s, "parameters.change", json!({"name": "len", "expression": "5 deg"}));
assert!(e.contains("expected a length, got an angle"), "{e}");
}
/// Setting a value back reuses the earlier results and says so: the features it restored are
/// reported next to `recomputed`, so `recomputed: 0` is not mistaken for "nothing changed". A
/// sketch dimension set back solves the sketch again from its current state (value edits
/// continue from the last solved state); the features after it see the same geometry and are
/// restored.
#[test]
fn setting_a_parameter_back_reports_restored_features() {
let mut s = Session::default();
block(&mut s);
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}");
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(1)), "{r}");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}");
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "10"}}}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(1)), "{r}");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
}
#[test]
fn feature_inputs_are_named_and_editable() {
let mut s = Session::default();
block(&mut s);
let l = run(&mut s, "parameters.list", json!({"filter": "feature"}));
let dist = l["parameters"].as_array().unwrap().iter().find(|r| r["input"] == "Distance").unwrap().clone();
assert_eq!(dist["feature"], "Extrude1", "{l}");
let d = dist["name"].as_str().unwrap().to_string();
run(&mut s, "parameters.change", json!({"name": d, "expression": "len / 3"}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 20.0) < 1e-9);
// Editing the feature keeps its parameter name.
run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "12 mm"}}}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 12.0) < 1e-9);
assert_eq!(expr_of(&mut s, &d), "12 mm");
// Another feature can use it.
run(&mut s, "solid.box", json!({"length": d, "width": 5, "height": 5, "corner": [100, 0, 0]}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 12.0 + 12.0 * 25.0) < 1e-9);
let u = run(&mut s, "parameters.users", json!({"name": d}));
assert_eq!(u["users"].as_array().map(Vec::len), Some(1), "{u}");
assert_eq!(u["drives"].as_array().map(Vec::len), Some(2), "{u}");
// Feature inputs can't be deleted on their own.
assert!(err(&mut s, "parameters.delete", json!({"name": d})).contains("feature's input"));
}
#[test]
fn unit_errors_and_cycles_are_refused() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "a", "expression": "10 mm"}));
let e = err(&mut s, "parameters.add", json!({"name": "bad", "expression": "a + 5 deg"}));
assert!(e.contains("cannot add a length and an angle"), "{e}");
let e = err(&mut s, "parameters.add", json!({"name": "ang", "expression": "a", "unit": "deg"}));
assert!(e.contains("expected an angle, got a length"), "{e}");
assert!(err(&mut s, "parameters.add", json!({"name": "x", "expression": "1", "unit": "furlong"})).contains("unknown unit"));
run(&mut s, "parameters.add", json!({"name": "b", "expression": "a * 2"}));
let e = err(&mut s, "parameters.change", json!({"name": "a", "expression": "b + 1"}));
assert!(e.contains("circular reference: a → b → a"), "{e}");
assert!(err(&mut s, "parameters.add", json!({"name": "selfref", "expression": "selfref"})).contains("circular"));
assert!(err(&mut s, "parameters.add", json!({"name": "a", "expression": "1"})).contains("already"));
assert!(err(&mut s, "parameters.add", json!({"name": "sin", "expression": "1"})).contains("function"));
assert!(err(&mut s, "parameters.add", json!({"name": "2x", "expression": "1"})).contains("not a valid parameter name"));
// Units are inferred for new parameters.
run(&mut s, "parameters.add", json!({"name": "tilt", "expression": "30 deg"}));
run(&mut s, "parameters.add", json!({"name": "n", "expression": "b / a"}));
assert_eq!(s.doc.param("tilt").map(|p| p.unit.as_str()), Some("deg"));
assert_eq!(s.doc.param("n").map(|p| p.unit.as_str()), Some(""));
let r = run(&mut s, "expr.evaluate", json!({"expression": "atan2(b; a) + tilt", "kind": "angle"}));
assert!((r["display_value"].as_f64().unwrap_or(0.0) - (2.0f64.atan().to_degrees() + 30.0)).abs() < 1e-9, "{r}");
let r = run(&mut s, "expr.evaluate", json!({"expression": "a + tilt"}));
assert_eq!(r["ok"], false);
assert!(r["error"].as_str().unwrap_or("").contains("cannot add"), "{r}");
let r = run(&mut s, "expr.evaluate", json!({"expression": "2", "unit": "in"}));
assert!((r["value"].as_f64().unwrap_or(0.0) - 50.8).abs() < 1e-9, "{r}");
}
#[test]
fn rename_updates_every_reference() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "parameters.add", json!({"name": "twice", "expression": "len * 2"}));
let r = run(&mut s, "parameters.rename", json!({"name": "len", "new_name": "length"}));
assert_eq!(r["references_updated"], 2, "{r}");
assert_eq!(expr_of(&mut s, "twice"), "length * 2");
let l = run(&mut s, "parameters.list", json!({"favorites": true}));
assert_eq!(l["count"], 1, "the favourite follows the rename");
assert_eq!(row(&l, "length")["comment"], "overall length");
run(&mut s, "parameters.change", json!({"name": "length", "expression": "80"}));
assert!(rel(volume(&mut s), 40.0 * 40.0 * 10.0) < 1e-9);
// Model and feature parameters can be renamed too: dimensions and features follow.
let dim = s.doc.features.first().and_then(|f| match &f.kind {
solidcraft_doc::FeatureKind::Sketch { sketch, .. } => sketch.constraints.iter().filter_map(|c| c.param.clone()).next(),
_ => None,
});
let dim = dim.unwrap_or_default();
run(&mut s, "parameters.change", json!({"name": dim, "new_name": "base_w", "expression": "50"}));
assert!(rel(volume(&mut s), 50.0 * 40.0 * 10.0) < 1e-9);
let ext = s.doc.features.get(1).and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "parameters.rename", json!({"name": ext, "new_name": "thick"}));
run(&mut s, "parameters.change", json!({"name": "thick", "expression": "base_w / 10"}));
assert!(rel(volume(&mut s), 50.0 * 40.0 * 5.0) < 1e-9);
assert!(err(&mut s, "parameters.rename", json!({"name": "twice", "new_name": "length"})).contains("already"));
assert!(err(&mut s, "parameters.rename", json!({"name": "nope", "new_name": "x"})).contains("unknown"));
// Undo puts the old name back.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.features.get(1).is_some_and(|f| f.param_names.first() == Some(&ext)));
}
#[test]
fn deleting_a_used_parameter_lists_its_users() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "parameters.add", json!({"name": "spare", "expression": "len + 1"}));
let e = err(&mut s, "parameters.delete", json!({"name": "len"}));
assert!(e.contains("parameter spare") && e.contains("parameter d"), "{e}");
let u = run(&mut s, "parameters.users", json!({"name": "len"}));
assert_eq!(u["users"].as_array().map(Vec::len), Some(2), "{u}");
assert_eq!(u["drives"][0]["name"], "Base", "{u}");
let dim = u["users"].as_array().unwrap().iter().find(|x| x["name"] != "spare").unwrap()["name"].as_str().unwrap().to_string();
let e = err(&mut s, "parameters.change", json!({"delete": dim}));
assert!(e.contains("Base dimension"), "{e}");
run(&mut s, "parameters.delete", json!({"name": "spare"}));
assert!(s.doc.param("spare").is_none());
let g = run(&mut s, "parameters.graph", json!({}));
assert!(g["edges"].as_array().unwrap().iter().any(|e| e[0] == dim.as_str() && e[1] == "len"), "{g}");
assert!(g["edges"].as_array().unwrap().iter().any(|e| e[0] == "Feature:Base" && e[1] == dim.as_str()), "{g}");
}
/// #4420: sheet metal and plastic rules and the plane of a canvas hold expressions too. Renaming a
/// parameter they use renames it there, and they count as its users (so it can't be deleted).
#[test]
fn rules_and_canvases_follow_a_renamed_parameter() {
let mut s = Session::default();
for (name, expression) in [("gauge", "2 mm"), ("lift", "15 mm"), ("taper", "3 deg")] {
run(&mut s, "parameters.add", json!({"name": name, "expression": expression}));
}
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
png.extend([0, 0, 0, 4, 0, 0, 0, 2, 8, 6, 0, 0, 0, 0, 0, 0, 0]);
run(&mut s, "canvas.insert", json!({"data": solidcraft_doc::canvas::base64_encode(&png), "plane": "XY", "offset": "lift"}));
run(&mut s, "sheet.manage_rules", json!({"name": "Steel", "thickness": "gauge", "active": true}));
run(&mut s, "solid.box", json!({"length": 60, "width": 40, "height": 2, "body_name": "Tray"}));
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [30, 20, 2], "rule": "Steel"}));
run(&mut s, "plastic.manage_rules", json!({"name": "Housing", "draft": "taper", "clearance": "gauge / 10"}));
let users = |s: &mut Session, name: &str| -> Vec<String> {
let u = run(s, "parameters.users", json!({"name": name}));
u["users"].as_array().unwrap().iter().map(|x| x["type"].as_str().unwrap_or_default().to_string()).collect()
};
assert_eq!(users(&mut s, "gauge"), ["rule", "rule"], "the sheet metal thickness and the plastic clearance");
assert_eq!(users(&mut s, "lift"), ["canvas"]);
let e = err(&mut s, "parameters.delete", json!({"name": "taper"}));
assert!(e.contains("plastic rule Housing (draft)"), "{e}");
// The sheet metal features rebuild when a rule's parameter changes, so they are what it drives.
let u = run(&mut s, "parameters.users", json!({"name": "gauge"}));
assert_eq!(
u["drives"].as_array().map(|d| d.iter().map(|x| x["name"].clone()).collect::<Vec<_>>()),
Some(vec![json!("ConvertToSheetMetal1")]),
"{u}"
);
let g = run(&mut s, "parameters.graph", json!({}));
assert!(g["edges"].as_array().unwrap().iter().any(|e| e[0] == "Feature:ConvertToSheetMetal1" && e[1] == "gauge"), "{g}");
assert!(!g["edges"].as_array().unwrap().iter().any(|e| e[0] == "Feature:Box1" && e[1] == "gauge"), "{g}");
// Every field of the shared list follows: `gap` was set through `sheet.manage_rules` too.
run(&mut s, "parameters.add", json!({"name": "seam", "expression": "0.5 mm"}));
run(&mut s, "sheet.manage_rules", json!({"name": "Steel", "gap": "seam"}));
run(&mut s, "parameters.rename", json!({"name": "seam", "new_name": "joint_gap"}));
assert_eq!(s.doc.sheet.rules[0].gap, "joint_gap");
for (old, new) in [("gauge", "sheet_t"), ("lift", "raise"), ("taper", "draft_angle")] {
run(&mut s, "parameters.rename", json!({"name": old, "new_name": new}));
}
let failing: Vec<String> = s.model.results.iter().filter_map(|r| r.error.clone()).collect();
assert!(failing.is_empty(), "{failing:?}");
assert_eq!(s.doc.sheet.rules[0].thickness, "sheet_t");
let housing = s.doc.plastic.rules.iter().find(|r| r.name == "Housing").unwrap();
assert_eq!((housing.draft.as_str(), housing.clearance.as_str()), ("draft_angle", "sheet_t / 10"));
assert!(run(&mut s, "plastic.rules", json!({}))["rules"].as_array().unwrap().iter().any(|r| r["name"] == "Housing" && !r["values"].is_null()));
match &s.doc.canvases[0].plane {
solidcraft_doc::PlaneRef::Offset { distance, .. } => assert_eq!(distance.as_ref(), "raise"),
other => panic!("{other:?}"),
}
assert_eq!(users(&mut s, "raise"), ["canvas"]);
// Undo takes the old names back.
for _ in 0..3 {
run(&mut s, "edit.undo", json!({}));
}
assert_eq!(s.doc.sheet.rules[0].thickness, "gauge");
}
#[test]
fn favorites_comments_and_completion() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "parameters.favorite", json!({"name": "len", "favorite": false}));
assert_eq!(run(&mut s, "parameters.list", json!({"favorites": true}))["count"], 0);
let ext = s.doc.features.get(1).and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "parameters.comment", json!({"name": ext, "comment": "plate thickness"}));
let l = run(&mut s, "parameters.list", json!({}));
assert_eq!(row(&l, &ext)["comment"], "plate thickness");
let c = run(&mut s, "parameters.complete", json!({"prefix": "2 * le"}));
assert_eq!(c["word"], "le");
assert_eq!(c["suggestions"][0]["text"], "len", "{c}");
let c = run(&mut s, "parameters.complete", json!({"prefix": "at"}));
assert!(c["suggestions"].as_array().unwrap().iter().any(|x| x["text"] == "atan2("), "{c}");
}
/// #2757: a parameter is suggested for a word anywhere in its name, not only at its start;
/// names that start with the word come first. Functions still match from the start.
#[test]
fn completion_matches_parameters_anywhere_in_their_name() {
let mut s = Session::default();
for (name, e) in [("cab_side_thickness", "18 mm"), ("side", "40 mm"), ("door_width", "300 mm")] {
run(&mut s, "parameters.add", json!({"name": name, "expression": e}));
}
let texts = |c: &Value| -> Vec<String> {
c["suggestions"].as_array().unwrap().iter().filter(|x| x["kind"] == "parameter").map(|x| x["text"].as_str().unwrap().to_string()).collect()
};
let c = run(&mut s, "parameters.complete", json!({"prefix": "2 * side"}));
assert_eq!(texts(&c), ["side", "cab_side_thickness"], "the name starting with it first: {c}");
let c = run(&mut s, "parameters.complete", json!({"prefix": "THICK"}));
assert_eq!(texts(&c), ["cab_side_thickness"], "any case: {c}");
assert!(texts(&run(&mut s, "parameters.complete", json!({"prefix": "xyz"}))).is_empty());
// Functions keep matching from the start: `in` is not `sin(`.
let c = run(&mut s, "parameters.complete", json!({"prefix": "in"}));
assert!(!c["suggestions"].as_array().unwrap().iter().any(|x| x["text"] == "sin("), "{c}");
}
#[test]
fn csv_and_json_round_trip() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "parameters.add", json!({"name": "note", "expression": "len / 4", "comment": "has, a \"comma\""}));
let csv = run(&mut s, "parameters.export", json!({"format": "csv"}))["text"].as_str().unwrap_or_default().to_string();
assert!(csv.contains("note,mm,len / 4,15,\"has, a \"\"comma\"\"\",false"), "{csv}");
let json_text = run(&mut s, "parameters.export", json!({"format": "json"}))["text"].as_str().unwrap_or_default().to_string();
let mut t = Session::default();
run(&mut t, "parameters.import", json!({"text": csv}));
assert_eq!(t.doc.param("note").map(|p| p.comment.as_str()), Some("has, a \"comma\""));
let mut u = Session::default();
run(&mut u, "parameters.import", json!({"text": json_text}));
assert_eq!(u.doc.param("note").map(|p| p.expr.as_str()), Some("len / 4"));
// Importing into the design updates values and rebuilds; all or nothing.
run(&mut s, "parameters.import", json!({"text": "name,expression\nlen,80 mm\nextra,3\n"}));
assert!(rel(volume(&mut s), 40.0 * 40.0 * 10.0) < 1e-9);
assert!(s.execute("parameters.import", &json!({"text": "name,expression\nlen,90 mm\nbroken,len + 1 deg\n"})).is_err());
assert_eq!(s.doc.param("len").map(|p| p.expr.as_str()), Some("80 mm"));
assert!(matches!(s.execute("parameters.import", &json!({})), Err(EngineError::BadParams { .. })));
let dir = std::env::temp_dir().join(format!("solidcraft-params-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("p.json");
let r = run(&mut s, "parameters.export", json!({"path": path.to_string_lossy()}));
assert_eq!(r["format"], "json");
let mut v = Session::default();
run(&mut v, "parameters.import", json!({"path": path.to_string_lossy()}));
assert_eq!(v.doc.param("len").map(|p| p.expr.as_str()), Some("80 mm"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn csv_round_trips_comments_with_line_breaks() {
for comment in ["line one\nline two", "\r\nfirst, \"quoted\"\r\n\r\nlast\r\n", "one\rtwo"] {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "L", "expression": "30", "comment": comment}));
run(&mut s, "parameters.add", json!({"name": "W", "expression": "10"}));
let exported = run(&mut s, "parameters.export", json!({"format": "csv"}));
let mut fresh = Session::default();
let imported = run(&mut fresh, "parameters.import", json!({"text": exported["text"]}));
assert_eq!(imported["imported"], json!(["L", "W"]));
let list = run(&mut fresh, "parameters.list", json!({}));
assert_eq!(row(&list, "L")["comment"], comment);
assert_eq!(row(&list, "L")["value"], 30.0);
assert_eq!(row(&list, "W")["value"], 10.0);
}
}
#[test]
fn csv_import_accepts_a_bom_before_the_header() {
let mut s = Session::default();
let csv = "\u{feff}\"NAME\",expression\r\n\r\nA,5";
let imported = run(&mut s, "parameters.import", json!({"text": csv}));
assert_eq!(imported["imported"], json!(["A"]));
assert_eq!(row(&run(&mut s, "parameters.list", json!({})), "A")["value"], 5.0);
}
#[test]
fn csv_import_with_an_unclosed_quote_leaves_parameters_alone() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "L", "expression": "10"}));
let before = s.doc.to_json();
let csv = "name,expression,comment\nL,30,updated\nW,5,\"unfinished\n";
assert!(s.execute("parameters.import", &json!({"text": csv})).is_err());
assert_eq!(s.doc.to_json(), before);
}
/// #8960: the text marker accepts ASCII case variants without changing numeric units.
#[test]
fn text_parameter_imports_accept_mixed_case_units() {
for unit in ["text", "Text", "TEXT", "tExT", " Text "] {
let csv = format!("name,unit,expression,comment\nwidth,in,1,numeric control\nverID,{unit},\"\"\"REV-A\"\"\",quoted label\n");
let json_text = json!([
{"name": "width", "unit": "in", "expression": "1", "comment": "numeric control"},
{"name": "verID", "unit": unit, "expression": "\"REV-A\"", "comment": "quoted label"}
])
.to_string();
for text in [csv, json_text] {
let mut s = Session::default();
// Check both adding and updating a text parameter through the import command.
for expected in ["REV-A", "REV-B"] {
let r = run(&mut s, "parameters.import", json!({"text": text.replace("REV-A", expected)}));
assert_eq!(r["imported"], json!(["width", "verID"]));
assert_eq!(r["errors"], json!([]));
let list = run(&mut s, "parameters.list", json!({}));
assert_eq!(list["count"], 2);
let label = row(&list, "verID");
assert_eq!(label["unit"], "text");
assert_eq!(label["text"], expected);
assert_eq!(label["expression"], expected);
assert_eq!(label["comment"], "quoted label");
assert!(label["value"].is_null());
assert_eq!(row(&list, "width")["unit"], "in");
assert_eq!(row(&list, "width")["value"], 25.4);
assert_eq!(row(&list, "width")["display_value"], 1.0);
assert_eq!(s.doc.param_values().0["width"].v, 25.4);
}
// Export still uses the canonical marker and round-trips the unquoted text.
let exported = run(&mut s, "parameters.export", json!({"format": "csv"}));
assert!(exported["text"].as_str().unwrap().contains("verID,text,REV-B,,quoted label,"), "{exported}");
let mut reopened = Session::default();
run(&mut reopened, "parameters.import", json!({"text": exported["text"]}));
assert_eq!(reopened.doc.text_param("verID").unwrap().text, "REV-B");
}
}
}
#[test]
fn text_parameter_imports_keep_unknown_units_atomic() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "verID", "unit": "text", "expression": "original"}));
run(&mut s, "parameters.add", json!({"name": "width", "unit": "mm", "expression": "20"}));
let before = s.doc.to_json();
for unit in ["Textual", "MM"] {
let csv = format!("name,unit,expression\nverID,Text,replacement\nwidth,mm,30\nbad,{unit},1\n");
let json_text = json!([
{"name": "verID", "unit": "Text", "expression": "replacement"},
{"name": "width", "unit": "mm", "expression": "30"},
{"name": "bad", "unit": unit, "expression": "1"}
])
.to_string();
for text in [csv, json_text] {
let why = err(&mut s, "parameters.import", json!({"text": text}));
assert!(why.contains(&format!("unknown unit `{unit}`")), "{why}");
assert_eq!(s.doc.to_json(), before, "a rejected row must roll back the entire import");
}
}
}
#[test]
fn text_parameter_commands_accept_mixed_case_units() {
let mut s = Session::default();
let r = run(&mut s, "parameters.add", json!({"name": "Label", "unit": "Text", "expression": "\"TAG-A\""}));
assert_eq!(r["unit"], "text");
assert_eq!(r["text"], "TAG-A");
run(&mut s, "parameters.change", json!({"name": "Label", "unit": "TEXT", "expression": "'TAG-B'"}));
assert_eq!(s.doc.text_param("Label").unwrap().text, "TAG-B");
let r = run(&mut s, "expr.evaluate", json!({"unit": " tExT ", "expression": "\"TAG-C\""}));
assert_eq!(r["ok"], true);
assert_eq!(r["kind"], "text");
assert_eq!(r["text"], "TAG-C");
}
/// Two triangles joined by three links (a b c, d e f; links a–d, b–e, c–f), a on the origin,
/// a–b horizontal, every distance a dimension: the construction leaves it to the iteration.
/// Returns the session, the sketch feature and the nine dimensions' parameters.
fn linkage_session() -> (Session, u64, Vec<String>) {
let layout = [[0.0, 0.0], [40.0, 0.0], [18.0, 30.0], [8.0, 55.0], [52.0, 48.0], [30.0, 80.0]];
let pairs = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)];
let ids = ["a", "b", "c", "d", "e", "f"];
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Linkage"}));
for (id, at) in ids.iter().zip(layout) {
run(&mut s, "sketch.point", json!({"point": at, "id": id}));
}
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"points": ["a", "b"], "mode": "horizontal"}));
let params = pairs
.iter()
.map(|&(i, j)| run(&mut s, "sketch.dimension", json!({"entities": [ids[i], ids[j]]}))["param"].as_str().unwrap_or_default().to_string())
.collect();
let feature = s.doc.find_feature("Linkage").map(|f| f.id).unwrap_or_default();
(s, feature, params)
}
/// Edited values the iteration cannot reach in one jump from the layout.
const REACHED_BY_CONTINUATION: [f64; 9] = [
49.75798473660107,
44.249179252138724,
28.66332553713371,
49.725624597008775,
45.81086166277159,
35.887660702567544,
45.64700450204954,
50.756441510188495,
63.72063313963373,
];
/// Turn of both triangles in the evaluated sketch.
fn linkage_turns(s: &Session, feature: u64) -> [bool; 2] {
let state = s.model.state();
let sk = &state.sketch(feature).unwrap().sketch;
let p = |id: &str| sk.point(sk.resolve_point(id).unwrap()).unwrap();
[(p("b") - p("a")).cross(p("c") - p("a")) > 0.0, (p("e") - p("d")).cross(p("f") - p("d")) > 0.0]
}
/// Set the parameters in the document only (as an edit that does not store the sketch).
fn set_params(s: &mut Session, params: &[String], values: &[f64]) {
let changes: Vec<(String, String)> = params.iter().zip(values).map(|(name, value)| (name.clone(), format!("{value} mm"))).collect();
s.doc_mut().change_params_atomic(&changes).unwrap();
}
#[test]
fn dimension_edits_of_a_triconnected_linkage_continue_and_store_the_solved_sketch() {
// The edit steps to the new values from the stored sketch, keeping both triangles' turn,
// and stores what it reached: evaluating the document afresh needs no continuation.
let (mut s, feature, params) = linkage_session();
run(&mut s, "sketch.finish", json!({}));
let before = linkage_turns(&s, feature);
// All nine at once: one at a time, some intermediate values have no solution.
let batch: Vec<_> = params.iter().zip(REACHED_BY_CONTINUATION).map(|(n, v)| json!({"name": n, "expression": format!("{v} mm")})).collect();
run(&mut s, "parameters.import", json!({"text": serde_json::to_string(&batch).unwrap()}));
let solved = s.model.state().sketch(feature).unwrap().report.clone();
assert!(solved.ok(), "{solved:?}");
assert_eq!(linkage_turns(&s, feature), before);
let mut fresh = solidcraft_doc::Model::default();
fresh.evaluate(&s.doc);
let again = fresh.state().sketch(feature).unwrap().report.clone();
assert!(again.ok() && again.continuations == 0, "{again:?}");
// The kept results are what evaluating the stored document gives.
let (kept, afresh) = (s.model.state(), fresh.state());
let pairs = kept.sketch(feature).unwrap().sketch.points.iter().zip(&afresh.sketch(feature).unwrap().sketch.points);
assert!(pairs.into_iter().all(|(a, b)| a.pos.dist(b.pos) < 1e-9));
// In one go (one evaluation from the layout) it needs continuation.
let (mut t, feature, params) = linkage_session();
run(&mut t, "sketch.finish", json!({}));
set_params(&mut t, &params, &REACHED_BY_CONTINUATION);
t.refresh();
let once = t.model.state().sketch(feature).unwrap().report.clone();
assert!(once.ok() && once.continuations > 0, "{once:?}");
}
#[test]
fn extending_after_a_parameter_edit_continues_from_the_stored_sketch() {
let (mut s, feature, params) = linkage_session();
run(&mut s, "sketch.line", json!({"points": [[100, 0], [110, 0]], "ids": ["ext"]}));
run(&mut s, "sketch.line", json!({"points": [[130, -20], [130, 20]], "ids": ["wall"]}));
let before = linkage_turns(&s, feature);
set_params(&mut s, &params, &REACHED_BY_CONTINUATION);
run(&mut s, "sketch.extend", json!({"curve": "ext", "at": [110, 0]}));
let solved = s.model.state().sketch(feature).unwrap().report.clone();
assert!(solved.ok(), "{solved:?}");
assert_eq!(linkage_turns(&s, feature), before);
}
#[test]
fn a_failed_edit_keeps_the_stored_sketch_solved_for_its_values() {
// Parameters no geometry satisfies, then an edit that does not insist on a solution: the
// stored dimensions keep the values its geometry is solved for.
let (mut s, feature, params) = linkage_session();
let mut values = REACHED_BY_CONTINUATION;
values[7] = 500.0;
set_params(&mut s, &params, &values);
run(&mut s, "sketch.point", json!({"point": [200, 200]}));
let stored = s.doc.sketch(feature).unwrap();
for c in stored.constraints.iter().filter(|c| c.param.is_some()) {
let measured = solidcraft_sketch::measure_dimension(stored, &c.kind).unwrap();
assert!((measured - c.kind.value().unwrap()).abs() < 1e-6, "{}: {measured} vs {:?}", c.id, c.kind.value());
}
}
/// Values the linkage reaches only with its top triangle mirrored.
const FLIPPING: [f64; 9] = [
26.483003374917082,
90.0253808040292,
84.67215428223084,
54.580784880976275,
68.12871394439186,
30.98508537669166,
116.94895853350752,
103.75535113133466,
32.83103173640168,
];
/// All nine parameters set by one command.
fn import_values(params: &[String], values: &[f64]) -> Value {
let batch: Vec<_> = params.iter().zip(values).map(|(n, v)| json!({"name": n, "expression": format!("{v} mm")})).collect();
json!({"text": serde_json::to_string(&batch).unwrap()})
}
#[test]
fn an_edit_that_would_flip_the_sketch_is_refused_and_named() {
let (mut s, feature, params) = linkage_session();
run(&mut s, "sketch.finish", json!({}));
let (points, before) = (stored_points(&s, feature), params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>());
let why = err(&mut s, "parameters.import", import_values(&params, &FLIPPING));
assert!(why.contains("Linkage") && why.contains("flip the sketch over"), "{why}");
assert_eq!(params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>(), before);
assert_eq!(stored_points(&s, feature), points);
assert!(s.model.state().sketch(feature).unwrap().report.keeps_orientation());
}
#[test]
fn a_design_stored_with_other_values_still_rebuilds() {
// A design whose stored sketch is not solved for its parameters (saved before value edits
// stored their solve): evaluating it never refuses. The flipped solve is kept, with a
// warning, the flipped constraints listed as conflicts, and the solid built from it.
let (mut s, feature, params) = linkage_session();
for line in [[[8.0, 55.0], [52.0, 48.0]], [[52.0, 48.0], [30.0, 80.0]], [[30.0, 80.0], [8.0, 55.0]]] {
run(&mut s, "sketch.line", json!({"points": line}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Linkage", "distance": 5}));
set_params(&mut s, &params, &FLIPPING);
let s = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
let state = s.model.state();
let solved = state.sketch(feature).unwrap();
assert!(solved.report.ok() && !solved.report.folded.is_empty(), "{:?}", solved.report);
let warning = s.model.result(feature).and_then(|r| r.warning.clone()).unwrap_or_default();
assert!(warning.contains("flip the sketch over") && solved.report.folded.iter().all(|id| warning.contains(id.as_str())), "{warning}");
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results.iter().map(|r| &r.error).collect::<Vec<_>>());
assert_eq!(state.bodies.len(), 1);
// Listed as conflicts wherever conflicts are shown.
let mut s = s;
s.active_sketch = Some(feature);
let si = run(&mut s, "sketch.inspect", json!({}));
let glyphs = run(&mut s, "sketch.glyphs", json!({}));
for id in &solved.report.folded {
assert!(si["failing"].as_array().unwrap().contains(&json!(id)), "{si}");
assert!(glyphs["conflicts"].as_array().unwrap().contains(&json!(id)), "{glyphs}");
}
}
#[test]
fn an_edit_that_breaks_a_projecting_sketch_downstream_applies_with_a_warning() {
// `w` places a guide line; a second sketch projects it and ends a 12 mm line on it. Moving
// the guide out of reach is the guide sketch's edit, not a value edit of the projecting
// sketch: it applies, and the projecting sketch carries a warning.
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "w", "expression": "10 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Guide"}));
run(&mut s, "sketch.line", json!({"points": [[10, -30], [10, 30]], "ids": ["guide"], "connect": false}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "guide", "mode": "vertical"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "guide.start"], "type": "horizontal", "value": "w"}));
run(&mut s, "sketch.finish", json!({}));
let id = run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Reach"}))["sketch"].as_u64().unwrap();
let projected = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "Guide", "curve": "guide"}]}))["links"][0]["curves"][0]
.as_str()
.unwrap()
.to_string();
let line = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, -(44.0_f64).sqrt()]]}))["curves"][0].as_str().unwrap().to_string();
run(&mut s, "sketch.dimension", json!({"entities": [line], "value": 12}));
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{line}.end"), "b": projected}));
run(&mut s, "sketch.finish", json!({}));
assert!(s.model.state().sketch(id).unwrap().report.ok());
let r = run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
assert_eq!(r["value"], json!(20.0), "the edit applies: {r}");
assert!(!s.model.state().sketch(id).unwrap().report.ok());
assert!(s.model.result(id).and_then(|r| r.warning.clone()).is_some_and(|w| w.contains("conflict")));
}
#[test]
fn a_failed_auto_constrain_keeps_the_stored_sketch_consistent() {
// Auto-constrain works on the current values: reached by continuation, its result is
// stored solved for them; values no geometry satisfies refuse it and change nothing.
let consistent = |s: &Session, feature: u64| {
let stored = s.doc.sketch(feature).unwrap();
stored.constraints.iter().filter(|c| c.param.is_some()).all(|c| {
let measured = solidcraft_sketch::measure_dimension(stored, &c.kind).unwrap();
(measured - c.kind.value().unwrap()).abs() < 1e-6
})
};
let (mut s, feature, params) = linkage_session();
let mut values = REACHED_BY_CONTINUATION;
values[7] = 500.0;
set_params(&mut s, &params, &values);
let before = s.doc.sketch(feature).unwrap().clone();
let why = err(&mut s, "sketch.auto_constrain", json!({}));
assert!(why.contains("conflict"), "{why}");
assert_eq!(*s.doc.sketch(feature).unwrap(), before);
assert!(consistent(&s, feature));
set_params(&mut s, &params, &REACHED_BY_CONTINUATION);
run(&mut s, "sketch.auto_constrain", json!({}));
assert!(consistent(&s, feature));
let stored = s.doc.sketch(feature).unwrap();
for (c, v) in stored.constraints.iter().filter(|c| c.param.is_some()).zip(REACHED_BY_CONTINUATION) {
assert!((c.kind.value().unwrap() - v).abs() < 1e-9, "{}", c.id);
}
}
/// The stored sketch's points, by id.
fn stored_points(s: &Session, feature: u64) -> Vec<solidcraft_geom::Vec2> {
s.doc.sketch(feature).unwrap().points.iter().map(|p| p.pos).collect()
}
#[test]
fn an_unsolvable_value_edit_is_refused_and_changes_nothing() {
let (mut s, feature, params) = linkage_session();
run(&mut s, "sketch.finish", json!({}));
let (expr, points) = (s.doc.param(&params[7]).map(|p| p.expr.clone()), stored_points(&s, feature));
let why = err(&mut s, "parameters.change", json!({"name": params[7], "expression": "500 mm"}));
assert!(why.contains("Linkage") && why.contains("conflict"), "{why}");
assert_eq!(s.doc.param(&params[7]).map(|p| p.expr.clone()), expr);
assert_eq!(stored_points(&s, feature), points);
assert!(s.model.state().sketch(feature).unwrap().report.ok());
// A batch with one such value: none of it.
let before: Vec<_> = params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect();
let batch: Vec<_> = params
.iter()
.enumerate()
.map(|(i, n)| json!({"name": n, "expression": if i == 7 { "500 mm".to_string() } else { format!("{} mm", REACHED_BY_CONTINUATION[i]) }}))
.collect();
let why = err(&mut s, "parameters.import", json!({"text": serde_json::to_string(&batch).unwrap()}));
assert!(why.contains("Linkage"), "{why}");
assert_eq!(params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>(), before);
assert_eq!(stored_points(&s, feature), points);
}
#[test]
fn value_edits_continue_from_the_last_solved_state() {
// A → B → C, each a small edit solved in one jump; C is solved from B (the stored state),
// and setting both values back reproduces A (the linkage is fully constrained).
let close = |a: &[solidcraft_geom::Vec2], b: &[solidcraft_geom::Vec2], tol: f64| a.iter().zip(b).all(|(p, q)| p.dist(*q) < tol);
let (mut s, feature, params) = linkage_session();
run(&mut s, "sketch.finish", json!({}));
let a = stored_points(&s, feature);
let (p7, p8) = (s.doc.param(&params[7]).unwrap().expr.clone(), s.doc.param(&params[8]).unwrap().expr.clone());
run(&mut s, "parameters.change", json!({"name": params[8], "expression": "53 mm"}));
let b_sketch = s.doc.sketch(feature).unwrap().clone();
let b = stored_points(&s, feature);
assert!(!close(&a, &b, 1e-3) && s.model.state().sketch(feature).unwrap().report.continuations == 0);
run(&mut s, "parameters.change", json!({"name": params[7], "expression": "50 mm"}));
let c = stored_points(&s, feature);
// The same edit solved from B directly.
let mut from_b = b_sketch.clone();
let stored = from_b.driving_values();
let (vals, _) = s.doc.param_values();
s.doc.apply_dimension_values(&vals, &mut from_b).unwrap();
assert!(solidcraft_sketch::solve_after_value_edit(&mut from_b, &stored).ok());
assert!(close(&c, &from_b.points.iter().map(|p| p.pos).collect::<Vec<_>>(), 1e-6) && !close(&c, &b, 1e-3));
run(&mut s, "parameters.change", json!({"name": params[7], "expression": p7}));
run(&mut s, "parameters.change", json!({"name": params[8], "expression": p8}));
assert!(close(&stored_points(&s, feature), &a, 1e-6));
}
#[test]
fn switching_configurations_back_reuses_the_later_features() {
// Rows A and B of `len`: back on A, the sketch solves again from B's state to A's
// geometry, and the extrude after it is restored from the cache.
let mut s = Session::default();
block(&mut s);
run(&mut s, "config.configure", json!({"params": ["len"]}));
run(&mut s, "config.row", json!({"name": "A", "values": {"len": "60 mm"}}));
run(&mut s, "config.row", json!({"name": "B", "values": {"len": "100 mm"}}));
run(&mut s, "config.activate", json!({"row": "B"}));
assert!(rel(volume(&mut s), 40.0 * 50.0 * 10.0) < 1e-9);
let hits = |s: &Session| s.model.cache.lock().map(|c| c.hits).unwrap_or(0);
let before = hits(&s);
let r = run(&mut s, "config.activate", json!({"row": "A"}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
assert!(hits(&s) > before, "{r}");
}
/// The user parameters' names in the order the Parameters dialog lists them.
fn user_order(s: &mut Session) -> Vec<String> {
let v = run(s, "parameters.list", json!({"filter": "user"}));
v["parameters"].as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap().to_string()).collect()
}
#[test]
fn user_parameters_reorder_with_undo() {
// #1187: move a user parameter before or after another; nothing else changes.
let mut s = Session::default();
for (n, e) in [("a", "1 mm"), ("b", "2 mm"), ("c", "a + b")] {
run(&mut s, "parameters.add", json!({"name": n, "expression": e}));
}
assert_eq!(user_order(&mut s), ["a", "b", "c"]);
let r = run(&mut s, "parameters.move", json!({"name": "c", "before": "a"}));
assert_eq!(r["order"], json!(["c", "a", "b"]));
assert_eq!(user_order(&mut s), ["c", "a", "b"]);
run(&mut s, "parameters.move", json!({"name": "c", "after": "a"}));
assert_eq!(user_order(&mut s), ["a", "c", "b"]);
// Values and references are untouched; the order is kept in the design file.
let list = run(&mut s, "parameters.list", json!({"filter": "user"}));
let c = list["parameters"].as_array().unwrap().iter().find(|r| r["name"] == "c").unwrap().clone();
assert_eq!(c["expression"], "a + b");
assert!((c["value"].as_f64().unwrap() - 3.0).abs() < 1e-9, "{c}");
let back = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
let names: Vec<&str> = back.doc.params.iter().filter(|p| !p.model).map(|p| p.name.as_str()).collect();
assert_eq!(names, ["a", "c", "b"]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(user_order(&mut s), ["c", "a", "b"]);
}
#[test]
fn moving_a_parameter_refuses_bad_targets() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "a", "expression": "1 mm"}));
run(&mut s, "parameters.add", json!({"name": "b", "expression": "2 mm"}));
let before = s.doc.to_json();
for p in [
json!({"name": "a", "before": "a"}),
json!({"name": "a", "before": "nope"}),
json!({"name": "nope", "before": "a"}),
json!({"name": "a"}),
json!({"name": "a", "before": "b", "after": "b"}),
] {
err(&mut s, "parameters.move", p);
}
assert_eq!(s.doc.to_json(), before);
}
#[test]
fn a_large_import_is_checked_once_and_keeps_each_rows_unit() {
// 3000 parameters, each the one before plus a millimetre, then an angle, a unit-less
// ratio and a row that names a parameter defined further down: all new, in one import.
let mut csv = String::from("name,expression\n");
csv.push_str("a,later * 2\np0,1 mm\n");
for i in 1..3000 {
csv.push_str(&format!("p{i},p{} + 1 mm\n", i - 1));
}
csv.push_str("turn,30 deg\nratio,p10 / p4\nlater,5\n");
let mut s = Session::default();
let t = std::time::Instant::now();
run(&mut s, "parameters.import", json!({"text": csv}));
println!("import of {} rows: {:.2} s", 3005, t.elapsed().as_secs_f64());
let (vals, errors) = s.doc.param_values();
assert!(errors.is_empty(), "{errors:?}");
let unit = |n: &str| s.doc.param(n).map(|p| p.unit.clone()).unwrap_or_default();
assert_eq!((unit("p2999"), unit("turn"), unit("ratio"), unit("later")), ("mm".into(), "deg".into(), String::new(), "mm".into()));
assert!(
vals.get("p2999").is_some_and(|v| (v.to_kind(solidcraft_doc::expr::Kind::Length).unwrap_or(f64::NAN) - 3000.0).abs() < 1e-9),
"{:?}",
vals.get("p2999")
);
assert!(
vals.get("a").is_some_and(|v| (v.to_kind(solidcraft_doc::expr::Kind::Length).unwrap_or(f64::NAN) - 10.0).abs() < 1e-9),
"{:?}",
vals.get("a")
);
// Changing the first moves the whole chain; a row that breaks changes nothing.
run(&mut s, "parameters.import", json!({"text": "name,expression\np0,101 mm\n"}));
assert!(
s.doc
.param_values()
.0
.get("p2999")
.is_some_and(|v| (v.to_kind(solidcraft_doc::expr::Kind::Length).unwrap_or(f64::NAN) - 3100.0).abs() < 1e-9)
);
assert!(s.execute("parameters.import", &json!({"text": "name,expression\np0,2 mm\np5,p0 + 1 deg\n"})).is_err());
assert_eq!(s.doc.param("p0").map(|p| p.expr.as_str()), Some("101 mm"));
}
#[test]
fn several_parameters_change_in_one_rebuild_and_one_undo() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "h", "expression": "10 mm"}));
block(&mut s);
let d = run(&mut s, "parameters.list", json!({"filter": "feature"}))["parameters"][0]["name"].as_str().unwrap_or_default().to_string();
run(&mut s, "parameters.change", json!({"name": d, "expression": "h"}));
let r = run(&mut s, "parameters.change", json!({"changes": {"len": "100 mm", "h": 20}}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
assert_eq!(r["changed"]["len"], 100.0, "{r}");
assert!(rel(volume(&mut s), 40.0 * 50.0 * 20.0) < 1e-9);
// All or nothing: an unknown name or a broken expression changes nothing.
assert!(err(&mut s, "parameters.change", json!({"changes": {"len": "60 mm", "nope": "1 mm"}})).contains("nope"));
assert!(s.execute("parameters.change", &json!({"changes": {"len": "60 mm", "h": "1 / 0"}})).is_err());
assert!(s.execute("parameters.change", &json!({"changes": {}})).is_err());
assert_eq!(expr_of(&mut s, "len"), "100 mm");
run(&mut s, "edit.undo", json!({}));
assert_eq!((expr_of(&mut s, "len"), expr_of(&mut s, "h")), ("60 mm".to_string(), "10 mm".to_string()));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
}
/// A sketch made with `flip` on an offset plane follows the offset's parameter (the parameter
/// walk stopped at the flipped plane, so a change rebuilt nothing).
#[test]
fn flipped_offset_sketch_plane_follows_its_parameter() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "d", "expression": "30 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Near"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-10, -10], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XZ", "offset": "d", "flip": "x", "name": "Far"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-10, -10], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "Near"}, {"sketch": "Far"}]}));
assert!((volume(&mut s) - 400.0 * 30.0).abs() < 1e-6);
run(&mut s, "parameters.change", json!({"name": "d", "expression": "50 mm"}));
assert!((volume(&mut s) - 400.0 * 50.0).abs() < 1e-6, "{}", volume(&mut s));
}
/// A value typed as `name = expression` in any value input defines the user parameter `name`
/// and leaves the input using it (#4071): a feature's distance, an angle, a count, a sheet metal
/// rule and a canvas offset. The parameter takes the input's unit, the design's for a bare length.
#[test]
fn any_value_input_can_define_a_parameter() {
let mut s = Session::default();
block(&mut s);
let r = run(&mut s, "solid.extrude", json!({"sketch": "Base", "distance": "depth = 15", "operation": "new", "body_name": "Second"}));
assert_eq!(r["defined"], "depth", "{r}");
assert_eq!(r["definitions"], json!([{"name": "depth", "expression": "15", "unit": "mm"}]), "{r}");
let l = run(&mut s, "parameters.list", json!({}));
assert_eq!((row(&l, "depth")["source"].as_str(), row(&l, "depth")["expression"].as_str()), (Some("user"), Some("15")), "{l}");
// The input shows the name afterwards, and follows the parameter.
let input = l["parameters"].as_array().unwrap().iter().find(|r| r["feature"] == "Extrude2" && r["input"] == "Distance").unwrap().clone();
assert_eq!(input["expression"], "depth", "{input}");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0 + 40.0 * 30.0 * 15.0) < 1e-9);
run(&mut s, "parameters.change", json!({"name": "depth", "expression": "20 mm"}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0 + 40.0 * 30.0 * 20.0) < 1e-9);
// One undo removes the feature and its parameter together.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.param("depth").is_none());
assert!(s.doc.features.iter().all(|f| f.name != "Extrude2"));
run(&mut s, "edit.redo", json!({}));
assert!(s.doc.param("depth").is_some());
// An angle input's parameter is in degrees; `#name` works as in a dimension.
let r = run(&mut s, "solid.extrude", json!({"sketch": "Base", "distance": 5, "taper": "#draft = 2", "operation": "new"}));
assert_eq!(r["definitions"][0], json!({"name": "draft", "expression": "2", "unit": "deg"}), "{r}");
// Sheet metal rules and a canvas offset take one too.
run(&mut s, "sheet.manage_rules", json!({"name": "Steel", "thickness": "gauge = 2", "active": true}));
assert_eq!((s.doc.sheet.rules[0].thickness.as_str(), s.doc.param("gauge").map(|p| p.expr.as_str())), ("gauge", Some("2")));
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
png.extend([0, 0, 0, 4, 0, 0, 0, 2, 8, 6, 0, 0, 0, 0, 0, 0, 0]);
run(&mut s, "canvas.insert", json!({"data": solidcraft_doc::canvas::base64_encode(&png), "plane": "XY", "offset": "lift = 15"}));
match &s.doc.canvases[0].plane {
solidcraft_doc::PlaneRef::Offset { distance, .. } => assert_eq!(distance.as_ref(), "lift"),
other => panic!("{other:?}"),
}
assert_eq!(s.doc.param("lift").map(|p| (p.expr.as_str(), p.unit.as_str())), Some(("15", "mm")));
}
/// In an inch design a bare number defines a parameter in inches, as the bare value would read;
/// a value with a unit keeps it.
#[test]
fn a_defined_length_takes_the_design_unit() {
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
let r = run(&mut s, "solid.box", json!({"length": "l = 2", "width": "w = 25.4 mm", "height": 1}));
assert_eq!(
r["definitions"],
json!([{"name": "l", "expression": "2 in", "unit": "in"}, {"name": "w", "expression": "25.4 mm", "unit": "in"}]),
"{r}"
);
assert!(rel(volume(&mut s), 50.8 * 25.4 * 25.4) < 1e-9);
}
/// A parameter added without a unit takes the unit of its value, a length in the design's units
/// as a typed length would be (#8865): `40/2` in an inch design is 20 in, as the Parameters
/// dialog makes it. A unit given, an angle and a unit-less value keep their own.
#[test]
fn a_parameter_added_without_a_unit_takes_the_design_unit() {
let added = |s: &mut Session, p: Value| {
let r = run(s, "parameters.add", p);
(r["unit"].as_str().unwrap_or("?").to_string(), r["value"].as_f64().unwrap_or(f64::NAN), r["display_value"].as_f64().unwrap_or(f64::NAN))
};
let close = |got: (String, f64, f64), unit: &str, val: f64, shown: f64| {
assert!(got.0 == unit && rel(got.1, val) < 1e-9 && rel(got.2, shown) < 1e-9, "{got:?}, expected {unit} {val} ({shown})");
};
// A millimetre design is as it was.
let mut s = Session::default();
close(added(&mut s, json!({"name": "v", "expression": "40/2"})), "mm", 20.0, 20.0);
close(added(&mut s, json!({"name": "w", "expression": "v + 1"})), "mm", 21.0, 21.0);
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
close(added(&mut s, json!({"name": "v", "expression": "40/2"})), "in", 508.0, 20.0);
close(added(&mut s, json!({"name": "given", "expression": "40/2", "unit": "in"})), "in", 508.0, 20.0);
close(added(&mut s, json!({"name": "metric", "expression": "40/2", "unit": "mm"})), "mm", 20.0, 20.0);
// A value with a unit keeps its size and shows in the design's units.
close(added(&mut s, json!({"name": "plate", "expression": "25.4 mm"})), "in", 25.4, 1.0);
// One that refers to another parameter: its bare number is an inch too.
close(added(&mut s, json!({"name": "more", "expression": "v + 1"})), "in", 533.4, 21.0);
close(added(&mut s, json!({"name": "half", "expression": "v / 2"})), "in", 254.0, 10.0);
// An angle reports degrees like every other reply, a unit-less count stays unit-less.
close(added(&mut s, json!({"name": "turn", "expression": "30 deg"})), "deg", 30.0, 30.0);
close(added(&mut s, json!({"name": "n", "expression": "4", "unit": ""})), "", 4.0, 4.0);
close(added(&mut s, json!({"name": "twice", "expression": "n * 2"})), "", 8.0, 8.0);
// parameters.change and parameters.import make a new parameter the same way.
run(&mut s, "parameters.change", json!({"name": "c", "expression": "3"}));
assert_eq!(s.doc.param("c").map(|p| p.unit.as_str()), Some("in"));
run(&mut s, "parameters.import", json!({"text": "name,expression\nimported,2\n"}));
assert_eq!(s.doc.param("imported").map(|p| p.unit.as_str()), Some("in"));
let (vals, _) = s.doc.param_values();
assert!(vals.get("imported").is_some_and(|v| rel(v.v, 50.8) < 1e-9), "{:?}", vals.get("imported"));
// An existing parameter changed without a unit keeps its own.
run(&mut s, "parameters.change", json!({"name": "metric", "expression": "30"}));
assert_eq!(s.doc.param("metric").map(|p| p.unit.as_str()), Some("mm"));
// A centimetre design works the same way.
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "cm"}));
close(added(&mut s, json!({"name": "v", "expression": "3"})), "cm", 30.0, 3.0);
}
/// Hostile parameters.add input in an inch design is refused with a reason and adds nothing.
#[test]
fn a_hostile_parameter_in_an_inch_design_is_refused() {
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
// 1e308 inches is past any finite millimetre value.
for (name, e) in [("huge", "1e308"), ("inf", "1e308 * 1e308"), ("nan", "0/0"), ("bad", "2 furlong"), ("empty", " "), ("self_ref", "self_ref + 1")]
{
let why = err(&mut s, "parameters.add", json!({"name": name, "expression": e}));
assert!(!why.is_empty() && s.doc.param(name).is_none(), "{name}: {why}");
}
let why = err(&mut s, "parameters.add", json!({"name": "u", "expression": "1", "unit": "furlong"}));
assert!(why.contains("unknown unit"), "{why}");
assert!(s.doc.params.is_empty());
}
/// A definition that cannot be made fails the command and leaves the design as it was: a taken,
/// reserved or malformed name, no value, a value that names itself, two different values for one
/// name, and a parameter's or configuration cell's own expression.
#[test]
fn a_refused_definition_changes_nothing() {
let mut s = Session::default();
block(&mut s);
let undo = s.undo.len();
let features = s.doc.features.len();
let refused = [
(json!({"distance": "len = 5"}), "there is already a parameter `len`: type `len` to use it"),
(json!({"distance": "mm = 5"}), "`mm` is a unit name"),
(json!({"distance": "pi = 5"}), "`pi` is a constant"),
(json!({"distance": "w ="}), "`w =` needs a value after the `=`"),
(json!({"distance": "w = "}), "needs a value"),
(json!({"distance": "w = w + 1"}), "w"),
(json!({"distance": "w = = 3"}), "unexpected `=`"),
(json!({"distance": "= 5 ="}), ""),
(json!({"distance": "w = 5", "distance2": "w = 6"}), "`w` is defined twice"),
];
for (p, message) in refused {
let mut q = json!({"sketch": "Base", "operation": "new"});
for (k, v) in p.as_object().unwrap() {
q[k] = v.clone();
}
let e = err(&mut s, "solid.extrude", q.clone());
assert!(e.contains(message), "{q}: {e}");
assert_eq!((s.undo.len(), s.doc.features.len()), (undo, features), "{q}: a refused definition changed the design");
assert!(s.doc.param("w").is_none(), "{q}");
}
// The same definition twice is one parameter.
let r = run(&mut s, "solid.extrude", json!({"sketch": "Base", "operation": "new", "distance": "w = 5", "distance2": "w = 5"}));
assert_eq!(r["definitions"].as_array().map(Vec::len), Some(1), "{r}");
run(&mut s, "edit.undo", json!({}));
// A parameter's own expression or a configuration cell defines nothing.
let e = err(&mut s, "parameters.add", json!({"name": "a", "expression": "b = 5"}));
assert!(e.contains("cannot define another parameter `b`"), "{e}");
assert!(s.doc.param("a").is_none() && s.doc.param("b").is_none());
let e = err(&mut s, "parameters.change", json!({"changes": {"len": "b = 5"}}));
assert!(e.contains("change it on its own to rename it"), "{e}");
// Text that only looks like a definition, where no value is read, is left alone.
let r = run(&mut s, "parameters.add", json!({"name": "label", "expression": "a = b", "unit": "text"}));
assert!(r.get("defined").is_none(), "{r}");
}
/// Changing a parameter to `name = expression` names it (#4071): the dimension's or feature
/// input's own parameter is renamed, references follow, and it takes the expression; no other
/// parameter is made. One undo takes both back.
#[test]
fn changing_a_parameter_to_a_definition_renames_it() {
let mut s = Session::default();
block(&mut s);
let l = run(&mut s, "parameters.list", json!({"filter": "feature"}));
let d = l["parameters"].as_array().unwrap().iter().find(|r| r["input"] == "Distance").unwrap()["name"].as_str().unwrap().to_string();
let count = run(&mut s, "parameters.list", json!({}))["count"].clone();
let r = run(&mut s, "parameters.change", json!({"name": d, "expression": "height = 12"}));
assert_eq!((r["name"].as_str(), r["renamed"]["from"].as_str()), (Some("height"), Some(d.as_str())), "{r}");
assert_eq!(expr_of(&mut s, "height"), "12");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 12.0) < 1e-9);
assert_eq!(run(&mut s, "parameters.list", json!({}))["count"], count, "no extra parameter");
// A dimension's parameter too; whatever refers to it follows.
let width =
s.doc.sketch(s.doc.features[0].id).unwrap().constraints.iter().find_map(|c| c.param.clone().filter(|p| expr_of_now(&s, p) == "40")).unwrap();
run(&mut s, "solid.box", json!({"length": width.clone(), "width": 5, "height": 5, "corner": [100, 0, 0]}));
run(&mut s, "parameters.change", json!({"name": width, "expression": "wide = 50"}));
assert_eq!(expr_of(&mut s, "wide"), "50");
assert!(rel(volume(&mut s), 50.0 * 30.0 * 12.0 + 50.0 * 25.0) < 1e-9);
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.param("wide").is_none());
assert_eq!(expr_of_now(&s, &width), "40");
// Its own name is just a change; a taken name is refused.
run(&mut s, "parameters.change", json!({"name": "height", "expression": "height = 14"}));
assert_eq!(expr_of(&mut s, "height"), "14");
let e = err(&mut s, "parameters.change", json!({"name": "height", "expression": "len = 3"}));
assert!(e.contains("there is already a parameter `len`"), "{e}");
let e = err(&mut s, "parameters.change", json!({"name": "height", "new_name": "h", "expression": "x = 3"}));
assert!(e.contains("not both"), "{e}");
}
fn expr_of_now(s: &Session, name: &str) -> String {
s.doc.param(name).map(|p| p.expr.clone()).unwrap_or_default()
}
/// #9015: `expr.evaluate` of a length with a bare offset uses the design's units.
#[test]
fn expr_evaluate_bare_offsets_follow_design_units() {
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
run(&mut s, "parameters.add", json!({"name": "baseLength", "expression": "76"}));
let r = run(&mut s, "expr.evaluate", json!({"expression": "baseLength - 1", "kind": "length"}));
assert_eq!(r["ok"], true, "{r}");
// 75 in in millimetres (without the fix this was 76 in − 1 mm).
assert!((r["value"].as_f64().unwrap_or(0.0) - 75.0 * 25.4).abs() < 1e-9, "{r}");
}
/// A dimension made as `w = 20` shows `w`; typing `w = 30` over it sets `w` rather than being
/// refused as a taken name (#4071).
#[test]
fn a_definition_of_the_parameter_a_value_names_changes_it() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "solid.box", json!({"length": "w = 20", "width": 5, "height": 5, "corner": [100, 0, 0]}));
let l = run(&mut s, "parameters.list", json!({"filter": "feature"}));
let d = l["parameters"].as_array().unwrap().iter().find(|r| r["expression"] == "w").unwrap()["name"].as_str().unwrap().to_string();
let r = run(&mut s, "parameters.change", json!({"name": d, "expression": "w = 30"}));
assert_eq!((r["name"].as_str(), r["changed_through"].as_str()), (Some("w"), Some(d.as_str())), "{r}");
assert_eq!((expr_of(&mut s, "w"), expr_of(&mut s, &d)), ("30".to_string(), "w".to_string()));
let r = run(&mut s, "expr.evaluate", json!({"expression": "q = w * 2", "kind": "length"}));
assert_eq!((r["ok"].as_bool(), r["value"].as_f64(), r["defines"].as_str()), (Some(true), Some(60.0), Some("q")), "{r}");
}
/// The error of the feature named `name` in the last evaluation.
fn feature_error(s: &Session, name: &str) -> Option<String> {
s.model.results.iter().find(|r| r.name == name).and_then(|r| r.error.clone())
}
/// A sketch dimension set negative fails its sketch; the features built from it fail naming that
/// sketch, not as a sketch that must come earlier in the timeline (#9075). Their errors follow
/// the sketch's own (never an earlier one from the cache) and clear when the value is set back.
#[test]
fn a_feature_on_a_failed_sketch_names_the_sketch() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let out = run(
&mut s,
"sketch.draw.dimensioned",
json!({"command": "sketch.rectangle.center", "params": {"center": [0, 0], "corner": [20, 10]}, "dimensions": {"width": 40, "height": 20}}),
);
let width = out["dimensions"]["width"]["param"].as_str().unwrap_or_default().to_string();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
// A healthy sketch and its extrude after them.
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [50, 0], "p1": [60, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Sketch2", "distance": 5, "operation": "new"}));
assert!(rel(volume(&mut s), 40.0 * 20.0 * 5.0 + 500.0) < 1e-9);
let r = run(&mut s, "parameters.change", json!({"name": width, "expression": "-10"}));
let sketch = feature_error(&s, "Sketch1").unwrap_or_default();
assert!(sketch.contains("dimension value -10"), "{sketch}");
let extrude = feature_error(&s, "Extrude1").unwrap_or_default();
assert!(extrude.contains("`Sketch1`") && extrude.contains("dimension value -10"), "{extrude}");
assert!(!extrude.contains("earlier in the timeline"), "{extrude}");
assert_eq!((feature_error(&s, "Sketch2"), feature_error(&s, "Extrude2")), (None, None), "{r}");
// Another failing value: its own error, not the earlier one from the cache.
run(&mut s, "parameters.change", json!({"name": width, "expression": "-5"}));
let extrude = feature_error(&s, "Extrude1").unwrap_or_default();
assert!(extrude.contains("dimension value -5"), "{extrude}");
// The failed sketch renamed: its new name.
run(&mut s, "timeline.rename", json!({"feature": "Sketch1", "name": "Outline"}));
let extrude = feature_error(&s, "Extrude1").unwrap_or_default();
assert!(extrude.contains("`Outline`") && !extrude.contains("Sketch1"), "{extrude}");
// Set back: both build again.
let r = run(&mut s, "parameters.change", json!({"name": width, "expression": "40"}));
assert_eq!(r["errors"], json!([]), "{r}");
assert!(rel(volume(&mut s), 40.0 * 20.0 * 5.0 + 500.0) < 1e-9);
}
/// A finished sketch with one line (0,0)-(30,0) whose length is dimensioned to parameter `L`.
fn line_dimensioned_by_a_parameter() -> (Session, u64) {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "L", "expression": "30"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "L"}));
run(&mut s, "sketch.finish", json!({}));
let feature = s.doc.find_feature("Sketch1").map(|f| f.id).unwrap_or_default();
(s, feature)
}
/// #9729: `sketch.dimension` refuses 0 ("that would shrink l1 to a point") but a value edit
/// let it through: the line collapsed to a point and every later value counted as a conflict,
/// so only Undo recovered — and after a save and reopen there is no Undo. A value edit now
/// runs the same shrink/fling checks `settle` runs, so the zero is refused where it happens.
#[test]
fn a_parameter_that_shrinks_a_dimensioned_line_to_a_point_is_refused() {
let (mut s, feature) = line_dimensioned_by_a_parameter();
let e = err(&mut s, "parameters.change", json!({"name": "L", "expression": "0"}));
assert!(e.contains("shrink l1 to a point") && e.contains("the values were not changed"), "{e}");
// The line is where it was and the sketch still solves.
assert!((far_x(&s, feature) - 30.0).abs() < 1e-6);
assert!(s.model.state().sketch(feature).unwrap().report.keeps_orientation());
// Other values still work, in both directions: the sketch was never stored collapsed.
run(&mut s, "parameters.change", json!({"name": "L", "expression": "20"}));
assert!((far_x(&s, feature) - 20.0).abs() < 1e-6);
run(&mut s, "parameters.change", json!({"name": "L", "expression": "30"}));
assert!((far_x(&s, feature) - 30.0).abs() < 1e-6);
}
/// The largest x among the stored sketch's points: the line's far end.
fn far_x(s: &Session, feature: u64) -> f64 {
stored_points(s, feature).iter().map(|p| p.x).fold(f64::MIN, f64::max)
}
/// The same through a configuration row: activating a row whose value collapses the line is
/// refused, and the Default row can still be activated afterwards.
#[test]
fn a_configuration_row_that_shrinks_a_dimensioned_line_to_a_point_is_refused() {
let (mut s, feature) = line_dimensioned_by_a_parameter();
run(&mut s, "config.configure", json!({"params": ["L"]}));
run(&mut s, "config.row", json!({"name": "Zero", "values": {"param:L": "0"}}));
let e = err(&mut s, "config.activate", json!({"row": "Zero"}));
assert!(e.contains("shrink l1 to a point"), "{e}");
// The Default row still activates and the line is untouched.
run(&mut s, "config.activate", json!({"row": "Default"}));
assert!((far_x(&s, feature) - 30.0).abs() < 1e-6);
}
/// #10747: `value` means millimetres (and degrees) in every reply — add, change and list —
/// with the parameter's own unit in `display_value`. Reported numbers carry no float noise;
/// stored values keep full precision.
#[test]
fn value_means_the_same_unit_in_every_reply() {
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
let a = run(&mut s, "parameters.add", json!({"name": "L", "expression": "2"}));
assert_eq!((a["value"].as_f64(), a["display_value"].as_f64()), (Some(50.8), Some(2.0)), "{a}");
// The change reply is rounded too: 3 in is 76.2, not 76.19999999999999.
let c = run(&mut s, "parameters.change", json!({"name": "L", "expression": "3"}));
assert_eq!((c["value"].as_f64(), c["display_value"].as_f64()), (Some(76.2), Some(3.0)), "{c}");
run(&mut s, "parameters.add", json!({"name": "d1", "expression": "L"}));
run(&mut s, "parameters.add", json!({"name": "d2", "expression": "1 in"}));
let l = run(&mut s, "parameters.list", json!({}));
// Every row's `value` is mm, whatever the parameter's own unit (#10747).
for (name, mm) in [("L", 76.2), ("d1", 76.2), ("d2", 25.4)] {
assert_eq!(row(&l, name)["value"], mm, "{name}: {l}");
}
assert_eq!(row(&l, "L")["display_value"], 3.0, "{l}");
assert_eq!(row(&l, "d2")["display_value"], 1.0, "{l}");
// An angle reports degrees like every other reply, without the noise.
let a = run(&mut s, "parameters.add", json!({"name": "ang", "expression": "30 deg"}));
assert_eq!((a["value"].as_f64(), a["display_value"].as_f64()), (Some(30.0), Some(30.0)), "{a}");
// Only what is reported is rounded: the stored value keeps full precision — the raw
// own-unit conversion still says 29.999999999999996.
let (vals, _) = s.doc.param_values();
assert_eq!(s.doc.param_display_value(&vals, "ang"), Some(29.999999999999996), "the stored angle keeps its bits");
}
/// #10747: exports carry the numbers a person would read, not float noise. The `value`
/// column keeps the number in the parameter's own unit, as it always did.
#[test]
fn exports_round_reported_numbers() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "ang", "expression": "30 deg"}));
run(&mut s, "parameters.add", json!({"name": "w", "expression": "1.2 in"}));
run(&mut s, "parameters.add", json!({"name": "s", "expression": "0.1 + 0.2"}));
let csv = run(&mut s, "parameters.export", json!({"format": "csv"}))["text"].as_str().unwrap_or_default().to_string();
assert!(csv.contains("ang,deg,30 deg,30,,false"), "{csv}");
assert!(csv.contains("w,mm,1.2 in,30.48,,false"), "{csv}");
assert!(csv.contains("s,mm,0.1 + 0.2,0.3,,false"), "{csv}");
let json_text = run(&mut s, "parameters.export", json!({"format": "json"}))["text"].as_str().unwrap_or_default().to_string();
let rows: Vec<Value> = serde_json::from_str(&json_text).unwrap_or_default();
let vals: Vec<f64> = rows.iter().filter_map(|r| r["value"].as_f64()).collect();
assert_eq!(vals, vec![30.0, 30.48, 0.3], "{json_text}");
}