diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 04edf5614..71c5ea60a 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -1002,6 +1002,10 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec vec![Signature::Vector; arg_count], Function::Median => vec![Signature::Vector; arg_count], Function::MinA => vec![Signature::Vector; arg_count], + Function::Quartile => vec![Signature::Vector, Signature::Scalar], + Function::QuartileExc => vec![Signature::Vector, Signature::Scalar], + Function::QuartileInc => vec![Signature::Vector, Signature::Scalar], + Function::Rank => vec![Signature::Scalar, Signature::Vector, Signature::Scalar], Function::RankAvg => vec![Signature::Scalar, Signature::Vector, Signature::Scalar], Function::RankEq => vec![Signature::Scalar, Signature::Vector, Signature::Scalar], Function::Skew => vec![Signature::Vector; arg_count], @@ -1353,6 +1357,10 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::MaxA => StaticResult::Scalar, Function::Median => StaticResult::Scalar, Function::MinA => StaticResult::Scalar, + Function::Quartile => StaticResult::Scalar, + Function::QuartileExc => StaticResult::Scalar, + Function::QuartileInc => StaticResult::Scalar, + Function::Rank => StaticResult::Scalar, Function::RankAvg => StaticResult::Scalar, Function::RankEq => StaticResult::Scalar, Function::Skew => StaticResult::Scalar, diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 3a8edf115..b6b97d32c 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -253,8 +253,10 @@ pub enum Function { Phi, PoissonDist, // Prob, - // QuartileExc, - // QuartileInc, + Quartile, + QuartileExc, + QuartileInc, + Rank, RankAvg, RankEq, Skew, @@ -428,6 +430,14 @@ macro_rules! impl_function_lookup { impl Functions { pub fn lookup(&self, name: &str) -> Option { let key = name.to_uppercase(); + // New functions without localization support + match key.as_str() { + "RANK" => return Some(Function::Rank), + "QUARTILE" => return Some(Function::Quartile), + "QUARTILE.INC" => return Some(Function::QuartileInc), + "QUARTILE.EXC" => return Some(Function::QuartileExc), + _ => {} + } $( if self.$field == key { return Some(Function::$variant); @@ -1019,6 +1029,10 @@ impl Function { Function::Pearson => functions.pearson.clone(), Function::Phi => functions.phi.clone(), Function::PoissonDist => functions.poissondist.clone(), + Function::Quartile => "QUARTILE".to_string(), + Function::QuartileExc => "QUARTILE.EXC".to_string(), + Function::QuartileInc => "QUARTILE.INC".to_string(), + Function::Rank => "RANK".to_string(), Function::RankAvg => functions.rankavg.clone(), Function::RankEq => functions.rankeq.clone(), Function::Skew => functions.skew.clone(), @@ -1168,7 +1182,7 @@ impl Function { Function::Steyx => functions.steyx.clone(), } } - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -1506,6 +1520,10 @@ impl Function { Function::Large, Function::Median, Function::Small, + Function::Quartile, + Function::QuartileExc, + Function::QuartileInc, + Function::Rank, Function::RankAvg, Function::RankEq, Function::Skew, @@ -2001,6 +2019,10 @@ impl<'a> Model<'a> { Function::MaxA => self.fn_maxa(args, cell), Function::Median => self.fn_median(args, cell), Function::MinA => self.fn_mina(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::Skew => self.fn_skew(args, cell), diff --git a/base/src/functions/statistical/mod.rs b/base/src/functions/statistical/mod.rs index 7d9eb5cfa..9fe8d9f9e 100644 --- a/base/src/functions/statistical/mod.rs +++ b/base/src/functions/statistical/mod.rs @@ -17,6 +17,7 @@ mod normal; mod pearson; mod phi; mod poisson; +mod quartile; mod rank_eq_avg; mod standard_dev; mod standardize; diff --git a/base/src/functions/statistical/quartile.rs b/base/src/functions/statistical/quartile.rs new file mode 100644 index 000000000..0d586c171 --- /dev/null +++ b/base/src/functions/statistical/quartile.rs @@ -0,0 +1,134 @@ +use std::cmp::Ordering; + +use crate::expressions::types::CellReferenceIndex; +use crate::{ + calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model, +}; + +impl<'a> Model<'a> { + /// Helper to collect numeric values for QUARTILE functions + fn collect_quartile_values( + &mut self, + arg: &Node, + cell: CellReferenceIndex, + ) -> Result, CalcResult> { + let values = match self.evaluate_node_in_context(arg, cell) { + CalcResult::Array(array) => match self.values_from_array(array) { + Ok(v) => v, + Err(e) => { + return Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: format!("Unsupported array argument: {}", e), + }) + } + }, + CalcResult::Range { left, right } => self.values_from_range(left, right)?, + CalcResult::Number(value) => vec![Some(value)], + _ => { + return Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Unsupported argument type".to_string(), + }) + } + }; + + let numeric_values: Vec = values.into_iter().flatten().collect(); + Ok(numeric_values) + } + + /// QUARTILE(array, quart) + /// Compatibility function - same as QUARTILE.INC + pub(crate) fn fn_quartile(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.fn_quartile_inc(args, cell) + } + + /// QUARTILE.INC(array, quart) + /// Returns the quartile of a data set (inclusive method) + 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.collect_quartile_values(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + let quart = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + self.quartile_impl(values, quart, true, cell) + } + + /// QUARTILE.EXC(array, quart) + /// Returns the quartile of a data set (exclusive method) + 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.collect_quartile_values(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + let quart = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + self.quartile_impl(values, quart, false, cell) + } + + /// Internal implementation for QUARTILE functions + fn quartile_impl( + &self, + mut values: Vec, + quart: f64, + inclusive: bool, + cell: CellReferenceIndex, + ) -> CalcResult { + 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])) + } + } +} diff --git a/base/src/functions/statistical/rank_eq_avg.rs b/base/src/functions/statistical/rank_eq_avg.rs index 1c99fca25..7182c32bc 100644 --- a/base/src/functions/statistical/rank_eq_avg.rs +++ b/base/src/functions/statistical/rank_eq_avg.rs @@ -199,4 +199,10 @@ impl<'a> Model<'a> { CalcResult::Number(rank) } + + /// RANK(number, ref, [order]) + /// Compatibility function - same as RANK.EQ + pub(crate) fn fn_rank(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.fn_rank_eq(args, cell) + } } diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 3c2b0f052..969b9b3ed 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -77,6 +77,7 @@ mod test_extend; mod test_floor; mod test_fn_datevalue_timevalue; mod test_fn_fv; +mod test_fn_quartile_rank; mod test_fn_round; mod test_fn_type; mod test_frozen_rows_and_columns; 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_quartile_rank.rs b/base/src/test/test_fn_quartile_rank.rs new file mode 100644 index 000000000..07cb6d807 --- /dev/null +++ b/base/src/test/test_fn_quartile_rank.rs @@ -0,0 +1,84 @@ +use crate::cell::CellValue; +use crate::test::util::new_empty_model; + +#[test] +fn test_rank_basic() { + let mut model = new_empty_model(); + model._set("A1", "1"); + model._set("A2", "2"); + model._set("A3", "3"); + model._set("A4", "4"); + model._set("A5", "5"); + + // RANK(3, A1:A5) - descending order, 3 is 3rd from top + model._set("B1", "=RANK(3, A1:A5)"); + model.evaluate(); + + assert_eq!( + model.get_cell_value_by_ref("Sheet1!B1"), + Ok(CellValue::Number(3.0)) + ); + + // RANK(3, A1:A5, 1) - ascending order, 3 is 3rd from bottom + model._set("B2", "=RANK(3, A1:A5, 1)"); + model.evaluate(); + + assert_eq!( + model.get_cell_value_by_ref("Sheet1!B2"), + Ok(CellValue::Number(3.0)) + ); +} + +#[test] +fn test_quartile_basic() { + let mut model = new_empty_model(); + // Data: 1, 2, 3, 4, 5, 6, 7, 8 + for i in 1..=8 { + model._set(&format!("A{}", i), &i.to_string()); + } + + // QUARTILE(A1:A8, 0) - minimum + model._set("B1", "=QUARTILE(A1:A8, 0)"); + // QUARTILE(A1:A8, 2) - median + model._set("B2", "=QUARTILE(A1:A8, 2)"); + // QUARTILE(A1:A8, 4) - maximum + model._set("B3", "=QUARTILE(A1:A8, 4)"); + model.evaluate(); + + assert_eq!( + model.get_cell_value_by_ref("Sheet1!B1"), + Ok(CellValue::Number(1.0)) + ); + assert_eq!( + model.get_cell_value_by_ref("Sheet1!B2"), + Ok(CellValue::Number(4.5)) + ); + assert_eq!( + model.get_cell_value_by_ref("Sheet1!B3"), + Ok(CellValue::Number(8.0)) + ); +} + +#[test] +fn test_quartile_inc() { + let mut model = new_empty_model(); + // Data: 1, 2, 3, 4, 5 + for i in 1..=5 { + model._set(&format!("A{}", i), &i.to_string()); + } + + // QUARTILE.INC(A1:A5, 1) - Q1 + model._set("B1", "=QUARTILE.INC(A1:A5, 1)"); + // QUARTILE.INC(A1:A5, 3) - Q3 + model._set("B2", "=QUARTILE.INC(A1:A5, 3)"); + model.evaluate(); + + assert_eq!( + model.get_cell_value_by_ref("Sheet1!B1"), + Ok(CellValue::Number(2.0)) + ); + assert_eq!( + model.get_cell_value_by_ref("Sheet1!B2"), + Ok(CellValue::Number(4.0)) + ); +} 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 75fb7890b..6ef5fde2b 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; @@ -53,3 +54,14 @@ impl<'a> Model<'a> { .unwrap() } } + +#[allow(dead_code)] +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/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.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