diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index 49ac144..d1c0ca5 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -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 { + 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; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3705f71..ab90d8d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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)); +}