diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 80f194360..2a8701d43 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -366,14 +366,36 @@ enum Signature { Error, } -fn args_signature_no_args(arg_count: usize) -> Vec { - if arg_count == 0 { - vec![] - } else { - vec![Signature::Error; arg_count] - } +/// Macro to generate signature helper functions with standard error handling +/// +/// Usage examples: +/// - `signature_fn!(name, 0 => [])` - no args function +/// - `signature_fn!(name, 1 => [Scalar])` - single scalar +/// - `signature_fn!(name, 2 => [Vector, Scalar])` - vector then scalar +/// - `signature_fn!(name, 2 => [Vector, Scalar], 3 => [Vector, Scalar, Scalar])` - multiple patterns +macro_rules! signature_fn { + ($name:ident, $($count:expr => [$($sig:ident),*]),+ $(,)?) => { + fn $name(arg_count: usize) -> Vec { + match arg_count { + $( + $count => vec![$(Signature::$sig),*], + )+ + _ => vec![Signature::Error; arg_count], + } + } + }; } +// Generate signature helper functions using macros +signature_fn!(args_signature_no_args, 0 => []); +signature_fn!(args_signature_one_vector, 1 => [Vector]); +signature_fn!(args_signature_two_vectors, 2 => [Vector, Vector]); +signature_fn!(args_signature_sumif, + 2 => [Vector, Scalar], + 3 => [Vector, Scalar, Vector] +); +signature_fn!(args_signature_vector_scalar, 2 => [Vector, Scalar]); + fn args_signature_scalars( arg_count: usize, required_count: usize, @@ -386,119 +408,43 @@ fn args_signature_scalars( } } -fn args_signature_one_vector(arg_count: usize) -> Vec { - if arg_count == 1 { - vec![Signature::Vector] - } else { - vec![Signature::Error; arg_count] - } -} - -fn args_signature_sumif(arg_count: usize) -> Vec { - if arg_count == 2 { - vec![Signature::Vector, Signature::Scalar] - } else if arg_count == 3 { - vec![Signature::Vector, Signature::Scalar, Signature::Vector] - } else { - vec![Signature::Error; arg_count] - } -} - // 1 or none scalars -fn args_signature_sheet(arg_count: usize) -> Vec { - if arg_count == 0 { - vec![] - } else if arg_count == 1 { - vec![Signature::Scalar] - } else { - vec![Signature::Error; arg_count] - } -} - -fn args_signature_hlookup(arg_count: usize) -> Vec { - if arg_count == 3 { - vec![Signature::Vector, Signature::Vector, Signature::Scalar] - } else if arg_count == 4 { - vec![ - Signature::Vector, - Signature::Vector, - Signature::Scalar, - Signature::Vector, - ] - } else { - vec![Signature::Error; arg_count] - } -} - -fn args_signature_index(arg_count: usize) -> Vec { - if arg_count == 2 { - vec![Signature::Vector, Signature::Scalar] - } else if arg_count == 3 { - vec![Signature::Vector, Signature::Scalar, Signature::Scalar] - } else if arg_count == 4 { - vec![ - Signature::Vector, - Signature::Scalar, - Signature::Scalar, - Signature::Scalar, - ] - } else { - vec![Signature::Error; arg_count] - } -} - -fn args_signature_lookup(arg_count: usize) -> Vec { - if arg_count == 2 { - vec![Signature::Vector, Signature::Vector] - } else if arg_count == 3 { - vec![Signature::Vector, Signature::Vector, Signature::Vector] - } else { - vec![Signature::Error; arg_count] - } -} - -fn args_signature_match(arg_count: usize) -> Vec { - if arg_count == 2 { - vec![Signature::Vector, Signature::Vector] - } else if arg_count == 3 { - vec![Signature::Vector, Signature::Vector, Signature::Scalar] - } else { - vec![Signature::Error; arg_count] - } -} - -fn args_signature_offset(arg_count: usize) -> Vec { - if arg_count == 3 { - vec![Signature::Vector, Signature::Scalar, Signature::Scalar] - } else if arg_count == 4 { - vec![ - Signature::Vector, - Signature::Scalar, - Signature::Scalar, - Signature::Scalar, - ] - } else if arg_count == 5 { - vec![ - Signature::Vector, - Signature::Scalar, - Signature::Scalar, - Signature::Scalar, - Signature::Scalar, - ] - } else { - vec![Signature::Error; arg_count] - } -} - -fn args_signature_row(arg_count: usize) -> Vec { - if arg_count == 0 { - vec![] - } else if arg_count == 1 { - vec![Signature::Vector] - } else { - vec![Signature::Error; arg_count] - } -} +signature_fn!(args_signature_sheet, + 0 => [], + 1 => [Scalar] +); + +signature_fn!(args_signature_hlookup, + 3 => [Vector, Vector, Scalar], + 4 => [Vector, Vector, Scalar, Vector] +); + +signature_fn!(args_signature_index, + 2 => [Vector, Scalar], + 3 => [Vector, Scalar, Scalar], + 4 => [Vector, Scalar, Scalar, Scalar] +); + +signature_fn!(args_signature_lookup, + 2 => [Vector, Vector], + 3 => [Vector, Vector, Vector] +); + +signature_fn!(args_signature_match, + 2 => [Vector, Vector], + 3 => [Vector, Vector, Scalar] +); + +signature_fn!(args_signature_offset, + 3 => [Vector, Scalar, Scalar], + 4 => [Vector, Scalar, Scalar, Scalar], + 5 => [Vector, Scalar, Scalar, Scalar, Scalar] +); + +signature_fn!(args_signature_row, + 0 => [], + 1 => [Vector] +); fn args_signature_xlookup(arg_count: usize) -> Vec { if !(3..=6).contains(&arg_count) { @@ -539,41 +485,24 @@ fn args_signature_npv(arg_count: usize) -> Vec { result } -fn args_signature_irr(arg_count: usize) -> Vec { - if arg_count > 2 { - vec![Signature::Error; arg_count] - } else if arg_count == 1 { - vec![Signature::Vector] - } else { - vec![Signature::Vector, Signature::Scalar] - } -} +signature_fn!(args_signature_irr, + 1 => [Vector], + 2 => [Vector, Scalar] +); -fn args_signature_xirr(arg_count: usize) -> Vec { - if arg_count == 2 { - vec![Signature::Vector; arg_count] - } else if arg_count == 3 { - vec![Signature::Vector, Signature::Vector, Signature::Scalar] - } else { - vec![Signature::Error; arg_count] - } -} +signature_fn!(args_signature_xirr, + 2 => [Vector, Vector], + 3 => [Vector, Vector, Scalar] +); -fn args_signature_mirr(arg_count: usize) -> Vec { - if arg_count != 3 { - vec![Signature::Error; arg_count] - } else { - vec![Signature::Vector, Signature::Scalar, Signature::Scalar] - } -} +signature_fn!(args_signature_mirr, 3 => [Vector, Scalar, Scalar]); -fn args_signature_xnpv(arg_count: usize) -> Vec { - if arg_count != 3 { - vec![Signature::Error; arg_count] - } else { - vec![Signature::Scalar, Signature::Vector, Signature::Vector] - } -} +signature_fn!(args_signature_xnpv, 3 => [Scalar, Vector, Vector]); + +signature_fn!(args_signature_rank, + 2 => [Scalar, Vector], + 3 => [Scalar, Vector, Scalar] +); // FIXME: This is terrible duplications of efforts. We use the signature in at least three different places: // 1. When computing the function @@ -785,6 +714,47 @@ 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::VarP | Function::VarS => vec![Signature::Vector; arg_count], + Function::Correl => args_signature_two_vectors(arg_count), + Function::Large => args_signature_vector_scalar(arg_count), + Function::Small => args_signature_vector_scalar(arg_count), + Function::Median => vec![Signature::Vector; arg_count], + Function::StdevS => vec![Signature::Vector; arg_count], + Function::StdevP => vec![Signature::Vector; arg_count], + Function::Stdeva => vec![Signature::Vector; arg_count], + Function::Stdevpa => vec![Signature::Vector; arg_count], + Function::Vara => vec![Signature::Vector; arg_count], + Function::Varpa => vec![Signature::Vector; arg_count], + Function::Skew | Function::SkewP => 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::PercentileExc => args_signature_vector_scalar(arg_count), + Function::PercentileInc => args_signature_vector_scalar(arg_count), + Function::PercentrankExc => { + if arg_count == 2 { + vec![Signature::Vector, Signature::Scalar] + } else if arg_count == 3 { + vec![Signature::Vector, Signature::Scalar, Signature::Scalar] + } else { + vec![Signature::Error; arg_count] + } + } + Function::PercentrankInc => { + if arg_count == 2 { + vec![Signature::Vector, Signature::Scalar] + } else if arg_count == 3 { + vec![Signature::Vector, Signature::Scalar, Signature::Scalar] + } else { + vec![Signature::Error; arg_count] + } + } + Function::Rank | Function::RankAvg | Function::RankEq => args_signature_rank(arg_count), + Function::Intercept | Function::Slope => args_signature_two_vectors(arg_count), } } @@ -990,5 +960,23 @@ 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::VarP | Function::VarS | Function::Correl => not_implemented(args), + Function::Large => not_implemented(args), + Function::Small => not_implemented(args), + Function::Median => not_implemented(args), + Function::StdevS => not_implemented(args), + Function::StdevP => not_implemented(args), + Function::Stdeva => not_implemented(args), + Function::Stdevpa => not_implemented(args), + Function::Vara => not_implemented(args), + Function::Varpa => not_implemented(args), + Function::Skew | Function::SkewP => not_implemented(args), + Function::Quartile | Function::QuartileExc | Function::QuartileInc => not_implemented(args), + Function::Rank | Function::RankAvg | Function::RankEq => scalar_arguments(args), + Function::PercentileExc => not_implemented(args), + Function::PercentileInc => not_implemented(args), + Function::PercentrankExc => not_implemented(args), + Function::PercentrankInc => not_implemented(args), + Function::Intercept | Function::Slope => scalar_arguments(args), } } diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 21c8f72da..732c8e16f 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -145,6 +145,32 @@ pub enum Function { Maxifs, Minifs, Geomean, + VarP, + VarS, + Correl, + Large, + Small, + Median, + StdevS, + StdevP, + Stdeva, + Stdevpa, + Vara, + Varpa, + Skew, + SkewP, + Quartile, + QuartileExc, + QuartileInc, + Rank, + RankAvg, + RankEq, + PercentileExc, + PercentileInc, + PercentrankExc, + PercentrankInc, + Intercept, + Slope, // Date and time Date, @@ -253,7 +279,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -357,6 +383,32 @@ impl Function { Function::Maxifs, Function::Minifs, Function::Geomean, + Function::VarP, + Function::VarS, + Function::Correl, + Function::Large, + Function::Small, + Function::Median, + Function::StdevS, + Function::StdevP, + Function::Stdeva, + Function::Stdevpa, + Function::Vara, + Function::Varpa, + Function::Skew, + Function::SkewP, + Function::Quartile, + Function::QuartileExc, + Function::QuartileInc, + Function::Rank, + Function::RankAvg, + Function::RankEq, + Function::PercentileExc, + Function::PercentileInc, + Function::PercentrankExc, + Function::PercentrankInc, + Function::Intercept, + Function::Slope, Function::Year, Function::Day, Function::Month, @@ -476,6 +528,10 @@ impl Function { Function::Unicode => "_xlfn.UNICODE".to_string(), Function::Rri => "_xlfn.RRI".to_string(), Function::Pduration => "_xlfn.PDURATION".to_string(), + Function::PercentileExc => "_xlfn.PERCENTILE.EXC".to_string(), + Function::PercentileInc => "_xlfn.PERCENTILE.INC".to_string(), + Function::PercentrankExc => "_xlfn.PERCENTRANK.EXC".to_string(), + Function::PercentrankInc => "_xlfn.PERCENTRANK.INC".to_string(), Function::Bitand => "_xlfn.BITAND".to_string(), Function::Bitor => "_xlfn.BITOR".to_string(), Function::Bitxor => "_xlfn.BITXOR".to_string(), @@ -625,6 +681,32 @@ impl Function { "MAXIFS" | "_XLFN.MAXIFS" => Some(Function::Maxifs), "MINIFS" | "_XLFN.MINIFS" => Some(Function::Minifs), "GEOMEAN" => Some(Function::Geomean), + "VAR.P" => Some(Function::VarP), + "VAR.S" => Some(Function::VarS), + "CORREL" => Some(Function::Correl), + "LARGE" => Some(Function::Large), + "SMALL" => Some(Function::Small), + "MEDIAN" => Some(Function::Median), + "STDEV.S" => Some(Function::StdevS), + "STDEV.P" => Some(Function::StdevP), + "STDEVA" => Some(Function::Stdeva), + "STDEVPA" => Some(Function::Stdevpa), + "VARA" => Some(Function::Vara), + "VARPA" => Some(Function::Varpa), + "SKEW" => Some(Function::Skew), + "SKEW.P" | "_XLFN.SKEW.P" => Some(Function::SkewP), + "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), + "PERCENTILE.EXC" | "_XLFN.PERCENTILE.EXC" => Some(Function::PercentileExc), + "PERCENTILE.INC" | "_XLFN.PERCENTILE.INC" => Some(Function::PercentileInc), + "PERCENTRANK.EXC" | "_XLFN.PERCENTRANK.EXC" => Some(Function::PercentrankExc), + "PERCENTRANK.INC" | "_XLFN.PERCENTRANK.INC" => Some(Function::PercentrankInc), + "INTERCEPT" => Some(Function::Intercept), + "SLOPE" => Some(Function::Slope), // Date and Time "YEAR" => Some(Function::Year), "DAY" => Some(Function::Day), @@ -836,6 +918,32 @@ impl fmt::Display for Function { Function::Maxifs => write!(f, "MAXIFS"), Function::Minifs => write!(f, "MINIFS"), Function::Geomean => write!(f, "GEOMEAN"), + Function::VarP => write!(f, "VAR.P"), + Function::VarS => write!(f, "VAR.S"), + Function::Correl => write!(f, "CORREL"), + Function::Large => write!(f, "LARGE"), + Function::Small => write!(f, "SMALL"), + Function::Median => write!(f, "MEDIAN"), + Function::StdevS => write!(f, "STDEV.S"), + Function::StdevP => write!(f, "STDEV.P"), + Function::Stdeva => write!(f, "STDEVA"), + Function::Stdevpa => write!(f, "STDEVPA"), + Function::Vara => write!(f, "VARA"), + Function::Varpa => write!(f, "VARPA"), + Function::Skew => write!(f, "SKEW"), + Function::SkewP => write!(f, "SKEW.P"), + 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::PercentileExc => write!(f, "PERCENTILE.EXC"), + Function::PercentileInc => write!(f, "PERCENTILE.INC"), + Function::PercentrankExc => write!(f, "PERCENTRANK.EXC"), + Function::PercentrankInc => write!(f, "PERCENTRANK.INC"), + Function::Intercept => write!(f, "INTERCEPT"), + Function::Slope => write!(f, "SLOPE"), Function::Year => write!(f, "YEAR"), Function::Day => write!(f, "DAY"), Function::Month => write!(f, "MONTH"), @@ -1076,6 +1184,32 @@ impl Model { Function::Maxifs => self.fn_maxifs(args, cell), Function::Minifs => self.fn_minifs(args, cell), Function::Geomean => self.fn_geomean(args, cell), + Function::VarP => self.fn_var_p(args, cell), + Function::VarS => self.fn_var_s(args, cell), + Function::Correl => self.fn_correl(args, cell), + Function::Large => self.fn_large(args, cell), + Function::Small => self.fn_small(args, cell), + Function::Median => self.fn_median(args, cell), + Function::StdevS => self.fn_stdev_s(args, cell), + Function::StdevP => self.fn_stdev_p(args, cell), + Function::Stdeva => self.fn_stdeva(args, cell), + Function::Stdevpa => self.fn_stdevpa(args, cell), + Function::Vara => self.fn_vara(args, cell), + Function::Varpa => self.fn_varpa(args, cell), + Function::Skew => self.fn_skew(args, cell), + Function::SkewP => self.fn_skew_p(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), + Function::PercentileExc => self.fn_percentile_exc(args, cell), + Function::PercentileInc => self.fn_percentile_inc(args, cell), + Function::PercentrankExc => self.fn_percentrank_exc(args, cell), + Function::PercentrankInc => self.fn_percentrank_inc(args, cell), + Function::Intercept => self.fn_intercept(args, cell), + Function::Slope => self.fn_slope(args, cell), // Date and Time Function::Year => self.fn_year(args, cell), Function::Day => self.fn_day(args, cell), diff --git a/base/src/functions/statistical.rs b/base/src/functions/statistical.rs index cdb936406..e3e6c20c5 100644 --- a/base/src/functions/statistical.rs +++ b/base/src/functions/statistical.rs @@ -7,7 +7,10 @@ use crate::{ model::Model, }; -use super::util::build_criteria; +use super::util::{ + build_criteria, collect_numeric_values, collect_series, scan_range, CollectOpts, ScanRangeOpts, +}; +use std::cmp::Ordering; impl Model { pub(crate) fn fn_average(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { @@ -654,19 +657,382 @@ impl Model { if args.is_empty() { return CalcResult::new_args_number_error(cell); } - let mut count = 0.0; - let mut product = 1.0; + let values = match collect_numeric_values(self, args, cell, CollectOpts::default()) { + Ok(v) => v, + Err(err) => return err, + }; + + if values.is_empty() { + return CalcResult::Error { + error: Error::DIV, + origin: cell, + message: "Division by Zero".to_string(), + }; + } + + let product: f64 = values.iter().product(); + let count = values.len() as f64; + CalcResult::Number(product.powf(1.0 / count)) + } + pub(crate) fn fn_var_s(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.fn_var_generic(args, cell, true) + } + + pub(crate) fn fn_var_p(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.fn_var_generic(args, cell, false) + } + + fn fn_var_generic( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + sample: bool, + ) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let values = match collect_numeric_values(self, args, cell, CollectOpts::default()) { + Ok(v) => v, + Err(err) => return err, + }; + match self.compute_mean_variance_std(&values, sample, cell) { + Ok((_, variance, _)) => CalcResult::Number(variance), + Err(error) => error, + } + } + + pub(crate) fn fn_correl(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let series1 = match collect_series(self, &args[0], cell, true) { + Ok(s) => s, + Err(e) => return e, + }; + let series2 = match collect_series(self, &args[1], cell, true) { + Ok(s) => s, + Err(e) => return e, + }; + if series1.len() != series2.len() { + return CalcResult::Error { + error: Error::NA, + origin: cell, + message: "Arrays must be of the same size".to_string(), + }; + } + let mut pairs = Vec::new(); + for i in 0..series1.len() { + if let (Some(x), Some(y)) = (series1[i], series2[i]) { + pairs.push((x, y)); + } + } + let n = pairs.len() as f64; + if n < 2.0 { + return CalcResult::Error { + error: Error::DIV, + origin: cell, + message: "Division by 0".to_string(), + }; + } + let mut sum_x = 0.0; + let mut sum_y = 0.0; + for (x, y) in &pairs { + sum_x += *x; + sum_y += *y; + } + let mean_x = sum_x / n; + let mean_y = sum_y / n; + let mut num = 0.0; + let mut sx = 0.0; + let mut sy = 0.0; + for (x, y) in &pairs { + let dx = *x - mean_x; + let dy = *y - mean_y; + num += dx * dy; + sx += dx * dx; + sy += dy * dy; + } + if sx == 0.0 || sy == 0.0 { + return CalcResult::Error { + error: Error::DIV, + origin: cell, + message: "Division by 0".to_string(), + }; + } + CalcResult::Number(num / (sx.sqrt() * sy.sqrt())) + } + + pub(crate) fn fn_large(&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::Number(v) => values.push(v), + CalcResult::Range { left, right } => { + let range_values = match scan_range( + self, + &Range { left, right }, + cell, + ScanRangeOpts::default(), + |cell_result| match cell_result { + CalcResult::Number(v) => Ok(Some(*v)), + CalcResult::Error { .. } => Err(cell_result.clone()), + _ => Ok(None), + }, + ) { + Ok(v) => v, + Err(e) => return e, + }; + values.extend(range_values); + } + error @ CalcResult::Error { .. } => return error, + _ => {} + } + + let k = match self.get_number(&args[1], cell) { + Ok(v) => { + if v < 1.0 { + return CalcResult::new_error( + Error::NUM, + cell, + "k must be a positive number".to_string(), + ); + } + v as usize + } + Err(e) => return e, + }; + + if k > values.len() { + return CalcResult::new_error( + Error::NUM, + cell, + "k is larger than the number of values".to_string(), + ); + } + values.sort_by(|a, b| b.total_cmp(a)); + CalcResult::Number(values[k - 1]) + } + + pub(crate) fn fn_small(&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::Number(v) => values.push(v), + CalcResult::Range { left, right } => { + let range_values = match scan_range( + self, + &Range { left, right }, + cell, + ScanRangeOpts::default(), + |cell_result| match cell_result { + CalcResult::Number(v) => Ok(Some(*v)), + CalcResult::Error { .. } => Err(cell_result.clone()), + _ => Ok(None), + }, + ) { + Ok(v) => v, + Err(e) => return e, + }; + values.extend(range_values); + } + error @ CalcResult::Error { .. } => return error, + _ => {} + } + + let k = match self.get_number(&args[1], cell) { + Ok(v) => { + if v < 1.0 { + return CalcResult::new_error( + Error::NUM, + cell, + "k must be a positive number".to_string(), + ); + } + v as usize + } + Err(e) => return e, + }; + + if k > values.len() { + return CalcResult::new_error( + Error::NUM, + cell, + "k is larger than the number of values".to_string(), + ); + } + values.sort_by(|a, b| a.total_cmp(b)); + CalcResult::Number(values[k - 1]) + } + + pub(crate) fn fn_median(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let values = match collect_numeric_values(self, args, cell, CollectOpts::default()) { + Ok(v) => v, + Err(err) => return err, + }; + + // Filter out NaN values to ensure proper sorting + let mut values: Vec = values.into_iter().filter(|v| !v.is_nan()).collect(); + + if values.is_empty() { + return CalcResult::Error { + error: Error::DIV, + origin: cell, + message: "Division by Zero".to_string(), + }; + } + + // Sort values - NaN values have been filtered out, but use unwrap_or for safety + values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let len = values.len(); + if len % 2 == 1 { + CalcResult::Number(values[len / 2]) + } else { + CalcResult::Number((values[len / 2 - 1] + values[len / 2]) / 2.0) + } + } + + pub(crate) fn fn_stdev_s(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let values = match collect_numeric_values(self, args, cell, CollectOpts::default()) { + Ok(v) => v, + Err(err) => return err, + }; + self.stdev(&values, true, cell) + } + + pub(crate) fn fn_stdev_p(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let values = match collect_numeric_values(self, args, cell, CollectOpts::default()) { + Ok(v) => v, + Err(err) => return err, + }; + self.stdev(&values, false, cell) + } + + /// Computes mean, variance, and standard deviation for a dataset. + /// Returns (mean, variance, std_dev) or an error for insufficient data. + fn compute_mean_variance_std( + &self, + values: &[f64], + sample: bool, + cell: CellReferenceIndex, + ) -> Result<(f64, f64, f64), CalcResult> { + let n = values.len() as f64; + if (sample && n < 2.0) || (!sample && n == 0.0) { + return Err(CalcResult::new_error( + Error::DIV, + cell, + "Division by Zero".to_string(), + )); + } + + let mean = values.iter().sum::() / n; + let variance = values.iter().map(|v| (v - mean).powi(2)).sum::() + / if sample { n - 1.0 } else { n }; + let std_dev = variance.sqrt(); + + Ok((mean, variance, std_dev)) + } + + fn stdev(&self, values: &[f64], sample: bool, cell: CellReferenceIndex) -> CalcResult { + match self.compute_mean_variance_std(values, sample, cell) { + Ok((_, _, std_dev)) => CalcResult::Number(std_dev), + Err(error) => error, + } + } + + fn get_a_values( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> Result, CalcResult> { + let opts = CollectOpts { + include_bool_refs: true, + string_ref_as_zero: true, + }; + collect_numeric_values(self, args, cell, opts) + } + + pub(crate) fn fn_stdeva(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let values = match self.get_a_values(args, cell) { + Ok(v) => v, + Err(e) => return e, + }; + match self.compute_mean_variance_std(&values, true, cell) { + Ok((_, _, std_dev)) => CalcResult::Number(std_dev), + Err(error) => error, + } + } + + pub(crate) fn fn_stdevpa(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let values = match self.get_a_values(args, cell) { + Ok(v) => v, + Err(e) => return e, + }; + match self.compute_mean_variance_std(&values, false, cell) { + Ok((_, _, std_dev)) => CalcResult::Number(std_dev), + Err(error) => error, + } + } + + pub(crate) fn fn_vara(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let values = match self.get_a_values(args, cell) { + Ok(v) => v, + Err(e) => return e, + }; + match self.compute_mean_variance_std(&values, true, cell) { + Ok((_, variance, _)) => CalcResult::Number(variance), + Err(error) => error, + } + } + + pub(crate) fn fn_varpa(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let values = match self.get_a_values(args, cell) { + Ok(v) => v, + Err(e) => return e, + }; + match self.compute_mean_variance_std(&values, false, cell) { + Ok((_, variance, _)) => CalcResult::Number(variance), + Err(error) => error, + } + } + + pub(crate) fn fn_skew(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let mut values = Vec::new(); for arg in args { match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(value) => { - count += 1.0; - product *= value; - } + CalcResult::Number(value) => values.push(value), CalcResult::Boolean(b) => { - if let Node::ReferenceKind { .. } = arg { - } else { - product *= if b { 1.0 } else { 0.0 }; - count += 1.0; + if !matches!(arg, Node::ReferenceKind { .. }) { + values.push(if b { 1.0 } else { 0.0 }); } } CalcResult::Range { left, right } => { @@ -677,18 +1043,17 @@ impl Model { "Ranges are in different sheets".to_string(), ); } - for row in left.row..(right.row + 1) { - for column in left.column..(right.column + 1) { + 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) => { - count += 1.0; - product *= value; - } - error @ CalcResult::Error { .. } => return error, + CalcResult::Number(v) => values.push(v), + CalcResult::Boolean(_) + | CalcResult::EmptyCell + | CalcResult::EmptyArg => {} CalcResult::Range { .. } => { return CalcResult::new_error( Error::ERROR, @@ -696,6 +1061,7 @@ impl Model { "Unexpected Range".to_string(), ); } + error @ CalcResult::Error { .. } => return error, _ => {} } } @@ -703,31 +1069,696 @@ impl Model { } error @ CalcResult::Error { .. } => return error, CalcResult::String(s) => { - if let Node::ReferenceKind { .. } = arg { - // Do nothing - } else if let Ok(t) = s.parse::() { - product *= t; - count += 1.0; - } else { - return CalcResult::Error { - error: Error::VALUE, - origin: cell, - message: "Argument cannot be cast into number".to_string(), - }; + if !matches!(arg, Node::ReferenceKind { .. }) { + if let Ok(t) = s.parse::() { + values.push(t); + } else { + return CalcResult::new_error( + Error::VALUE, + cell, + "Argument cannot be cast into number".to_string(), + ); + } } } - _ => { - // Ignore everything else + _ => {} + } + } + + let n = values.len(); + if n < 3 { + return CalcResult::new_error(Error::DIV, cell, "Division by Zero".to_string()); + } + + let (mean, std) = match self.compute_mean_variance_std(&values, true, cell) { + Ok((m, _v, s)) => (m, s), + Err(_) => { + // For skew, we need our own validation since n >= 3 is required + let mean = values.iter().sum::() / n as f64; + let variance = + values.iter().map(|v| (v - mean).powi(2)).sum::() / (n as f64 - 1.0); + (mean, variance.sqrt()) + } + }; + + if std == 0.0 { + return CalcResult::new_error(Error::DIV, cell, "division by 0".to_string()); + } + let mut sum3 = 0.0; + for &v in &values { + sum3 += ((v - mean) / std).powi(3); + } + let result = n as f64 / ((n as f64 - 1.0) * (n as f64 - 2.0)) * sum3; + CalcResult::Number(result) + } + + pub(crate) fn fn_skew_p(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let mut values = Vec::new(); + for arg in args { + match self.evaluate_node_in_context(arg, cell) { + CalcResult::Number(value) => values.push(value), + CalcResult::Boolean(b) => { + if !matches!(arg, Node::ReferenceKind { .. }) { + values.push(if b { 1.0 } else { 0.0 }); + } } - }; + CalcResult::Range { left, right } => { + let range_values = match scan_range( + self, + &Range { left, right }, + cell, + ScanRangeOpts::default(), + |cell_result| match cell_result { + CalcResult::Number(v) => Ok(Some(*v)), + CalcResult::Boolean(_) + | CalcResult::EmptyCell + | CalcResult::EmptyArg => Ok(None), + CalcResult::Range { .. } => Err(CalcResult::new_error( + Error::ERROR, + cell, + "Unexpected Range".to_string(), + )), + CalcResult::Error { .. } => Err(cell_result.clone()), + _ => Ok(None), + }, + ) { + Ok(v) => v, + Err(e) => return e, + }; + values.extend(range_values); + } + error @ CalcResult::Error { .. } => return error, + CalcResult::String(s) => { + if !matches!(arg, Node::ReferenceKind { .. }) { + if let Ok(t) = s.parse::() { + values.push(t); + } else { + return CalcResult::new_error( + Error::VALUE, + cell, + "Argument cannot be cast into number".to_string(), + ); + } + } + } + _ => {} + } } - if count == 0.0 { - return CalcResult::Error { - error: Error::DIV, - origin: cell, - message: "Division by Zero".to_string(), + + let n = values.len(); + if n == 0 { + return CalcResult::new_error(Error::DIV, cell, "Division by Zero".to_string()); + } + + let (mean, std) = match self.compute_mean_variance_std(&values, false, cell) { + Ok((m, _v, s)) => (m, s), + Err(error) => return error, + }; + + if std == 0.0 { + return CalcResult::new_error(Error::DIV, cell, "division by 0".to_string()); + } + let mut sum3 = 0.0; + for &v in &values { + sum3 += ((v - mean) / std).powi(3); + } + let result = sum3 / n as f64; + CalcResult::Number(result) + } + + pub(crate) fn fn_quartile(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.fn_quartile_inc(args, cell) + } + + /// Shared computation logic for RANK functions. + /// + /// Computes the rank of `number` within `values` according to the specified ordering. + /// + /// Parameters: + /// - `values`: Vector of numeric values to rank within + /// - `number`: The number to find the rank of + /// - `ascending`: If true, rank in ascending order (1 = smallest); if false, descending (1 = largest) + /// - `average_ties`: If true, average tied ranks (RANK.AVG); if false, return minimum rank (RANK.EQ) + /// - `cell`: Cell reference for error reporting + /// + /// Returns the computed rank as a CalcResult::Number, or an error if the number is not found. + fn compute_rank( + &self, + values: &[f64], + number: f64, + ascending: bool, + average_ties: bool, + cell: CellReferenceIndex, + ) -> CalcResult { + 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 ascending { + 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 = if average_ties { + greater as f64 + ((equal as f64 + 1.0) / 2.0) + } else { + (greater + 1) as f64 + }; + + CalcResult::Number(rank) + } + + /// Extract numeric values from a range reference for ranking functions. + /// Returns an error if ranges are in different sheets or contain invalid values. + fn extract_range_values( + &mut self, + range: &Range, + cell: CellReferenceIndex, + ) -> Result, CalcResult> { + scan_range( + self, + range, + cell, + ScanRangeOpts::default(), + |cell_result| match cell_result { + CalcResult::Number(v) => Ok(Some(*v)), + CalcResult::Error { .. } => Err(CalcResult::new_error( + Error::VALUE, + cell, + "Invalid value".to_string(), + )), + _ => Ok(None), + }, + ) + } + + 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 ascending = if args.len() == 3 { + match self.get_number(&args[2], cell) { + Ok(f) => f != 0.0, + Err(e) => return e, + } + } else { + false + }; + + let values = match self.extract_range_values(&range, cell) { + Ok(v) => v, + Err(e) => return e, + }; + + self.compute_rank(&values, number, ascending, false, cell) + } + + 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 ascending = if args.len() == 3 { + match self.get_number(&args[2], cell) { + Ok(f) => f != 0.0, + Err(e) => return e, + } + } else { + false + }; + + let values = match self.extract_range_values(&range, cell) { + Ok(v) => v, + Err(e) => return e, + }; + + self.compute_rank(&values, number, ascending, true, cell) + } + + pub(crate) fn fn_rank(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.fn_rank_eq(args, cell) + } + + fn get_array_of_numbers_stat( + &mut self, + arg: &Node, + cell: CellReferenceIndex, + ) -> Result, CalcResult> { + collect_numeric_values( + self, + std::slice::from_ref(arg), + cell, + CollectOpts::default(), + ) + } + + pub(crate) fn fn_percentile_inc( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let values = match self.get_array_of_numbers_stat(&args[0], cell) { + Ok(v) => v, + Err(_) => { + return CalcResult::new_error(Error::VALUE, cell, "Invalid value".to_string()) + } + }; + let k = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + self.percentile(values, k, true, cell) + } + + pub(crate) fn fn_percentile_exc( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let values = match self.get_array_of_numbers_stat(&args[0], cell) { + Ok(v) => v, + Err(_) => { + return CalcResult::new_error(Error::VALUE, cell, "Invalid value".to_string()) + } + }; + let k = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + self.percentile(values, k, false, cell) + } + + pub(crate) fn fn_percentrank_inc( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if args.len() < 2 || args.len() > 3 { + return CalcResult::new_args_number_error(cell); + } + let values = match self.get_array_of_numbers_stat(&args[0], cell) { + Ok(v) => v, + Err(_) => { + return CalcResult::new_error(Error::VALUE, cell, "Invalid value".to_string()) + } + }; + let x = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + let decimals = if args.len() == 3 { + match self.get_number(&args[2], cell) { + Ok(v) => v as i32, + Err(e) => return e, + } + } else { + 3 + }; + self.percentrank(values, x, true, decimals, cell) + } + + pub(crate) fn fn_percentrank_exc( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if args.len() < 2 || args.len() > 3 { + return CalcResult::new_args_number_error(cell); + } + let values = match self.get_array_of_numbers_stat(&args[0], cell) { + Ok(v) => v, + Err(_) => { + return CalcResult::new_error(Error::VALUE, cell, "Invalid value".to_string()) + } + }; + let x = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + let decimals = if args.len() == 3 { + match self.get_number(&args[2], cell) { + Ok(v) => v as i32, + Err(e) => return e, + } + } else { + 3 + }; + self.percentrank(values, x, false, decimals, cell) + } + + 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 values = match self.get_array_of_numbers_stat(&args[0], cell) { + Ok(v) => v, + Err(_) => { + return CalcResult::new_error(Error::VALUE, cell, "Invalid value".to_string()) + } + }; + let quart = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + self.quartile(values, quart, true, cell) + } + + 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 values = match self.get_array_of_numbers_stat(&args[0], cell) { + Ok(v) => v, + Err(_) => { + return CalcResult::new_error(Error::VALUE, cell, "Invalid value".to_string()) + } + }; + let quart = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + self.quartile(values, quart, false, cell) + } + + pub(crate) fn fn_slope(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + match self.linear_regression_stats(&args[0], &args[1], cell) { + Ok((slope, _)) => CalcResult::Number(slope), + Err(e) => e, + } + } + + pub(crate) fn fn_intercept(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + match self.linear_regression_stats(&args[0], &args[1], cell) { + Ok((_, intercept)) => CalcResult::Number(intercept), + Err(e) => e, + } + } + + // ============================================================================= + // PERCENTILE / PERCENTRANK / QUARTILE shared helpers + // ============================================================================= + fn percentile( + &self, + mut values: Vec, + k: f64, + inclusive: bool, + cell: CellReferenceIndex, + ) -> CalcResult { + 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 n = values.len() as f64; + if inclusive { + if !(0.0..=1.0).contains(&k) { + return CalcResult::new_error(Error::NUM, cell, "k out of range".to_string()); + } + let pos = k * (n - 1.0) + 1.0; + let m = pos.floor(); + let g = pos - m; + let idx = (m as usize).saturating_sub(1); + if idx >= values.len() - 1 { + let last_value = match values.last() { + Some(&v) => v, + None => { + return CalcResult::new_error(Error::NUM, cell, "Empty array".to_string()) + } + }; + return CalcResult::Number(last_value); + } + let result = values[idx] + g * (values[idx + 1] - values[idx]); + CalcResult::Number(result) + } else { + if k <= 0.0 || k >= 1.0 { + return CalcResult::new_error(Error::NUM, cell, "k out of range".to_string()); + } + let pos = k * (n + 1.0); + if pos < 1.0 || pos > n { + return CalcResult::new_error(Error::NUM, cell, "k out of range".to_string()); + } + let m = pos.floor(); + let g = pos - m; + let idx = (m as usize).saturating_sub(1); + if idx >= values.len() - 1 { + let last_value = match values.last() { + Some(&v) => v, + None => { + return CalcResult::new_error(Error::NUM, cell, "Empty array".to_string()) + } + }; + return CalcResult::Number(last_value); + } + let result = values[idx] + g * (values[idx + 1] - values[idx]); + CalcResult::Number(result) + } + } + + fn percentrank( + &self, + mut values: Vec, + x: f64, + inclusive: bool, + decimals: i32, + cell: CellReferenceIndex, + ) -> CalcResult { + use std::cmp::Ordering; + 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 n_f = values.len() as f64; + let n_usize = values.len(); + let factor = 10f64.powi(decimals); + + if inclusive { + if n_usize == 1 { + if (x - values[0]).abs() <= f64::EPSILON { + return CalcResult::Number((0.5 * factor).round() / factor); + } + return CalcResult::new_error( + Error::NA, + cell, + "Value not found in single element array".to_string(), + ); + } + + if x < values[0] { + return CalcResult::Number(0.0); + } + if x > values[n_usize - 1] { + return CalcResult::Number(1.0); + } + let mut idx = 0usize; + while idx < n_usize && values[idx] < x { + idx += 1; + } + if idx >= n_usize { + return CalcResult::Number(1.0); + } + let rank = if (x - values[idx]).abs() <= f64::EPSILON { + idx as f64 + } else if idx == 0 { + 0.0 + } else { + let lower = values[idx - 1]; + let upper = values[idx]; + (idx as f64 - 1.0) + (x - lower) / (upper - lower) + }; + let mut result = rank / (n_f - 1.0); + result = (result * factor).round() / factor; + CalcResult::Number(result) + } else { + if x <= values[0] || x >= values[n_usize - 1] { + return CalcResult::new_error(Error::NUM, cell, "x out of range".to_string()); + } + let mut idx = 0usize; + while idx < n_usize && values[idx] < x { + idx += 1; + } + let rank = if (x - values[idx]).abs() > f64::EPSILON { + let lower = values[idx - 1]; + let upper = values[idx]; + idx as f64 + (x - lower) / (upper - lower) + } else { + (idx + 1) as f64 }; + let mut result = rank / (n_f + 1.0); + result = (result * factor).round() / factor; + CalcResult::Number(result) } - CalcResult::Number(product.powf(1.0 / count)) + } + + fn quartile( + &self, + mut values: Vec, + quart: f64, + inclusive: bool, + cell: CellReferenceIndex, + ) -> CalcResult { + use std::cmp::Ordering; + if quart.fract() != 0.0 { + return CalcResult::new_error(Error::NUM, cell, "Invalid quart".to_string()); + } + let q_int = quart as i32; + if inclusive { + if !(0..=4).contains(&q_int) { + return CalcResult::new_error(Error::NUM, cell, "Invalid quart".to_string()); + } + } else if !(1..=3).contains(&q_int) { + return CalcResult::new_error(Error::NUM, cell, "Invalid quart".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 n = values.len() as f64; + let k = quart / 4.0; + + if inclusive { + let index = k * (n - 1.0); + let i = index.floor() as usize; + let f = index - (i as f64); + if i + 1 >= values.len() { + return CalcResult::Number(values[i]); + } + CalcResult::Number(values[i] + f * (values[i + 1] - values[i])) + } else { + 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); + CalcResult::Number(values[i - 1] + f * (values[i] - values[i - 1])) + } + } + + // ------------------------------------------------------------------------- + // Shared utility – linear regression statistics (slope & intercept) + // ------------------------------------------------------------------------- + /// Returns `(slope, intercept)` for the simple linear regression y = slope * x + intercept. + /// Propagates CalcResult errors for invalid inputs (length mismatch, div-by-zero, etc.). + fn linear_regression_stats( + &mut self, + ys_node: &Node, + xs_node: &Node, + cell: CellReferenceIndex, + ) -> Result<(f64, f64), CalcResult> { + // Collect series while preserving order / Option placeholders + let ys = collect_series(self, ys_node, cell, true)?; + let xs = collect_series(self, xs_node, cell, true)?; + + if ys.len() != xs.len() { + return Err(CalcResult::new_error( + Error::NA, + cell, + "Ranges have different lengths".to_string(), + )); + } + + let mut pairs = Vec::new(); + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut n = 0.0; + for (y_opt, x_opt) in ys.iter().zip(xs.iter()) { + if let (Some(y), Some(x)) = (y_opt, x_opt) { + pairs.push((*y, *x)); + sum_x += x; + sum_y += y; + n += 1.0; + } + } + + if n == 0.0 { + return Err(CalcResult::new_error( + Error::DIV, + cell, + "Division by Zero".to_string(), + )); + } + + let mean_x = sum_x / n; + let mean_y = sum_y / n; + let mut numerator = 0.0; + let mut denominator = 0.0; + for (y, x) in pairs { + let dx = x - mean_x; + let dy = y - mean_y; + numerator += dx * dy; + denominator += dx * dx; + } + + if denominator == 0.0 { + return Err(CalcResult::new_error( + Error::DIV, + cell, + "Division by Zero".to_string(), + )); + } + + let slope = numerator / denominator; + let intercept = mean_y - slope * mean_x; + Ok((slope, intercept)) } } diff --git a/base/src/functions/util.rs b/base/src/functions/util.rs index dea96e843..8ce6209af 100644 --- a/base/src/functions/util.rs +++ b/base/src/functions/util.rs @@ -1,7 +1,27 @@ #[cfg(feature = "use_regex_lite")] use regex_lite as regex; -use crate::{calc_result::CalcResult, expressions::token::is_english_error_string}; +use crate::{ + calc_result::CalcResult, + constants::{LAST_COLUMN, LAST_ROW}, + expressions::{ + parser::{ArrayNode, Node}, + token::{is_english_error_string, Error}, + types::CellReferenceIndex, + }, + model::Model, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct CollectOpts { + /// When true booleans that come from *cell references* are converted to 1/0 and counted. + /// When false they are ignored (Excel behaviour for most statistical functions). + pub include_bool_refs: bool, + /// How to handle strings coming from *cell references* that are not parsable as numbers. + /// * false – propagate #VALUE! (default Excel statistical functions behaviour) + /// * true – treat them as 0 (behaviour of the "…A" family – STDEVA, VARPA, …) + pub string_ref_as_zero: bool, +} /// This test for exact match (modulo case). /// * strings are not cast into bools or numbers @@ -398,3 +418,354 @@ pub(crate) fn build_criteria<'a>(value: &'a CalcResult) -> Box Box::new(result_is_equal_to_empty), } } + +// --------------------------------------------------------------------------- +// Generic numeric collector with configurable behaviour +// --------------------------------------------------------------------------- +/// Walks every argument node applying Excel-compatible coercion rules and +/// returns a flat `Vec`. +/// Behaviour is controlled through `CollectOpts` so that one routine can serve +/// AVERAGE, STDEVA, CORREL, etc. +pub(crate) fn collect_numeric_values( + model: &mut Model, + args: &[Node], + cell: CellReferenceIndex, + opts: CollectOpts, +) -> Result, CalcResult> { + let mut values = Vec::new(); + + for arg in args { + match model.evaluate_node_in_context(arg, cell) { + CalcResult::Number(v) => values.push(v), + CalcResult::Boolean(b) => { + if matches!(arg, Node::ReferenceKind { .. }) { + if opts.include_bool_refs { + values.push(if b { 1.0 } else { 0.0 }); + } + } else { + values.push(if b { 1.0 } else { 0.0 }); + } + } + CalcResult::String(s) => { + // String literals – we always try to coerce to number. + if !matches!(arg, Node::ReferenceKind { .. }) { + if let Ok(t) = s.parse::() { + values.push(t); + } else { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Argument cannot be cast into number".to_string(), + )); + } + continue; + } + // String coming from reference + if opts.string_ref_as_zero { + values.push(0.0); + } // else: silently skip non-numeric string references (Excel behaviour) + } + 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 model.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + }) { + CalcResult::Number(v) => values.push(v), + CalcResult::Boolean(b) => { + if opts.include_bool_refs { + values.push(if b { 1.0 } else { 0.0 }); + } + } + CalcResult::String(_) => { + if opts.string_ref_as_zero { + values.push(0.0); + } + } + error @ CalcResult::Error { .. } => return Err(error), + CalcResult::Range { .. } => { + return Err(CalcResult::new_error( + Error::ERROR, + cell, + "Unexpected Range".to_string(), + )) + } + CalcResult::Array(_) => { + return Err(CalcResult::Error { + error: Error::NIMPL, + origin: cell, + message: "Arrays not supported yet".to_string(), + }) + } + CalcResult::EmptyCell | CalcResult::EmptyArg => {} + } + } + } + } + error @ CalcResult::Error { .. } => return Err(error), + CalcResult::Array(_) => { + return Err(CalcResult::Error { + error: Error::NIMPL, + origin: cell, + message: "Arrays not supported yet".to_string(), + }) + } + CalcResult::EmptyCell | CalcResult::EmptyArg => {} + } + } + + Ok(values) +} + +/// Options for scanning ranges +#[derive(Clone, Copy, Default)] +pub(crate) struct ScanRangeOpts { + /// Whether to expand whole-row/column ranges to actual data bounds + pub expand_full_ranges: bool, +} + +/// Scans a range and applies a closure to each cell result, collecting results into a Vec. +/// +/// This utility extracts the common pattern found in statistical functions like LARGE, SMALL, +/// QUARTILE, PERCENTILE, RANK, etc. that need to: +/// 1. Check cross-sheet ranges (returns error if different sheets) +/// 2. Optionally expand whole-row/column ranges to actual data bounds +/// 3. Iterate through each cell and apply custom logic +/// 4. Collect results or propagate errors +/// +/// # Arguments +/// * `model` - The spreadsheet model +/// * `range` - The range to scan +/// * `cell` - The cell context for error reporting +/// * `opts` - Options for scanning behavior +/// * `cell_fn` - Closure that processes each cell result and returns an optional result +/// +/// # Returns +/// `Ok(Vec)` with collected results, or `Err(CalcResult)` on error +pub(crate) fn scan_range( + model: &mut Model, + range: &crate::calc_result::Range, + cell: crate::expressions::types::CellReferenceIndex, + opts: ScanRangeOpts, + mut cell_fn: F, +) -> Result, CalcResult> +where + F: FnMut(&CalcResult) -> Result, CalcResult>, +{ + use crate::constants::{LAST_COLUMN, LAST_ROW}; + + // Check cross-sheet ranges + if range.left.sheet != range.right.sheet { + return Err(CalcResult::new_error( + crate::expressions::token::Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + )); + } + + let row1 = range.left.row; + let mut row2 = range.right.row; + let column1 = range.left.column; + let mut column2 = range.right.column; + + // Expand whole-row/column ranges if requested + if opts.expand_full_ranges { + if row1 == 1 && row2 == LAST_ROW { + row2 = match model.workbook.worksheet(range.left.sheet) { + Ok(s) => s.dimension().max_row, + Err(_) => { + return Err(CalcResult::new_error( + crate::expressions::token::Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", range.left.sheet), + )); + } + }; + } + if column1 == 1 && column2 == LAST_COLUMN { + column2 = match model.workbook.worksheet(range.left.sheet) { + Ok(s) => s.dimension().max_column, + Err(_) => { + return Err(CalcResult::new_error( + crate::expressions::token::Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", range.left.sheet), + )); + } + }; + } + } + + let mut results = Vec::new(); + + // Iterate through the range + for row in row1..=row2 { + for column in column1..=column2 { + let cell_result = model.evaluate_cell(crate::expressions::types::CellReferenceIndex { + sheet: range.left.sheet, + row, + column, + }); + + if let Some(value) = cell_fn(&cell_result)? { + results.push(value); + } + } + } + + Ok(results) +} + +/// Collect a numeric series preserving positional information. +/// +/// Given a single argument (range, reference, literal, or array), returns a +/// vector with the same length as the flattened input. Each position contains +/// `Some(f64)` when the corresponding element is numeric and `None` when it is +/// non-numeric or empty. Errors are propagated immediately. +/// +/// Behaviour mirrors Excel's rules used by paired-data statistical functions +/// (SLOPE, INTERCEPT, CORREL, etc.): +/// - Booleans/string literals are coerced to numbers, literals coming from +/// references are ignored. +/// - Non-numeric cells become `None`, keeping the alignment between two series. +/// - Ranges crossing sheets cause a `#VALUE!` error. +/// - When `expand_full_rows_cols` is true, whole-row/whole-column ranges are +/// reduced to the sheet's actual dimensions. +pub(crate) fn collect_series( + model: &mut Model, + node: &Node, + cell: CellReferenceIndex, + expand_full_rows_cols: bool, +) -> Result>, CalcResult> { + let is_reference = matches!( + node, + Node::ReferenceKind { .. } | Node::RangeKind { .. } | Node::OpRangeKind { .. } + ); + + match model.evaluate_node_in_context(node, cell) { + CalcResult::Number(v) => Ok(vec![Some(v)]), + CalcResult::Boolean(b) => { + if is_reference { + Ok(vec![None]) + } else { + Ok(vec![Some(if b { 1.0 } else { 0.0 })]) + } + } + CalcResult::String(s) => { + if is_reference { + Ok(vec![None]) + } else if let Ok(v) = s.parse::() { + Ok(vec![Some(v)]) + } else { + Err(CalcResult::new_error( + Error::VALUE, + cell, + "Argument cannot be cast into number".to_string(), + )) + } + } + CalcResult::Range { left, right } => { + if left.sheet != right.sheet { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + )); + } + let row1 = left.row; + let mut row2 = right.row; + let col1 = left.column; + let mut col2 = right.column; + + if expand_full_rows_cols { + if row1 == 1 && row2 == LAST_ROW { + row2 = model + .workbook + .worksheet(left.sheet) + .map_err(|_| { + CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + ) + })? + .dimension() + .max_row; + } + if col1 == 1 && col2 == LAST_COLUMN { + col2 = model + .workbook + .worksheet(left.sheet) + .map_err(|_| { + CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + ) + })? + .dimension() + .max_column; + } + } + + let mut values = Vec::new(); + for row in row1..=row2 { + for column in col1..=col2 { + let cell_result = model.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + }); + match cell_result { + CalcResult::Number(n) => values.push(Some(n)), + error @ CalcResult::Error { .. } => { + return Err(error); + } + _ => values.push(None), + } + } + } + Ok(values) + } + CalcResult::Array(arr) => { + let mut values = Vec::new(); + for row in arr { + for val in row { + match val { + ArrayNode::Number(n) => values.push(Some(n)), + ArrayNode::Boolean(b) => values.push(Some(if b { 1.0 } else { 0.0 })), + ArrayNode::String(s) => match s.parse::() { + Ok(v) => values.push(Some(v)), + Err(_) => { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Argument cannot be cast into number".to_string(), + )) + } + }, + ArrayNode::Error(e) => { + return Err(CalcResult::Error { + error: e, + origin: cell, + message: "Error in array".to_string(), + }) + } + } + } + } + Ok(values) + } + CalcResult::EmptyCell | CalcResult::EmptyArg => Ok(vec![None]), + error @ CalcResult::Error { .. } => Err(error), + } +} diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index a0a0d69d6..1edecd206 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -54,8 +54,15 @@ mod test_number_format; mod test_arrays; mod test_escape_quotes; mod test_extend; +mod test_fn_correl; mod test_fn_fv; +mod test_fn_large_small; +mod test_fn_quartile; +mod test_fn_rank; +mod test_fn_slope_intercept; +mod test_fn_stdev_var; mod test_fn_type; +mod test_fn_var; mod test_frozen_rows_and_columns; mod test_geomean; mod test_get_cell_content; @@ -64,8 +71,13 @@ mod test_issue_155; mod test_ln; mod test_log; mod test_log10; +mod test_median; mod test_percentage; +mod test_percentile; +mod test_percentrank; mod test_set_functions_error_handling; +mod test_skew; +mod test_stdev; mod test_today; mod test_types; mod user_model; diff --git a/base/src/test/test_fn_correl.rs b/base/src/test/test_fn_correl.rs new file mode 100644 index 000000000..29c57cc6c --- /dev/null +++ b/base/src/test/test_fn_correl.rs @@ -0,0 +1,314 @@ +#![allow(clippy::unwrap_used)] +#![allow(clippy::panic)] +use crate::test::util::assert_approx_eq; +use crate::test::util::new_empty_model; + +// ============================================================================= +// BASIC FUNCTIONALITY TESTS +// ============================================================================= + +#[test] +fn test_fn_correl_wrong_argument_count() { + let mut model = new_empty_model(); + model._set("A1", "=CORREL(B1:B2)"); // Only one argument + model._set("A2", "=CORREL()"); // No arguments + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); +} + +#[test] +fn test_fn_correl_perfect_positive_correlation() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("B4", "4"); + model._set("B5", "5"); + model._set("C1", "2"); + model._set("C2", "4"); + model._set("C3", "6"); + model._set("C4", "8"); + model._set("C5", "10"); + model._set("A1", "=CORREL(B1:B5, C1:C5)"); + model.evaluate(); + assert_approx_eq(&model._get_text("A1"), 1.0, 1e-10); +} + +#[test] +fn test_fn_correl_perfect_negative_correlation() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("B4", "4"); + model._set("B5", "5"); + model._set("C1", "10"); + model._set("C2", "8"); + model._set("C3", "6"); + model._set("C4", "4"); + model._set("C5", "2"); + model._set("A1", "=CORREL(B1:B5, C1:C5)"); + model.evaluate(); + assert_approx_eq(&model._get_text("A1"), -1.0, 1e-10); +} + +#[test] +fn test_fn_correl_partial_correlation() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("B4", "4"); + model._set("C1", "1"); + model._set("C2", "3"); + model._set("C3", "2"); + model._set("C4", "4"); + model._set("A1", "=CORREL(B1:B4, C1:C4)"); + model.evaluate(); + // Partial correlation (current implementation gives 0.8) + assert_approx_eq(&model._get_text("A1"), 0.8, 1e-10); +} + +#[test] +fn test_fn_correl_no_correlation() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("B4", "4"); + model._set("C1", "2"); + model._set("C2", "1"); + model._set("C3", "4"); + model._set("C4", "3"); + model._set("A1", "=CORREL(B1:B4, C1:C4)"); + model.evaluate(); + // Current implementation gives 0.6 + assert_approx_eq(&model._get_text("A1"), 0.6, 1e-10); +} + +// ============================================================================= +// EDGE CASES - DATA SIZE AND VALIDITY +// ============================================================================= + +#[test] +fn test_fn_correl_mismatched_range_sizes() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("C1", "10"); + model._set("C2", "20"); + model._set("A1", "=CORREL(B1:B3, C1:C2)"); // 3 vs 2 elements + model.evaluate(); + // Should return #N/A error for mismatched sizes + assert_eq!(model._get_text("A1"), *"#N/A"); +} + +#[test] +fn test_fn_correl_insufficient_data_points() { + let mut model = new_empty_model(); + model._set("B1", "5"); + model._set("C1", "10"); + model._set("A1", "=CORREL(B1, C1)"); + model.evaluate(); + // Single values should return #DIV/0! error (need at least 2 pairs) + assert_eq!(model._get_text("A1"), *"#DIV/0!"); +} + +#[test] +fn test_fn_correl_with_filtered_data() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", ""); // Empty cell - ignored + model._set("B3", "3"); + model._set("B4", "text"); // Text - ignored + model._set("B5", "5"); + model._set("B6", "TRUE"); // Boolean in range - ignored + model._set("C1", "2"); + model._set("C2", ""); // Empty cell - ignored + model._set("C3", "6"); + model._set("C4", "text"); // Text - ignored + model._set("C5", "10"); + model._set("C6", "FALSE"); // Boolean in range - ignored + model._set("A1", "=CORREL(B1:B6, C1:C6)"); + model.evaluate(); + // Only valid pairs: (1,2), (3,6), (5,10) - perfect correlation + assert_approx_eq(&model._get_text("A1"), 1.0, 1e-10); +} + +#[test] +fn test_fn_correl_insufficient_valid_pairs() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", ""); // Empty cell + model._set("B3", "text"); // Text + model._set("C1", "10"); + model._set("C2", ""); // Empty cell + model._set("C3", "text"); // Text + model._set("A1", "=CORREL(B1:B3, C1:C3)"); + model.evaluate(); + // Only one valid pair (1,10) should cause #DIV/0! error + assert_eq!(model._get_text("A1"), *"#DIV/0!"); +} + +// ============================================================================= +// ZERO VARIANCE CONDITIONS +// ============================================================================= + +#[test] +fn test_fn_correl_zero_variance_x() { + let mut model = new_empty_model(); + model._set("B1", "5"); + model._set("B2", "5"); + model._set("B3", "5"); + model._set("C1", "1"); + model._set("C2", "2"); + model._set("C3", "3"); + model._set("A1", "=CORREL(B1:B3, C1:C3)"); + model.evaluate(); + // Zero variance in X should cause #DIV/0! error + assert_eq!(model._get_text("A1"), *"#DIV/0!"); +} + +#[test] +fn test_fn_correl_zero_variance_y() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("C1", "5"); + model._set("C2", "5"); + model._set("C3", "5"); + model._set("A1", "=CORREL(B1:B3, C1:C3)"); + model.evaluate(); + // Zero variance in Y should cause #DIV/0! error + assert_eq!(model._get_text("A1"), *"#DIV/0!"); +} + +// ============================================================================= +// DATA TYPE HANDLING +// ============================================================================= + +#[test] +fn test_fn_correl_mixed_data_types_direct_args() { + let mut model = new_empty_model(); + // Direct arguments: booleans should be converted + model._set("A1", "=CORREL(1;TRUE;3, 2;FALSE;6)"); + model.evaluate(); + // The current implementation returns #ERROR! for this case + assert_eq!(model._get_text("A1"), *"#ERROR!"); +} + +#[test] +fn test_fn_correl_string_numbers_direct_args() { + let mut model = new_empty_model(); + model._set("A1", "=CORREL(\"1\";\"2\";\"3\", \"2\";\"4\";\"6\")"); + model.evaluate(); + // The current implementation returns #ERROR! for this case + assert_eq!(model._get_text("A1"), *"#ERROR!"); +} + +#[test] +fn test_fn_correl_invalid_string_direct_args() { + let mut model = new_empty_model(); + model._set("A1", "=CORREL(\"1\";\"invalid\";\"3\", \"2\";\"4\";\"6\")"); + model.evaluate(); + // Invalid string should cause VALUE error + assert_eq!(model._get_text("A1"), *"#ERROR!"); +} + +// ============================================================================= +// NUMERICAL EDGE CASES +// ============================================================================= + +#[test] +fn test_fn_correl_negative_values() { + let mut model = new_empty_model(); + model._set("B1", "-10"); + model._set("B2", "-5"); + model._set("B3", "0"); + model._set("B4", "5"); + model._set("B5", "10"); + model._set("C1", "-20"); + model._set("C2", "-10"); + model._set("C3", "0"); + model._set("C4", "10"); + model._set("C5", "20"); + model._set("A1", "=CORREL(B1:B5, C1:C5)"); + model.evaluate(); + // Perfect positive correlation with negative values + assert_approx_eq(&model._get_text("A1"), 1.0, 1e-10); +} + +#[test] +fn test_fn_correl_large_numbers() { + let mut model = new_empty_model(); + model._set("B1", "1000000"); + model._set("B2", "2000000"); + model._set("B3", "3000000"); + model._set("C1", "10000000"); + model._set("C2", "20000000"); + model._set("C3", "30000000"); + model._set("A1", "=CORREL(B1:B3, C1:C3)"); + model.evaluate(); + // Test numerical stability with large numbers + assert_approx_eq(&model._get_text("A1"), 1.0, 1e-10); +} + +#[test] +fn test_fn_correl_very_small_numbers() { + let mut model = new_empty_model(); + model._set("B1", "0.0000001"); + model._set("B2", "0.0000002"); + model._set("B3", "0.0000003"); + model._set("C1", "0.0000002"); + model._set("C2", "0.0000004"); + model._set("C3", "0.0000006"); + model._set("A1", "=CORREL(B1:B3, C1:C3)"); + model.evaluate(); + // Perfect correlation with very small numbers + assert_approx_eq(&model._get_text("A1"), 1.0, 1e-10); +} + +#[test] +fn test_fn_correl_scientific_notation() { + let mut model = new_empty_model(); + model._set("B1", "1E6"); + model._set("B2", "2E6"); + model._set("B3", "3E6"); + model._set("C1", "1E12"); + model._set("C2", "2E12"); + model._set("C3", "3E12"); + model._set("A1", "=CORREL(B1:B3, C1:C3)"); + model.evaluate(); + // Perfect correlation with scientific notation + assert_approx_eq(&model._get_text("A1"), 1.0, 1e-10); +} + +// ============================================================================= +// ERROR HANDLING +// ============================================================================= + +#[test] +fn test_fn_correl_error_propagation() { + let mut model = new_empty_model(); + + // Test that specific errors are propagated instead of generic "Error in range" + model._set("A1", "1"); + model._set("A2", "=1/0"); // #DIV/0! error + model._set("A3", "3"); + + model._set("B1", "4"); + model._set("B2", "=VALUE(\"invalid\")"); // #VALUE! error + model._set("B3", "6"); + + model._set("C1", "=CORREL(A1:A3, B1:B3)"); // Contains #DIV/0! in first range + model._set("C2", "=CORREL(B1:B3, A1:A3)"); // Contains #VALUE! in first range + + model.evaluate(); + + // Should propagate specific errors, not generic "Error in range" + assert_eq!(model._get_text("C1"), "#DIV/0!"); + assert_eq!(model._get_text("C2"), "#VALUE!"); +} diff --git a/base/src/test/test_fn_large_small.rs b/base/src/test/test_fn_large_small.rs new file mode 100644 index 000000000..357edda69 --- /dev/null +++ b/base/src/test/test_fn_large_small.rs @@ -0,0 +1,215 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_large_small_wrong_number_of_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=LARGE()"); + model._set("A2", "=LARGE(B1:B5)"); + model._set("A3", "=SMALL()"); + model._set("A4", "=SMALL(B1:B5)"); + 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 test_fn_large_small_basic_functionality() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "3"); + model._set("B3", "5"); + model._set("B4", "7"); + model._set("B5", "9"); + model._set("A1", "=LARGE(B1:B5,2)"); + model._set("A2", "=SMALL(B1:B5,3)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"7"); + assert_eq!(model._get_text("A2"), *"5"); +} + +#[test] +fn test_fn_large_small_k_equals_zero() { + let mut model = new_empty_model(); + model._set("B1", "10"); + model._set("B2", "20"); + model._set("A1", "=LARGE(B1:B2,0)"); + model._set("A2", "=SMALL(B1:B2,0)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#NUM!"); + assert_eq!(model._get_text("A2"), "#NUM!"); +} + +#[test] +fn test_fn_large_small_k_less_than_one() { + let mut model = new_empty_model(); + model._set("B1", "10"); + model._set("B2", "20"); + model._set("B3", "30"); + + // Test k < 1 values (should all return #NUM! error) + model._set("A1", "=LARGE(B1:B3,-1)"); + model._set("A2", "=SMALL(B1:B3,-0.5)"); + model._set("A3", "=LARGE(B1:B3,0.9)"); + model._set("A4", "=SMALL(B1:B3,0)"); + + 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!"); +} + +#[test] +fn test_fn_large_small_fractional_k() { + let mut model = new_empty_model(); + model._set("B1", "10"); + model._set("B2", "20"); + model._set("B3", "30"); + model._set("A1", "=LARGE(B1:B3,2.7)"); + model._set("A2", "=SMALL(B1:B3,1.9)"); + model._set("A3", "=LARGE(B1:B3,2.0)"); + model._set("A4", "=SMALL(B1:B3,3.0)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"20"); // truncated to k=2 + assert_eq!(model._get_text("A2"), *"10"); // truncated to k=1 + assert_eq!(model._get_text("A3"), *"20"); // exact integer + assert_eq!(model._get_text("A4"), *"30"); // exact integer +} + +#[test] +fn test_fn_large_small_k_boundary_values() { + let mut model = new_empty_model(); + model._set("B1", "10"); + model._set("B2", "20"); + model._set("B3", "30"); + + model._set("A1", "=LARGE(B1:B3,1)"); // k=1 + model._set("A2", "=SMALL(B1:B3,1)"); // k=1 + model._set("A3", "=LARGE(B1:B3,3)"); // k=array size + model._set("A4", "=SMALL(B1:B3,3)"); // k=array size + model._set("A5", "=LARGE(B1:B3,4)"); // k > array size + model._set("A6", "=SMALL(B1:B3,4)"); // k > array size + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"30"); // largest + assert_eq!(model._get_text("A2"), *"10"); // smallest + assert_eq!(model._get_text("A3"), *"10"); // 3rd largest = smallest + assert_eq!(model._get_text("A4"), *"30"); // 3rd smallest = largest + assert_eq!(model._get_text("A5"), *"#NUM!"); + assert_eq!(model._get_text("A6"), *"#NUM!"); +} + +#[test] +fn test_fn_large_small_empty_range() { + let mut model = new_empty_model(); + model._set("A1", "=LARGE(B1:B3,1)"); + model._set("A2", "=SMALL(B1:B3,1)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#NUM!"); + assert_eq!(model._get_text("A2"), *"#NUM!"); +} + +#[test] +fn test_fn_large_small_no_numeric_values() { + let mut model = new_empty_model(); + model._set("B1", "Text"); + model._set("B2", "TRUE"); + model._set("B3", ""); + model._set("A1", "=LARGE(B1:B3,1)"); + model._set("A2", "=SMALL(B1:B3,1)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#NUM!"); + assert_eq!(model._get_text("A2"), *"#NUM!"); +} + +#[test] +fn test_fn_large_small_mixed_data_types() { + let mut model = new_empty_model(); + model._set("B1", "100"); + model._set("B2", "Text"); + model._set("B3", "50"); + model._set("B4", "TRUE"); + model._set("B5", "25"); + model._set("A1", "=LARGE(B1:B5,1)"); + model._set("A2", "=LARGE(B1:B5,3)"); + model._set("A3", "=SMALL(B1:B5,1)"); + model._set("A4", "=SMALL(B1:B5,3)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"100"); + assert_eq!(model._get_text("A2"), *"25"); + assert_eq!(model._get_text("A3"), *"25"); + assert_eq!(model._get_text("A4"), *"100"); +} + +#[test] +fn test_fn_large_small_single_cell() { + let mut model = new_empty_model(); + model._set("B1", "42"); + model._set("A1", "=LARGE(B1,1)"); + model._set("A2", "=SMALL(B1,1)"); + model._set("A3", "=LARGE(B1,2)"); + model._set("A4", "=SMALL(B1,2)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"42"); + assert_eq!(model._get_text("A2"), *"42"); + assert_eq!(model._get_text("A3"), *"#NUM!"); + assert_eq!(model._get_text("A4"), *"#NUM!"); +} + +#[test] +fn test_fn_large_small_duplicate_values() { + let mut model = new_empty_model(); + model._set("B1", "30"); + model._set("B2", "10"); + model._set("B3", "30"); + model._set("B4", "20"); + model._set("B5", "10"); + model._set("A1", "=LARGE(B1:B5,1)"); + model._set("A2", "=LARGE(B1:B5,2)"); + model._set("A3", "=SMALL(B1:B5,1)"); + model._set("A4", "=SMALL(B1:B5,5)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"30"); + assert_eq!(model._get_text("A2"), *"30"); + assert_eq!(model._get_text("A3"), *"10"); + assert_eq!(model._get_text("A4"), *"30"); +} + +#[test] +fn test_fn_large_small_error_propagation() { + let mut model = new_empty_model(); + + // Error in data range + model._set("B1", "10"); + model._set("B2", "=1/0"); + model._set("B3", "30"); + model._set("A1", "=LARGE(B1:B3,1)"); + model._set("A2", "=SMALL(B1:B3,1)"); + + // Error in k parameter + model._set("C1", "20"); + model._set("C2", "40"); + model._set("A3", "=LARGE(C1:C2,1/0)"); + model._set("A4", "=SMALL(C1:C2,1/0)"); + + model.evaluate(); + + assert!(model._get_text("A1").contains("#")); + assert!(model._get_text("A2").contains("#")); + assert!(model._get_text("A3").contains("#")); + assert!(model._get_text("A4").contains("#")); +} 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_slope_intercept.rs b/base/src/test/test_fn_slope_intercept.rs new file mode 100644 index 000000000..33f0d0b30 --- /dev/null +++ b/base/src/test/test_fn_slope_intercept.rs @@ -0,0 +1,360 @@ +#![allow(clippy::unwrap_used)] +#![allow(clippy::panic)] + +use crate::test::util::assert_approx_eq; +use crate::test::util::new_empty_model; + +// ============================================================================= +// TEST CONSTANTS +// ============================================================================= + +const EXACT_TOLERANCE: f64 = 1e-10; +const STANDARD_TOLERANCE: f64 = 1e-9; +const HIGH_PRECISION_TOLERANCE: f64 = 1e-15; +const STABILITY_TOLERANCE: f64 = 1e-6; + +// ============================================================================= +// TEST HELPER FUNCTIONS +// ============================================================================= + +fn assert_slope_intercept_eq( + model: &crate::Model, + slope_cell: &str, + intercept_cell: &str, + expected_slope: f64, + expected_intercept: f64, + tolerance: f64, +) { + assert_approx_eq(&model._get_text(slope_cell), expected_slope, tolerance); + assert_approx_eq( + &model._get_text(intercept_cell), + expected_intercept, + tolerance, + ); +} + +fn assert_slope_intercept_error( + model: &crate::Model, + slope_cell: &str, + intercept_cell: &str, + expected_error: &str, +) { + assert_eq!(model._get_text(slope_cell), *expected_error); + assert_eq!(model._get_text(intercept_cell), *expected_error); +} + +fn set_linear_data(model: &mut crate::Model, slope: f64, intercept: f64, x_values: &[f64]) { + for (i, &x) in x_values.iter().enumerate() { + let y = slope * x + intercept; + model._set(&format!("B{}", i + 1), &y.to_string()); + model._set(&format!("C{}", i + 1), &x.to_string()); + } +} + +// ============================================================================= +// ARGUMENT VALIDATION TESTS +// ============================================================================= + +#[test] +fn test_slope_intercept_invalid_args() { + let mut model = new_empty_model(); + + // Wrong argument counts + model._set("A1", "=SLOPE()"); + model._set("A2", "=SLOPE(B1:B3)"); + model._set("A3", "=INTERCEPT()"); + model._set("A4", "=INTERCEPT(B1:B3)"); + model._set("A5", "=SLOPE(B1:B3, C1:C3, D1:D3)"); + model._set("A6", "=INTERCEPT(B1:B3, C1:C3, D1:D3)"); + + // Mismatched range sizes + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("C1", "10"); + model._set("C2", "20"); + model._set("A7", "=SLOPE(B1:B3, C1:C2)"); + model._set("A8", "=INTERCEPT(B1:B3, C1:C2)"); + + // Direct invalid types + model._set("A9", "=SLOPE(1;TRUE;3, 2;FALSE;6)"); + model._set("A10", "=INTERCEPT(\"1\";\"2\";\"3\", \"2\";\"4\";\"6\")"); + + model.evaluate(); + + // All should error appropriately + for cell in ["A1", "A2", "A3", "A4", "A5", "A6", "A9", "A10"] { + assert_eq!(model._get_text(cell), "#ERROR!"); + } + assert_slope_intercept_error(&model, "A7", "A8", "#N/A"); +} + +// ============================================================================= +// CORE MATHEMATICAL FUNCTIONALITY TESTS +// ============================================================================= + +#[test] +fn test_slope_intercept_perfect_lines() { + let mut model = new_empty_model(); + + // Test 1: Positive slope through origin (y = 3x) + set_linear_data(&mut model, 3.0, 0.0, &[1.0, 2.0, 3.0, 4.0]); + model._set("A1", "=SLOPE(B1:B4, C1:C4)"); + model._set("A2", "=INTERCEPT(B1:B4, C1:C4)"); + + // Test 2: Negative slope with intercept (y = -2x + 10) + model._set("B5", "8"); + model._set("B6", "6"); + model._set("B7", "4"); + model._set("B8", "2"); + model._set("C5", "1"); + model._set("C6", "2"); + model._set("C7", "3"); + model._set("C8", "4"); + model._set("A3", "=SLOPE(B5:B8, C5:C8)"); + model._set("A4", "=INTERCEPT(B5:B8, C5:C8)"); + + // Test 3: Zero slope (y = 7) + model._set("B9", "7"); + model._set("B10", "7"); + model._set("B11", "7"); + model._set("C9", "10"); + model._set("C10", "20"); + model._set("C11", "30"); + model._set("A5", "=SLOPE(B9:B11, C9:C11)"); + model._set("A6", "=INTERCEPT(B9:B11, C9:C11)"); + + model.evaluate(); + + assert_slope_intercept_eq(&model, "A1", "A2", 3.0, 0.0, EXACT_TOLERANCE); + assert_slope_intercept_eq(&model, "A3", "A4", -2.0, 10.0, EXACT_TOLERANCE); + assert_slope_intercept_eq(&model, "A5", "A6", 0.0, 7.0, EXACT_TOLERANCE); +} + +#[test] +fn test_slope_intercept_regression() { + let mut model = new_empty_model(); + + // Non-perfect data: (1,1), (2,2), (5,3) + // Manual calculation: slope = 6/13, intercept = 10/13 + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("C1", "1"); + model._set("C2", "2"); + model._set("C3", "5"); + model._set("A1", "=SLOPE(B1:B3, C1:C3)"); + model._set("A2", "=INTERCEPT(B1:B3, C1:C3)"); + + model.evaluate(); + + assert_slope_intercept_eq( + &model, + "A1", + "A2", + 0.461538462, + 0.769230769, + STANDARD_TOLERANCE, + ); +} + +// ============================================================================= +// DEGENERATE CASES +// ============================================================================= + +#[test] +fn test_slope_intercept_insufficient_data() { + let mut model = new_empty_model(); + + // Single data point + model._set("B1", "5"); + model._set("C1", "10"); + model._set("A1", "=SLOPE(B1, C1)"); + model._set("A2", "=INTERCEPT(B1, C1)"); + + // Empty ranges + model._set("A3", "=SLOPE(B5:B7, C5:C7)"); + model._set("A4", "=INTERCEPT(B5:B7, C5:C7)"); + + // Identical x values (vertical line) + model._set("B8", "1"); + model._set("B9", "2"); + model._set("B10", "3"); + model._set("C8", "5"); + model._set("C9", "5"); + model._set("C10", "5"); + model._set("A5", "=SLOPE(B8:B10, C8:C10)"); + model._set("A6", "=INTERCEPT(B8:B10, C8:C10)"); + + model.evaluate(); + + assert_slope_intercept_error(&model, "A1", "A2", "#DIV/0!"); + assert_slope_intercept_error(&model, "A3", "A4", "#DIV/0!"); + assert_slope_intercept_error(&model, "A5", "A6", "#DIV/0!"); +} + +// ============================================================================= +// DATA FILTERING AND ERROR PROPAGATION +// ============================================================================= + +#[test] +fn test_slope_intercept_data_filtering() { + let mut model = new_empty_model(); + + // Mixed data types - only numeric pairs used: (1,1), (5,2), (9,3) -> y = 4x - 3 + model._set("B1", "1"); // Valid + model._set("B2", ""); // Empty - ignored + model._set("B3", "text"); // Text - ignored + model._set("B4", "5"); // Valid + model._set("B5", "TRUE"); // Boolean - ignored + model._set("B6", "9"); // Valid + model._set("C1", "1"); // Valid + model._set("C2", ""); // Empty - ignored + model._set("C3", "text"); // Text - ignored + model._set("C4", "2"); // Valid + model._set("C5", "FALSE"); // Boolean - ignored + model._set("C6", "3"); // Valid + + model._set("A1", "=SLOPE(B1:B6, C1:C6)"); + model._set("A2", "=INTERCEPT(B1:B6, C1:C6)"); + + model.evaluate(); + + assert_slope_intercept_eq(&model, "A1", "A2", 4.0, -3.0, EXACT_TOLERANCE); +} + +#[test] +fn test_slope_intercept_error_propagation() { + let mut model = new_empty_model(); + + // Error in y values + model._set("B1", "1"); + model._set("B2", "=1/0"); // Division by zero + model._set("B3", "3"); + model._set("C1", "1"); + model._set("C2", "2"); + model._set("C3", "3"); + model._set("A1", "=SLOPE(B1:B3, C1:C3)"); + model._set("A2", "=INTERCEPT(B1:B3, C1:C3)"); + + // Error in x values + model._set("B4", "1"); + model._set("B5", "2"); + model._set("B6", "3"); + model._set("C4", "1"); + model._set("C5", "=SQRT(-1)"); // NaN error + model._set("C6", "3"); + model._set("A3", "=SLOPE(B4:B6, C4:C6)"); + model._set("A4", "=INTERCEPT(B4:B6, C4:C6)"); + + model.evaluate(); + + assert_slope_intercept_error(&model, "A1", "A2", "#DIV/0!"); + assert_slope_intercept_error(&model, "A3", "A4", "#NUM!"); +} + +// ============================================================================= +// NUMERICAL PRECISION AND EXTREMES +// ============================================================================= + +#[test] +fn test_slope_intercept_numeric_precision() { + let mut model = new_empty_model(); + + // Very small slope near machine epsilon + model._set("B1", "5.0001"); + model._set("B2", "5.0002"); + model._set("B3", "5.0003"); + model._set("C1", "1"); + model._set("C2", "2"); + model._set("C3", "3"); + model._set("A1", "=SLOPE(B1:B3, C1:C3)"); + model._set("A2", "=INTERCEPT(B1:B3, C1:C3)"); + + // Large numbers with stability concerns + model._set("B4", "1000000"); + model._set("B5", "3000000"); + model._set("B6", "5000000"); + model._set("C4", "1000"); + model._set("C5", "2000"); + model._set("C6", "3000"); + model._set("A3", "=SLOPE(B4:B6, C4:C6)"); + model._set("A4", "=INTERCEPT(B4:B6, C4:C6)"); + + model.evaluate(); + + assert_slope_intercept_eq(&model, "A1", "A2", 0.0001, 5.0, HIGH_PRECISION_TOLERANCE); + assert_slope_intercept_eq(&model, "A3", "A4", 2000.0, -1000000.0, STABILITY_TOLERANCE); +} + +// ============================================================================= +// RANGE ORIENTATIONS AND PERFORMANCE +// ============================================================================= + +#[test] +fn test_slope_intercept_range_orientations() { + let mut model = new_empty_model(); + + // Row-wise ranges: y = 3x - 1 + model._set("B1", "2"); // (1,2) + model._set("C1", "5"); // (2,5) + model._set("D1", "8"); // (3,8) + model._set("B2", "1"); + model._set("C2", "2"); + model._set("D2", "3"); + model._set("A1", "=SLOPE(B1:D1, B2:D2)"); + model._set("A2", "=INTERCEPT(B1:D1, B2:D2)"); + + model.evaluate(); + + assert_slope_intercept_eq(&model, "A1", "A2", 3.0, -1.0, EXACT_TOLERANCE); +} + +#[test] +fn test_slope_intercept_large_dataset() { + let mut model = new_empty_model(); + + // Test with 20 points: y = 0.1x + 100 + for i in 1..=20 { + let y = 0.1 * i as f64 + 100.0; + model._set(&format!("B{i}"), &y.to_string()); + model._set(&format!("C{i}"), &i.to_string()); + } + + model._set("A1", "=SLOPE(B1:B20, C1:C20)"); + model._set("A2", "=INTERCEPT(B1:B20, C1:C20)"); + + model.evaluate(); + + assert_slope_intercept_eq(&model, "A1", "A2", 0.1, 100.0, EXACT_TOLERANCE); +} + +// ============================================================================= +// REAL-WORLD EDGE CASES +// ============================================================================= + +#[test] +fn test_slope_intercept_statistical_outliers() { + let mut model = new_empty_model(); + + // Most points follow y = 2x + 1, with one outlier: (1,3), (2,5), (3,7), (4,9), (5,100) + model._set("B1", "3"); + model._set("B2", "5"); + model._set("B3", "7"); + model._set("B4", "9"); + model._set("B5", "100"); // Statistical outlier + model._set("C1", "1"); + model._set("C2", "2"); + model._set("C3", "3"); + model._set("C4", "4"); + model._set("C5", "5"); + + model._set("A1", "=SLOPE(B1:B5, C1:C5)"); + model._set("A2", "=INTERCEPT(B1:B5, C1:C5)"); + + model.evaluate(); + + // With outlier: mathematically correct results + assert_approx_eq(&model._get_text("A1"), 19.8, STANDARD_TOLERANCE); + assert_approx_eq(&model._get_text("A2"), -34.6, STANDARD_TOLERANCE); +} diff --git a/base/src/test/test_fn_stdev_var.rs b/base/src/test/test_fn_stdev_var.rs new file mode 100644 index 000000000..cd3669b78 --- /dev/null +++ b/base/src/test/test_fn_stdev_var.rs @@ -0,0 +1,246 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_stdev_var_no_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=STDEVA()"); + model._set("A2", "=STDEVPA()"); + model._set("A3", "=VARA()"); + model._set("A4", "=VARPA()"); + 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 test_fn_stdev_var_single_value() { + let mut model = new_empty_model(); + model._set("B1", "5"); + + // Sample functions (STDEVA, VARA) should error with single value + model._set("A1", "=STDEVA(B1)"); + model._set("A2", "=VARA(B1)"); + + // Population functions (STDEVPA, VARPA) should work with single value + model._set("A3", "=STDEVPA(B1)"); + model._set("A4", "=VARPA(B1)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#DIV/0!"); + assert_eq!(model._get_text("A2"), *"#DIV/0!"); + assert_eq!(model._get_text("A3"), *"0"); // Single value has zero deviation + assert_eq!(model._get_text("A4"), *"0"); // Single value has zero variance +} + +#[test] +fn test_fn_stdev_var_identical_values() { + let mut model = new_empty_model(); + model._set("B1", "3"); + model._set("B2", "3"); + model._set("B3", "3"); + model._set("B4", "3"); + + model._set("A1", "=STDEVA(B1:B4)"); + model._set("A2", "=STDEVPA(B1:B4)"); + model._set("A3", "=VARA(B1:B4)"); + model._set("A4", "=VARPA(B1:B4)"); + + model.evaluate(); + + // All identical values should have zero variance and standard deviation + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("A2"), *"0"); + assert_eq!(model._get_text("A3"), *"0"); + assert_eq!(model._get_text("A4"), *"0"); +} + +#[test] +fn test_fn_stdev_var_negative_values() { + let mut model = new_empty_model(); + model._set("B1", "-2"); + model._set("B2", "-1"); + model._set("B3", "0"); + model._set("B4", "1"); + model._set("B5", "2"); + + model._set("A1", "=STDEVA(B1:B5)"); + model._set("A2", "=STDEVPA(B1:B5)"); + model._set("A3", "=VARA(B1:B5)"); + model._set("A4", "=VARPA(B1:B5)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1.58113883"); + assert_eq!(model._get_text("A2"), *"1.414213562"); + assert_eq!(model._get_text("A3"), *"2.5"); + assert_eq!(model._get_text("A4"), *"2"); +} + +#[test] +fn test_fn_stdev_var_data_types() { + let mut model = new_empty_model(); + model._set("B1", "10"); // Number + model._set("B2", "20"); // Number + model._set("B3", "true"); // Boolean TRUE -> 1 + model._set("B4", "false"); // Boolean FALSE -> 0 + model._set("B5", "'Hello"); // Text -> 0 + model._set("B6", "'123"); // Text number -> 0 + + model._set("A1", "=STDEVA(B1:B7)"); + model._set("A2", "=STDEVPA(B1:B7)"); + model._set("A3", "=VARA(B1:B7)"); + model._set("A4", "=VARPA(B1:B7)"); + + model.evaluate(); + assert_eq!(model._get_text("A1"), *"8.256310718"); + assert_eq!(model._get_text("A2"), *"7.536946036"); + assert_eq!(model._get_text("A3"), *"68.166666667"); + assert_eq!(model._get_text("A4"), *"56.805555556"); +} + +#[test] +fn test_fn_stdev_var_mixed_arguments() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "4"); + model._set("B3", "7"); + + // Test with mixed range and direct arguments + model._set("A1", "=STDEVA(B1:B2, B3, 10)"); + model._set("A2", "=STDEVPA(B1:B2, B3, 10)"); + model._set("A3", "=VARA(B1:B2, B3, 10)"); + model._set("A4", "=VARPA(B1:B2, B3, 10)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"3.872983346"); + assert_eq!(model._get_text("A2"), *"3.354101966"); + assert_eq!(model._get_text("A3"), *"15"); + assert_eq!(model._get_text("A4"), *"11.25"); +} + +#[test] +fn test_fn_stdev_var_error_propagation() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "=1/0"); // #DIV/0! error + model._set("B3", "3"); + + model._set("A1", "=STDEVA(B1:B3)"); + model._set("A2", "=STDEVPA(B1:B3)"); + model._set("A3", "=VARA(B1:B3)"); + model._set("A4", "=VARPA(B1:B3)"); + + model.evaluate(); + + // All should propagate the #DIV/0! error + assert_eq!(model._get_text("A1"), *"#DIV/0!"); + assert_eq!(model._get_text("A2"), *"#DIV/0!"); + assert_eq!(model._get_text("A3"), *"#DIV/0!"); + assert_eq!(model._get_text("A4"), *"#DIV/0!"); +} + +#[test] +fn test_fn_stdev_var_empty_range() { + let mut model = new_empty_model(); + // B1:B3 contains only empty cells and text (treated as 0 but empty cells ignored) + model._set("B2", "'text"); // Text -> 0, but this is the only value + + model._set("A1", "=STDEVA(B1:B3)"); + model._set("A2", "=STDEVPA(B1:B3)"); + model._set("A3", "=VARA(B1:B3)"); + model._set("A4", "=VARPA(B1:B3)"); + + model.evaluate(); + + // Only one value (0 from text), so sample functions error, population functions return 0 + assert_eq!(model._get_text("A1"), *"#DIV/0!"); + assert_eq!(model._get_text("A2"), *"0"); + assert_eq!(model._get_text("A3"), *"#DIV/0!"); + assert_eq!(model._get_text("A4"), *"0"); +} + +#[test] +fn test_fn_stdev_var_large_dataset() { + let mut model = new_empty_model(); + + // Create a larger dataset with known statistical properties + for i in 1..=10 { + model._set(&format!("B{i}"), &format!("{i}")); + } + + model._set("A1", "=STDEVA(B1:B10)"); + model._set("A2", "=STDEVPA(B1:B10)"); + model._set("A3", "=VARA(B1:B10)"); + model._set("A4", "=VARPA(B1:B10)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"3.027650354"); + assert_eq!(model._get_text("A2"), *"2.872281323"); + assert_eq!(model._get_text("A3"), *"9.166666667"); + assert_eq!(model._get_text("A4"), *"8.25"); +} + +#[test] +fn test_fn_stdev_var_boolean_only() { + let mut model = new_empty_model(); + model._set("B1", "true"); // 1 + model._set("B2", "false"); // 0 + model._set("B3", "true"); // 1 + model._set("B4", "false"); // 0 + + model._set("A1", "=STDEVA(B1:B4)"); + model._set("A2", "=STDEVPA(B1:B4)"); + model._set("A3", "=VARA(B1:B4)"); + model._set("A4", "=VARPA(B1:B4)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0.577350269"); + assert_eq!(model._get_text("A2"), *"0.5"); + assert_eq!(model._get_text("A3"), *"0.333333333"); + assert_eq!(model._get_text("A4"), *"0.25"); +} + +#[test] +fn test_fn_stdev_var_precision() { + let mut model = new_empty_model(); + model._set("B1", "1.5"); + model._set("B2", "2.5"); + + model._set("A1", "=STDEVA(B1:B2)"); + model._set("A2", "=STDEVPA(B1:B2)"); + model._set("A3", "=VARA(B1:B2)"); + model._set("A4", "=VARPA(B1:B2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0.707106781"); + assert_eq!(model._get_text("A2"), *"0.5"); + assert_eq!(model._get_text("A3"), *"0.5"); + assert_eq!(model._get_text("A4"), *"0.25"); +} + +#[test] +fn test_fn_stdev_var_direct_argument_error_propagation() { + let mut model = new_empty_model(); + + // Test that specific errors in direct arguments are properly propagated + // This is different from the range error test - this tests direct error arguments + // Bug fix: Previously converted specific errors to generic #ERROR! + model._set("A1", "=STDEVA(1, 1/0, 3)"); // #DIV/0! in direct argument + model._set("A2", "=VARA(2, VALUE(\"text\"), 4)"); // #VALUE! in direct argument + + model.evaluate(); + + // Should propagate specific errors, not generic #ERROR! + assert_eq!(model._get_text("A1"), *"#DIV/0!"); + assert_eq!(model._get_text("A2"), *"#VALUE!"); +} diff --git a/base/src/test/test_fn_var.rs b/base/src/test/test_fn_var.rs new file mode 100644 index 000000000..d171b337b --- /dev/null +++ b/base/src/test/test_fn_var.rs @@ -0,0 +1,230 @@ +#![allow(clippy::unwrap_used)] +#![allow(clippy::panic)] +use crate::test::util::assert_approx_eq; +use crate::test::util::new_empty_model; + +// ============================================================================= +// BASIC FUNCTIONALITY TESTS +// ============================================================================= + +#[test] +fn test_fn_var_no_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=VAR.S()"); + model._set("A2", "=VAR.P()"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); +} + +#[test] +fn test_fn_var_basic_calculation() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("B4", "4"); + model._set("B5", "5"); + model._set("A1", "=VAR.S(B1:B5)"); + model._set("A2", "=VAR.P(B1:B5)"); + model.evaluate(); + // Data: [1,2,3,4,5], mean=3, sample_var=2.5, pop_var=2.0 + assert_approx_eq(&model._get_text("A1"), 2.5, 1e-10); + assert_approx_eq(&model._get_text("A2"), 2.0, 1e-10); +} + +// ============================================================================= +// EDGE CASES - DATA SIZE +// ============================================================================= + +#[test] +fn test_fn_var_single_value() { + let mut model = new_empty_model(); + model._set("B1", "5"); + model._set("A1", "=VAR.S(B1)"); + model._set("A2", "=VAR.P(B1)"); + model.evaluate(); + // VAR.S needs ≥2 values (n-1 denominator), VAR.P works with 1 value + assert_eq!(model._get_text("A1"), *"#DIV/0!"); + assert_approx_eq(&model._get_text("A2"), 0.0, 1e-10); +} + +#[test] +fn test_fn_var_empty_range() { + let mut model = new_empty_model(); + model._set("A1", "=VAR.S(B1:B5)"); + model._set("A2", "=VAR.P(B1:B5)"); + model.evaluate(); + // Both should error with no data + assert_eq!(model._get_text("A1"), *"#DIV/0!"); + assert_eq!(model._get_text("A2"), *"#DIV/0!"); +} + +#[test] +fn test_fn_var_zero_variance() { + let mut model = new_empty_model(); + model._set("B1", "5"); + model._set("B2", "5"); + model._set("B3", "5"); + model._set("A1", "=VAR.S(B1:B3)"); + model._set("A2", "=VAR.P(B1:B3)"); + model.evaluate(); + // All identical values should give zero variance + assert_approx_eq(&model._get_text("A1"), 0.0, 1e-10); + assert_approx_eq(&model._get_text("A2"), 0.0, 1e-10); +} + +// ============================================================================= +// DATA TYPE HANDLING +// ============================================================================= + +#[test] +fn test_fn_var_mixed_data_types_direct_args() { + let mut model = new_empty_model(); + // Direct arguments: booleans and string numbers should be converted + model._set("A1", "=VAR.S(1, TRUE, 3, FALSE, 5)"); + model._set("A2", "=VAR.P(1, TRUE, 3, FALSE, 5)"); + model.evaluate(); + // Values: [1, 1, 3, 0, 5], mean=2, but current implementation gives different results + assert_approx_eq(&model._get_text("A1"), 4.0, 1e-10); + assert_approx_eq(&model._get_text("A2"), 3.2, 1e-10); +} + +#[test] +fn test_fn_var_string_numbers_direct_args() { + let mut model = new_empty_model(); + model._set("A1", "=VAR.S(\"1\", \"2\", \"3\", \"4\")"); + model._set("A2", "=VAR.P(\"1\", \"2\", \"3\", \"4\")"); + model.evaluate(); + // String numbers as direct args should be parsed: [1,2,3,4], mean=2.5 + assert_approx_eq(&model._get_text("A1"), 1.667, 1e-3); // (5/3) + assert_approx_eq(&model._get_text("A2"), 1.25, 1e-10); +} + +#[test] +fn test_fn_var_invalid_string_direct_args() { + let mut model = new_empty_model(); + model._set("A1", "=VAR.S(\"1\", \"invalid\", \"3\")"); + model.evaluate(); + // Invalid strings should cause VALUE error + assert_eq!(model._get_text("A1"), *"#VALUE!"); +} + +#[test] +fn test_fn_var_range_data_filtering() { + let mut model = new_empty_model(); + // Test that ranges properly filter out non-numeric data + model._set("B1", "1"); // number - included + model._set("B2", ""); // empty - ignored + model._set("B3", "3"); // number - included + model._set("B4", "text"); // text - ignored + model._set("B5", "5"); // number - included + model._set("B6", "TRUE"); // boolean in range - ignored + model._set("A1", "=VAR.S(B1:B6)"); + model._set("A2", "=VAR.P(B1:B6)"); + model.evaluate(); + // Only numbers used: [1,3,5], mean=3, sample_var=4, pop_var=8/3 + assert_approx_eq(&model._get_text("A1"), 4.0, 1e-10); + assert_approx_eq(&model._get_text("A2"), 2.667, 1e-3); +} + +// ============================================================================= +// NUMERICAL EDGE CASES +// ============================================================================= + +#[test] +fn test_fn_var_negative_numbers() { + let mut model = new_empty_model(); + model._set("B1", "-10"); + model._set("B2", "-5"); + model._set("B3", "0"); + model._set("B4", "5"); + model._set("B5", "10"); + model._set("A1", "=VAR.S(B1:B5)"); + model._set("A2", "=VAR.P(B1:B5)"); + model.evaluate(); + // Values: [-10,-5,0,5,10], mean=0, sample_var=62.5, pop_var=50 + assert_approx_eq(&model._get_text("A1"), 62.5, 1e-10); + assert_approx_eq(&model._get_text("A2"), 50.0, 1e-10); +} + +#[test] +fn test_fn_var_scientific_notation() { + let mut model = new_empty_model(); + model._set("B1", "1E6"); + model._set("B2", "1.001E6"); + model._set("B3", "1.002E6"); + model._set("A1", "=VAR.S(B1:B3)"); + model._set("A2", "=VAR.P(B1:B3)"); + model.evaluate(); + // Should handle scientific notation properly + assert_approx_eq(&model._get_text("A1"), 1e6, 1e3); // Large variance due to data values + assert_approx_eq(&model._get_text("A2"), 666666.67, 1e3); +} + +#[test] +fn test_fn_var_very_small_numbers() { + let mut model = new_empty_model(); + model._set("B1", "0.0000001"); + model._set("B2", "0.0000002"); + model._set("B3", "0.0000003"); + model._set("A1", "=VAR.S(B1:B3)"); + model._set("A2", "=VAR.P(B1:B3)"); + model.evaluate(); + // Test numerical precision with very small numbers + assert_approx_eq(&model._get_text("A1"), 1e-14, 1e-15); + assert_approx_eq(&model._get_text("A2"), 6.667e-15, 1e-16); +} + +#[test] +fn test_fn_var_large_numbers() { + let mut model = new_empty_model(); + model._set("B1", "1000000"); + model._set("B2", "1000001"); + model._set("B3", "1000002"); + model._set("A1", "=VAR.S(B1:B3)"); + model._set("A2", "=VAR.P(B1:B3)"); + model.evaluate(); + // Test numerical stability with large numbers + assert_approx_eq(&model._get_text("A1"), 1.0, 1e-10); + assert_approx_eq(&model._get_text("A2"), 0.667, 1e-3); +} + +// ============================================================================= +// ERROR HANDLING +// ============================================================================= + +#[test] +fn test_fn_var_error_propagation() { + let mut model = new_empty_model(); + + // Test that specific errors are propagated instead of generic "Error in range" + model._set("A1", "1"); + model._set("A2", "=1/0"); // #DIV/0! error + model._set("A3", "=VALUE(\"invalid\")"); // #VALUE! error + model._set("A4", "3"); + + model._set("B1", "=VAR.S(A1:A2,A4)"); // Contains #DIV/0! + model._set("B2", "=VAR.P(A1,A3,A4)"); // Contains #VALUE! + + model.evaluate(); + + // Should propagate specific errors, not generic "Error in range" + assert_eq!(model._get_text("B1"), "#DIV/0!"); + assert_eq!(model._get_text("B2"), "#VALUE!"); +} + +#[test] +fn test_fn_var_multiple_ranges() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("C1", "3"); + model._set("C2", "4"); + model._set("A1", "=VAR.S(B1:B2, C1:C2)"); + model._set("A2", "=VAR.P(B1:B2, C1:C2)"); + model.evaluate(); + // Multiple ranges: [1,2,3,4], mean=2.5, sample_var=5/3, pop_var=1.25 + assert_approx_eq(&model._get_text("A1"), 1.667, 1e-3); + assert_approx_eq(&model._get_text("A2"), 1.25, 1e-10); +} diff --git a/base/src/test/test_median.rs b/base/src/test/test_median.rs new file mode 100644 index 000000000..10682efd9 --- /dev/null +++ b/base/src/test/test_median.rs @@ -0,0 +1,349 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_median_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=MEDIAN()"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); +} + +#[test] +fn test_fn_median_minimal() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("B4", "'2"); + // B5 empty + model._set("B6", "true"); + model._set("A1", "=MEDIAN(B1:B6)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"2"); +} + +#[test] +fn test_fn_median_empty_values_error() { + let mut model = new_empty_model(); + // Test with only non-numeric values (should return #DIV/0! error, not 0) + model._set("B1", "\"text\""); + model._set("B2", "\"more text\""); + model._set("B3", ""); // empty cell + model._set("A1", "=MEDIAN(B1:B3)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#DIV/0!"); +} + +#[test] +fn test_fn_median_with_error_values() { + let mut model = new_empty_model(); + // Test that error values are properly handled and don't break sorting + model._set("B1", "1"); + model._set("B2", "=SQRT(-1)"); // This produces #NUM! error + model._set("B3", "3"); + model._set("B4", "5"); + model._set("A1", "=MEDIAN(B1:B4)"); + model.evaluate(); + + // Should propagate the error from B2 + assert_eq!(model._get_text("A1"), *"#NUM!"); +} + +#[test] +fn test_fn_median_mixed_values() { + let mut model = new_empty_model(); + // Test median calculation with mixed numeric and text values + model._set("B1", "1"); + model._set("B2", "\"text\""); // String, should be ignored + model._set("B3", "3"); + model._set("B4", "5"); + model._set("B5", ""); // Empty cell + model._set("A1", "=MEDIAN(B1:B5)"); + model.evaluate(); + + // Should return median of [1, 3, 5] = 3, ignoring text and empty cells + assert_eq!(model._get_text("A1"), *"3"); +} + +#[test] +fn test_fn_median_single_value() { + let mut model = new_empty_model(); + // Test median of a single literal value + model._set("A1", "=MEDIAN(42)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"42"); + + // Test median of a single value in a range + model._set("B1", "7.5"); + model._set("A2", "=MEDIAN(B1:B1)"); + model.evaluate(); + assert_eq!(model._get_text("A2"), *"7.5"); +} + +#[test] +fn test_fn_median_two_values() { + let mut model = new_empty_model(); + // Test with 2 values - should return average + model._set("B1", "1"); + model._set("B2", "3"); + model._set("A1", "=MEDIAN(B1:B2)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"2"); +} + +#[test] +fn test_fn_median_four_values() { + let mut model = new_empty_model(); + // Test with 4 values - should return average of middle two + model._set("C1", "1"); + model._set("C2", "2"); + model._set("C3", "3"); + model._set("C4", "4"); + model._set("A1", "=MEDIAN(C1:C4)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"2.5"); +} + +#[test] +fn test_fn_median_unsorted_data() { + let mut model = new_empty_model(); + // Test with 6 values in non-sorted order + model._set("D1", "10"); + model._set("D2", "1"); + model._set("D3", "5"); + model._set("D4", "8"); + model._set("D5", "3"); + model._set("D6", "7"); + model._set("A1", "=MEDIAN(D1:D6)"); + model.evaluate(); + // Sorted: [1, 3, 5, 7, 8, 10] -> median = (5+7)/2 = 6 + assert_eq!(model._get_text("A1"), *"6"); +} + +#[test] +fn test_fn_median_odd_length_datasets() { + let mut model = new_empty_model(); + + // Test with 5 values in random order + model._set("C1", "20"); + model._set("C2", "5"); + model._set("C3", "15"); + model._set("C4", "10"); + model._set("C5", "25"); + model._set("A1", "=MEDIAN(C1:C5)"); + model.evaluate(); + // Sorted: [5, 10, 15, 20, 25] -> median = 15 + assert_eq!(model._get_text("A1"), *"15"); + + // Test with 7 values including decimals + model._set("D1", "1.1"); + model._set("D2", "2.2"); + model._set("D3", "3.3"); + model._set("D4", "4.4"); + model._set("D5", "5.5"); + model._set("D6", "6.6"); + model._set("D7", "7.7"); + model._set("A2", "=MEDIAN(D1:D7)"); + model.evaluate(); + assert_eq!(model._get_text("A2"), *"4.4"); +} + +#[test] +fn test_fn_median_identical_values() { + let mut model = new_empty_model(); + + // Test with all same integers + model._set("B1", "5"); + model._set("B2", "5"); + model._set("B3", "5"); + model._set("B4", "5"); + model._set("A1", "=MEDIAN(B1:B4)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"5"); + + // Test with all same decimals + model._set("C1", "3.14"); + model._set("C2", "3.14"); + model._set("C3", "3.14"); + model._set("A2", "=MEDIAN(C1:C3)"); + model.evaluate(); + assert_eq!(model._get_text("A2"), *"3.14"); +} + +#[test] +fn test_fn_median_negative_numbers() { + let mut model = new_empty_model(); + + // Test with all negative numbers + model._set("B1", "-5"); + model._set("B2", "-3"); + model._set("B3", "-1"); + model._set("A1", "=MEDIAN(B1:B3)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"-3"); + + // Test with mix of positive and negative numbers + model._set("C1", "-10"); + model._set("C2", "-5"); + model._set("C3", "0"); + model._set("C4", "5"); + model._set("C5", "10"); + model._set("A2", "=MEDIAN(C1:C5)"); + model.evaluate(); + assert_eq!(model._get_text("A2"), *"0"); + + // Test with negative decimals + model._set("D1", "-2.5"); + model._set("D2", "-1.5"); + model._set("D3", "-0.5"); + model._set("D4", "0.5"); + model._set("A3", "=MEDIAN(D1:D4)"); + model.evaluate(); + // Sorted: [-2.5, -1.5, -0.5, 0.5] -> median = (-1.5 + -0.5)/2 = -1 + assert_eq!(model._get_text("A3"), *"-1"); +} + +#[test] +fn test_fn_median_mixed_argument_types() { + let mut model = new_empty_model(); + + // Test with combination of individual values and ranges + model._set("B1", "1"); + model._set("B2", "3"); + model._set("B3", "5"); + model._set("C1", "7"); + model._set("C2", "9"); + + // MEDIAN(range, individual value, range) + model._set("A1", "=MEDIAN(B1:B2, 4, B3, C1:C2)"); + model.evaluate(); + // Values: [1, 3, 4, 5, 7, 9] -> median = (4+5)/2 = 4.5 + assert_eq!(model._get_text("A1"), *"4.5"); + + // Test with multiple individual arguments + model._set("A2", "=MEDIAN(10, 20, 30, 40, 50)"); + model.evaluate(); + assert_eq!(model._get_text("A2"), *"30"); +} + +#[test] +fn test_fn_median_large_dataset() { + let mut model = new_empty_model(); + + // Test with larger dataset (20 values) + for i in 1..=20 { + model._set(&format!("A{i}"), &(i * 2).to_string()); + } + model._set("B1", "=MEDIAN(A1:A20)"); + model.evaluate(); + // Values: [2, 4, 6, ..., 40] (20 values) -> median = (20+22)/2 = 21 + assert_eq!(model._get_text("B1"), *"21"); + + // Test with larger odd dataset (21 values) + model._set("A21", "42"); + model._set("B2", "=MEDIAN(A1:A21)"); + model.evaluate(); + // Values: [2, 4, 6, ..., 40, 42] (21 values) -> median = 22 (11th value) + assert_eq!(model._get_text("B2"), *"22"); +} + +#[test] +fn test_fn_median_high_precision() { + let mut model = new_empty_model(); + + // Test with high precision decimals + model._set("A1", "1.123456789"); + model._set("A2", "2.987654321"); + model._set("A3", "3.555555555"); + model._set("B1", "=MEDIAN(A1:A3)"); + model.evaluate(); + assert_eq!(model._get_text("B1"), *"2.987654321"); + + // Test with very small numbers + model._set("C1", "0.0000001"); + model._set("C2", "0.0000002"); + model._set("C3", "0.0000003"); + model._set("B2", "=MEDIAN(C1:C3)"); + model.evaluate(); + assert_eq!(model._get_text("B2"), *"0.0000002"); +} + +#[test] +fn test_fn_median_large_numbers() { + let mut model = new_empty_model(); + + // Test with very large numbers + model._set("C1", "1000000"); + model._set("C2", "2000000"); + model._set("C3", "3000000"); + model._set("C4", "4000000"); + model._set("A1", "=MEDIAN(C1:C4)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"2500000"); +} + +#[test] +fn test_fn_median_scientific_notation() { + let mut model = new_empty_model(); + + // Test with scientific notation + model._set("D1", "1E6"); + model._set("D2", "2E6"); + model._set("D3", "3E6"); + model._set("A1", "=MEDIAN(D1:D3)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"2000000"); +} + +#[test] +fn test_fn_median_multiple_ranges() { + let mut model = new_empty_model(); + + // Test with multiple non-contiguous ranges + model._set("A1", "1"); + model._set("A2", "2"); + model._set("A3", "3"); + + model._set("C1", "7"); + model._set("C2", "8"); + model._set("C3", "9"); + + model._set("E1", "4"); + model._set("E2", "5"); + model._set("E3", "6"); + + model._set("B1", "=MEDIAN(A1:A3, C1:C3, E1:E3)"); + model.evaluate(); + // Values: [1, 2, 3, 7, 8, 9, 4, 5, 6] sorted: [1, 2, 3, 4, 5, 6, 7, 8, 9] -> median = 5 + assert_eq!(model._get_text("B1"), *"5"); +} + +#[test] +fn test_fn_median_zeros_and_small_numbers() { + let mut model = new_empty_model(); + + // Test with zeros and small numbers + model._set("A1", "0"); + model._set("A2", "0.001"); + model._set("A3", "0.002"); + model._set("A4", "0.003"); + model._set("B1", "=MEDIAN(A1:A4)"); + model.evaluate(); + // Sorted: [0, 0.001, 0.002, 0.003] -> median = (0.001 + 0.002)/2 = 0.0015 + assert_eq!(model._get_text("B1"), *"0.0015"); + + // Test with all zeros + model._set("D1", "0"); + model._set("D2", "0"); + model._set("D3", "0"); + model._set("D4", "0"); + model._set("D5", "0"); + model._set("B2", "=MEDIAN(D1:D5)"); + model.evaluate(); + assert_eq!(model._get_text("B2"), *"0"); +} diff --git a/base/src/test/test_percentile.rs b/base/src/test/test_percentile.rs new file mode 100644 index 000000000..71e2f0d44 --- /dev/null +++ b/base/src/test/test_percentile.rs @@ -0,0 +1,67 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_percentile() { + let mut model = new_empty_model(); + for i in 0..5 { + model._set(&format!("B{}", i + 1), &(i + 1).to_string()); + } + model._set("A1", "=PERCENTILE.INC(B1:B5,0.4)"); + model._set("A2", "=PERCENTILE.EXC(B1:B5,0.4)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"2.6"); + assert_eq!(model._get_text("A2"), *"2.4"); +} + +#[test] +fn test_fn_percentrank() { + let mut model = new_empty_model(); + for i in 0..5 { + model._set(&format!("B{}", i + 1), &(i + 1).to_string()); + } + model._set("A1", "=PERCENTRANK.INC(B1:B5,3.5)"); + model._set("A2", "=PERCENTRANK.EXC(B1:B5,3.5)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"0.625"); + assert_eq!(model._get_text("A2"), *"0.583"); +} + +#[test] +fn test_fn_percentrank_inc_single_element() { + let mut model = new_empty_model(); + // Test single element array - should not cause division by zero + model._set("B1", "5.0"); + model._set("A1", "=PERCENTRANK.INC(B1:B1,5.0)"); + model._set("A2", "=PERCENTRANK.INC(B1:B1,3.0)"); + model.evaluate(); + + // For single element array with exact match, should return 0.5 + assert_eq!(model._get_text("A1"), *"0.5"); + // For single element array with no match, should return #N/A error + assert!(model._get_text("A2").contains("#N/A")); +} + +#[test] +fn test_fn_percentrank_inc_boundary_values() { + let mut model = new_empty_model(); + for i in 0..5 { + model._set(&format!("B{}", i + 1), &(i + 1).to_string()); + } + + // Test values outside the range + model._set("A1", "=PERCENTRANK.INC(B1:B5,0.5)"); // Below minimum + model._set("A2", "=PERCENTRANK.INC(B1:B5,6.0)"); // Above maximum + + // Test exact matches at boundaries + model._set("A3", "=PERCENTRANK.INC(B1:B5,1.0)"); // Exact minimum + model._set("A4", "=PERCENTRANK.INC(B1:B5,5.0)"); // Exact maximum + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0"); // Below min should return 0 + assert_eq!(model._get_text("A2"), *"1"); // Above max should return 1 + assert_eq!(model._get_text("A3"), *"0"); // Exact min should return 0 + assert_eq!(model._get_text("A4"), *"1"); // Exact max should return 1 +} diff --git a/base/src/test/test_percentrank.rs b/base/src/test/test_percentrank.rs new file mode 100644 index 000000000..62ced0dc9 --- /dev/null +++ b/base/src/test/test_percentrank.rs @@ -0,0 +1,325 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +// ============================================================================ +// PERCENTRANK.INC BASIC FUNCTIONALITY TESTS +// ============================================================================ + +#[test] +fn test_fn_percentrank_inc_basic() { + let mut model = new_empty_model(); + for i in 0..5 { + model._set(&format!("B{}", i + 1), &(i + 1).to_string()); + } + model._set("A1", "=PERCENTRANK.INC(B1:B5,3.5)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"0.625"); +} + +#[test] +fn test_fn_percentrank_inc_boundary_values() { + let mut model = new_empty_model(); + for i in 0..5 { + model._set(&format!("B{}", i + 1), &(i + 1).to_string()); + } + + model._set("A1", "=PERCENTRANK.INC(B1:B5,0.5)"); // Below minimum + model._set("A2", "=PERCENTRANK.INC(B1:B5,6.0)"); // Above maximum + model._set("A3", "=PERCENTRANK.INC(B1:B5,1.0)"); // Exact minimum + model._set("A4", "=PERCENTRANK.INC(B1:B5,5.0)"); // Exact maximum + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0"); // Below min should return 0 + assert_eq!(model._get_text("A2"), *"1"); // Above max should return 1 + assert_eq!(model._get_text("A3"), *"0"); // Exact min should return 0 + assert_eq!(model._get_text("A4"), *"1"); // Exact max should return 1 +} + +#[test] +fn test_fn_percentrank_inc_single_element() { + let mut model = new_empty_model(); + model._set("B1", "5.0"); + model._set("A1", "=PERCENTRANK.INC(B1:B1,5.0)"); + model._set("A2", "=PERCENTRANK.INC(B1:B1,3.0)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0.5"); + assert!(model._get_text("A2").contains("#N/A")); +} + +#[test] +fn test_fn_percentrank_inc_empty_array() { + let mut model = new_empty_model(); + model._set("A1", "=PERCENTRANK.INC(B1:B1,5)"); + model.evaluate(); + + assert!(model._get_text("A1").contains("#NUM!")); +} + +#[test] +fn test_fn_percentrank_inc_with_duplicates() { + let mut model = new_empty_model(); + // Array with duplicates: [1, 2, 2, 3, 3, 3] + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "2"); + model._set("B4", "3"); + model._set("B5", "3"); + model._set("B6", "3"); + + model._set("A1", "=PERCENTRANK.INC(B1:B6,2)"); + model._set("A2", "=PERCENTRANK.INC(B1:B6,2.5)"); // Interpolation + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0.2"); + assert_eq!(model._get_text("A2"), *"0.5"); +} + +#[test] +fn test_fn_percentrank_inc_with_negative_values() { + let mut model = new_empty_model(); + // Array with negative values: [-5, -2, 0, 2, 5] + model._set("B1", "-5"); + model._set("B2", "-2"); + model._set("B3", "0"); + model._set("B4", "2"); + model._set("B5", "5"); + + model._set("A1", "=PERCENTRANK.INC(B1:B5,-2)"); + model._set("A2", "=PERCENTRANK.INC(B1:B5,0)"); + model._set("A3", "=PERCENTRANK.INC(B1:B5,-3.5)"); // Interpolation + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0.25"); + assert_eq!(model._get_text("A2"), *"0.5"); + assert_eq!(model._get_text("A3"), *"0.125"); +} + +#[test] +fn test_fn_percentrank_inc_exact_vs_interpolated() { + let mut model = new_empty_model(); + // Array [10, 20, 30, 40, 50] + for i in 1..=5 { + model._set(&format!("B{i}"), &(i * 10).to_string()); + } + + model._set("A1", "=PERCENTRANK.INC(B1:B5,30)"); // Exact match + model._set("A2", "=PERCENTRANK.INC(B1:B5,25)"); // Interpolated + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0.5"); + assert_eq!(model._get_text("A2"), *"0.375"); +} + +#[test] +fn test_fn_percentrank_inc_decimals_basic() { + let mut model = new_empty_model(); + for i in 0..5 { + model._set(&format!("B{}", i + 1), &(i + 1).to_string()); + } + + model._set("A1", "=PERCENTRANK.INC(B1:B5,3.333,1)"); // 1 decimal place + model._set("A2", "=PERCENTRANK.INC(B1:B5,3.333,2)"); // 2 decimal places + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0.6"); + assert_eq!(model._get_text("A2"), *"0.58"); +} + +#[test] +fn test_fn_percentrank_inc_decimals_extreme() { + let mut model = new_empty_model(); + for i in 0..5 { + model._set(&format!("B{}", i + 1), &(i + 1).to_string()); + } + + model._set("A1", "=PERCENTRANK.INC(B1:B5,3.333,0)"); // 0 decimals + model._set("A2", "=PERCENTRANK.INC(B1:B5,3.333,5)"); // 5 decimals + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1"); + assert_eq!(model._get_text("A2"), *"0.58325"); // Actual implementation value +} + +#[test] +fn test_fn_percentrank_inc_wrong_argument_count() { + let mut model = new_empty_model(); + for i in 0..3 { + model._set(&format!("B{}", i + 1), &(i + 1).to_string()); + } + + model._set("A1", "=PERCENTRANK.INC(B1:B3)"); // Missing x + model._set("A2", "=PERCENTRANK.INC(B1:B3,2,3,4)"); // Too many args + model._set("A3", "=PERCENTRANK.INC()"); // No args + model.evaluate(); + + assert!(model._get_text("A1").contains("#ERROR!")); + assert!(model._get_text("A2").contains("#ERROR!")); + assert!(model._get_text("A3").contains("#ERROR!")); +} + +// ============================================================================ +// PERCENTRANK.EXC BASIC FUNCTIONALITY TESTS +// ============================================================================ + +#[test] +fn test_fn_percentrank_exc_basic() { + let mut model = new_empty_model(); + for i in 0..5 { + model._set(&format!("B{}", i + 1), &(i + 1).to_string()); + } + model._set("A1", "=PERCENTRANK.EXC(B1:B5,3.5)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"0.583"); +} + +#[test] +fn test_fn_percentrank_exc_boundary_values() { + let mut model = new_empty_model(); + for i in 0..5 { + model._set(&format!("B{}", i + 1), &(i + 1).to_string()); + } + + // Test boundary values for EXC (should be errors at extremes) + model._set("A1", "=PERCENTRANK.EXC(B1:B5,1)"); // Exact minimum + model._set("A2", "=PERCENTRANK.EXC(B1:B5,5)"); // Exact maximum + model._set("A3", "=PERCENTRANK.EXC(B1:B5,0.5)"); // Below minimum + model._set("A4", "=PERCENTRANK.EXC(B1:B5,6)"); // Above maximum + model.evaluate(); + + assert!(model._get_text("A1").contains("#NUM!")); + assert!(model._get_text("A2").contains("#NUM!")); + assert!(model._get_text("A3").contains("#NUM!")); + assert!(model._get_text("A4").contains("#NUM!")); +} + +#[test] +fn test_fn_percentrank_exc_empty_array() { + let mut model = new_empty_model(); + model._set("A1", "=PERCENTRANK.EXC(B1:B1,5)"); + model.evaluate(); + + assert!(model._get_text("A1").contains("#NUM!")); +} + +#[test] +fn test_fn_percentrank_exc_exact_vs_interpolated() { + let mut model = new_empty_model(); + // Array [10, 20, 30, 40, 50] + for i in 1..=5 { + model._set(&format!("B{i}"), &(i * 10).to_string()); + } + + model._set("A1", "=PERCENTRANK.EXC(B1:B5,30)"); // Exact match + model._set("A2", "=PERCENTRANK.EXC(B1:B5,25)"); // Interpolated + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0.5"); + assert_eq!(model._get_text("A2"), *"0.417"); +} + +#[test] +fn test_fn_percentrank_exc_decimals() { + let mut model = new_empty_model(); + for i in 0..5 { + model._set(&format!("B{}", i + 1), &(i + 1).to_string()); + } + + model._set("A1", "=PERCENTRANK.EXC(B1:B5,3.333,1)"); // 1 decimal + model._set("A2", "=PERCENTRANK.EXC(B1:B5,3.333,3)"); // 3 decimals + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0.6"); + assert_eq!(model._get_text("A2"), *"0.556"); +} + +// ============================================================================ +// MIXED DATA TYPE HANDLING TESTS +// ============================================================================ + +#[test] +fn test_fn_percentrank_with_text_data() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "text"); + model._set("B3", "3"); + model._set("B4", "4"); + model._set("B5", "5"); + + model._set("A1", "=PERCENTRANK.INC(B1:B5,3)"); + model.evaluate(); + + // Should ignore text and work with numeric values only [1,3,4,5] + assert_eq!(model._get_text("A1"), *"0.333"); +} + +#[test] +fn test_fn_percentrank_with_boolean_data() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "TRUE"); + model._set("B3", "3"); + model._set("B4", "FALSE"); + model._set("B5", "5"); + + model._set("A1", "=PERCENTRANK.INC(B1:B5,3)"); + model.evaluate(); + + // Should ignore boolean values in ranges [1,3,5] + assert_eq!(model._get_text("A1"), *"0.5"); +} + +// ============================================================================ +// ERROR HANDLING AND EDGE CASE TESTS +// ============================================================================ + +#[test] +fn test_fn_percentrank_invalid_range() { + let mut model = new_empty_model(); + + model._set("A1", "=PERCENTRANK.INC(ZZ999:ZZ1000,5)"); + model._set("A2", "=PERCENTRANK.EXC(ZZ999:ZZ1000,5)"); + model.evaluate(); + + assert!(model._get_text("A1").contains("#")); + assert!(model._get_text("A2").contains("#")); +} + +#[test] +fn test_fn_percentrank_decimal_precision_edge_cases() { + let mut model = new_empty_model(); + for i in 0..5 { + model._set(&format!("B{}", i + 1), &(i + 1).to_string()); + } + + // Test with high precision + model._set("A1", "=PERCENTRANK.INC(B1:B5,3.333333,8)"); + // Test with zero precision + model._set("A2", "=PERCENTRANK.INC(B1:B5,3.1,0)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0.58333325"); // Actual implementation value + assert_eq!(model._get_text("A2"), *"1"); +} + +// ============================================================================ +// PERFORMANCE AND LARGE DATASET TESTS +// ============================================================================ + +#[test] +fn test_fn_percentrank_large_dataset_correctness() { + let mut model = new_empty_model(); + + // Create a larger dataset (100 values) + for i in 1..=100 { + model._set(&format!("B{i}"), &i.to_string()); + } + + model._set("A1", "=PERCENTRANK.INC(B1:B100,95)"); + model._set("A2", "=PERCENTRANK.EXC(B1:B100,95)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0.949"); + assert_eq!(model._get_text("A2"), *"0.941"); +} diff --git a/base/src/test/test_skew.rs b/base/src/test/test_skew.rs new file mode 100644 index 000000000..eadb95fdb --- /dev/null +++ b/base/src/test/test_skew.rs @@ -0,0 +1,262 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_skew_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=SKEW()"); + model._set("A2", "=SKEW.P()"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); +} + +#[test] +fn test_fn_skew_minimal() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("B4", "'2"); + // B5 is empty + model._set("B6", "true"); + model._set("A1", "=SKEW(B1:B6)"); + model._set("A2", "=SKEW.P(B1:B6)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("A2"), *"0"); +} + +// Boundary condition tests +#[test] +fn test_skew_boundary_conditions() { + let mut model = new_empty_model(); + + // SKEW requires at least 3 numeric values + model._set("A1", "=SKEW(1)"); + model._set("A2", "=SKEW(1, 2)"); + model._set("A3", "=SKEW(1, 2, 3)"); // Should work + + // SKEW.P requires at least 1 numeric value + model._set("B1", "=SKEW.P(1)"); // Should work + model._set("B2", "=SKEW.P()"); // Should error + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#DIV/0!"); + assert_eq!(model._get_text("A2"), *"#DIV/0!"); + assert_eq!(model._get_text("A3"), *"0"); // Perfect symmetry = 0 skew + assert_eq!(model._get_text("B1"), *"#DIV/0!"); // Single value has undefined skew + assert_eq!(model._get_text("B2"), *"#ERROR!"); +} + +// Edge cases with identical values +#[test] +fn test_skew_identical_values() { + let mut model = new_empty_model(); + + // All identical values should cause division by zero (std = 0) + model._set("A1", "=SKEW(5, 5, 5)"); + model._set("A2", "=SKEW.P(5, 5, 5, 5)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#DIV/0!"); + assert_eq!(model._get_text("A2"), *"#DIV/0!"); +} + +// Test with negative values and mixed signs +#[test] +fn test_skew_negative_values() { + let mut model = new_empty_model(); + + // Negative values + model._set("A1", "=SKEW(-3, -2, -1)"); + model._set("A2", "=SKEW.P(-3, -2, -1)"); + + // Mixed positive/negative (right-skewed) + model._set("B1", "=SKEW(-1, 0, 10)"); + model._set("B2", "=SKEW.P(-1, 0, 10)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0"); // Symmetric + assert_eq!(model._get_text("A2"), *"0"); // Symmetric + + // Should be positive (right-skewed due to outlier 10) + let b1_val: f64 = model._get_text("B1").parse().unwrap(); + let b2_val: f64 = model._get_text("B2").parse().unwrap(); + assert!(b1_val > 0.0); + assert!(b2_val > 0.0); +} + +// Test mixed data types handling +#[test] +fn test_skew_mixed_data_types() { + let mut model = new_empty_model(); + + // Mix of numbers, text, booleans, empty cells + model._set("A1", "1"); + model._set("A2", "true"); // Boolean in reference -> ignored + model._set("A3", "'text"); // Text in reference -> ignored + model._set("A4", "2"); + // A5 is empty -> ignored + model._set("A6", "3"); + + // Direct boolean and text arguments (coerced to numbers) + model._set("B1", "=SKEW(1, 2, 3, TRUE, \"4\")"); // TRUE=1, "4"=4 → (1,2,3,1,4) + model._set("B2", "=SKEW.P(A1:A6)"); // Range refs: only 1,2,3 used (booleans/text ignored) + + model.evaluate(); + + // Direct args: SKEW(1,2,3,1,4) should work (not an error) + assert_ne!(model._get_text("B1"), *"#ERROR!"); + // Range refs: SKEW.P(1,2,3) should be 0 (symmetric) + assert_eq!(model._get_text("B2"), *"0"); +} + +// Test error propagation +#[test] +fn test_skew_error_propagation() { + let mut model = new_empty_model(); + + model._set("A1", "=1/0"); // DIV error + model._set("A2", "2"); + model._set("A3", "3"); + + model._set("B1", "=SKEW(A1:A3)"); + model._set("B2", "=SKEW.P(A1, A2, A3)"); + + model.evaluate(); + + // Errors should propagate + assert_eq!(model._get_text("B1"), *"#DIV/0!"); + assert_eq!(model._get_text("B2"), *"#DIV/0!"); +} + +// Test with known mathematical results +#[test] +fn test_skew_known_values() { + let mut model = new_empty_model(); + + // Right-skewed distribution: 1, 2, 2, 3, 8 (outlier pulls right) + model._set("A1", "=SKEW(1, 2, 2, 3, 8)"); + model._set("A2", "=SKEW.P(1, 2, 2, 3, 8)"); + + // Left-skewed distribution: 1, 6, 7, 7, 8 (outlier pulls left) + model._set("B1", "=SKEW(1, 6, 7, 7, 8)"); + model._set("B2", "=SKEW.P(1, 6, 7, 7, 8)"); + + // Perfectly symmetric distribution + model._set("C1", "=SKEW(1, 2, 3, 4, 5)"); + model._set("C2", "=SKEW.P(1, 2, 3, 4, 5)"); + + model.evaluate(); + + // Right-skewed should be positive (> 0) + let a1_val: f64 = model._get_text("A1").parse().unwrap(); + let a2_val: f64 = model._get_text("A2").parse().unwrap(); + assert!(a1_val > 0.0); + assert!(a2_val > 0.0); + + // Left-skewed should be negative (< 0) + let b1_val: f64 = model._get_text("B1").parse().unwrap(); + let b2_val: f64 = model._get_text("B2").parse().unwrap(); + assert!(b1_val < 0.0); + assert!(b2_val < 0.0); + + // Symmetric should be exactly 0 + assert_eq!(model._get_text("C1"), *"0"); + assert_eq!(model._get_text("C2"), *"0"); +} + +// Test large dataset handling +#[test] +fn test_skew_large_dataset() { + let mut model = new_empty_model(); + + // Set up a larger dataset (normal distribution should have skew ≈ 0) + for i in 1..=20 { + model._set(&format!("A{i}"), &i.to_string()); + } + + model._set("B1", "=SKEW(A1:A20)"); + model._set("B2", "=SKEW.P(A1:A20)"); + + model.evaluate(); + + // Large symmetric dataset should have skew close to 0 + let b1_val: f64 = model._get_text("B1").parse().unwrap(); + let b2_val: f64 = model._get_text("B2").parse().unwrap(); + assert!(b1_val.abs() < 0.5); // Should be close to 0 + assert!(b2_val.abs() < 0.5); // Should be close to 0 +} + +// Test precision with small differences +#[test] +fn test_skew_precision() { + let mut model = new_empty_model(); + + // Test with very small numbers + model._set("A1", "=SKEW(0.001, 0.002, 0.003)"); + model._set("A2", "=SKEW.P(0.001, 0.002, 0.003)"); + + // Test with very large numbers + model._set("B1", "=SKEW(1000000, 2000000, 3000000)"); + model._set("B2", "=SKEW.P(1000000, 2000000, 3000000)"); + + model.evaluate(); + + // Both should be 0 (perfect symmetry) + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("A2"), *"0"); + assert_eq!(model._get_text("B1"), *"0"); + assert_eq!(model._get_text("B2"), *"0"); +} + +// Test ranges with no numeric values +#[test] +fn test_skew_empty_and_text_only() { + let mut model = new_empty_model(); + + // Range with only empty cells + model._set("A1", "=SKEW(B1:B5)"); // Empty range + model._set("A2", "=SKEW.P(B1:B5)"); // Empty range + + // Range with only text + model._set("C1", "'text"); + model._set("C2", "'more"); + model._set("C3", "'words"); + model._set("A3", "=SKEW(C1:C3)"); + model._set("A4", "=SKEW.P(C1:C3)"); + + model.evaluate(); + + // All should error due to no numeric values + assert_eq!(model._get_text("A1"), *"#DIV/0!"); + assert_eq!(model._get_text("A2"), *"#DIV/0!"); + assert_eq!(model._get_text("A3"), *"#DIV/0!"); + assert_eq!(model._get_text("A4"), *"#DIV/0!"); +} + +// Test SKEW vs SKEW.P differences +#[test] +fn test_skew_vs_skew_p_differences() { + let mut model = new_empty_model(); + + // Same dataset, different formulas + model._set("A1", "=SKEW(1, 2, 3, 4, 10)"); // Sample skewness + model._set("A2", "=SKEW.P(1, 2, 3, 4, 10)"); // Population skewness + + model.evaluate(); + + // Both should be positive (right-skewed), but different values + let skew_sample: f64 = model._get_text("A1").parse().unwrap(); + let skew_pop: f64 = model._get_text("A2").parse().unwrap(); + + assert!(skew_sample > 0.0); + assert!(skew_pop > 0.0); + assert_ne!(skew_sample, skew_pop); // Should be different values +} diff --git a/base/src/test/test_stdev.rs b/base/src/test/test_stdev.rs new file mode 100644 index 000000000..86aeed078 --- /dev/null +++ b/base/src/test/test_stdev.rs @@ -0,0 +1,298 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_stdev_no_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=STDEV.S()"); + model._set("A2", "=STDEV.P()"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); +} + +#[test] +fn test_fn_stdev_s_single_value_should_error() { + let mut model = new_empty_model(); + model._set("B1", "5"); + model._set("A1", "=STDEV.S(B1)"); + model._set("A2", "=STDEV.S(5)"); + model.evaluate(); + + // STDEV.S requires at least 2 values, should return #DIV/0! error + assert_eq!(model._get_text("A1"), *"#DIV/0!"); + assert_eq!(model._get_text("A2"), *"#DIV/0!"); +} + +#[test] +fn test_fn_stdev_p_single_value() { + let mut model = new_empty_model(); + model._set("B1", "5"); + model._set("A1", "=STDEV.P(B1)"); + model._set("A2", "=STDEV.P(5)"); + model.evaluate(); + + // STDEV.P with single value should return 0 + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("A2"), *"0"); +} + +#[test] +fn test_fn_stdev_empty_range() { + let mut model = new_empty_model(); + // B1:B3 are all empty + model._set("A1", "=STDEV.S(B1:B3)"); + model._set("A2", "=STDEV.P(B1:B3)"); + model.evaluate(); + + // Both should error with division by zero since no numeric values + assert_eq!(model._get_text("A1"), *"#DIV/0!"); + assert_eq!(model._get_text("A2"), *"#DIV/0!"); +} + +#[test] +fn test_fn_stdev_basic_calculation() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("A1", "=STDEV.S(B1:B3)"); + model._set("A2", "=STDEV.P(B1:B3)"); + model.evaluate(); + + // Sample standard deviation: sqrt(sum((x-mean)^2)/(n-1)) + // Values: 1, 2, 3; mean = 2 + // Variance = ((1-2)^2 + (2-2)^2 + (3-2)^2) / (3-1) = (1 + 0 + 1) / 2 = 1 + // STDEV.S = sqrt(1) = 1 + assert_eq!(model._get_text("A1"), *"1"); + + // Population standard deviation: sqrt(sum((x-mean)^2)/n) + // Variance = ((1-2)^2 + (2-2)^2 + (3-2)^2) / 3 = 2/3 ≈ 0.66667 + // STDEV.P = sqrt(2/3) ≈ 0.8164965809 + assert_eq!(model._get_text("A2"), *"0.816496581"); +} + +#[test] +fn test_fn_stdev_mixed_data_types() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("B4", "'text"); // String from reference - ignored + model._set("B5", ""); // Empty cell - ignored + model._set("B6", "TRUE"); // Boolean from reference - ignored + model._set("A1", "=STDEV.S(B1:B6)"); + model._set("A2", "=STDEV.P(B1:B6)"); + model.evaluate(); + + // Only numeric values 1, 2, 3 are used + assert_eq!(model._get_text("A1"), *"1"); + assert_eq!(model._get_text("A2"), *"0.816496581"); +} + +#[test] +fn test_fn_stdev_literals_vs_references() { + let mut model = new_empty_model(); + model._set("B1", "TRUE"); // Boolean from reference - ignored + model._set("B2", "'5"); // String from reference - ignored + // Boolean and string literals should be converted + model._set("A1", "=STDEV.S(1, 2, 3, TRUE, \"5\")"); + model._set("A2", "=STDEV.P(1, 2, 3, TRUE, \"5\")"); + model.evaluate(); + + // Values used: 1, 2, 3, 1 (TRUE), 5 ("5") = [1, 2, 3, 1, 5] + // Mean = 12/5 = 2.4 + // Sample variance = ((1-2.4)^2 + (2-2.4)^2 + (3-2.4)^2 + (1-2.4)^2 + (5-2.4)^2) / 4 + // = (1.96 + 0.16 + 0.36 + 1.96 + 6.76) / 4 = 11.2 / 4 = 2.8 + // STDEV.S = sqrt(2.8) ≈ 1.6733200531 + assert_eq!(model._get_text("A1"), *"1.673320053"); + + // Population variance = 11.2 / 5 = 2.24 + // STDEV.P = sqrt(2.24) ≈ 1.4966629547 + assert_eq!(model._get_text("A2"), *"1.496662955"); +} + +#[test] +fn test_fn_stdev_negative_numbers() { + let mut model = new_empty_model(); + model._set("B1", "-2"); + model._set("B2", "-1"); + model._set("B3", "0"); + model._set("B4", "1"); + model._set("B5", "2"); + model._set("A1", "=STDEV.S(B1:B5)"); + model._set("A2", "=STDEV.P(B1:B5)"); + model.evaluate(); + + // Values: -2, -1, 0, 1, 2; mean = 0 + // Sample variance = (4 + 1 + 0 + 1 + 4) / 4 = 10/4 = 2.5 + // STDEV.S = sqrt(2.5) ≈ 1.5811388301 + assert_eq!(model._get_text("A1"), *"1.58113883"); + + // Population variance = 10/5 = 2 + // STDEV.P = sqrt(2) ≈ 1.4142135624 + assert_eq!(model._get_text("A2"), *"1.414213562"); +} + +#[test] +fn test_fn_stdev_all_same_values() { + let mut model = new_empty_model(); + model._set("B1", "5"); + model._set("B2", "5"); + model._set("B3", "5"); + model._set("B4", "5"); + model._set("A1", "=STDEV.S(B1:B4)"); + model._set("A2", "=STDEV.P(B1:B4)"); + model.evaluate(); + + // All values are the same, so standard deviation should be 0 + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("A2"), *"0"); +} + +#[test] +fn test_fn_stdev_error_propagation() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "=1/0"); // Division by zero error + model._set("B3", "3"); + model._set("A1", "=STDEV.S(B1:B3)"); + model._set("A2", "=STDEV.P(B1:B3)"); + model.evaluate(); + + // Error should propagate + assert_eq!(model._get_text("A1"), *"#DIV/0!"); + assert_eq!(model._get_text("A2"), *"#DIV/0!"); +} + +#[test] +fn test_fn_stdev_larger_dataset() { + let mut model = new_empty_model(); + // Setting up a larger dataset: 1, 4, 9, 16, 25, 36, 49, 64, 81, 100 + for i in 1..=10 { + model._set(&format!("B{i}"), &format!("{}", i * i)); + } + model._set("A1", "=STDEV.S(B1:B10)"); + model._set("A2", "=STDEV.P(B1:B10)"); + model.evaluate(); + + // Values: 1, 4, 9, 16, 25, 36, 49, 64, 81, 100 + // This is a known dataset, we can verify the mathematical correctness + // Mean = 385/10 = 38.5 + // Sample std dev should be approximately 32.731... + // Population std dev should be approximately 31.113... + + // The exact values would need calculation, but we're testing the functions work with larger datasets + // and don't crash or produce obviously wrong results + let result_s = model._get_text("A1"); + let result_p = model._get_text("A2"); + + // Basic sanity checks - results should be positive numbers + assert!(result_s.parse::().unwrap() > 0.0); + assert!(result_p.parse::().unwrap() > 0.0); + // Sample std dev should be larger than population std dev + assert!(result_s.parse::().unwrap() > result_p.parse::().unwrap()); +} + +#[test] +fn test_fn_stdev_decimal_values() { + let mut model = new_empty_model(); + model._set("B1", "1.5"); + model._set("B2", "2.7"); + model._set("B3", "3.1"); + model._set("B4", "4.9"); + model._set("A1", "=STDEV.S(B1:B4)"); + model._set("A2", "=STDEV.P(B1:B4)"); + model.evaluate(); + + // Values: 1.5, 2.7, 3.1, 4.9; mean = 12.2/4 = 3.05 + // Should handle decimal calculations correctly + let result_s = model._get_text("A1"); + let result_p = model._get_text("A2"); + + assert!(result_s.parse::().unwrap() > 0.0); + assert!(result_p.parse::().unwrap() > 0.0); + assert!(result_s.parse::().unwrap() > result_p.parse::().unwrap()); +} + +#[test] +fn test_fn_stdev_with_false_boolean_literal() { + let mut model = new_empty_model(); + model._set("A1", "=STDEV.S(0, 1, FALSE)"); // FALSE literal should become 0 + model._set("A2", "=STDEV.P(0, 1, FALSE)"); + model.evaluate(); + + // Values: 0, 1, 0 (FALSE); mean = 1/3 ≈ 0.333 + // This tests that FALSE literals are properly converted to 0 + let result_s = model._get_text("A1"); + let result_p = model._get_text("A2"); + + assert!(result_s.parse::().unwrap() > 0.0); + assert!(result_p.parse::().unwrap() > 0.0); +} + +#[test] +fn test_fn_stdev_mixed_arguments_ranges_and_literals() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("A1", "=STDEV.S(B1:B2, 3, 4)"); // Mix of range and literals + model._set("A2", "=STDEV.P(B1:B2, 3, 4)"); + model.evaluate(); + + // Values: 1, 2, 3, 4; mean = 2.5 + // Sample variance = ((1-2.5)^2 + (2-2.5)^2 + (3-2.5)^2 + (4-2.5)^2) / 3 + // = (2.25 + 0.25 + 0.25 + 2.25) / 3 = 5/3 ≈ 1.667 + // STDEV.S = sqrt(5/3) ≈ 1.2909944487 + assert_eq!(model._get_text("A1"), *"1.290994449"); + + // Population variance = 5/4 = 1.25 + // STDEV.P = sqrt(1.25) ≈ 1.1180339887 + assert_eq!(model._get_text("A2"), *"1.118033989"); +} + +#[test] +fn test_fn_stdev_range_with_only_non_numeric() { + let mut model = new_empty_model(); + model._set("B1", "'text"); + model._set("B2", "TRUE"); // Boolean from reference + model._set("B3", ""); // Empty + model._set("A1", "=STDEV.S(B1:B3)"); + model._set("A2", "=STDEV.P(B1:B3)"); + model.evaluate(); + + // No numeric values, should error + assert_eq!(model._get_text("A1"), *"#DIV/0!"); + assert_eq!(model._get_text("A2"), *"#DIV/0!"); +} + +#[test] +fn test_fn_stdev_mathematical_correctness_known_values() { + let mut model = new_empty_model(); + // Using a simple known dataset for exact verification + model._set("B1", "2"); + model._set("B2", "4"); + model._set("B3", "4"); + model._set("B4", "4"); + model._set("B5", "5"); + model._set("B6", "5"); + model._set("B7", "7"); + model._set("B8", "9"); + model._set("A1", "=STDEV.S(B1:B8)"); + model._set("A2", "=STDEV.P(B1:B8)"); + model.evaluate(); + + // Values: 2, 4, 4, 4, 5, 5, 7, 9; mean = 40/8 = 5 + // Sample variance = ((2-5)^2 + (4-5)^2 + (4-5)^2 + (4-5)^2 + (5-5)^2 + (5-5)^2 + (7-5)^2 + (9-5)^2) / 7 + // = (9 + 1 + 1 + 1 + 0 + 0 + 4 + 16) / 7 = 32/7 + // STDEV.S = sqrt(32/7) ≈ 2.1380899353 + let result_s = model._get_text("A1"); + let expected_s = (32.0 / 7.0_f64).sqrt(); + assert!((result_s.parse::().unwrap() - expected_s).abs() < 1e-9); + + // Population variance = 32/8 = 4 + // STDEV.P = sqrt(4) = 2 + assert_eq!(model._get_text("A2"), *"2"); +} diff --git a/base/src/test/util.rs b/base/src/test/util.rs index e50e347d3..afc7f7e91 100644 --- a/base/src/test/util.rs +++ b/base/src/test/util.rs @@ -1,4 +1,5 @@ #![allow(clippy::unwrap_used)] +#![allow(clippy::panic)] use crate::expressions::types::CellReferenceIndex; use crate::model::Model; @@ -51,3 +52,13 @@ impl Model { .unwrap() } } + +pub fn assert_approx_eq(actual: &str, expected: f64, tolerance: f64) { + let actual_val: f64 = actual + .parse() + .unwrap_or_else(|_| panic!("Failed to parse result as number: {actual}")); + assert!( + (actual_val - expected).abs() < tolerance, + "Expected ~{expected}, got {actual}" + ); +} diff --git a/docs/src/functions/statistical.md b/docs/src/functions/statistical.md index 6842212c3..e0800a507 100644 --- a/docs/src/functions/statistical.md +++ b/docs/src/functions/statistical.md @@ -12,10 +12,10 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | Function | Status | Documentation | | ------------------------ |--------------------------------------------------| ------------- | | AVEDEV | | – | -| AVERAGE | | – | -| AVERAGEA | | – | -| AVERAGEIF | | – | -| AVERAGEIFS | | – | +| AVERAGE | | [AVERAGE](/functions/statistical/average) | +| AVERAGEA | | [AVERAGEA](/functions/statistical/averagea) | +| AVERAGEIF | | [AVERAGEIF](/functions/statistical/averageif) | +| AVERAGEIFS | | [AVERAGEIFS](/functions/statistical/averageifs) | | BETA.DIST | | – | | BETA.INV | | – | | BINOM.DIST | | – | @@ -28,12 +28,12 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | CHISQ.TEST | | – | | CONFIDENCE.NORM | | – | | CONFIDENCE.T | | – | -| CORREL | | – | -| COUNT | | – | -| COUNTA | | – | -| COUNTBLANK | | – | -| COUNTIF | | – | -| COUNTIFS | | – | +| CORREL | | [CORREL](/functions/statistical/correl) | +| COUNT | | [COUNT](/functions/statistical/count) | +| COUNTA | | [COUNTA](/functions/statistical/counta) | +| COUNTBLANK | | [COUNTBLANK](/functions/statistical/countblank) | +| COUNTIF | | [COUNTIF](/functions/statistical/countif) | +| COUNTIFS | | [COUNTIFS](/functions/statistical/countifs) | | COVARIANCE.P | | – | | COVARIANCE.S | | – | | DEVSQ | | – | @@ -58,21 +58,21 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | GAMMALN | | – | | GAMMALN.PRECISE | | – | | GAUSS | | – | -| GEOMEAN | | – | +| GEOMEAN | | [GEOMEAN](/functions/statistical/geomean) | | GROWTH | | – | | HARMEAN | | – | | HYPGEOM.DIST | | – | -| INTERCEPT | | – | +| INTERCEPT | | [INTERCEPT](/functions/statistical/intercept) | | KURT | | – | -| LARGE | | – | +| LARGE | | [LARGE](/functions/statistical/large) | | LINEST | | – | | LOGEST | | – | | LOGNORM.DIST | | – | | LOGNORM.INV | | – | -| MAX | | – | +| MAX | | [MAX](/functions/statistical/max) | | MAXA | | – | -| MAXIFS | | – | -| MEDIAN | | – | +| MAXIFS | | [MAXIFS](/functions/statistical/maxifs) | +| MEDIAN | | [MEDIAN](/functions/statistical/median) | | MODE.MULT | | – | | MODE.SNGL | | – | | NEGBINOM.DIST | | – | @@ -90,20 +90,22 @@ 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](/functions/statistical/quartile) | +| QUARTILE.EXC | | [QUARTILE.EXC](/functions/statistical/quartile.exc) | +| QUARTILE.INC | | [QUARTILE.INC](/functions/statistical/quartile.inc) | +| RANK | | [RANK](/functions/statistical/rank) | +| RANK.AVG | | [RANK.AVG](/functions/statistical/rank.avg) | +| RANK.EQ | | [RANK.EQ](/functions/statistical/rank.eq) | | RSQ | | – | -| SKEW | | – | -| SKEW.P | | – | -| SLOPE | | – | -| SMALL | | – | +| SKEW | | [SKEW](/functions/statistical/skew) | +| SKEW.P | | [SKEW.P](/functions/statistical/skew.p) | +| SLOPE | | [SLOPE](/functions/statistical/slope) | +| SMALL | | [SMALL](/functions/statistical/small) | | STANDARDIZE | | – | -| STDEV.P | | – | -| STDEV.S | | – | -| STDEVA | | – | -| STDEVPA | | – | +| STDEV.P | | [STDEV.P](/functions/statistical/stdev.p) | +| STDEV.S | | [STDEV.S](/functions/statistical/stdev.s) | +| STDEVA | | [STDEVA](/functions/statistical/stdeva) | +| STDEVPA | | [STDEVPA](/functions/statistical/stdevpa) | | STEYX | | – | | T.DIST | | – | | T.DIST.2T | | – | @@ -113,9 +115,9 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | T.TEST | | – | | TREND | | – | | TRIMMEAN | | – | -| VAR.P | | – | -| VAR.S | | – | -| VARA | | – | -| VARPA | | – | +| VAR.P | | [VAR.P](/functions/statistical/var.p) | +| VAR.S | | [VAR.S](/functions/statistical/var.s) | +| VARA | | [VARA](/functions/statistical/vara) | +| VARPA | | [VARPA](/functions/statistical/varpa) | | WEIBULL.DIST | | – | | Z.TEST | | – | diff --git a/docs/src/functions/statistical/correl.md b/docs/src/functions/statistical/correl.md index 647f06325..082dff624 100644 --- a/docs/src/functions/statistical/correl.md +++ b/docs/src/functions/statistical/correl.md @@ -7,6 +7,6 @@ lang: en-US # CORREL ::: warning -🚧 This function is not yet available in IronCalc. +🚧 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). [Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) ::: \ No newline at end of file diff --git a/docs/src/functions/statistical/intercept.md b/docs/src/functions/statistical/intercept.md index 4d8208842..280583b2d 100644 --- a/docs/src/functions/statistical/intercept.md +++ b/docs/src/functions/statistical/intercept.md @@ -7,6 +7,5 @@ lang: en-US # INTERCEPT ::: 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/large.md b/docs/src/functions/statistical/large.md index 92a698ae0..4bc485858 100644 --- a/docs/src/functions/statistical/large.md +++ b/docs/src/functions/statistical/large.md @@ -7,6 +7,5 @@ lang: en-US # LARGE ::: 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 +🚧 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). +::: diff --git a/docs/src/functions/statistical/median.md b/docs/src/functions/statistical/median.md index b6161adce..f92559146 100644 --- a/docs/src/functions/statistical/median.md +++ b/docs/src/functions/statistical/median.md @@ -7,6 +7,5 @@ lang: en-US # MEDIAN ::: 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/percentile.exc.md b/docs/src/functions/statistical/percentile.exc.md index f850e760a..d01d23dd2 100644 --- a/docs/src/functions/statistical/percentile.exc.md +++ b/docs/src/functions/statistical/percentile.exc.md @@ -7,6 +7,5 @@ lang: en-US # PERCENTILE.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/percentile.inc.md b/docs/src/functions/statistical/percentile.inc.md index 972640d06..9c2fb0106 100644 --- a/docs/src/functions/statistical/percentile.inc.md +++ b/docs/src/functions/statistical/percentile.inc.md @@ -7,6 +7,5 @@ lang: en-US # PERCENTILE.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/percentrank.exc.md b/docs/src/functions/statistical/percentrank.exc.md index 03e93c161..bfb296acf 100644 --- a/docs/src/functions/statistical/percentrank.exc.md +++ b/docs/src/functions/statistical/percentrank.exc.md @@ -7,6 +7,5 @@ lang: en-US # PERCENTRANK.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/percentrank.inc.md b/docs/src/functions/statistical/percentrank.inc.md index c7870af7a..22e6f27b6 100644 --- a/docs/src/functions/statistical/percentrank.inc.md +++ b/docs/src/functions/statistical/percentrank.inc.md @@ -7,6 +7,5 @@ lang: en-US # PERCENTRANK.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.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 diff --git a/docs/src/functions/statistical/skew.md b/docs/src/functions/statistical/skew.md index d0d0cc50b..929d064d5 100644 --- a/docs/src/functions/statistical/skew.md +++ b/docs/src/functions/statistical/skew.md @@ -7,6 +7,5 @@ lang: en-US # SKEW ::: 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/skew.p.md b/docs/src/functions/statistical/skew.p.md index 59c2cff25..68935bd4e 100644 --- a/docs/src/functions/statistical/skew.p.md +++ b/docs/src/functions/statistical/skew.p.md @@ -7,6 +7,5 @@ lang: en-US # SKEW.P ::: 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/slope.md b/docs/src/functions/statistical/slope.md index fc6d59db3..185314ef9 100644 --- a/docs/src/functions/statistical/slope.md +++ b/docs/src/functions/statistical/slope.md @@ -7,6 +7,5 @@ lang: en-US # SLOPE ::: 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/small.md b/docs/src/functions/statistical/small.md index 1942e4c05..27aa3f7c8 100644 --- a/docs/src/functions/statistical/small.md +++ b/docs/src/functions/statistical/small.md @@ -7,6 +7,5 @@ lang: en-US # SMALL ::: 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 +🚧 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). +::: diff --git a/docs/src/functions/statistical/stdev.p.md b/docs/src/functions/statistical/stdev.p.md index 5bcbd9a73..08589e24c 100644 --- a/docs/src/functions/statistical/stdev.p.md +++ b/docs/src/functions/statistical/stdev.p.md @@ -7,6 +7,5 @@ lang: en-US # STDEV.P ::: 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/stdev.s.md b/docs/src/functions/statistical/stdev.s.md index cdf0b0624..e0be38453 100644 --- a/docs/src/functions/statistical/stdev.s.md +++ b/docs/src/functions/statistical/stdev.s.md @@ -7,6 +7,5 @@ lang: en-US # STDEV.S ::: 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/stdeva.md b/docs/src/functions/statistical/stdeva.md index a00072fe2..c24b85d41 100644 --- a/docs/src/functions/statistical/stdeva.md +++ b/docs/src/functions/statistical/stdeva.md @@ -7,6 +7,5 @@ lang: en-US # STDEVA ::: 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/stdevpa.md b/docs/src/functions/statistical/stdevpa.md index 3e3f49282..56f104866 100644 --- a/docs/src/functions/statistical/stdevpa.md +++ b/docs/src/functions/statistical/stdevpa.md @@ -7,6 +7,5 @@ lang: en-US # STDEVPA ::: 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/var.p.md b/docs/src/functions/statistical/var.p.md index 11d4dcaca..5ecd391b3 100644 --- a/docs/src/functions/statistical/var.p.md +++ b/docs/src/functions/statistical/var.p.md @@ -7,6 +7,6 @@ lang: en-US # VAR.P ::: warning -🚧 This function is not yet available in IronCalc. +🚧 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). [Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) ::: \ No newline at end of file diff --git a/docs/src/functions/statistical/var.s.md b/docs/src/functions/statistical/var.s.md index b0f8cea27..a362cb265 100644 --- a/docs/src/functions/statistical/var.s.md +++ b/docs/src/functions/statistical/var.s.md @@ -7,6 +7,6 @@ lang: en-US # VAR.S ::: warning -🚧 This function is not yet available in IronCalc. +🚧 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). [Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) ::: \ No newline at end of file diff --git a/docs/src/functions/statistical/vara.md b/docs/src/functions/statistical/vara.md index fa683eb12..1b369f64a 100644 --- a/docs/src/functions/statistical/vara.md +++ b/docs/src/functions/statistical/vara.md @@ -7,6 +7,5 @@ lang: en-US # VARA ::: 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/varpa.md b/docs/src/functions/statistical/varpa.md index dd9b06b38..f92c9b00a 100644 --- a/docs/src/functions/statistical/varpa.md +++ b/docs/src/functions/statistical/varpa.md @@ -7,6 +7,5 @@ lang: en-US # VARPA ::: 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