//! 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 { 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> = 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())); }