Files
gridcraft/crates/engine/src/tests/python_cells.rs
T
prxnvt 14ab0f8ee3 Engine: Replace edits a Python cell's code and keeps it a Python cell
Replace matched a Python cell on its code and then rebuilt the cell from the
replaced text as if it had been typed, so the cell became a text cell holding
the edited code and the Python cell was lost. The replacement now edits the
code: the cell stays a Python cell with its return type, its result is dropped
(#BLOCKED!, as for any changed code) and a spill it had is cleared. Code that
would no longer fit in a PY formula is left unchanged and counted in a new
skipped field of the result.
2026-11-01 12:10:31 -05:00

299 lines
15 KiB
Rust

//! Python cells under edits: what keeps, moves, rewrites or removes their code.
use std::sync::Arc;
use gridcraft_core::{CellRef, Value};
use gridcraft_model::{Cell, PyCode};
use serde_json::json;
use super::s;
use crate::Session;
/// Makes `cell` on sheet `sheet` a Python cell with `code` and a cached value.
fn plant(s: &mut Session, sheet: usize, cell: &str, code: &str) {
let d = s.doc_mut().unwrap();
let mut wb = (*d.wb).clone();
let sh = wb.sheet_mut(sheet).unwrap();
let at = CellRef::parse(cell).unwrap();
let style = sh.style_id(at);
sh.set_cell(at, Cell { value: Value::Number(1.0), formula: None, style, py: Some(Arc::new(PyCode::new(code))) });
d.wb = Arc::new(wb);
}
fn code(s: &Session, sheet: usize, cell: &str) -> Option<String> {
let d = s.doc().unwrap();
d.wb.sheet(sheet).unwrap().cell(CellRef::parse(cell).unwrap()).and_then(|c| c.py.as_ref()).map(|p| p.code.clone())
}
#[test]
fn typing_py_formulas_points_to_python_set() {
let mut s = s();
let e = s.execute("cell.set", json!({"cell": "A1", "input": "=PY(\"1+1\",0)"})).unwrap_err();
assert!(e.to_string().contains("python.set"), "{e}");
assert!(s.execute("cell.set", json!({"cell": "A1", "input": "=py"})).is_err());
// Other functions starting with PY are not affected.
s.execute("cell.set", json!({"cell": "A2", "input": "=PI()"})).unwrap();
}
#[test]
fn entering_or_clearing_a_python_cell_removes_its_code() {
let mut s = s();
plant(&mut s, 0, "A1", "1 + 1");
plant(&mut s, 0, "A2", "2 + 2");
plant(&mut s, 0, "A3", "3 + 3");
let got = s.execute("cell.get", json!({"cell": "A1"})).unwrap();
assert_eq!(got["input"], "1 + 1");
assert_eq!(got["python"]["code"], "1 + 1");
assert_eq!(got["python"]["output"], "value");
s.execute("cell.set", json!({"cell": "A1", "input": "hello"})).unwrap();
assert_eq!(code(&s, 0, "A1"), None);
s.execute("edit.clearFormats", json!({"range": "A2"})).unwrap();
assert_eq!(code(&s, 0, "A2").as_deref(), Some("2 + 2"), "clearing formats keeps the code");
s.execute("edit.clearContents", json!({"range": "A2"})).unwrap();
assert_eq!(code(&s, 0, "A2"), None);
s.execute("edit.clearAll", json!({"range": "A3"})).unwrap();
assert_eq!(code(&s, 0, "A3"), None);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(code(&s, 0, "A3").as_deref(), Some("3 + 3"), "undo brings the code back");
}
#[test]
fn copy_fill_and_sort_carry_the_code_and_paste_values_drops_it() {
let mut s = s();
plant(&mut s, 0, "A1", "xl('B1') * 2");
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
s.execute("edit.paste", json!({"at": "C1"})).unwrap();
assert_eq!(code(&s, 0, "C1").as_deref(), Some("xl('B1') * 2"), "the code is copied as it is");
s.execute("edit.pasteSpecial", json!({"at": "D1", "what": "values"})).unwrap();
assert_eq!(code(&s, 0, "D1"), None);
assert_eq!(super::v(&s, "D1"), Value::Number(1.0), "the cached value is pasted");
s.execute("edit.fillDown", json!({"range": "A1:A3"})).unwrap();
assert_eq!(code(&s, 0, "A3").as_deref(), Some("xl('B1') * 2"));
// Sorting moves Python cells with their rows.
s.execute("range.setValues", json!({"range": "E1", "values": [[3], [1], [2]]})).unwrap();
plant(&mut s, 0, "F1", "'three'");
plant(&mut s, 0, "F2", "'one'");
s.execute("data.sort", json!({"range": "E1:F3", "keys": [{"column": "E"}]})).unwrap();
assert_eq!(code(&s, 0, "F1").as_deref(), Some("'one'"));
assert_eq!(code(&s, 0, "F3").as_deref(), Some("'three'"));
}
#[test]
fn xl_references_follow_inserts_moves_and_renames() {
let mut s = s();
s.execute("home.insertSheet", json!({})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
let other = s.doc().unwrap().wb.sheet(1).unwrap().name.clone();
plant(&mut s, 0, "C5", &format!("a = xl(\"A1:A3\")\nb = xl('{other}!B2', headers=True)\nc = xl(dynamic)\n# xl(\"A1\") stays in comments"));
s.execute("home.insertRows", json!({"rows": "2:2"})).unwrap();
let c = code(&s, 0, "C6").unwrap();
assert!(c.starts_with("a = xl(\"A1:A4\")\n"), "{c}");
assert!(c.contains(&format!("xl('{other}!B2', headers=True)")), "{c}");
assert!(c.contains("# xl(\"A1\") stays"), "{c}");
s.execute("sheet.rename", json!({"sheet": 1, "name": "Data"})).unwrap();
assert!(code(&s, 0, "C6").unwrap().contains("xl('Data!B2', headers=True)"));
// Cutting the cells a reference names moves the reference with them.
s.execute("edit.cut", json!({"range": "A1:A4"})).unwrap();
s.execute("edit.paste", json!({"at": "H1"})).unwrap();
assert!(code(&s, 0, "C6").unwrap().starts_with("a = xl(\"H1:H4\")"), "{:?}", code(&s, 0, "C6"));
// Deleting the referenced cells leaves #REF!, which fails when the code runs.
// The selection is elsewhere, so only the named column goes.
s.execute("selection.set", json!({"range": "A1"})).unwrap();
s.execute("home.deleteColumns", json!({"cols": "H:H"})).unwrap();
assert!(code(&s, 0, "C6").unwrap().starts_with("a = xl(\"#REF!\")"), "{:?}", code(&s, 0, "C6"));
}
#[test]
fn a_rename_to_a_name_that_needs_quotes_rewrites_single_quoted_references() {
let mut s = s();
s.execute("home.insertSheet", json!({})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
let other = s.doc().unwrap().wb.sheet(1).unwrap().name.clone();
plant(&mut s, 0, "A1", &format!("a = xl('{other}!B2')\nb = xl(\"{other}!C3\")"));
s.execute("sheet.rename", json!({"sheet": 1, "name": "Q4 2026"})).unwrap();
assert_eq!(code(&s, 0, "A1").unwrap(), "a = xl(\"'Q4 2026'!B2\")\nb = xl(\"'Q4 2026'!C3\")");
// A name with both quotes in it: the literal's own quote is escaped.
s.execute("sheet.rename", json!({"sheet": 1, "name": "Bob's \"Q4\""})).unwrap();
let c = code(&s, 0, "A1").unwrap();
assert_eq!(c, "a = xl(\"'Bob''s \\\"Q4\\\"'!B2\")\nb = xl(\"'Bob''s \\\"Q4\\\"'!C3\")");
let refs: Vec<Option<String>> = gridcraft_model::python::xl_calls(&c).into_iter().map(|x| x.reference).collect();
assert_eq!(refs, [Some("'Bob''s \"Q4\"'!B2".to_string()), Some("'Bob''s \"Q4\"'!C3".to_string())]);
// And back to a plain name.
s.execute("sheet.rename", json!({"sheet": 1, "name": "Data"})).unwrap();
assert_eq!(code(&s, 0, "A1").unwrap(), "a = xl(\"Data!B2\")\nb = xl(\"Data!C3\")");
}
#[test]
fn a_cut_to_another_sheet_keeps_references_on_the_source_sheet() {
let mut s = s();
s.execute("home.insertSheet", json!({})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
let (src, dest) = {
let wb = &s.doc().unwrap().wb;
(wb.sheet(0).unwrap().name.clone(), wb.sheet(1).unwrap().name.clone())
};
plant(&mut s, 0, "A1", "a = xl(\"B5\")\nb = xl('A2')");
plant(&mut s, 0, "A2", "1");
s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap();
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
s.execute("edit.paste", json!({"at": "C1"})).unwrap();
// B5 stays on the source sheet; A2 moved with the cut, so it follows to the destination.
assert_eq!(code(&s, 1, "C1").unwrap(), format!("a = xl(\"{src}!B5\")\nb = xl('{dest}!C2')"));
assert_eq!(code(&s, 0, "A1"), None);
// Within one sheet nothing is qualified.
s.execute("edit.cut", json!({"range": "C1"})).unwrap();
s.execute("edit.paste", json!({"at": "E1"})).unwrap();
assert_eq!(code(&s, 1, "E1").unwrap(), format!("a = xl(\"{src}!B5\")\nb = xl('{dest}!C2')"));
// A copy to another sheet still carries the code as it is.
plant(&mut s, 0, "A9", "xl(\"B5\")");
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
s.execute("edit.copy", json!({"range": "A9"})).unwrap();
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
s.execute("edit.paste", json!({"at": "A9"})).unwrap();
assert_eq!(code(&s, 1, "A9").as_deref(), Some("xl(\"B5\")"));
}
#[test]
fn goal_seek_and_solver_refuse_to_change_a_python_cell() {
let mut s = s();
plant(&mut s, 0, "A1", "2");
s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap();
let e = s.execute("data.goalSeek", json!({"set": "B1", "to": 10, "changing": "A1"})).unwrap_err();
assert!(e.to_string().contains("must contain a value"), "{e}");
assert_eq!(code(&s, 0, "A1").as_deref(), Some("2"), "the code is kept");
// The set cell must be a formula: a Python cell isn't one this build can recalculate.
s.execute("cell.set", json!({"cell": "C1", "input": "3"})).unwrap();
assert!(s.execute("data.goalSeek", json!({"set": "A1", "to": 10, "changing": "C1"})).is_err());
let e = s.execute("data.solver", json!({"objective": "B1", "goal": "max", "variables": "A1"})).unwrap_err();
assert!(e.to_string().contains("constants"), "{e}");
assert_eq!(code(&s, 0, "A1").as_deref(), Some("2"));
}
#[test]
fn text_to_columns_keeps_a_py_piece_as_text() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "'=PY(\"x\");7"})).unwrap();
assert_eq!(super::v(&s, "A1"), Value::text("=PY(\"x\");7"));
s.execute("data.textToColumns", json!({"range": "A1", "delimiters": [";"]})).unwrap();
// The default text qualifier takes the quotes away, as in Excel.
assert_eq!(super::v(&s, "A1"), Value::text("=PY(x)"), "the piece is kept, not cleared");
assert_eq!(code(&s, 0, "A1"), None, "and it is not a Python cell");
assert_eq!(super::v(&s, "B1"), Value::Number(7.0));
}
#[test]
fn python_cells_survive_native_json() {
let mut s = s();
plant(&mut s, 0, "B2", "x = 1\nx");
let wb = s.doc().unwrap().wb.clone();
let json = serde_json::to_string(&*wb).unwrap();
let back: gridcraft_model::Workbook = serde_json::from_str(&json).unwrap();
let cell = back.sheet(0).unwrap().cell(CellRef::parse("B2").unwrap()).unwrap();
assert_eq!(cell.py.as_ref().unwrap().code, "x = 1\nx");
// Cells written before Python existed have no `py` field.
let plain: Cell = serde_json::from_value(json!({"value": {"t": "Number", "v": 2.0}, "formula": null, "style": 0})).unwrap();
assert!(plain.py.is_none());
assert!(!serde_json::to_string(&plain).unwrap().contains("py"), "no field is written for ordinary cells");
}
#[test]
fn new_errors_parse_and_have_error_type_numbers() {
use gridcraft_core::CellError;
for (text, e) in
[("#PYTHON!", CellError::Python), ("#BUSY!", CellError::Busy), ("#TIMEOUT!", CellError::Timeout), ("#BLOCKED!", CellError::Blocked)]
{
assert_eq!(CellError::parse(text), Some(e));
assert_eq!(e.as_str(), text);
}
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "=ERROR.TYPE(#BLOCKED!)"})).unwrap();
assert_eq!(super::v(&s, "A1"), Value::Number(11.0));
s.execute("cell.set", json!({"cell": "A2", "input": "=ISERROR(#PYTHON!)"})).unwrap();
assert_eq!(super::v(&s, "A2"), Value::Bool(true));
}
#[test]
fn go_to_special_counts_a_python_cell_as_a_formula_not_a_constant() {
let mut s = s();
plant(&mut s, 0, "A1", "1 + 1");
s.execute("cell.set", json!({"cell": "A2", "input": "=1+1"})).unwrap();
s.execute("cell.set", json!({"cell": "A3", "input": "7"})).unwrap();
s.execute("selection.set", json!({"range": "A1"})).unwrap();
let r = s.execute("edit.goToSpecial", json!({"kind": "formulas"})).unwrap();
assert_eq!(r["count"], 2, "{r}");
assert_eq!(r["selection"], "A1:A2", "{r}");
s.execute("selection.set", json!({"range": "A1"})).unwrap();
let r = s.execute("edit.goToSpecial", json!({"kind": "constants"})).unwrap();
assert_eq!(r["count"], 1, "{r}");
assert_eq!(r["selection"], "A3", "{r}");
}
#[test]
fn a_macro_counts_python_code_as_data_and_a_blocked_spill_as_no_write() {
let mut s = s();
// F2 shows 1, its cached value: a step that enters 1 there still replaces the code.
plant(&mut s, 0, "F2", "1");
// H2's code shows #BLOCKED!, as any other new code there would.
s.execute("python.set", json!({"cell": "H2", "code": "'a'"})).unwrap();
// J2's result spills over J2:J3. A step that writes J3 blocks it, which doesn't write J2.
s.execute("python.set", json!({"cell": "J2", "code": "[5, 6]"})).unwrap();
{
let d = s.doc_mut().unwrap();
let wb = Arc::make_mut(&mut d.wb);
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("J2").unwrap()).unwrap();
let data = vec![Value::Number(5.0), Value::Number(6.0)];
let value = Value::Array(Arc::new(gridcraft_core::Array::new(2, 1, data).unwrap()));
Arc::make_mut(cell.py.as_mut().unwrap()).output =
Some(gridcraft_model::PyOutput { value, type_name: "list".into(), preview: String::new(), convertible: true });
}
s.execute("formulas.calculateNow", json!({})).unwrap();
assert_eq!(super::v(&s, "J3"), Value::Number(6.0));
super::macros::put(
&mut s,
json!({"name": "Py", "steps": [
{"command": "cell.set", "params": {"cell": "F2", "input": "1"}},
{"command": "python.set", "params": {"cell": "H2", "code": "'b'"}},
{"command": "cell.set", "params": {"cell": "J3", "input": "x"}}
]}),
);
let c = s.execute("macro.check", json!({"name": "Py"})).unwrap();
assert_eq!(c["footprint"]["sheets"][0]["cells"], json!(["F2", "H2", "J3"]), "{c}");
assert_eq!(c["footprint"]["sheets"][0]["conflicts"], json!(["F2", "H2"]), "{c}");
let e = s.execute("macro.run", json!({"name": "Py", "onConflict": "fail"})).unwrap_err().to_string();
assert!(e.contains("F2") && e.contains("H2") && !e.contains("J2"), "{e}");
assert_eq!(code(&s, 0, "F2").as_deref(), Some("1"), "nothing was changed");
}
#[test]
fn replace_edits_a_python_cells_code_and_keeps_it_a_python_cell() {
let mut s = s();
plant(&mut s, 0, "A1", "x = 1\nx");
{
let d = s.doc_mut().unwrap();
let mut wb = (*d.wb).clone();
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("A1").unwrap()).unwrap();
Arc::make_mut(cell.py.as_mut().unwrap()).object = true;
d.wb = Arc::new(wb);
}
let r = s.execute("edit.replace", json!({"what": "x", "with": "y"})).unwrap();
assert_eq!(r["replaced"], 1);
assert_eq!(code(&s, 0, "A1").as_deref(), Some("y = 1\ny"));
let d = s.doc().unwrap();
let cell = d.wb.sheet(0).unwrap().cell(CellRef::parse("A1").unwrap()).unwrap();
assert!(cell.py.as_ref().unwrap().object, "the return type is kept");
assert_eq!(cell.value, Value::Error(gridcraft_core::CellError::Blocked));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(code(&s, 0, "A1").as_deref(), Some("x = 1\nx"));
}
#[test]
fn replace_leaves_python_code_that_would_no_longer_fit() {
let mut s = s();
let long = "a".repeat(gridcraft_model::python::MAX_FORMULA_CHARS / 2);
plant(&mut s, 0, "A1", &long);
let r = s.execute("edit.replace", json!({"what": "a", "with": "bb"})).unwrap();
assert_eq!(r["replaced"], 0);
assert_eq!(r["skipped"], 1);
assert_eq!(code(&s, 0, "A1").as_deref(), Some(long.as_str()));
}