diff --git a/src/formula/parser.rs b/src/formula/parser.rs index d4628d8..7d2d9fb 100644 --- a/src/formula/parser.rs +++ b/src/formula/parser.rs @@ -304,3 +304,60 @@ fn parse_comparison(tokens: &[Token], pos: &mut usize) -> Result { let right = parse_add_sub(tokens, pos)?; Ok(Expr::BinOp(op.to_string(), Box::new(left), Box::new(right))) } + +#[cfg(test)] +mod tests { + use super::parse_formula; + use crate::formula::{Expr, AggFunc}; + + #[test] + fn parse_simple_subtraction() { + let f = parse_formula("Profit = Revenue - Cost", "Measure").unwrap(); + assert_eq!(f.target, "Profit"); + assert_eq!(f.target_category, "Measure"); + assert!(matches!(f.expr, Expr::BinOp(ref op, _, _) if op == "-")); + } + + #[test] + fn parse_where_clause() { + let f = parse_formula("EastRev = Revenue WHERE Region = \"East\"", "Measure").unwrap(); + assert_eq!(f.target, "EastRev"); + let filter = f.filter.as_ref().unwrap(); + assert_eq!(filter.category, "Region"); + assert_eq!(filter.item, "East"); + } + + #[test] + fn parse_sum_aggregation() { + let f = parse_formula("Total = SUM(Revenue)", "Measure").unwrap(); + assert!(matches!(f.expr, Expr::Agg(AggFunc::Sum, _, _))); + } + + #[test] + fn parse_avg_aggregation() { + let f = parse_formula("Avg = AVG(Revenue)", "Measure").unwrap(); + assert!(matches!(f.expr, Expr::Agg(AggFunc::Avg, _, _))); + } + + #[test] + fn parse_if_expression() { + let f = parse_formula("Capped = IF(Revenue > 1000, 1000, Revenue)", "Measure").unwrap(); + assert!(matches!(f.expr, Expr::If(_, _, _))); + } + + #[test] + fn parse_numeric_literal() { + let f = parse_formula("Fixed = 42", "Measure").unwrap(); + assert!(matches!(f.expr, Expr::Number(n) if (n - 42.0).abs() < 1e-10)); + } + + #[test] + fn parse_chained_arithmetic() { + parse_formula("X = (A + B) * (C - D)", "Cat").unwrap(); + } + + #[test] + fn parse_missing_equals_returns_error() { + assert!(parse_formula("BadFormula Revenue Cost", "Cat").is_err()); + } +} diff --git a/src/model/category.rs b/src/model/category.rs index 9f73f25..4b3235d 100644 --- a/src/model/category.rs +++ b/src/model/category.rs @@ -119,3 +119,76 @@ impl Category { seen } } + +#[cfg(test)] +mod tests { + use super::{Category, Group}; + + fn cat() -> Category { + Category::new(0, "Region") + } + + #[test] + fn add_item_returns_sequential_ids() { + let mut c = cat(); + let id0 = c.add_item("East"); + let id1 = c.add_item("West"); + assert_eq!(id0, 0); + assert_eq!(id1, 1); + } + + #[test] + fn add_item_duplicate_returns_same_id() { + let mut c = cat(); + let id1 = c.add_item("East"); + let id2 = c.add_item("East"); + assert_eq!(id1, id2); + assert_eq!(c.items.len(), 1); + } + + #[test] + fn add_item_in_group_sets_group() { + let mut c = cat(); + c.add_item_in_group("Jan", "Q1"); + let item = c.item_by_name("Jan").unwrap(); + assert_eq!(item.group.as_deref(), Some("Q1")); + } + + #[test] + fn add_item_in_group_duplicate_returns_same_id() { + let mut c = cat(); + let id1 = c.add_item_in_group("Jan", "Q1"); + let id2 = c.add_item_in_group("Jan", "Q1"); + assert_eq!(id1, id2); + assert_eq!(c.items.len(), 1); + } + + #[test] + fn add_group_deduplicates() { + let mut c = cat(); + c.add_group(Group::new("Q1")); + c.add_group(Group::new("Q1")); + assert_eq!(c.groups.len(), 1); + } + + #[test] + fn top_level_groups_returns_unique_groups_in_insertion_order() { + let mut c = cat(); + c.add_item_in_group("Jan", "Q1"); + c.add_item_in_group("Feb", "Q1"); + c.add_item_in_group("Apr", "Q2"); + let groups = c.top_level_groups(); + assert_eq!(groups, vec!["Q1", "Q2"]); + } + + #[test] + fn item_index_reflects_insertion_order() { + let mut c = cat(); + c.add_item("East"); + c.add_item("West"); + c.add_item("North"); + assert_eq!(c.item_index("East"), Some(0)); + assert_eq!(c.item_index("West"), Some(1)); + assert_eq!(c.item_index("North"), Some(2)); + } +} diff --git a/src/model/cell.rs b/src/model/cell.rs index 917f152..d36285f 100644 --- a/src/model/cell.rs +++ b/src/model/cell.rs @@ -156,3 +156,177 @@ impl DataStore { .collect() } } + +#[cfg(test)] +mod cell_key { + use super::{CellKey, CellValue, DataStore}; + + fn key(pairs: &[(&str, &str)]) -> CellKey { + CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect()) + } + + #[test] + fn coords_are_sorted_by_category_name() { + let k = key(&[("Region", "East"), ("Measure", "Revenue"), ("Product", "Shirts")]); + assert_eq!(k.0[0].0, "Measure"); + assert_eq!(k.0[1].0, "Product"); + assert_eq!(k.0[2].0, "Region"); + } + + #[test] + fn get_returns_item_for_known_category() { + let k = key(&[("Region", "East"), ("Product", "Shirts")]); + assert_eq!(k.get("Region"), Some("East")); + assert_eq!(k.get("Product"), Some("Shirts")); + } + + #[test] + fn get_returns_none_for_unknown_category() { + let k = key(&[("Region", "East")]); + assert_eq!(k.get("Measure"), None); + } + + #[test] + fn with_adds_new_coordinate_in_sorted_order() { + let k = key(&[("Region", "East")]).with("Measure", "Revenue"); + assert_eq!(k.get("Measure"), Some("Revenue")); + assert_eq!(k.get("Region"), Some("East")); + assert_eq!(k.0[0].0, "Measure"); + assert_eq!(k.0[1].0, "Region"); + } + + #[test] + fn with_replaces_existing_coordinate() { + let k = key(&[("Region", "East"), ("Product", "Shirts")]).with("Region", "West"); + assert_eq!(k.get("Region"), Some("West")); + assert_eq!(k.0.len(), 2); + } + + #[test] + fn without_removes_coordinate() { + let k = key(&[("Region", "East"), ("Product", "Shirts")]).without("Region"); + assert_eq!(k.get("Region"), None); + assert_eq!(k.get("Product"), Some("Shirts")); + assert_eq!(k.0.len(), 1); + } + + #[test] + fn without_missing_category_is_noop() { + let k = key(&[("Region", "East")]).without("Measure"); + assert_eq!(k.0.len(), 1); + } + + #[test] + fn matches_partial_full_match() { + let k = key(&[("Region", "East"), ("Product", "Shirts")]); + let partial = vec![("Region".to_string(), "East".to_string())]; + assert!(k.matches_partial(&partial)); + } + + #[test] + fn matches_partial_empty_matches_all() { + let k = key(&[("Region", "East"), ("Product", "Shirts")]); + assert!(k.matches_partial(&[])); + } + + #[test] + fn matches_partial_wrong_item_no_match() { + let k = key(&[("Region", "East"), ("Product", "Shirts")]); + let partial = vec![("Region".to_string(), "West".to_string())]; + assert!(!k.matches_partial(&partial)); + } + + #[test] + fn matches_partial_missing_category_no_match() { + let k = key(&[("Region", "East")]); + let partial = vec![("Product".to_string(), "Shirts".to_string())]; + assert!(!k.matches_partial(&partial)); + } + + #[test] + fn display_format() { + let k = key(&[("Region", "East")]); + assert_eq!(k.to_string(), "{Region=East}"); + } +} + +#[cfg(test)] +mod data_store { + use super::{CellKey, CellValue, DataStore}; + + fn key(pairs: &[(&str, &str)]) -> CellKey { + CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect()) + } + + #[test] + fn get_missing_returns_empty() { + let store = DataStore::new(); + assert_eq!(store.get(&key(&[("Region", "East")])), &CellValue::Empty); + } + + #[test] + fn set_and_get_roundtrip() { + let mut store = DataStore::new(); + let k = key(&[("Region", "East"), ("Product", "Shirts")]); + store.set(k.clone(), CellValue::Number(42.0)); + assert_eq!(store.get(&k), &CellValue::Number(42.0)); + } + + #[test] + fn overwrite_value() { + let mut store = DataStore::new(); + let k = key(&[("Region", "East")]); + store.set(k.clone(), CellValue::Number(1.0)); + store.set(k.clone(), CellValue::Number(99.0)); + assert_eq!(store.get(&k), &CellValue::Number(99.0)); + } + + #[test] + fn setting_empty_removes_key() { + let mut store = DataStore::new(); + let k = key(&[("Region", "East")]); + store.set(k.clone(), CellValue::Number(5.0)); + store.set(k.clone(), CellValue::Empty); + assert!(store.cells().is_empty()); + } + + #[test] + fn sum_matching_sums_across_dimension() { + let mut store = DataStore::new(); + store.set(key(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(100.0)); + store.set(key(&[("Measure", "Revenue"), ("Region", "West")]), CellValue::Number(200.0)); + store.set(key(&[("Measure", "Cost"), ("Region", "East")]), CellValue::Number(50.0)); + let partial = vec![("Measure".to_string(), "Revenue".to_string())]; + assert_eq!(store.sum_matching(&partial), 300.0); + } + + #[test] + fn sum_matching_empty_partial_sums_everything() { + let mut store = DataStore::new(); + store.set(key(&[("Region", "East")]), CellValue::Number(10.0)); + store.set(key(&[("Region", "West")]), CellValue::Number(20.0)); + assert_eq!(store.sum_matching(&[]), 30.0); + } + + #[test] + fn matching_cells_returns_correct_subset() { + let mut store = DataStore::new(); + store.set(key(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(100.0)); + store.set(key(&[("Measure", "Revenue"), ("Region", "West")]), CellValue::Number(200.0)); + store.set(key(&[("Measure", "Cost"), ("Region", "East")]), CellValue::Number(50.0)); + let partial = vec![("Measure".to_string(), "Revenue".to_string())]; + let cells = store.matching_cells(&partial); + assert_eq!(cells.len(), 2); + let values: Vec = cells.iter().filter_map(|(_, v)| v.as_f64()).collect(); + assert!(values.contains(&100.0)); + assert!(values.contains(&200.0)); + } + + #[test] + fn text_values_excluded_from_sum() { + let mut store = DataStore::new(); + store.set(key(&[("Cat", "A")]), CellValue::Number(10.0)); + store.set(key(&[("Cat", "B")]), CellValue::Text("hello".into())); + assert_eq!(store.sum_matching(&[]), 10.0); + } +} diff --git a/src/view/view.rs b/src/view/view.rs index ec5f6b6..c3385f2 100644 --- a/src/view/view.rs +++ b/src/view/view.rs @@ -125,3 +125,117 @@ impl View { self.col_offset = 0; } } + +#[cfg(test)] +mod tests { + use super::View; + use crate::view::Axis; + + fn view_with_cats(cats: &[&str]) -> View { + let mut v = View::new("Test"); + for &c in cats { v.on_category_added(c); } + v + } + + #[test] + fn first_category_assigned_to_row() { + let v = view_with_cats(&["Region"]); + assert_eq!(v.axis_of("Region"), Axis::Row); + } + + #[test] + fn second_category_assigned_to_column() { + let v = view_with_cats(&["Region", "Product"]); + assert_eq!(v.axis_of("Product"), Axis::Column); + } + + #[test] + fn third_and_later_categories_assigned_to_page() { + let v = view_with_cats(&["Region", "Product", "Time", "Scenario"]); + assert_eq!(v.axis_of("Time"), Axis::Page); + assert_eq!(v.axis_of("Scenario"), Axis::Page); + } + + #[test] + fn set_axis_changes_assignment() { + let mut v = view_with_cats(&["Region", "Product"]); + v.set_axis("Region", Axis::Column); + assert_eq!(v.axis_of("Region"), Axis::Column); + } + + #[test] + fn categories_on_returns_correct_list() { + let v = view_with_cats(&["Region", "Product", "Time"]); + assert_eq!(v.categories_on(Axis::Row), vec!["Region"]); + assert_eq!(v.categories_on(Axis::Column), vec!["Product"]); + assert_eq!(v.categories_on(Axis::Page), vec!["Time"]); + } + + #[test] + fn axis_of_unknown_category_returns_unassigned() { + let v = View::new("Test"); + assert_eq!(v.axis_of("Ghost"), Axis::Unassigned); + } + + #[test] + fn page_selection_set_and_get() { + let mut v = view_with_cats(&["Region", "Product", "Time"]); + v.set_page_selection("Time", "Q1"); + assert_eq!(v.page_selection("Time"), Some("Q1")); + assert_eq!(v.page_selection("Region"), None); + } + + #[test] + fn toggle_group_collapse_toggles_twice() { + let mut v = View::new("Test"); + assert!(!v.is_group_collapsed("Time", "Q1")); + v.toggle_group_collapse("Time", "Q1"); + assert!(v.is_group_collapsed("Time", "Q1")); + v.toggle_group_collapse("Time", "Q1"); + assert!(!v.is_group_collapsed("Time", "Q1")); + } + + #[test] + fn hide_and_show_item() { + let mut v = View::new("Test"); + assert!(!v.is_hidden("Region", "East")); + v.hide_item("Region", "East"); + assert!(v.is_hidden("Region", "East")); + v.show_item("Region", "East"); + assert!(!v.is_hidden("Region", "East")); + } + + #[test] + fn cycle_axis_row_to_column() { + let mut v = view_with_cats(&["Region", "Product"]); + v.cycle_axis("Region"); + assert_eq!(v.axis_of("Region"), Axis::Column); + } + + #[test] + fn cycle_axis_column_to_page() { + let mut v = view_with_cats(&["Region", "Product"]); + v.set_axis("Product", Axis::Column); + v.cycle_axis("Product"); + assert_eq!(v.axis_of("Product"), Axis::Page); + } + + #[test] + fn cycle_axis_page_to_row() { + let mut v = view_with_cats(&["Region", "Product", "Time"]); + v.cycle_axis("Time"); + assert_eq!(v.axis_of("Time"), Axis::Row); + } + + #[test] + fn cycle_axis_resets_scroll_and_selection() { + let mut v = view_with_cats(&["Region"]); + v.row_offset = 5; + v.col_offset = 3; + v.selected = (2, 2); + v.cycle_axis("Region"); + assert_eq!(v.row_offset, 0); + assert_eq!(v.col_offset, 0); + assert_eq!(v.selected, (0, 0)); + } +}