mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 04:19:48 -06:00
# Conflicts: # crates/engine/src/lib.rs # docs/macros.md
This commit is contained in:
@@ -52,7 +52,8 @@ pub fn check(m: &Macro) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Refuses a macro too large to keep in an xlsx file ([`stash::MAX_ITEM_CHARS`] characters of
|
||||
/// JSON), so saving a workbook never fails because of one of its macros.
|
||||
/// JSON), so a macro's size never makes a save fail. (A clashing visible name doesn't either:
|
||||
/// [`with_names`] stores the macro under another index.)
|
||||
fn check_size(m: &Macro) -> Result<(), String> {
|
||||
if macro_json(m).to_string().chars().count() > stash::MAX_ITEM_CHARS {
|
||||
return Err(format!("This macro is too large to keep in the workbook ({} characters at most).", stash::MAX_ITEM_CHARS));
|
||||
@@ -105,34 +106,88 @@ pub fn fingerprint(m: &Macro) -> String {
|
||||
}
|
||||
|
||||
/// A copy of `wb` for xlsx with each macro in hidden names (`_gc_macro_<n>_<k>`) instead of the
|
||||
/// field, or `None` when there is nothing to change.
|
||||
/// field, or `None` when there is nothing to change. The macros keep their order; the index `n`
|
||||
/// is this build's choice, so it steps around any a person's own visible name already uses.
|
||||
pub fn with_names(wb: &Workbook) -> crate::Result<Option<Workbook>> {
|
||||
// Only the numbered items are this build's: a hidden `_gc_macro_<x>_<k>` name with some
|
||||
// other key stays, as `load` leaves it.
|
||||
let stored = |n: &DefinedName| macro_item(n).is_some_and(|(_, i)| i.is_some());
|
||||
if wb.macros.is_empty() && !wb.names.iter().any(stored) {
|
||||
if wb.macros.is_empty() && wb.macros_unread.is_empty() && !wb.names.iter().any(stored) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut out = wb.clone();
|
||||
// One pass drops the items of the last save, and one writes the macros (a file can hold a
|
||||
// thousand macros and many thousands of names).
|
||||
out.names.retain(|n| !stored(n));
|
||||
let items: Vec<(String, String)> = wb.macros.iter().enumerate().map(|(i, m)| (format!("{MACRO_KEY}{i}"), macro_json(m).to_string())).collect();
|
||||
// A visible workbook-scope name of the storage shape is someone's own, and `set_many` won't
|
||||
// write a hidden one of the same name beside it. Such a name (`_GC_MACRO_1_0`, or one a
|
||||
// person defined after the file opened) blocks its item's key, in any chunk, so the macros
|
||||
// take the free indices from 0 up. The keys compare as the stash reads them, so this skips
|
||||
// exactly what `set_many` would refuse, and no arithmetic depends on a number from the file.
|
||||
let blocked: HashSet<String> = out.names.iter().filter(|n| !n.hidden && n.scope.is_none()).filter_map(|n| stash::item_key(&n.name)).collect();
|
||||
let free = (0..=u64::MAX).map(|i| format!("{MACRO_KEY}{i}")).filter(|key| !blocked.contains(key));
|
||||
// Macros in a newer format follow the readable ones, written back as the file held them.
|
||||
let texts = wb.macros.iter().map(|m| macro_json(m).to_string()).chain(wb.macros_unread.iter().map(Json::to_string));
|
||||
let items: Vec<(String, String)> = texts.zip(free).map(|(text, key)| (key, text)).collect();
|
||||
stash::set_many(&mut out, &items)?;
|
||||
out.macros.clear();
|
||||
// An empty list of its own, not a copy of the shared one cleared.
|
||||
out.macros = gridcraft_model::Macros::default();
|
||||
out.macros_unread = std::sync::Arc::default();
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// After a file is opened: moves macros out of hidden names into `Workbook::macros` and drops
|
||||
/// macros that are damaged, over the limits or in a newer format. Returns a warning for what was dropped.
|
||||
/// The JSON format's bytes for `wb`: the workbook, with the macros in a newer format put back into
|
||||
/// the `macros` list as the file held them.
|
||||
pub fn json_bytes(wb: &Workbook) -> crate::Result<Vec<u8>> {
|
||||
// Every path that adds a macro checks it, so this refuses only one that skipped the check, as
|
||||
// the xlsx save does, instead of writing a macro the next open would drop.
|
||||
for m in &wb.macros {
|
||||
check(m).map_err(crate::EngineError::Other)?;
|
||||
}
|
||||
let bytes = if wb.macros_unread.is_empty() {
|
||||
serde_json::to_vec_pretty(wb)
|
||||
} else {
|
||||
serde_json::to_value(wb).and_then(|mut v| {
|
||||
if let Some(list) = v.as_object_mut().map(|o| o.entry("macros").or_insert_with(|| Json::Array(vec![]))).and_then(Json::as_array_mut) {
|
||||
list.extend(wb.macros_unread.iter().cloned());
|
||||
}
|
||||
serde_json::to_vec_pretty(&v)
|
||||
})
|
||||
};
|
||||
bytes.map_err(|e| crate::EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// Whether `v` is a macro in a newer format than this build reads.
|
||||
fn is_newer(v: &Json) -> bool {
|
||||
v.get("v").and_then(Json::as_u64).is_some_and(|found| found > u64::from(gridcraft_model::MACRO_VERSION))
|
||||
}
|
||||
|
||||
/// Keeps a macro in a newer format unread, if it is small enough to save back
|
||||
/// ([`stash::MAX_ITEM_CHARS`] characters of JSON, the size every xlsx save can write).
|
||||
fn keep_unread(unread: &mut Vec<Json>, v: Json, bad: &mut usize) {
|
||||
if v.to_string().chars().count() <= stash::MAX_ITEM_CHARS {
|
||||
unread.push(v);
|
||||
} else {
|
||||
*bad = bad.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// After a file is opened: moves macros out of hidden names into `Workbook::macros`, keeps
|
||||
/// macros in a newer format unread (`Workbook::macros_unread`), and drops macros that are damaged
|
||||
/// or over the limits. Returns a warning for what was kept unread and one for what was dropped.
|
||||
pub fn load(wb: &mut Workbook) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
let mut bad = 0usize;
|
||||
let mut unread = Vec::new();
|
||||
// Entries of the JSON format's field, each read on its own so a damaged one drops alone.
|
||||
let read = std::mem::take(&mut wb.macros_read);
|
||||
bad = bad.saturating_add(read.len().saturating_sub(gridcraft_model::MAX_MACROS));
|
||||
for v in read.iter().take(gridcraft_model::MAX_MACROS) {
|
||||
match parse_macro(v) {
|
||||
for v in read.into_iter().take(gridcraft_model::MAX_MACROS) {
|
||||
if is_newer(&v) {
|
||||
keep_unread(&mut unread, v, &mut bad);
|
||||
continue;
|
||||
}
|
||||
match parse_macro(&v) {
|
||||
Ok(m) => wb.macros.push(m),
|
||||
Err(_) => bad += 1,
|
||||
}
|
||||
@@ -156,7 +211,12 @@ pub fn load(wb: &mut Workbook) -> Vec<String> {
|
||||
let mut item = Workbook::new();
|
||||
for ((_, key), names) in stored.into_iter().take(gridcraft_model::MAX_MACROS) {
|
||||
item.names = names;
|
||||
match stash::get_json(&item, &key).ok_or_else(|| "unreadable".to_string()).and_then(|v| parse_macro(&v)) {
|
||||
let v = stash::get_json(&item, &key);
|
||||
if let Some(v) = v.as_ref().filter(|v| is_newer(v)) {
|
||||
keep_unread(&mut unread, v.clone(), &mut bad);
|
||||
continue;
|
||||
}
|
||||
match v.ok_or_else(|| "unreadable".to_string()).and_then(|v| parse_macro(&v)) {
|
||||
Ok(m) => found.push(m),
|
||||
Err(_) => bad += 1,
|
||||
}
|
||||
@@ -184,9 +244,25 @@ pub fn load(wb: &mut Workbook) -> Vec<String> {
|
||||
Err(_) => bad += 1,
|
||||
}
|
||||
}
|
||||
// Readable and unread macros together stay within the limit, the readable ones first.
|
||||
let room = gridcraft_model::MAX_MACROS.saturating_sub(wb.macros.len());
|
||||
bad = bad.saturating_add(unread.len().saturating_sub(room));
|
||||
unread.truncate(room);
|
||||
if !unread.is_empty() {
|
||||
let one = unread.len() == 1;
|
||||
warnings.push(format!(
|
||||
"{} macro{} in the workbook {} made by a newer version of GridCraft: this version can't show or run {}, and saving keeps {} unchanged.",
|
||||
unread.len(),
|
||||
if one { "" } else { "s" },
|
||||
if one { "was" } else { "were" },
|
||||
if one { "it" } else { "them" },
|
||||
if one { "it" } else { "them" },
|
||||
));
|
||||
}
|
||||
wb.macros_unread = std::sync::Arc::new(unread);
|
||||
if bad > 0 {
|
||||
warnings.push(format!(
|
||||
"{bad} macro{} in the workbook couldn't be opened: {} damaged, made by a newer version of GridCraft, or over the limits ({} macros, {} steps each, Repeat and If nested {} deep).",
|
||||
"{bad} macro{} in the workbook couldn't be opened: {} damaged or over the limits ({} macros, {} steps each, Repeat and If nested {} deep).",
|
||||
if bad == 1 { "" } else { "s" },
|
||||
if bad == 1 { "it is" } else { "they are" },
|
||||
gridcraft_model::MAX_MACROS,
|
||||
|
||||
@@ -288,9 +288,12 @@ pub fn resolve_token(text: &str, env: &Env) -> R<Place> {
|
||||
return bad(format!("'{t}': '{name}' with these arguments isn't a token method ({}).", METHODS.join(", ")));
|
||||
}
|
||||
};
|
||||
// After a method the result is plain cells: `data` no longer applies, and only `data`
|
||||
// keeps the header row for `col("…")`.
|
||||
header = false;
|
||||
// After a method the result is plain cells, except that `col` and `cols` keep the input's top
|
||||
// row, so a later `data` still takes the header off; only `data` keeps the header row for
|
||||
// `col("…")`.
|
||||
if !matches!(method.as_str(), "col" | "cols") {
|
||||
header = false;
|
||||
}
|
||||
if method != "data" {
|
||||
names_row = None;
|
||||
}
|
||||
@@ -615,7 +618,9 @@ mod tests {
|
||||
("input", "B2:D11"),
|
||||
("Input.col(2)", "C2:C11"),
|
||||
("Input.col(\"Amount\")", "C2:C11"),
|
||||
("Input.col(\"date\").data", "D2:D11"),
|
||||
("Input.col(\"date\").data", "D3:D11"),
|
||||
("Input.cols(2..3).data", "C3:D11"),
|
||||
("Input.col(2).data.col(1)", "C3:C11"),
|
||||
("Input.data", "B3:D11"),
|
||||
("Input.data.col(2)", "C3:C11"),
|
||||
("Input.data.col(\"Amount\")", "C3:C11"),
|
||||
|
||||
@@ -213,24 +213,82 @@ fn a_macro_built_in_code_goes_through_the_size_check_too() {
|
||||
assert!(check(&big).unwrap_err().contains("too large"));
|
||||
let mut wb = Workbook::new();
|
||||
wb.macros.push(big);
|
||||
assert!(crate::io::save_bytes(&wb, "book.xlsx").is_err(), "a macro that fails `check` fails the save");
|
||||
// A macro that fails `check` fails the save in either format, rather than being written and
|
||||
// dropped the next time the file opens.
|
||||
for file in ["book.xlsx", "book.json"] {
|
||||
assert!(crate::io::save_bytes(&wb, file).is_err(), "{file}");
|
||||
}
|
||||
}
|
||||
|
||||
fn visible(name: &str) -> DefinedName {
|
||||
DefinedName { name: name.into(), scope: None, formula: "Sheet1!$A$1".into(), comment: String::new(), hidden: false }
|
||||
}
|
||||
|
||||
/// Saves `wb` to xlsx and returns the names in the file and the workbook opened from it.
|
||||
fn xlsx_round_trip(wb: &Workbook) -> (Vec<String>, Workbook) {
|
||||
let bytes = crate::io::save_bytes(wb, "book.xlsx").unwrap();
|
||||
let raw = names_of(&gridcraft_xlsx::read_xlsx(&bytes).unwrap().0);
|
||||
let (back, warnings) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
(raw, back)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_refuses_a_clashing_visible_name_before_changing_anything() {
|
||||
fn a_visible_name_of_the_storage_shape_does_not_block_a_save() {
|
||||
// The index a macro is stored under is GridCraft's choice, so a person's own visible name
|
||||
// of that shape (here in capitals: names compare without case) is stepped around, not
|
||||
// refused. Stale hidden items still go in the same pass.
|
||||
let mut wb = Workbook::new();
|
||||
wb.macros.push(sample_macro("A"));
|
||||
wb.macros.push(sample_macro("B"));
|
||||
let own = DefinedName { hidden: false, ..hidden("_GC_MACRO_1_0", "mine") };
|
||||
wb.names.push(own.clone());
|
||||
wb.names.push(visible("_GC_MACRO_0_0"));
|
||||
wb.names.push(hidden("_gc_macro_7_0", "stale"));
|
||||
let err = crate::io::save_bytes(&wb, "book.xlsx").unwrap_err().to_string();
|
||||
assert!(err.contains("_GC_MACRO_1_0"), "{err}");
|
||||
assert_eq!(wb.names.len(), 2, "nothing changed");
|
||||
wb.names.remove(0);
|
||||
let (raw, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap();
|
||||
let names = names_of(&raw);
|
||||
assert_eq!(names, ["_gc_macro_0_0", "_gc_macro_1_0"], "the stale item went in the same pass");
|
||||
let (raw, back) = xlsx_round_trip(&wb);
|
||||
assert_eq!(raw, ["_GC_MACRO_0_0", "_gc_macro_1_0", "_gc_macro_2_0"]);
|
||||
assert_eq!(back.macros, wb.macros, "both macros, in their order");
|
||||
assert_eq!(back.names, vec![visible("_GC_MACRO_0_0")], "the person's name is kept as it was");
|
||||
assert_eq!(wb.names.len(), 2, "the open workbook is not changed by a save");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_visible_index_takes_no_part_in_numbering() {
|
||||
// A file can carry a visible name with any number in it. The macros take the free indices
|
||||
// from 0 up, so no arithmetic depends on that number (one past the largest would overflow).
|
||||
let mut wb = Workbook::new();
|
||||
wb.macros.push(sample_macro("A"));
|
||||
wb.names.push(visible(&format!("_gc_macro_{}_0", u64::MAX)));
|
||||
wb.names.push(visible("_gc_macro_0_0"));
|
||||
let (raw, back) = xlsx_round_trip(&wb);
|
||||
assert_eq!(raw, [format!("_gc_macro_{}_0", u64::MAX), "_gc_macro_0_0".into(), "_gc_macro_1_0".into()]);
|
||||
assert_eq!(back.macros, wb.macros);
|
||||
assert_eq!(names_of(&back), names_of(&wb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_visible_legacy_name_of_the_storage_shape_is_kept() {
|
||||
// The stash reads a legacy `_sc_<key>` name as data whether or not it is hidden, so writing
|
||||
// key `macro_0` would remove a person's visible `_sc_macro_0`. That key is stepped around too.
|
||||
let mut wb = Workbook::new();
|
||||
wb.macros.push(sample_macro("A"));
|
||||
wb.names.push(visible("_sc_macro_0"));
|
||||
let (raw, back) = xlsx_round_trip(&wb);
|
||||
assert_eq!(raw, ["_sc_macro_0", "_gc_macro_1_0"]);
|
||||
assert_eq!(back.macros, wb.macros);
|
||||
assert_eq!(back.names, vec![visible("_sc_macro_0")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_defined_after_opening_does_not_block_the_next_save() {
|
||||
// Once a file is open its macros are out of the names, so Define Name accepts a visible
|
||||
// `_gc_macro_0_0`; the next save steps around it.
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", json!({})).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).macros.push(sample_macro("A"));
|
||||
s.execute("formulas.defineName", json!({"name": "_gc_macro_0_0", "refersTo": "=Sheet1!$A$1"})).unwrap();
|
||||
let (raw, back) = xlsx_round_trip(&s.doc().unwrap().wb);
|
||||
assert_eq!(raw, ["_gc_macro_0_0", "_gc_macro_1_0"]);
|
||||
assert_eq!(back.macros.len(), 1);
|
||||
assert_eq!(names_of(&back), ["_gc_macro_0_0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -272,7 +330,8 @@ fn damaged_or_oversized_macros_are_dropped_with_a_warning() {
|
||||
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();
|
||||
// Written as another program would: a GridCraft save refuses a macro that fails `check`.
|
||||
let bytes = serde_json::to_vec(&wb).unwrap();
|
||||
let (back, warnings) = crate::io::open_bytes("many.json", &bytes).unwrap();
|
||||
// Only the first MAX_MACROS entries are read, as in xlsx.
|
||||
assert_eq!(back.macros.len(), MAX_MACROS - 1);
|
||||
@@ -285,7 +344,8 @@ fn damaged_or_oversized_macros_are_dropped_with_a_warning() {
|
||||
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();
|
||||
// Written as another program would: a GridCraft save refuses a macro that fails `check`.
|
||||
let bytes = serde_json::to_vec(&wb).unwrap();
|
||||
let (back, warnings) = crate::io::open_bytes("huge.json", &bytes).unwrap();
|
||||
assert!(back.macros.is_empty() && warnings[0].starts_with("1 macro "), "{warnings:?}");
|
||||
}
|
||||
@@ -319,7 +379,8 @@ fn json_field_macros_get_the_name_check_too() {
|
||||
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();
|
||||
// Written as another program would: a GridCraft save refuses a macro that fails `check`.
|
||||
let bytes = serde_json::to_vec(&wb).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"]);
|
||||
@@ -391,10 +452,11 @@ fn a_damaged_json_macro_drops_alone() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_macro_in_a_newer_format_is_dropped_not_rewritten() {
|
||||
fn a_macro_in_a_newer_format_is_kept_unread_and_saved_back_unchanged() {
|
||||
// A newer build's macro (`v` 2, with a field this build doesn't know) would lose that field
|
||||
// here and be saved back as if complete, so it is dropped with the usual warning instead.
|
||||
// Version 1 and a missing version are read as before.
|
||||
// if it were read here, so it is never read: it is kept as the file held it, saved back
|
||||
// unchanged, and the newer build gets it back whole. Version 1 and a missing version are
|
||||
// read as before.
|
||||
let future = json!({"name": "Future", "v": MACRO_VERSION + 1, "fromALaterBuild": {"x": 1}, "steps": [{"command": "cell.set"}]});
|
||||
let current = json!({"name": "Current", "v": MACRO_VERSION, "steps": [{"command": "cell.set"}]});
|
||||
let missing = json!({"name": "Missing"});
|
||||
@@ -407,17 +469,24 @@ fn a_macro_in_a_newer_format_is_dropped_not_rewritten() {
|
||||
let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect();
|
||||
assert_eq!(names, ["Current", "Missing"], "{file}");
|
||||
assert!(back.macros.iter().all(|m| m.v == MACRO_VERSION), "{file}");
|
||||
assert!(warnings.len() == 1 && warnings[0].starts_with("1 macro ") && warnings[0].contains("newer version"), "{file}: {warnings:?}");
|
||||
// Saving and opening again leaves no reduced copy of it behind.
|
||||
assert_eq!(*back.macros_unread, std::slice::from_ref(&future), "{file}");
|
||||
assert!(
|
||||
warnings.len() == 1 && warnings[0].starts_with("1 macro ") && warnings[0].contains("newer version") && warnings[0].contains("unchanged"),
|
||||
"{file}: {warnings:?}"
|
||||
);
|
||||
// Saved and opened again, it is still there, whole, and the others are as they were.
|
||||
let (again, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(back, file).unwrap()).unwrap();
|
||||
assert!(warnings.is_empty(), "{file}: {warnings:?}");
|
||||
assert_eq!(warnings.len(), 1, "{file}: {warnings:?}");
|
||||
assert_eq!(again.macros, back.macros, "{file}");
|
||||
assert_eq!(again.macros_unread, back.macros_unread, "{file}");
|
||||
};
|
||||
// The JSON format's field.
|
||||
let mut v: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap();
|
||||
v["macros"] = json!([future, current, missing]);
|
||||
let (back, warnings) = crate::io::open_bytes("book.json", v.to_string().as_bytes()).unwrap();
|
||||
check("book.json", &back, &warnings);
|
||||
let saved: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&back, "book.json").unwrap()).unwrap();
|
||||
assert!(saved["macros"].as_array().unwrap().contains(&future), "the file holds it as it was");
|
||||
// Hidden names in xlsx.
|
||||
let mut wb = Workbook::new();
|
||||
for (i, m) in [&future, ¤t, &missing].into_iter().enumerate() {
|
||||
@@ -429,6 +498,42 @@ fn a_macro_in_a_newer_format_is_dropped_not_rewritten() {
|
||||
check("book.xlsx", &back, &warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_macros_share_the_save_limits_and_the_numbering() {
|
||||
// A newer macro too large for the file is left out (keeping it would make every xlsx save
|
||||
// fail), and readable and unread macros together stay within the macro limit, readable ones
|
||||
// first. Kept ones are numbered after the readable ones, around a person's visible name.
|
||||
let newer = |name: &str| json!({"name": name, "v": MACRO_VERSION + 1});
|
||||
let mut big = newer("Big");
|
||||
big["pad"] = json!(long(crate::cmd::stash::MAX_ITEM_CHARS));
|
||||
let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap();
|
||||
let mut list = vec![big, newer("Kept"), json!({"name": "Read"})];
|
||||
file["macros"] = json!(list);
|
||||
let (mut wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap();
|
||||
assert_eq!(*wb.macros_unread, [newer("Kept")]);
|
||||
assert_eq!(warnings.len(), 2, "one kept, one left out: {warnings:?}");
|
||||
wb.names.push(visible("_gc_macro_1_0"));
|
||||
let (raw, back) = {
|
||||
let bytes = crate::io::save_bytes(&wb, "book.xlsx").unwrap();
|
||||
let raw = names_of(&gridcraft_xlsx::read_xlsx(&bytes).unwrap().0);
|
||||
(raw, crate::io::open_bytes("book.xlsx", &bytes).unwrap().0)
|
||||
};
|
||||
assert_eq!(raw, ["_gc_macro_1_0", "_gc_macro_0_0", "_gc_macro_2_0"]);
|
||||
assert_eq!((back.macros.len(), back.macros_unread.len()), (1, 1));
|
||||
// Past the limit the rest are left out; and when a JSON file's list is full, an unread item
|
||||
// it also holds in hidden names is the one left out: readable macros come first.
|
||||
list = (0..MAX_MACROS).map(|i| json!({"name": format!("M{i}")})).collect();
|
||||
let mut with_item = Workbook::new();
|
||||
with_item.names.push(hidden("_gc_macro_0_0", &newer("InNames").to_string()));
|
||||
let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&with_item, "book.json").unwrap()).unwrap();
|
||||
for extra in [None, Some(newer("Over"))] {
|
||||
file["macros"] = json!(list.iter().cloned().chain(extra).collect::<Vec<_>>());
|
||||
let (wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap();
|
||||
assert_eq!((wb.macros.len(), wb.macros_unread.len()), (MAX_MACROS, 0));
|
||||
assert!(warnings.len() == 1 && warnings[0].contains("couldn't be opened"), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_macros_with_one_name_all_survive_save_and_reopen() {
|
||||
// MAX_MACROS macros with the same name, at the length limit, so the suffixes run from " (2)"
|
||||
@@ -478,3 +583,149 @@ fn naming_clashing_macros_takes_linear_work() {
|
||||
taken.insert("a (2)");
|
||||
assert_eq!(taken.unique("a"), "a (4)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_json_workbook_with_no_sheets_keeps_its_macros() {
|
||||
// A JSON file with an empty sheet list opens as a new workbook; its macros come along.
|
||||
let mut wb = Workbook::new();
|
||||
wb.macros.push(sample_macro("A"));
|
||||
let mut v: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&wb, "book.json").unwrap()).unwrap();
|
||||
v["sheets"] = json!([]);
|
||||
let (back, warnings) = crate::io::open_bytes("book.json", v.to_string().as_bytes()).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(back.sheets.len(), 1);
|
||||
assert_eq!(back.macros, wb.macros);
|
||||
}
|
||||
|
||||
fn long(n: usize) -> String {
|
||||
"x".repeat(n)
|
||||
}
|
||||
|
||||
/// `sample_macro` as JSON with `change` made to it.
|
||||
fn changed(change: fn(&mut serde_json::Value)) -> serde_json::Value {
|
||||
let mut v = macro_json(&sample_macro("T"));
|
||||
change(&mut v);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_a_file_sets_has_a_limit() {
|
||||
// The sample's steps: 0 a command, 1 a Repeat whose body holds an If whose else holds a Stop.
|
||||
let refused: [(&str, fn(&mut serde_json::Value)); 14] = [
|
||||
("shortcut", |v| v["shortcut"] = json!(long(MAX_MACRO_SHORTCUT + 1))),
|
||||
("shortcut line break", |v| v["shortcut"] = json!("Ctrl+\nM")),
|
||||
("permission count", |v| v["permissions"] = json!(vec!["data.getData"; MAX_MACRO_PERMISSIONS + 1])),
|
||||
("permission text", |v| v["permissions"] = json!(["data getData"])),
|
||||
("output anchor", |v| v["output"]["anchor"] = json!(long(MAX_MACRO_REF + 1))),
|
||||
("default range", |v| v["inputs"][0]["defaultRef"] = json!(long(MAX_MACRO_REF + 1))),
|
||||
("column types", |v| {
|
||||
v["inputs"][0]["cols"] = json!([1, 0]);
|
||||
v["inputs"][0]["colTypes"] = json!(vec!["any"; MAX_MACRO_COLUMNS + 1]);
|
||||
}),
|
||||
("command id", |v| v["steps"][0]["id"] = json!("cell set")),
|
||||
("empty command id", |v| v["steps"][0]["id"] = json!("")),
|
||||
("Repeat range", |v| v["steps"][1]["over"] = json!(long(MAX_MACRO_REF + 1))),
|
||||
("If cell", |v| v["steps"][1]["body"][0]["cell"] = json!(long(MAX_MACRO_REF + 1))),
|
||||
// As JSON the string takes its two quotes as well.
|
||||
("If value", |v| v["steps"][1]["body"][0]["value"] = json!(long(MAX_MACRO_TEXT - 1))),
|
||||
("Stop message", |v| v["steps"][1]["body"][0]["else"][0]["message"] = json!(long(MAX_MACRO_TEXT + 1))),
|
||||
("Stop message, nested", |v| v["steps"][1]["body"][0]["else"][0]["message"] = json!(long(MAX_MACRO_TEXT * 2))),
|
||||
];
|
||||
for (what, change) in refused {
|
||||
assert!(parse_macro(&changed(change)).is_err(), "{what} past its limit is refused");
|
||||
}
|
||||
let accepted: [(&str, fn(&mut serde_json::Value)); 9] = [
|
||||
("shortcut", |v| v["shortcut"] = json!(long(MAX_MACRO_SHORTCUT))),
|
||||
("permission count", |v| v["permissions"] = json!(vec!["data.getData"; MAX_MACRO_PERMISSIONS])),
|
||||
("output anchor", |v| v["output"]["anchor"] = json!(long(MAX_MACRO_REF))),
|
||||
("column types", |v| {
|
||||
v["inputs"][0]["cols"] = json!([1, 0]);
|
||||
v["inputs"][0]["colTypes"] = json!(vec!["any"; MAX_MACRO_COLUMNS]);
|
||||
}),
|
||||
("If value", |v| v["steps"][1]["body"][0]["value"] = json!(long(MAX_MACRO_TEXT - 2))),
|
||||
// Empty and NotEmpty ignore the value, which is then left out (null).
|
||||
("If with no value", |v| {
|
||||
v["steps"][1]["body"][0]["op"] = json!("empty");
|
||||
v["steps"][1]["body"][0].as_object_mut().unwrap().remove("value");
|
||||
}),
|
||||
// How many times a Repeat may run is the runner's to bound, not the format's.
|
||||
("Repeat max", |v| v["steps"][1]["max"] = json!(u32::MAX)),
|
||||
("Stop message", |v| v["steps"][1]["body"][0]["else"][0]["message"] = json!(long(MAX_MACRO_TEXT))),
|
||||
("no shortcut", |v| {
|
||||
v.as_object_mut().unwrap().remove("shortcut");
|
||||
}),
|
||||
];
|
||||
for (what, change) in accepted {
|
||||
assert!(parse_macro(&changed(change)).is_ok(), "{what} at its limit is accepted");
|
||||
}
|
||||
// Every command there is passes as a step and as a permission.
|
||||
for spec in crate::command_specs() {
|
||||
let m = json!({"name": "T", "steps": [{"command": spec.id}], "permissions": [spec.id]});
|
||||
assert!(parse_macro(&m).is_ok(), "{}", spec.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_input_declaration_is_checked_as_a_whole() {
|
||||
let input = |i: serde_json::Value| json!({"name": "T", "inputs": [i]});
|
||||
for i in [
|
||||
json!({"name": "Input", "rows": [5, 2]}),
|
||||
json!({"name": "Input", "cols": [3, 2]}),
|
||||
json!({"name": "Input", "kind": "cell", "cols": [2, 5]}),
|
||||
json!({"name": "Input", "kind": "cell", "rows": [0, 0]}),
|
||||
json!({"name": "Input", "kind": "cell", "header": true}),
|
||||
json!({"name": "Input", "kind": "cell", "colTypes": ["number", "text"]}),
|
||||
json!({"name": "Input", "cols": [2, 2], "colTypes": ["number", "text", "any"]}),
|
||||
] {
|
||||
assert!(parse_macro(&input(i.clone())).is_err(), "{i}");
|
||||
}
|
||||
for i in [
|
||||
json!({"name": "Input", "rows": [5, 0]}),
|
||||
json!({"name": "Input", "rows": [2, 2], "cols": [0, 3]}),
|
||||
json!({"name": "Input", "kind": "cell"}),
|
||||
json!({"name": "Input", "kind": "cell", "rows": [1, 1], "cols": [1, 1], "colTypes": ["number"]}),
|
||||
json!({"name": "Input", "cols": [1, 2], "colTypes": ["number", "text"]}),
|
||||
] {
|
||||
assert!(parse_macro(&input(i.clone())).is_ok(), "{i}");
|
||||
}
|
||||
// From a file, a macro whose declaration fails is left out with the usual warning, and the
|
||||
// workbook's other macros open.
|
||||
let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap();
|
||||
file["macros"] = json!([input(json!({"name": "Input", "rows": [5, 2]})), {"name": "Fine"}]);
|
||||
let (wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap();
|
||||
assert_eq!(wb.macros.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Fine"]);
|
||||
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_storage_rules_hold_together_through_both_formats() {
|
||||
// A visible name of the storage shape, a macro in a newer format and one whose input breaks
|
||||
// the declaration rules, in one file, saved and opened again in each format and then the
|
||||
// other: the visible name stays, the newer macro comes back whole, the broken one is left out
|
||||
// once, and the readable one survives.
|
||||
let newer = json!({"name": "Kept", "v": MACRO_VERSION + 1, "fromALaterBuild": true});
|
||||
let broken = json!({"name": "Broken", "inputs": [{"name": "Input", "rows": [5, 2]}]});
|
||||
let mut start = Workbook::new();
|
||||
start.names.push(visible("_gc_macro_0_0"));
|
||||
let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&start, "book.json").unwrap()).unwrap();
|
||||
file["macros"] = json!([newer, broken, {"name": "Good"}]);
|
||||
let (wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap();
|
||||
assert_eq!(warnings.len(), 2, "one kept, one left out: {warnings:?}");
|
||||
let expect = |wb: &Workbook, how: &str| {
|
||||
assert_eq!(wb.macros.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Good"], "{how}");
|
||||
assert_eq!(*wb.macros_unread, std::slice::from_ref(&newer), "{how}");
|
||||
assert_eq!(names_of(wb), ["_gc_macro_0_0"], "{how}");
|
||||
};
|
||||
expect(&wb, "opened");
|
||||
for formats in [["book.xlsx", "book.json"], ["book.json", "book.xlsx"]] {
|
||||
let mut now = wb.clone();
|
||||
for file in formats {
|
||||
let (back, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&now, file).unwrap()).unwrap();
|
||||
assert_eq!(warnings.len(), 1, "{file}: only the kept macro is reported again: {warnings:?}");
|
||||
expect(&back, file);
|
||||
now = back;
|
||||
}
|
||||
}
|
||||
let bytes = crate::io::save_bytes(&wb, "book.xlsx").unwrap();
|
||||
assert_eq!(names_of(&gridcraft_xlsx::read_xlsx(&bytes).unwrap().0), ["_gc_macro_0_0", "_gc_macro_1_0", "_gc_macro_2_0"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user