Files
gridcraft/crates/engine/src/tests.rs
T
Fabio LussiandClaude Opus 5.5 ded67f51d2 fix(fill): clamp month and year date series to the month's last day
Fill Series by month used the previous cell's day as an overflowing DATE()
day, so Jan 31 + 1 month became Mar 3 and February was skipped. The same
overflow affected year steps from Feb 29 and the autofill month pattern.

Each item is now computed from the start date plus n months and the day is
clamped to the target month's length (Jan 31, Feb 28, Mar 31, Apr 30), so
the clamp does not compound. Leap years are handled.

Fixes #132

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:28:07 +02:00

243 lines
9.8 KiB
Rust

use gridcraft_core::{CellRef, Value};
use serde_json::json;
use crate::Session;
fn s() -> Session {
let mut s = Session::new();
s.new_workbook();
s
}
fn v(s: &Session, a: &str) -> Value {
s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap())
}
#[test]
fn enter_values_and_formulas() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "10"})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "input": "32"})).unwrap();
s.execute("cell.set", json!({"cell": "A3", "input": "=sum(a1:a2"})).unwrap();
assert_eq!(v(&s, "A3"), Value::Number(42.0));
assert!(s.execute("cell.set", json!({"cell": "A4", "input": "=1+*2"})).is_err());
s.execute("cell.set", json!({"cell": "B1", "input": "12%"})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::parse("B1").unwrap()), "12%");
}
#[test]
fn undo_redo() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(1.0));
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(2.0));
assert!(s.doc().unwrap().is_dirty());
}
#[test]
fn copy_paste_shifts_formulas() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "=A1+B1"})).unwrap();
s.execute("edit.copy", json!({"range": "C1"})).unwrap();
s.execute("selection.set", json!({"range": "C2"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(v(&s, "C2"), Value::Number(7.0));
s.execute("edit.cut", json!({"range": "A1"})).unwrap();
s.execute("selection.set", json!({"range": "E5"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(v(&s, "E5"), Value::Number(1.0));
let f = s.execute("cell.get", json!({"cell": "C1"})).unwrap();
assert_eq!(f["formula"], "=E5+B1");
}
#[test]
fn insert_delete_rows_adjust() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A1:A3)"})).unwrap();
s.execute("home.insertRows", json!({"rows": "2:2"})).unwrap();
assert_eq!(s.execute("cell.get", json!({"cell": "B1"})).unwrap()["formula"], "=SUM(A1:A4)");
s.execute("cell.set", json!({"cell": "A2", "input": "10"})).unwrap();
assert_eq!(v(&s, "B1"), Value::Number(16.0));
s.execute("home.deleteRows", json!({"rows": "1:1"})).unwrap();
assert_eq!(v(&s, "B1"), Value::Empty);
assert_eq!(v(&s, "A1"), Value::Number(10.0));
}
#[test]
fn fill_series() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [3]]})).unwrap();
s.execute("edit.autoFill", json!({"source": "A1:A2", "target": "A1:A6"})).unwrap();
assert_eq!(v(&s, "A6"), Value::Number(11.0));
s.execute("cell.set", json!({"cell": "B1", "input": "Mon"})).unwrap();
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3"})).unwrap();
assert_eq!(v(&s, "B3"), Value::from("Wed"));
s.execute("cell.set", json!({"cell": "C1", "input": "Item 9"})).unwrap();
s.execute("edit.autoFill", json!({"source": "C1", "target": "C1:C2"})).unwrap();
assert_eq!(v(&s, "C2"), Value::from("Item 10"));
}
#[test]
fn sort_and_filter() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["b", 2], ["c", 3], ["a", 1]]})).unwrap();
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
s.execute("data.sortDescending", json!({})).unwrap();
assert_eq!(v(&s, "A2"), Value::from("c"));
assert_eq!(v(&s, "A1"), Value::from("Name"));
s.execute("data.filter", json!({})).unwrap();
let r = s.execute("data.filterBy", json!({"column": "B", "custom": {"op": ">", "value": "1"}})).unwrap();
assert_eq!(r["hiddenRows"], 1);
}
#[test]
fn formatting_and_styles() {
let mut s = s();
s.execute("selection.set", json!({"range": "A1:B2"})).unwrap();
s.execute("home.bold", json!({})).unwrap();
s.execute("home.fillColor", json!({"color": "#FFFF00"})).unwrap();
s.execute("home.mergeCenter", json!({})).unwrap();
let c = s.execute("cell.get", json!({"cell": "B2"})).unwrap();
assert_eq!(c["style"]["font"]["bold"], true);
assert_eq!(c["merge"], "A1:B2");
s.execute("home.cellStyle", json!({"range": "D1", "name": "Good"})).unwrap();
}
#[test]
fn tables_and_structured_refs() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Qty"], ["x", 2], ["y", 5]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B3"})).unwrap();
s.execute("cell.set", json!({"cell": "D1", "input": "=SUM(Table1[Qty])"})).unwrap();
assert_eq!(v(&s, "D1"), Value::Number(7.0));
s.execute("table.totalRow", json!({"on": true, "table": "Table1"})).unwrap();
assert_eq!(v(&s, "B4"), Value::Number(7.0));
}
#[test]
fn sheets() {
let mut s = s();
s.execute("home.insertSheet", json!({})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
s.execute("sheet.rename", json!({"name": "Data"})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": "=Data!A1*2"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(10.0));
s.execute("sheet.rename", json!({"sheet": "Data", "name": "My Data"})).unwrap();
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["formula"], "='My Data'!A1*2");
}
#[test]
fn xlsx_roundtrip_through_engine() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["a", 1], ["b", 2]]})).unwrap();
s.execute("cell.set", json!({"cell": "B3", "input": "=SUM(B1:B2)"})).unwrap();
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
let b64 = r["base64"].as_str().unwrap().to_string();
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
assert_eq!(v(&s, "B3"), Value::Number(3.0));
}
#[test]
fn samples_build() {
for (name, _) in crate::sample::SAMPLES {
let wb = crate::sample::build(name).unwrap_or_else(|| panic!("sample {name}"));
assert!(!wb.sheets[0].cells.is_empty());
}
}
#[test]
fn every_command_survives_empty_params() {
let mut s = Session::new();
s.execute("file.new", json!({"sample": "sales"})).unwrap();
for spec in crate::command_specs() {
if matches!(spec.id, "file.close" | "file.open" | "file.save" | "file.saveAs" | "file.exportCsv" | "file.exportHtml" | "insert.picture") {
continue;
}
let _ = s.execute(spec.id, json!({}));
let _ = s.execute(spec.id, json!([1, 2]));
let _ = s.execute(spec.id, json!({"range": "ZZZ999999999", "cell": "", "sheet": 99}));
}
}
#[test]
fn parity_counts() {
let (done, total) = crate::catalog::parity();
assert!(total > 250);
assert!(done > 150, "parity {done}/{total}");
}
#[test]
fn ink_strokes_and_ink_to_shape() {
let mut s = s();
// A rough closed box.
let pts: Vec<[f64; 2]> = vec![[100.0, 100.0], [200.0, 102.0], [201.0, 180.0], [99.0, 181.0], [101.0, 104.0]];
s.execute("draw.stroke", json!({"points": pts})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert_eq!(sh.shapes.len(), 1);
assert_eq!(sh.shapes[0].kind, gridcraft_model::ShapeKind::Ink);
let r = s.execute("draw.inkToShape", json!({})).unwrap();
assert_eq!(r["kind"], "Rectangle");
// An open stroke becomes a line.
s.execute("draw.stroke", json!({"points": [[0.0, 0.0], [50.0, 10.0], [120.0, 30.0]]})).unwrap();
assert_eq!(s.execute("draw.inkToShape", json!({})).unwrap()["kind"], "Line");
assert!(s.execute("draw.stroke", json!({"points": [[1.0, 1.0]]})).is_err());
}
fn ymd(y: i64, m: i64, d: i64) -> Value {
Value::Number(gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, y, m, d).unwrap())
}
fn series_date(seed: &str, range: &str, unit: &str) -> Session {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": seed})).unwrap();
s.execute("edit.fillSeries", json!({"range": range, "direction": "columns", "type": "date", "dateUnit": unit})).unwrap();
s
}
#[test]
fn fill_series_month_clamps_to_month_end() {
let s = series_date("2026-01-31", "A1:A4", "month");
assert_eq!(v(&s, "A1"), ymd(2026, 1, 31));
assert_eq!(v(&s, "A2"), ymd(2026, 2, 28));
assert_eq!(v(&s, "A3"), ymd(2026, 3, 31));
assert_eq!(v(&s, "A4"), ymd(2026, 4, 30));
// Mid-month seeds are untouched.
let s = series_date("2026-01-15", "A1:A4", "month");
assert_eq!(v(&s, "A4"), ymd(2026, 4, 15));
}
#[test]
fn fill_series_month_leap_year() {
let s = series_date("2028-01-31", "A1:A3", "month");
assert_eq!(v(&s, "A2"), ymd(2028, 2, 29));
assert_eq!(v(&s, "A3"), ymd(2028, 3, 31));
let s = series_date("1996-01-31", "A1:A3", "month");
assert_eq!(v(&s, "A2"), ymd(1996, 2, 29));
}
#[test]
fn fill_series_year_clamps_leap_day() {
let s = series_date("2028-02-29", "A1:A5", "year");
assert_eq!(v(&s, "A2"), ymd(2029, 2, 28));
assert_eq!(v(&s, "A5"), ymd(2032, 2, 29));
}
#[test]
fn autofill_month_pattern_clamps_to_month_end() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["2026-01-31"], ["2026-03-31"]]})).unwrap();
s.execute("edit.autoFill", json!({"source": "A1:A2", "target": "A1:A6"})).unwrap();
assert_eq!(v(&s, "A3"), ymd(2026, 5, 31));
assert_eq!(v(&s, "A4"), ymd(2026, 7, 31));
assert_eq!(v(&s, "A5"), ymd(2026, 9, 30));
assert_eq!(v(&s, "A6"), ymd(2026, 11, 30));
}