mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-31 06:06:48 -06:00
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.
237 lines
12 KiB
Rust
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}");
|
|
}
|
|
}
|