Merge pull request #160 from itrycodinganditsbad/fix-month-fill-clamp

fill: clamp month and year date series to the month's last day

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
Brandon Thomas 2026-10-10 15:47:32 -04:00
commit b07249de75
2 changed files with 66 additions and 6 deletions

View File

@ -3,7 +3,7 @@
use std::sync::Arc;
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, serial_from_ymd};
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula};
@ -264,15 +264,25 @@ impl Pattern {
}
Pattern::Months { last, first, months } => {
let base = if forward { *last } else { *first };
let d = datetime_from_serial(gridcraft_core::DateSystem::D1900, base)?;
let m = d.month as i64 + months * k * sign;
let s = serial_from_ymd(gridcraft_core::DateSystem::D1900, d.year as i64, m, d.day as i64)?;
let s = add_months_clamped(gridcraft_core::DateSystem::D1900, base, months.checked_mul(k * sign)?)?;
Some(Value::number(s))
}
}
}
}
/// `serial` shifted by `months` calendar months, keeping the day of month but clamping it to the
/// target month's last day (Jan 31 + 1 month = Feb 28/29). Always measured from the original date,
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30.
fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option<f64> {
let d = datetime_from_serial(sys, serial)?;
let total = (d.year as i64).checked_mul(12)?.checked_add(d.month as i64 - 1)?.checked_add(months)?;
let year = total.div_euclid(12);
let month = total.rem_euclid(12) + 1;
let last = days_in_month(i32::try_from(year).ok()?, month as u32);
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64))
}
fn round15(x: f64) -> f64 {
if x == 0.0 || !x.is_finite() {
return x;
@ -316,8 +326,8 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
"date" => {
let d = datetime_from_serial(sys, v);
match (unit, d) {
("month", Some(d)) => serial_from_ymd(sys, d.year as i64, d.month as i64 + step as i64, d.day as i64).unwrap_or(v),
("year", Some(d)) => serial_from_ymd(sys, d.year as i64 + step as i64, d.month as i64, d.day as i64).unwrap_or(v),
("month", Some(_)) => (step as i64).checked_mul(i as i64).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
("year", Some(_)) => (step as i64).checked_mul(i as i64 * 12).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
("weekday", Some(_)) => {
let mut n = v;
let mut left = step.abs() as i64;

View File

@ -1555,3 +1555,53 @@ fn cell_picture_cut_uses_live_source_after_edit() {
}
}
}
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));
}