mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-29 13:16:50 -06:00
Engine: macro.run and macro.check: input checks, footprint dry run, policy
macro.run runs a macro as one undo step ("Run Macro: <name>"); an error
at any step rolls everything back and names the step and its path (3,
2.1 inside a Repeat, 4.then.1 inside an If). It works in four parts:
- Policy (macro_policy). File, app, window, automate and macro commands
and Undo and Redo never run in a macro. Reading or writing files
(data.getData, data.fromTextCsv, insert.picture, Page Layout
Background, or an export with a path) needs the command in the
macro's permissions and the person's approval. A macro in a workbook
opened from a file asks before it runs. Approval holds for the
session until the macro's content changes; it is asked for with the
new EngineError::Approve and given with approve: true. A step that
would open a dialog or a web page fails instead.
- Inputs. Each declared input is bound (given, its default, or the
selection for the first one), whole columns shrink to the used range,
and the shape and column types are checked with messages that name
the cells: "Input column 2 (Amount) must hold numbers; C4 holds text".
- Footprint. Unless the macro may overwrite, it first runs in dry-run
mode on the real workbook inside an undo group that is rolled back,
with the session's clipboard, journal, windows and the document's
trust put back. CellStore::diff on every sheet, plus the cells
commands wrote (DocState::touched), sheets and objects, gives what it
would write outside its inputs; cells that only recalculated do not
count. Writing over data, deleting a sheet or deleting an object is a
conflict: ask (the new EngineError::Confirm, so programmatic runs fail
unless they pass onConflict or overwrite), fail, overwrite, or shift
the data down or right with Insert Cells and check again. A step that
would fail, for example past the edge of the sheet, fails the run
before anything changes. File steps and Solver stop the dry run early
and are reported.
- Steps run on a machine with an explicit frame stack: Repeat over the
rows or columns of a range with Row or Column bound, If on one cell
with Excel's comparisons, and Stop, which keeps the changes or rolls
them back. A run executes at most 100,000 steps within a time limit
(60 s by default, timeLimit to change; the web build has no clock, so
only the step limit applies there).
macro.check reports the bound inputs, the output anchor, the approval
needed, the footprint and the conflicts without changing anything;
nothing from an unapproved macro runs, not even as a dry run.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
//! Macros: typed command lists that run as one undo step. See [`crate::macros`] for the model,
|
||||
//! tokens, runner and policy. Automate scripts (`automate.*`) are a separate, older feature.
|
||||
|
||||
use serde_json::Value as Json;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"macro.run",
|
||||
"Run Macro",
|
||||
[],
|
||||
None,
|
||||
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}",
|
||||
has_doc,
|
||||
run
|
||||
),
|
||||
cmd!(
|
||||
query "macro.check",
|
||||
"Check Macro",
|
||||
[],
|
||||
None,
|
||||
"{name, inputs?} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok, errors?, needsApproval?, inputs, output?, conflicts, footprint}",
|
||||
has_doc,
|
||||
check
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn name_param<'a>(cmd: &str, p: &'a Json) -> Result<&'a str> {
|
||||
str_param(p, "name").filter(|n| !n.trim().is_empty()).ok_or_else(|| bad(cmd, "missing `name`"))
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = name_param("macro.run", p)?;
|
||||
crate::macros::run::run(s, name, p)
|
||||
}
|
||||
|
||||
fn check(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = name_param("macro.check", p)?;
|
||||
crate::macros::run::check(s, name, p)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod format;
|
||||
pub mod formulas;
|
||||
pub mod insert;
|
||||
pub mod inspect;
|
||||
pub mod macros;
|
||||
pub mod pivot;
|
||||
pub mod print;
|
||||
pub mod review;
|
||||
@@ -143,6 +144,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(view::specs());
|
||||
v.extend(inspect::specs());
|
||||
v.extend(extra::specs());
|
||||
v.extend(macros::specs());
|
||||
v.extend(draw::specs());
|
||||
v.extend(spelling::specs());
|
||||
v.extend(solver::specs());
|
||||
@@ -365,6 +367,13 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
if !structural {
|
||||
check_arrays(&d.wb, &wb)?;
|
||||
}
|
||||
if let Some(log) = d.touched.as_mut() {
|
||||
for (si, c) in changed.iter().take(TOUCH_LOG_LIMIT.saturating_sub(log.len())) {
|
||||
if let Some(sh) = wb.sheet(*si) {
|
||||
log.push((sh.name.clone(), *c));
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed.len() <= 200_000 {
|
||||
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
|
||||
}
|
||||
@@ -392,6 +401,9 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The most written cells a macro's footprint check records.
|
||||
const TOUCH_LOG_LIMIT: usize = 1_000_000;
|
||||
|
||||
/// Excel's message for an edit of a locked cell on a protected sheet.
|
||||
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
|
||||
|
||||
|
||||
@@ -52,6 +52,12 @@ pub enum EngineError {
|
||||
NoDocument,
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
/// The command would replace something; run it again with `overwrite: true` to go ahead.
|
||||
#[error("confirm: {0} (run again with `overwrite: true`)")]
|
||||
Confirm(String),
|
||||
/// Active content (a macro) asks to run; run it again with `approve: true` to allow it.
|
||||
#[error("approve: {0} (run again with `approve: true`)")]
|
||||
Approve(String),
|
||||
#[error("internal error in `{0}` (the workbook was kept as it was): {1}")]
|
||||
Internal(String, String),
|
||||
}
|
||||
@@ -173,6 +179,9 @@ pub struct DocState {
|
||||
pub content_kind: Option<io::FileKind>,
|
||||
/// Whether the workbook's active content may run without asking.
|
||||
pub trust: Trust,
|
||||
/// While a macro's footprint is measured: every cell a command wrote, by sheet name, even
|
||||
/// where it wrote what was already there.
|
||||
pub touched: Option<Vec<(String, CellRef)>>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -199,6 +208,7 @@ impl DocState {
|
||||
unlocked_ranges: vec![],
|
||||
content_kind: None,
|
||||
trust: Trust::Local,
|
||||
touched: None,
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -312,6 +322,8 @@ pub struct Session {
|
||||
pub recorder: Option<record::Recorder>,
|
||||
/// How many `execute` calls are running (0 between top-level commands).
|
||||
depth: u32,
|
||||
/// Macros the person allowed to run in this session: (document uid, macro content hash).
|
||||
pub macro_approvals: std::collections::HashSet<(u64, u64)>,
|
||||
/// Windows hidden with View › Hide.
|
||||
pub hidden_windows: Vec<usize>,
|
||||
/// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width.
|
||||
|
||||
@@ -14,7 +14,9 @@ use serde_json::{Value as Json, json};
|
||||
|
||||
use crate::cmd::stash;
|
||||
|
||||
pub mod policy;
|
||||
pub mod resolve;
|
||||
pub mod run;
|
||||
|
||||
/// The hidden-data key prefix of a stored macro (`macro_<n>`).
|
||||
const MACRO_KEY: &str = "macro_";
|
||||
@@ -86,6 +88,14 @@ pub fn find(wb: &Workbook, name: &str) -> Option<usize> {
|
||||
wb.macros.iter().position(|m| m.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// A stable fingerprint of a macro's content: approval to run a macro from a file holds until
|
||||
/// the macro changes.
|
||||
pub fn hash(m: &Macro) -> u64 {
|
||||
// FNV-1a over the macro's JSON.
|
||||
let text = macro_json(m).to_string();
|
||||
text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn with_names(wb: &Workbook) -> crate::Result<Option<Workbook>> {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! What a macro step may run. Macros run commands, so this is the one place that keeps a macro
|
||||
//! from saving, opening or closing files, driving the app, running other macros, or reading and
|
||||
//! writing files without the person knowing.
|
||||
|
||||
use serde_json::Value as Json;
|
||||
|
||||
use crate::Trust;
|
||||
|
||||
/// Whether a command may run as a macro step.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Policy {
|
||||
Allow,
|
||||
/// Allowed once the person approves the macro; the text says why it asks ("came with a
|
||||
/// workbook you opened").
|
||||
Ask(String),
|
||||
/// Reads or writes files: the macro must list the command in its permissions, and the person
|
||||
/// approves the macro before it runs. The text says what it does ("reads a file").
|
||||
NeedsPermission(String),
|
||||
/// Never runs in a macro; the text says why.
|
||||
Deny(String),
|
||||
}
|
||||
|
||||
/// Prefixes of commands that never run in a macro, with the reason.
|
||||
const DENIED_PREFIXES: &[(&str, &str)] = &[
|
||||
("file.", "macros don't open, save, close or create workbooks"),
|
||||
("app.", "macros don't change the app"),
|
||||
("window.", "macros don't switch windows"),
|
||||
("automate.", "macros don't record or run scripts or other macros"),
|
||||
("macro.", "macros don't record or run other macros"),
|
||||
];
|
||||
|
||||
/// Single commands that never run in a macro.
|
||||
const DENIED: &[(&str, &str)] =
|
||||
&[("edit.undo", "a macro is undone as one step after it runs"), ("edit.redo", "a macro is undone as one step after it runs")];
|
||||
|
||||
/// Commands under a denied prefix that only change the workbook.
|
||||
const ALLOWED: &[&str] = &["file.properties"];
|
||||
|
||||
/// Commands that read a file when given a `path`.
|
||||
const READS_FILES: &[&str] = &["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background"];
|
||||
|
||||
/// File commands that only produce bytes unless given a `path` (then they write a file).
|
||||
const EXPORTS: &[&str] = &["file.exportPdf", "file.exportCsv", "file.exportHtml", "file.print", "file.printPreview", "file.saveBytes"];
|
||||
|
||||
/// The policy for running command `id` with `params` as a macro step in a document with `trust`.
|
||||
pub fn macro_policy(id: &str, params: &Json, trust: Trust) -> Policy {
|
||||
let has_path = params.get("path").and_then(Json::as_str).is_some_and(|p| !p.trim().is_empty());
|
||||
let base = if EXPORTS.contains(&id) {
|
||||
if has_path { Policy::NeedsPermission("writes a file".into()) } else { Policy::Allow }
|
||||
} else if ALLOWED.contains(&id) {
|
||||
Policy::Allow
|
||||
} else if let Some((_, why)) = DENIED.iter().find(|(d, _)| *d == id) {
|
||||
Policy::Deny((*why).into())
|
||||
} else if let Some((_, why)) = DENIED_PREFIXES.iter().find(|(p, _)| id.starts_with(p)) {
|
||||
Policy::Deny((*why).into())
|
||||
} else if READS_FILES.contains(&id) && has_path {
|
||||
Policy::NeedsPermission("reads a file".into())
|
||||
} else {
|
||||
Policy::Allow
|
||||
};
|
||||
match base {
|
||||
Policy::Allow if !trust.allows_code() => Policy::Ask("came with a workbook you opened".into()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the footprint dry run can preview a command. Solver can take long, so a dry run stops
|
||||
/// before it and reports the footprint as partial.
|
||||
pub fn previewable(id: &str) -> bool {
|
||||
id != "data.solver"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn the_deny_and_permission_lists() {
|
||||
let none = json!({});
|
||||
let path = json!({"path": "/tmp/x"});
|
||||
for id in [
|
||||
"file.saveAs",
|
||||
"file.save",
|
||||
"file.open",
|
||||
"file.close",
|
||||
"file.new",
|
||||
"app.commands",
|
||||
"window.activate",
|
||||
"automate.runScript",
|
||||
"automate.recordActions",
|
||||
"macro.run",
|
||||
"macro.record",
|
||||
"edit.undo",
|
||||
"edit.redo",
|
||||
] {
|
||||
assert!(matches!(macro_policy(id, &none, Trust::Local), Policy::Deny(_)), "{id}");
|
||||
assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::Deny(_)), "{id}");
|
||||
}
|
||||
for id in ["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background", "file.exportPdf", "file.print", "file.exportCsv"] {
|
||||
assert!(matches!(macro_policy(id, &path, Trust::Local), Policy::NeedsPermission(_)), "{id}");
|
||||
assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::NeedsPermission(_)), "{id}");
|
||||
}
|
||||
// Without a path they touch no file.
|
||||
for id in ["data.getData", "insert.picture", "file.exportPdf", "file.saveBytes", "file.printPreview"] {
|
||||
assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}");
|
||||
assert_eq!(macro_policy(id, &json!({"path": " "}), Trust::Local), Policy::Allow, "{id}");
|
||||
}
|
||||
for id in ["cell.set", "home.bold", "view.macros", "edit.copy", "file.properties"] {
|
||||
assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}");
|
||||
assert!(matches!(macro_policy(id, &none, Trust::FromFile), Policy::Ask(_)), "{id}");
|
||||
}
|
||||
assert!(!previewable("data.solver") && previewable("cell.set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_listed_command_exists() {
|
||||
for id in ALLOWED.iter().chain(READS_FILES).chain(EXPORTS).chain(DENIED.iter().map(|(id, _)| id)).chain(&["data.solver"]) {
|
||||
assert!(crate::find_command(id).is_some(), "{id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -213,6 +213,11 @@ fn names() {
|
||||
assert_eq!(unique_name(&wb, &long).chars().count(), MAX_MACRO_NAME);
|
||||
assert_eq!(find(&wb, " MACRO1 "), Some(0));
|
||||
assert_eq!(find(&wb, "Macro2"), None);
|
||||
let a = sample_macro("A");
|
||||
let mut b = a.clone();
|
||||
assert_eq!(hash(&a), hash(&b));
|
||||
b.steps[0].note = "changed".into();
|
||||
assert_ne!(hash(&a), hash(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3344,4 +3344,5 @@ fn csv_as_text_keeps_identifier_columns_as_written() {
|
||||
let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap();
|
||||
assert_eq!(crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(), csv.replace('\n', "\r\n").as_bytes());
|
||||
}
|
||||
mod macros;
|
||||
mod seams;
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
//! Macros: running, input checks, the footprint dry run, the policy and the limits.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{s, v};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn undo_labels(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().undo.iter().map(|e| e.label.clone()).collect()
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, cell: &str, input: &str) {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
|
||||
/// Puts a macro into the active workbook (replacing one with the same name).
|
||||
pub(super) fn put(s: &mut Session, m: Json) {
|
||||
let m = crate::macros::parse_macro(&m).unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
wb.macros.retain(|x| !x.name.eq_ignore_ascii_case(&m.name));
|
||||
wb.macros.push(m);
|
||||
d.wb = Arc::new(wb);
|
||||
d.saved = d.wb.clone();
|
||||
}
|
||||
|
||||
/// Names in B2:B5 and amounts in C2:C5 under a header row.
|
||||
fn data(s: &mut Session) {
|
||||
for (cell, input) in [("B2", "Name"), ("C2", "Amount"), ("B3", "Ann"), ("C3", "120"), ("B4", "Bo"), ("C4", "80"), ("B5", "Cy"), ("C5", "200")] {
|
||||
set(s, cell, input);
|
||||
}
|
||||
}
|
||||
|
||||
/// A macro that writes a label and a total next to its input and bolds the header.
|
||||
fn totals() -> Json {
|
||||
json!({
|
||||
"name": "Totals",
|
||||
"inputs": [{"name": "Input", "header": true, "cols": [2, 2], "colTypes": ["text", "number"]}],
|
||||
"output": {"anchor": "Input.right(2).cell(1,2)"},
|
||||
"steps": [
|
||||
{"command": "cell.set", "params": {"cell": "Output", "input": "Total"}},
|
||||
{"command": "cell.set", "params": {"cell": "Output.offset(1,0)", "input": "=SUM({{Input.data.col(2)}})"}},
|
||||
{"command": "home.bold", "params": {"range": "Input.row(1)"}}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn bold(s: &Session, a: &str) -> bool {
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
d.wb.styles.get(sh.style_id(gridcraft_core::CellRef::parse(a).unwrap())).font.bold
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_resolves_tokens_and_is_one_undo_step() {
|
||||
let mut s = s();
|
||||
data(&mut s);
|
||||
put(&mut s, totals());
|
||||
let before = undo_labels(&s);
|
||||
let r = s.execute("macro.run", json!({"name": "totals", "inputs": {"Input": "B2:C5"}})).unwrap();
|
||||
assert_eq!(r["ran"], 3);
|
||||
assert_eq!(r["inputs"], json!({"Input": "B2:C5"}));
|
||||
assert_eq!(v(&s, "E2").display(), "Total");
|
||||
assert_eq!(v(&s, "E3").display(), "400");
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().input_text(gridcraft_core::CellRef::parse("E3").unwrap()), "=SUM(C3:C5)");
|
||||
assert!(bold(&s, "B2") && bold(&s, "C2"));
|
||||
let after = undo_labels(&s);
|
||||
assert_eq!(after.len(), before.len() + 1);
|
||||
assert_eq!(after.last().unwrap(), "Run Macro: Totals");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(v(&s, "E2").is_empty() && v(&s, "E3").is_empty() && !bold(&s, "B2"));
|
||||
assert_eq!(undo_labels(&s), before);
|
||||
// The selection is the default input.
|
||||
s.execute("selection.set", json!({"range": "B2:C5"})).unwrap();
|
||||
s.execute("macro.run", json!({"name": "Totals"})).unwrap();
|
||||
assert_eq!(v(&s, "E3").display(), "400");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inputs_are_checked_with_messages_that_name_the_cells() {
|
||||
let mut s = s();
|
||||
data(&mut s);
|
||||
set(&mut s, "C4", "eighty");
|
||||
set(&mut s, "C5", "TRUE");
|
||||
put(&mut s, totals());
|
||||
let start = s.doc().unwrap().wb.clone();
|
||||
let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string();
|
||||
assert!(err.contains("Input column 2 (Amount) must hold numbers; C4 holds text, C5 holds TRUE or FALSE."), "{err}");
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "nothing ran");
|
||||
|
||||
// Shape: width, rows, a single cell, blanks, dates.
|
||||
let mut m = totals();
|
||||
m["inputs"][0] = json!({"name": "Input", "header": true, "rows": [1, 2], "cols": [3, 3], "nonEmpty": true, "colTypes": ["any", "date"]});
|
||||
put(&mut s, m);
|
||||
set(&mut s, "B4", "");
|
||||
let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string();
|
||||
for needle in [
|
||||
"Input needs at most 2 data rows; B2:C5 has 3.",
|
||||
"Input needs exactly 3 columns; B2:C5 has 2.",
|
||||
"Input column 2 (Amount) must hold dates; C3 holds a number that isn't a date, C4 holds text, C5 holds TRUE or FALSE.",
|
||||
"Input must have no blank cells; B4 is blank.",
|
||||
] {
|
||||
assert!(err.contains(needle), "missing `{needle}` in {err}");
|
||||
}
|
||||
let mut m = totals();
|
||||
m["inputs"][0] = json!({"name": "Input", "kind": "cell"});
|
||||
put(&mut s, m);
|
||||
let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string();
|
||||
assert!(err.contains("Input must be a single cell; B2:C5 is 8 cells."), "{err}");
|
||||
// A second input with no range, and a bad reference.
|
||||
put(&mut s, json!({"name": "Two", "inputs": [{"name": "Input"}, {"name": "Rates"}], "steps": []}));
|
||||
let err = s.execute("macro.run", json!({"name": "Two"})).unwrap_err().to_string();
|
||||
assert!(err.contains("Rates needs a range"), "{err}");
|
||||
let err = s.execute("macro.run", json!({"name": "Two", "inputs": {"Rates": "Nowhere!A1"}})).unwrap_err().to_string();
|
||||
assert!(err.contains("Rates: There is no sheet named 'Nowhere'."), "{err}");
|
||||
// Whole columns shrink to the cells in use.
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Col", "inputs": [{"name": "Input", "rows": [1, 10]}], "steps": [{"command": "cell.set", "params": {"cell": "Input.below", "input": "end"}}], "output": {"onConflict": "overwrite"}}),
|
||||
);
|
||||
s.execute("macro.run", json!({"name": "Col", "inputs": "B:B"})).unwrap();
|
||||
assert_eq!(v(&s, "B6").display(), "end");
|
||||
assert!(s.execute("macro.run", json!({"name": "nope"})).unwrap_err().to_string().contains("no macro called 'nope'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_rolls_everything_back_and_names_the_step() {
|
||||
let mut s = s();
|
||||
data(&mut s);
|
||||
let mut m = totals();
|
||||
m["output"]["onConflict"] = json!("overwrite");
|
||||
m["steps"][2] = json!({"type": "repeat", "over": "Input.data", "body": [
|
||||
{"command": "home.italic", "params": {"range": "Row"}},
|
||||
{"command": "cell.set", "params": {"cell": "Row.cell(1,9)", "input": "=1/"}}
|
||||
]});
|
||||
put(&mut s, m);
|
||||
let (before, undo) = (s.doc().unwrap().wb.clone(), undo_labels(&s));
|
||||
let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string();
|
||||
assert!(err.starts_with("Macro 'Totals' stopped. Step 3.2 (cell.set):"), "{err}");
|
||||
assert!(err.ends_with("Nothing was changed."), "{err}");
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &before));
|
||||
assert_eq!(undo_labels(&s), undo);
|
||||
assert!(s.doc().unwrap().redo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicts_ask_fail_or_overwrite() {
|
||||
let mut s = s();
|
||||
data(&mut s);
|
||||
set(&mut s, "E3", "keep me");
|
||||
// A formula that reads what the macro writes: its value changes, but it isn't written.
|
||||
set(&mut s, "H1", "=E3");
|
||||
put(&mut s, totals());
|
||||
let start = s.doc().unwrap().wb.clone();
|
||||
let r = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"}));
|
||||
let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") };
|
||||
assert!(q.contains("would write over cells that hold data (E3 on Sheet1)"), "{q}");
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "programmatic runs default to failing");
|
||||
let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "fail"})).unwrap_err().to_string();
|
||||
assert!(err.contains("Nothing was changed"), "{err}");
|
||||
assert!(s.execute("macro.run", json!({"name": "Totals", "onConflict": "sideways"})).is_err());
|
||||
|
||||
// macro.check shows the footprint and the conflict, and changes nothing.
|
||||
let journal = s.journal.len();
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
let c = s.execute("macro.check", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap();
|
||||
assert_eq!(c["ok"], true);
|
||||
assert_eq!(c["conflicts"], 1);
|
||||
assert_eq!(c["output"], "E2");
|
||||
assert_eq!(c["footprint"]["sheets"][0]["cells"], json!(["E2:E3"]));
|
||||
assert_eq!(c["footprint"]["sheets"][0]["conflicts"], json!(["E3"]));
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start));
|
||||
assert_eq!((s.journal.len(), &s.doc().unwrap().selection), (journal, &sel));
|
||||
|
||||
// The answer to the question overwrites.
|
||||
s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "overwrite": true})).unwrap();
|
||||
assert_eq!(v(&s, "E3").display(), "400");
|
||||
assert_eq!(v(&s, "H1").display(), "400");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifting_makes_room_and_is_checked_again() {
|
||||
for (rule, moved) in [("shiftDown", ["E4", "E5"]), ("shiftRight", ["F2", "F3"])] {
|
||||
let mut s = s();
|
||||
data(&mut s);
|
||||
set(&mut s, "E2", "old 1");
|
||||
set(&mut s, "E3", "old 2");
|
||||
put(&mut s, totals());
|
||||
let r = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": rule})).unwrap();
|
||||
assert_eq!(r["shifted"], rule);
|
||||
assert_eq!((v(&s, "E2").display(), v(&s, "E3").display()), ("Total".into(), "400".into()), "{rule}");
|
||||
assert_eq!((v(&s, moved[0]).display(), v(&s, moved[1]).display()), ("old 1".into(), "old 2".into()), "{rule}");
|
||||
// The shift and the run are one undo step.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "E2").display(), "old 1", "{rule}");
|
||||
assert!(v(&s, moved[1]).is_empty(), "{rule}");
|
||||
}
|
||||
// A cell written with what it already holds isn't a conflict, but it moves with the others,
|
||||
// so rows stay together.
|
||||
let mut s = super::s();
|
||||
for (cell, input) in [("F2", "x"), ("G2", "b"), ("F3", "f3"), ("G3", "g3")] {
|
||||
set(&mut s, cell, input);
|
||||
}
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Pair", "output": {"onConflict": "shiftDown"}, "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "F2", "input": "a"}},
|
||||
{"command": "cell.set", "params": {"cell": "G2", "input": "b"}}
|
||||
]}),
|
||||
);
|
||||
let c = s.execute("macro.check", json!({"name": "Pair"})).unwrap();
|
||||
assert_eq!((c["conflicts"].as_u64(), &c["footprint"]["sheets"][0]["cells"]), (Some(1), &json!(["F2:G2"])));
|
||||
s.execute("macro.run", json!({"name": "Pair"})).unwrap();
|
||||
let row = |s: &Session, r: u32| (v(s, &format!("F{r}")).display(), v(s, &format!("G{r}")).display());
|
||||
assert_eq!(row(&s, 2), ("a".into(), "b".into()));
|
||||
assert_eq!(row(&s, 3), ("x".into(), "b".into()));
|
||||
assert_eq!(row(&s, 4), ("f3".into(), "g3".into()));
|
||||
// An anchor that moves with the shifted data still lands on it: the second check refuses.
|
||||
let mut s = super::s();
|
||||
data(&mut s);
|
||||
set(&mut s, "E2", "old");
|
||||
s.execute("formulas.defineName", json!({"name": "Target", "refersTo": "=Sheet1!$E$2"})).unwrap();
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Named", "steps": [{"command": "cell.set", "params": {"cell": "Name:Target", "input": "new"}}], "output": {"onConflict": "shiftDown"}}),
|
||||
);
|
||||
let start = s.doc().unwrap().wb.clone();
|
||||
let err = s.execute("macro.run", json!({"name": "Named"})).unwrap_err().to_string();
|
||||
assert!(err.contains("shifting the data didn't make room (E3 on Sheet1)"), "{err}");
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_footprint_covers_other_sheets_objects_and_edges() {
|
||||
let mut s = s();
|
||||
data(&mut s);
|
||||
s.execute("home.insertSheet", json!({"name": "Notes"})).unwrap();
|
||||
set(&mut s, "A1", "note");
|
||||
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "B2:C5", "type": "column", "at": "H2"})).unwrap();
|
||||
let chart = s.doc().unwrap().wb.sheet(0).unwrap().charts[0].id;
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Wide", "output": {"onConflict": "fail"}, "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "Notes!A1", "input": "x"}},
|
||||
{"command": "cell.set", "params": {"cell": "Notes!B2", "input": "y"}},
|
||||
{"command": "home.insertSheet", "params": {"name": "Extra"}},
|
||||
{"command": "sheet.activate", "params": {"sheet": "Sheet1"}},
|
||||
{"command": "insert.shape", "params": {"kind": "ellipse", "at": "J2"}},
|
||||
{"command": "object.delete", "params": {"kind": "chart", "id": chart}}
|
||||
]}),
|
||||
);
|
||||
let c = s.execute("macro.check", json!({"name": "Wide", "inputs": {}})).unwrap();
|
||||
let fp = &c["footprint"];
|
||||
assert_eq!(
|
||||
fp["sheets"],
|
||||
json!([{"sheet": "Notes", "cells": ["A1", "B2"], "changed": 2, "conflicts": ["A1"], "conflictCount": 1, "bounds": "A1:B2"}])
|
||||
);
|
||||
assert_eq!(fp["sheetsAdded"], json!(["Extra"]));
|
||||
assert_eq!((fp["objectsAdded"].as_u64(), fp["objectsRemoved"].as_u64()), (Some(1), Some(1)));
|
||||
assert_eq!(c["conflicts"], 2, "the deleted chart and the note");
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2, "the dry run's new sheet went away");
|
||||
let err = s.execute("macro.run", json!({"name": "Wide"})).unwrap_err().to_string();
|
||||
assert!(err.contains("(A1 on Notes; 1 object)"), "{err}");
|
||||
|
||||
// Running past the edge of the sheet fails before anything changes.
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Edge", "inputs": [{"name": "Input"}], "steps": [{"command": "cell.set", "params": {"cell": "Input.offset(-3,0)", "input": "x"}}]}),
|
||||
);
|
||||
let err = s.execute("macro.run", json!({"name": "Edge", "inputs": "B2"})).unwrap_err().to_string();
|
||||
assert!(err.contains("Step 1 (cell.set): 'Input.offset(-3,0)' runs past the top of the sheet."), "{err}");
|
||||
let c = s.execute("macro.check", json!({"name": "Edge", "inputs": "B2"})).unwrap();
|
||||
assert_eq!((c["ok"].as_bool(), c["footprint"]["error"]["edge"].as_bool()), (Some(false), Some(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_if_and_stop() {
|
||||
let mut s = s();
|
||||
data(&mut s);
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Flag", "inputs": [{"name": "Input", "header": true}], "output": {"onConflict": "overwrite"}, "steps": [
|
||||
{"type": "repeat", "over": "Input.data", "body": [
|
||||
{"type": "if", "cell": "Row.cell(1,2)", "op": "gt", "value": 100,
|
||||
"then": [{"command": "cell.set", "params": {"cell": "Row.right", "input": "big"}}],
|
||||
"else": [{"command": "cell.set", "params": {"cell": "Row.right", "input": "small"}}]},
|
||||
{"type": "if", "cell": "Row.cell(1,1)", "op": "eq", "value": "bo", "then": [{"type": "stop", "message": "Found Bo."}]}
|
||||
]},
|
||||
{"command": "cell.set", "params": {"cell": "A1", "input": "not reached"}}
|
||||
]}),
|
||||
);
|
||||
let r = s.execute("macro.run", json!({"name": "Flag", "inputs": "B2:C5"})).unwrap();
|
||||
assert_eq!(r["stopped"], "Found Bo.");
|
||||
assert_eq!((v(&s, "D3").display(), v(&s, "D4").display()), ("big".into(), "small".into()));
|
||||
assert!(v(&s, "D5").is_empty() && v(&s, "A1").is_empty());
|
||||
assert_eq!(undo_labels(&s).last().unwrap(), "Run Macro: Flag", "a stop keeps the changes");
|
||||
// A Stop that rolls back is an error that changes nothing.
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Guard", "output": {"onConflict": "overwrite"}, "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "A1", "input": "x"}},
|
||||
{"type": "if", "cell": "C3", "op": "notEmpty", "then": [{"type": "stop", "message": "C3 must be empty.", "rollback": true}]}
|
||||
]}),
|
||||
);
|
||||
let err = s.execute("macro.run", json!({"name": "Guard"})).unwrap_err().to_string();
|
||||
assert_eq!(err, "Macro 'Guard' stopped: C3 must be empty. Nothing was changed.");
|
||||
assert!(v(&s, "A1").is_empty());
|
||||
// Comparisons.
|
||||
for (op, value, want) in [
|
||||
("eq", json!(120), true),
|
||||
("eq", json!("120"), true),
|
||||
("ne", json!(120), false),
|
||||
("ge", json!(120), true),
|
||||
("lt", json!(120.5), true),
|
||||
("le", json!(100), false),
|
||||
("contains", json!("12"), true),
|
||||
("empty", json!(null), false),
|
||||
] {
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Cmp", "output": {"onConflict": "overwrite"}, "steps": [
|
||||
{"type": "if", "cell": "C3", "op": op, "value": value, "then": [{"command": "cell.set", "params": {"cell": "A9", "input": "yes"}}], "else": [{"command": "cell.set", "params": {"cell": "A9", "input": "no"}}]}
|
||||
]}),
|
||||
);
|
||||
s.execute("macro.run", json!({"name": "Cmp"})).unwrap();
|
||||
assert_eq!(v(&s, "A9").display(), if want { "yes" } else { "no" }, "{op} {value}");
|
||||
}
|
||||
// If needs one cell.
|
||||
put(&mut s, json!({"name": "Many", "steps": [{"type": "if", "cell": "A1:A2", "op": "empty"}]}));
|
||||
assert!(s.execute("macro.run", json!({"name": "Many"})).unwrap_err().to_string().contains("If looks at one cell"));
|
||||
// Disabled steps are skipped.
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Off", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "A8", "input": "x"}, "enabled": false}]}),
|
||||
);
|
||||
assert_eq!(s.execute("macro.run", json!({"name": "Off"})).unwrap()["ran"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macros_are_bounded() {
|
||||
let mut s = s();
|
||||
put(&mut s, json!({"name": "Long", "steps": [{"type": "repeat", "over": "A1:A20", "max": 10, "body": [{"type": "stop"}]}]}));
|
||||
let err = s.execute("macro.run", json!({"name": "Long"})).unwrap_err().to_string();
|
||||
assert!(err.contains("Repeat over A1:A20 has 20 rows, more than its limit of 10."), "{err}");
|
||||
// 400 × 400 passes is over the step limit.
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Spin", "output": {"onConflict": "overwrite"}, "steps": [{"type": "repeat", "over": "A1:A400", "body": [
|
||||
{"type": "repeat", "over": "A1:A400", "body": [{"type": "if", "cell": "A1", "op": "empty"}]}
|
||||
]}]}),
|
||||
);
|
||||
let err = s.execute("macro.run", json!({"name": "Spin"})).unwrap_err().to_string();
|
||||
assert!(err.contains("ran more than 100000 steps"), "{err}");
|
||||
let err = s.execute("macro.run", json!({"name": "Spin", "timeLimit": 0.001})).unwrap_err().to_string();
|
||||
assert!(err.contains("ran longer than"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_policy_keeps_macros_away_from_files_and_the_app() {
|
||||
let mut s = s();
|
||||
for (id, params) in [
|
||||
("file.saveAs", json!({"path": "/tmp/x.xlsx"})),
|
||||
("file.close", json!({})),
|
||||
("app.language.set", json!({"language": "de"})),
|
||||
("automate.runScript", json!({"name": "Script 1"})),
|
||||
("edit.undo", json!({})),
|
||||
("macro.run", json!({"name": "Self"})),
|
||||
] {
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Bad", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, {"command": id, "params": params}]}),
|
||||
);
|
||||
let err = s.execute("macro.run", json!({"name": "Bad"})).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("Step 2 ({id}) can't run in a macro")), "{err}");
|
||||
assert!(v(&s, "A1").is_empty(), "{id}: nothing ran");
|
||||
}
|
||||
// A disabled step isn't checked.
|
||||
put(&mut s, json!({"name": "Bad", "steps": [{"command": "file.close", "enabled": false}]}));
|
||||
s.execute("macro.run", json!({"name": "Bad"})).unwrap();
|
||||
|
||||
// Reading a file needs the permission, then the person's approval (once per macro content).
|
||||
let dir = std::env::temp_dir().join(format!("gc-macro-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let csv = dir.join("in.csv");
|
||||
std::fs::write(&csv, "a,b\n1,2\n").unwrap();
|
||||
let step = json!({"command": "data.fromTextCsv", "params": {"path": csv.to_string_lossy(), "sheet": "Imported"}});
|
||||
put(&mut s, json!({"name": "Import", "steps": [step]}));
|
||||
let err = s.execute("macro.run", json!({"name": "Import"})).unwrap_err().to_string();
|
||||
assert!(err.contains("Step 1 (data.fromTextCsv) reads a file. Add data.fromTextCsv to the macro's permissions"), "{err}");
|
||||
put(&mut s, json!({"name": "Import", "steps": [step], "permissions": ["data.fromTextCsv"]}));
|
||||
let r = s.execute("macro.run", json!({"name": "Import"}));
|
||||
let Err(EngineError::Approve(q)) = &r else { panic!("{r:?}") };
|
||||
assert_eq!(q, "Run macro 'Import'? It runs data.fromTextCsv, which reads a file.");
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 1);
|
||||
let c = s.execute("macro.check", json!({"name": "Import"})).unwrap();
|
||||
assert_eq!(c["needsApproval"], json!(["runs data.fromTextCsv, which reads a file"]));
|
||||
assert!(c.get("footprint").is_none(), "nothing unapproved runs, even dry");
|
||||
s.execute("macro.run", json!({"name": "Import", "approve": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("macro.run", json!({"name": "Import"})).unwrap();
|
||||
// The dry run skips the file step and says so.
|
||||
let c = s.execute("macro.check", json!({"name": "Import"})).unwrap();
|
||||
assert_eq!(c["footprint"]["partial"]["step"], "1");
|
||||
// Changing the macro asks again.
|
||||
put(&mut s, json!({"name": "Import", "description": "changed", "steps": [step], "permissions": ["data.fromTextCsv"]}));
|
||||
assert!(matches!(s.execute("macro.run", json!({"name": "Import"})), Err(EngineError::Approve(_))));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// Steps that would open a dialog fail instead.
|
||||
put(&mut s, json!({"name": "Pic", "output": {"onConflict": "overwrite"}, "steps": [{"command": "insert.picture"}]}));
|
||||
let err = s.execute("macro.run", json!({"name": "Pic"})).unwrap_err().to_string();
|
||||
assert!(err.contains("would open the insertPicture dialog"), "{err}");
|
||||
assert!(s.ui_requests.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macros_from_a_file_ask_before_running() {
|
||||
let mut s = s();
|
||||
let mut wb = gridcraft_model::Workbook::new();
|
||||
wb.macros.push(crate::macros::parse_macro(&json!({"name": "Theirs", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "ran"}}]})).unwrap());
|
||||
let bytes = serde_json::to_vec(&wb).unwrap();
|
||||
s.execute("file.open", json!({"name": "theirs.json", "base64": crate::io::base64_encode(&bytes)})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().trust, crate::Trust::FromFile);
|
||||
let r = s.execute("macro.run", json!({"name": "Theirs"}));
|
||||
let Err(EngineError::Approve(q)) = &r else { panic!("{r:?}") };
|
||||
assert_eq!(q, "Run macro 'Theirs'? It came with a workbook you opened.");
|
||||
assert!(v(&s, "A1").is_empty());
|
||||
s.execute("macro.run", json!({"name": "Theirs", "approve": true})).unwrap();
|
||||
assert_eq!(v(&s, "A1").display(), "ran");
|
||||
// Approved once per macro content: the second run doesn't ask.
|
||||
s.execute("macro.run", json!({"name": "Theirs"})).unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user