mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-25 03:16:50 -06:00
Squash of PR #548 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
7.3 KiB
Rust
190 lines
7.3 KiB
Rust
//! Table formulas (#534): `=Sum(A2:A4)`, Average, Count, cell references and arithmetic;
|
|
//! recalculated on edit, row/column insertion and REGEN; cycles and bad input show an error text
|
|
//! and never panic. TABLEDIT and TABLEEXPORT.
|
|
|
|
use cadcraft_engine::Session;
|
|
use cadcraft_engine::doc::{EntityKind, Table};
|
|
use serde_json::{Value, json};
|
|
|
|
/// A 2-column table without title/header: A1..A3 = 1, 2, 3; B column empty.
|
|
fn session() -> (Session, String) {
|
|
let mut s = Session::new();
|
|
let r = s
|
|
.execute("table", &json!({ "at": [0, 0], "cells": [["1", ""], ["2", ""], ["3", ""], ["", ""]], "rowHeight": 1.0, "colWidth": 3.0 }))
|
|
.unwrap();
|
|
let h = r["handle"].as_str().unwrap().to_string();
|
|
(s, h)
|
|
}
|
|
|
|
fn table(s: &Session, h: &str) -> Table {
|
|
let h = cadcraft_engine::doc::Handle::parse_hex(h).unwrap();
|
|
match &s.doc().unwrap().entity(h).unwrap().kind {
|
|
EntityKind::Table(t) => t.clone(),
|
|
_ => panic!("not a table"),
|
|
}
|
|
}
|
|
|
|
fn text(s: &Session, h: &str, r: usize, c: usize) -> String {
|
|
table(s, h).cells[r][c].text.clone()
|
|
}
|
|
|
|
fn set(s: &mut Session, h: &str, r: usize, c: usize, text: &str) {
|
|
s.execute("table.set", &json!({ "handle": h, "row": r, "col": c, "text": text })).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn sum_average_count_and_arithmetic() {
|
|
let (mut s, h) = session();
|
|
set(&mut s, &h, 3, 0, "=Sum(A1:A3)");
|
|
assert_eq!(text(&s, &h, 3, 0), "6");
|
|
assert_eq!(table(&s, &h).cells[3][0].formula.as_deref(), Some("=Sum(A1:A3)"));
|
|
set(&mut s, &h, 0, 1, "=Average(A1:A3)");
|
|
set(&mut s, &h, 1, 1, "=count(a1:a3, B1)");
|
|
set(&mut s, &h, 2, 1, "=(A1+A2)*A3^2/-2");
|
|
assert_eq!(text(&s, &h, 0, 1), "2");
|
|
assert_eq!(text(&s, &h, 1, 1), "4");
|
|
assert_eq!(text(&s, &h, 2, 1), "-13.5");
|
|
// Editing a referenced cell recalculates everything that depends on it.
|
|
set(&mut s, &h, 0, 0, "10");
|
|
assert_eq!(text(&s, &h, 3, 0), "15");
|
|
assert_eq!(text(&s, &h, 1, 1), "4");
|
|
// Plain text replaces the formula.
|
|
set(&mut s, &h, 3, 0, "total");
|
|
assert_eq!(table(&s, &h).cells[3][0].formula, None);
|
|
}
|
|
|
|
#[test]
|
|
fn errors_show_as_text() {
|
|
let (mut s, h) = session();
|
|
// A cycle: B1 -> B2 -> B1.
|
|
set(&mut s, &h, 0, 1, "=B2+1");
|
|
set(&mut s, &h, 1, 1, "=B1*2");
|
|
assert_eq!(text(&s, &h, 0, 1), "#CYCLE");
|
|
assert_eq!(text(&s, &h, 1, 1), "#CYCLE");
|
|
set(&mut s, &h, 2, 1, "=A1/(A2-2)");
|
|
assert_eq!(text(&s, &h, 2, 1), "#DIV/0");
|
|
set(&mut s, &h, 3, 1, "=Z99");
|
|
assert_eq!(text(&s, &h, 3, 1), "#REF");
|
|
// A formula that can't be read is refused at once.
|
|
assert!(s.execute("table.set", &json!({ "handle": h, "row": 0, "col": 0, "text": "=Sum(A1:" })).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn inserting_and_deleting_rows_moves_references() {
|
|
let (mut s, h) = session();
|
|
set(&mut s, &h, 3, 0, "=Sum(A1:A3)");
|
|
s.execute("table.insertrow", &json!({ "handle": h, "row": 1 })).unwrap();
|
|
set(&mut s, &h, 1, 0, "4");
|
|
let t = table(&s, &h);
|
|
assert_eq!(t.cells[4][0].formula.as_deref(), Some("=Sum(A1:A4)"));
|
|
assert_eq!(t.cells[4][0].text, "10");
|
|
s.execute("table.deleterow", &json!({ "handle": h, "row": 0 })).unwrap();
|
|
let t = table(&s, &h);
|
|
assert_eq!(t.cells[3][0].formula.as_deref(), Some("=Sum(A1:A3)"));
|
|
assert_eq!(t.cells[3][0].text, "9");
|
|
}
|
|
|
|
#[test]
|
|
fn insert_formula_options() {
|
|
let mut s = Session::new();
|
|
let r = s.execute("table", &json!({ "at": [0, 0], "title": "T", "header": ["Qty"], "cells": [["2"], ["5"], [""]] })).unwrap();
|
|
let h = r["handle"].as_str().unwrap().to_string();
|
|
// Default range: the data cells above.
|
|
let r = s.execute("table.formula", &json!({ "handle": h, "row": 4, "col": 0, "function": "sum" })).unwrap();
|
|
assert_eq!(r["formula"], "=Sum(A3:A4)");
|
|
assert_eq!(text(&s, &h, 4, 0), "7");
|
|
s.execute("table.formula", &json!({ "handle": h, "row": 4, "col": 0, "function": "equation", "expression": "A3*A4" })).unwrap();
|
|
assert_eq!(text(&s, &h, 4, 0), "10");
|
|
s.execute("table.formula", &json!({ "handle": h, "row": 4, "col": 0, "function": "cell", "cell": "A4" })).unwrap();
|
|
assert_eq!(text(&s, &h, 4, 0), "5");
|
|
assert!(s.execute("table.formula", &json!({ "handle": h, "row": 4, "col": 0, "function": "median" })).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn regen_recalculates() {
|
|
let (mut s, h) = session();
|
|
set(&mut s, &h, 3, 0, "=Sum(A1:A3)");
|
|
// Make the shown value stale, as a foreign file could.
|
|
let hh = cadcraft_engine::doc::Handle::parse_hex(&h).unwrap();
|
|
s.doc_mut()
|
|
.unwrap()
|
|
.modify_entity(hh, |e| {
|
|
if let EntityKind::Table(t) = &mut e.kind {
|
|
t.cells[3][0].text = "999".into();
|
|
}
|
|
})
|
|
.unwrap();
|
|
s.execute("regen", &Value::Null).unwrap();
|
|
assert_eq!(text(&s, &h, 3, 0), "6");
|
|
assert_eq!(s.execute("table.recalc", &json!({})).unwrap()["changed"], 0);
|
|
}
|
|
|
|
#[test]
|
|
fn tabledit_at_the_command_line() {
|
|
let (mut s, h) = session();
|
|
// Cell A4 spans y -3..-4.
|
|
s.cmdline("tabledit").unwrap();
|
|
s.cmdline("1,-3.5").unwrap();
|
|
s.cmdline("=Sum(A1:A3)").unwrap();
|
|
assert_eq!(text(&s, &h, 3, 0), "6");
|
|
// JSON form with a point.
|
|
s.execute("tabledit", &json!({ "at": [4, -0.5], "text": "hello" })).unwrap();
|
|
assert_eq!(text(&s, &h, 0, 1), "hello");
|
|
}
|
|
|
|
#[test]
|
|
fn tableexport_writes_csv() {
|
|
let (mut s, h) = session();
|
|
set(&mut s, &h, 3, 0, "=Sum(A1:A3)");
|
|
set(&mut s, &h, 0, 1, "a, \"b\"");
|
|
let r = s.execute("tableexport", &json!({ "handle": h })).unwrap();
|
|
assert_eq!(r["csv"], "1,\"a, \"\"b\"\"\"\r\n2,\r\n3,\r\n6,\r\n");
|
|
let dir = std::env::temp_dir().join(format!("cadcraft-tableexport-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("t.csv");
|
|
s.execute("tableexport", &json!({ "handle": h, "path": path.to_str().unwrap(), "separator": "tab" })).unwrap();
|
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "1\t\"a, \"\"b\"\"\"\r\n2\t\r\n3\t\r\n6\t\r\n");
|
|
// At the command line: select by point, then the file name.
|
|
let path2 = dir.join("u.csv");
|
|
s.cmdline("tableexport").unwrap();
|
|
s.cmdline("1,-0.5").unwrap();
|
|
s.cmdline(path2.to_str().unwrap()).unwrap();
|
|
assert!(std::fs::read_to_string(&path2).unwrap().starts_with("1,"));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_formulas_never_panic() {
|
|
let (mut s, h) = session();
|
|
let long = format!("={}", "A1+".repeat(2000));
|
|
let deep = format!("={}1{}", "(".repeat(5000), ")".repeat(5000));
|
|
let huge = "=Sum(A1:ZZZ9999999)".to_string();
|
|
for f in [
|
|
long.as_str(),
|
|
deep.as_str(),
|
|
huge.as_str(),
|
|
"=",
|
|
"==1",
|
|
"=1e999",
|
|
"=Round(1,1e300)",
|
|
"=A1:B2",
|
|
"=#REF+1",
|
|
"=Sum()",
|
|
"=1/0",
|
|
"=-(-(-1))",
|
|
"=Abs(-2,3)",
|
|
] {
|
|
let _ = s.execute("table.set", &json!({ "handle": h, "row": 3, "col": 1, "text": f }));
|
|
let _ = s.execute("table.formula", &json!({ "handle": h, "row": 3, "col": 1, "function": "equation", "expression": f }));
|
|
}
|
|
// A long chain of formula cells (each adds one to the cell above) evaluates without recursion.
|
|
let n = 1500;
|
|
let r = s.execute("table", &json!({ "at": [100, 0], "cols": 1, "rows": n })).unwrap();
|
|
let h2 = r["handle"].as_str().unwrap().to_string();
|
|
set(&mut s, &h2, 0, 0, "1");
|
|
for i in (1..n).rev() {
|
|
set(&mut s, &h2, i, 0, &format!("=A{i}+1"));
|
|
}
|
|
assert_eq!(text(&s, &h2, n - 1, 0), n.to_string());
|
|
}
|