Files
solidcraft-upstream/crates/ui-egui/src/primitive_units_tests.rs
T
semmelrindeandClaude Opus 5.5 ce5762d873 fix(ui): Box, Cylinder, Sphere, Torus and Coil dialogs show the unit of their sizes (#630) (#709)
The Box, Cylinder, Sphere, Torus and Coil dialogs now start their sizes in the design's units with the unit written out ("20 mm", "20 in"), like the other dialogs. OK makes the same sizes as before. Fixes #630.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:44:10 -04:00

231 lines
10 KiB
Rust

//! #630: the Box, Cylinder, Sphere, Torus and Coil dialogs show the unit of their sizes, as the
//! other dialogs do ("10 mm"), in the design's units. OK makes what the bare numbers made before,
//! and a bare number typed into a field still means the design's units.
use serde_json::{Value, json};
use solidcraft_engine::Session;
use crate::dialogs::{Kind, apply_commands};
use crate::scenario::Harness;
/// The primitives with their sizes as a bare number reads them: the command and fields.
const PRIMITIVES: [(&str, &[(&str, &str)]); 5] = [
("solid.box", &[("length", "20"), ("width", "20"), ("height", "20")]),
("solid.cylinder", &[("diameter", "20"), ("height", "20")]),
("solid.sphere", &[("diameter", "20")]),
("solid.torus", &[("major", "20"), ("minor", "5")]),
("solid.coil", &[("diameter", "40"), ("revolutions", "4"), ("pitch", "8"), ("section_size", "3")]),
];
/// A design in `units`, with nothing in it.
fn design(units: &str) -> Harness {
let mut h = Harness::new();
h.app.home.open = false;
if units != "mm" {
h.app.run("document.units", json!({ "units": units })).unwrap();
}
h.frames(2);
h
}
/// The open dialog's fields.
fn fields(h: &Harness) -> Vec<(&'static str, String)> {
match h.app.dialog.as_ref().map(|d| &d.kind) {
Some(Kind::Primitive { fields, .. }) => fields.clone(),
other => panic!("no primitive dialog: {other:?}"),
}
}
/// Every field shows its text in its row, as a person sees it.
fn assert_shown(h: &Harness, fields: &[(&str, String)]) {
for (label, text) in fields {
assert!(h.widget_near(text, label).is_some(), "the {label} row shows {text:?}");
}
}
/// Replace a field's text by typing, as a person does.
fn type_into(h: &mut Harness, label: &str, text: &str) {
h.step(&json!({"click": {"handle": format!("dialog:{label}")}})).unwrap();
h.step(&json!({"key": "A", "cmd": true})).unwrap();
h.step(&json!({"text": text})).unwrap();
}
fn ok(h: &mut Harness) {
h.step(&json!({"click": {"handle": "dialog:OK"}})).unwrap();
h.frames(2);
assert!(h.app.dialog.is_none(), "OK closes the dialog: {:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone()));
}
/// The volume of everything in the design (mm³).
fn volume(s: &mut Session) -> f64 {
s.execute("inspect.measure", &json!({})).unwrap()["total"]["volume_mm3"].as_f64().unwrap()
}
/// The size of the design's only body (mm).
fn size(s: &mut Session) -> [f64; 3] {
let r = s.execute("inspect.measure", &json!({})).unwrap();
let b = &r["bodies"][0]["bbox"];
std::array::from_fn(|i| b["max"][i].as_f64().unwrap() - b["min"][i].as_f64().unwrap())
}
fn assert_size(s: &mut Session, want: [f64; 3]) {
let got = size(s);
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-6), "{got:?}, want {want:?}");
}
/// The volume the command makes from the bare numbers in a design in `units` (what the dialog
/// made before its fields showed units).
fn bare_volume(units: &str, cmd: &str, sizes: &[(&str, &str)]) -> f64 {
let mut s = Session::default();
s.execute("document.units", &json!({ "units": units })).unwrap();
let params: serde_json::Map<String, Value> = sizes.iter().map(|(k, v)| ((*k).to_string(), json!(v))).collect();
s.execute(cmd, &Value::Object(params)).unwrap();
volume(&mut s)
}
#[test]
fn primitive_dialogs_show_millimetres_and_make_the_same_sizes() {
for (cmd, sizes) in PRIMITIVES {
let mut h = design("mm");
h.app.start(cmd);
h.frames(3);
let shown = fields(&h);
let want: Vec<(&str, String)> =
sizes.iter().map(|(k, v)| (*k, if *k == "revolutions" { (*v).to_string() } else { format!("{v} mm") })).collect();
assert_eq!(shown, want, "{cmd}");
assert_shown(&h, &shown);
// OK sends the fields' texts, units and all.
let (id, params) = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().remove(0);
assert_eq!(id, cmd);
for (k, v) in &want {
assert_eq!(params[*k], json!(v), "{cmd} {k}");
}
ok(&mut h);
let made = volume(&mut h.app.session);
let before = bare_volume("mm", cmd, sizes);
assert!(made > 0.0 && (made - before).abs() < 1e-6 * before, "{cmd}: {made} mm³, the bare numbers made {before} mm³");
}
let mut h = design("mm");
h.app.start("solid.box");
h.frames(3);
ok(&mut h);
assert_size(&mut h.app.session, [20.0, 20.0, 20.0]);
}
#[test]
fn in_an_inch_design_primitive_dialogs_show_inches_and_read_bare_numbers_as_inches() {
for (cmd, sizes) in PRIMITIVES {
let mut h = design("in");
h.app.start(cmd);
h.frames(3);
let shown = fields(&h);
let want: Vec<(&str, String)> =
sizes.iter().map(|(k, v)| (*k, if *k == "revolutions" { (*v).to_string() } else { format!("{v} in") })).collect();
assert_eq!(shown, want, "{cmd}");
assert_shown(&h, &shown);
ok(&mut h);
// The same size the bare numbers made in an inch design.
let made = volume(&mut h.app.session);
let before = bare_volume("in", cmd, sizes);
assert!(made > 0.0 && (made - before).abs() < 1e-6 * before, "{cmd}: {made} mm³, the bare numbers made {before} mm³");
}
// A bare number typed into a field is in the design's units; a typed unit is honoured.
let mut h = design("in");
h.app.start("solid.box");
h.frames(3);
type_into(&mut h, "width", "2");
type_into(&mut h, "height", "10 mm");
h.frames(2);
let (_, params) = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().remove(0);
assert_eq!((&params["length"], &params["width"], &params["height"]), (&json!("20 in"), &json!("2"), &json!("10 mm")));
ok(&mut h);
assert_size(&mut h.app.session, [508.0, 50.8, 10.0]);
}
#[test]
fn a_centimetre_design_shows_centimetres() {
let mut h = design("cm");
h.app.start("solid.sphere");
h.frames(3);
assert_eq!(fields(&h), vec![("diameter", "20 cm".to_string())]);
ok(&mut h);
assert_size(&mut h.app.session, [200.0, 200.0, 200.0]);
}
/// The sizes a primitive feature keeps, as stored.
fn stored(h: &Harness, id: u64) -> Vec<String> {
use solidcraft_engine::doc::FeatureKind;
match h.app.session.doc.feature(id).map(|f| &f.kind) {
Some(FeatureKind::Box { length, width, height, .. }) => vec![length.clone(), width.clone(), height.clone()],
Some(FeatureKind::Cylinder { radius, height, .. }) => vec![radius.clone(), height.clone()],
Some(FeatureKind::Sphere { radius, .. }) => vec![radius.clone()],
Some(FeatureKind::Torus { major, minor, .. }) => vec![major.clone(), minor.clone()],
other => panic!("not a primitive: {other:?}"),
}
}
#[test]
fn editing_a_primitive_after_the_design_goes_to_inches_keeps_its_millimetres() {
// Made by a script in a millimetre design, then the design goes to inches: bare sizes are
// still millimetres, and Edit shows them so; OK without a change keeps the part. Sizes with
// a unit, expressions and parameters show as they are kept.
let mut h = design("mm");
h.app.run("parameters.add", json!({"name": "w", "expression": "6 mm"})).unwrap();
h.app.run("solid.box", json!({"length": 40, "width": "w", "height": 20})).unwrap();
h.app.run("solid.cylinder", json!({"diameter": "10 mm", "height": 12, "base": [60, 0, 0], "operation": "new"})).unwrap();
h.app.run("solid.sphere", json!({"radius": 4, "center": [0, 60, 0], "operation": "new"})).unwrap();
h.app.run("solid.torus", json!({"major": 10, "minor": "2 mm", "center": [60, 60, 0], "operation": "new"})).unwrap();
h.app.run("document.units", json!({"units": "in"})).unwrap();
h.frames(2);
let before = volume(&mut h.app.session);
let ids: Vec<u64> = h.app.session.doc.features.iter().map(|f| f.id).collect();
let want: [&[(&str, &str)]; 4] = [
&[("length", "40 mm"), ("width", "w"), ("height", "20 mm")],
&[("radius", "(10 mm) / 2"), ("height", "12 mm")],
&[("radius", "4 mm")],
&[("major", "10 mm"), ("minor", "2 mm")],
];
assert_eq!(ids.len(), want.len());
for (id, want) in ids.into_iter().zip(want) {
h.app.edit_feature(id);
h.frames(3);
let shown = fields(&h);
assert_eq!(shown, want.iter().map(|(k, v)| (*k, (*v).to_string())).collect::<Vec<_>>());
assert_shown(&h, &shown);
ok(&mut h);
assert_eq!(stored(&h, id), want.iter().map(|(_, v)| (*v).to_string()).collect::<Vec<_>>());
let after = volume(&mut h.app.session);
assert!((after - before).abs() < 1e-6 * before, "the part is unchanged: {after} mm³, was {before} mm³");
}
// A size typed bare in the edit is in the design's units, as anywhere else.
let first = h.app.session.doc.features.first().map(|f| f.id).unwrap();
h.app.edit_feature(first);
h.frames(3);
type_into(&mut h, "height", "1");
h.frames(2);
ok(&mut h);
let r = h.app.session.execute("inspect.measure", &json!({})).unwrap();
let height = r["bodies"].as_array().unwrap().iter().map(|b| b["bbox"]["max"][2].as_f64().unwrap()).fold(f64::MIN, f64::max);
assert!((height - 25.4).abs() < 1e-6, "1 in tall: {height}");
}
#[test]
fn editing_in_a_millimetre_design_keeps_the_sizes_as_they_are_kept() {
// A bare number already means millimetres here: Edit shows and sends it as kept, so OK
// without a change leaves the feature (and the active configuration's match) alone.
let mut h = design("mm");
h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap();
let (id, length) = h.app.session.doc.features.first().map(|f| (f.id, f.param_names[0].clone())).unwrap();
h.app.run("config.configure", json!({ "params": [length] })).unwrap();
h.app.run("config.activate", json!({"row": "Default"})).unwrap();
assert!(h.app.session.doc.configuration_matches());
h.app.edit_feature(id);
h.frames(3);
let shown = fields(&h);
assert_eq!(shown, vec![("length", "40".to_string()), ("width", "30".to_string()), ("height", "20".to_string())]);
ok(&mut h);
assert_eq!(stored(&h, id), ["40", "30", "20"]);
assert!(h.app.session.doc.configuration_matches(), "an edit without a change keeps the configuration matched");
assert_size(&mut h.app.session, [40.0, 30.0, 20.0]);
}