diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 04edf5614..c22206026 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -654,6 +654,11 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 2, 0), Function::Rounddown => args_signature_scalars(arg_count, 2, 0), Function::Roundup => args_signature_scalars(arg_count, 2, 0), + Function::Fact => args_signature_scalars(arg_count, 1, 0), + Function::Combin => args_signature_scalars(arg_count, 2, 0), + Function::Combina => args_signature_scalars(arg_count, 2, 0), + Function::Permut => args_signature_scalars(arg_count, 2, 0), + Function::Permutationa => args_signature_scalars(arg_count, 2, 0), Function::Sin => args_signature_scalars(arg_count, 1, 0), Function::Sinh => args_signature_scalars(arg_count, 1, 0), Function::Sqrt => args_signature_scalars(arg_count, 1, 0), @@ -844,7 +849,6 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 1, 0), Function::Sech => args_signature_scalars(arg_count, 1, 0), Function::Exp => args_signature_scalars(arg_count, 1, 0), - Function::Fact => args_signature_scalars(arg_count, 1, 0), Function::Factdouble => args_signature_scalars(arg_count, 1, 0), Function::Sign => args_signature_scalars(arg_count, 1, 0), Function::Radians => args_signature_scalars(arg_count, 1, 0), @@ -869,8 +873,6 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 2, 0), Function::Roman => args_signature_scalars(arg_count, 1, 1), Function::Arabic => args_signature_scalars(arg_count, 1, 0), - Function::Combin => args_signature_scalars(arg_count, 2, 0), - Function::Combina => args_signature_scalars(arg_count, 2, 0), Function::Sumsq => vec![Signature::Vector; arg_count], Function::N => args_signature_scalars(arg_count, 1, 0), Function::Sheets => args_signature_scalars(arg_count, 0, 1), @@ -1045,6 +1047,11 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Round => scalar_arguments(args), Function::Rounddown => scalar_arguments(args), Function::Roundup => scalar_arguments(args), + Function::Fact => scalar_arguments(args), + Function::Combin => scalar_arguments(args), + Function::Combina => scalar_arguments(args), + Function::Permut => scalar_arguments(args), + Function::Permutationa => scalar_arguments(args), Function::Ln => scalar_arguments(args), Function::Log => scalar_arguments(args), Function::Log10 => scalar_arguments(args), @@ -1238,7 +1245,6 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Sec => scalar_arguments(args), Function::Sech => scalar_arguments(args), Function::Exp => scalar_arguments(args), - Function::Fact => scalar_arguments(args), Function::Factdouble => scalar_arguments(args), Function::Sign => scalar_arguments(args), Function::Radians => scalar_arguments(args), @@ -1263,8 +1269,6 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Decimal => scalar_arguments(args), Function::Roman => scalar_arguments(args), Function::Arabic => scalar_arguments(args), - Function::Combin => scalar_arguments(args), - Function::Combina => scalar_arguments(args), Function::Sumsq => StaticResult::Scalar, Function::N => scalar_arguments(args), Function::Sheets => scalar_arguments(args), diff --git a/base/src/formatter/format.rs b/base/src/formatter/format.rs index 41f641ed5..dec8d0bc5 100644 --- a/base/src/formatter/format.rs +++ b/base/src/formatter/format.rs @@ -928,6 +928,11 @@ fn parse_number( } else { 1.0 }; + + if bytes[position] == group_separator { + return Err("Cannot parse number".to_string()); + } + // numbers before the decimal point while position < len { let x = bytes[position]; diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index 846e4e038..c8e745d31 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -1663,11 +1663,93 @@ impl<'a> Model<'a> { message: "Arguments must be non-negative integers".to_string(), }; } + // Avoid huge loops and overflow: Excel returns #NUM! for very large n or k + const MAX_N: f64 = 1e15; + const MAX_K: f64 = 1e6; + if n > MAX_N || k > MAX_K { + return CalcResult::new_error(Error::NUM, cell, "overflow".to_string()); + } let k = k as usize; let mut result = 1.0; for i in 0..k { let t = i as f64; result *= (n + t) / (t + 1.0); + if result.is_infinite() { + return CalcResult::new_error(Error::NUM, cell, "overflow".to_string()); + } + } + CalcResult::Number(result) + } + + pub(crate) fn fn_permut(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let n = match self.get_number(&args[0], cell) { + Ok(f) => f.floor(), + Err(s) => return s, + }; + let k = match self.get_number(&args[1], cell) { + Ok(f) => f.floor(), + Err(s) => return s, + }; + if n < 0.0 || k < 0.0 { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Arguments must be non-negative integers".to_string(), + }; + } + if k > n { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "k cannot be greater than n".to_string(), + }; + } + let n = n as usize; + let k = k as usize; + let mut result = 1.0; + for i in 0..k { + result *= (n - i) as f64; + } + if result.is_infinite() { + return CalcResult::new_error(Error::NUM, cell, "overflow".to_string()); + } + CalcResult::Number(result) + } + + pub(crate) fn fn_permutationa( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let n = match self.get_number(&args[0], cell) { + Ok(f) => f.floor(), + Err(s) => return s, + }; + let k = match self.get_number(&args[1], cell) { + Ok(f) => f.floor(), + Err(s) => return s, + }; + if n < 0.0 || k < 0.0 { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Arguments must be non-negative integers".to_string(), + }; + } + let n = n as usize; + let k = k as usize; + let mut result = 1.0; + for _ in 0..k { + result *= n as f64; + } + if result.is_infinite() { + return CalcResult::new_error(Error::NUM, cell, "overflow".to_string()); } CalcResult::Number(result) } diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 3a8edf115..a9e00f676 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -118,6 +118,8 @@ pub enum Function { Arabic, Combin, Combina, + Permut, + Permutationa, Sumsq, // Information @@ -428,6 +430,11 @@ macro_rules! impl_function_lookup { impl Functions { pub fn lookup(&self, name: &str) -> Option { let key = name.to_uppercase(); + match key.as_str() { + "PERMUT" => return Some(Function::Permut), + "PERMUTATIONA" => return Some(Function::Permutationa), + _ => {} + } $( if self.$field == key { return Some(Function::$variant); @@ -906,6 +913,8 @@ impl Function { Function::Arabic => functions.arabic.clone(), Function::Combin => functions.combin.clone(), Function::Combina => functions.combina.clone(), + Function::Permut => "PERMUT".to_string(), + Function::Permutationa => "PERMUTATIONA".to_string(), Function::Sumsq => functions.sumsq.clone(), Function::ErrorType => functions.errortype.clone(), Function::Formulatext => functions.formulatext.clone(), @@ -1168,7 +1177,7 @@ impl Function { Function::Steyx => functions.steyx.clone(), } } - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -1425,6 +1434,8 @@ impl Function { Function::Arabic, Function::Combin, Function::Combina, + Function::Permut, + Function::Permutationa, Function::Sumsq, Function::N, Function::Cell, @@ -1701,6 +1712,11 @@ impl<'a> Model<'a> { Function::Round => self.fn_round(args, cell), Function::Rounddown => self.fn_rounddown(args, cell), Function::Roundup => self.fn_roundup(args, cell), + Function::Fact => self.fn_fact(args, cell), + Function::Combin => self.fn_combin(args, cell), + Function::Combina => self.fn_combina(args, cell), + Function::Permut => self.fn_permut(args, cell), + Function::Permutationa => self.fn_permutationa(args, cell), Function::Sum => self.fn_sum(args, cell), Function::Sumif => self.fn_sumif(args, cell), Function::Sumifs => self.fn_sumifs(args, cell), @@ -1886,7 +1902,6 @@ impl<'a> Model<'a> { Function::Sec => self.fn_sec(args, cell), Function::Sech => self.fn_sech(args, cell), Function::Exp => self.fn_exp(args, cell), - Function::Fact => self.fn_fact(args, cell), Function::Factdouble => self.fn_factdouble(args, cell), Function::Sign => self.fn_sign(args, cell), Function::Radians => self.fn_radians(args, cell), @@ -1911,8 +1926,6 @@ impl<'a> Model<'a> { Function::Decimal => self.fn_decimal(args, cell), Function::Roman => self.fn_roman(args, cell), Function::Arabic => self.fn_arabic(args, cell), - Function::Combin => self.fn_combin(args, cell), - Function::Combina => self.fn_combina(args, cell), Function::Sumsq => self.fn_sumsq(args, cell), Function::N => self.fn_n(args, cell), Function::Cell => self.fn_cell(args, cell), diff --git a/base/src/functions/statistical/chisq.rs b/base/src/functions/statistical/chisq.rs index 2e0e5c354..22674a076 100644 --- a/base/src/functions/statistical/chisq.rs +++ b/base/src/functions/statistical/chisq.rs @@ -5,6 +5,7 @@ use crate::expressions::types::CellReferenceIndex; use crate::{ calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model, }; +const MAX_DEGREES_OF_FREEDOM: f64 = 10_000_000_000.0; impl<'a> Model<'a> { // CHISQ.DIST(x, deg_freedom, cumulative) @@ -13,12 +14,12 @@ impl<'a> Model<'a> { return CalcResult::new_args_number_error(cell); } - let x = match self.get_number_no_bools(&args[0], cell) { + let x = match self.get_number(&args[0], cell) { Ok(f) => f, Err(e) => return e, }; - let df = match self.get_number_no_bools(&args[1], cell) { + let df = match self.get_number(&args[1], cell) { Ok(f) => f.trunc(), Err(e) => return e, }; @@ -35,11 +36,12 @@ impl<'a> Model<'a> { "x must be >= 0 in CHISQ.DIST".to_string(), ); } - if df < 1.0 { + // if degrees of freedom < 1 or > 10^10 → #NUM! + if !(1.0..=MAX_DEGREES_OF_FREEDOM).contains(&df) { return CalcResult::new_error( Error::NUM, cell, - "degrees of freedom must be >= 1 in CHISQ.DIST".to_string(), + "degrees of freedom must be in [1, 10^10] in CHISQ.DIST".to_string(), ); } @@ -77,12 +79,12 @@ impl<'a> Model<'a> { return CalcResult::new_args_number_error(cell); } - let x = match self.get_number_no_bools(&args[0], cell) { + let x = match self.get_number(&args[0], cell) { Ok(f) => f, Err(e) => return e, }; - let df_raw = match self.get_number_no_bools(&args[1], cell) { + let df_raw = match self.get_number(&args[1], cell) { Ok(f) => f, Err(e) => return e, }; @@ -96,11 +98,13 @@ impl<'a> Model<'a> { "x must be >= 0 in CHISQ.DIST.RT".to_string(), ); } - if df < 1.0 { + + // if degrees of freedom < 1 or > 10^10 → #NUM! + if !(1.0..=MAX_DEGREES_OF_FREEDOM).contains(&df) { return CalcResult::new_error( Error::NUM, cell, - "degrees of freedom must be >= 1 in CHISQ.DIST.RT".to_string(), + "degrees of freedom must be in [1, 10^10] in CHISQ.DIST.RT".to_string(), ); } @@ -136,12 +140,12 @@ impl<'a> Model<'a> { return CalcResult::new_args_number_error(cell); } - let p = match self.get_number_no_bools(&args[0], cell) { + let p = match self.get_number(&args[0], cell) { Ok(f) => f, Err(e) => return e, }; - let df = match self.get_number_no_bools(&args[1], cell) { + let df = match self.get_number(&args[1], cell) { Ok(f) => f.trunc(), Err(e) => return e, }; @@ -154,11 +158,13 @@ impl<'a> Model<'a> { "probability must be in [0,1] in CHISQ.INV".to_string(), ); } - if df < 1.0 { + + // if degrees of freedom < 1 or > 10^10 → #NUM! + if !(1.0..=MAX_DEGREES_OF_FREEDOM).contains(&df) { return CalcResult::new_error( Error::NUM, cell, - "degrees of freedom must be >= 1 in CHISQ.INV".to_string(), + "degrees of freedom must be in [1, 10^10] in CHISQ.INV".to_string(), ); } @@ -196,12 +202,12 @@ impl<'a> Model<'a> { return CalcResult::new_args_number_error(cell); } - let p = match self.get_number_no_bools(&args[0], cell) { + let p = match self.get_number(&args[0], cell) { Ok(f) => f, Err(e) => return e, }; - let df_raw = match self.get_number_no_bools(&args[1], cell) { + let df_raw = match self.get_number(&args[1], cell) { Ok(f) => f, Err(e) => return e, }; @@ -216,11 +222,12 @@ impl<'a> Model<'a> { "probability must be in [0,1] in CHISQ.INV.RT".to_string(), ); } - if df < 1.0 { + // if degrees of freedom < 1 or > 10^10 → #NUM! + if !(1.0..=MAX_DEGREES_OF_FREEDOM).contains(&df) { return CalcResult::new_error( Error::NUM, cell, - "degrees of freedom must be >= 1 in CHISQ.INV.RT".to_string(), + "degrees of freedom must be in [1, 10^10] in CHISQ.INV.RT".to_string(), ); } diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 3c2b0f052..d4cc91de5 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -75,8 +75,13 @@ mod test_even_odd; mod test_exp_sign; mod test_extend; mod test_floor; +mod test_fn_combin; +mod test_fn_combina; mod test_fn_datevalue_timevalue; +mod test_fn_fact; mod test_fn_fv; +mod test_fn_permut; +mod test_fn_permutationa; mod test_fn_round; mod test_fn_type; mod test_frozen_rows_and_columns; diff --git a/base/src/test/statistical/test_fn_chisq.rs b/base/src/test/statistical/test_fn_chisq.rs index fce8ec63d..ea03cdb1f 100644 --- a/base/src/test/statistical/test_fn_chisq.rs +++ b/base/src/test/statistical/test_fn_chisq.rs @@ -22,8 +22,10 @@ fn test_fn_chisq_dist_smoke() { // Domain errors // x < 0 -> #NUM! model._set("A6", "=CHISQ.DIST(-1, 4, TRUE)"); - // deg_freedom < 1 -> #NUM! + // deg_freedom < 1 or > 10^10 -> #NUM! model._set("A7", "=CHISQ.DIST(0.5, 0, TRUE)"); + model._set("A8", "=CHISQ.DIST(10, 10000000000, TRUE)"); + model._set("A9", "=CHISQ.DIST(10, 10000000001, TRUE)"); model.evaluate(); @@ -37,6 +39,8 @@ fn test_fn_chisq_dist_smoke() { assert_eq!(model._get_text("A5"), *"#ERROR!"); assert_eq!(model._get_text("A6"), *"#NUM!"); assert_eq!(model._get_text("A7"), *"#NUM!"); + assert_eq!(model._get_text("A8"), *"0"); + assert_eq!(model._get_text("A9"), *"#NUM!"); } #[test] @@ -54,8 +58,10 @@ fn test_fn_chisq_dist_rt_smoke() { // Domain errors // x < 0 -> #NUM! model._set("A5", "=CHISQ.DIST.RT(-1, 4)"); - // deg_freedom < 1 -> #NUM! + // deg_freedom < 1 or > 10^10 -> #NUM! model._set("A6", "=CHISQ.DIST.RT(0.5, 0)"); + model._set("A7", "=CHISQ.DIST.RT(0, 10000000000)"); + model._set("A8", "=CHISQ.DIST.RT(0, 10000000001)"); model.evaluate(); @@ -69,6 +75,8 @@ fn test_fn_chisq_dist_rt_smoke() { assert_eq!(model._get_text("A4"), *"#ERROR!"); assert_eq!(model._get_text("A5"), *"#NUM!"); assert_eq!(model._get_text("A6"), *"#NUM!"); + assert_eq!(model._get_text("A7"), *"1"); + assert_eq!(model._get_text("A8"), *"#NUM!"); } #[test] @@ -87,8 +95,10 @@ fn test_fn_chisq_inv_smoke() { // probability < 0 or > 1 -> #NUM! model._set("A5", "=CHISQ.INV(-0.1, 4)"); model._set("A6", "=CHISQ.INV(1.1, 4)"); - // deg_freedom < 1 -> #NUM! + // deg_freedom < 1 or > 10^10 -> #NUM! model._set("A7", "=CHISQ.INV(0.5, 0)"); + model._set("A8", "=CHISQ.INV(0, 10000000000)"); + model._set("A9", "=CHISQ.INV(0, 10000000001)"); model.evaluate(); @@ -103,6 +113,8 @@ fn test_fn_chisq_inv_smoke() { assert_eq!(model._get_text("A5"), *"#NUM!"); assert_eq!(model._get_text("A6"), *"#NUM!"); assert_eq!(model._get_text("A7"), *"#NUM!"); + assert_eq!(model._get_text("A8"), *"0"); + assert_eq!(model._get_text("A9"), *"#NUM!"); } #[test] @@ -121,8 +133,10 @@ fn test_fn_chisq_inv_rt_smoke() { // probability < 0 or > 1 -> #NUM! model._set("A5", "=CHISQ.INV.RT(-0.1, 4)"); model._set("A6", "=CHISQ.INV.RT(1.1, 4)"); - // deg_freedom < 1 -> #NUM! + // deg_freedom < 1 or > 10^10 -> #NUM! model._set("A7", "=CHISQ.INV.RT(0.5, 0)"); + model._set("A8", "=CHISQ.INV.RT(1, 10000000000)"); + model._set("A9", "=CHISQ.INV.RT(1, 10000000001)"); model.evaluate(); @@ -137,4 +151,23 @@ fn test_fn_chisq_inv_rt_smoke() { assert_eq!(model._get_text("A5"), *"#NUM!"); assert_eq!(model._get_text("A6"), *"#NUM!"); assert_eq!(model._get_text("A7"), *"#NUM!"); + assert_eq!(model._get_text("A8"), *"0"); + assert_eq!(model._get_text("A9"), *"#NUM!"); +} + +#[test] +fn test_booleans() { + let mut model = new_empty_model(); + + model._set("A1", "=CHISQ.DIST(7, TRUE, TRUE)"); + model._set("A2", "=CHISQ.INV(A1, TRUE)"); + model._set("A3", "=CHISQ.DIST.RT(7, TRUE)"); + model._set("A4", "=CHISQ.INV.RT(A3, TRUE)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), "0.991849028"); + assert_eq!(model._get_text("A2"), "7"); + assert_eq!(model._get_text("A3"), "0.008150972"); + assert_eq!(model._get_text("A4"), "7"); } diff --git a/base/src/test/test_database.rs b/base/src/test/test_database.rs index d20bf3f69..4e8bc93f6 100644 --- a/base/src/test/test_database.rs +++ b/base/src/test/test_database.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_used)] use crate::test::util::new_empty_model; +use crate::Model; #[test] fn arguments() { @@ -67,3 +68,305 @@ fn arguments() { assert_eq!(model._get_text("A23"), *"#ERROR!"); assert_eq!(model._get_text("A24"), *"#ERROR!"); } + +#[test] +fn locale_iso_format() { + // ISO format with YYYY-MM-DD format. Works with any locale. + let mut model = Model::new_empty("model", "en", "UTC", "en").unwrap(); + + // Create database + model._set("A1", "ID"); + model._set("B1", "Date"); + model._set("C1", "Amount"); + model._set("A2", "1"); + model._set("B2", "2026-01-15"); + model._set("C2", "1200"); + model._set("A3", "2"); + model._set("B3", "2026-03-15"); + model._set("C3", "900"); + model._set("A4", "3"); + model._set("B4", "2026-06-15"); + model._set("C4", "2100"); + + // Define criteria + model._set("A6", "Date"); + model._set("A7", ">=2026-03-01"); + model._set("B6", "Date"); + model._set("B7", "2026-01-15"); // DGET needs an exact match + + // Test functions + model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); + model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); + model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); + model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); + model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); + model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A9"), *"900"); + assert_eq!(model._get_text("A10"), *"2100"); + assert_eq!(model._get_text("A11"), *"1500"); + assert_eq!(model._get_text("A12"), *"3000"); + assert_eq!(model._get_text("A13"), *"1890000"); + assert_eq!(model._get_text("A14"), *"1200"); + assert_eq!(model._get_text("A15"), *"2"); + assert_eq!(model._get_text("A16"), *"2"); + assert_eq!(model._get_text("A17"), *"720000"); + assert_eq!(model._get_text("A18"), *"360000"); + assert_eq!(model._get_text("A19"), *"848.528137424"); + assert_eq!(model._get_text("A20"), *"600"); +} + +#[test] +fn locale_uk() { + // en-GB locale with DD/MM/YYYY format + let mut model = Model::new_empty("model", "en-GB", "UTC", "en").unwrap(); + + // Create database + model._set("A1", "ID"); + model._set("B1", "Date"); + model._set("C1", "Amount"); + model._set("A2", "1"); + model._set("B2", "15/01/2026"); + model._set("C2", "1200"); + model._set("A3", "2"); + model._set("B3", "15/03/2026"); + model._set("C3", "900"); + model._set("A4", "3"); + model._set("B4", "15/06/2026"); + model._set("C4", "2100"); + + // Define criteria + model._set("A6", "Date"); + model._set("A7", ">=01/03/2026"); + model._set("B6", "Date"); + model._set("B7", "15/01/2026"); // DGET needs an exact match + + // Test functions + model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); + model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); + model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); + model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); + model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); + model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A9"), *"900"); + assert_eq!(model._get_text("A10"), *"2100"); + assert_eq!(model._get_text("A11"), *"1500"); + assert_eq!(model._get_text("A12"), *"3000"); + assert_eq!(model._get_text("A13"), *"1890000"); + assert_eq!(model._get_text("A14"), *"1200"); + assert_eq!(model._get_text("A15"), *"2"); + assert_eq!(model._get_text("A16"), *"2"); + assert_eq!(model._get_text("A17"), *"720000"); + assert_eq!(model._get_text("A18"), *"360000"); + assert_eq!(model._get_text("A19"), *"848.528137424"); + assert_eq!(model._get_text("A20"), *"600"); +} + +#[test] +fn locale_us() { + // en-US locale with MM/DD/YY format + let mut model = Model::new_empty("model", "en", "UTC", "en").unwrap(); + + // Create database + model._set("A1", "ID"); + model._set("B1", "Date"); + model._set("C1", "Amount"); + model._set("A2", "1"); + model._set("B2", "1/15/26"); + model._set("C2", "1200"); + model._set("A3", "2"); + model._set("B3", "3/15/26"); + model._set("C3", "900"); + model._set("A4", "3"); + model._set("B4", "6/15/26"); + model._set("C4", "2100"); + + // Define criteria + model._set("A6", "Date"); + model._set("A7", ">=3/1/26"); + model._set("B6", "Date"); + model._set("B7", "1/15/26"); // DGET needs an exact match + + // Test functions + model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); + model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); + model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); + model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); + model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); + model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A9"), *"900"); + assert_eq!(model._get_text("A10"), *"2100"); + assert_eq!(model._get_text("A11"), *"1500"); + assert_eq!(model._get_text("A12"), *"3000"); + assert_eq!(model._get_text("A13"), *"1890000"); + assert_eq!(model._get_text("A14"), *"1200"); + assert_eq!(model._get_text("A15"), *"2"); + assert_eq!(model._get_text("A16"), *"2"); + assert_eq!(model._get_text("A17"), *"720000"); + assert_eq!(model._get_text("A18"), *"360000"); + assert_eq!(model._get_text("A19"), *"848.528137424"); + assert_eq!(model._get_text("A20"), *"600"); +} + +#[test] +fn locale_de() { + // de-DE locale with D.M.YYYY format + let mut model = Model::new_empty("model", "de", "UTC", "en").unwrap(); + + // Create database + model._set("A1", "ID"); + model._set("B1", "Date"); + model._set("C1", "Amount"); + model._set("A2", "1"); + model._set("B2", "15.1.2026"); + model._set("C2", "1200"); + model._set("A3", "2"); + model._set("B3", "15.3.2026"); + model._set("C3", "900"); + model._set("A4", "3"); + model._set("B4", "15.6.2026"); + model._set("C4", "2100"); + + // Define criteria + model._set("A6", "Date"); + model._set("A7", ">=1.3.2026"); + model._set("B6", "Date"); + model._set("B7", "15.1.2026"); // DGET needs an exact match + + // Test functions + model._set("A9", "=DMIN(A1:C4; C1; A6:A7)"); + model._set("A10", "=DMAX(A1:C4; C1; A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4; C1; A6:A7)"); + model._set("A12", "=DSUM(A1:C4; C1; A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4; C1; A6:A7)"); + model._set("A14", "=DGET(A1:C4; C1; B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4; C1; A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4; C1; A6:A7)"); + model._set("A17", "=DVAR(A1:C4; C1; A6:A7)"); + model._set("A18", "=DVARP(A1:C4; C1; A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4; C1; A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4; C1; A6:A7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A9"), *"900"); + assert_eq!(model._get_text("A10"), *"2100"); + assert_eq!(model._get_text("A11"), *"1500"); + assert_eq!(model._get_text("A12"), *"3000"); + assert_eq!(model._get_text("A13"), *"1890000"); + assert_eq!(model._get_text("A14"), *"1200"); + assert_eq!(model._get_text("A15"), *"2"); + assert_eq!(model._get_text("A16"), *"2"); + assert_eq!(model._get_text("A17"), *"720000"); + assert_eq!(model._get_text("A18"), *"360000"); + assert_eq!(model._get_text("A19"), *"848,528137424"); + assert_eq!(model._get_text("A20"), *"600"); +} + +#[test] +fn locale_wrong_format() { + // en-US locale with incorrect D.M.YYYY format + let mut model = Model::new_empty("model", "en", "UTC", "en").unwrap(); + + // Create database + model._set("A1", "ID"); + model._set("B1", "Date"); + model._set("C1", "Amount"); + model._set("A2", "1"); + model._set("B2", "10.1.2026"); + model._set("C2", "1200"); + model._set("A3", "2"); + model._set("B3", "10.3.2026"); + model._set("C3", "900"); + model._set("A4", "3"); + model._set("B4", "10.6.2026"); + model._set("C4", "2100"); + + // Define criteria + model._set("A6", "Date"); + model._set("A7", ">=1.3.2026"); + model._set("B6", "Date"); + model._set("B7", "10.1.2026"); // DGET needs an exact match + + // Test functions - results should be the same as using empty criteria + model._set("A9", "=DMIN(A1:C4, C1, A6:A7)"); + model._set("A10", "=DMAX(A1:C4, C1, A6:A7)"); + model._set("A11", "=DAVERAGE(A1:C4, C1, A6:A7)"); + model._set("A12", "=DSUM(A1:C4, C1, A6:A7)"); + model._set("A13", "=DPRODUCT(A1:C4, C1, A6:A7)"); + model._set("A14", "=DGET(A1:C4, C1, B6:B7)"); + model._set("A15", "=DCOUNT(A1:C4, C1, A6:A7)"); + model._set("A16", "=DCOUNTA(A1:C4, C1, A6:A7)"); + model._set("A17", "=DVAR(A1:C4, C1, A6:A7)"); + model._set("A18", "=DVARP(A1:C4, C1, A6:A7)"); + model._set("A19", "=DSTDEV(A1:C4, C1, A6:A7)"); + model._set("A20", "=DSTDEVP(A1:C4, C1, A6:A7)"); + + // Test functions with empty criteria - range C6:C7 is empty + model._set("B9", "=DMIN(A1:C4, C1, C6:C7)"); + model._set("B10", "=DMAX(A1:C4, C1, C6:C7)"); + model._set("B11", "=DAVERAGE(A1:C4, C1, C6:C7)"); + model._set("B12", "=DSUM(A1:C4, C1, C6:C7)"); + model._set("B13", "=DPRODUCT(A1:C4, C1, C6:C7)"); + model._set("B14", "=DGET(A1:C4, C1, B6:B7)"); + model._set("B15", "=DCOUNT(A1:C4, C1, C6:C7)"); + model._set("B16", "=DCOUNTA(A1:C4, C1, C6:C7)"); + model._set("B17", "=DVAR(A1:C4, C1, C6:C7)"); + model._set("B18", "=DVARP(A1:C4, C1, C6:C7)"); + model._set("B19", "=DSTDEV(A1:C4, C1, C6:C7)"); + model._set("B20", "=DSTDEVP(A1:C4, C1, C6:C7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A9"), *"900"); + assert_eq!(model._get_text("A10"), *"2100"); + assert_eq!(model._get_text("A11"), *"1400"); + assert_eq!(model._get_text("A12"), *"4200"); + assert_eq!(model._get_text("A13"), *"2268000000"); + assert_eq!(model._get_text("A14"), *"1200"); + assert_eq!(model._get_text("A15"), *"3"); + assert_eq!(model._get_text("A16"), *"3"); + assert_eq!(model._get_text("A17"), *"390000"); + assert_eq!(model._get_text("A18"), *"260000"); + assert_eq!(model._get_text("A19"), *"624.49979984"); + assert_eq!(model._get_text("A20"), *"509.901951359"); + + assert_eq!(model._get_text("B9"), *"900"); + assert_eq!(model._get_text("B10"), *"2100"); + assert_eq!(model._get_text("B11"), *"1400"); + assert_eq!(model._get_text("B12"), *"4200"); + assert_eq!(model._get_text("B13"), *"2268000000"); + assert_eq!(model._get_text("B14"), *"1200"); + assert_eq!(model._get_text("B15"), *"3"); + assert_eq!(model._get_text("B16"), *"3"); + assert_eq!(model._get_text("B17"), *"390000"); + assert_eq!(model._get_text("B18"), *"260000"); + assert_eq!(model._get_text("B19"), *"624.49979984"); + assert_eq!(model._get_text("B20"), *"509.901951359"); +} diff --git a/base/src/test/test_fn_combin.rs b/base/src/test/test_fn_combin.rs new file mode 100644 index 000000000..dadbe01a3 --- /dev/null +++ b/base/src/test/test_fn_combin.rs @@ -0,0 +1,57 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn combin_comprehensive() { + let mut model = new_empty_model(); + + model._set("A1", "=COMBIN(5,0)"); + model._set("A2", "=COMBIN(5,5)"); + model._set("A3", "=COMBIN(10,3)"); + model._set("A4", "=COMBIN(7,1)"); + model._set("A5", "=COMBIN(10.9,3.2)"); + model._set("A6", "=COMBIN(3,4)"); + model._set("A7", "=COMBIN(-1,2)"); + model._set("A8", "=COMBIN()"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1"); + assert_eq!(model._get_text("A2"), *"1"); + assert_eq!(model._get_text("A3"), *"120"); + assert_eq!(model._get_text("A4"), *"7"); + assert_eq!(model._get_text("A5"), *"120"); + assert_eq!(model._get_text("A6"), *"#NUM!"); + assert_eq!(model._get_text("A7"), *"#NUM!"); + assert_eq!(model._get_text("A8"), *"#ERROR!"); +} + +#[test] +fn combin_mathematical_properties() { + let mut model = new_empty_model(); + + model._set("A1", "=COMBIN(8,3)"); + model._set("A2", "=COMBIN(8,5)"); + model._set("A3", "=COMBIN(5,2)"); + model._set("A4", "=COMBIN(4,1)+COMBIN(4,2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), model._get_text("A2")); + assert_eq!(model._get_text("A3"), model._get_text("A4")); +} + +#[test] +fn combin_overflow() { + let mut model = new_empty_model(); + + model._set("A1", "=COMBIN(1000, 500)"); + model._set("A2", "=COMBIN(10000, 5000)"); + + model.evaluate(); + + let a1_result = model._get_text("A1"); + assert!(a1_result == "#NUM!" || !a1_result.is_empty()); + assert_eq!(model._get_text("A2"), *"#NUM!"); +} diff --git a/base/src/test/test_fn_combina.rs b/base/src/test/test_fn_combina.rs new file mode 100644 index 000000000..5563b5888 --- /dev/null +++ b/base/src/test/test_fn_combina.rs @@ -0,0 +1,53 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn combina_comprehensive() { + let mut model = new_empty_model(); + + model._set("A1", "=COMBINA(0,0)"); + model._set("A2", "=COMBINA(0,3)"); + model._set("A3", "=COMBINA(4,3)"); + model._set("A4", "=COMBINA(3,1)"); + model._set("A5", "=COMBINA(4.9,2.1)"); + model._set("A6", "=COMBINA(-1,2)"); + model._set("A7", "=COMBINA()"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1"); + assert_eq!(model._get_text("A2"), *"#NUM!"); + assert_eq!(model._get_text("A3"), *"20"); + assert_eq!(model._get_text("A4"), *"3"); + assert_eq!(model._get_text("A5"), *"10"); + assert_eq!(model._get_text("A6"), *"#NUM!"); + assert_eq!(model._get_text("A7"), *"#ERROR!"); +} + +#[test] +fn combina_mathematical_identity() { + let mut model = new_empty_model(); + + model._set("A1", "=COMBINA(4,3)"); + model._set("A2", "=COMBIN(6,3)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), model._get_text("A2")); +} + +#[test] +fn combina_overflow() { + let mut model = new_empty_model(); + + model._set("A1", "=COMBINA(18446744073709551615, 2)"); + model._set("A2", "=COMBINA(18446744073709551614, 3)"); + model._set("A3", "=COMBINA(9223372036854775807, 9223372036854775807)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#NUM!"); + assert_eq!(model._get_text("A2"), *"#NUM!"); + assert_eq!(model._get_text("A3"), *"#NUM!"); +} diff --git a/base/src/test/test_fn_fact.rs b/base/src/test/test_fn_fact.rs new file mode 100644 index 000000000..2d8d3f44e --- /dev/null +++ b/base/src/test/test_fn_fact.rs @@ -0,0 +1,43 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn fact_comprehensive() { + let mut model = new_empty_model(); + + model._set("A1", "=FACT(0)"); + model._set("A2", "=FACT(3)"); + model._set("A3", "=FACT(5)"); + model._set("A4", "=FACT(170)"); + model._set("A5", "=FACT(171)"); + model._set("A6", "=FACT(3.7)"); + model._set("A7", "=FACT(-1)"); + model._set("A8", "=FACT()"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1"); + assert_eq!(model._get_text("A2"), *"6"); + assert_eq!(model._get_text("A3"), *"120"); + assert_eq!(model._get_text("A4"), *"7.25742E+306"); + assert_eq!(model._get_text("A5"), *"#NUM!"); + assert_eq!(model._get_text("A6"), *"6"); + assert_eq!(model._get_text("A7"), *"#NUM!"); + assert_eq!(model._get_text("A8"), *"#ERROR!"); +} + +#[test] +fn fact_overflow() { + let mut model = new_empty_model(); + + model._set("A1", "=FACT(170)"); + model._set("A2", "=FACT(171)"); + model._set("A3", "=FACT(200)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"7.25742E+306"); + assert_eq!(model._get_text("A2"), *"#NUM!"); + assert_eq!(model._get_text("A3"), *"#NUM!"); +} diff --git a/base/src/test/test_fn_permut.rs b/base/src/test/test_fn_permut.rs new file mode 100644 index 000000000..f79a20349 --- /dev/null +++ b/base/src/test/test_fn_permut.rs @@ -0,0 +1,58 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn permut_comprehensive() { + let mut model = new_empty_model(); + + model._set("A1", "=PERMUT(5,0)"); + model._set("A2", "=PERMUT(4,4)"); + model._set("A3", "=PERMUT(8,3)"); + model._set("A4", "=PERMUT(6,1)"); + model._set("A5", "=PERMUT(8.7,2.3)"); + model._set("A6", "=PERMUT(3,5)"); + model._set("A7", "=PERMUT(-1,2)"); + model._set("A8", "=PERMUT()"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1"); + assert_eq!(model._get_text("A2"), *"24"); + assert_eq!(model._get_text("A3"), *"336"); + assert_eq!(model._get_text("A4"), *"6"); + assert_eq!(model._get_text("A5"), *"56"); + assert_eq!(model._get_text("A6"), *"#NUM!"); + assert_eq!(model._get_text("A7"), *"#NUM!"); + assert_eq!(model._get_text("A8"), *"#ERROR!"); +} + +#[test] +fn permut_mathematical_relationships() { + let mut model = new_empty_model(); + + model._set("A1", "=PERMUT(6,3)"); + model._set("A2", "=COMBIN(6,3)*FACT(3)"); + model._set("A3", "=PERMUT(5,5)"); + model._set("A4", "=FACT(5)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), model._get_text("A2")); + assert_eq!(model._get_text("A3"), model._get_text("A4")); +} + +#[test] +fn permut_overflow() { + let mut model = new_empty_model(); + + model._set("A1", "=PERMUT(100, 50)"); + model._set("A2", "=PERMUT(1000, 500)"); + model._set("A3", "=PERMUT(200, 100)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"3.06852E+93"); + assert_eq!(model._get_text("A2"), *"#NUM!"); + assert_eq!(model._get_text("A3"), *"8.45055E+216"); +} diff --git a/base/src/test/test_fn_permutationa.rs b/base/src/test/test_fn_permutationa.rs new file mode 100644 index 000000000..d8592ae10 --- /dev/null +++ b/base/src/test/test_fn_permutationa.rs @@ -0,0 +1,53 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn permutationa_comprehensive() { + let mut model = new_empty_model(); + + model._set("A1", "=PERMUTATIONA(0,0)"); + model._set("A2", "=PERMUTATIONA(0,3)"); + model._set("A3", "=PERMUTATIONA(5,0)"); + model._set("A4", "=PERMUTATIONA(10,3)"); + model._set("A5", "=PERMUTATIONA(5.9,2.9)"); + model._set("A6", "=PERMUTATIONA(-1,2)"); + model._set("A7", "=PERMUTATIONA()"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1"); + assert_eq!(model._get_text("A2"), *"0"); + assert_eq!(model._get_text("A3"), *"1"); + assert_eq!(model._get_text("A4"), *"1000"); + assert_eq!(model._get_text("A5"), *"25"); + assert_eq!(model._get_text("A6"), *"#NUM!"); + assert_eq!(model._get_text("A7"), *"#ERROR!"); +} + +#[test] +fn permutationa_power_equivalence() { + let mut model = new_empty_model(); + + model._set("A1", "=PERMUTATIONA(4,3)"); + model._set("A2", "=POWER(4,3)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), model._get_text("A2")); +} + +#[test] +fn permutationa_overflow() { + let mut model = new_empty_model(); + + model._set("A1", "=PERMUTATIONA(100, 10)"); + model._set("A2", "=PERMUTATIONA(1000, 100)"); + model._set("A3", "=PERMUTATIONA(10, 100)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1E+20"); + assert_eq!(model._get_text("A2"), *"1E+300"); + assert_eq!(model._get_text("A3"), *"1E+100"); +} diff --git a/base/src/test/test_number_format.rs b/base/src/test/test_number_format.rs index e4dcb4a59..3c4ffa71b 100644 --- a/base/src/test/test_number_format.rs +++ b/base/src/test/test_number_format.rs @@ -1,6 +1,8 @@ #![allow(clippy::unwrap_used)] use crate::number_format::format_number; +use crate::test::util::new_empty_model; +use crate::UserModel; #[test] fn test_simple_format() { @@ -17,6 +19,28 @@ fn test_maximum_zeros() { assert_eq!(formatted.text, "1,234.3333333333300000000".to_string()); } +#[test] +fn test_leading_comma_text() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.set_user_input(0, 1, 1, ",10").unwrap(); // A1 + model.set_user_input(0, 2, 1, ",100").unwrap(); // A2 + model.set_user_input(0, 3, 1, ",1000").unwrap(); // A3 + + assert_eq!( + model.get_formatted_cell_value(0, 1, 1), + Ok(",10".to_string()) + ); + assert_eq!( + model.get_formatted_cell_value(0, 2, 1), + Ok(",100".to_string()) + ); + assert_eq!( + model.get_formatted_cell_value(0, 3, 1), + Ok(",1000".to_string()) + ); +} + #[test] #[ignore = "not yet implemented"] fn test_wrong_locale() { diff --git a/base/src/test/user_model/mod.rs b/base/src/test/user_model/mod.rs index 89d346419..386c9ef4f 100644 --- a/base/src/test/user_model/mod.rs +++ b/base/src/test/user_model/mod.rs @@ -5,10 +5,12 @@ mod test_batch_row_column_diff; mod test_border; mod test_clear_cells; mod test_column_style; +mod test_cut_n_paste; mod test_defined_names; mod test_delete_row_column_formatting; mod test_diff_queue; mod test_evaluation; +mod test_fn_formulatext; mod test_general; mod test_grid_lines; mod test_keyboard_navigation; diff --git a/base/src/test/user_model/test_cut_n_paste.rs b/base/src/test/user_model/test_cut_n_paste.rs new file mode 100644 index 000000000..7e3907e93 --- /dev/null +++ b/base/src/test/user_model/test_cut_n_paste.rs @@ -0,0 +1,99 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; +use crate::UserModel; + +#[test] +fn cun_n_paste_same_area() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + // B3:D5 with data + model.set_user_input(0, 3, 2, "A").unwrap(); + model.set_user_input(0, 3, 3, "B").unwrap(); + model.set_user_input(0, 3, 4, "C").unwrap(); + model.set_user_input(0, 4, 2, "D").unwrap(); + model.set_user_input(0, 4, 3, "E").unwrap(); + model.set_user_input(0, 4, 4, "F").unwrap(); + model.set_user_input(0, 5, 2, "G").unwrap(); + model.set_user_input(0, 5, 3, "H").unwrap(); + model.set_user_input(0, 5, 4, "I").unwrap(); + + // Cut it and paste it in C4 + model.set_selected_cell(3, 2).unwrap(); + model.set_selected_range(3, 2, 5, 4).unwrap(); + let cp = model.copy_to_clipboard().unwrap(); + + // C4 + model.set_selected_cell(4, 3).unwrap(); + + let source_range = (3, 2, 5, 4); + model + .paste_from_clipboard(0, source_range, &cp.data, true) + .unwrap(); + + // Check data is in C4:E6 + assert_eq!(model.get_formatted_cell_value(0, 4, 3).unwrap(), "A"); + assert_eq!(model.get_formatted_cell_value(0, 4, 4).unwrap(), "B"); + assert_eq!(model.get_formatted_cell_value(0, 4, 5).unwrap(), "C"); + assert_eq!(model.get_formatted_cell_value(0, 5, 3).unwrap(), "D"); + assert_eq!(model.get_formatted_cell_value(0, 5, 4).unwrap(), "E"); + assert_eq!(model.get_formatted_cell_value(0, 5, 5).unwrap(), "F"); + assert_eq!(model.get_formatted_cell_value(0, 6, 3).unwrap(), "G"); + assert_eq!(model.get_formatted_cell_value(0, 6, 4).unwrap(), "H"); + assert_eq!(model.get_formatted_cell_value(0, 6, 5).unwrap(), "I"); +} + +#[test] +fn cun_n_paste_different_sheet() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + // B3:D5 with data + model.set_user_input(0, 3, 2, "A").unwrap(); + model.set_user_input(0, 3, 3, "B").unwrap(); + model.set_user_input(0, 3, 4, "C").unwrap(); + model.set_user_input(0, 4, 2, "D").unwrap(); + model.set_user_input(0, 4, 3, "E").unwrap(); + model.set_user_input(0, 4, 4, "F").unwrap(); + model.set_user_input(0, 5, 2, "G").unwrap(); + model.set_user_input(0, 5, 3, "H").unwrap(); + model.set_user_input(0, 5, 4, "I").unwrap(); + + // Cut it and paste it in C4 + model.set_selected_cell(3, 2).unwrap(); + model.set_selected_range(3, 2, 5, 4).unwrap(); + let cp = model.copy_to_clipboard().unwrap(); + + // New sheet and select it + model.new_sheet().unwrap(); + model.set_selected_sheet(1).unwrap(); + + // C4 + model.set_selected_cell(4, 3).unwrap(); + + let source_range = (3, 2, 5, 4); + model + .paste_from_clipboard(0, source_range, &cp.data, true) + .unwrap(); + + // Check data is in Sheet2!C4:E6 + assert_eq!(model.get_formatted_cell_value(1, 4, 3).unwrap(), "A"); + assert_eq!(model.get_formatted_cell_value(1, 4, 4).unwrap(), "B"); + assert_eq!(model.get_formatted_cell_value(1, 4, 5).unwrap(), "C"); + assert_eq!(model.get_formatted_cell_value(1, 5, 3).unwrap(), "D"); + assert_eq!(model.get_formatted_cell_value(1, 5, 4).unwrap(), "E"); + assert_eq!(model.get_formatted_cell_value(1, 5, 5).unwrap(), "F"); + assert_eq!(model.get_formatted_cell_value(1, 6, 3).unwrap(), "G"); + assert_eq!(model.get_formatted_cell_value(1, 6, 4).unwrap(), "H"); + assert_eq!(model.get_formatted_cell_value(1, 6, 5).unwrap(), "I"); + + // Check original range is empty Sheet1!B3:D5 + assert_eq!(model.get_formatted_cell_value(0, 3, 2).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 3, 3).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 3, 4).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 4, 2).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 4, 3).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 4, 4).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 5, 2).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 5, 3).unwrap(), ""); + assert_eq!(model.get_formatted_cell_value(0, 5, 4).unwrap(), ""); +} diff --git a/base/src/test/user_model/test_fn_formulatext.rs b/base/src/test/user_model/test_fn_formulatext.rs index 24df46095..67bf8e63b 100644 --- a/base/src/test/user_model/test_fn_formulatext.rs +++ b/base/src/test/user_model/test_fn_formulatext.rs @@ -1,10 +1,17 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::user_model::util::new_empty_user_model; + #[test] fn formulatext_english() { - let mut model = UserModel::from_model(new_empty_model()); + let mut model = new_empty_user_model(); model.set_user_input(0, 1, 1, "=SUM(1, 2, 3)").unwrap(); model.set_user_input(0, 1, 2, "=FORMULATEXT(A1)").unwrap(); model.set_language("de").unwrap(); - assert_eq!(model.get_formatted_cell_value(0, 1, 2), Ok("=SUM(1,2,3)".to_string())); -} \ No newline at end of file + assert_eq!( + model.get_formatted_cell_value(0, 1, 2), + Ok("=SUM(1,2,3)".to_string()) + ); +} diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index 1c635d5fb..190a9a2a8 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -1,6 +1,10 @@ #![deny(missing_docs)] -use std::{collections::HashMap, fmt::Debug, io::Cursor}; +use std::{ + collections::{HashMap, HashSet}, + fmt::Debug, + io::Cursor, +}; use csv::{ReaderBuilder, WriterBuilder}; use serde::{Deserialize, Serialize}; @@ -1830,6 +1834,7 @@ impl<'a> UserModel<'a> { width: source_last_column - source_first_column + 1, height: source_last_row - source_first_row + 1, }; + let mut seen_cells = HashSet::new(); for (source_row, data_row) in clipboard { let delta_row = source_row - source_first_row; let target_row = selected_row + delta_row; @@ -1893,11 +1898,15 @@ impl<'a> UserModel<'a> { old_value: Box::new(old_style), new_value: Box::new(value.style.clone()), }); + seen_cells.insert((target_row, target_column)); } } if is_cut { for row in source_first_row..=source_last_row { for column in source_first_column..=source_last_column { + if (source_sheet == sheet) && seen_cells.contains(&(row, column)) { + continue; + } let old_value = self .model .workbook diff --git a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx index 2ca57b446..081c137fc 100644 --- a/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx +++ b/webapp/IronCalc/src/components/BorderPicker/BorderPicker.tsx @@ -1,5 +1,7 @@ import { type BorderOptions, BorderStyle, BorderType } from "@ironcalc/wasm"; -import Popover, { type PopoverOrigin } from "@mui/material/Popover"; +import ClickAwayListener from "@mui/material/ClickAwayListener"; +import MenuItem from "@mui/material/MenuItem"; +import Popper, { type PopperPlacementType } from "@mui/material/Popper"; import { styled } from "@mui/material/styles"; import { Grid2X2 as BorderAllIcon, @@ -29,8 +31,7 @@ type BorderPickerProps = { onChange: (border: BorderOptions) => void; onClose: () => void; anchorEl: React.RefObject; - anchorOrigin?: PopoverOrigin; - transformOrigin?: PopoverOrigin; + placement: PopperPlacementType; open: boolean; }; @@ -68,302 +69,457 @@ const BorderPicker = (properties: BorderPickerProps) => { const borderColorButton = useRef(null); const borderStyleButton = useRef(null); + const stylePickerCloseTimeout = useRef | null>( + null, + ); + + const handleStylePickerOpen = () => { + if (stylePickerCloseTimeout.current) { + clearTimeout(stylePickerCloseTimeout.current); + } + setStylePickerOpen(true); + }; + + const handleStylePickerClose = () => { + if (stylePickerCloseTimeout.current) { + clearTimeout(stylePickerCloseTimeout.current); + } + stylePickerCloseTimeout.current = setTimeout(() => { + setStylePickerOpen(false); + }, 150); + }; + + useEffect( + () => () => { + if (stylePickerCloseTimeout.current) { + clearTimeout(stylePickerCloseTimeout.current); + } + }, + [], + ); + + if (!properties.anchorEl.current) { + return null; + } + return ( - -
- - - - - - - - - - - + + + + + + + + + + + + + + + + setColorPickerOpen(true)} + ref={borderColorButton} > - - - - - - - - - - - setColorPickerOpen(true)} - ref={borderColorButton} - > - + + Border color + + -
Border color
- - - - setStylePickerOpen(true)} - ref={borderStyleButton} - > - -
Border style
- -
-
-
- { - setBorderColor(color); - setColorPickerOpen(false); - }} - onClose={() => { - setColorPickerOpen(false); - }} - anchorEl={borderColorButton} - open={colorPickerOpen} - anchorOrigin={{ - vertical: "top", // Keep vertical alignment at the top - horizontal: "right", // Set horizontal alignment to right - }} - transformOrigin={{ - vertical: "top", // Keep vertical alignment at the top - horizontal: "left", // Set horizontal alignment to left - }} - /> - { - setStylePickerOpen(false); - }} - anchorEl={borderStyleButton.current} - anchorOrigin={{ - vertical: "top", - horizontal: "right", - }} - > - - { - setBorderStyle(BorderStyle.Thin); - setStylePickerOpen(false); - }} - $checked={borderStyle === BorderStyle.Thin} - > - Thin - - - { - setBorderStyle(BorderStyle.Medium); - setStylePickerOpen(false); - }} - $checked={borderStyle === BorderStyle.Medium} - > - Medium - - - { - setBorderStyle(BorderStyle.Thick); - setStylePickerOpen(false); - }} - $checked={borderStyle === BorderStyle.Thick} + + + Border style + + + + + { + setBorderColor(color); + setColorPickerOpen(false); + }} + onClose={() => { + setColorPickerOpen(false); + }} + anchorEl={borderColorButton} + open={colorPickerOpen} + anchorOrigin={{ + vertical: "top", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "left", + }} + /> + {borderStyleButton.current && ( + - Thick - - - - -
-
+ + { + setBorderStyle(BorderStyle.Thin); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Thin} + > + + + { + setBorderStyle(BorderStyle.Medium); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Medium} + > + + + { + setBorderStyle(BorderStyle.Thick); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Thick} + > + + + { + setBorderStyle(BorderStyle.Double); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Double} + > + + + { + setBorderStyle(BorderStyle.Dotted); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.Dotted} + > + + + { + setBorderStyle(BorderStyle.MediumDashed); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.MediumDashed} + > + + + { + setBorderStyle(BorderStyle.SlantDashDot); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.SlantDashDot} + > + + + { + setBorderStyle(BorderStyle.MediumDashDot); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.MediumDashDot} + > + + + { + setBorderStyle(BorderStyle.MediumDashDotDot); + setStylePickerOpen(false); + }} + selected={borderStyle === BorderStyle.MediumDashDotDot} + > + + + + + )} + + + ); }; -type LineWrapperProperties = { $checked: boolean }; -const LineWrapper = styled("div")` +const borderLineColor = theme.palette.grey["900"]; +const borderLinePreviewWidth = 68; + +const dashDotGradient = (color: string) => + `repeating-linear-gradient(90deg, ${color} 0px 4px, transparent 4px 6px, ${color} 6px 7px, transparent 7px 9px)`; + +const dashDotDotGradient = (color: string) => + `repeating-linear-gradient(90deg, ${color} 0px 4px, transparent 4px 6px, ${color} 6px 7px, transparent 7px 9px, ${color} 9px 10px, transparent 10px 12px)`; + +const StyledMenuItem = styled(MenuItem)` display: flex; flex-direction: row; align-items: center; - background-color: ${({ $checked }): string => { - if ($checked) { - return theme.palette.grey["200"]; - } - return "inherit;"; - }}; - &:hover { - border: 1px solid ${theme.palette.grey["200"]}; - } + justify-content: center; + height: 32px; padding: 8px; - cursor: pointer; border-radius: 4px; - border: 1px solid white; + &::before { + content: none; + } + &.Mui-selected { + background-color: ${({ theme }) => theme.palette.action.hover}; + &:hover { + background-color: ${({ theme }) => theme.palette.action.hover}; + } + } `; const SolidLine = styled("div")` - width: 68px; + width: ${borderLinePreviewWidth}px; border-top: 1px solid ${theme.palette.grey["900"]}; `; const MediumLine = styled("div")` - width: 68px; + width: ${borderLinePreviewWidth}px; border-top: 2px solid ${theme.palette.grey["900"]}; `; const ThickLine = styled("div")` - width: 68px; + width: ${borderLinePreviewWidth}px; border-top: 3px solid ${theme.palette.grey["900"]}; `; +const DoubleLine = styled("div")` + width: ${borderLinePreviewWidth}px; + height: 3px; + position: relative; + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + border-top: 1px solid ${theme.palette.grey["900"]}; + } + &::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + border-top: 1px solid ${theme.palette.grey["900"]}; + } +`; + +const DottedLine = styled("div")` + width: ${borderLinePreviewWidth}px; + border-top: 1px dotted ${theme.palette.grey["900"]}; +`; + +const MediumDashedLine = styled("div")` + width: ${borderLinePreviewWidth}px; + border-top: 2px dashed ${theme.palette.grey["900"]}; +`; + +const SlantDashDotLine = styled("div")` + width: ${borderLinePreviewWidth}px; + height: 1px; + background: ${dashDotGradient(borderLineColor)}; +`; + +const MediumDashDotLine = styled("div")` + width: ${borderLinePreviewWidth}px; + height: 2px; + background: ${dashDotGradient(borderLineColor)}; +`; + +const MediumDashDotDotLine = styled("div")` + width: ${borderLinePreviewWidth}px; + height: 2px; + background: ${dashDotDotGradient(borderLineColor)}; +`; + const Divider = styled("div")` width: 100%; margin: auto; @@ -390,46 +546,56 @@ const Line = styled("div")` gap: 4px; `; -const ButtonWrapper = styled("div")` +const BaseMenuItem = (props: React.ComponentProps) => ( + +); + +const MenuItemWrapper = styled(BaseMenuItem)` display: flex; - flex-direction: row; - align-items: center; + justify-content: flex-start; border-radius: 4px; - gap: 8px; - &:hover { - background-color: ${theme.palette.grey["200"]}; - border-top-color: ${(): string => theme.palette.grey["200"]}; - } - cursor: pointer; padding: 8px; + height: 32px; + min-height: 32px; + max-height: 32px; + color: ${theme.palette.common.black}; + font-size: 12px; + gap: 8px; svg { - width: 16px; - height: 16px; + max-width: 16px; + min-width: 16px; + max-height: 16px; + min-height: 16px; + color: ${theme.palette.grey[600]}; } `; -const BorderStyleDialog = styled("div")` +const MenuItemText = styled("div")` + flex-grow: 1; +`; + +const PopperContent = styled("div")` + border-radius: 8px; + border: 0px solid ${({ theme }): string => theme.palette.background.default}; + box-shadow: 1px 2px 8px rgba(139, 143, 173, 0.5); background: ${({ theme }): string => theme.palette.background.default}; + font-family: ${({ theme }) => theme.typography.fontFamily}; + font-size: 12px; + overflow: hidden; +`; + +const StylePicker = styled(PopperContent)` padding: 4px; display: flex; flex-direction: column; align-items: center; `; -const StyledPopover = styled(Popover)` - .MuiPopover-paper { - border-radius: 8px; - border: 0px solid ${({ theme }): string => theme.palette.background.default}; - box-shadow: 1px 2px 8px rgba(139, 143, 173, 0.5); - } - .MuiPopover-padding { - padding: 0px; - } - .MuiList-padding { - padding: 0px; +const StyledPopper = styled(Popper)` + z-index: 1300; + &[data-popper-placement] { + pointer-events: auto; } - font-family: ${({ theme }) => theme.typography.fontFamily}; - font-size: 12px; `; const BorderPickerDialog = styled("div")` @@ -438,10 +604,6 @@ const BorderPickerDialog = styled("div")` flex-direction: column; `; -const BorderDescription = styled("div")` - width: 70px; -`; - type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string }; const Button = styled("button")( ({ disabled, $pressed, $underlinedColor }) => { diff --git a/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx b/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx index 2c708b2ce..491dffc60 100644 --- a/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx +++ b/webapp/IronCalc/src/components/Toolbar/Toolbar.tsx @@ -415,7 +415,7 @@ function Toolbar(properties: ToolbarProperties) { setBorderPickerOpen(true)} ref={borderButton} disabled={!canEdit} @@ -582,6 +582,7 @@ function Toolbar(properties: ToolbarProperties) { transformOrigin={{ vertical: "top", horizontal: "left" }} /> { properties.onBorderChanged(border); }} diff --git a/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts b/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts index fe4c6f775..02ebd12db 100644 --- a/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts +++ b/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts @@ -709,6 +709,92 @@ export default class WorksheetCanvas { this.cells.push(textProperties); } + /// Draws a single line from (x1,y1) to (x2,y2) using current stroke style/width. + private drawBorderLine( + context: CanvasRenderingContext2D, + x1: number, + y1: number, + x2: number, + y2: number, + ): void { + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); + } + + /// Helper function to draw a border with different styles + private drawBorder( + context: CanvasRenderingContext2D, + style: string, + color: string, + x1: number, + y1: number, + x2: number, + y2: number, + isVertical: boolean, + ): void { + context.save(); + context.strokeStyle = color; + + switch (style) { + case "thin": + context.lineWidth = 1; + this.drawBorderLine(context, x1, y1, x2, y2); + break; + case "medium": + context.lineWidth = 2; + this.drawBorderLine(context, x1, y1, x2, y2); + break; + case "thick": + context.lineWidth = 3; + this.drawBorderLine(context, x1, y1, x2, y2); + break; + case "double": + // Draw two parallel lines + context.lineWidth = 1; + if (isVertical) { + this.drawBorderLine(context, x1 - 1, y1, x1 - 1, y2); + this.drawBorderLine(context, x1 + 1, y1, x1 + 1, y2); + } else { + this.drawBorderLine(context, x1, y1 - 1, x2, y1 - 1); + this.drawBorderLine(context, x1, y1 + 1, x2, y1 + 1); + } + break; + case "dotted": + context.lineWidth = 1; + context.setLineDash([1, 2]); + this.drawBorderLine(context, x1, y1, x2, y2); + context.setLineDash([]); + break; + case "mediumdashed": + context.lineWidth = 2; + context.setLineDash([4, 2]); + this.drawBorderLine(context, x1, y1, x2, y2); + context.setLineDash([]); + break; + case "slantdashdot": + context.lineWidth = 1; + context.setLineDash([4, 2, 1, 2]); + this.drawBorderLine(context, x1, y1, x2, y2); + context.setLineDash([]); + break; + case "mediumdashdot": + context.lineWidth = 2; + context.setLineDash([4, 2, 1, 2]); + this.drawBorderLine(context, x1, y1, x2, y2); + context.setLineDash([]); + break; + case "mediumdashdotdot": + context.lineWidth = 2; + context.setLineDash([4, 2, 1, 2, 1, 2]); + this.drawBorderLine(context, x1, y1, x2, y2); + context.setLineDash([]); + break; + } + context.restore(); + } + /// Renders the cell style: colors, borders, etc. But not the text. private renderCellStyle( row: number, @@ -746,18 +832,10 @@ export default class WorksheetCanvas { // we skip don't draw a left border if it is marked as a "spill cell" if (this.spills.get(`${row}-${column}`) !== 1) { let borderLeftColor = cellGridColor; - let borderLeftWidth = 1; + let borderLeftStyle = "thin"; if (border.left) { borderLeftColor = border.left.color; - switch (border.left.style) { - case "thin": - break; - case "medium": - borderLeftWidth = 2; - break; - case "thick": - borderLeftWidth = 3; - } + borderLeftStyle = border.left.style; } else { const leftStyle = this.model.getCellStyle( selectedSheet, @@ -766,15 +844,7 @@ export default class WorksheetCanvas { ); if (leftStyle.border.right) { borderLeftColor = leftStyle.border.right.color; - switch (leftStyle.border.right.style) { - case "thin": - break; - case "medium": - borderLeftWidth = 2; - break; - case "thick": - borderLeftWidth = 3; - } + borderLeftStyle = leftStyle.border.right.style; } else if (style.fill.fg_color) { borderLeftColor = style.fill.fg_color; } else if (leftStyle.fill.fg_color) { @@ -782,52 +852,44 @@ export default class WorksheetCanvas { } } - context.beginPath(); - context.strokeStyle = borderLeftColor; - context.lineWidth = borderLeftWidth; - context.moveTo(x, y); - context.lineTo(x, y + height); - context.stroke(); + this.drawBorder( + context, + borderLeftStyle, + borderLeftColor, + x, + y, + x, + y + height, + true, + ); } let borderTopColor = cellGridColor; - let borderTopWidth = 1; + let borderTopStyle = "thin"; if (border.top) { borderTopColor = border.top.color; - switch (border.top.style) { - case "thin": - break; - case "medium": - borderTopWidth = 2; - break; - case "thick": - borderTopWidth = 3; - } + borderTopStyle = border.top.style; } else { const topStyle = this.model.getCellStyle(selectedSheet, row - 1, column); if (topStyle.border.bottom) { borderTopColor = topStyle.border.bottom.color; - switch (topStyle.border.bottom.style) { - case "thin": - break; - case "medium": - borderTopWidth = 2; - break; - case "thick": - borderTopWidth = 3; - } + borderTopStyle = topStyle.border.bottom.style; } else if (style.fill.fg_color) { borderTopColor = style.fill.fg_color; } else if (topStyle.fill.fg_color) { borderTopColor = topStyle.fill.fg_color; } } - context.beginPath(); - context.strokeStyle = borderTopColor; - context.lineWidth = borderTopWidth; - context.moveTo(x, y); - context.lineTo(x + width, y); - context.stroke(); + this.drawBorder( + context, + borderTopStyle, + borderTopColor, + x, + y, + x + width, + y, + false, + ); } /// Renders the text in the cell.