Files
gridcraft/crates/engine/src/macros/tests.rs
T
prxnvt 7011395fcf Engine: a macro renamed on a name clash keeps its name within the length limit
When a file held two macros with the same name, the second was renamed
"name (2)" without room for the suffix. Two macros with the same
255-character name both opened, but the second name grew to 259
characters, was saved that way, and the next open dropped that macro as
over the limits. unique_name now shortens the name to make room for the
suffix, and load checks the macro again after renaming it.
2026-10-22 02:26:32 -04:00

237 lines
12 KiB
Rust

use gridcraft_model::*;
use serde_json::json;
use super::*;
fn sample_macro(name: &str) -> Macro {
parse_macro(&json!({
"name": name,
"description": "Totals the amounts",
"shortcut": "Ctrl+Shift+T",
"inputs": [{"name": "Input", "rows": [1, 500], "cols": [2, 2], "header": true, "colTypes": ["text", "number"], "nonEmpty": true}],
"steps": [
{"type": "command", "id": "cell.set", "params": {"cell": "Output", "input": "=SUM({{Input.col(2)}})"}, "breakpoint": true, "note": "the total"},
{"type": "repeat", "over": "Input.rows(2..)", "of": "rows", "max": 100, "body": [
{"type": "if", "cell": "Row.cell(1,2)", "op": "gt", "value": 100, "then": [
{"command": "home.bold", "params": {"range": "Row"}}
], "else": [{"type": "stop", "message": "done"}]}
]},
{"type": "command", "id": "home.italic", "enabled": false}
],
"output": {"anchor": "Input.offset(0,3)", "onConflict": "shiftDown"},
"permissions": ["data.getData"]
}))
.unwrap()
}
#[test]
fn macros_read_from_json_in_both_step_forms() {
let m = sample_macro("Totals");
assert_eq!(m.v, MACRO_VERSION);
assert_eq!(m.inputs[0].col_types, [ColType::Text, ColType::Number]);
assert_eq!(m.output.on_conflict, OnConflict::ShiftDown);
assert_eq!(m.step_count(), 6);
assert!(m.steps[0].breakpoint && m.steps[0].enabled && !m.steps[2].enabled);
let StepKind::Repeat { body, max, .. } = &m.steps[1].kind else { panic!("{:?}", m.steps[1]) };
assert_eq!(*max, 100);
let StepKind::If { then, otherwise, op, .. } = &body[0].kind else { panic!() };
assert_eq!(*op, CompareOp::Gt);
// The short form of earlier scripts reads as a command step.
assert_eq!(then[0].kind, StepKind::Command { id: "home.bold".into(), params: json!({"range": "Row"}) });
assert!(matches!(&otherwise[0].kind, StepKind::Stop { message, rollback: false } if message == "done"));
// Writing and reading again gives the same macro; defaults stay out of the JSON.
let v = macro_json(&m);
assert_eq!(v["steps"][0]["type"], "command");
assert!(v["steps"][1].get("enabled").is_none() && v["steps"][1].get("breakpoint").is_none());
assert_eq!(v["steps"][2]["enabled"], false);
assert_eq!(parse_macro(&v).unwrap(), m);
// A minimal macro gets the defaults.
let m = parse_macro(&json!({"name": " Bare "})).unwrap();
assert_eq!((m.name.as_str(), m.output.on_conflict, m.inputs.len()), ("Bare", OnConflict::Ask, 0));
}
#[test]
fn macros_over_the_limits_are_refused() {
let flat = |n: usize| json!({"name": "Big", "steps": vec![json!({"command": "cell.set"}); n]});
assert!(parse_macro(&flat(MAX_MACRO_STEPS)).is_ok());
assert!(parse_macro(&flat(MAX_MACRO_STEPS + 1)).unwrap_err().contains("10000 steps"));
// Nested Repeat steps: 4 levels are fine, 5 are not.
let nest = |levels: usize| {
let mut step = json!({"command": "cell.set"});
for _ in 1..levels {
step = json!({"type": "repeat", "over": "Input", "body": [step]});
}
json!({"name": "Deep", "steps": [step]})
};
assert!(parse_macro(&nest(4)).is_ok());
assert!(parse_macro(&nest(5)).unwrap_err().contains("4 deep"));
// Far deeper than serde would allow is refused before it is read.
assert!(parse_macro(&nest(200)).is_err());
for bad in [
json!({"name": ""}),
json!({"name": "x".repeat(256)}),
json!({"name": "a\nb"}),
json!({"name": "A", "steps": {}}),
json!({"name": "A", "steps": [1]}),
json!({"name": "A", "steps": [{"type": "nope"}]}),
json!({"name": "A", "steps": [{"type": "if", "cell": "A1", "op": "between"}]}),
json!({"name": "A", "inputs": [{"name": ""}]}),
json!({"name": "A", "inputs": [{"name": "Has space"}]}),
json!({"name": "A", "inputs": [{"name": "row"}]}),
json!({"name": "A", "inputs": [{"name": "In"}, {"name": "IN"}]}),
json!({"name": "A", "output": {"onConflict": "explode"}}),
json!([]),
] {
assert!(parse_macro(&bad).is_err(), "{bad}");
}
}
fn names_of(wb: &Workbook) -> Vec<String> {
wb.names.iter().map(|n| n.name.clone()).collect()
}
#[test]
fn macros_round_trip_through_xlsx_and_json() {
let mut wb = Workbook::new();
wb.macros.push(sample_macro("Totals"));
// Large enough to need several hidden names.
let long: Vec<Step> = (0..300).map(|r| Step::command("cell.set", json!({"cell": format!("A{r}"), "input": "x\"y".repeat(10)}))).collect();
wb.macros.push(Macro { name: "Long".into(), steps: long, ..Macro::default() });
for file in ["book.xlsx", "book.json"] {
let bytes = crate::io::save_bytes(&wb, file).unwrap();
let (back, warnings) = crate::io::open_bytes(file, &bytes).unwrap();
assert!(warnings.is_empty(), "{file}: {warnings:?}");
assert_eq!(back.macros, wb.macros, "{file}");
assert!(back.names.is_empty(), "{file}: macros stay out of the names once opened: {:?}", names_of(&back));
}
// In the xlsx file they are hidden names; the long one spans several.
let bytes = crate::io::save_bytes(&wb, "book.xlsx").unwrap();
let (raw, _) = gridcraft_xlsx::read_xlsx(&bytes).unwrap();
let names = names_of(&raw);
assert!(names.contains(&"_gc_macro_0_0".to_string()), "{names:?}");
assert!(names.iter().filter(|n| n.starts_with("_gc_macro_1_")).count() >= 2, "{names:?}");
assert!(raw.names.iter().all(|n| n.hidden));
// Saving without macros writes no macro names, even if stale ones linger.
let mut empty = raw.clone();
empty.macros.clear();
let (again, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&empty, "x.xlsx").unwrap()).unwrap();
assert!(again.names.iter().all(|n| !n.name.starts_with("_gc_macro_")));
// The JSON format keeps the field (and leaves it out when there are no macros).
let json = String::from_utf8(crate::io::save_bytes(&wb, "book.json").unwrap()).unwrap();
assert!(json.contains("\"macros\""));
let plain = String::from_utf8(crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap();
assert!(!plain.contains("\"macros\""));
}
fn hidden(name: &str, text: &str) -> DefinedName {
DefinedName { name: name.into(), scope: None, formula: format!("\"{}\"", text.replace('"', "\"\"")), comment: String::new(), hidden: true }
}
#[test]
fn scripts_and_vba_stay_as_they_are() {
// Automate scripts keep their own storage, and a VBA project is carried through unchanged.
let mut wb = Workbook::new();
let scripts = json!({"Fill": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]});
wb.names.push(hidden("_sc_scripts", &scripts.to_string()));
wb.vba_project = Some(b"\xd0\xcf\x11\xe0 synthetic vbaProject".to_vec());
wb.macros.push(sample_macro("Totals"));
let bytes = crate::io::save_bytes(&wb, "book.xlsm").unwrap();
let (back, warnings) = crate::io::open_bytes("book.xlsm", &bytes).unwrap();
// The only warning is the reader's note that the VBA project is kept and not run.
assert!(warnings.iter().all(|w| w.contains("VBA project")), "{warnings:?}");
assert_eq!(back.macros, wb.macros);
assert_eq!(names_of(&back), ["_sc_scripts"], "the scripts item is untouched");
assert_eq!(back.names[0].formula, wb.names[0].formula);
assert_eq!(back.vba_project, wb.vba_project);
}
#[test]
fn damaged_or_oversized_macros_are_dropped_with_a_warning() {
let mut wb = Workbook::new();
wb.names.push(hidden("_gc_macro_0_0", &json!({"name": "Good"}).to_string()));
wb.names.push(hidden("_gc_macro_1_0", "{not json"));
wb.names.push(hidden("_gc_macro_2_0", &json!({"name": "Huge", "steps": vec![json!({"command": "x"}); MAX_MACRO_STEPS + 1]}).to_string()));
wb.names.push(hidden("_gc_macro_3_0", &json!({"name": "good"}).to_string()));
wb.names.push(hidden("_gc_macro_10_0", &json!({"name": "Tenth"}).to_string()));
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
let (back, warnings) = crate::io::open_bytes("hostile.xlsx", &bytes).unwrap();
let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect();
// Saved order is by number (10 after 3); a clashing name gets a suffix.
assert_eq!(names, ["Good", "good (2)", "Tenth"]);
assert!(back.names.is_empty(), "{:?}", names_of(&back));
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].starts_with("2 macros"), "{warnings:?}");
// The JSON format: more than the limit, and one over the step limit.
let mut wb = Workbook::new();
wb.macros = (0..MAX_MACROS + 5).map(|i| Macro { name: format!("M{i}"), ..Macro::default() }).collect();
wb.macros[0].steps = vec![Step::command("x", json!({})); MAX_MACRO_STEPS + 1];
let bytes = crate::io::save_bytes(&wb, "many.json").unwrap();
let (back, warnings) = crate::io::open_bytes("many.json", &bytes).unwrap();
assert_eq!(back.macros.len(), MAX_MACROS);
assert_eq!(back.macros[0].name, "M1");
assert!(warnings[0].starts_with("5 macros"), "{warnings:?}");
// A macro too large for the hidden names is refused, so a save never fails on it.
let huge = "x".repeat(crate::cmd::stash::MAX_ITEM_CHARS);
let err = parse_macro(&json!({"name": "Huge", "steps": [{"command": "cell.set", "params": {"input": huge}}]})).unwrap_err();
assert!(err.contains("too large"), "{err}");
let mut wb = Workbook::new();
wb.macros.push(Macro { name: "Huge".into(), steps: vec![Step::command("cell.set", json!({"input": huge}))], ..Macro::default() });
let bytes = crate::io::save_bytes(&wb, "huge.json").unwrap();
let (back, warnings) = crate::io::open_bytes("huge.json", &bytes).unwrap();
assert!(back.macros.is_empty() && warnings[0].starts_with("1 macro "), "{warnings:?}");
}
#[test]
fn names() {
let mut wb = Workbook::new();
wb.macros.push(Macro { name: "Macro1".into(), ..Macro::default() });
assert_eq!(unique_name(&wb, "macro1"), "macro1 (2)");
assert_eq!(unique_name(&wb, "Other"), "Other");
assert_eq!(unique_name(&wb, ""), "Macro");
// A long name is shortened to make room for the suffix.
let long = "y".repeat(MAX_MACRO_NAME);
wb.macros.push(Macro { name: long.clone(), ..Macro::default() });
let renamed = unique_name(&wb, &long);
assert_eq!(renamed, format!("{} (2)", "y".repeat(MAX_MACRO_NAME - 4)));
wb.macros.push(Macro { name: renamed, ..Macro::default() });
assert_eq!(unique_name(&wb, &long).chars().count(), MAX_MACRO_NAME);
assert_eq!(find(&wb, " MACRO1 "), Some(0));
assert_eq!(find(&wb, "Macro2"), None);
}
#[test]
fn json_field_macros_get_the_name_check_too() {
// A JSON workbook can't bring in a name that macro.save and macro.rename refuse.
let mut wb = Workbook::new();
wb.macros.push(Macro { name: "a\nb".into(), ..Macro::default() });
wb.macros.push(Macro { name: "Fine".into(), ..Macro::default() });
let bytes = crate::io::save_bytes(&wb, "ctl.json").unwrap();
let (back, warnings) = crate::io::open_bytes("ctl.json", &bytes).unwrap();
let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, ["Fine"]);
assert!(warnings.len() == 1 && warnings[0].starts_with("1 macro "), "{warnings:?}");
}
#[test]
fn duplicate_names_at_the_length_limit_survive_save_and_reopen() {
// Two macros with the same 255-character name: the second is renamed within the limit, so a
// save and a second open keep both.
let long = "é".repeat(MAX_MACRO_NAME);
for file in ["dup.json", "dup.xlsx"] {
let mut wb = Workbook::new();
wb.macros.push(Macro { name: long.clone(), ..Macro::default() });
wb.macros.push(Macro { name: long.clone(), description: "second".into(), ..Macro::default() });
let (first, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&wb, file).unwrap()).unwrap();
assert!(warnings.is_empty(), "{file}: {warnings:?}");
assert_eq!(first.macros.len(), 2, "{file}");
let renamed = &first.macros[1].name;
assert_eq!(renamed.chars().count(), MAX_MACRO_NAME, "{file}");
assert!(renamed.ends_with(" (2)") && renamed.starts_with("éé"), "{file}: {renamed}");
let (again, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&first, file).unwrap()).unwrap();
assert!(warnings.is_empty(), "{file}: {warnings:?}");
assert_eq!(again.macros, first.macros, "{file}");
}
}