Merge origin/main (6f52cc50, the squash of upstream pull request 471) into w/form-controls

Both sides added a kind of floating object (equation, form control); both are kept everywhere.
ROADMAP.md: header combined (form controls first, then equations), catalog 281 / 296 as `cargo xtask parity` measures, the form-controls progress-log and revision rows above main's equation rows.
crates/engine/src/cmd.rs: both spec lists (controls and equation).
crates/engine/src/cmd/extra.rs: the arrange params docs list both kinds.
crates/engine/src/cmd/extra/objects.rs: main's `found` match and `anchors_of` on ObjKind gain the Control arms; the unlocked check takes the parsed kind directly.
crates/engine/src/cmd/insert.rs: the object params docs and the kind error name both kinds.
crates/engine/src/tests.rs: both test modules.
crates/engine/src/tests/objects.rs: every_kind returns six objects (equation at L18, slider at P18) and the anchor lists carry both.
crates/model/src/features.rs: ObjKind has Equation and Control, ALL lists six kinds, name() both.
crates/model/src/sheet.rs: both imports, both accessor arms, the order test inserts equations (6, 8) and controls (13, 15) and documents controls after slicers with equations counted as shapes.
crates/model/src/workbook.rs: main's refresh_equations and this branch's free-id doc comment.
crates/ui-egui/src/chartview.rs: both doc comments combined.
crates/ui-egui/src/objects.rs: both OpenAction variants and arms.
crates/ui-egui/src/ribbon/insert.rs: the import list is the union.
docs/control-protocol.md: the kind list names equation and control; main's equation sentences and this branch's form-controls sentence.
docs/file-format-parity.md: header combined; the xlsx row carries both additions; the form-controls revision row above main's equation rows.
docs/parity-checklist.md: regenerated with `cargo xtask parity` (281 / 296).
docs/target-app-parity.md: header combined; catalog 281 / 296; the Insert objects row carries both additions; the form-controls revision row (277 / 292 → 281 / 296) above main's.
docs/ui-parity.md: header combined with catalog 281 / 296; main's move-and-resize row with equations plus the form-controls row; the form-controls revision row above main's.
This commit is contained in:
prxnvt
2026-11-02 10:47:43 -05:00
95 changed files with 6317 additions and 218 deletions
+227
View File
@@ -0,0 +1,227 @@
//! Equations: insert, edit, the layout cache and its rebuild on load.
use gridcraft_model::{Color, Shape, ShapeKind};
use serde_json::json;
use super::s;
use crate::Session;
fn equation(s: &Session, id: u32) -> Shape {
s.doc().unwrap().wb.active().unwrap().shapes.iter().find(|x| x.id == id).cloned().unwrap()
}
fn insert(s: &mut Session, p: serde_json::Value) -> u32 {
s.execute("insert.equation", p).unwrap()["equation"].as_u64().unwrap() as u32
}
#[test]
fn insert_lays_the_equation_out_and_sizes_its_frame() {
let mut s = s();
let id = insert(&mut s, json!({"latex": "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}", "cell": "C3"}));
let e = equation(&s, id);
assert_eq!(e.kind, ShapeKind::Equation);
assert_eq!(e.anchor.cell.a1(), "C3");
assert_eq!(e.line, Color::Auto, "Automatic ink");
let list = e.math.get(&e.text).unwrap().unwrap();
let scale = crate::cmd::equation::fit_scale(list, e.anchor.width, e.anchor.height);
assert!((scale - 11.0 * 96.0 / 72.0).abs() < 0.01, "11 pt: {scale}");
// A bigger size makes a bigger frame; `at` works like `cell`.
let big = insert(&mut s, json!({"latex": "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}", "at": "H3", "size": 22, "color": "#1F4E79"}));
let b = equation(&s, big);
assert!((b.anchor.width - 8.0) > 1.9 * (e.anchor.width - 8.0));
assert_eq!(b.line, Color::Rgb(0x1F4E79));
assert_eq!(b.anchor.cell.a1(), "H3");
// One undo step each.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.doc().unwrap().wb.active().unwrap().shapes.iter().all(|x| x.id != big));
}
#[test]
fn insert_refuses_missing_bad_or_hostile_source() {
let mut s = s();
for p in [
json!({}),
json!({"latex": ""}),
json!({"latex": " "}),
json!({"latex": 5}),
json!({"latex": "x", "size": 0}),
json!({"latex": "x", "size": f64::MAX}),
json!({"latex": "x", "color": "nope"}),
] {
assert!(s.execute("insert.equation", p.clone()).is_err(), "{p}");
}
let e = s.execute("insert.equation", json!({"latex": "a + \\foo"})).unwrap_err().to_string();
assert!(e.contains("character 5") && e.contains("\\foo"), "{e}");
let e = s.execute("insert.equation", json!({"latex": "x".repeat(5000)})).unwrap_err().to_string();
assert!(e.contains("4096"), "{e}");
let e = s.execute("insert.equation", json!({"latex": format!("{}x{}", "{".repeat(40), "}".repeat(40))})).unwrap_err().to_string();
assert!(e.contains("nest"), "{e}");
assert!(s.doc().unwrap().wb.active().unwrap().shapes.is_empty());
// `equation.edit` is the other way a source reaches the layout engine; it has the same caps,
// and a refused edit leaves the equation as it was.
let id = insert(&mut s, json!({"latex": "x"}));
let e = s.execute("equation.edit", json!({"id": id, "latex": "x".repeat(5000)})).unwrap_err().to_string();
assert!(e.contains("4096"), "{e}");
let e = s.execute("equation.edit", json!({"id": id, "latex": format!("{}x{}", "{".repeat(40), "}".repeat(40))})).unwrap_err().to_string();
assert!(e.contains("nest"), "{e}");
assert_eq!(equation(&s, id).text, "x");
}
#[test]
fn edit_keeps_the_scale_and_fits_the_frame() {
let mut s = s();
let id = insert(&mut s, json!({"latex": "x", "size": 20}));
let before = equation(&s, id);
s.execute("equation.edit", json!({"id": id, "latex": "x + y + z"})).unwrap();
let after = equation(&s, id);
assert_eq!(after.text, "x + y + z");
let list = after.math.get(&after.text).unwrap().unwrap();
let scale = crate::cmd::equation::fit_scale(list, after.anchor.width, after.anchor.height);
assert!((scale - 20.0 * 96.0 / 72.0).abs() < 0.05, "{scale}");
assert!(after.anchor.width > before.anchor.width);
// A frame the user resized keeps its new scale through an edit.
s.execute("object.move", json!({"kind": "equation", "id": id, "width": after.anchor.width * 2.0, "height": after.anchor.height * 2.0})).unwrap();
s.execute("equation.edit", json!({"id": id, "latex": "x + y"})).unwrap();
let e = equation(&s, id);
let scale = crate::cmd::equation::fit_scale(e.math.get(&e.text).unwrap().unwrap(), e.anchor.width, e.anchor.height);
assert!(scale > 2.0 * 20.0 * 96.0 / 72.0 * 0.95, "{scale}");
// Undo restores the source and its layout.
s.execute("edit.undo", json!({})).unwrap();
let e = equation(&s, id);
assert_eq!(e.text, "x + y + z");
assert!(e.math.get(&e.text).unwrap().is_ok());
// Same source: nothing to do. Bad source: refused, nothing changes.
let depth = s.doc().unwrap().undo.len();
s.execute("equation.edit", json!({"id": id, "latex": "x + y + z"})).unwrap();
assert_eq!(s.doc().unwrap().undo.len(), depth);
assert!(s.execute("equation.edit", json!({"id": id, "latex": "\\frac{"})).is_err());
assert!(s.execute("equation.edit", json!({"id": id, "latex": ""})).is_err());
assert_eq!(equation(&s, id).text, "x + y + z");
}
#[test]
fn the_source_excerpt_is_at_most_the_cap_and_whole_chars() {
use crate::cmd::equation::source_excerpt;
use gridcraft_math::MAX_SOURCE_BYTES as CAP;
assert_eq!(source_excerpt(""), "");
assert_eq!(source_excerpt("x+y"), "x+y");
let at = "x".repeat(CAP);
assert_eq!(source_excerpt(&at), at, "a source at the cap is whole");
assert_eq!(source_excerpt(&format!("{at}tail")), at);
// A three-byte character straddling the cap is left out rather than cut in two.
let s = format!("{}\u{20AC}", "x".repeat(CAP - 1));
assert_eq!(source_excerpt(&s), "x".repeat(CAP - 1));
let s = format!("{}\u{20AC}\u{20AC}", "x".repeat(CAP - 2));
assert_eq!(source_excerpt(&s).len(), CAP - 2);
assert_eq!(source_excerpt(&"\u{1F600}".repeat(2000)).len(), CAP);
}
#[test]
fn edit_refuses_what_is_not_an_equation() {
let mut s = s();
let tb = s.execute("insert.textBox", json!({"text": "hi"})).unwrap()["shape"].as_u64().unwrap();
assert!(s.execute("equation.edit", json!({"id": tb, "latex": "x"})).is_err());
assert!(s.execute("equation.edit", json!({"id": 999, "latex": "x"})).is_err());
assert!(s.execute("equation.edit", json!({"latex": "x"})).is_err());
assert!(s.execute("equation.edit", json!({"id": -1, "latex": "x"})).is_err());
}
#[test]
fn layouts_are_rebuilt_on_load_and_not_saved() {
let mut s = s();
let id = insert(&mut s, json!({"latex": "e^{i\\pi} + 1 = 0"}));
let wb = s.doc().unwrap().wb.clone();
let bytes = serde_json::to_vec(&*wb).unwrap();
assert!(!String::from_utf8_lossy(&bytes).contains("\"math\""), "the cache is not saved");
let (opened, _) = crate::io::open_bytes("book.json", &bytes).unwrap();
let raw = opened.active().unwrap().shapes.iter().find(|x| x.id == id).cloned().unwrap();
assert!(raw.math.get(&raw.text).is_none(), "a freshly read shape has no layout");
let mut t = Session::new();
t.add_document(crate::DocState::new(opened, None, "Book".into(), crate::Trust::FromFile));
let e = equation(&t, id);
assert!(e.math.get(&e.text).unwrap().is_ok(), "opening lays it out");
}
#[test]
fn a_bad_source_from_a_file_keeps_its_error() {
let mut s = s();
let id = insert(&mut s, json!({"latex": "x"}));
let mut wb = (*s.doc().unwrap().wb).clone();
if let Some(sh) = wb.sheet_mut(0) {
sh.shapes.iter_mut().for_each(|x| x.text = "\\frac{".into());
}
let mut t = Session::new();
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
let e = equation(&t, id);
assert!(e.math.get(&e.text).unwrap().is_err());
}
#[test]
fn equations_on_a_protected_sheet_are_locked() {
let mut s = s();
let id = insert(&mut s, json!({"latex": "x"}));
s.execute("review.protectSheet", json!({})).unwrap();
assert!(s.execute("equation.edit", json!({"id": id, "latex": "y"})).is_err());
assert!(s.execute("insert.equation", json!({"latex": "y"})).is_err());
}
#[test]
fn a_file_with_too_many_equations_lays_out_a_bounded_amount() {
// About 1 KB each: past the budget, equations are refused without running the layout engine,
// so opening the file takes bounded time.
let source = format!("x{}", "+x".repeat(500));
let count = gridcraft_model::MAX_EQUATION_LAYOUT_BYTES / source.len() + 40;
let mut s = s();
let first = insert(&mut s, json!({"latex": "x", "cell": "A1"}));
let mut wb = (*s.doc().unwrap().wb).clone();
if let Some(sh) = wb.sheet_mut(0) {
let template = sh.shapes.iter().find(|x| x.id == first).cloned().unwrap();
sh.shapes.clear();
for i in 0..count {
let mut e = template.clone();
e.id = 100 + i as u32;
e.text = source.clone();
e.math = Default::default();
sh.shapes.push(e);
}
}
let mut t = Session::new();
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
let shapes = t.doc().unwrap().wb.active().unwrap().shapes.clone();
let laid = shapes.iter().filter(|x| matches!(x.math.get(&x.text), Some(Ok(_)))).count();
let refused: Vec<&Shape> =
shapes.iter().filter(|x| matches!(x.math.get(&x.text), Some(Err(e)) if e.message == gridcraft_model::EQUATION_BUDGET_MESSAGE)).collect();
assert_eq!(laid + refused.len(), count, "every equation has a layout or the budget's error");
assert!(laid * source.len() <= gridcraft_model::MAX_EQUATION_LAYOUT_BYTES, "{laid} laid out");
assert!(refused.len() >= 40, "{} refused", refused.len());
// A later edit does not retry them all, and OK on one, unchanged, lays it out.
let id = refused[0].id;
t.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
assert!(matches!(equation(&t, id).math.get(&source), Some(Err(_))));
t.execute("equation.edit", json!({"id": id, "latex": source})).unwrap();
assert!(matches!(equation(&t, id).math.get(&source), Some(Ok(_))), "editing lays it out");
}
#[test]
fn ids_stay_unique_when_a_file_used_the_largest_id() {
// A workbook (from a JSON file, say) whose shape has id u32::MAX: new equations and the
// objects of a copied sheet still get ids no other object has.
let mut s = s();
let first = insert(&mut s, json!({"latex": "x", "cell": "A1"}));
let mut wb = (*s.doc().unwrap().wb).clone();
if let Some(sh) = wb.sheet_mut(0) {
sh.shapes.iter_mut().filter(|x| x.id == first).for_each(|x| x.id = u32::MAX);
}
let mut t = Session::new();
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
let a = insert(&mut t, json!({"latex": "y", "cell": "C1"}));
let b = insert(&mut t, json!({"latex": "z", "cell": "E1"}));
t.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
let wb = &t.doc().unwrap().wb;
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| sh.shapes.iter().map(|x| x.id)).collect();
assert_eq!(ids.len(), 6, "three equations on each sheet");
assert!(a != u32::MAX && b != u32::MAX && a != b, "{a} {b}");
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), 6, "every id is distinct: {ids:?}");
}
+123 -13
View File
@@ -20,8 +20,8 @@ fn id_of(v: &serde_json::Value, key: &str) -> u32 {
}
/// A table with one object of each kind on the first sheet: a chart, a picture, a shape, a
/// slicer and a form control, in `ObjKind::ALL` order.
fn every_kind(s: &mut Session) -> [ObjRef; 5] {
/// slicer, an equation and a form control, in `ObjKind::ALL` order.
fn every_kind(s: &mut Session) -> [ObjRef; 6] {
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 3], ["West", 5], ["East", 2], ["North", 8]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap();
let chart = id_of(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D2"})).unwrap(), "chart");
@@ -29,12 +29,14 @@ fn every_kind(s: &mut Session) -> [ObjRef; 5] {
let shape = id_of(&s.execute("insert.shape", json!({"kind": "ellipse", "at": "H18"})).unwrap(), "shape");
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
let slicer = id_of(&s.execute("insert.slicer", json!({"column": "Region", "at": "L2"})).unwrap(), "slicer");
let equation = id_of(&s.execute("insert.equation", json!({"latex": "x^2", "cell": "L18"})).unwrap(), "equation");
let control = id_of(&s.execute("insert.slider", json!({"at": "P18", "link": "$B$7"})).unwrap(), "control");
[
ObjRef::new(ObjKind::Chart, chart),
ObjRef::new(ObjKind::Image, image),
ObjRef::new(ObjKind::Shape, shape),
ObjRef::new(ObjKind::Slicer, slicer),
ObjRef::new(ObjKind::Equation, equation),
ObjRef::new(ObjKind::Control, control),
]
}
@@ -52,6 +54,9 @@ fn every_kind_is_found_moved_and_deleted_by_kind_and_id() {
s.execute("object.setAnchorMode", json!({"kind": o.kind.name(), "id": o.id, "mode": "absolute"})).unwrap();
assert_eq!(sheet(&s, 0).object_anchor(o).unwrap().mode, AnchorMode::Absolute);
}
// An equation is a shape too; a plain shape is not an equation.
assert!(sheet(&s, 0).has_object(ObjRef::new(ObjKind::Shape, objs[4].id)));
assert!(s.execute("object.delete", json!({"kind": "equation", "id": objs[2].id})).is_err());
// A kind that does not match the id, an unknown id or kind, and a missing kind are refused.
assert!(s.execute("object.delete", json!({"kind": "slicer", "id": objs[0].id})).is_err());
assert!(s.execute("object.delete", json!({"kind": "chart", "id": 9999})).is_err());
@@ -65,9 +70,9 @@ fn every_kind_is_found_moved_and_deleted_by_kind_and_id() {
assert!(sheet(&s, 0).has_object(*later), "deleting {o:?} leaves {later:?}");
}
}
// One undo step per delete brings the control back.
// One undo step per delete brings the control, deleted last, back.
s.execute("edit.undo", json!({})).unwrap();
assert!(sheet(&s, 0).has_object(objs[4]));
assert!(sheet(&s, 0).has_object(objs[5]));
}
#[test]
@@ -85,7 +90,7 @@ fn selection_pane_lists_and_stacks_slicers() {
assert_eq!(kinds.get(kinds.len() - 2), Some(&"slicer"), "slicers stack above charts, pictures and shapes");
let names: Vec<&str> = items.iter().map(|o| o["name"].as_str().unwrap()).collect();
assert!(names.contains(&"Region") && names.contains(&"Qty"), "{names:?}");
assert!(names.contains(&format!("Slider {}", objs[4].id).as_str()), "{names:?}");
assert!(names.contains(&format!("Slider {}", objs[5].id).as_str()), "{names:?}");
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(objs[3].id));
s.execute("arrange.sendBackward", json!({"kind": "slicer", "id": second, "toBack": true})).unwrap();
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(second));
@@ -121,17 +126,17 @@ fn cell_of(s: &Session, o: ObjRef) -> String {
fn every_kind_follows_row_and_column_edits_and_sorts() {
let mut s = s();
let objs = every_kind(&mut s);
// Anchors: chart D2, picture D18, shape H18, slicer L2, slider P18.
// Anchors: chart D2, picture D18, shape H18, slicer L2, equation L18, slider P18.
s.execute("object.setAnchorMode", json!({"kind": "shape", "id": objs[2].id, "mode": "absolute"})).unwrap();
let cells = |s: &Session| objs.map(|o| cell_of(s, o));
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "P18"]);
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18", "P18"]);
s.execute("home.insertRows", json!({"rows": "10:11"})).unwrap();
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "P20"], "rows inserted above move the objects below them; an absolute shape stays");
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "L20", "P20"], "rows inserted above move the objects below them; an absolute shape stays");
s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap();
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "Q20"]);
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "M20", "Q20"]);
s.execute("home.deleteColumns", json!({"cols": "C:C"})).unwrap();
s.execute("home.deleteRows", json!({"rows": "10:11"})).unwrap();
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "P18"]);
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18", "P18"]);
// Sorting moves a picture anchored inside the sorted block with its row.
s.execute("range.setValues", json!({"range": "T1", "values": [["k"], [3], [1], [2]]})).unwrap();
let pic = ObjRef::new(ObjKind::Image, super::add_image(&mut s, "T2", AnchorMode::MoveAndSize));
@@ -269,9 +274,11 @@ fn a_copied_sheet_gives_its_objects_fresh_ids() {
ids.dedup();
assert_eq!(ids.len(), 2 * objs.len(), "every object id stays unique across the workbook");
let copy = sheet(&s, 1);
for (o, id) in objs.iter().zip(ids_of(copy)) {
assert_eq!(copy.object_ref(id).map(|c| c.kind), Some(o.kind));
}
let mut kinds: Vec<&str> = ids_of(copy).into_iter().filter_map(|id| copy.object_ref(id)).map(|o| o.kind.name()).collect();
let mut want: Vec<&str> = objs.iter().map(|o| o.kind.name()).collect();
kinds.sort_unstable();
want.sort_unstable();
assert_eq!(kinds, want, "the copies keep their kinds");
assert!(s.doc().unwrap().wb.next_object_id() > *ids.last().unwrap_or(&0));
}
@@ -389,3 +396,106 @@ fn chart_set_keeps_a_chart_size_a_file_made_larger_than_the_limit() {
s.execute("chart.set", json!({"chart": chart, "width": 50_000, "height": 50_000})).unwrap();
assert_eq!(size(&s), (11_990.0, 10_000.0), "hostile sizes are capped at the current width and at 10,000");
}
#[test]
fn arrange_and_align_match_a_shape_id_to_its_kind() {
// `arrange.*` and `arrange.align` under `kind: "equation"` act on equations only: an
// ordinary shape's id given as an equation is refused by `arrange` and left in place by
// `align`, as `object.delete` refuses it. An equation is a shape too, so its id works under
// `shape`, as for the other object commands; an unknown kind, and an id of no object of the
// kind, are refused instead of acting on whatever shape has the id.
let mut s = s();
let objs = every_kind(&mut s);
let (shape, equation) = (objs[2], objs[4]);
let order = |s: &Session| -> Vec<u32> { sheet(s, 0).shapes.iter().map(|c| c.id).collect() };
assert_eq!(order(&s), vec![shape.id, equation.id]);
assert!(s.execute("arrange.bringForward", json!({"kind": "equation", "id": shape.id, "toFront": true})).is_err());
assert_eq!(order(&s), vec![shape.id, equation.id], "the shape is not reordered as an equation");
s.execute("arrange.sendBackward", json!({"kind": "equation", "id": equation.id, "toBack": true})).unwrap();
assert_eq!(order(&s), vec![equation.id, shape.id]);
s.execute("arrange.bringForward", json!({"kind": "shape", "id": equation.id, "toFront": true})).unwrap();
assert_eq!(order(&s), vec![shape.id, equation.id], "an equation is a shape too");
assert!(s.execute("arrange.bringForward", json!({"kind": "widget", "id": shape.id})).is_err());
assert!(s.execute("arrange.bringForward", json!({"kind": "chart", "id": 9999})).is_err());
assert!(s.execute("arrange.bringForward", json!({"kind": "shape", "id": objs[0].id})).is_err(), "a chart's id is not a shape");
// Align: the shape's id given as an equation is refused and nothing moves; the two equations
// line up on their own.
let second = ObjRef::new(ObjKind::Equation, id_of(&s.execute("insert.equation", json!({"latex": "y", "cell": "B30"})).unwrap(), "equation"));
let before = (anchor_of(&s, shape), anchor_of(&s, equation));
assert!(s.execute("arrange.align", json!({"kind": "equation", "ids": [shape.id, equation.id, second.id], "align": "left"})).is_err());
assert_eq!((anchor_of(&s, shape), anchor_of(&s, equation)), before, "nothing is aligned with a refused id");
s.execute("arrange.align", json!({"kind": "equation", "ids": [equation.id, second.id], "align": "left"})).unwrap();
let (a, b) = (sheet(&s, 0).object_anchor(equation).unwrap(), sheet(&s, 0).object_anchor(second).unwrap());
assert_eq!((a.cell.col, a.dx), (b.cell.col, b.dx), "the equations line up");
assert!(s.execute("arrange.align", json!({"kind": "widget", "ids": [shape.id, equation.id], "align": "left"})).is_err());
}
#[test]
fn arrange_refuses_a_kind_that_is_present_but_not_a_name() {
// `kind` is `shape` only when the parameter is absent: `null`, a number or another non-name
// is refused, so a shape's id is not reordered or aligned under it. The `object.*` commands
// refuse the same values, their kind being required.
let mut s = s();
let objs = every_kind(&mut s);
let (shape, equation) = (objs[2], objs[4]);
let order = |s: &Session| -> Vec<u32> { sheet(s, 0).shapes.iter().map(|c| c.id).collect() };
let before = (order(&s), anchor_of(&s, shape));
for kind in [json!(null), json!(7), json!(["shape"]), json!({"kind": "shape"}), json!(true)] {
assert!(s.execute("arrange.bringForward", json!({"kind": kind, "id": shape.id, "toFront": true})).is_err(), "{kind}");
assert!(s.execute("arrange.align", json!({"kind": kind, "ids": [shape.id, equation.id], "align": "left"})).is_err(), "{kind}");
assert!(s.execute("object.move", json!({"kind": kind, "id": shape.id, "at": "A1"})).is_err(), "{kind}");
}
assert_eq!((order(&s), anchor_of(&s, shape)), before, "nothing moved");
// Absent, the kind is `shape`, as before.
s.execute("arrange.bringForward", json!({"id": shape.id, "toFront": true})).unwrap();
assert_eq!(order(&s).last(), Some(&shape.id));
}
#[test]
fn align_refuses_an_id_that_is_not_an_object_of_the_kind() {
// `arrange.align` and `arrange.group` refuse an id in `ids` that is not an object of the
// kind, or not a number, as `arrange` and the `object.*` commands refuse it, instead of
// aligning the rest and reporting success; nothing moves. With fewer than two ids they
// change nothing and succeed.
let mut s = s();
let objs = every_kind(&mut s);
let (shape, equation) = (objs[2], objs[4]);
let second = ObjRef::new(ObjKind::Equation, id_of(&s.execute("insert.equation", json!({"latex": "y", "cell": "B30"})).unwrap(), "equation"));
let before = (anchor_of(&s, shape), anchor_of(&s, equation), anchor_of(&s, second));
for ids in [
json!([shape.id, equation.id, second.id]),
json!([equation.id, second.id, 9999]),
json!([equation.id, second.id, -1]),
json!([equation.id, second.id, "x"]),
json!([equation.id, second.id, 1e12]),
json!([equation.id, second.id, null]),
] {
assert!(s.execute("arrange.align", json!({"kind": "equation", "ids": ids, "align": "left"})).is_err(), "{ids}");
assert!(s.execute("arrange.group", json!({"kind": "equation", "ids": ids})).is_err(), "{ids}");
}
assert_eq!((anchor_of(&s, shape), anchor_of(&s, equation), anchor_of(&s, second)), before, "nothing moved");
s.execute("arrange.align", json!({"kind": "equation", "ids": [equation.id], "align": "left"})).unwrap();
s.execute("arrange.align", json!({"kind": "equation", "align": "left"})).unwrap();
assert_eq!(anchor_of(&s, equation), before.1, "fewer than two ids change nothing");
s.execute("arrange.align", json!({"kind": "equation", "ids": [equation.id, second.id], "align": "left"})).unwrap();
let (a, b) = (sheet(&s, 0).object_anchor(equation).unwrap(), sheet(&s, 0).object_anchor(second).unwrap());
assert_eq!((a.cell.col, a.dx), (b.cell.col, b.dx), "the equations line up");
}
#[test]
fn align_refuses_ids_that_are_not_an_array() {
// `ids` may be absent (nothing to align) but, present, must be an array of object ids: `null`,
// a number, a string or an object is refused by `arrange.align` and `arrange.group`, and
// nothing moves.
let mut s = s();
let objs = every_kind(&mut s);
let (shape, equation) = (objs[2], objs[4]);
let before = (anchor_of(&s, shape), anchor_of(&s, equation));
for ids in [json!(null), json!(7), json!("x"), json!({"ids": [1]})] {
assert!(s.execute("arrange.align", json!({"kind": "shape", "ids": ids, "align": "left"})).is_err(), "{ids}");
assert!(s.execute("arrange.group", json!({"kind": "shape", "ids": ids})).is_err(), "{ids}");
}
s.execute("arrange.align", json!({"kind": "shape", "align": "left"})).unwrap();
s.execute("arrange.group", json!({"kind": "shape"})).unwrap();
assert_eq!((anchor_of(&s, shape), anchor_of(&s, equation)), before, "nothing moved");
}