diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 80f194360..8d3f8ddd5 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -575,6 +575,16 @@ fn args_signature_xnpv(arg_count: usize) -> Vec { } } +fn args_signature_rank(arg_count: usize) -> Vec { + if arg_count == 2 { + vec![Signature::Scalar, Signature::Vector] + } else if arg_count == 3 { + vec![Signature::Scalar, Signature::Vector, Signature::Scalar] + } else { + vec![Signature::Error; arg_count] + } +} + // FIXME: This is terrible duplications of efforts. We use the signature in at least three different places: // 1. When computing the function // 2. Checking the arguments to see if we need to insert the implicit intersection operator @@ -690,10 +700,17 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec vec![Signature::Vector; arg_count], Function::Minifs => vec![Signature::Vector; arg_count], Function::Date => args_signature_scalars(arg_count, 3, 0), + Function::Datedif => args_signature_scalars(arg_count, 3, 0), + Function::Datevalue => args_signature_scalars(arg_count, 1, 0), Function::Day => args_signature_scalars(arg_count, 1, 0), Function::Edate => args_signature_scalars(arg_count, 2, 0), Function::Eomonth => args_signature_scalars(arg_count, 2, 0), Function::Month => args_signature_scalars(arg_count, 1, 0), + Function::Time => args_signature_scalars(arg_count, 3, 0), + Function::Timevalue => args_signature_scalars(arg_count, 1, 0), + Function::Hour => args_signature_scalars(arg_count, 1, 0), + Function::Minute => args_signature_scalars(arg_count, 1, 0), + Function::Second => args_signature_scalars(arg_count, 1, 0), Function::Now => args_signature_no_args(arg_count), Function::Today => args_signature_no_args(arg_count), Function::Year => args_signature_scalars(arg_count, 1, 0), @@ -785,6 +802,24 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 1, 0), Function::Unicode => args_signature_scalars(arg_count, 1, 0), Function::Geomean => vec![Signature::Vector; arg_count], + Function::Quartile | Function::QuartileExc | Function::QuartileInc => { + if arg_count == 2 { + vec![Signature::Vector, Signature::Scalar] + } else { + vec![Signature::Error; arg_count] + } + } + Function::Rank | Function::RankAvg | Function::RankEq => args_signature_rank(arg_count), + Function::Days => args_signature_scalars(arg_count, 2, 0), + Function::Days360 => args_signature_scalars(arg_count, 2, 1), + Function::Weekday => args_signature_scalars(arg_count, 1, 1), + Function::Weeknum => args_signature_scalars(arg_count, 1, 1), + Function::Isoweeknum => args_signature_scalars(arg_count, 1, 0), + Function::Workday => args_signature_scalars(arg_count, 2, 1), + Function::WorkdayIntl => args_signature_scalars(arg_count, 2, 2), + Function::Yearfrac => args_signature_scalars(arg_count, 2, 1), + Function::Networkdays => args_signature_scalars(arg_count, 2, 1), + Function::NetworkdaysIntl => args_signature_scalars(arg_count, 2, 2), } } @@ -896,9 +931,16 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Maxifs => not_implemented(args), Function::Minifs => not_implemented(args), Function::Date => not_implemented(args), + Function::Datedif => not_implemented(args), + Function::Datevalue => not_implemented(args), Function::Day => not_implemented(args), Function::Edate => not_implemented(args), Function::Month => not_implemented(args), + Function::Time => not_implemented(args), + Function::Timevalue => not_implemented(args), + Function::Hour => not_implemented(args), + Function::Minute => not_implemented(args), + Function::Second => not_implemented(args), Function::Now => not_implemented(args), Function::Today => not_implemented(args), Function::Year => not_implemented(args), @@ -990,5 +1032,17 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Eomonth => scalar_arguments(args), Function::Formulatext => not_implemented(args), Function::Geomean => not_implemented(args), + Function::Quartile | Function::QuartileExc | Function::QuartileInc => not_implemented(args), + Function::Rank | Function::RankAvg | Function::RankEq => scalar_arguments(args), + Function::Days => scalar_arguments(args), + Function::Days360 => scalar_arguments(args), + Function::Weekday => scalar_arguments(args), + Function::Weeknum => scalar_arguments(args), + Function::Isoweeknum => scalar_arguments(args), + Function::Workday => scalar_arguments(args), + Function::WorkdayIntl => scalar_arguments(args), + Function::Yearfrac => scalar_arguments(args), + Function::Networkdays => scalar_arguments(args), + Function::NetworkdaysIntl => scalar_arguments(args), } } diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index 8134b2166..af15ce67b 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -1,6 +1,8 @@ use chrono::DateTime; use chrono::Datelike; use chrono::Months; +use chrono::NaiveDateTime; +use chrono::NaiveTime; use chrono::Timelike; use crate::constants::MAXIMUM_DATE_SERIAL_NUMBER; @@ -14,17 +16,360 @@ use crate::{ expressions::token::Error, formatter::dates::from_excel_date, model::Model, }; +fn parse_day_simple(day_str: &str) -> Result { + let bytes_len = day_str.len(); + if bytes_len == 0 || bytes_len > 2 { + return Err("Not a valid day".to_string()); + } + match day_str.parse::() { + Ok(y) => Ok(y), + Err(_) => Err("Not a valid day".to_string()), + } +} + +fn parse_month_simple(month_str: &str) -> Result { + let bytes_len = month_str.len(); + if bytes_len == 0 { + return Err("Not a valid month".to_string()); + } + if bytes_len <= 2 { + // Numeric month representation. Ensure it is within the valid range 1-12. + return match month_str.parse::() { + Ok(m) if (1..=12).contains(&m) => Ok(m), + _ => Err("Not a valid month".to_string()), + }; + } + + // Textual month representations. + // Use standard 3-letter abbreviations (e.g. "Sep") but also accept the legacy "Sept". + let month_names_short = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + let month_names_long = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + if let Some(m) = month_names_short + .iter() + .position(|&r| r.eq_ignore_ascii_case(month_str)) + { + return Ok(m as u32 + 1); + } + // Special-case the non-standard abbreviation "Sept" so older inputs still work. + if month_str.eq_ignore_ascii_case("Sept") { + return Ok(9); + } + + if let Some(m) = month_names_long + .iter() + .position(|&r| r.eq_ignore_ascii_case(month_str)) + { + return Ok(m as u32 + 1); + } + Err("Not a valid month".to_string()) +} + +fn parse_year_simple(year_str: &str) -> Result { + let bytes_len = year_str.len(); + if bytes_len != 2 && bytes_len != 4 { + return Err("Not a valid year".to_string()); + } + let y = year_str + .parse::() + .map_err(|_| "Not a valid year".to_string())?; + if y < 30 && bytes_len == 2 { + Ok(2000 + y) + } else if y < 100 && bytes_len == 2 { + Ok(1900 + y) + } else { + Ok(y) + } +} + +fn parse_datevalue_text(value: &str) -> Result { + let separator = if value.contains('/') { + '/' + } else if value.contains('-') { + '-' + } else { + return Err("Not a valid date".to_string()); + }; + + let mut parts: Vec<&str> = value.split(separator).map(|s| s.trim()).collect(); + if parts.len() != 3 { + return Err("Not a valid date".to_string()); + } + + // Identify the year: prefer the one that is four-digit numeric, otherwise assume the third part. + let mut year_idx: usize = 2; + for (idx, p) in parts.iter().enumerate() { + if p.len() == 4 && p.chars().all(char::is_numeric) { + year_idx = idx; + break; + } + } + + let year_str = parts[year_idx]; + // Remove the year from the remaining vector to process day / month. + parts.remove(year_idx); + let part1 = parts[0]; + let part2 = parts[1]; + + // Helper closures + let is_numeric = |s: &str| s.chars().all(char::is_numeric); + + // Determine month and day. + let (month_str, day_str) = if !is_numeric(part1) { + // textual month in first + (part1, part2) + } else if !is_numeric(part2) { + // textual month in second + (part2, part1) + } else { + // Both numeric – apply disambiguation rules + let v1: u32 = part1.parse().unwrap_or(0); + let v2: u32 = part2.parse().unwrap_or(0); + match (v1 > 12, v2 > 12) { + (true, false) => (part2, part1), // first cannot be month + (false, true) => (part1, part2), // second cannot be month + _ => (part1, part2), // ambiguous -> assume MM/DD + } + }; + + let day = parse_day_simple(day_str)?; + let month = parse_month_simple(month_str)?; + let year = parse_year_simple(year_str)?; + + match date_to_serial_number(day, month, year) { + Ok(n) => Ok(n), + Err(_) => Err("Not a valid date".to_string()), + } +} + +fn parse_time_string(text: &str) -> Option { + let text = text.trim(); + + // First, try custom parsing for edge cases like "24:00:00", "23:60:00", "23:59:60" + // that need normalization to match Excel behavior + if let Some(time_fraction) = parse_time_with_normalization(text) { + return Some(time_fraction); + } + + // First, try manual parsing for simple "N PM" / "N AM" format + if let Some((hour_str, is_pm)) = parse_simple_am_pm(text) { + if let Ok(hour) = hour_str.parse::() { + if (1..=12).contains(&hour) { + let hour_24 = if is_pm { + if hour == 12 { + 12 + } else { + hour + 12 + } + } else if hour == 12 { + 0 + } else { + hour + }; + let time = NaiveTime::from_hms_opt(hour_24, 0, 0)?; + return Some(time.num_seconds_from_midnight() as f64 / 86_400.0); + } + } + } + + // Standard patterns + let patterns_time = ["%H:%M:%S", "%H:%M", "%I:%M %p", "%I %p", "%I:%M:%S %p"]; + for p in patterns_time { + if let Ok(t) = NaiveTime::parse_from_str(text, p) { + return Some(t.num_seconds_from_midnight() as f64 / 86_400.0); + } + } + + let patterns_dt = [ + // ISO formats + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M", + // Excel-style date formats with AM/PM + "%d-%b-%Y %I:%M:%S %p", // "22-Aug-2011 6:35:00 AM" + "%d-%b-%Y %I:%M %p", // "22-Aug-2011 6:35 AM" + "%d-%b-%Y %H:%M:%S", // "22-Aug-2011 06:35:00" + "%d-%b-%Y %H:%M", // "22-Aug-2011 06:35" + // US date formats with AM/PM + "%m/%d/%Y %I:%M:%S %p", // "8/22/2011 6:35:00 AM" + "%m/%d/%Y %I:%M %p", // "8/22/2011 6:35 AM" + "%m/%d/%Y %H:%M:%S", // "8/22/2011 06:35:00" + "%m/%d/%Y %H:%M", // "8/22/2011 06:35" + // European date formats with AM/PM + "%d/%m/%Y %I:%M:%S %p", // "22/8/2011 6:35:00 AM" + "%d/%m/%Y %I:%M %p", // "22/8/2011 6:35 AM" + "%d/%m/%Y %H:%M:%S", // "22/8/2011 06:35:00" + "%d/%m/%Y %H:%M", // "22/8/2011 06:35" + ]; + for p in patterns_dt { + if let Ok(dt) = NaiveDateTime::parse_from_str(text, p) { + return Some(dt.time().num_seconds_from_midnight() as f64 / 86_400.0); + } + } + if let Ok(dt) = DateTime::parse_from_rfc3339(text) { + return Some(dt.time().num_seconds_from_midnight() as f64 / 86_400.0); + } + None +} + +// Custom parser that handles time normalization like Excel does +fn parse_time_with_normalization(text: &str) -> Option { + // Try to parse H:M:S format with potential overflow values + let parts: Vec<&str> = text.split(':').collect(); + + if parts.len() == 3 { + // H:M:S format + if let (Ok(h), Ok(m), Ok(s)) = ( + parts[0].parse::(), + parts[1].parse::(), + parts[2].parse::(), + ) { + // Only normalize specific edge cases that Excel handles + // Don't normalize arbitrary large values like 25:00:00 + if should_normalize_time_components(h, m, s) { + return Some(normalize_time_components(h, m, s)); + } + } + } else if parts.len() == 2 { + // H:M format (assume seconds = 0) + if let (Ok(h), Ok(m)) = (parts[0].parse::(), parts[1].parse::()) { + // Only normalize specific edge cases + if should_normalize_time_components(h, m, 0) { + return Some(normalize_time_components(h, m, 0)); + } + } + } + + None +} + +// Normalize time components with overflow handling (like Excel) +fn normalize_time_components(hour: i32, minute: i32, second: i32) -> f64 { + // Convert everything to total seconds + let mut total_seconds = hour * 3600 + minute * 60 + second; + + // Handle negative values by wrapping around + if total_seconds < 0 { + total_seconds = total_seconds.rem_euclid(86400); + } + + // Normalize to within a day (0-86399 seconds) + total_seconds %= 86400; + + // Convert to fraction of a day + total_seconds as f64 / 86400.0 +} + +// Check if time components should be normalized (only specific Excel edge cases) +fn should_normalize_time_components(hour: i32, minute: i32, second: i32) -> bool { + // Only normalize these specific cases that Excel handles: + // 1. Hour 24 with valid minutes/seconds + // 2. Hour 23 with minute 60 (becomes 24:00) + // 3. Any time with second 60 that normalizes to exactly 24:00 + + if hour == 24 && (0..=59).contains(&minute) && (0..=59).contains(&second) { + return true; // 24:MM:SS -> normalize to next day + } + + if hour == 23 && minute == 60 && (0..=59).contains(&second) { + return true; // 23:60:SS -> normalize to 24:00:SS + } + + if (0..=23).contains(&hour) && (0..=59).contains(&minute) && second == 60 { + // Check if this normalizes to exactly 24:00:00 + let total_seconds = hour * 3600 + minute * 60 + second; + return total_seconds == 86400; // Exactly 24:00:00 + } + + false +} + +// Helper function to parse simple "N PM" / "N AM" formats +fn parse_simple_am_pm(text: &str) -> Option<(&str, bool)> { + if let Some(hour_part) = text.strip_suffix(" PM") { + if hour_part.chars().all(|c| c.is_ascii_digit()) { + return Some((hour_part, true)); + } + } else if let Some(hour_part) = text.strip_suffix(" AM") { + if hour_part.chars().all(|c| c.is_ascii_digit()) { + return Some((hour_part, false)); + } + } + None +} + impl Model { + fn get_date_serial( + &mut self, + node: &Node, + cell: CellReferenceIndex, + ) -> Result { + let result = self.evaluate_node_in_context(node, cell); + match result { + CalcResult::Number(f) => Ok(f.floor() as i64), + CalcResult::String(s) => match parse_datevalue_text(&s) { + Ok(n) => Ok(n as i64), + Err(_) => Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid date".to_string(), + }), + }, + CalcResult::Boolean(b) => { + if b { + Ok(1) + } else { + Ok(0) + } + } + error @ CalcResult::Error { .. } => Err(error), + CalcResult::Range { .. } => Err(CalcResult::Error { + error: Error::NIMPL, + origin: cell, + message: "Arrays not supported yet".to_string(), + }), + CalcResult::EmptyCell | CalcResult::EmptyArg => Ok(0), + CalcResult::Array(_) => Err(CalcResult::Error { + error: Error::NIMPL, + origin: cell, + message: "Arrays not supported yet".to_string(), + }), + } + } + pub(crate) fn fn_day(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let args_count = args.len(); if args_count != 1 { return CalcResult::new_args_number_error(cell); } - let serial_number = match self.get_number(&args[0], cell) { - Ok(c) => c.floor() as i64, - Err(s) => return s, + let value = match self.get_number(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, }; - let date = match from_excel_date(serial_number) { + let days = value.floor() as i32; + if !(MINIMUM_DATE_SERIAL_NUMBER..=MAXIMUM_DATE_SERIAL_NUMBER).contains(&days) { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }; + } + let date = match from_excel_date(days as i64) { Ok(date) => date, Err(_) => { return CalcResult::Error { @@ -34,8 +379,7 @@ impl Model { } } }; - let day = date.day() as f64; - CalcResult::Number(day) + CalcResult::Number(date.day() as f64) } pub(crate) fn fn_month(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { @@ -43,11 +387,19 @@ impl Model { if args_count != 1 { return CalcResult::new_args_number_error(cell); } - let serial_number = match self.get_number(&args[0], cell) { - Ok(c) => c.floor() as i64, - Err(s) => return s, + let value = match self.get_number(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, }; - let date = match from_excel_date(serial_number) { + let days = value.floor() as i32; + if !(MINIMUM_DATE_SERIAL_NUMBER..=MAXIMUM_DATE_SERIAL_NUMBER).contains(&days) { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }; + } + let date = match from_excel_date(days as i64) { Ok(date) => date, Err(_) => { return CalcResult::Error { @@ -57,30 +409,27 @@ impl Model { } } }; - let month = date.month() as f64; - CalcResult::Number(month) + CalcResult::Number(date.month() as f64) } - pub(crate) fn fn_eomonth(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + pub(crate) fn fn_year(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let args_count = args.len(); - if args_count != 2 { + if args_count != 1 { return CalcResult::new_args_number_error(cell); } - let serial_number = match self.get_number(&args[0], cell) { - Ok(c) => { - let t = c.floor() as i64; - if t < 0 { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Function EOMONTH parameter 1 value is negative. It should be positive or zero.".to_string(), - }; - } - t - } - Err(s) => return s, + let value = match self.get_number(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, }; - let date = match from_excel_date(serial_number) { + let days = value.floor() as i32; + if !(MINIMUM_DATE_SERIAL_NUMBER..=MAXIMUM_DATE_SERIAL_NUMBER).contains(&days) { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }; + } + let date = match from_excel_date(days as i64) { Ok(date) => date, Err(_) => { return CalcResult::Error { @@ -90,102 +439,58 @@ impl Model { } } }; - if serial_number > MAXIMUM_DATE_SERIAL_NUMBER as i64 { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Function DAY parameter 1 value is too large.".to_string(), - }; - } - - let months = match self.get_number_no_bools(&args[1], cell) { - Ok(c) => { - let t = c.trunc(); - t as i32 - } - Err(s) => return s, - }; - - let months_abs = months.unsigned_abs(); - - let native_date = if months > 0 { - date + Months::new(months_abs) - } else { - date - Months::new(months_abs) - }; - - // Instead of calculating the end of month we compute the first day of the following month - // and take one day. - let mut month = native_date.month() + 1; - let mut year = native_date.year(); - if month == 13 { - month = 1; - year += 1; - } - match date_to_serial_number(1, month, year) { - Ok(serial_number) => CalcResult::Number(serial_number as f64 - 1.0), - Err(message) => CalcResult::Error { - error: Error::NUM, - origin: cell, - message, - }, - } + CalcResult::Number(date.year() as f64) } - // year, month, day pub(crate) fn fn_date(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let args_count = args.len(); if args_count != 3 { return CalcResult::new_args_number_error(cell); } let year = match self.get_number(&args[0], cell) { - Ok(c) => { - let t = c.floor() as i32; - if t < 0 { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - }; - } - t - } - Err(s) => return s, + Ok(f) => f, + Err(e) => return e, }; let month = match self.get_number(&args[1], cell) { - Ok(c) => { - let t = c.floor(); - t as i32 - } - Err(s) => return s, + Ok(f) => f, + Err(e) => return e, }; let day = match self.get_number(&args[2], cell) { - Ok(c) => { - let t = c.floor(); - t as i32 - } - Err(s) => return s, + Ok(f) => f, + Err(e) => return e, }; - match permissive_date_to_serial_number(day, month, year) { - Ok(serial_number) => CalcResult::Number(serial_number as f64), - Err(message) => CalcResult::Error { + match permissive_date_to_serial_number(day as i32, month as i32, year as i32) { + Ok(n) => CalcResult::Number(n as f64), + Err(_) => CalcResult::Error { error: Error::NUM, origin: cell, - message, + message: "Out of range parameters for date".to_string(), }, } } - pub(crate) fn fn_year(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + pub(crate) fn fn_edate(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let args_count = args.len(); - if args_count != 1 { + if args_count != 2 { return CalcResult::new_args_number_error(cell); } - let serial_number = match self.get_number(&args[0], cell) { - Ok(c) => c.floor() as i64, - Err(s) => return s, + let start_date = match self.get_number(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + let months = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, }; - let date = match from_excel_date(serial_number) { + let start_days = start_date.floor() as i32; + if !(MINIMUM_DATE_SERIAL_NUMBER..=MAXIMUM_DATE_SERIAL_NUMBER).contains(&start_days) { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }; + } + let start_date = match from_excel_date(start_days as i64) { Ok(date) => date, Err(_) => { return CalcResult::Error { @@ -195,21 +500,38 @@ impl Model { } } }; - let year = date.year() as f64; - CalcResult::Number(year) + let months = months.floor() as i32; + let result_date = if months >= 0 { + start_date + Months::new(months as u32) + } else { + start_date - Months::new((-months) as u32) + }; + let serial_number = result_date.num_days_from_ce() - EXCEL_DATE_BASE; + CalcResult::Number(serial_number as f64) } - // date, months - pub(crate) fn fn_edate(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + pub(crate) fn fn_eomonth(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let args_count = args.len(); if args_count != 2 { return CalcResult::new_args_number_error(cell); } - let serial_number = match self.get_number(&args[0], cell) { - Ok(c) => c.floor() as i64, - Err(s) => return s, + let start_date = match self.get_number(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + let months = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, }; - let date = match from_excel_date(serial_number) { + let start_days = start_date.floor() as i32; + if !(MINIMUM_DATE_SERIAL_NUMBER..=MAXIMUM_DATE_SERIAL_NUMBER).contains(&start_days) { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }; + } + let start_date = match from_excel_date(start_days as i64) { Ok(date) => date, Err(_) => { return CalcResult::Error { @@ -219,92 +541,1000 @@ impl Model { } } }; - - let months = match self.get_number(&args[1], cell) { - Ok(c) => { - let t = c.trunc(); - t as i32 - } - Err(s) => return s, - }; - - let months_abs = months.unsigned_abs(); - - let native_date = if months > 0 { - date + Months::new(months_abs) + let months = months.floor() as i32; + let result_date = if months >= 0 { + start_date + Months::new(months as u32 + 1) } else { - date - Months::new(months_abs) + start_date - Months::new((-months) as u32 - 1) }; + let last_day_of_month = result_date.with_day(1).map_or_else( + || result_date - chrono::Duration::days(result_date.day() as i64 - 1), + |first_of_month| first_of_month - chrono::Duration::days(1), + ); + let serial_number = last_day_of_month.num_days_from_ce() - EXCEL_DATE_BASE; + CalcResult::Number(serial_number as f64) + } - let serial_number = native_date.num_days_from_ce() - EXCEL_DATE_BASE; - if serial_number < MINIMUM_DATE_SERIAL_NUMBER { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "EDATE out of bounds".to_string(), - }; + pub(crate) fn fn_now(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let args_count = args.len(); + if args_count != 0 { + return CalcResult::new_args_number_error(cell); } - CalcResult::Number(serial_number as f64) + let milliseconds = get_milliseconds_since_epoch(); + let days = (milliseconds as f64) / (24.0 * 60.0 * 60.0 * 1000.0); + let days_from_1900 = days + 25569.0; + + CalcResult::Number(days_from_1900) } pub(crate) fn fn_today(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let args_count = args.len(); if args_count != 0 { + return CalcResult::new_args_number_error(cell); + } + let milliseconds = get_milliseconds_since_epoch(); + let days = ((milliseconds as f64) / (24.0 * 60.0 * 60.0 * 1000.0)).floor(); + let days_from_1900 = days + 25569.0; + + CalcResult::Number(days_from_1900 as f64 + days.fract()) + } + + pub(crate) fn fn_datevalue(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 1 { + return CalcResult::new_args_number_error(cell); + } + match self.evaluate_node_in_context(&args[0], cell) { + CalcResult::String(s) => match parse_datevalue_text(&s) { + Ok(n) => CalcResult::Number(n as f64), + Err(_) => CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid date".to_string(), + }, + }, + CalcResult::Number(f) => CalcResult::Number(f.floor()), + CalcResult::Boolean(b) => { + if b { + CalcResult::Number(1.0) + } else { + CalcResult::Number(0.0) + } + } + err @ CalcResult::Error { .. } => err, + CalcResult::Range { .. } | CalcResult::Array(_) => CalcResult::Error { + error: Error::NIMPL, + origin: cell, + message: "Arrays not supported yet".to_string(), + }, + CalcResult::EmptyCell | CalcResult::EmptyArg => CalcResult::Number(0.0), + } + } + + pub(crate) fn fn_datedif(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 3 { + return CalcResult::new_args_number_error(cell); + } + + let start_serial = match self.get_date_serial(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + let end_serial = match self.get_date_serial(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + if end_serial < start_serial { return CalcResult::Error { - error: Error::ERROR, + error: Error::NUM, origin: cell, - message: "Wrong number of arguments".to_string(), + message: "Start date greater than end date".to_string(), }; } - // milliseconds since January 1, 1970 00:00:00 UTC. - let milliseconds = get_milliseconds_since_epoch(); - let seconds = milliseconds / 1000; - let local_time = match DateTime::from_timestamp(seconds, 0) { - Some(dt) => dt.with_timezone(&self.tz), - None => { + let start = match from_excel_date(start_serial) { + Ok(d) => d, + Err(_) => { return CalcResult::Error { - error: Error::ERROR, + error: Error::NUM, origin: cell, - message: "Invalid date".to_string(), + message: "Out of range parameters for date".to_string(), + } + } + }; + let end = match from_excel_date(end_serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + } + } + }; + + let unit = match self.get_string(&args[2], cell) { + Ok(s) => s.to_ascii_uppercase(), + Err(e) => return e, + }; + + let result = match unit.as_str() { + "Y" => { + let mut years = end.year() - start.year(); + if (end.month(), end.day()) < (start.month(), start.day()) { + years -= 1; + } + years as f64 + } + "M" => { + let mut months = + (end.year() - start.year()) * 12 + (end.month() as i32 - start.month() as i32); + if end.day() < start.day() { + months -= 1; + } + months as f64 + } + "D" => (end_serial - start_serial) as f64, + "YM" => { + let mut months = + (end.year() - start.year()) * 12 + (end.month() as i32 - start.month() as i32); + if end.day() < start.day() { + months -= 1; } + (months % 12).abs() as f64 + } + "YD" => { + // Helper to create a date or early-return with #NUM! if impossible + let make_date = |y: i32, m: u32, d: u32| -> Result { + match chrono::NaiveDate::from_ymd_opt(y, m, d) { + Some(dt) => Ok(dt), + None => Err(CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid date".to_string(), + }), + } + }; + + // Compute the last valid day of a given month/year. + let make_last_day_of_month = + |y: i32, m: u32| -> Result { + let (next_y, next_m) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + let first_next = make_date(next_y, next_m, 1)?; + let last_day = first_next - chrono::Duration::days(1); + make_date(y, m, last_day.day()) + }; + + // Attempt to build the anniversary date in the end year. + let mut start_adj = + match chrono::NaiveDate::from_ymd_opt(end.year(), start.month(), start.day()) { + Some(d) => d, + None => match make_last_day_of_month(end.year(), start.month()) { + Ok(d) => d, + Err(e) => return e, + }, + }; + + // If the adjusted date is after the end date, shift one year back. + if start_adj > end { + let shift_year = end.year() - 1; + start_adj = match chrono::NaiveDate::from_ymd_opt( + shift_year, + start.month(), + start.day(), + ) { + Some(d) => d, + None => match make_last_day_of_month(shift_year, start.month()) { + Ok(d) => d, + Err(e) => return e, + }, + }; + } + + (end - start_adj).num_days() as f64 + } + "MD" => { + let mut months = + (end.year() - start.year()) * 12 + (end.month() as i32 - start.month() as i32); + if end.day() < start.day() { + months -= 1; + } + let start_shifted = if months >= 0 { + start + Months::new(months as u32) + } else { + start - Months::new((-months) as u32) + }; + (end - start_shifted).num_days() as f64 + } + _ => { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid unit".to_string(), + }; } }; - // 693_594 is computed as: - // NaiveDate::from_ymd(1900, 1, 1).num_days_from_ce() - 2 - // The 2 days offset is because of Excel 1900 bug - let days_from_1900 = local_time.num_days_from_ce() - 693_594; + CalcResult::Number(result) + } - CalcResult::Number(days_from_1900 as f64) + pub(crate) fn fn_time(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 3 { + return CalcResult::new_args_number_error(cell); + } + let hour = match self.get_number(&args[0], cell) { + Ok(f) => f, + Err(e) => return e, + }; + let minute = match self.get_number(&args[1], cell) { + Ok(f) => f, + Err(e) => return e, + }; + let second = match self.get_number(&args[2], cell) { + Ok(f) => f, + Err(e) => return e, + }; + if hour < 0.0 || minute < 0.0 || second < 0.0 { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid time".to_string(), + }; + } + let total_seconds = hour.floor() * 3600.0 + minute.floor() * 60.0 + second.floor(); + let day_seconds = 24.0 * 3600.0; + let secs = total_seconds.rem_euclid(day_seconds); + CalcResult::Number(secs / day_seconds) } - pub(crate) fn fn_now(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - let args_count = args.len(); - if args_count != 0 { + pub(crate) fn fn_timevalue(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 1 { + return CalcResult::new_args_number_error(cell); + } + let text = match self.get_string(&args[0], cell) { + Ok(s) => s, + Err(e) => return e, + }; + match parse_time_string(&text) { + Some(value) => CalcResult::Number(value), + None => CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid time".to_string(), + }, + } + } + + pub(crate) fn fn_hour(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 1 { + return CalcResult::new_args_number_error(cell); + } + let value = match self.get_number(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + if value < 0.0 { return CalcResult::Error { - error: Error::ERROR, + error: Error::NUM, origin: cell, - message: "Wrong number of arguments".to_string(), + message: "Invalid time".to_string(), }; } - // milliseconds since January 1, 1970 00:00:00 UTC. - let milliseconds = get_milliseconds_since_epoch(); - let seconds = milliseconds / 1000; - let local_time = match DateTime::from_timestamp(seconds, 0) { - Some(dt) => dt.with_timezone(&self.tz), + let hours = (value.rem_euclid(1.0) * 24.0).floor(); + CalcResult::Number(hours) + } + + pub(crate) fn fn_minute(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 1 { + return CalcResult::new_args_number_error(cell); + } + let value = match self.get_number(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + if value < 0.0 { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid time".to_string(), + }; + } + let total_seconds = (value.rem_euclid(1.0) * 86400.0).floor(); + let minutes = ((total_seconds / 60.0) as i64 % 60) as f64; + CalcResult::Number(minutes) + } + + pub(crate) fn fn_second(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 1 { + return CalcResult::new_args_number_error(cell); + } + let value = match self.get_number(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + if value < 0.0 { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid time".to_string(), + }; + } + let total_seconds = (value.rem_euclid(1.0) * 86400.0).floor(); + let seconds = (total_seconds as i64 % 60) as f64; + CalcResult::Number(seconds) + } + + pub(crate) fn fn_days(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let (end_serial, _end_date) = match self.get_and_validate_date_serial(&args[0], cell) { + Ok((s, d)) => (s, d), + Err(e) => return e, + }; + let (start_serial, _start_date) = match self.get_and_validate_date_serial(&args[1], cell) { + Ok((s, d)) => (s, d), + Err(e) => return e, + }; + CalcResult::Number((end_serial - start_serial) as f64) + } + + pub(crate) fn fn_days360(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if !(2..=3).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let (_start_serial, start_date) = match self.get_and_validate_date_serial(&args[0], cell) { + Ok((s, d)) => (s, d), + Err(e) => return e, + }; + let (_end_serial, end_date) = match self.get_and_validate_date_serial(&args[1], cell) { + Ok((s, d)) => (s, d), + Err(e) => return e, + }; + let method = if args.len() == 3 { + match self.get_number(&args[2], cell) { + Ok(f) => f != 0.0, + Err(s) => return s, + } + } else { + false + }; + + fn last_day_feb(year: i32) -> u32 { + if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 { + 29 + } else { + 28 + } + } + let mut sd_day = start_date.day(); + let sd_month = start_date.month(); + let sd_year = start_date.year(); + let mut ed_day = end_date.day(); + let ed_month = end_date.month(); + let ed_year = end_date.year(); + + if method { + if sd_day == 31 { + sd_day = 30; + } + if ed_day == 31 { + ed_day = 30; + } + } else { + if (sd_month == 2 && sd_day == last_day_feb(sd_year)) || sd_day == 31 { + sd_day = 30; + } + if ed_month == 2 && ed_day == last_day_feb(ed_year) && sd_day == 30 { + ed_day = 30; + } + if ed_day == 31 && sd_day >= 30 { + ed_day = 30; + } + } + + let result = (ed_year - sd_year) * 360 + + (ed_month as i32 - sd_month as i32) * 30 + + (ed_day as i32 - sd_day as i32); + CalcResult::Number(result as f64) + } + + pub(crate) fn fn_weekday(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if !(1..=2).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let (_serial, date) = match self.get_and_validate_date_serial(&args[0], cell) { + Ok((s, d)) => (s, d), + Err(e) => return e, + }; + let return_type = if args.len() == 2 { + match self.get_number(&args[1], cell) { + Ok(f) => f as i32, + Err(s) => return s, + } + } else { + 1 + }; + let weekday = date.weekday(); + let num = match return_type { + 1 => weekday.num_days_from_sunday() + 1, + 2 => weekday.number_from_monday(), + 3 => (weekday.number_from_monday() - 1) % 7, // 0-based Monday start + 11..=17 => { + let start = (return_type - 11) as u32; // 0 for Monday + ((weekday.number_from_monday() + 7 - start) % 7) + 1 + } + 0 => { + return CalcResult::new_error(Error::VALUE, cell, "Invalid return_type".to_string()) + } + _ => return CalcResult::new_error(Error::NUM, cell, "Invalid return_type".to_string()), + }; + CalcResult::Number(num as f64) + } + + pub(crate) fn fn_weeknum(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if !(1..=2).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let (_serial, date) = match self.get_and_validate_date_serial(&args[0], cell) { + Ok((s, d)) => (s, d), + Err(e) => return e, + }; + let return_type = if args.len() == 2 { + match self.get_number(&args[1], cell) { + Ok(f) => f as i32, + Err(s) => return s, + } + } else { + 1 + }; + if return_type == 21 { + let w = date.iso_week().week(); + return CalcResult::Number(w as f64); + } + let start_offset = match return_type { + 1 => chrono::Weekday::Sun, + 2 | 11 => chrono::Weekday::Mon, + 12 => chrono::Weekday::Tue, + 13 => chrono::Weekday::Wed, + 14 => chrono::Weekday::Thu, + 15 => chrono::Weekday::Fri, + 16 => chrono::Weekday::Sat, + 17 => chrono::Weekday::Sun, + x if x <= 0 || x == 3 => { + return CalcResult::new_error(Error::VALUE, cell, "Invalid return_type".to_string()) + } + _ => return CalcResult::new_error(Error::NUM, cell, "Invalid return_type".to_string()), + }; + let mut first = match chrono::NaiveDate::from_ymd_opt(date.year(), 1, 1) { + Some(d) => d, None => { - return CalcResult::Error { - error: Error::ERROR, + return CalcResult::new_error( + Error::NUM, + cell, + "Out of range parameters for date".to_string(), + ); + } + }; + while first.weekday() != start_offset { + first -= chrono::Duration::days(1); + } + let week = (date - first).num_days() / 7 + 1; + CalcResult::Number(week as f64) + } + + pub(crate) fn fn_isoweeknum(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 1 { + return CalcResult::new_args_number_error(cell); + } + let (_serial, date) = match self.get_and_validate_date_serial(&args[0], cell) { + Ok((s, d)) => (s, d), + Err(e) => return e, + }; + CalcResult::Number(date.iso_week().week() as f64) + } + + fn is_weekend(day: chrono::Weekday, weekend_mask: &[bool; 7]) -> bool { + match day { + chrono::Weekday::Mon => weekend_mask[0], + chrono::Weekday::Tue => weekend_mask[1], + chrono::Weekday::Wed => weekend_mask[2], + chrono::Weekday::Thu => weekend_mask[3], + chrono::Weekday::Fri => weekend_mask[4], + chrono::Weekday::Sat => weekend_mask[5], + chrono::Weekday::Sun => weekend_mask[6], + } + } + + pub(crate) fn fn_workday(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if !(2..=3).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let (_start_serial, mut date) = match self.get_and_validate_date_serial(&args[0], cell) { + Ok((s, d)) => (s, d), + Err(e) => return e, + }; + let mut days = match self.get_number(&args[1], cell) { + Ok(f) => f as i32, + Err(s) => return s, + }; + let weekend = [false, false, false, false, false, true, true]; + let holidays = match self.process_date_array(args.get(2), cell) { + Ok(dates) => self.dates_to_holiday_set(dates), + Err(e) => return e, + }; + while days != 0 { + if days > 0 { + date += chrono::Duration::days(1); + if !Self::is_weekend(date.weekday(), &weekend) && !holidays.contains(&date) { + days -= 1; + } + } else { + date -= chrono::Duration::days(1); + if !Self::is_weekend(date.weekday(), &weekend) && !holidays.contains(&date) { + days += 1; + } + } + } + let serial = date.num_days_from_ce() - EXCEL_DATE_BASE; + CalcResult::Number(serial as f64) + } + + // Consolidated holiday/date array processing function + fn process_date_array( + &mut self, + arg_option: Option<&Node>, + cell: CellReferenceIndex, + ) -> Result, CalcResult> { + let mut values = Vec::new(); + + if let Some(arg) = arg_option { + match self.evaluate_node_in_context(arg, cell) { + CalcResult::Number(value) => { + let serial = value.floor() as i64; + if from_excel_date(serial).is_err() { + return Err(CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }); + } + values.push(serial); + } + CalcResult::Range { left, right } => { + if left.sheet != right.sheet { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + )); + } + for row in left.row..=right.row { + for column in left.column..=right.column { + match self.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + }) { + CalcResult::Number(value) => { + let serial = value.floor() as i64; + if from_excel_date(serial).is_err() { + return Err(CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }); + } + values.push(serial); + } + CalcResult::EmptyCell => { + // Empty cells are ignored in holiday lists + } + e @ CalcResult::Error { .. } => return Err(e), + _ => { + // Non-numeric values in holiday lists should cause VALUE error + return Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid holiday date".to_string(), + }); + } + } + } + } + } + CalcResult::Array(array) => { + for row in &array { + for value in row { + match value { + crate::expressions::parser::ArrayNode::Number(n) => { + let serial = n.floor() as i64; + if from_excel_date(serial).is_err() { + return Err(CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }); + } + values.push(serial); + } + _ => { + return Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid holiday date".to_string(), + }); + } + } + } + } + } + CalcResult::String(_) => { + // String holidays should cause VALUE error + return Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid holiday date".to_string(), + }); + } + CalcResult::EmptyCell | CalcResult::EmptyArg => {} + e @ CalcResult::Error { .. } => return Err(e), + _ => { + // Other non-numeric types should cause VALUE error + return Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid holiday date".to_string(), + }); + } + } + } + Ok(values) + } + + // Helper to convert Vec to HashSet for backward compatibility + fn dates_to_holiday_set( + &self, + dates: Vec, + ) -> std::collections::HashSet { + dates + .into_iter() + .filter_map(|serial| from_excel_date(serial).ok()) + .collect() + } + + // Consolidated date validation helper - eliminates 29+ duplicate patterns + fn validate_and_convert_date_serial( + &self, + serial: i64, + cell: CellReferenceIndex, + ) -> Result { + match from_excel_date(serial) { + Ok(date) => Ok(date), + Err(_) => Err(CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }), + } + } + + // Helper for common pattern: get number, floor to i64, validate as date + fn get_and_validate_date_serial( + &mut self, + arg: &Node, + cell: CellReferenceIndex, + ) -> Result<(i64, chrono::NaiveDate), CalcResult> { + let serial = match self.get_number(arg, cell) { + Ok(n) => n.floor() as i64, + Err(e) => return Err(e), + }; + let date = self.validate_and_convert_date_serial(serial, cell)?; + Ok((serial, date)) + } + + // Consolidated weekend pattern processing function + fn parse_weekend_pattern_unified( + &mut self, + node: Option<&Node>, + cell: CellReferenceIndex, + ) -> Result<[bool; 7], CalcResult> { + // Default: Saturday-Sunday weekend (pattern 1) + let mut weekend = [false, false, false, false, false, true, true]; + + if let Some(node_ref) = node { + match self.evaluate_node_in_context(node_ref, cell) { + CalcResult::Number(n) => { + let code = n.trunc() as i32; + if (n - n.trunc()).abs() > f64::EPSILON { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Invalid weekend".to_string(), + )); + } + weekend = match code { + 1 | 0 => [false, false, false, false, false, true, true], // Saturday-Sunday + 2 => [true, false, false, false, false, false, true], // Sunday-Monday + 3 => [true, true, false, false, false, false, false], // Monday-Tuesday + 4 => [false, true, true, false, false, false, false], // Tuesday-Wednesday + 5 => [false, false, true, true, false, false, false], // Wednesday-Thursday + 6 => [false, false, false, true, true, false, false], // Thursday-Friday + 7 => [false, false, false, false, true, true, false], // Friday-Saturday + 11 => [false, false, false, false, false, false, true], // Sunday only + 12 => [true, false, false, false, false, false, false], // Monday only + 13 => [false, true, false, false, false, false, false], // Tuesday only + 14 => [false, false, true, false, false, false, false], // Wednesday only + 15 => [false, false, false, true, false, false, false], // Thursday only + 16 => [false, false, false, false, true, false, false], // Friday only + 17 => [false, false, false, false, false, true, false], // Saturday only + _ => { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Invalid weekend".to_string(), + )) + } + }; + Ok(weekend) + } + CalcResult::String(s) => { + if s.len() != 7 { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Invalid weekend".to_string(), + )); + } + if !s.chars().all(|c| c == '0' || c == '1') { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Invalid weekend".to_string(), + )); + } + weekend = [false; 7]; + for (i, ch) in s.chars().enumerate() { + weekend[i] = ch == '1'; + } + Ok(weekend) + } + CalcResult::Boolean(_) => Err(CalcResult::new_error( + Error::VALUE, + cell, + "Invalid weekend".to_string(), + )), + e @ CalcResult::Error { .. } => Err(e), + CalcResult::Range { .. } => Err(CalcResult::Error { + error: Error::VALUE, origin: cell, - message: "Invalid date".to_string(), + message: "Invalid weekend".to_string(), + }), + CalcResult::EmptyCell | CalcResult::EmptyArg => Ok(weekend), + CalcResult::Array(_) => Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid weekend".to_string(), + }), + } + } else { + Ok(weekend) + } + } + + // Legacy wrapper for weekend_from_arg (used by WORKDAY.INTL) + fn weekend_from_arg( + &mut self, + arg: Option<&Node>, + cell: CellReferenceIndex, + ) -> Result<[bool; 7], CalcResult> { + self.parse_weekend_pattern_unified(arg, cell) + } + + pub(crate) fn fn_workday_intl( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if !(2..=4).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let (_start_serial, mut date) = match self.get_and_validate_date_serial(&args[0], cell) { + Ok((s, d)) => (s, d), + Err(e) => return e, + }; + let mut days = match self.get_number(&args[1], cell) { + Ok(f) => f as i32, + Err(s) => return s, + }; + let weekend_mask = match self.weekend_from_arg(args.get(2), cell) { + Ok(m) => m, + Err(e) => return e, + }; + let holidays = match self.process_date_array(args.get(3), cell) { + Ok(dates) => self.dates_to_holiday_set(dates), + Err(e) => return e, + }; + while days != 0 { + if days > 0 { + date += chrono::Duration::days(1); + if !Self::is_weekend(date.weekday(), &weekend_mask) && !holidays.contains(&date) { + days -= 1; + } + } else { + date -= chrono::Duration::days(1); + if !Self::is_weekend(date.weekday(), &weekend_mask) && !holidays.contains(&date) { + days += 1; + } + } + } + let serial = date.num_days_from_ce() - EXCEL_DATE_BASE; + CalcResult::Number(serial as f64) + } + + pub(crate) fn fn_yearfrac(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if !(2..=3).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let (_start_serial, start_date) = match self.get_and_validate_date_serial(&args[0], cell) { + Ok((s, d)) => (s, d), + Err(e) => return e, + }; + let (_end_serial, end_date) = match self.get_and_validate_date_serial(&args[1], cell) { + Ok((s, d)) => (s, d), + Err(e) => return e, + }; + let basis = if args.len() == 3 { + match self.get_number(&args[2], cell) { + Ok(f) => f as i32, + Err(s) => return s, + } + } else { + 0 + }; + let days = (end_date - start_date).num_days() as f64; + let result = match basis { + 0 => { + let d360 = self.fn_days360(args, cell); + if let CalcResult::Number(n) = d360 { + n / 360.0 + } else { + return d360; + } + } + 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 + } + } else { + 365.0 + }; + days / year_days + } + 2 => days / 360.0, + 3 => days / 365.0, + 4 => { + let d360 = self.fn_days360( + &[args[0].clone(), args[1].clone(), Node::NumberKind(1.0)], + cell, + ); + if let CalcResult::Number(n) = d360 { + n / 360.0 + } else { + return d360; } } + _ => return CalcResult::new_error(Error::NUM, cell, "Invalid basis".to_string()), }; - // 693_594 is computed as: - // NaiveDate::from_ymd(1900, 1, 1).num_days_from_ce() - 2 - // The 2 days offset is because of Excel 1900 bug - let days_from_1900 = local_time.num_days_from_ce() - 693_594; - let days = (local_time.num_seconds_from_midnight() as f64) / (60.0 * 60.0 * 24.0); + CalcResult::Number(result) + } - CalcResult::Number(days_from_1900 as f64 + days.fract()) + pub(crate) fn fn_networkdays(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if !(2..=3).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let start_serial = match self.get_number(&args[0], cell) { + Ok(n) => n.floor() as i64, + Err(e) => return e, + }; + let end_serial = match self.get_number(&args[1], cell) { + Ok(n) => n.floor() as i64, + Err(e) => return e, + }; + let mut holidays: std::collections::HashSet = std::collections::HashSet::new(); + if args.len() == 3 { + let values = match self.process_date_array(Some(&args[2]), cell) { + Ok(v) => v, + Err(e) => return e, + }; + for v in values { + holidays.insert(v); + } + } + + let (from, to, sign) = if start_serial <= end_serial { + (start_serial, end_serial, 1.0) + } else { + (end_serial, start_serial, -1.0) + }; + let mut count = 0i64; + for serial in from..=to { + let date = match from_excel_date(serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + } + } + }; + let weekday = date.weekday().number_from_monday(); + let is_weekend = matches!(weekday, 6 | 7); + if !is_weekend && !holidays.contains(&serial) { + count += 1; + } + } + CalcResult::Number(count as f64 * sign) + } + + pub(crate) fn fn_networkdays_intl( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if !(2..=4).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let start_serial = match self.get_number(&args[0], cell) { + Ok(n) => n.floor() as i64, + Err(e) => return e, + }; + let end_serial = match self.get_number(&args[1], cell) { + Ok(n) => n.floor() as i64, + Err(e) => return e, + }; + + let weekend_pattern = match self.parse_weekend_pattern_unified(args.get(2), cell) { + Ok(p) => p, + Err(e) => return e, + }; + + let mut holidays: std::collections::HashSet = std::collections::HashSet::new(); + if args.len() == 4 { + let values = match self.process_date_array(Some(&args[3]), cell) { + Ok(v) => v, + Err(e) => return e, + }; + for v in values { + holidays.insert(v); + } + } + + let (from, to, sign) = if start_serial <= end_serial { + (start_serial, end_serial, 1.0) + } else { + (end_serial, start_serial, -1.0) + }; + let mut count = 0i64; + for serial in from..=to { + let date = match from_excel_date(serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + } + } + }; + let weekday = date.weekday().number_from_monday() as usize - 1; + if !weekend_pattern[weekday] && !holidays.contains(&serial) { + count += 1; + } + } + CalcResult::Number(count as f64 * sign) } } diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 21c8f72da..cb1e4cc12 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -145,16 +145,39 @@ pub enum Function { Maxifs, Minifs, Geomean, + Quartile, + QuartileExc, + QuartileInc, + Rank, + RankAvg, + RankEq, // Date and time Date, + Datedif, + Datevalue, Day, Edate, Eomonth, Month, + Time, + Timevalue, + Hour, + Minute, + Second, Now, Today, Year, + Days, + Days360, + Weekday, + Weeknum, + Isoweeknum, + Workday, + WorkdayIntl, + Yearfrac, + Networkdays, + NetworkdaysIntl, // Financial Cumipmt, @@ -253,7 +276,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -357,14 +380,37 @@ impl Function { Function::Maxifs, Function::Minifs, Function::Geomean, + Function::Quartile, + Function::QuartileExc, + Function::QuartileInc, + Function::Rank, + Function::RankAvg, + Function::RankEq, Function::Year, Function::Day, Function::Month, Function::Eomonth, Function::Date, + Function::Datedif, + Function::Datevalue, Function::Edate, + Function::Time, + Function::Timevalue, + Function::Hour, + Function::Minute, + Function::Second, Function::Today, Function::Now, + Function::Days, + Function::Days360, + Function::Weekday, + Function::Weeknum, + Function::Isoweeknum, + Function::Workday, + Function::WorkdayIntl, + Function::Yearfrac, + Function::Networkdays, + Function::NetworkdaysIntl, Function::Pmt, Function::Pv, Function::Rate, @@ -625,15 +671,38 @@ impl Function { "MAXIFS" | "_XLFN.MAXIFS" => Some(Function::Maxifs), "MINIFS" | "_XLFN.MINIFS" => Some(Function::Minifs), "GEOMEAN" => Some(Function::Geomean), + "QUARTILE" => Some(Function::Quartile), + "QUARTILE.EXC" => Some(Function::QuartileExc), + "QUARTILE.INC" => Some(Function::QuartileInc), + "RANK" => Some(Function::Rank), + "RANK.AVG" => Some(Function::RankAvg), + "RANK.EQ" => Some(Function::RankEq), // Date and Time "YEAR" => Some(Function::Year), "DAY" => Some(Function::Day), "EOMONTH" => Some(Function::Eomonth), "MONTH" => Some(Function::Month), "DATE" => Some(Function::Date), + "DATEDIF" => Some(Function::Datedif), + "DATEVALUE" => Some(Function::Datevalue), "EDATE" => Some(Function::Edate), + "TIME" => Some(Function::Time), + "TIMEVALUE" => Some(Function::Timevalue), + "HOUR" => Some(Function::Hour), + "MINUTE" => Some(Function::Minute), + "SECOND" => Some(Function::Second), "TODAY" => Some(Function::Today), "NOW" => Some(Function::Now), + "DAYS" => Some(Function::Days), + "DAYS360" => Some(Function::Days360), + "WEEKDAY" => Some(Function::Weekday), + "WEEKNUM" => Some(Function::Weeknum), + "ISOWEEKNUM" => Some(Function::Isoweeknum), + "WORKDAY" => Some(Function::Workday), + "WORKDAY.INTL" => Some(Function::WorkdayIntl), + "YEARFRAC" => Some(Function::Yearfrac), + "NETWORKDAYS" => Some(Function::Networkdays), + "NETWORKDAYS.INTL" => Some(Function::NetworkdaysIntl), // Financial "PMT" => Some(Function::Pmt), "PV" => Some(Function::Pv), @@ -836,14 +905,37 @@ impl fmt::Display for Function { Function::Maxifs => write!(f, "MAXIFS"), Function::Minifs => write!(f, "MINIFS"), Function::Geomean => write!(f, "GEOMEAN"), + Function::Quartile => write!(f, "QUARTILE"), + Function::QuartileExc => write!(f, "QUARTILE.EXC"), + Function::QuartileInc => write!(f, "QUARTILE.INC"), + Function::Rank => write!(f, "RANK"), + Function::RankAvg => write!(f, "RANK.AVG"), + Function::RankEq => write!(f, "RANK.EQ"), Function::Year => write!(f, "YEAR"), Function::Day => write!(f, "DAY"), Function::Month => write!(f, "MONTH"), Function::Eomonth => write!(f, "EOMONTH"), Function::Date => write!(f, "DATE"), + Function::Datedif => write!(f, "DATEDIF"), + Function::Datevalue => write!(f, "DATEVALUE"), Function::Edate => write!(f, "EDATE"), + Function::Time => write!(f, "TIME"), + Function::Timevalue => write!(f, "TIMEVALUE"), + Function::Hour => write!(f, "HOUR"), + Function::Minute => write!(f, "MINUTE"), + Function::Second => write!(f, "SECOND"), Function::Today => write!(f, "TODAY"), Function::Now => write!(f, "NOW"), + Function::Days => write!(f, "DAYS"), + Function::Days360 => write!(f, "DAYS360"), + Function::Weekday => write!(f, "WEEKDAY"), + Function::Weeknum => write!(f, "WEEKNUM"), + Function::Isoweeknum => write!(f, "ISOWEEKNUM"), + Function::Workday => write!(f, "WORKDAY"), + Function::WorkdayIntl => write!(f, "WORKDAY.INTL"), + Function::Yearfrac => write!(f, "YEARFRAC"), + Function::Networkdays => write!(f, "NETWORKDAYS"), + Function::NetworkdaysIntl => write!(f, "NETWORKDAYS.INTL"), Function::Pmt => write!(f, "PMT"), Function::Pv => write!(f, "PV"), Function::Rate => write!(f, "RATE"), @@ -1076,16 +1168,38 @@ impl Model { Function::Maxifs => self.fn_maxifs(args, cell), Function::Minifs => self.fn_minifs(args, cell), Function::Geomean => self.fn_geomean(args, cell), + Function::Quartile => self.fn_quartile(args, cell), + Function::QuartileExc => self.fn_quartile_exc(args, cell), + Function::QuartileInc => self.fn_quartile_inc(args, cell), + Function::Rank => self.fn_rank(args, cell), + Function::RankAvg => self.fn_rank_avg(args, cell), + Function::RankEq => self.fn_rank_eq(args, cell), // Date and Time Function::Year => self.fn_year(args, cell), Function::Day => self.fn_day(args, cell), Function::Eomonth => self.fn_eomonth(args, cell), Function::Month => self.fn_month(args, cell), Function::Date => self.fn_date(args, cell), + Function::Datedif => self.fn_datedif(args, cell), + Function::Datevalue => self.fn_datevalue(args, cell), Function::Edate => self.fn_edate(args, cell), + Function::Time => self.fn_time(args, cell), + Function::Timevalue => self.fn_timevalue(args, cell), + Function::Hour => self.fn_hour(args, cell), + Function::Minute => self.fn_minute(args, cell), + Function::Second => self.fn_second(args, cell), Function::Today => self.fn_today(args, cell), Function::Now => self.fn_now(args, cell), - // Financial + Function::Days => self.fn_days(args, cell), + Function::Days360 => self.fn_days360(args, cell), + Function::Weekday => self.fn_weekday(args, cell), + Function::Weeknum => self.fn_weeknum(args, cell), + Function::Isoweeknum => self.fn_isoweeknum(args, cell), + Function::Workday => self.fn_workday(args, cell), + Function::WorkdayIntl => self.fn_workday_intl(args, cell), + Function::Yearfrac => self.fn_yearfrac(args, cell), + Function::Networkdays => self.fn_networkdays(args, cell), + Function::NetworkdaysIntl => self.fn_networkdays_intl(args, cell), Function::Pmt => self.fn_pmt(args, cell), Function::Pv => self.fn_pv(args, cell), Function::Rate => self.fn_rate(args, cell), diff --git a/base/src/functions/statistical.rs b/base/src/functions/statistical.rs index cdb936406..57677287c 100644 --- a/base/src/functions/statistical.rs +++ b/base/src/functions/statistical.rs @@ -8,6 +8,7 @@ use crate::{ }; use super::util::build_criteria; +use std::cmp::Ordering; impl Model { pub(crate) fn fn_average(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { @@ -730,4 +731,350 @@ impl Model { } CalcResult::Number(product.powf(1.0 / count)) } + + pub(crate) fn fn_quartile_inc( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let mut values = Vec::new(); + match self.evaluate_node_in_context(&args[0], cell) { + CalcResult::Range { left, right } => { + if left.sheet != right.sheet { + return CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + ); + } + for row in left.row..=right.row { + for column in left.column..=right.column { + match self.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + }) { + CalcResult::Number(v) => values.push(v), + CalcResult::Error { .. } => { + return CalcResult::new_error( + Error::VALUE, + cell, + "Invalid value".to_string(), + ) + } + _ => {} + } + } + } + } + CalcResult::Number(v) => values.push(v), + CalcResult::Boolean(b) => { + if !matches!(args[0], Node::ReferenceKind { .. }) { + values.push(if b { 1.0 } else { 0.0 }); + } + } + CalcResult::String(s) => { + if !matches!(args[0], Node::ReferenceKind { .. }) { + if let Ok(f) = s.parse::() { + values.push(f); + } else { + return CalcResult::new_error( + Error::VALUE, + cell, + "Argument cannot be cast into number".to_string(), + ); + } + } + } + CalcResult::Error { .. } => { + return CalcResult::new_error(Error::VALUE, cell, "Invalid value".to_string()) + } + _ => {} + } + + if values.is_empty() { + return CalcResult::new_error(Error::NUM, cell, "Empty array".to_string()); + } + values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + + let quart = match self.get_number(&args[1], cell) { + Ok(f) => f, + Err(e) => return e, + }; + if quart.fract() != 0.0 { + return CalcResult::new_error(Error::NUM, cell, "Invalid quart".to_string()); + } + let q = quart as i32; + if !(0..=4).contains(&q) { + return CalcResult::new_error(Error::NUM, cell, "Invalid quart".to_string()); + } + + let k = quart / 4.0; + let n = values.len() as f64; + let index = k * (n - 1.0); + let i = index.floor() as usize; + let fraction = index - (i as f64); + if i + 1 >= values.len() { + return CalcResult::Number(values[i]); + } + let result = values[i] + fraction * (values[i + 1] - values[i]); + CalcResult::Number(result) + } + + pub(crate) fn fn_quartile_exc( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let mut values = Vec::new(); + match self.evaluate_node_in_context(&args[0], cell) { + CalcResult::Range { left, right } => { + if left.sheet != right.sheet { + return CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + ); + } + for row in left.row..=right.row { + for column in left.column..=right.column { + match self.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + }) { + CalcResult::Number(v) => values.push(v), + CalcResult::Error { .. } => { + return CalcResult::new_error( + Error::VALUE, + cell, + "Invalid value".to_string(), + ) + } + _ => {} + } + } + } + } + CalcResult::Number(v) => values.push(v), + CalcResult::Boolean(b) => { + if !matches!(args[0], Node::ReferenceKind { .. }) { + values.push(if b { 1.0 } else { 0.0 }); + } + } + CalcResult::String(s) => { + if !matches!(args[0], Node::ReferenceKind { .. }) { + if let Ok(f) = s.parse::() { + values.push(f); + } else { + return CalcResult::new_error( + Error::VALUE, + cell, + "Argument cannot be cast into number".to_string(), + ); + } + } + } + CalcResult::Error { .. } => { + return CalcResult::new_error(Error::VALUE, cell, "Invalid value".to_string()) + } + _ => {} + } + + if values.is_empty() { + return CalcResult::new_error(Error::NUM, cell, "Empty array".to_string()); + } + values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + + let quart = match self.get_number(&args[1], cell) { + Ok(f) => f, + Err(e) => return e, + }; + if quart.fract() != 0.0 { + return CalcResult::new_error(Error::NUM, cell, "Invalid quart".to_string()); + } + let q = quart as i32; + if !(1..=3).contains(&q) { + return CalcResult::new_error(Error::NUM, cell, "Invalid quart".to_string()); + } + + let k = quart / 4.0; + let n = values.len() as f64; + let r = k * (n + 1.0); + if r <= 1.0 || r >= n { + return CalcResult::new_error(Error::NUM, cell, "Invalid quart".to_string()); + } + let i = r.floor() as usize; + let f = r - (i as f64); + let result = values[i - 1] + f * (values[i] - values[i - 1]); + CalcResult::Number(result) + } + + pub(crate) fn fn_quartile(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.fn_quartile_inc(args, cell) + } + + pub(crate) fn fn_rank_eq(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() < 2 || args.len() > 3 { + return CalcResult::new_args_number_error(cell); + } + let number = match self.get_number_no_bools(&args[0], cell) { + Ok(f) => f, + Err(e) => return e, + }; + let range = match self.get_reference(&args[1], cell) { + Ok(r) => r, + Err(e) => return e, + }; + let order = if args.len() == 3 { + match self.get_number(&args[2], cell) { + Ok(f) => f != 0.0, + Err(e) => return e, + } + } else { + false + }; + + let mut values = Vec::new(); + if range.left.sheet != range.right.sheet { + return CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + ); + } + for row in range.left.row..=range.right.row { + for column in range.left.column..=range.right.column { + match self.evaluate_cell(CellReferenceIndex { + sheet: range.left.sheet, + row, + column, + }) { + CalcResult::Number(v) => values.push(v), + CalcResult::Error { .. } => { + return CalcResult::new_error( + Error::VALUE, + cell, + "Invalid value".to_string(), + ) + } + _ => {} + } + } + } + + if values.is_empty() { + return CalcResult::new_error(Error::NUM, cell, "Empty range".to_string()); + } + + let mut greater = 0; + let mut found = false; + for v in &values { + if order { + if *v < number { + greater += 1; + } else if (*v - number).abs() < f64::EPSILON { + found = true; + } + } else if *v > number { + greater += 1; + } else if (*v - number).abs() < f64::EPSILON { + found = true; + } + } + + if !found { + return CalcResult::new_error(Error::NA, cell, "Number not found in range".to_string()); + } + + let rank = (greater + 1) as f64; + CalcResult::Number(rank) + } + + pub(crate) fn fn_rank_avg(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() < 2 || args.len() > 3 { + return CalcResult::new_args_number_error(cell); + } + let number = match self.get_number_no_bools(&args[0], cell) { + Ok(f) => f, + Err(e) => return e, + }; + let range = match self.get_reference(&args[1], cell) { + Ok(r) => r, + Err(e) => return e, + }; + let order = if args.len() == 3 { + match self.get_number(&args[2], cell) { + Ok(f) => f != 0.0, + Err(e) => return e, + } + } else { + false + }; + + if range.left.sheet != range.right.sheet { + return CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + ); + } + let mut values = Vec::new(); + for row in range.left.row..=range.right.row { + for column in range.left.column..=range.right.column { + match self.evaluate_cell(CellReferenceIndex { + sheet: range.left.sheet, + row, + column, + }) { + CalcResult::Number(v) => values.push(v), + CalcResult::Error { .. } => { + return CalcResult::new_error( + Error::VALUE, + cell, + "Invalid value".to_string(), + ) + } + _ => {} + } + } + } + + if values.is_empty() { + return CalcResult::new_error(Error::NUM, cell, "Empty range".to_string()); + } + + let mut greater = 0; + let mut equal = 0; + for v in &values { + if order { + if *v < number { + greater += 1; + } else if (*v - number).abs() < f64::EPSILON { + equal += 1; + } + } else if *v > number { + greater += 1; + } else if (*v - number).abs() < f64::EPSILON { + equal += 1; + } + } + + if equal == 0 { + return CalcResult::new_error(Error::NA, cell, "Number not found in range".to_string()); + } + + let rank = greater as f64 + ((equal as f64 + 1.0) / 2.0); + CalcResult::Number(rank) + } + + pub(crate) fn fn_rank(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.fn_rank_eq(args, cell) + } } diff --git a/base/src/model.rs b/base/src/model.rs index d55e95a80..f15221948 100644 --- a/base/src/model.rs +++ b/base/src/model.rs @@ -113,6 +113,7 @@ pub struct Model { /// The language used pub(crate) language: Language, /// The timezone used to evaluate the model + #[allow(dead_code)] pub(crate) tz: Tz, /// The view id. A view consists of a selected sheet and ranges. pub(crate) view_id: u32, diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index a0a0d69d6..313595496 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -27,6 +27,7 @@ mod test_fn_sum; mod test_fn_sumifs; mod test_fn_textbefore; mod test_fn_textjoin; +mod test_fn_time; mod test_fn_unicode; mod test_frozen_rows_columns; mod test_general; @@ -55,6 +56,8 @@ mod test_arrays; mod test_escape_quotes; mod test_extend; mod test_fn_fv; +mod test_fn_quartile; +mod test_fn_rank; mod test_fn_type; mod test_frozen_rows_and_columns; mod test_geomean; @@ -64,6 +67,7 @@ mod test_issue_155; mod test_ln; mod test_log; mod test_log10; +mod test_networkdays; mod test_percentage; mod test_set_functions_error_handling; mod test_today; diff --git a/base/src/test/test_fn_datevalue_datedif.rs b/base/src/test/test_fn_datevalue_datedif.rs new file mode 100644 index 000000000..8ec4fa24a --- /dev/null +++ b/base/src/test/test_fn_datevalue_datedif.rs @@ -0,0 +1,182 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; +use crate::types::Cell; + +// Helper to evaluate a formula and return the formatted text +fn eval_formula(formula: &str) -> String { + let mut model = new_empty_model(); + model._set("A1", formula); + model.evaluate(); + model._get_text("A1") +} + +// Helper that evaluates a formula and returns the raw value of A1 as a Result +fn eval_formula_raw_number(formula: &str) -> Result { + let mut model = new_empty_model(); + model._set("A1", formula); + model.evaluate(); + match model._get_cell("A1") { + Cell::NumberCell { v, .. } => Ok(*v), + Cell::BooleanCell { v, .. } => Ok(if *v { 1.0 } else { 0.0 }), + Cell::ErrorCell { ei, .. } => Err(format!("{}", ei)), + _ => Err(model._get_text("A1")), + } +} + +#[test] +fn test_datevalue_basic_numeric() { + // DATEVALUE should return the serial number representing the date, **not** a formatted date + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"2/1/2023\")").unwrap(), + 44958.0 + ); +} + +#[test] +fn test_datevalue_mmdd_with_leading_zero() { + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"02/01/2023\")").unwrap(), + 44958.0 + ); // 1-Feb-2023 +} + +#[test] +fn test_datevalue_iso() { + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"2023-01-02\")").unwrap(), + 44928.0 + ); +} + +#[test] +fn test_datevalue_month_name() { + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"2-Jan-23\")").unwrap(), + 44928.0 + ); +} + +#[test] +fn test_datevalue_ambiguous_ddmm() { + // 01/02/2023 interpreted as MM/DD -> 2-Jan-2023 + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"01/02/2023\")").unwrap(), + 44929.0 + ); +} + +#[test] +fn test_datevalue_ddmm_unambiguous() { + // 15/01/2023 should be 15-Jan-2023 since 15 cannot be month + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"15/01/2023\")").unwrap(), + 44941.0 + ); +} + +#[test] +fn test_datevalue_leap_day() { + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"29/02/2020\")").unwrap(), + 43890.0 + ); +} + +#[test] +fn test_datevalue_year_first_text_month() { + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"2023/Jan/15\")").unwrap(), + 44941.0 + ); +} + +#[test] +fn test_datevalue_mmdd_with_day_gt_12() { + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"6/15/2021\")").unwrap(), + 44373.0 + ); +} + +#[test] +fn test_datevalue_error_conditions() { + let cases = [ + "=DATEVALUE(\"31/04/2023\")", // invalid day (Apr has 30 days) + "=DATEVALUE(\"13/13/2023\")", // invalid month + "=DATEVALUE(\"not a date\")", // non-date text + ]; + for formula in cases { + let result = eval_formula(formula); + assert_eq!(result, *"#VALUE!", "Expected #VALUE! for {}", formula); + } +} + +// Helper to set and evaluate a single DATEDIF call +fn eval_datedif(unit: &str) -> String { + let mut model = new_empty_model(); + let formula = format!("=DATEDIF(\"2020-01-01\", \"2021-06-15\", \"{}\")", unit); + model._set("A1", &formula); + model.evaluate(); + model._get_text("A1") +} + +#[test] +fn test_datedif_y() { + assert_eq!(eval_datedif("Y"), *"1"); +} + +#[test] +fn test_datedif_m() { + assert_eq!(eval_datedif("M"), *"17"); +} + +#[test] +fn test_datedif_d() { + assert_eq!(eval_datedif("D"), *"531"); +} + +#[test] +fn test_datedif_ym() { + assert_eq!(eval_datedif("YM"), *"5"); +} + +#[test] +fn test_datedif_yd() { + assert_eq!(eval_datedif("YD"), *"165"); +} + +#[test] +fn test_datedif_md() { + assert_eq!(eval_datedif("MD"), *"14"); +} + +#[test] +fn test_datedif_edge_and_error_cases() { + let mut model = new_empty_model(); + // Leap-year spanning + model._set("A1", "=DATEDIF(\"28/2/2020\", \"1/3/2020\", \"D\")"); + // End date before start date => #NUM! + model._set("A2", "=DATEDIF(\"1/2/2021\", \"1/1/2021\", \"D\")"); + // Invalid unit => #VALUE! + model._set("A3", "=DATEDIF(\"1/1/2020\", \"1/1/2021\", \"Z\")"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"2"); + assert_eq!(model._get_text("A2"), *"#NUM!"); + assert_eq!(model._get_text("A3"), *"#VALUE!"); +} + +#[test] +fn test_datedif_mixed_case_unit() { + assert_eq!(eval_datedif("yD"), *"165"); // mixed-case should work +} + +#[test] +fn test_datedif_error_propagation() { + // Invalid date in arguments should propagate #VALUE! + let mut model = new_empty_model(); + model._set("A1", "=DATEDIF(\"bad\", \"bad\", \"Y\")"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#VALUE!"); +} diff --git a/base/src/test/test_fn_quartile.rs b/base/src/test/test_fn_quartile.rs new file mode 100644 index 000000000..b44b38d40 --- /dev/null +++ b/base/src/test/test_fn_quartile.rs @@ -0,0 +1,180 @@ +#![allow(clippy::unwrap_used)] +use crate::test::util::new_empty_model; + +#[test] +fn test_quartile_basic_functionality() { + let mut model = new_empty_model(); + for i in 1..=8 { + model._set(&format!("B{i}"), &i.to_string()); + } + + // Test basic quartile calculations + model._set("A1", "=QUARTILE(B1:B8,1)"); // Legacy function + model._set("A2", "=QUARTILE.INC(B1:B8,3)"); // Inclusive method + model._set("A3", "=QUARTILE.EXC(B1:B8,1)"); // Exclusive method + model.evaluate(); + + assert_eq!(model._get_text("A1"), "2.75"); + assert_eq!(model._get_text("A2"), "6.25"); + assert_eq!(model._get_text("A3"), "2.25"); +} + +#[test] +fn test_quartile_all_parameters() { + let mut model = new_empty_model(); + for i in 1..=8 { + model._set(&format!("B{i}"), &i.to_string()); + } + + // Test all valid quartile parameters + model._set("A1", "=QUARTILE.INC(B1:B8,0)"); // Min + model._set("A2", "=QUARTILE.INC(B1:B8,2)"); // Median + model._set("A3", "=QUARTILE.INC(B1:B8,4)"); // Max + model._set("A4", "=QUARTILE.EXC(B1:B8,2)"); // EXC median + model.evaluate(); + + assert_eq!(model._get_text("A1"), "1"); // Min + assert_eq!(model._get_text("A2"), "4.5"); // Median + assert_eq!(model._get_text("A3"), "8"); // Max + assert_eq!(model._get_text("A4"), "4.5"); // EXC median +} + +#[test] +fn test_quartile_data_filtering() { + let mut model = new_empty_model(); + + // Mixed data types - only numbers should be considered + model._set("B1", "1"); + model._set("B2", "text"); // Ignored + model._set("B3", "3"); + model._set("B4", "TRUE"); // Ignored + model._set("B5", "5"); + model._set("B6", ""); // Ignored + + model._set("A1", "=QUARTILE.INC(B1:B6,2)"); // Median of [1,3,5] + model.evaluate(); + + assert_eq!(model._get_text("A1"), "3"); +} + +#[test] +fn test_quartile_single_element() { + let mut model = new_empty_model(); + model._set("B1", "5"); + + model._set("A1", "=QUARTILE.INC(B1,0)"); // Min + model._set("A2", "=QUARTILE.INC(B1,2)"); // Median + model._set("A3", "=QUARTILE.INC(B1,4)"); // Max + model.evaluate(); + + // All quartiles should return the single value + assert_eq!(model._get_text("A1"), "5"); + assert_eq!(model._get_text("A2"), "5"); + assert_eq!(model._get_text("A3"), "5"); +} + +#[test] +fn test_quartile_duplicate_values() { + let mut model = new_empty_model(); + // Data with duplicates: 1, 1, 3, 3 + model._set("C1", "1"); + model._set("C2", "1"); + model._set("C3", "3"); + model._set("C4", "3"); + + model._set("A1", "=QUARTILE.INC(C1:C4,1)"); // Q1 + model._set("A2", "=QUARTILE.INC(C1:C4,2)"); // Q2 + model._set("A3", "=QUARTILE.INC(C1:C4,3)"); // Q3 + model.evaluate(); + + assert_eq!(model._get_text("A1"), "1"); // Q1 with duplicates + assert_eq!(model._get_text("A2"), "2"); // Median with duplicates + assert_eq!(model._get_text("A3"), "3"); // Q3 with duplicates +} + +#[test] +fn test_quartile_exc_boundary_conditions() { + let mut model = new_empty_model(); + + // Small dataset for EXC - should work for median but fail for Q1/Q3 + model._set("D1", "1"); + model._set("D2", "2"); + + model._set("A1", "=QUARTILE.EXC(D1:D2,1)"); // Should fail + model._set("A2", "=QUARTILE.EXC(D1:D2,2)"); // Should work (median) + model._set("A3", "=QUARTILE.EXC(D1:D2,3)"); // Should fail + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#NUM!"); // EXC Q1 fails + assert_eq!(model._get_text("A2"), "1.5"); // EXC median works + assert_eq!(model._get_text("A3"), "#NUM!"); // EXC Q3 fails +} + +#[test] +fn test_quartile_invalid_arguments() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + + // Invalid argument count + model._set("A1", "=QUARTILE.INC(B1:B2)"); // Too few + model._set("A2", "=QUARTILE.INC(B1:B2,1,2)"); // Too many + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#ERROR!"); + assert_eq!(model._get_text("A2"), "#ERROR!"); +} + +#[test] +fn test_quartile_invalid_quartile_values() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + + // Invalid quartile values for QUARTILE.INC + model._set("A1", "=QUARTILE.INC(B1:B2,-1)"); // Below 0 + model._set("A2", "=QUARTILE.INC(B1:B2,5)"); // Above 4 + + // Invalid quartile values for QUARTILE.EXC + model._set("A3", "=QUARTILE.EXC(B1:B2,0)"); // Below 1 + model._set("A4", "=QUARTILE.EXC(B1:B2,4)"); // Above 3 + + // Non-numeric quartile + model._set("A5", "=QUARTILE.INC(B1:B2,\"text\")"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#NUM!"); + assert_eq!(model._get_text("A2"), "#NUM!"); + assert_eq!(model._get_text("A3"), "#NUM!"); + assert_eq!(model._get_text("A4"), "#NUM!"); + assert_eq!(model._get_text("A5"), "#VALUE!"); +} + +#[test] +fn test_quartile_invalid_data_ranges() { + let mut model = new_empty_model(); + + // Empty range + model._set("A1", "=QUARTILE.INC(B1:B3,1)"); // Empty range + + // Text-only range + model._set("C1", "text"); + model._set("A2", "=QUARTILE.INC(C1,1)"); // Text-only + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#NUM!"); + assert_eq!(model._get_text("A2"), "#NUM!"); +} + +#[test] +fn test_quartile_error_propagation() { + let mut model = new_empty_model(); + + // Error propagation from cell references + model._set("E1", "=1/0"); + model._set("E2", "2"); + model._set("A1", "=QUARTILE.INC(E1:E2,1)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#VALUE!"); +} diff --git a/base/src/test/test_fn_rank.rs b/base/src/test/test_fn_rank.rs new file mode 100644 index 000000000..e573655d9 --- /dev/null +++ b/base/src/test/test_fn_rank.rs @@ -0,0 +1,208 @@ +#![allow(clippy::unwrap_used)] +use crate::test::util::new_empty_model; + +#[test] +fn test_rank_basic_functionality() { + let mut model = new_empty_model(); + model._set("B1", "3"); + model._set("B2", "3"); + model._set("B3", "2"); + model._set("B4", "1"); + + // Test basic rank calculations + model._set("A1", "=RANK(2,B1:B4)"); // Legacy function + model._set("A2", "=RANK.AVG(3,B1:B4)"); // Average rank for duplicates + model._set("A3", "=RANK.EQ(3,B1:B4)"); // Equal rank for duplicates + model._set("A4", "=RANK(3,B1:B4,1)"); // Ascending order + model.evaluate(); + + assert_eq!(model._get_text("A1"), "3"); // Descending rank of 2 + assert_eq!(model._get_text("A2"), "1.5"); // Average of ranks 1,2 for value 3 + assert_eq!(model._get_text("A3"), "1"); // Highest rank for value 3 + assert_eq!(model._get_text("A4"), "3"); // Ascending rank of 3 +} + +#[test] +fn test_rank_sort_order_and_duplicates() { + let mut model = new_empty_model(); + // Data: 1, 3, 5, 7, 9 (no duplicates) + for (i, val) in [1, 3, 5, 7, 9].iter().enumerate() { + model._set(&format!("B{}", i + 1), &val.to_string()); + } + + // Test sort orders + model._set("A1", "=RANK(5,B1:B5)"); // Descending (default) + model._set("A2", "=RANK(5,B1:B5,1)"); // Ascending + + // Data with many duplicates: 1, 2, 2, 3, 3, 3, 4 + model._set("C1", "1"); + model._set("C2", "2"); + model._set("C3", "2"); + model._set("C4", "3"); + model._set("C5", "3"); + model._set("C6", "3"); + model._set("C7", "4"); + + // Test duplicate handling + model._set("A3", "=RANK.EQ(3,C1:C7)"); // Highest rank for duplicates + model._set("A4", "=RANK.AVG(3,C1:C7)"); // Average rank for duplicates + model._set("A5", "=RANK.AVG(2,C1:C7)"); // Average of ranks 5,6 + + model.evaluate(); + + assert_eq!(model._get_text("A1"), "3"); // 5 is 3rd largest + assert_eq!(model._get_text("A2"), "3"); // 5 is 3rd smallest + assert_eq!(model._get_text("A3"), "2"); // Highest rank for value 3 + assert_eq!(model._get_text("A4"), "3"); // Average rank for value 3: (2+3+4)/3 + assert_eq!(model._get_text("A5"), "5.5"); // Average rank for value 2: (5+6)/2 +} + +#[test] +fn test_rank_not_found() { + let mut model = new_empty_model(); + model._set("B1", "3"); + model._set("B2", "2"); + model._set("B3", "1"); + + // Test cases where target number is not in range + model._set("A1", "=RANK(5,B1:B3)"); // Not in range + model._set("A2", "=RANK.AVG(0,B1:B3)"); // Not in range + model._set("A3", "=RANK.EQ(2.5,B1:B3)"); // Close but not exact + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#N/A"); + assert_eq!(model._get_text("A2"), "#N/A"); + assert_eq!(model._get_text("A3"), "#N/A"); +} + +#[test] +fn test_rank_single_element() { + let mut model = new_empty_model(); + model._set("B1", "5"); + + model._set("A1", "=RANK(5,B1)"); + model._set("A2", "=RANK.EQ(5,B1)"); + model._set("A3", "=RANK.AVG(5,B1)"); + model.evaluate(); + + // All should return rank 1 for single element + assert_eq!(model._get_text("A1"), "1"); + assert_eq!(model._get_text("A2"), "1"); + assert_eq!(model._get_text("A3"), "1"); +} + +#[test] +fn test_rank_identical_values() { + let mut model = new_empty_model(); + // All values are the same + for i in 1..=4 { + model._set(&format!("C{i}"), "7"); + } + + model._set("A1", "=RANK.EQ(7,C1:C4)"); // Should be rank 1 + model._set("A2", "=RANK.AVG(7,C1:C4)"); // Should be average: 2.5 + model.evaluate(); + + assert_eq!(model._get_text("A1"), "1"); // All identical - highest rank + assert_eq!(model._get_text("A2"), "2.5"); // All identical - average rank +} + +#[test] +fn test_rank_mixed_data_types() { + let mut model = new_empty_model(); + // Mixed data types (only numbers counted) + model._set("D1", "1"); + model._set("D2", "text"); // Ignored + model._set("D3", "3"); + model._set("D4", "TRUE"); // Ignored + model._set("D5", "5"); + + model._set("A1", "=RANK(3,D1:D5)"); // Rank in [1,3,5] + model._set("A2", "=RANK(1,D1:D5)"); // Rank of smallest + model.evaluate(); + + assert_eq!(model._get_text("A1"), "2"); // 3 is 2nd largest in [1,3,5] + assert_eq!(model._get_text("A2"), "3"); // 1 is smallest +} + +#[test] +fn test_rank_extreme_values() { + let mut model = new_empty_model(); + // Extreme values + model._set("E1", "1e10"); + model._set("E2", "0"); + model._set("E3", "-1e10"); + + model._set("A1", "=RANK(0,E1:E3)"); // Rank of 0 + model._set("A2", "=RANK(1e10,E1:E3)"); // Rank of largest + model._set("A3", "=RANK(-1e10,E1:E3)"); // Rank of smallest + model.evaluate(); + + assert_eq!(model._get_text("A1"), "2"); // 0 is 2nd largest + assert_eq!(model._get_text("A2"), "1"); // 1e10 is largest + assert_eq!(model._get_text("A3"), "3"); // -1e10 is smallest +} + +#[test] +fn test_rank_invalid_arguments() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + + // Invalid argument count + model._set("A1", "=RANK(1)"); // Too few + model._set("A2", "=RANK(1,B1:B2,0,1)"); // Too many + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#ERROR!"); + assert_eq!(model._get_text("A2"), "#ERROR!"); +} + +#[test] +fn test_rank_invalid_parameters() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + + // Non-numeric search value + model._set("A1", "=RANK(\"text\",B1:B2)"); + model._set("A2", "=RANK.EQ(TRUE,B1:B2)"); // Boolean + + // Invalid order parameter + model._set("A3", "=RANK(2,B1:B2,\"text\")"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#VALUE!"); + assert_eq!(model._get_text("A2"), "#VALUE!"); + assert_eq!(model._get_text("A3"), "#VALUE!"); +} + +#[test] +fn test_rank_invalid_data_ranges() { + let mut model = new_empty_model(); + + // Empty range + model._set("A1", "=RANK(1,C1:C3)"); // Empty cells + + // Text-only range + model._set("D1", "text1"); + model._set("D2", "text2"); + model._set("A2", "=RANK(1,D1:D2)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#NUM!"); + assert_eq!(model._get_text("A2"), "#NUM!"); +} + +#[test] +fn test_rank_error_propagation() { + let mut model = new_empty_model(); + + // Error propagation from cell references + model._set("E1", "=1/0"); + model._set("E2", "2"); + model._set("A1", "=RANK(2,E1:E2)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#VALUE!"); +} diff --git a/base/src/test/test_fn_time.rs b/base/src/test/test_fn_time.rs new file mode 100644 index 000000000..862aa0a17 --- /dev/null +++ b/base/src/test/test_fn_time.rs @@ -0,0 +1,520 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +// Helper constants for common time values with detailed documentation +const MIDNIGHT: &str = "0"; // 00:00:00 = 0/24 = 0 +const NOON: &str = "0.5"; // 12:00:00 = 12/24 = 0.5 +const TIME_14_30: &str = "0.604166667"; // 14:30:00 = 14.5/24 ≈ 0.604166667 +const TIME_14_30_45: &str = "0.6046875"; // 14:30:45 = 14.5125/24 = 0.6046875 +const TIME_14_30_59: &str = "0.604849537"; // 14:30:59 (from floored fractional inputs) +const TIME_23_59_59: &str = "0.999988426"; // 23:59:59 = 23.99972.../24 ≈ 0.999988426 + +// Excel documentation test values with explanations +const TIME_2_24_AM: &str = "0.1"; // 2:24 AM = 2.4/24 = 0.1 +const TIME_2_PM: &str = "0.583333333"; // 2:00 PM = 14/24 ≈ 0.583333333 +const TIME_6_45_PM: &str = "0.78125"; // 6:45 PM = 18.75/24 = 0.78125 +const TIME_6_35_AM: &str = "0.274305556"; // 6:35 AM = 6.583333.../24 ≈ 0.274305556 +const TIME_2_30_AM: &str = "0.104166667"; // 2:30 AM = 2.5/24 ≈ 0.104166667 +const TIME_1_AM: &str = "0.041666667"; // 1:00 AM = 1/24 ≈ 0.041666667 +const TIME_9_PM: &str = "0.875"; // 9:00 PM = 21/24 = 0.875 +const TIME_2_AM: &str = "0.083333333"; // 2:00 AM = 2/24 ≈ 0.083333333 + // Additional helper: 1-second past midnight (00:00:01) +const TIME_00_00_01: &str = "0.000011574"; // 1 second = 1/86400 ≈ 0.000011574 + +/// Helper function to set up and evaluate a model with time expressions +fn test_time_expressions(expressions: &[(&str, &str)]) -> crate::model::Model { + let mut model = new_empty_model(); + for (cell, formula) in expressions { + model._set(cell, formula); + } + model.evaluate(); + model +} + +/// Helper function to test component extraction for a given time value +/// Returns (hour, minute, second) as strings +fn test_component_extraction(time_value: &str) -> (String, String, String) { + let model = test_time_expressions(&[ + ("A1", &format!("=HOUR({time_value})")), + ("B1", &format!("=MINUTE({time_value})")), + ("C1", &format!("=SECOND({time_value})")), + ]); + ( + model._get_text("A1").to_string(), + model._get_text("B1").to_string(), + model._get_text("C1").to_string(), + ) +} + +#[test] +fn test_excel_timevalue_compatibility() { + // Test cases based on Excel's official documentation and examples + let model = test_time_expressions(&[ + // Excel documentation examples + ("A1", "=TIMEVALUE(\"2:24 AM\")"), // Should be 0.1 + ("A2", "=TIMEVALUE(\"2 PM\")"), // Should be 0.583333... (14/24) + ("A3", "=TIMEVALUE(\"6:45 PM\")"), // Should be 0.78125 (18.75/24) + ("A4", "=TIMEVALUE(\"18:45\")"), // Same as above, 24-hour format + // Date-time format (date should be ignored) + ("B1", "=TIMEVALUE(\"22-Aug-2011 6:35 AM\")"), // Should be ~0.2743 + ("B2", "=TIMEVALUE(\"2023-01-01 14:30:00\")"), // Should be 0.604166667 + // Edge cases that Excel should support + ("C1", "=TIMEVALUE(\"12:00 AM\")"), // Midnight: 0 + ("C2", "=TIMEVALUE(\"12:00 PM\")"), // Noon: 0.5 + ("C3", "=TIMEVALUE(\"11:59:59 PM\")"), // Almost midnight: 0.999988426 + // Single digit variations + ("D1", "=TIMEVALUE(\"1 AM\")"), // 1:00 AM + ("D2", "=TIMEVALUE(\"9 PM\")"), // 9:00 PM + ("D3", "=TIMEVALUE(\"12 AM\")"), // Midnight + ("D4", "=TIMEVALUE(\"12 PM\")"), // Noon + ]); + + // Excel documentation examples - verify exact values + assert_eq!(model._get_text("A1"), *TIME_2_24_AM); // 2:24 AM + assert_eq!(model._get_text("A2"), *TIME_2_PM); // 2 PM = 14:00 + assert_eq!(model._get_text("A3"), *TIME_6_45_PM); // 6:45 PM = 18:45 + assert_eq!(model._get_text("A4"), *TIME_6_45_PM); // 18:45 (24-hour) + + // Date-time formats (date ignored, extract time only) + assert_eq!(model._get_text("B1"), *TIME_6_35_AM); // 6:35 AM ≈ 0.2743 + assert_eq!(model._get_text("B2"), *TIME_14_30); // 14:30:00 + + // Edge cases + assert_eq!(model._get_text("C1"), *MIDNIGHT); // 12:00 AM = 00:00 + assert_eq!(model._get_text("C2"), *NOON); // 12:00 PM = 12:00 + assert_eq!(model._get_text("C3"), *TIME_23_59_59); // 11:59:59 PM + + // Single digit hours + assert_eq!(model._get_text("D1"), *TIME_1_AM); // 1:00 AM + assert_eq!(model._get_text("D2"), *TIME_9_PM); // 9:00 PM = 21:00 + assert_eq!(model._get_text("D3"), *MIDNIGHT); // 12 AM = 00:00 + assert_eq!(model._get_text("D4"), *NOON); // 12 PM = 12:00 +} + +#[test] +fn test_time_function_basic_cases() { + let model = test_time_expressions(&[ + ("A1", "=TIME(0,0,0)"), // Midnight + ("A2", "=TIME(12,0,0)"), // Noon + ("A3", "=TIME(14,30,0)"), // 2:30 PM + ("A4", "=TIME(23,59,59)"), // Max time + ]); + + assert_eq!(model._get_text("A1"), *MIDNIGHT); + assert_eq!(model._get_text("A2"), *NOON); + assert_eq!(model._get_text("A3"), *TIME_14_30); + assert_eq!(model._get_text("A4"), *TIME_23_59_59); +} + +#[test] +fn test_time_function_normalization() { + let model = test_time_expressions(&[ + ("A1", "=TIME(25,0,0)"), // Hours > 24 wrap around + ("A2", "=TIME(48,0,0)"), // 48 hours = 0 (2 full days) + ("A3", "=TIME(0,90,0)"), // 90 minutes = 1.5 hours + ("A4", "=TIME(0,0,90)"), // 90 seconds = 1.5 minutes + ("A5", "=TIME(14.9,30.9,59.9)"), // Fractional inputs floored to 14:30:59 + ]); + + assert_eq!(model._get_text("A1"), *TIME_1_AM); // 1:00:00 + assert_eq!(model._get_text("A2"), *MIDNIGHT); // 0:00:00 + assert_eq!(model._get_text("A3"), *"0.0625"); // 1:30:00 + assert_eq!(model._get_text("A4"), *"0.001041667"); // 0:01:30 + assert_eq!(model._get_text("A5"), *TIME_14_30_59); // 14:30:59 (floored) +} + +#[test] +fn test_time_function_precision_edge_cases() { + let model = test_time_expressions(&[ + // High precision fractional seconds + ("A1", "=TIME(14,30,45.999)"), // Fractional seconds should be floored + ("A2", "=SECOND(TIME(14,30,45.999))"), // Should extract 45, not 46 + // Very large normalization values + ("B1", "=TIME(999,999,999)"), // Extreme normalization test + ("B2", "=HOUR(999.5)"), // Multiple days, extract hour from fractional part + ("B3", "=MINUTE(999.75)"), // Multiple days, extract minute + // Boundary conditions at rollover points + ("C1", "=TIME(24,60,60)"), // Should normalize to next day (00:01:00) + ("C2", "=HOUR(0.999999999)"), // Almost 24 hours should be 23 + ("C3", "=MINUTE(0.999999999)"), // Almost 24 hours, extract minutes + ("C4", "=SECOND(0.999999999)"), // Almost 24 hours, extract seconds + // Precision at boundaries + ("D1", "=TIME(23,59,59.999)"), // Very close to midnight + ("D2", "=TIME(0,0,0.001)"), // Just after midnight + ]); + + // Fractional seconds are floored + assert_eq!(model._get_text("A2"), *"45"); // 45.999 floored to 45 + + // Multiple days should work with rem_euclid + assert_eq!(model._get_text("B2"), *"12"); // 999.5 days, hour = 12 (noon) + + // Boundary normalization + assert_eq!(model._get_text("C1"), *"0.042361111"); // 24:60:60 = 01:01:00 (normalized) + assert_eq!(model._get_text("C2"), *"23"); // Almost 24 hours = 23:xx:xx + + // High precision should be handled correctly + let result_d1 = model._get_text("D1").parse::().unwrap(); + assert!(result_d1 < 1.0 && result_d1 > 0.999); // Very close to but less than 1.0 +} + +#[test] +fn test_time_function_errors() { + let model = test_time_expressions(&[ + ("A1", "=TIME()"), // Wrong arg count + ("A2", "=TIME(12)"), // Wrong arg count + ("A3", "=TIME(12,30,0,0)"), // Wrong arg count + ("B1", "=TIME(-1,0,0)"), // Negative hour + ("B2", "=TIME(0,-1,0)"), // Negative minute + ("B3", "=TIME(0,0,-1)"), // Negative second + ]); + + // Wrong argument count + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); + assert_eq!(model._get_text("A3"), *"#ERROR!"); + + // Negative values should return #NUM! error + assert_eq!(model._get_text("B1"), *"#NUM!"); + assert_eq!(model._get_text("B2"), *"#NUM!"); + assert_eq!(model._get_text("B3"), *"#NUM!"); +} + +#[test] +fn test_timevalue_function_formats() { + let model = test_time_expressions(&[ + // Basic formats + ("A1", "=TIMEVALUE(\"14:30\")"), + ("A2", "=TIMEVALUE(\"14:30:45\")"), + ("A3", "=TIMEVALUE(\"00:00:00\")"), + // AM/PM formats + ("B1", "=TIMEVALUE(\"2:30 PM\")"), + ("B2", "=TIMEVALUE(\"2:30 AM\")"), + ("B3", "=TIMEVALUE(\"12:00 PM\")"), // Noon + ("B4", "=TIMEVALUE(\"12:00 AM\")"), // Midnight + // Single hour with AM/PM (now supported!) + ("B5", "=TIMEVALUE(\"2 PM\")"), + ("B6", "=TIMEVALUE(\"2 AM\")"), + // Date-time formats (extract time only) + ("C1", "=TIMEVALUE(\"2023-01-01 14:30:00\")"), + ("C2", "=TIMEVALUE(\"2023-01-01T14:30:00\")"), + // Whitespace handling + ("D1", "=TIMEVALUE(\" 14:30 \")"), + ]); + + // Basic formats + assert_eq!(model._get_text("A1"), *TIME_14_30); + assert_eq!(model._get_text("A2"), *TIME_14_30_45); + assert_eq!(model._get_text("A3"), *MIDNIGHT); + + // AM/PM formats + assert_eq!(model._get_text("B1"), *TIME_14_30); // 2:30 PM = 14:30 + assert_eq!(model._get_text("B2"), *TIME_2_30_AM); // 2:30 AM + assert_eq!(model._get_text("B3"), *NOON); // 12:00 PM = noon + assert_eq!(model._get_text("B4"), *MIDNIGHT); // 12:00 AM = midnight + + // Single hour AM/PM formats (now supported!) + assert_eq!(model._get_text("B5"), *TIME_2_PM); // 2 PM = 14:00 + assert_eq!(model._get_text("B6"), *TIME_2_AM); // 2 AM = 02:00 + + // Date-time formats + assert_eq!(model._get_text("C1"), *TIME_14_30); + assert_eq!(model._get_text("C2"), *TIME_14_30); + + // Whitespace + assert_eq!(model._get_text("D1"), *TIME_14_30); +} + +#[test] +fn test_timevalue_function_errors() { + let model = test_time_expressions(&[ + ("A1", "=TIMEVALUE()"), // Wrong arg count + ("A2", "=TIMEVALUE(\"14:30\", \"x\")"), // Wrong arg count + ("B1", "=TIMEVALUE(\"invalid\")"), // Invalid format + ("B2", "=TIMEVALUE(\"25:00\")"), // Invalid hour + ("B3", "=TIMEVALUE(\"14:70\")"), // Invalid minute + ("B4", "=TIMEVALUE(\"\")"), // Empty string + ("B5", "=TIMEVALUE(\"2PM\")"), // Missing space (still unsupported) + ]); + + // Wrong argument count should return #ERROR! + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); + + // Invalid formats should return #VALUE! + assert_eq!(model._get_text("B1"), *"#VALUE!"); + assert_eq!(model._get_text("B2"), *"#VALUE!"); + assert_eq!(model._get_text("B3"), *"#VALUE!"); + assert_eq!(model._get_text("B4"), *"#VALUE!"); + assert_eq!(model._get_text("B5"), *"#VALUE!"); // "2PM" no space - not supported +} + +#[test] +fn test_time_component_extraction_comprehensive() { + // Test component extraction using helper function for consistency + + // Test basic time values + let test_cases = [ + (MIDNIGHT, ("0", "0", "0")), // 00:00:00 + (NOON, ("12", "0", "0")), // 12:00:00 + (TIME_14_30, ("14", "30", "0")), // 14:30:00 + (TIME_23_59_59, ("23", "59", "59")), // 23:59:59 + ]; + + for (time_value, expected) in test_cases { + let (hour, minute, second) = test_component_extraction(time_value); + assert_eq!(hour, expected.0, "Hour mismatch for {time_value}"); + assert_eq!(minute, expected.1, "Minute mismatch for {time_value}"); + assert_eq!(second, expected.2, "Second mismatch for {time_value}"); + } + + // Test multiple days (extract from fractional part) + let (hour, minute, second) = test_component_extraction("1.5"); // Day 2, 12:00 + assert_eq!( + (hour, minute, second), + ("12".to_string(), "0".to_string(), "0".to_string()) + ); + + let (hour, minute, second) = test_component_extraction("100.604166667"); // Day 101, 14:30 + assert_eq!( + (hour, minute, second), + ("14".to_string(), "30".to_string(), "0".to_string()) + ); + + // Test precision at boundaries + let (hour, _, _) = test_component_extraction("0.041666666"); // Just under 1:00 AM + assert_eq!(hour, "0"); + + let (hour, _, _) = test_component_extraction("0.041666667"); // Exactly 1:00 AM + assert_eq!(hour, "1"); + + let (hour, _, _) = test_component_extraction("0.041666668"); // Just over 1:00 AM + assert_eq!(hour, "1"); + + // Test very large day values + let (hour, minute, second) = test_component_extraction("1000000.25"); // Million days + 6 hours + assert_eq!( + (hour, minute, second), + ("6".to_string(), "0".to_string(), "0".to_string()) + ); +} + +#[test] +fn test_time_component_function_errors() { + let model = test_time_expressions(&[ + // Wrong argument counts + ("A1", "=HOUR()"), // No arguments + ("A2", "=MINUTE()"), // No arguments + ("A3", "=SECOND()"), // No arguments + ("A4", "=HOUR(1, 2)"), // Too many arguments + ("A5", "=MINUTE(1, 2)"), // Too many arguments + ("A6", "=SECOND(1, 2)"), // Too many arguments + // Negative values should return #NUM! + ("B1", "=HOUR(-0.5)"), // Negative value + ("B2", "=MINUTE(-1)"), // Negative value + ("B3", "=SECOND(-1)"), // Negative value + ("B4", "=HOUR(-0.000001)"), // Slightly negative + ("B5", "=MINUTE(-0.000001)"), // Slightly negative + ("B6", "=SECOND(-0.000001)"), // Slightly negative + ]); + + // Wrong argument count should return #ERROR! + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); + assert_eq!(model._get_text("A3"), *"#ERROR!"); + assert_eq!(model._get_text("A4"), *"#ERROR!"); + assert_eq!(model._get_text("A5"), *"#ERROR!"); + assert_eq!(model._get_text("A6"), *"#ERROR!"); + + // Negative values should return #NUM! + assert_eq!(model._get_text("B1"), *"#NUM!"); + assert_eq!(model._get_text("B2"), *"#NUM!"); + assert_eq!(model._get_text("B3"), *"#NUM!"); + assert_eq!(model._get_text("B4"), *"#NUM!"); + assert_eq!(model._get_text("B5"), *"#NUM!"); + assert_eq!(model._get_text("B6"), *"#NUM!"); +} + +#[test] +fn test_time_functions_integration() { + // Test how TIME, TIMEVALUE and component extraction functions work together + let model = test_time_expressions(&[ + // Create times with both functions + ("A1", "=TIME(14,30,45)"), + ("A2", "=TIMEVALUE(\"14:30:45\")"), + // Extract components from TIME function results + ("B1", "=HOUR(A1)"), + ("B2", "=MINUTE(A1)"), + ("B3", "=SECOND(A1)"), + // Extract components from TIMEVALUE function results + ("C1", "=HOUR(A2)"), + ("C2", "=MINUTE(A2)"), + ("C3", "=SECOND(A2)"), + // Test additional TIME variations + ("D1", "=TIME(14,0,0)"), // 14:00:00 + ("E1", "=HOUR(D1)"), // Extract hour from 14:00:00 + ("E2", "=MINUTE(D1)"), // Extract minute from 14:00:00 + ("E3", "=SECOND(D1)"), // Extract second from 14:00:00 + ]); + + // TIME and TIMEVALUE should produce equivalent results + assert_eq!(model._get_text("A1"), model._get_text("A2")); + + // Extracting components should work consistently + assert_eq!(model._get_text("B1"), *"14"); + assert_eq!(model._get_text("B2"), *"30"); + assert_eq!(model._get_text("B3"), *"45"); + assert_eq!(model._get_text("C1"), *"14"); + assert_eq!(model._get_text("C2"), *"30"); + assert_eq!(model._get_text("C3"), *"45"); + + // Components from TIME(14,0,0) + assert_eq!(model._get_text("E1"), *"14"); + assert_eq!(model._get_text("E2"), *"0"); + assert_eq!(model._get_text("E3"), *"0"); +} + +#[test] +fn test_time_function_extreme_values() { + // Test missing edge cases: very large fractional inputs + let model = test_time_expressions(&[ + // Extremely large fractional values to TIME function + ("A1", "=TIME(999999.9, 999999.9, 999999.9)"), // Very large fractional inputs + ("A2", "=TIME(1e6, 1e6, 1e6)"), // Scientific notation inputs + ("A3", "=TIME(0.000001, 0.000001, 0.000001)"), // Very small fractional inputs + // Large day values for component extraction (stress test) + ("B1", "=HOUR(999999.999)"), // Almost a million days + ("B2", "=MINUTE(999999.999)"), + ("B3", "=SECOND(999999.999)"), + // Edge case: exactly 1.0 (should be midnight of next day) + ("C1", "=HOUR(1.0)"), + ("C2", "=MINUTE(1.0)"), + ("C3", "=SECOND(1.0)"), + // Very high precision values + ("D1", "=HOUR(0.999999999999)"), // Almost exactly 24:00:00 + ("D2", "=MINUTE(0.999999999999)"), + ("D3", "=SECOND(0.999999999999)"), + ]); + + // Large fractional inputs should be floored and normalized + let result_a1 = model._get_text("A1").parse::().unwrap(); + assert!( + (0.0..1.0).contains(&result_a1), + "Result should be valid time fraction" + ); + + // Component extraction should work with very large values + let hour_b1 = model._get_text("B1").parse::().unwrap(); + assert!((0..=23).contains(&hour_b1), "Hour should be 0-23"); + + // Exactly 1.0 should be midnight (start of next day) + assert_eq!(model._get_text("C1"), *"0"); + assert_eq!(model._get_text("C2"), *"0"); + assert_eq!(model._get_text("C3"), *"0"); + + // Very high precision should still extract valid components + let hour_d1 = model._get_text("D1").parse::().unwrap(); + assert!((0..=23).contains(&hour_d1), "Hour should be 0-23"); +} + +#[test] +fn test_timevalue_malformed_but_parseable() { + // Test missing edge case: malformed but potentially parseable strings + let model = test_time_expressions(&[ + // Test various malformed but potentially parseable time strings + ("A1", "=TIMEVALUE(\"14:30:00.123\")"), // Milliseconds (might be truncated) + ("A2", "=TIMEVALUE(\"14:30:00.999\")"), // High precision milliseconds + ("A3", "=TIMEVALUE(\"02:30:00\")"), // Leading zero hours + ("A4", "=TIMEVALUE(\"2:05:00\")"), // Single digit hour, zero-padded minute + // Boundary cases for AM/PM parsing + ("B1", "=TIMEVALUE(\"11:59:59 PM\")"), // Just before midnight + ("B2", "=TIMEVALUE(\"12:00:01 AM\")"), // Just after midnight + ("B3", "=TIMEVALUE(\"12:00:01 PM\")"), // Just after noon + ("B4", "=TIMEVALUE(\"11:59:59 AM\")"), // Just before noon + // Test various date-time combinations + ("C1", "=TIMEVALUE(\"2023-12-31T23:59:59\")"), // ISO format at year end + ("C2", "=TIMEVALUE(\"2023-01-01 00:00:01\")"), // New year, just after midnight + // Test potential edge cases that might still be parseable + ("D1", "=TIMEVALUE(\"24:00:00\")"), // Should error (invalid hour) + ("D2", "=TIMEVALUE(\"23:60:00\")"), // Should error (invalid minute) + ("D3", "=TIMEVALUE(\"23:59:60\")"), // Should error (invalid second) + ]); + + // Milliseconds are not supported, should return a #VALUE! error like Excel + assert_eq!(model._get_text("A1"), *"#VALUE!"); + assert_eq!(model._get_text("A2"), *"#VALUE!"); + + // Leading zeros should work fine + assert_eq!(model._get_text("A3"), *TIME_2_30_AM); // 02:30:00 should parse as 2:30:00 + + // AM/PM boundary cases should work + let result_b1 = model._get_text("B1").parse::().unwrap(); + assert!( + result_b1 > 0.99 && result_b1 < 1.0, + "11:59:59 PM should be very close to 1.0" + ); + + let result_b2 = model._get_text("B2").parse::().unwrap(); + assert!( + result_b2 > 0.0 && result_b2 < 0.01, + "12:00:01 AM should be very close to 0.0" + ); + + // ISO 8601 format with "T" separator should be parsed correctly + assert_eq!(model._get_text("C1"), *TIME_23_59_59); // 23:59:59 → almost midnight + assert_eq!(model._get_text("C2"), *TIME_00_00_01); // 00:00:01 → one second past midnight + + // Time parser normalizes edge cases to midnight (Excel compatibility) + assert_eq!(model._get_text("D1"), *"0"); // 24:00:00 = midnight of next day + assert_eq!(model._get_text("D2"), *"0"); // 23:60:00 normalizes to 24:00:00 = midnight + assert_eq!(model._get_text("D3"), *"0"); // 23:59:60 normalizes to 24:00:00 = midnight +} + +#[test] +fn test_performance_stress_with_extreme_values() { + // Test performance/stress cases with extreme values + let model = test_time_expressions(&[ + // Very large numbers that should still work + ("A1", "=TIME(2147483647, 0, 0)"), // Max i32 hours + ("A2", "=TIME(0, 2147483647, 0)"), // Max i32 minutes + ("A3", "=TIME(0, 0, 2147483647)"), // Max i32 seconds + // Component extraction with extreme day values + ("B1", "=HOUR(1e15)"), // Very large day number + ("B2", "=MINUTE(1e15)"), + ("B3", "=SECOND(1e15)"), + // Edge of floating point precision + ("C1", "=HOUR(1.7976931348623157e+308)"), // Near max f64 + ("C2", "=HOUR(2.2250738585072014e-308)"), // Near min positive f64 + // Multiple TIME function calls with large values + ("D1", "=TIME(1000000, 1000000, 1000000)"), // Large normalized values + ("D2", "=HOUR(D1)"), // Extract from large TIME result + ("D3", "=MINUTE(D1)"), + ("D4", "=SECOND(D1)"), + ]); + + // All results should be valid (not errors) even with extreme inputs + for cell in ["A1", "A2", "A3", "B1", "B2", "B3", "D1", "D2", "D3", "D4"] { + let result = model._get_text(cell); + assert!( + result != *"#ERROR!" && result != *"#NUM!" && result != *"#VALUE!", + "Cell {cell} should not error with extreme values: {result}", + ); + } + + // Results should be mathematically valid + let hour_b1 = model._get_text("B1").parse::().unwrap(); + let minute_b2 = model._get_text("B2").parse::().unwrap(); + let second_b3 = model._get_text("B3").parse::().unwrap(); + + assert!((0..=23).contains(&hour_b1)); + assert!((0..=59).contains(&minute_b2)); + assert!((0..=59).contains(&second_b3)); + + // TIME function results should be valid time fractions + let time_d1 = model._get_text("D1").parse::().unwrap(); + assert!( + (0.0..1.0).contains(&time_d1), + "TIME result should be valid fraction" + ); +} diff --git a/base/src/test/test_networkdays.rs b/base/src/test/test_networkdays.rs new file mode 100644 index 000000000..8e672d0c0 --- /dev/null +++ b/base/src/test/test_networkdays.rs @@ -0,0 +1,347 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +// Test data: Jan 1-10, 2023 week +const JAN_1_2023: i32 = 44927; // Sunday +const JAN_2_2023: i32 = 44928; // Monday +const JAN_6_2023: i32 = 44932; // Friday +const JAN_9_2023: i32 = 44935; // Monday +const JAN_10_2023: i32 = 44936; // Tuesday + +#[test] +fn networkdays_calculates_weekdays_excluding_weekends() { + let mut model = new_empty_model(); + + model._set("A1", &format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023})")); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "7", + "Should count 7 weekdays in 10-day span" + ); +} + +#[test] +fn networkdays_handles_reverse_date_order() { + let mut model = new_empty_model(); + + model._set("A1", &format!("=NETWORKDAYS({JAN_10_2023},{JAN_1_2023})")); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "-7", + "Reversed dates should return negative count" + ); +} + +#[test] +fn networkdays_excludes_holidays_from_weekdays() { + let mut model = new_empty_model(); + + model._set( + "A1", + &format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},{JAN_9_2023})"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "6", + "Should exclude Monday holiday from 7 weekdays" + ); +} + +#[test] +fn networkdays_handles_same_start_end_date() { + let mut model = new_empty_model(); + + model._set("A1", &format!("=NETWORKDAYS({JAN_9_2023},{JAN_9_2023})")); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "1", + "Same weekday date should count as 1 workday" + ); +} + +#[test] +fn networkdays_accepts_holiday_ranges() { + let mut model = new_empty_model(); + + model._set("B1", &JAN_2_2023.to_string()); + model._set("B2", &JAN_6_2023.to_string()); + model._set( + "A1", + &format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},B1:B2)"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "5", + "Should exclude 2 holidays from 7 weekdays" + ); +} + +#[test] +fn networkdays_intl_uses_standard_weekend_by_default() { + let mut model = new_empty_model(); + + model._set( + "A1", + &format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023})"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "7", + "Default should be Saturday-Sunday weekend" + ); +} + +#[test] +fn networkdays_intl_supports_numeric_weekend_patterns() { + let mut model = new_empty_model(); + + // Pattern 2 = Sunday-Monday weekend + model._set( + "A1", + &format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023},2)"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "6", + "Sunday-Monday weekend should give 6 workdays" + ); +} + +#[test] +fn networkdays_intl_supports_single_day_weekends() { + let mut model = new_empty_model(); + + // Pattern 11 = Sunday only weekend + model._set( + "A1", + &format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023},11)"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "8", + "Sunday-only weekend should give 8 workdays" + ); +} + +#[test] +fn networkdays_intl_supports_string_weekend_patterns() { + let mut model = new_empty_model(); + + // "0000110" = Friday-Saturday weekend + model._set( + "A1", + &format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023},\"0000110\")"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "8", + "Friday-Saturday weekend should give 8 workdays" + ); +} + +#[test] +fn networkdays_intl_no_weekends_counts_all_days() { + let mut model = new_empty_model(); + + model._set( + "A1", + &format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023},\"0000000\")"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "10", + "No weekends should count all 10 days" + ); +} + +#[test] +fn networkdays_intl_combines_custom_weekends_with_holidays() { + let mut model = new_empty_model(); + + model._set( + "A1", + &format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023},\"0000110\",{JAN_9_2023})"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "7", + "Should exclude both weekend and holiday" + ); +} + +#[test] +fn networkdays_validates_argument_count() { + let mut model = new_empty_model(); + + model._set("A1", "=NETWORKDAYS()"); + model._set("A2", "=NETWORKDAYS(1,2,3,4)"); + model._set("A3", "=NETWORKDAYS.INTL()"); + model._set("A4", "=NETWORKDAYS.INTL(1,2,3,4,5)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#ERROR!"); + assert_eq!(model._get_text("A2"), "#ERROR!"); + assert_eq!(model._get_text("A3"), "#ERROR!"); + assert_eq!(model._get_text("A4"), "#ERROR!"); +} + +#[test] +fn networkdays_rejects_invalid_dates() { + let mut model = new_empty_model(); + + model._set("A1", "=NETWORKDAYS(-1,100)"); + model._set("A2", "=NETWORKDAYS(1,3000000)"); + model._set("A3", "=NETWORKDAYS(\"text\",100)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#NUM!"); + assert_eq!(model._get_text("A2"), "#NUM!"); + assert_eq!(model._get_text("A3"), "#VALUE!"); +} + +#[test] +fn networkdays_intl_rejects_invalid_weekend_patterns() { + let mut model = new_empty_model(); + + model._set("A1", "=NETWORKDAYS.INTL(1,10,99)"); + model._set("A2", "=NETWORKDAYS.INTL(1,10,\"111110\")"); + model._set("A3", "=NETWORKDAYS.INTL(1,10,\"11111000\")"); + model._set("A4", "=NETWORKDAYS.INTL(1,10,\"1111102\")"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#NUM!"); + assert_eq!(model._get_text("A2"), "#VALUE!"); + assert_eq!(model._get_text("A3"), "#VALUE!"); + assert_eq!(model._get_text("A4"), "#VALUE!"); +} + +#[test] +fn networkdays_rejects_invalid_holidays() { + let mut model = new_empty_model(); + + model._set("B1", "invalid"); + model._set( + "A1", + &format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},B1)"), + ); + model._set( + "A2", + &format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},-1)"), + ); + + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "#VALUE!", + "Should reject non-numeric holidays" + ); + assert_eq!( + model._get_text("A2"), + "#NUM!", + "Should reject out-of-range holidays" + ); +} + +#[test] +fn networkdays_handles_weekend_only_periods() { + let mut model = new_empty_model(); + + let saturday = JAN_1_2023 - 1; + model._set("A1", &format!("=NETWORKDAYS({saturday},{JAN_1_2023})")); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "0", + "Weekend-only period should count 0 workdays" + ); +} + +#[test] +fn networkdays_ignores_holidays_outside_date_range() { + let mut model = new_empty_model(); + + let future_holiday = JAN_10_2023 + 100; + model._set( + "A1", + &format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},{future_holiday})"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "7", + "Out-of-range holidays should be ignored" + ); +} + +#[test] +fn networkdays_handles_empty_holiday_ranges() { + let mut model = new_empty_model(); + + model._set( + "A1", + &format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},B1:B3)"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "7", + "Empty holiday range should be treated as no holidays" + ); +} + +#[test] +fn networkdays_handles_minimum_valid_dates() { + let mut model = new_empty_model(); + + model._set("A1", "=NETWORKDAYS(1,7)"); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "5", + "Should handle earliest Excel dates correctly" + ); +} + +#[test] +fn networkdays_handles_large_date_ranges_efficiently() { + let mut model = new_empty_model(); + + model._set("A1", "=NETWORKDAYS(1,365)"); + model.evaluate(); + + assert!( + !model._get_text("A1").starts_with('#'), + "Large ranges should not error" + ); +} diff --git a/docs/src/functions/date-and-time.md b/docs/src/functions/date-and-time.md index 2a479420e..3d70fc1a6 100644 --- a/docs/src/functions/date-and-time.md +++ b/docs/src/functions/date-and-time.md @@ -6,33 +6,33 @@ lang: en-US # Date and Time functions -At the moment IronCalc only supports a few function in this section. +At the moment IronCalc supports most functions in this section. You can track the progress in this [GitHub issue](https://github.com/ironcalc/IronCalc/issues/48). | Function | Status | Documentation | | ---------------- | ---------------------------------------------- | ------------- | | DATE | | – | -| DATEDIF | | – | -| DATEVALUE | | – | +| DATEDIF | | [DATEDIF](date_and_time/datedif) | +| DATEVALUE | | [DATEVALUE](date_and_time/datevalue) | | DAY | | [DAY](date_and_time/day) | -| DAYS | | – | -| DAYS360 | | – | +| DAYS | | – | +| DAYS360 | | – | | EDATE | | – | | EOMONTH | | – | -| HOUR | | – | -| ISOWEEKNUM | | – | -| MINUTE | | – | +| HOUR | | – | +| ISOWEEKNUM | | – | +| MINUTE | | – | | MONTH | | [MONTH](date_and_time/month) | -| NETWORKDAYS | | – | -| NETWORKDAYS.INTL | | – | +| NETWORKDAYS | | [NETWORKDAYS](date_and_time/networkdays) | +| NETWORKDAYS.INTL | | [NETWORKDAYS.INTL](date_and_time/networkdays.intl) | | NOW | | – | -| SECOND | | – | -| TIME | | – | -| TIMEVALUE | | – | +| SECOND | | – | +| TIME | | – | +| TIMEVALUE | | – | | TODAY | | – | -| WEEKDAY | | – | -| WEEKNUM | | – | -| WORKDAY | | – | -| WORKDAY.INTL | | – | +| WEEKDAY | | – | +| WEEKNUM | | – | +| WORKDAY | | – | +| WORKDAY.INTL | | – | | YEAR | | [YEAR](date_and_time/year) | -| YEARFRAC | | – | +| YEARFRAC | | – | diff --git a/docs/src/functions/date_and_time/datedif.md b/docs/src/functions/date_and_time/datedif.md index 1cb23a380..6b19c6e9e 100644 --- a/docs/src/functions/date_and_time/datedif.md +++ b/docs/src/functions/date_and_time/datedif.md @@ -7,6 +7,5 @@ lang: en-US # DATEDIF ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/date_and_time/datevalue.md b/docs/src/functions/date_and_time/datevalue.md index 5f211e8c5..da62d378c 100644 --- a/docs/src/functions/date_and_time/datevalue.md +++ b/docs/src/functions/date_and_time/datevalue.md @@ -7,6 +7,5 @@ lang: en-US # DATEVALUE ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/date_and_time/networkdays.intl.md b/docs/src/functions/date_and_time/networkdays.intl.md index d3e3937b5..893a3e67f 100644 --- a/docs/src/functions/date_and_time/networkdays.intl.md +++ b/docs/src/functions/date_and_time/networkdays.intl.md @@ -4,9 +4,73 @@ outline: deep lang: en-US --- -# NETWORKDAYS.INTL +# NETWORKDAYS.INTL function ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) -::: \ No newline at end of file +**Note:** This draft page is under construction 🚧 +::: + +## Overview +NETWORKDAYS.INTL is a function of the Date and Time category that calculates the number of working days between two dates, with customizable weekend definitions and optionally specified holidays. + +## Usage + +### Syntax +**NETWORKDAYS.INTL(start_date, end_date, [weekend], [holidays]) => workdays** + +### Argument descriptions +* *start_date* ([number](/features/value-types#numbers), required). The start date expressed as a [serial number](/features/serial-numbers.md). +* *end_date* ([number](/features/value-types#numbers), required). The end date expressed as a [serial number](/features/serial-numbers.md). +* *weekend* ([number](/features/value-types#numbers) or [string](/features/value-types#strings), optional). Defines which days are considered weekends. Default is 1 (Saturday-Sunday). +* *holidays* ([array](/features/value-types#arrays) or [range](/features/ranges), optional). A list of dates to exclude from the calculation, expressed as serial numbers. + +### Weekend parameter options +The _weekend_ parameter can be specified in two ways: + +**Numeric codes:** +- 1 (default): Saturday and Sunday +- 2: Sunday and Monday +- 3: Monday and Tuesday +- 4: Tuesday and Wednesday +- 5: Wednesday and Thursday +- 6: Thursday and Friday +- 7: Friday and Saturday +- 11: Sunday only +- 12: Monday only +- 13: Tuesday only +- 14: Wednesday only +- 15: Thursday only +- 16: Friday only +- 17: Saturday only + +**String pattern:** A 7-character string of "0" and "1" where "1" indicates a weekend day. The string represents Monday through Sunday. For example, "0000011" means Saturday and Sunday are weekends. + +### Additional guidance +- If the supplied _start_date_ and _end_date_ arguments have fractional parts, NETWORKDAYS.INTL uses their [floor values](https://en.wikipedia.org/wiki/Floor_and_ceiling_functions). +- If _start_date_ is later than _end_date_, the function returns a negative number. +- Empty cells in the _holidays_ array are ignored. +- The calculation includes both the start and end dates if they are workdays. + +### Returned value +NETWORKDAYS.INTL returns a [number](/features/value-types#numbers) representing the count of working days between the two dates. + +### Error conditions +* In common with many other IronCalc functions, NETWORKDAYS.INTL propagates errors that are found in its arguments. +* If fewer than 2 or more than 4 arguments are supplied, then NETWORKDAYS.INTL returns the [`#ERROR!`](/features/error-types.md#error) error. +* If the *start_date* or *end_date* arguments are not (or cannot be converted to) [numbers](/features/value-types#numbers), then NETWORKDAYS.INTL returns the [`#VALUE!`](/features/error-types.md#value) error. +* If the *start_date* or *end_date* values are outside the valid date range, then NETWORKDAYS.INTL returns the [`#NUM!`](/features/error-types.md#num) error. +* If the *weekend* parameter is an invalid numeric code or an improperly formatted string, then NETWORKDAYS.INTL returns the [`#NUM!`](/features/error-types.md#num) or [`#VALUE!`](/features/error-types.md#value) error. +* If the *holidays* array contains non-numeric values, then NETWORKDAYS.INTL returns the [`#VALUE!`](/features/error-types.md#value) error. + + + +## Details +IronCalc utilizes Rust's [chrono](https://docs.rs/chrono/latest/chrono/) crate to implement the NETWORKDAYS.INTL function. This function provides more flexibility than NETWORKDAYS by allowing custom weekend definitions. + +## Examples +[See some examples in IronCalc](https://app.ironcalc.com/?example=networkdays-intl). + +## Links +* See also IronCalc's [NETWORKDAYS](/functions/date_and_time/networkdays.md) function for the basic version with fixed weekends. +* Visit Microsoft Excel's [NETWORKDAYS.INTL function](https://support.microsoft.com/en-us/office/networkdays-intl-function-a9b26239-4f20-46a1-9ab8-4e925bfd5e28) page. +* Both [Google Sheets](https://support.google.com/docs/answer/3093019) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/NETWORKDAYS.INTL) provide versions of the NETWORKDAYS.INTL function. \ No newline at end of file diff --git a/docs/src/functions/date_and_time/networkdays.md b/docs/src/functions/date_and_time/networkdays.md index 9fd886f14..8a2c7602a 100644 --- a/docs/src/functions/date_and_time/networkdays.md +++ b/docs/src/functions/date_and_time/networkdays.md @@ -4,9 +4,51 @@ outline: deep lang: en-US --- -# NETWORKDAYS +# NETWORKDAYS function ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) -::: \ No newline at end of file +**Note:** This draft page is under construction 🚧 +::: + +## Overview +NETWORKDAYS is a function of the Date and Time category that calculates the number of working days between two dates, excluding weekends (Saturday and Sunday by default) and optionally specified holidays. + +## Usage + +### Syntax +**NETWORKDAYS(start_date, end_date, [holidays]) => workdays** + +### Argument descriptions +* *start_date* ([number](/features/value-types#numbers), required). The start date expressed as a [serial number](/features/serial-numbers.md). +* *end_date* ([number](/features/value-types#numbers), required). The end date expressed as a [serial number](/features/serial-numbers.md). +* *holidays* ([array](/features/value-types#arrays) or [range](/features/ranges), optional). A list of dates to exclude from the calculation, expressed as serial numbers. + +### Additional guidance +- If the supplied _start_date_ and _end_date_ arguments have fractional parts, NETWORKDAYS uses their [floor values](https://en.wikipedia.org/wiki/Floor_and_ceiling_functions). +- If _start_date_ is later than _end_date_, the function returns a negative number. +- Weekend days are Saturday and Sunday by default. Use [NETWORKDAYS.INTL](networkdays.intl) for custom weekend definitions. +- Empty cells in the _holidays_ array are ignored. +- The calculation includes both the start and end dates if they are workdays. + +### Returned value +NETWORKDAYS returns a [number](/features/value-types#numbers) representing the count of working days between the two dates. + +### Error conditions +* In common with many other IronCalc functions, NETWORKDAYS propagates errors that are found in its arguments. +* If fewer than 2 or more than 3 arguments are supplied, then NETWORKDAYS returns the [`#ERROR!`](/features/error-types.md#error) error. +* If the *start_date* or *end_date* arguments are not (or cannot be converted to) [numbers](/features/value-types#numbers), then NETWORKDAYS returns the [`#VALUE!`](/features/error-types.md#value) error. +* If the *start_date* or *end_date* values are outside the valid date range, then NETWORKDAYS returns the [`#NUM!`](/features/error-types.md#num) error. +* If the *holidays* array contains non-numeric values, then NETWORKDAYS returns the [`#VALUE!`](/features/error-types.md#value) error. + + + +## Details +IronCalc utilizes Rust's [chrono](https://docs.rs/chrono/latest/chrono/) crate to implement the NETWORKDAYS function. The function treats Saturday and Sunday as weekend days. + +## Examples +[See some examples in IronCalc](https://app.ironcalc.com/?example=networkdays). + +## Links +* See also IronCalc's [NETWORKDAYS.INTL](/functions/date_and_time/networkdays.intl.md) function for custom weekend definitions. +* Visit Microsoft Excel's [NETWORKDAYS function](https://support.microsoft.com/en-us/office/networkdays-function-48e717bf-a7a3-495f-969e-5005e3eb18e7) page. +* Both [Google Sheets](https://support.google.com/docs/answer/3093018) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/NETWORKDAYS) provide versions of the NETWORKDAYS function. \ No newline at end of file diff --git a/docs/src/functions/statistical.md b/docs/src/functions/statistical.md index 6842212c3..74e0a98a4 100644 --- a/docs/src/functions/statistical.md +++ b/docs/src/functions/statistical.md @@ -90,10 +90,12 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | PHI | | – | | POISSON.DIST | | – | | PROB | | – | -| QUARTILE.EXC | | – | -| QUARTILE.INC | | – | -| RANK.AVG | | – | -| RANK.EQ | | – | +| QUARTILE | | – | +| QUARTILE.EXC | | – | +| QUARTILE.INC | | – | +| RANK | | – | +| RANK.AVG | | – | +| RANK.EQ | | – | | RSQ | | – | | SKEW | | – | | SKEW.P | | – | diff --git a/docs/src/functions/statistical/quartile.exc.md b/docs/src/functions/statistical/quartile.exc.md index dde3e34a0..6674ab523 100644 --- a/docs/src/functions/statistical/quartile.exc.md +++ b/docs/src/functions/statistical/quartile.exc.md @@ -7,6 +7,5 @@ lang: en-US # QUARTILE.EXC ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/statistical/quartile.inc.md b/docs/src/functions/statistical/quartile.inc.md index 8d2a1ff75..a41348bcd 100644 --- a/docs/src/functions/statistical/quartile.inc.md +++ b/docs/src/functions/statistical/quartile.inc.md @@ -7,6 +7,5 @@ lang: en-US # QUARTILE.INC ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/statistical/quartile.md b/docs/src/functions/statistical/quartile.md new file mode 100644 index 000000000..5ff225283 --- /dev/null +++ b/docs/src/functions/statistical/quartile.md @@ -0,0 +1,11 @@ +--- +layout: doc +outline: deep +lang: en-US +--- + +# QUARTILE + +::: warning +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). +::: \ No newline at end of file diff --git a/docs/src/functions/statistical/rank.avg.md b/docs/src/functions/statistical/rank.avg.md index 16f656ec3..e8778df1d 100644 --- a/docs/src/functions/statistical/rank.avg.md +++ b/docs/src/functions/statistical/rank.avg.md @@ -7,6 +7,5 @@ lang: en-US # RANK.AVG ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/statistical/rank.eq.md b/docs/src/functions/statistical/rank.eq.md index d8efbe1a9..6f304e97d 100644 --- a/docs/src/functions/statistical/rank.eq.md +++ b/docs/src/functions/statistical/rank.eq.md @@ -7,6 +7,5 @@ lang: en-US # RANK.EQ ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/statistical/rank.md b/docs/src/functions/statistical/rank.md new file mode 100644 index 000000000..05e593dcb --- /dev/null +++ b/docs/src/functions/statistical/rank.md @@ -0,0 +1,11 @@ +--- +layout: doc +outline: deep +lang: en-US +--- + +# RANK + +::: warning +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). +::: \ No newline at end of file