mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-09 12:00:01 -06:00
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.
299 lines
15 KiB
Rust
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()));
|
|
}
|