diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 04edf5614..405546438 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::PercentileExc => vec![Signature::Vector, Signature::Scalar], + Function::PercentileInc => vec![Signature::Vector, Signature::Scalar], + Function::PercentrankExc => vec![Signature::Vector, Signature::Scalar, Signature::Scalar], + Function::PercentrankInc => vec![Signature::Vector, Signature::Scalar, 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::PercentileExc => StaticResult::Scalar, + Function::PercentileInc => StaticResult::Scalar, + Function::PercentrankExc => StaticResult::Scalar, + Function::PercentrankInc => 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..dbb988fc0 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -244,10 +244,10 @@ pub enum Function { NormSdist, NormSInv, Pearson, - // PercentileExc, - // PercentileInc, - // PercentrankExc, - // PercentrankInc, + PercentileExc, + PercentileInc, + PercentrankExc, + PercentrankInc, // Permut, // Permutationa, Phi, @@ -428,6 +428,14 @@ macro_rules! impl_function_lookup { impl Functions { pub fn lookup(&self, name: &str) -> Option { let key = name.to_uppercase(); + // Hardcoded lookups for functions not yet in language.json + match key.as_str() { + "PERCENTILE.EXC" => return Some(Function::PercentileExc), + "PERCENTILE.INC" => return Some(Function::PercentileInc), + "PERCENTRANK.EXC" => return Some(Function::PercentrankExc), + "PERCENTRANK.INC" => return Some(Function::PercentrankInc), + _ => {} + } $( if self.$field == key { return Some(Function::$variant); @@ -1017,6 +1025,10 @@ impl Function { Function::NormSdist => functions.normsdist.clone(), Function::NormSInv => functions.normsinv.clone(), Function::Pearson => functions.pearson.clone(), + Function::PercentileExc => "PERCENTILE.EXC".to_string(), + Function::PercentileInc => "PERCENTILE.INC".to_string(), + Function::PercentrankExc => "PERCENTRANK.EXC".to_string(), + Function::PercentrankInc => "PERCENTRANK.INC".to_string(), Function::Phi => functions.phi.clone(), Function::PoissonDist => functions.poissondist.clone(), Function::RankAvg => functions.rankavg.clone(), @@ -1967,6 +1979,10 @@ impl<'a> Model<'a> { Function::NormSdist => self.fn_norm_s_dist(args, cell), Function::NormSInv => self.fn_norm_s_inv(args, cell), Function::Pearson => self.fn_pearson(args, cell), + Function::PercentileExc => self.fn_percentile_exc(args, cell), + Function::PercentileInc => self.fn_percentile_inc(args, cell), + Function::PercentrankExc => self.fn_percentrank_exc(args, cell), + Function::PercentrankInc => self.fn_percentrank_inc(args, cell), Function::Phi => self.fn_phi(args, cell), Function::PoissonDist => self.fn_poisson_dist(args, cell), Function::Standardize => self.fn_standardize(args, cell), diff --git a/base/src/functions/statistical/mod.rs b/base/src/functions/statistical/mod.rs index 7d9eb5cfa..e64b64775 100644 --- a/base/src/functions/statistical/mod.rs +++ b/base/src/functions/statistical/mod.rs @@ -15,6 +15,7 @@ mod if_ifs; mod log_normal; mod normal; mod pearson; +mod percentile; mod phi; mod poisson; mod rank_eq_avg; diff --git a/base/src/functions/statistical/percentile.rs b/base/src/functions/statistical/percentile.rs new file mode 100644 index 000000000..f76425da0 --- /dev/null +++ b/base/src/functions/statistical/percentile.rs @@ -0,0 +1,310 @@ +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 PERCENTILE/PERCENTRANK functions + fn collect_percentile_values( + &mut self, + arg: &Node, + cell: CellReferenceIndex, + ) -> Result, CalcResult> { + let values = match self.evaluate_node_in_context(arg, cell) { + CalcResult::Number(value) => vec![Some(value)], + CalcResult::Boolean(b) => { + if !matches!(arg, Node::ReferenceKind { .. }) { + vec![Some(if b { 1.0 } else { 0.0 })] + } else { + vec![] + } + } + CalcResult::String(s) => { + if !matches!(arg, Node::ReferenceKind { .. }) { + if let Ok(v) = s.parse::() { + vec![Some(v)] + } else { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Argument cannot be cast into number".to_string(), + )); + } + } else { + vec![] + } + } + CalcResult::Range { left, right } => self.values_from_range(left, right)?, + 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::Error { .. } => return Err(self.evaluate_node_in_context(arg, cell)), + CalcResult::EmptyCell | CalcResult::EmptyArg => vec![], + }; + + let numeric_values: Vec = values.into_iter().flatten().collect(); + Ok(numeric_values) + } + + /// PERCENTILE.INC(array, k) + /// Returns the k-th percentile of values in a range, where k is in [0,1]. + pub(crate) fn fn_percentile_inc( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + + let mut values = match self.collect_percentile_values(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + + 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 k = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + + if !(0.0..=1.0).contains(&k) { + return CalcResult::new_error( + Error::NUM, + cell, + "k must be between 0 and 1".to_string(), + ); + } + + let n = values.len() as f64; + let pos = k * (n - 1.0) + 1.0; + let m = pos.floor(); + let g = pos - m; + let idx = (m as usize).saturating_sub(1); + + if idx >= values.len() - 1 { + return CalcResult::Number(values[values.len() - 1]); + } + + let result = values[idx] + g * (values[idx + 1] - values[idx]); + CalcResult::Number(result) + } + + /// PERCENTILE.EXC(array, k) + /// Returns the k-th percentile of values in a range, where k is in (0,1). + pub(crate) fn fn_percentile_exc( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + + let mut values = match self.collect_percentile_values(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + + 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 k = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + + let n = values.len() as f64; + + if k <= 0.0 || k >= 1.0 { + return CalcResult::new_error( + Error::NUM, + cell, + "k must be strictly between 0 and 1".to_string(), + ); + } + + let pos = k * (n + 1.0); + if pos < 1.0 || pos > n { + return CalcResult::new_error( + Error::NUM, + cell, + "k out of range for data size".to_string(), + ); + } + + let m = pos.floor(); + let g = pos - m; + let idx = (m as usize).saturating_sub(1); + + if idx >= values.len() - 1 { + return CalcResult::Number(values[values.len() - 1]); + } + + let result = values[idx] + g * (values[idx + 1] - values[idx]); + CalcResult::Number(result) + } + + /// PERCENTRANK.INC(array, x, [significance]) + /// Returns the rank of a value as a percentage of the data set (inclusive). + pub(crate) fn fn_percentrank_inc( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if !(2..=3).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + + let mut values = match self.collect_percentile_values(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + + 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 x = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + + let significance = if args.len() == 3 { + match self.get_number(&args[2], cell) { + Ok(v) => v as i32, + Err(e) => return e, + } + } else { + 3 + }; + + let n = values.len() as f64; + + // Handle single element array + if n == 1.0 { + if (x - values[0]).abs() <= f64::EPSILON { + // Single element exact match returns 0.5 + let factor = 10f64.powi(significance); + let result = (0.5 * factor).floor() / factor; + return CalcResult::Number(result); + } else { + return CalcResult::new_error(Error::NA, cell, "Value not found".to_string()); + } + } + + // Handle boundary cases - clamp to 0 or 1 + if x < values[0] { + return CalcResult::Number(0.0); + } + if x > values[values.len() - 1] { + return CalcResult::Number(1.0); + } + + // Find position + let mut idx = 0; + while idx < values.len() && values[idx] < x { + idx += 1; + } + + let rank = if idx < values.len() && (x - values[idx]).abs() <= f64::EPSILON { + // Exact match + idx as f64 + } else if idx == 0 { + 0.0 + } else { + // Interpolate + let lower = values[idx - 1]; + let upper = values[idx]; + (idx as f64 - 1.0) + (x - lower) / (upper - lower) + }; + + let mut result = rank / (n - 1.0); + let factor = 10f64.powi(significance); + result = (result * factor).round() / factor; + CalcResult::Number(result) + } + + /// PERCENTRANK.EXC(array, x, [significance]) + /// Returns the rank of a value as a percentage of the data set (exclusive). + pub(crate) fn fn_percentrank_exc( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if !(2..=3).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + + let mut values = match self.collect_percentile_values(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + + 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 x = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + + let significance = if args.len() == 3 { + match self.get_number(&args[2], cell) { + Ok(v) => v as i32, + Err(e) => return e, + } + } else { + 3 + }; + + let n = values.len(); + + // Exclusive: x must be strictly within the range + if x <= values[0] || x >= values[n - 1] { + return CalcResult::new_error(Error::NUM, cell, "x out of range".to_string()); + } + + // Find position + let mut idx = 0; + while idx < n && values[idx] < x { + idx += 1; + } + + let rank = if (x - values[idx]).abs() > f64::EPSILON { + // Interpolate + let lower = values[idx - 1]; + let upper = values[idx]; + idx as f64 + (x - lower) / (upper - lower) + } else { + (idx + 1) as f64 + }; + + let mut result = rank / ((n + 1) as f64); + let factor = 10f64.powi(significance); + result = (result * factor).round() / factor; + CalcResult::Number(result) + } +} diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 3c2b0f052..f0af1cc9b 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -94,6 +94,8 @@ mod test_mod_quotient; mod test_networkdays; mod test_now; mod test_percentage; +mod test_percentile; +mod test_percentrank; mod test_range_evaluation; mod test_set_functions_error_handling; mod test_sheet_names; 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/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