mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-03 00:41:56 -06:00
A Python cell's return type had no command of its own: python.set takes
it, but only together with the code. python.output {cell?, mode?} sets
it on its own; its command entry lists Excel's shortcut,
Ctrl+Alt+Shift+M. Without a mode it switches to the other one, so the
shortcut needs no parameters; a cell that isn't a Python cell is an
error. A cached result belongs to the return type it was made for, so
a switch treats it as python.set treats a new return type: the result
goes, the cell shows #BLOCKED! until it runs, and its old spill goes
with what read it recalculated. Both commands write the cell through
one path, so python.output refuses a table header and a locked cell on
a protected sheet as python.set does. Each switch is one undo step, and
setting the mode a cell already has adds none.
python.set now refuses an output that isn't text instead of ignoring it.
The command-sequence fuzzer runs python.output too. A macro recording
keeps the return type a switch without a mode set, as its mode (as Bold
records on), also for a switch inside a recorded script run, so playing
it back sets that type rather than switching again.
Tests: switching without a mode drops the cached result, its spill and
the value its reader saw, the same as python.set with the other return
type; switching back brings no result back; the code is kept; one undo
step per switch, undo brings the result back, and none for the mode a
cell already has; a cell without a result stays #BLOCKED!; the
refusals (no Python cell, a bad or non-text mode, a bad cell, a table
header, a locked cell, a non-text output for python.set), an unlocked
Hidden cell on a protected sheet switches, and a recorded switch played
back on a cell that already has the new type keeps it.
246 lines
12 KiB
Rust
246 lines
12 KiB
Rust
//! The python.* commands.
|
|
|
|
use gridcraft_core::{CellError, Value};
|
|
use serde_json::json;
|
|
|
|
use super::{s, v};
|
|
|
|
fn set(s: &mut crate::Session, cell: &str, code: &str) -> serde_json::Value {
|
|
s.execute("python.set", json!({"cell": cell, "code": code})).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn set_stores_the_code_and_blocks_the_cell_until_it_can_run() {
|
|
let mut s = s();
|
|
let r = set(&mut s, "B1", "xl('A1') + 1");
|
|
assert_eq!(r["cell"], "B1");
|
|
assert_eq!(v(&s, "B1"), Value::Error(CellError::Blocked));
|
|
let got = s.execute("cell.get", json!({"cell": "B1"})).unwrap();
|
|
assert_eq!(got["python"]["code"], "xl('A1') + 1");
|
|
assert_eq!(got["python"]["output"], "value");
|
|
assert_eq!(got["input"], "xl('A1') + 1");
|
|
// The active cell by default.
|
|
s.execute("selection.set", json!({"cell": "D4"})).unwrap();
|
|
s.execute("python.set", json!({"code": "2"})).unwrap();
|
|
assert_eq!(s.execute("cell.get", json!({"cell": "D4"})).unwrap()["python"]["code"], "2");
|
|
// One undo step takes the cell back.
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert!(s.execute("cell.get", json!({"cell": "D4"})).unwrap()["python"].is_null());
|
|
}
|
|
|
|
#[test]
|
|
fn unchanged_code_keeps_its_cached_result() {
|
|
let mut s = s();
|
|
set(&mut s, "A1", "21 * 2");
|
|
// As a file would have cached it.
|
|
{
|
|
let d = s.doc_mut().unwrap();
|
|
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
|
let sh = wb.sheet_mut(0).unwrap();
|
|
let cell = sh.cells.get_mut(gridcraft_core::CellRef::new(0, 0)).unwrap();
|
|
let py = std::sync::Arc::make_mut(cell.py.as_mut().unwrap());
|
|
py.output = Some(gridcraft_model::PyOutput { value: Value::Number(42.0), type_name: "int".into(), preview: "42".into(), convertible: true });
|
|
cell.value = Value::Number(42.0);
|
|
}
|
|
set(&mut s, "A1", "21 * 2");
|
|
assert_eq!(v(&s, "A1"), Value::Number(42.0), "same code, same result");
|
|
s.execute("python.set", json!({"cell": "A1", "code": "21 * 2", "output": "object"})).unwrap();
|
|
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "object");
|
|
set(&mut s, "A1", "21 * 3");
|
|
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked), "new code has no result yet");
|
|
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "object", "the return type stays");
|
|
}
|
|
|
|
#[test]
|
|
fn set_refuses_bad_params_long_code_headers_and_protected_cells() {
|
|
let mut s = s();
|
|
for p in [
|
|
json!({"cell": "A1"}),
|
|
json!({"cell": "A1", "code": 5}),
|
|
json!({"cell": "not a cell", "code": "1"}),
|
|
json!({"cell": "A1", "code": "1", "output": "sideways"}),
|
|
] {
|
|
assert!(s.execute("python.set", p.clone()).is_err(), "{p}");
|
|
}
|
|
// The limit is the stored PY(…) formula's 8,192 characters.
|
|
assert!(s.execute("python.set", json!({"cell": "A1", "code": "y".repeat(8_080)})).is_err());
|
|
s.execute("python.set", json!({"cell": "A1", "code": "y".repeat(8_079)})).unwrap();
|
|
let quoted = format!("{}{}", "\"".repeat(1_200), "y".repeat(5_800));
|
|
let e = s.execute("python.set", json!({"cell": "A2", "code": quoted})).unwrap_err();
|
|
assert!(e.to_string().contains("8192") || e.to_string().contains("8,192"), "{e}");
|
|
// A table header is the column's name.
|
|
s.execute("range.setValues", json!({"range": "C1", "values": [["Name", "Score"], ["a", 1]]})).unwrap();
|
|
s.execute("insert.table", json!({"range": "C1:D2", "headers": true})).unwrap();
|
|
assert!(s.execute("python.set", json!({"cell": "D1", "code": "1"})).is_err());
|
|
assert_eq!(v(&s, "D1"), Value::text("Score"));
|
|
set(&mut s, "D2", "1");
|
|
// A locked cell on a protected sheet refuses it.
|
|
s.execute("review.protectSheet", json!({})).unwrap();
|
|
let e = s.execute("python.set", json!({"cell": "F1", "code": "1"})).unwrap_err();
|
|
assert!(e.to_string().contains("protected"), "{e}");
|
|
}
|
|
|
|
#[test]
|
|
fn set_over_a_spilling_formula_takes_the_spill_away() {
|
|
let mut s = s();
|
|
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
|
assert_eq!(v(&s, "A3"), Value::Number(3.0));
|
|
set(&mut s, "A1", "[1, 2, 3]");
|
|
assert_eq!(v(&s, "A3"), Value::Empty);
|
|
assert!(s.doc().unwrap().wb.sheet(0).unwrap().spill_ranges.is_empty());
|
|
}
|
|
|
|
/// A cached result as a file would have kept it on the cell: `[[1], [2], [3]]`, a list.
|
|
fn cache_list(s: &mut crate::Session, cell: &str) {
|
|
let at = gridcraft_core::CellRef::parse(cell).unwrap();
|
|
let d = s.doc_mut().unwrap();
|
|
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
|
let sh = wb.sheet_mut(0).unwrap();
|
|
let py = std::sync::Arc::make_mut(sh.cells.get_mut(at).unwrap().py.as_mut().unwrap());
|
|
let arr = gridcraft_core::Array::new(3, 1, vec![1.0.into(), 2.0.into(), 3.0.into()]).map(Value::from).unwrap();
|
|
py.output = Some(gridcraft_model::PyOutput { value: arr, type_name: "list".into(), preview: "[1, 2, 3]".into(), convertible: true });
|
|
}
|
|
|
|
/// A1 holds `[1, 2, 3]` with its cached list spilled over A1:A3 as Excel values; B1 reads A3.
|
|
fn cached_spill_with_a_reader() -> crate::Session {
|
|
let mut s = s();
|
|
set(&mut s, "A1", "[1, 2, 3]");
|
|
cache_list(&mut s, "A1");
|
|
s.execute("formulas.calculateNow", json!({})).unwrap();
|
|
s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A3)"})).unwrap();
|
|
assert_eq!(v(&s, "A1"), Value::Number(1.0));
|
|
assert_eq!(v(&s, "B1"), Value::Number(3.0));
|
|
s
|
|
}
|
|
|
|
fn output_of(s: &mut crate::Session, cell: &str) -> serde_json::Value {
|
|
s.execute("cell.get", json!({"cell": cell})).unwrap()["python"]["output"].clone()
|
|
}
|
|
|
|
fn undo_depth(s: &mut crate::Session) -> Option<usize> {
|
|
s.execute("history.list", json!({})).unwrap()["undo"].as_array().map(Vec::len)
|
|
}
|
|
|
|
#[test]
|
|
fn output_switches_the_return_type_and_drops_a_result_of_the_other_type() {
|
|
let mut s = cached_spill_with_a_reader();
|
|
// The mode it already has changes nothing and adds no undo step.
|
|
let undo = undo_depth(&mut s);
|
|
let r = s.execute("python.output", json!({"cell": "A1", "mode": "value"})).unwrap();
|
|
assert_eq!(r["output"], "value");
|
|
assert_eq!(undo_depth(&mut s), undo);
|
|
assert_eq!(v(&s, "A3"), Value::Number(3.0), "the kept result still spills");
|
|
// Without `mode` it switches. The cached list is an Excel value's result: as with python.set
|
|
// and a new return type, the cell has no result until it runs, and its old spill goes with
|
|
// its readers recalculated.
|
|
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
|
let r = s.execute("python.output", json!({})).unwrap();
|
|
assert_eq!(r["output"], "object");
|
|
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked));
|
|
assert_eq!(v(&s, "A3"), Value::Empty, "the old spill goes");
|
|
assert_eq!(v(&s, "B1"), Value::Number(0.0), "and what read it is recalculated");
|
|
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["code"], "[1, 2, 3]", "the code stays");
|
|
// The same as python.set with the same code and the other return type.
|
|
let mut by_set = cached_spill_with_a_reader();
|
|
by_set.execute("python.set", json!({"cell": "A1", "code": "[1, 2, 3]", "output": "object"})).unwrap();
|
|
for c in ["A1", "A3", "B1"] {
|
|
assert_eq!(v(&s, c), v(&by_set, c), "{c}");
|
|
}
|
|
assert_eq!(output_of(&mut s, "A1"), output_of(&mut by_set, "A1"));
|
|
// Switching back brings no result back: the dropped one belonged to the old type.
|
|
s.execute("python.output", json!({"cell": "A1", "mode": "value"})).unwrap();
|
|
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked));
|
|
assert_eq!(v(&s, "A3"), Value::Empty);
|
|
// Each switch is one undo step, and undoing the first brings the result and its spill back.
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert_eq!(output_of(&mut s, "A1"), "object");
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert_eq!(output_of(&mut s, "A1"), "value");
|
|
assert_eq!(v(&s, "A1"), Value::Number(1.0));
|
|
assert_eq!(v(&s, "A3"), Value::Number(3.0));
|
|
assert_eq!(v(&s, "B1"), Value::Number(3.0));
|
|
// A cell with no result keeps showing #BLOCKED!.
|
|
set(&mut s, "C1", "x = 1");
|
|
s.execute("python.output", json!({"cell": "C1"})).unwrap();
|
|
assert_eq!(v(&s, "C1"), Value::Error(CellError::Blocked));
|
|
assert_eq!(output_of(&mut s, "C1"), "object");
|
|
}
|
|
|
|
#[test]
|
|
fn output_refuses_table_headers_and_locked_cells_as_set_does() {
|
|
let mut s = s();
|
|
// A Python cell in a table's header row (as a file could hold one): python.set refuses to
|
|
// put code there, and python.output refuses to change it.
|
|
s.execute("range.setValues", json!({"range": "C1", "values": [["Name", "Score"], ["a", 1]]})).unwrap();
|
|
s.execute("insert.table", json!({"range": "C1:D2", "headers": true})).unwrap();
|
|
{
|
|
let d = s.doc_mut().unwrap();
|
|
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
|
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(gridcraft_core::CellRef::parse("D1").unwrap()).unwrap();
|
|
cell.py = Some(std::sync::Arc::new(gridcraft_model::PyCode::new("'Score'")));
|
|
}
|
|
let e = s.execute("python.output", json!({"cell": "D1"})).unwrap_err();
|
|
assert!(e.to_string().contains("table header"), "{e}");
|
|
assert_eq!(output_of(&mut s, "D1"), "value");
|
|
// A locked cell on a protected sheet refuses it; an unlocked one formatted Hidden takes it,
|
|
// as it takes python.set.
|
|
set(&mut s, "F1", "1");
|
|
set(&mut s, "G1", "2");
|
|
s.execute("home.formatCells", json!({"range": "G1", "style": {"protection": {"locked": false, "hidden": true}}})).unwrap();
|
|
s.execute("review.protectSheet", json!({})).unwrap();
|
|
let e = s.execute("python.output", json!({"cell": "F1"})).unwrap_err();
|
|
assert!(e.to_string().contains("protected"), "{e}");
|
|
assert_eq!(output_of(&mut s, "F1"), "value");
|
|
s.execute("python.set", json!({"cell": "G1", "code": "2"})).unwrap();
|
|
s.execute("python.output", json!({"cell": "G1"})).unwrap();
|
|
assert_eq!(output_of(&mut s, "G1"), "object");
|
|
}
|
|
|
|
#[test]
|
|
fn output_refuses_cells_without_python_and_bad_modes() {
|
|
let mut s = s();
|
|
// Empty params: the active cell, which holds no Python code.
|
|
let e = s.execute("python.output", json!({})).unwrap_err();
|
|
assert!(e.to_string().contains("isn't a Python cell"), "{e}");
|
|
s.execute("cell.set", json!({"cell": "B1", "input": "=1+1"})).unwrap();
|
|
assert!(s.execute("python.output", json!({"cell": "B1"})).is_err());
|
|
set(&mut s, "A1", "1");
|
|
for p in [json!({"cell": "A1", "mode": "sideways"}), json!({"cell": "A1", "mode": 1}), json!({"cell": "nowhere"})] {
|
|
assert!(s.execute("python.output", p.clone()).is_err(), "{p}");
|
|
}
|
|
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "value");
|
|
// python.set refuses a return type that isn't text too, rather than ignoring it.
|
|
assert!(s.execute("python.set", json!({"cell": "A1", "code": "1", "output": true})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn a_recorded_switch_keeps_the_return_type_it_set() {
|
|
// python.output without `mode` switches, so a recording keeps the return type the switch set
|
|
// (as Bold records `on`): played back on a cell that already has it, nothing switches back.
|
|
let mut s = s();
|
|
set(&mut s, "A1", "1");
|
|
s.execute("macro.record", json!({"relative": false})).unwrap();
|
|
s.execute("python.output", json!({"cell": "A1"})).unwrap();
|
|
s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap();
|
|
assert_eq!(output_of(&mut s, "A1"), "object");
|
|
let m = s.execute("macro.get", json!({"name": "Rec"})).unwrap();
|
|
let m = crate::macros::parse_macro(&m).unwrap();
|
|
let steps = crate::macros::command_steps(&m.steps);
|
|
let step = steps.iter().find(|(_, id, _)| *id == "python.output").map(|(_, _, p)| (*p).clone());
|
|
assert_eq!(step.and_then(|p| p.get("mode").cloned()), Some(json!("object")));
|
|
for _ in 0..2 {
|
|
s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap();
|
|
assert_eq!(output_of(&mut s, "A1"), "object");
|
|
}
|
|
// A run inside a script is recorded the same way.
|
|
s.execute("automate.newScript", json!({"name": "Switch", "commands": [{"command": "python.output", "params": {"cell": "A1"}}]})).unwrap();
|
|
s.execute("macro.record", json!({"relative": false})).unwrap();
|
|
s.execute("automate.runScript", json!({"name": "Switch"})).unwrap();
|
|
s.execute("macro.record", json!({"stop": true, "name": "Rec2"})).unwrap();
|
|
assert_eq!(output_of(&mut s, "A1"), "value");
|
|
for _ in 0..2 {
|
|
s.execute("macro.run", json!({"name": "Rec2", "onConflict": "overwrite"})).unwrap();
|
|
assert_eq!(output_of(&mut s, "A1"), "value");
|
|
}
|
|
}
|