diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 80f194360..c09e0b774 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -575,6 +575,16 @@ fn args_signature_xnpv(arg_count: usize) -> Vec { } } +fn args_signature_rank(arg_count: usize) -> Vec { + if arg_count == 2 { + vec![Signature::Scalar, Signature::Vector] + } else if arg_count == 3 { + vec![Signature::Scalar, Signature::Vector, Signature::Scalar] + } else { + vec![Signature::Error; arg_count] + } +} + // FIXME: This is terrible duplications of efforts. We use the signature in at least three different places: // 1. When computing the function // 2. Checking the arguments to see if we need to insert the implicit intersection operator @@ -785,6 +795,14 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 1, 0), Function::Unicode => args_signature_scalars(arg_count, 1, 0), Function::Geomean => vec![Signature::Vector; arg_count], + Function::Quartile | Function::QuartileExc | Function::QuartileInc => { + if arg_count == 2 { + vec![Signature::Vector, Signature::Scalar] + } else { + vec![Signature::Error; arg_count] + } + } + Function::Rank | Function::RankAvg | Function::RankEq => args_signature_rank(arg_count), } } @@ -990,5 +1008,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Eomonth => scalar_arguments(args), Function::Formulatext => not_implemented(args), Function::Geomean => not_implemented(args), + Function::Quartile | Function::QuartileExc | Function::QuartileInc => not_implemented(args), + Function::Rank | Function::RankAvg | Function::RankEq => scalar_arguments(args), } } diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 21c8f72da..dc47b9b1d 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -145,6 +145,12 @@ pub enum Function { Maxifs, Minifs, Geomean, + Quartile, + QuartileExc, + QuartileInc, + Rank, + RankAvg, + RankEq, // Date and time Date, @@ -253,7 +259,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -357,6 +363,12 @@ impl Function { Function::Maxifs, Function::Minifs, Function::Geomean, + Function::Quartile, + Function::QuartileExc, + Function::QuartileInc, + Function::Rank, + Function::RankAvg, + Function::RankEq, Function::Year, Function::Day, Function::Month, @@ -625,6 +637,12 @@ impl Function { "MAXIFS" | "_XLFN.MAXIFS" => Some(Function::Maxifs), "MINIFS" | "_XLFN.MINIFS" => Some(Function::Minifs), "GEOMEAN" => Some(Function::Geomean), + "QUARTILE" => Some(Function::Quartile), + "QUARTILE.EXC" => Some(Function::QuartileExc), + "QUARTILE.INC" => Some(Function::QuartileInc), + "RANK" => Some(Function::Rank), + "RANK.AVG" => Some(Function::RankAvg), + "RANK.EQ" => Some(Function::RankEq), // Date and Time "YEAR" => Some(Function::Year), "DAY" => Some(Function::Day), @@ -836,6 +854,12 @@ impl fmt::Display for Function { Function::Maxifs => write!(f, "MAXIFS"), Function::Minifs => write!(f, "MINIFS"), Function::Geomean => write!(f, "GEOMEAN"), + Function::Quartile => write!(f, "QUARTILE"), + Function::QuartileExc => write!(f, "QUARTILE.EXC"), + Function::QuartileInc => write!(f, "QUARTILE.INC"), + Function::Rank => write!(f, "RANK"), + Function::RankAvg => write!(f, "RANK.AVG"), + Function::RankEq => write!(f, "RANK.EQ"), Function::Year => write!(f, "YEAR"), Function::Day => write!(f, "DAY"), Function::Month => write!(f, "MONTH"), @@ -1076,6 +1100,12 @@ impl Model { Function::Maxifs => self.fn_maxifs(args, cell), Function::Minifs => self.fn_minifs(args, cell), Function::Geomean => self.fn_geomean(args, cell), + Function::Quartile => self.fn_quartile(args, cell), + Function::QuartileExc => self.fn_quartile_exc(args, cell), + Function::QuartileInc => self.fn_quartile_inc(args, cell), + Function::Rank => self.fn_rank(args, cell), + Function::RankAvg => self.fn_rank_avg(args, cell), + Function::RankEq => self.fn_rank_eq(args, cell), // Date and Time Function::Year => self.fn_year(args, cell), Function::Day => self.fn_day(args, cell), diff --git a/base/src/functions/statistical.rs b/base/src/functions/statistical.rs index cdb936406..57677287c 100644 --- a/base/src/functions/statistical.rs +++ b/base/src/functions/statistical.rs @@ -8,6 +8,7 @@ use crate::{ }; use super::util::build_criteria; +use std::cmp::Ordering; impl Model { pub(crate) fn fn_average(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { @@ -730,4 +731,350 @@ impl Model { } CalcResult::Number(product.powf(1.0 / count)) } + + pub(crate) fn fn_quartile_inc( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let mut values = Vec::new(); + match self.evaluate_node_in_context(&args[0], cell) { + CalcResult::Range { left, right } => { + if left.sheet != right.sheet { + return CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + ); + } + for row in left.row..=right.row { + for column in left.column..=right.column { + match self.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + }) { + CalcResult::Number(v) => values.push(v), + CalcResult::Error { .. } => { + return CalcResult::new_error( + Error::VALUE, + cell, + "Invalid value".to_string(), + ) + } + _ => {} + } + } + } + } + CalcResult::Number(v) => values.push(v), + CalcResult::Boolean(b) => { + if !matches!(args[0], Node::ReferenceKind { .. }) { + values.push(if b { 1.0 } else { 0.0 }); + } + } + CalcResult::String(s) => { + if !matches!(args[0], Node::ReferenceKind { .. }) { + if let Ok(f) = s.parse::() { + values.push(f); + } else { + return CalcResult::new_error( + Error::VALUE, + cell, + "Argument cannot be cast into number".to_string(), + ); + } + } + } + CalcResult::Error { .. } => { + return CalcResult::new_error(Error::VALUE, cell, "Invalid value".to_string()) + } + _ => {} + } + + if values.is_empty() { + return CalcResult::new_error(Error::NUM, cell, "Empty array".to_string()); + } + values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + + let quart = match self.get_number(&args[1], cell) { + Ok(f) => f, + Err(e) => return e, + }; + if quart.fract() != 0.0 { + return CalcResult::new_error(Error::NUM, cell, "Invalid quart".to_string()); + } + let q = quart as i32; + if !(0..=4).contains(&q) { + return CalcResult::new_error(Error::NUM, cell, "Invalid quart".to_string()); + } + + let k = quart / 4.0; + let n = values.len() as f64; + let index = k * (n - 1.0); + let i = index.floor() as usize; + let fraction = index - (i as f64); + if i + 1 >= values.len() { + return CalcResult::Number(values[i]); + } + let result = values[i] + fraction * (values[i + 1] - values[i]); + CalcResult::Number(result) + } + + pub(crate) fn fn_quartile_exc( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let mut values = Vec::new(); + match self.evaluate_node_in_context(&args[0], cell) { + CalcResult::Range { left, right } => { + if left.sheet != right.sheet { + return CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + ); + } + for row in left.row..=right.row { + for column in left.column..=right.column { + match self.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + }) { + CalcResult::Number(v) => values.push(v), + CalcResult::Error { .. } => { + return CalcResult::new_error( + Error::VALUE, + cell, + "Invalid value".to_string(), + ) + } + _ => {} + } + } + } + } + CalcResult::Number(v) => values.push(v), + CalcResult::Boolean(b) => { + if !matches!(args[0], Node::ReferenceKind { .. }) { + values.push(if b { 1.0 } else { 0.0 }); + } + } + CalcResult::String(s) => { + if !matches!(args[0], Node::ReferenceKind { .. }) { + if let Ok(f) = s.parse::() { + values.push(f); + } else { + return CalcResult::new_error( + Error::VALUE, + cell, + "Argument cannot be cast into number".to_string(), + ); + } + } + } + CalcResult::Error { .. } => { + return CalcResult::new_error(Error::VALUE, cell, "Invalid value".to_string()) + } + _ => {} + } + + if values.is_empty() { + return CalcResult::new_error(Error::NUM, cell, "Empty array".to_string()); + } + values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + + let quart = match self.get_number(&args[1], cell) { + Ok(f) => f, + Err(e) => return e, + }; + if quart.fract() != 0.0 { + return CalcResult::new_error(Error::NUM, cell, "Invalid quart".to_string()); + } + let q = quart as i32; + if !(1..=3).contains(&q) { + return CalcResult::new_error(Error::NUM, cell, "Invalid quart".to_string()); + } + + let k = quart / 4.0; + let n = values.len() as f64; + let r = k * (n + 1.0); + if r <= 1.0 || r >= n { + return CalcResult::new_error(Error::NUM, cell, "Invalid quart".to_string()); + } + let i = r.floor() as usize; + let f = r - (i as f64); + let result = values[i - 1] + f * (values[i] - values[i - 1]); + CalcResult::Number(result) + } + + pub(crate) fn fn_quartile(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.fn_quartile_inc(args, cell) + } + + pub(crate) fn fn_rank_eq(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() < 2 || args.len() > 3 { + return CalcResult::new_args_number_error(cell); + } + let number = match self.get_number_no_bools(&args[0], cell) { + Ok(f) => f, + Err(e) => return e, + }; + let range = match self.get_reference(&args[1], cell) { + Ok(r) => r, + Err(e) => return e, + }; + let order = if args.len() == 3 { + match self.get_number(&args[2], cell) { + Ok(f) => f != 0.0, + Err(e) => return e, + } + } else { + false + }; + + let mut values = Vec::new(); + if range.left.sheet != range.right.sheet { + return CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + ); + } + for row in range.left.row..=range.right.row { + for column in range.left.column..=range.right.column { + match self.evaluate_cell(CellReferenceIndex { + sheet: range.left.sheet, + row, + column, + }) { + CalcResult::Number(v) => values.push(v), + CalcResult::Error { .. } => { + return CalcResult::new_error( + Error::VALUE, + cell, + "Invalid value".to_string(), + ) + } + _ => {} + } + } + } + + if values.is_empty() { + return CalcResult::new_error(Error::NUM, cell, "Empty range".to_string()); + } + + let mut greater = 0; + let mut found = false; + for v in &values { + if order { + if *v < number { + greater += 1; + } else if (*v - number).abs() < f64::EPSILON { + found = true; + } + } else if *v > number { + greater += 1; + } else if (*v - number).abs() < f64::EPSILON { + found = true; + } + } + + if !found { + return CalcResult::new_error(Error::NA, cell, "Number not found in range".to_string()); + } + + let rank = (greater + 1) as f64; + CalcResult::Number(rank) + } + + pub(crate) fn fn_rank_avg(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() < 2 || args.len() > 3 { + return CalcResult::new_args_number_error(cell); + } + let number = match self.get_number_no_bools(&args[0], cell) { + Ok(f) => f, + Err(e) => return e, + }; + let range = match self.get_reference(&args[1], cell) { + Ok(r) => r, + Err(e) => return e, + }; + let order = if args.len() == 3 { + match self.get_number(&args[2], cell) { + Ok(f) => f != 0.0, + Err(e) => return e, + } + } else { + false + }; + + if range.left.sheet != range.right.sheet { + return CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + ); + } + let mut values = Vec::new(); + for row in range.left.row..=range.right.row { + for column in range.left.column..=range.right.column { + match self.evaluate_cell(CellReferenceIndex { + sheet: range.left.sheet, + row, + column, + }) { + CalcResult::Number(v) => values.push(v), + CalcResult::Error { .. } => { + return CalcResult::new_error( + Error::VALUE, + cell, + "Invalid value".to_string(), + ) + } + _ => {} + } + } + } + + if values.is_empty() { + return CalcResult::new_error(Error::NUM, cell, "Empty range".to_string()); + } + + let mut greater = 0; + let mut equal = 0; + for v in &values { + if order { + if *v < number { + greater += 1; + } else if (*v - number).abs() < f64::EPSILON { + equal += 1; + } + } else if *v > number { + greater += 1; + } else if (*v - number).abs() < f64::EPSILON { + equal += 1; + } + } + + if equal == 0 { + return CalcResult::new_error(Error::NA, cell, "Number not found in range".to_string()); + } + + let rank = greater as f64 + ((equal as f64 + 1.0) / 2.0); + CalcResult::Number(rank) + } + + pub(crate) fn fn_rank(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.fn_rank_eq(args, cell) + } } diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index a0a0d69d6..77495e5c0 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -55,6 +55,8 @@ mod test_arrays; mod test_escape_quotes; mod test_extend; mod test_fn_fv; +mod test_fn_quartile; +mod test_fn_rank; mod test_fn_type; mod test_frozen_rows_and_columns; mod test_geomean; diff --git a/base/src/test/test_fn_quartile.rs b/base/src/test/test_fn_quartile.rs new file mode 100644 index 000000000..b44b38d40 --- /dev/null +++ b/base/src/test/test_fn_quartile.rs @@ -0,0 +1,180 @@ +#![allow(clippy::unwrap_used)] +use crate::test::util::new_empty_model; + +#[test] +fn test_quartile_basic_functionality() { + let mut model = new_empty_model(); + for i in 1..=8 { + model._set(&format!("B{i}"), &i.to_string()); + } + + // Test basic quartile calculations + model._set("A1", "=QUARTILE(B1:B8,1)"); // Legacy function + model._set("A2", "=QUARTILE.INC(B1:B8,3)"); // Inclusive method + model._set("A3", "=QUARTILE.EXC(B1:B8,1)"); // Exclusive method + model.evaluate(); + + assert_eq!(model._get_text("A1"), "2.75"); + assert_eq!(model._get_text("A2"), "6.25"); + assert_eq!(model._get_text("A3"), "2.25"); +} + +#[test] +fn test_quartile_all_parameters() { + let mut model = new_empty_model(); + for i in 1..=8 { + model._set(&format!("B{i}"), &i.to_string()); + } + + // Test all valid quartile parameters + model._set("A1", "=QUARTILE.INC(B1:B8,0)"); // Min + model._set("A2", "=QUARTILE.INC(B1:B8,2)"); // Median + model._set("A3", "=QUARTILE.INC(B1:B8,4)"); // Max + model._set("A4", "=QUARTILE.EXC(B1:B8,2)"); // EXC median + model.evaluate(); + + assert_eq!(model._get_text("A1"), "1"); // Min + assert_eq!(model._get_text("A2"), "4.5"); // Median + assert_eq!(model._get_text("A3"), "8"); // Max + assert_eq!(model._get_text("A4"), "4.5"); // EXC median +} + +#[test] +fn test_quartile_data_filtering() { + let mut model = new_empty_model(); + + // Mixed data types - only numbers should be considered + model._set("B1", "1"); + model._set("B2", "text"); // Ignored + model._set("B3", "3"); + model._set("B4", "TRUE"); // Ignored + model._set("B5", "5"); + model._set("B6", ""); // Ignored + + model._set("A1", "=QUARTILE.INC(B1:B6,2)"); // Median of [1,3,5] + model.evaluate(); + + assert_eq!(model._get_text("A1"), "3"); +} + +#[test] +fn test_quartile_single_element() { + let mut model = new_empty_model(); + model._set("B1", "5"); + + model._set("A1", "=QUARTILE.INC(B1,0)"); // Min + model._set("A2", "=QUARTILE.INC(B1,2)"); // Median + model._set("A3", "=QUARTILE.INC(B1,4)"); // Max + model.evaluate(); + + // All quartiles should return the single value + assert_eq!(model._get_text("A1"), "5"); + assert_eq!(model._get_text("A2"), "5"); + assert_eq!(model._get_text("A3"), "5"); +} + +#[test] +fn test_quartile_duplicate_values() { + let mut model = new_empty_model(); + // Data with duplicates: 1, 1, 3, 3 + model._set("C1", "1"); + model._set("C2", "1"); + model._set("C3", "3"); + model._set("C4", "3"); + + model._set("A1", "=QUARTILE.INC(C1:C4,1)"); // Q1 + model._set("A2", "=QUARTILE.INC(C1:C4,2)"); // Q2 + model._set("A3", "=QUARTILE.INC(C1:C4,3)"); // Q3 + model.evaluate(); + + assert_eq!(model._get_text("A1"), "1"); // Q1 with duplicates + assert_eq!(model._get_text("A2"), "2"); // Median with duplicates + assert_eq!(model._get_text("A3"), "3"); // Q3 with duplicates +} + +#[test] +fn test_quartile_exc_boundary_conditions() { + let mut model = new_empty_model(); + + // Small dataset for EXC - should work for median but fail for Q1/Q3 + model._set("D1", "1"); + model._set("D2", "2"); + + model._set("A1", "=QUARTILE.EXC(D1:D2,1)"); // Should fail + model._set("A2", "=QUARTILE.EXC(D1:D2,2)"); // Should work (median) + model._set("A3", "=QUARTILE.EXC(D1:D2,3)"); // Should fail + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#NUM!"); // EXC Q1 fails + assert_eq!(model._get_text("A2"), "1.5"); // EXC median works + assert_eq!(model._get_text("A3"), "#NUM!"); // EXC Q3 fails +} + +#[test] +fn test_quartile_invalid_arguments() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + + // Invalid argument count + model._set("A1", "=QUARTILE.INC(B1:B2)"); // Too few + model._set("A2", "=QUARTILE.INC(B1:B2,1,2)"); // Too many + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#ERROR!"); + assert_eq!(model._get_text("A2"), "#ERROR!"); +} + +#[test] +fn test_quartile_invalid_quartile_values() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + + // Invalid quartile values for QUARTILE.INC + model._set("A1", "=QUARTILE.INC(B1:B2,-1)"); // Below 0 + model._set("A2", "=QUARTILE.INC(B1:B2,5)"); // Above 4 + + // Invalid quartile values for QUARTILE.EXC + model._set("A3", "=QUARTILE.EXC(B1:B2,0)"); // Below 1 + model._set("A4", "=QUARTILE.EXC(B1:B2,4)"); // Above 3 + + // Non-numeric quartile + model._set("A5", "=QUARTILE.INC(B1:B2,\"text\")"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#NUM!"); + assert_eq!(model._get_text("A2"), "#NUM!"); + assert_eq!(model._get_text("A3"), "#NUM!"); + assert_eq!(model._get_text("A4"), "#NUM!"); + assert_eq!(model._get_text("A5"), "#VALUE!"); +} + +#[test] +fn test_quartile_invalid_data_ranges() { + let mut model = new_empty_model(); + + // Empty range + model._set("A1", "=QUARTILE.INC(B1:B3,1)"); // Empty range + + // Text-only range + model._set("C1", "text"); + model._set("A2", "=QUARTILE.INC(C1,1)"); // Text-only + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#NUM!"); + assert_eq!(model._get_text("A2"), "#NUM!"); +} + +#[test] +fn test_quartile_error_propagation() { + let mut model = new_empty_model(); + + // Error propagation from cell references + model._set("E1", "=1/0"); + model._set("E2", "2"); + model._set("A1", "=QUARTILE.INC(E1:E2,1)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#VALUE!"); +} diff --git a/base/src/test/test_fn_rank.rs b/base/src/test/test_fn_rank.rs new file mode 100644 index 000000000..e573655d9 --- /dev/null +++ b/base/src/test/test_fn_rank.rs @@ -0,0 +1,208 @@ +#![allow(clippy::unwrap_used)] +use crate::test::util::new_empty_model; + +#[test] +fn test_rank_basic_functionality() { + let mut model = new_empty_model(); + model._set("B1", "3"); + model._set("B2", "3"); + model._set("B3", "2"); + model._set("B4", "1"); + + // Test basic rank calculations + model._set("A1", "=RANK(2,B1:B4)"); // Legacy function + model._set("A2", "=RANK.AVG(3,B1:B4)"); // Average rank for duplicates + model._set("A3", "=RANK.EQ(3,B1:B4)"); // Equal rank for duplicates + model._set("A4", "=RANK(3,B1:B4,1)"); // Ascending order + model.evaluate(); + + assert_eq!(model._get_text("A1"), "3"); // Descending rank of 2 + assert_eq!(model._get_text("A2"), "1.5"); // Average of ranks 1,2 for value 3 + assert_eq!(model._get_text("A3"), "1"); // Highest rank for value 3 + assert_eq!(model._get_text("A4"), "3"); // Ascending rank of 3 +} + +#[test] +fn test_rank_sort_order_and_duplicates() { + let mut model = new_empty_model(); + // Data: 1, 3, 5, 7, 9 (no duplicates) + for (i, val) in [1, 3, 5, 7, 9].iter().enumerate() { + model._set(&format!("B{}", i + 1), &val.to_string()); + } + + // Test sort orders + model._set("A1", "=RANK(5,B1:B5)"); // Descending (default) + model._set("A2", "=RANK(5,B1:B5,1)"); // Ascending + + // Data with many duplicates: 1, 2, 2, 3, 3, 3, 4 + model._set("C1", "1"); + model._set("C2", "2"); + model._set("C3", "2"); + model._set("C4", "3"); + model._set("C5", "3"); + model._set("C6", "3"); + model._set("C7", "4"); + + // Test duplicate handling + model._set("A3", "=RANK.EQ(3,C1:C7)"); // Highest rank for duplicates + model._set("A4", "=RANK.AVG(3,C1:C7)"); // Average rank for duplicates + model._set("A5", "=RANK.AVG(2,C1:C7)"); // Average of ranks 5,6 + + model.evaluate(); + + assert_eq!(model._get_text("A1"), "3"); // 5 is 3rd largest + assert_eq!(model._get_text("A2"), "3"); // 5 is 3rd smallest + assert_eq!(model._get_text("A3"), "2"); // Highest rank for value 3 + assert_eq!(model._get_text("A4"), "3"); // Average rank for value 3: (2+3+4)/3 + assert_eq!(model._get_text("A5"), "5.5"); // Average rank for value 2: (5+6)/2 +} + +#[test] +fn test_rank_not_found() { + let mut model = new_empty_model(); + model._set("B1", "3"); + model._set("B2", "2"); + model._set("B3", "1"); + + // Test cases where target number is not in range + model._set("A1", "=RANK(5,B1:B3)"); // Not in range + model._set("A2", "=RANK.AVG(0,B1:B3)"); // Not in range + model._set("A3", "=RANK.EQ(2.5,B1:B3)"); // Close but not exact + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#N/A"); + assert_eq!(model._get_text("A2"), "#N/A"); + assert_eq!(model._get_text("A3"), "#N/A"); +} + +#[test] +fn test_rank_single_element() { + let mut model = new_empty_model(); + model._set("B1", "5"); + + model._set("A1", "=RANK(5,B1)"); + model._set("A2", "=RANK.EQ(5,B1)"); + model._set("A3", "=RANK.AVG(5,B1)"); + model.evaluate(); + + // All should return rank 1 for single element + assert_eq!(model._get_text("A1"), "1"); + assert_eq!(model._get_text("A2"), "1"); + assert_eq!(model._get_text("A3"), "1"); +} + +#[test] +fn test_rank_identical_values() { + let mut model = new_empty_model(); + // All values are the same + for i in 1..=4 { + model._set(&format!("C{i}"), "7"); + } + + model._set("A1", "=RANK.EQ(7,C1:C4)"); // Should be rank 1 + model._set("A2", "=RANK.AVG(7,C1:C4)"); // Should be average: 2.5 + model.evaluate(); + + assert_eq!(model._get_text("A1"), "1"); // All identical - highest rank + assert_eq!(model._get_text("A2"), "2.5"); // All identical - average rank +} + +#[test] +fn test_rank_mixed_data_types() { + let mut model = new_empty_model(); + // Mixed data types (only numbers counted) + model._set("D1", "1"); + model._set("D2", "text"); // Ignored + model._set("D3", "3"); + model._set("D4", "TRUE"); // Ignored + model._set("D5", "5"); + + model._set("A1", "=RANK(3,D1:D5)"); // Rank in [1,3,5] + model._set("A2", "=RANK(1,D1:D5)"); // Rank of smallest + model.evaluate(); + + assert_eq!(model._get_text("A1"), "2"); // 3 is 2nd largest in [1,3,5] + assert_eq!(model._get_text("A2"), "3"); // 1 is smallest +} + +#[test] +fn test_rank_extreme_values() { + let mut model = new_empty_model(); + // Extreme values + model._set("E1", "1e10"); + model._set("E2", "0"); + model._set("E3", "-1e10"); + + model._set("A1", "=RANK(0,E1:E3)"); // Rank of 0 + model._set("A2", "=RANK(1e10,E1:E3)"); // Rank of largest + model._set("A3", "=RANK(-1e10,E1:E3)"); // Rank of smallest + model.evaluate(); + + assert_eq!(model._get_text("A1"), "2"); // 0 is 2nd largest + assert_eq!(model._get_text("A2"), "1"); // 1e10 is largest + assert_eq!(model._get_text("A3"), "3"); // -1e10 is smallest +} + +#[test] +fn test_rank_invalid_arguments() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + + // Invalid argument count + model._set("A1", "=RANK(1)"); // Too few + model._set("A2", "=RANK(1,B1:B2,0,1)"); // Too many + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#ERROR!"); + assert_eq!(model._get_text("A2"), "#ERROR!"); +} + +#[test] +fn test_rank_invalid_parameters() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + + // Non-numeric search value + model._set("A1", "=RANK(\"text\",B1:B2)"); + model._set("A2", "=RANK.EQ(TRUE,B1:B2)"); // Boolean + + // Invalid order parameter + model._set("A3", "=RANK(2,B1:B2,\"text\")"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#VALUE!"); + assert_eq!(model._get_text("A2"), "#VALUE!"); + assert_eq!(model._get_text("A3"), "#VALUE!"); +} + +#[test] +fn test_rank_invalid_data_ranges() { + let mut model = new_empty_model(); + + // Empty range + model._set("A1", "=RANK(1,C1:C3)"); // Empty cells + + // Text-only range + model._set("D1", "text1"); + model._set("D2", "text2"); + model._set("A2", "=RANK(1,D1:D2)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#NUM!"); + assert_eq!(model._get_text("A2"), "#NUM!"); +} + +#[test] +fn test_rank_error_propagation() { + let mut model = new_empty_model(); + + // Error propagation from cell references + model._set("E1", "=1/0"); + model._set("E2", "2"); + model._set("A1", "=RANK(2,E1:E2)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#VALUE!"); +} diff --git a/docs/src/functions/statistical.md b/docs/src/functions/statistical.md index 6842212c3..74e0a98a4 100644 --- a/docs/src/functions/statistical.md +++ b/docs/src/functions/statistical.md @@ -90,10 +90,12 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | PHI | | – | | POISSON.DIST | | – | | PROB | | – | -| QUARTILE.EXC | | – | -| QUARTILE.INC | | – | -| RANK.AVG | | – | -| RANK.EQ | | – | +| QUARTILE | | – | +| QUARTILE.EXC | | – | +| QUARTILE.INC | | – | +| RANK | | – | +| RANK.AVG | | – | +| RANK.EQ | | – | | RSQ | | – | | SKEW | | – | | SKEW.P | | – | diff --git a/docs/src/functions/statistical/quartile.exc.md b/docs/src/functions/statistical/quartile.exc.md index dde3e34a0..6674ab523 100644 --- a/docs/src/functions/statistical/quartile.exc.md +++ b/docs/src/functions/statistical/quartile.exc.md @@ -7,6 +7,5 @@ lang: en-US # QUARTILE.EXC ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/statistical/quartile.inc.md b/docs/src/functions/statistical/quartile.inc.md index 8d2a1ff75..a41348bcd 100644 --- a/docs/src/functions/statistical/quartile.inc.md +++ b/docs/src/functions/statistical/quartile.inc.md @@ -7,6 +7,5 @@ lang: en-US # QUARTILE.INC ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/statistical/quartile.md b/docs/src/functions/statistical/quartile.md new file mode 100644 index 000000000..5ff225283 --- /dev/null +++ b/docs/src/functions/statistical/quartile.md @@ -0,0 +1,11 @@ +--- +layout: doc +outline: deep +lang: en-US +--- + +# QUARTILE + +::: warning +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). +::: \ No newline at end of file diff --git a/docs/src/functions/statistical/rank.avg.md b/docs/src/functions/statistical/rank.avg.md index 16f656ec3..e8778df1d 100644 --- a/docs/src/functions/statistical/rank.avg.md +++ b/docs/src/functions/statistical/rank.avg.md @@ -7,6 +7,5 @@ lang: en-US # RANK.AVG ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/statistical/rank.eq.md b/docs/src/functions/statistical/rank.eq.md index d8efbe1a9..6f304e97d 100644 --- a/docs/src/functions/statistical/rank.eq.md +++ b/docs/src/functions/statistical/rank.eq.md @@ -7,6 +7,5 @@ lang: en-US # RANK.EQ ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/statistical/rank.md b/docs/src/functions/statistical/rank.md new file mode 100644 index 000000000..05e593dcb --- /dev/null +++ b/docs/src/functions/statistical/rank.md @@ -0,0 +1,11 @@ +--- +layout: doc +outline: deep +lang: en-US +--- + +# RANK + +::: warning +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). +::: \ No newline at end of file