From 18db1cf0526111c277b811ed231b8b26893ed53a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher?= Date: Sat, 8 Nov 2025 13:41:33 +0100 Subject: [PATCH] FIX: Two small fixes to YEARFRAC * Takes abs value in between two dates * Follows ODFv1.2 part 2 section 4.11.7.7 --- base/src/functions/date_and_time.rs | 96 ++++++++++++++++++++++++--- base/src/test/test_date_and_time.rs | 2 - base/src/test/test_yearfrac_basis.rs | 4 +- xlsx/tests/calc_tests/YEARFRAC.xlsx | Bin 0 -> 10826 bytes 4 files changed, 87 insertions(+), 15 deletions(-) create mode 100644 xlsx/tests/calc_tests/YEARFRAC.xlsx diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index 1d8ceb2..7a1a532 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -8,6 +8,26 @@ use chrono::Timelike; const SECONDS_PER_DAY: i32 = 86_400; const SECONDS_PER_DAY_F64: f64 = SECONDS_PER_DAY as f64; +fn is_leap_year(year: i32) -> bool { + (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0) +} + +fn is_feb_29_between_dates(start: chrono::NaiveDate, end: chrono::NaiveDate) -> bool { + let start_year = start.year(); + let end_year = end.year(); + + for year in start_year..=end_year { + if is_leap_year(year) + && (year < end_year + || (year == end_year && end.month() > 2) + && (year > start_year || (year == start_year && start.month() <= 2))) + { + return true; + } + } + false +} + // --------------------------------------------------------------------------- // Helper macros to eliminate boilerplate in date/time component extraction // functions (DAY, MONTH, YEAR, HOUR, MINUTE, SECOND). @@ -1567,18 +1587,44 @@ impl Model { } } 1 => { - let year_days = if start_date.year() == end_date.year() { - if (start_date.year() % 4 == 0 && start_date.year() % 100 != 0) - || start_date.year() % 400 == 0 - { - 366.0 - } else { - 365.0 + // Procedure E + + let start_year = start_date.year(); + let end_year = end_date.year(); + + let step_a = start_year != end_year; + let step_b = start_year + 1 != end_year; + let step_c = start_date.month() < end_date.month(); + let step_d = start_date.month() == end_date.month(); + let step_e = start_date.day() <= end_date.day(); + let step_f = step_a && (step_b || step_c || (step_d && step_e)); + if step_f { + // 7. + // return average of days in year between start_year and end_year, inclusive + let mut total_days = 0; + for year in start_year..=end_year { + if is_leap_year(year) { + total_days += 366; + } else { + total_days += 365; + } } + days / (total_days as f64 / (end_year - start_year + 1) as f64) + } else if step_a && is_leap_year(start_year) { + // 8. + days / 366.0 + } else if is_feb_29_between_dates(start_date, end_date) { + // 9. If a February 29 occurs between date1 and date2 then return 366 + days / 366.0 + } else if end_date.month() == 2 && end_date.day() == 29 { + // 10. If date2 is February 29 then return 366 + days / 366.0 + } else if !step_a && is_leap_year(start_year) { + days / 366.0 } else { - 365.0 - }; - days / year_days + // 11. + days / 365.0 + } } 2 => days / 360.0, 3 => days / 365.0, @@ -1595,6 +1641,34 @@ impl Model { } _ => return CalcResult::new_error(Error::NUM, cell, "Invalid basis".to_string()), }; - CalcResult::Number(result) + CalcResult::Number(result.abs()) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn test_is_leap_year() { + assert!(is_leap_year(2000)); + assert!(!is_leap_year(1900)); + assert!(is_leap_year(2004)); + assert!(!is_leap_year(2001)); + } + + #[test] + fn test_is_feb_29_between_dates() { + let d1 = chrono::NaiveDate::from_ymd_opt(2020, 2, 28).unwrap(); + let d2 = chrono::NaiveDate::from_ymd_opt(2020, 3, 1).unwrap(); + assert!(is_feb_29_between_dates(d1, d2)); + } + + #[test] + fn test_is_feb_29_between_dates_false() { + let d1 = chrono::NaiveDate::from_ymd_opt(2021, 2, 28).unwrap(); + let d2 = chrono::NaiveDate::from_ymd_opt(2021, 3, 1).unwrap(); + assert!(!is_feb_29_between_dates(d1, d2)); } } diff --git a/base/src/test/test_date_and_time.rs b/base/src/test/test_date_and_time.rs index 5924c57..03a4c5e 100644 --- a/base/src/test/test_date_and_time.rs +++ b/base/src/test/test_date_and_time.rs @@ -542,7 +542,6 @@ fn test_yearfrac_function() { // Edge cases model._set("A4", "=YEARFRAC(44561,44561,1)"); // Same date = 0 - model._set("A5", "=YEARFRAC(44926,44561,1)"); // Reverse = negative model._set("A6", "=YEARFRAC(44197,44562,1)"); // Exact year (2021) // Error cases @@ -559,7 +558,6 @@ fn test_yearfrac_function() { // Edge cases assert_eq!(model._get_text("A4"), *"0"); // Same date - assert_eq!(model._get_text("A5"), *"-1"); // Negative assert_eq!(model._get_text("A6"), *"1"); // Exact year // Error cases diff --git a/base/src/test/test_yearfrac_basis.rs b/base/src/test/test_yearfrac_basis.rs index e236976..8ed78b2 100644 --- a/base/src/test/test_yearfrac_basis.rs +++ b/base/src/test/test_yearfrac_basis.rs @@ -26,8 +26,8 @@ fn test_yearfrac_basis_2_actual_360() { panic!("Expected numeric value in A2"); } - // Negative symmetric of A1 - assert_eq!(model._get_text("A3"), *"-1"); + // always positive A1 + assert_eq!(model._get_text("A3"), *"1"); } #[test] diff --git a/xlsx/tests/calc_tests/YEARFRAC.xlsx b/xlsx/tests/calc_tests/YEARFRAC.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..6c104b10f172b76a60b9fd6309d45581d39a6449 GIT binary patch literal 10826 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