use std::collections::HashMap; use anyhow::{anyhow, Result}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use super::category::{Category, CategoryId}; use super::cell::{CellKey, CellValue, DataStore}; use crate::formula::{AggFunc, Formula}; use crate::view::View; const MAX_CATEGORIES: usize = 12; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Model { pub name: String, pub categories: IndexMap, pub data: DataStore, formulas: Vec, pub views: IndexMap, pub active_view: String, next_category_id: CategoryId, /// Per-measure aggregation function (measure item name → agg func). /// Used when collapsing categories on `Axis::None`. Defaults to SUM. #[serde(default)] pub measure_agg: HashMap, } impl Model { pub fn new(name: impl Into) -> Self { use crate::model::category::CategoryKind; let name = name.into(); let default_view = View::new("Default"); let mut views = IndexMap::new(); views.insert("Default".to_string(), default_view); let mut categories = IndexMap::new(); // Virtual categories — always present, default to Axis::None categories.insert( "_Index".to_string(), Category::new(0, "_Index").with_kind(CategoryKind::VirtualIndex), ); categories.insert( "_Dim".to_string(), Category::new(1, "_Dim").with_kind(CategoryKind::VirtualDim), ); let mut m = Self { name, categories, data: DataStore::new(), formulas: Vec::new(), views, active_view: "Default".to_string(), next_category_id: 2, measure_agg: HashMap::new(), }; // Add virtuals to existing views (default view) for view in m.views.values_mut() { view.on_category_added("_Index"); view.on_category_added("_Dim"); } m } pub fn add_category(&mut self, name: impl Into) -> Result { let name = name.into(); // Virtuals don't count against the regular category limit let regular_count = self .categories .values() .filter(|c| !c.kind.is_virtual()) .count(); if regular_count >= MAX_CATEGORIES { return Err(anyhow!("Maximum of {MAX_CATEGORIES} categories reached")); } if self.categories.contains_key(&name) { return Ok(self.categories[&name].id); } let id = self.next_category_id; self.next_category_id += 1; self.categories .insert(name.clone(), Category::new(id, name.clone())); // Add to all views for view in self.views.values_mut() { view.on_category_added(&name); } Ok(id) } pub fn category_mut(&mut self, name: &str) -> Option<&mut Category> { self.categories.get_mut(name) } pub fn category(&self, name: &str) -> Option<&Category> { self.categories.get(name) } pub fn set_cell(&mut self, key: CellKey, value: CellValue) { self.data.set(key, value); } pub fn clear_cell(&mut self, key: &CellKey) { self.data.remove(key); } pub fn get_cell(&self, key: &CellKey) -> Option<&CellValue> { self.data.get(key) } pub fn add_formula(&mut self, formula: Formula) { // Replace if same target within the same category if let Some(pos) = self.formulas.iter().position(|f| { f.target == formula.target && f.target_category == formula.target_category }) { self.formulas[pos] = formula; } else { self.formulas.push(formula); } } pub fn remove_formula(&mut self, target: &str, target_category: &str) { self.formulas .retain(|f| !(f.target == target && f.target_category == target_category)); } pub fn formulas(&self) -> &[Formula] { &self.formulas } pub fn active_view(&self) -> &View { self.views .get(&self.active_view) .expect("active_view always names an existing view") } pub fn active_view_mut(&mut self) -> &mut View { self.views .get_mut(&self.active_view) .expect("active_view always names an existing view") } pub fn create_view(&mut self, name: impl Into) -> &mut View { let name = name.into(); let mut view = View::new(name.clone()); // Copy category assignments from default if any for cat_name in self.categories.keys() { view.on_category_added(cat_name); } self.views.insert(name.clone(), view); self.views.get_mut(&name).unwrap() } pub fn switch_view(&mut self, name: &str) -> Result<()> { if self.views.contains_key(name) { self.active_view = name.to_string(); Ok(()) } else { Err(anyhow!("View '{name}' not found")) } } pub fn delete_view(&mut self, name: &str) -> Result<()> { if self.views.len() <= 1 { return Err(anyhow!("Cannot delete the last view")); } self.views.shift_remove(name); if self.active_view == name { self.active_view = self.views.keys().next().unwrap().clone(); } Ok(()) } /// Reset all view scroll offsets to zero. /// Call this after loading or replacing a model so stale offsets don't /// cause the grid to render an empty area. pub fn normalize_view_state(&mut self) { for view in self.views.values_mut() { view.row_offset = 0; view.col_offset = 0; } } /// Return all category names /// Names of all categories (including virtual ones). pub fn category_names(&self) -> Vec<&str> { self.categories.keys().map(|s| s.as_str()).collect() } /// Evaluate a computed value at a given key, considering formulas. /// Returns None when the cell is empty (no stored value, no applicable formula). pub fn evaluate(&self, key: &CellKey) -> Option { for formula in &self.formulas { if let Some(item_val) = key.get(&formula.target_category) { if item_val == formula.target { return self.eval_formula(formula, key); } } } self.data.get(key).cloned() } /// Evaluate a key as a numeric value, returning 0.0 for empty/non-numeric cells. pub fn evaluate_f64(&self, key: &CellKey) -> f64 { self.evaluate(key).and_then(|v| v.as_f64()).unwrap_or(0.0) } /// Evaluate a cell, aggregating over any hidden (None-axis) categories. /// When `none_cats` is empty, delegates to `evaluate`. /// Otherwise, uses `matching_cells` with the partial key and aggregates /// using the measure's agg function (default SUM). pub fn evaluate_aggregated(&self, key: &CellKey, none_cats: &[String]) -> Option { if none_cats.is_empty() { return self.evaluate(key); } // Check formulas first — they handle their own aggregation for formula in &self.formulas { if let Some(item_val) = key.get(&formula.target_category) { if item_val == formula.target { return self.eval_formula(formula, key); } } } // Aggregate raw data across all None-axis categories let values: Vec = self .data .matching_values(&key.0) .into_iter() .filter_map(|v| v.as_f64()) .collect(); if values.is_empty() { return None; } // Determine agg func from measure_agg map, defaulting to SUM let agg = key .get("Measure") .and_then(|m| self.measure_agg.get(m)) .unwrap_or(&AggFunc::Sum); let result = match agg { AggFunc::Sum => values.iter().sum(), AggFunc::Avg => values.iter().sum::() / values.len() as f64, AggFunc::Min => values.iter().cloned().reduce(f64::min)?, AggFunc::Max => values.iter().cloned().reduce(f64::max)?, AggFunc::Count => values.len() as f64, }; Some(CellValue::Number(result)) } /// Evaluate aggregated as f64, returning 0.0 for empty cells. pub fn evaluate_aggregated_f64(&self, key: &CellKey, none_cats: &[String]) -> f64 { self.evaluate_aggregated(key, none_cats) .and_then(|v| v.as_f64()) .unwrap_or(0.0) } fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option { use crate::formula::{AggFunc, Expr}; // Check WHERE filter first if let Some(filter) = &formula.filter { let matches = context .get(&filter.category) .map(|v| v == filter.item.as_str()) .unwrap_or(false); if !matches { return self.data.get(context).cloned(); } } fn find_item_category<'a>(model: &'a Model, item_name: &str) -> Option<&'a str> { for (cat_name, cat) in &model.categories { if cat.items.contains_key(item_name) { return Some(cat_name.as_str()); } } None } fn eval_expr( expr: &Expr, context: &CellKey, model: &Model, target_category: &str, ) -> Option { match expr { Expr::Number(n) => Some(*n), Expr::Ref(name) => { let cat = find_item_category(model, name)?; let new_key = context.clone().with(cat, name); model.evaluate(&new_key).and_then(|v| v.as_f64()) } Expr::BinOp(op, l, r) => { use crate::formula::BinOp; let lv = eval_expr(l, context, model, target_category)?; let rv = eval_expr(r, context, model, target_category)?; Some(match op { BinOp::Add => lv + rv, BinOp::Sub => lv - rv, BinOp::Mul => lv * rv, BinOp::Div => { if rv == 0.0 { return None; } lv / rv } BinOp::Pow => lv.powf(rv), // Comparison operators are handled by eval_bool; reaching // here means a comparison was used where a number is expected. BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => { return None } }) } Expr::UnaryMinus(e) => Some(-eval_expr(e, context, model, target_category)?), Expr::Agg(func, inner, agg_filter) => { let mut partial = context.without(target_category); if let Expr::Ref(item_name) = inner.as_ref() { if let Some(cat) = find_item_category(model, item_name) { partial = partial.with(cat, item_name.as_str()); } } if let Some(f) = agg_filter { partial = partial.with(&f.category, &f.item); } let values: Vec = model .data .matching_values(&partial.0) .into_iter() .filter_map(|v| v.as_f64()) .collect(); match func { AggFunc::Sum => Some(values.iter().sum()), AggFunc::Avg => { if values.is_empty() { None } else { Some(values.iter().sum::() / values.len() as f64) } } AggFunc::Min => values.iter().cloned().reduce(f64::min), AggFunc::Max => values.iter().cloned().reduce(f64::max), AggFunc::Count => Some(values.len() as f64), } } Expr::If(cond, then, else_) => { let cv = eval_bool(cond, context, model, target_category)?; if cv { eval_expr(then, context, model, target_category) } else { eval_expr(else_, context, model, target_category) } } } } fn eval_bool( expr: &Expr, context: &CellKey, model: &Model, target_category: &str, ) -> Option { use crate::formula::BinOp; match expr { Expr::BinOp(op, l, r) => { let lv = eval_expr(l, context, model, target_category)?; let rv = eval_expr(r, context, model, target_category)?; Some(match op { BinOp::Eq => (lv - rv).abs() < 1e-10, BinOp::Ne => (lv - rv).abs() >= 1e-10, BinOp::Lt => lv < rv, BinOp::Gt => lv > rv, BinOp::Le => lv <= rv, BinOp::Ge => lv >= rv, // Arithmetic operators are not comparisons BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Pow => { return None } }) } _ => None, } } eval_expr(&formula.expr, context, self, &formula.target_category).map(CellValue::Number) } } #[cfg(test)] mod model_tests { use super::Model; use crate::model::cell::{CellKey, CellValue}; use crate::view::Axis; fn coord(pairs: &[(&str, &str)]) -> CellKey { CellKey::new( pairs .iter() .map(|(c, i)| (c.to_string(), i.to_string())) .collect(), ) } #[test] fn new_model_has_default_view() { let m = Model::new("Test"); // active_view() panics if missing; this test just ensures it doesn't panic let _ = m.active_view(); } #[test] fn add_category_creates_entry() { let mut m = Model::new("Test"); m.add_category("Region").unwrap(); assert!(m.category("Region").is_some()); } #[test] fn add_category_duplicate_is_idempotent() { let mut m = Model::new("Test"); let id1 = m.add_category("Region").unwrap(); let id2 = m.add_category("Region").unwrap(); assert_eq!(id1, id2); // Region + 2 virtuals (_Index, _Dim) assert_eq!(m.category_names().len(), 3); } #[test] fn add_category_max_limit() { let mut m = Model::new("Test"); for i in 0..12 { m.add_category(format!("Cat{i}")).unwrap(); } assert!(m.add_category("TooMany").is_err()); } #[test] fn add_category_notifies_existing_views() { let mut m = Model::new("Test"); m.add_category("Region").unwrap(); // axis_of panics for unknown categories; not panicking here confirms it was registered let _ = m.active_view().axis_of("Region"); } #[test] fn set_and_get_cell_roundtrip() { let mut m = Model::new("Test"); m.add_category("Region").unwrap(); m.add_category("Measure").unwrap(); let k = coord(&[("Region", "East"), ("Measure", "Revenue")]); m.set_cell(k.clone(), CellValue::Number(500.0)); assert_eq!(m.get_cell(&k), Some(&CellValue::Number(500.0))); } #[test] fn get_unset_cell_returns_empty() { let m = Model::new("Test"); let k = coord(&[("Region", "East")]); assert_eq!(m.get_cell(&k), None); } #[test] fn overwrite_cell() { let mut m = Model::new("Test"); let k = coord(&[("Region", "East")]); m.set_cell(k.clone(), CellValue::Number(1.0)); m.set_cell(k.clone(), CellValue::Number(2.0)); assert_eq!(m.get_cell(&k), Some(&CellValue::Number(2.0))); } #[test] fn three_category_model_independent_cells() { let mut m = Model::new("Test"); m.add_category("Region").unwrap(); m.add_category("Product").unwrap(); m.add_category("Measure").unwrap(); let k1 = coord(&[ ("Region", "East"), ("Product", "Shirts"), ("Measure", "Revenue"), ]); let k2 = coord(&[ ("Region", "West"), ("Product", "Shirts"), ("Measure", "Revenue"), ]); let k3 = coord(&[ ("Region", "East"), ("Product", "Pants"), ("Measure", "Revenue"), ]); let k4 = coord(&[ ("Region", "East"), ("Product", "Shirts"), ("Measure", "Cost"), ]); m.set_cell(k1.clone(), CellValue::Number(100.0)); m.set_cell(k2.clone(), CellValue::Number(200.0)); m.set_cell(k3.clone(), CellValue::Number(300.0)); m.set_cell(k4.clone(), CellValue::Number(40.0)); assert_eq!(m.get_cell(&k1), Some(&CellValue::Number(100.0))); assert_eq!(m.get_cell(&k2), Some(&CellValue::Number(200.0))); assert_eq!(m.get_cell(&k3), Some(&CellValue::Number(300.0))); assert_eq!(m.get_cell(&k4), Some(&CellValue::Number(40.0))); } #[test] fn create_view_copies_category_structure() { let mut m = Model::new("Test"); m.add_category("Region").unwrap(); m.add_category("Product").unwrap(); m.create_view("Secondary"); let v = m.views.get("Secondary").unwrap(); // axis_of panics for unknown categories; not panicking confirms categories were registered let _ = v.axis_of("Region"); let _ = v.axis_of("Product"); } #[test] fn switch_view_changes_active_view() { let mut m = Model::new("Test"); m.create_view("Other"); m.switch_view("Other").unwrap(); assert_eq!(m.active_view, "Other"); } #[test] fn switch_view_unknown_returns_error() { let mut m = Model::new("Test"); assert!(m.switch_view("NoSuchView").is_err()); } #[test] fn delete_view_removes_it() { let mut m = Model::new("Test"); m.create_view("Extra"); m.delete_view("Extra").unwrap(); assert!(!m.views.contains_key("Extra")); } #[test] fn delete_last_view_returns_error() { let mut m = Model::new("Test"); assert!(m.delete_view("Default").is_err()); } #[test] fn delete_active_view_switches_to_another() { let mut m = Model::new("Test"); m.create_view("Other"); m.switch_view("Other").unwrap(); m.delete_view("Other").unwrap(); assert_ne!(m.active_view, "Other"); } #[test] fn first_category_goes_to_row_second_to_column_rest_to_page() { let mut m = Model::new("Test"); m.add_category("Region").unwrap(); m.add_category("Product").unwrap(); m.add_category("Time").unwrap(); let v = m.active_view(); assert_eq!(v.axis_of("Region"), Axis::Row); assert_eq!(v.axis_of("Product"), Axis::Column); assert_eq!(v.axis_of("Time"), Axis::Page); } #[test] fn data_is_shared_across_views() { let mut m = Model::new("Test"); m.create_view("Second"); let k = coord(&[("Region", "East")]); m.set_cell(k.clone(), CellValue::Number(77.0)); assert_eq!(m.get_cell(&k), Some(&CellValue::Number(77.0))); } #[test] fn evaluate_aggregated_sums_over_hidden_dimension() { let mut m = Model::new("Test"); m.add_category("Payee").unwrap(); m.add_category("Date").unwrap(); m.add_category("Measure").unwrap(); m.category_mut("Payee").unwrap().add_item("Acme"); m.category_mut("Date").unwrap().add_item("Jan-01"); m.category_mut("Date").unwrap().add_item("Jan-02"); m.category_mut("Measure").unwrap().add_item("Amount"); m.set_cell( coord(&[("Payee", "Acme"), ("Date", "Jan-01"), ("Measure", "Amount")]), CellValue::Number(100.0), ); m.set_cell( coord(&[("Payee", "Acme"), ("Date", "Jan-02"), ("Measure", "Amount")]), CellValue::Number(50.0), ); // Without hidden dims, returns None for partial key let partial_key = coord(&[("Payee", "Acme"), ("Measure", "Amount")]); assert_eq!(m.evaluate(&partial_key), None); // With Date as hidden dimension, aggregates to SUM let none_cats = vec!["Date".to_string()]; let result = m.evaluate_aggregated(&partial_key, &none_cats); assert_eq!(result, Some(CellValue::Number(150.0))); } #[test] fn evaluate_aggregated_no_hidden_delegates_to_evaluate() { let mut m = Model::new("Test"); m.add_category("Region").unwrap(); m.category_mut("Region").unwrap().add_item("East"); m.set_cell(coord(&[("Region", "East")]), CellValue::Number(42.0)); let key = coord(&[("Region", "East")]); assert_eq!( m.evaluate_aggregated(&key, &[]), Some(CellValue::Number(42.0)) ); } #[test] fn evaluate_aggregated_respects_measure_agg() { use crate::formula::AggFunc; let mut m = Model::new("Test"); m.add_category("Payee").unwrap(); m.add_category("Date").unwrap(); m.add_category("Measure").unwrap(); m.category_mut("Payee").unwrap().add_item("Acme"); m.category_mut("Date").unwrap().add_item("D1"); m.category_mut("Date").unwrap().add_item("D2"); m.category_mut("Measure").unwrap().add_item("Price"); m.set_cell( coord(&[("Payee", "Acme"), ("Date", "D1"), ("Measure", "Price")]), CellValue::Number(10.0), ); m.set_cell( coord(&[("Payee", "Acme"), ("Date", "D2"), ("Measure", "Price")]), CellValue::Number(30.0), ); m.measure_agg.insert("Price".to_string(), AggFunc::Avg); let key = coord(&[("Payee", "Acme"), ("Measure", "Price")]); let none_cats = vec!["Date".to_string()]; let result = m.evaluate_aggregated(&key, &none_cats); assert_eq!(result, Some(CellValue::Number(20.0))); // avg(10, 30) = 20 } } #[cfg(test)] mod formula_tests { use super::Model; use crate::formula::parse_formula; use crate::model::cell::{CellKey, CellValue}; fn coord(pairs: &[(&str, &str)]) -> CellKey { CellKey::new( pairs .iter() .map(|(c, i)| (c.to_string(), i.to_string())) .collect(), ) } fn approx_eq(a: f64, b: f64) -> bool { (a - b).abs() < 1e-9 } fn revenue_cost_model() -> Model { let mut m = Model::new("Test"); m.add_category("Measure").unwrap(); m.add_category("Region").unwrap(); if let Some(cat) = m.category_mut("Measure") { cat.add_item("Revenue"); cat.add_item("Cost"); cat.add_item("Profit"); } if let Some(cat) = m.category_mut("Region") { cat.add_item("East"); cat.add_item("West"); } m.set_cell( coord(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(1000.0), ); m.set_cell( coord(&[("Measure", "Cost"), ("Region", "East")]), CellValue::Number(600.0), ); m.set_cell( coord(&[("Measure", "Revenue"), ("Region", "West")]), CellValue::Number(800.0), ); m.set_cell( coord(&[("Measure", "Cost"), ("Region", "West")]), CellValue::Number(500.0), ); m } #[test] fn profit_equals_revenue_minus_cost() { let mut m = revenue_cost_model(); m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap()); let k = coord(&[("Measure", "Profit"), ("Region", "East")]); assert_eq!(m.evaluate(&k), Some(CellValue::Number(400.0))); } #[test] fn formula_evaluates_per_region() { let mut m = revenue_cost_model(); m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap()); let east = m.evaluate(&coord(&[("Measure", "Profit"), ("Region", "East")])); let west = m.evaluate(&coord(&[("Measure", "Profit"), ("Region", "West")])); assert_eq!(east, Some(CellValue::Number(400.0))); assert_eq!(west, Some(CellValue::Number(300.0))); } #[test] fn formula_multiplication() { let mut m = revenue_cost_model(); m.add_formula(parse_formula("Tax = Revenue * 0.1", "Measure").unwrap()); if let Some(cat) = m.category_mut("Measure") { cat.add_item("Tax"); } let val = m .evaluate(&coord(&[("Measure", "Tax"), ("Region", "East")])) .and_then(|v| v.as_f64()) .unwrap(); assert!(approx_eq(val, 100.0)); } #[test] fn formula_division() { let mut m = revenue_cost_model(); m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap()); m.add_formula(parse_formula("Margin = Profit / Revenue", "Measure").unwrap()); if let Some(cat) = m.category_mut("Measure") { cat.add_item("Profit"); cat.add_item("Margin"); } let val = m .evaluate(&coord(&[("Measure", "Margin"), ("Region", "East")])) .and_then(|v| v.as_f64()) .unwrap(); assert!(approx_eq(val, 0.4)); } #[test] fn division_by_zero_yields_empty() { let mut m = Model::new("Test"); m.add_category("Measure").unwrap(); m.add_category("Region").unwrap(); if let Some(cat) = m.category_mut("Measure") { cat.add_item("Revenue"); cat.add_item("Zero"); cat.add_item("Result"); } m.set_cell( coord(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(100.0), ); m.set_cell( coord(&[("Measure", "Zero"), ("Region", "East")]), CellValue::Number(0.0), ); m.add_formula(parse_formula("Result = Revenue / Zero", "Measure").unwrap()); // Division by zero must yield Empty, not 0, so the user sees a blank not a misleading zero. assert_eq!( m.evaluate(&coord(&[("Measure", "Result"), ("Region", "East")])), None ); } #[test] fn unary_minus() { let mut m = revenue_cost_model(); m.add_formula(parse_formula("NegRevenue = -Revenue", "Measure").unwrap()); if let Some(cat) = m.category_mut("Measure") { cat.add_item("NegRevenue"); } let k = coord(&[("Measure", "NegRevenue"), ("Region", "East")]); assert_eq!(m.evaluate(&k), Some(CellValue::Number(-1000.0))); } #[test] fn power_operator() { let mut m = Model::new("Test"); m.add_category("Measure").unwrap(); if let Some(cat) = m.category_mut("Measure") { cat.add_item("Base"); cat.add_item("Squared"); } m.set_cell(coord(&[("Measure", "Base")]), CellValue::Number(4.0)); m.add_formula(parse_formula("Squared = Base ^ 2", "Measure").unwrap()); assert_eq!( m.evaluate(&coord(&[("Measure", "Squared")])), Some(CellValue::Number(16.0)) ); } #[test] fn formula_with_missing_ref_returns_empty() { let mut m = revenue_cost_model(); m.add_formula(parse_formula("Ghost = NoSuchField - Cost", "Measure").unwrap()); if let Some(cat) = m.category_mut("Measure") { cat.add_item("Ghost"); } let k = coord(&[("Measure", "Ghost"), ("Region", "East")]); assert_eq!(m.evaluate(&k), None); } #[test] fn formula_where_applied_to_matching_region() { let mut m = revenue_cost_model(); m.add_formula( parse_formula("EastOnly = Revenue WHERE Region = \"East\"", "Measure").unwrap(), ); if let Some(cat) = m.category_mut("Measure") { cat.add_item("EastOnly"); } let val = m .evaluate(&coord(&[("Measure", "EastOnly"), ("Region", "East")])) .and_then(|v| v.as_f64()) .unwrap(); assert!(approx_eq(val, 1000.0)); } #[test] fn formula_where_not_applied_to_non_matching_region() { let mut m = revenue_cost_model(); m.add_formula( parse_formula("EastOnly = Revenue WHERE Region = \"East\"", "Measure").unwrap(), ); if let Some(cat) = m.category_mut("Measure") { cat.add_item("EastOnly"); } assert_eq!( m.evaluate(&coord(&[("Measure", "EastOnly"), ("Region", "West")])), None ); } #[test] fn add_formula_replaces_same_target() { let mut m = revenue_cost_model(); m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap()); m.add_formula(parse_formula("Profit = Revenue - Cost - 100", "Measure").unwrap()); assert_eq!(m.formulas.len(), 1); let k = coord(&[("Measure", "Profit"), ("Region", "East")]); assert_eq!(m.evaluate(&k), Some(CellValue::Number(300.0))); } #[test] fn remove_formula() { let mut m = revenue_cost_model(); m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap()); m.remove_formula("Profit", "Measure"); assert!(m.formulas.is_empty()); let k = coord(&[("Measure", "Profit"), ("Region", "East")]); assert_eq!(m.evaluate(&k), None); } #[test] fn sum_aggregation_across_region() { let mut m = revenue_cost_model(); m.add_formula(parse_formula("Total = SUM(Revenue)", "Measure").unwrap()); if let Some(cat) = m.category_mut("Measure") { cat.add_item("Total"); } let val = m .evaluate(&coord(&[("Measure", "Total"), ("Region", "East")])) .and_then(|v| v.as_f64()) .unwrap(); // Revenue(East)=1000 only — Cost must not be included assert_eq!(val, 1000.0); } #[test] fn count_aggregation() { let mut m = Model::new("Test"); m.add_category("Measure").unwrap(); m.add_category("Region").unwrap(); if let Some(cat) = m.category_mut("Measure") { cat.add_item("Sales"); cat.add_item("Count"); } for region in ["East", "West", "North"] { m.set_cell( coord(&[("Measure", "Sales"), ("Region", region)]), CellValue::Number(100.0), ); } m.add_formula(parse_formula("Count = COUNT(Sales)", "Measure").unwrap()); let val = m .evaluate(&coord(&[("Measure", "Count"), ("Region", "East")])) .and_then(|v| v.as_f64()) .unwrap(); assert!(val >= 1.0); } #[test] fn if_true_branch() { let mut m = Model::new("Test"); m.add_category("Measure").unwrap(); if let Some(cat) = m.category_mut("Measure") { cat.add_item("X"); cat.add_item("Result"); } m.set_cell(coord(&[("Measure", "X")]), CellValue::Number(10.0)); m.add_formula(parse_formula("Result = IF(X > 5, 1, 0)", "Measure").unwrap()); assert_eq!( m.evaluate(&coord(&[("Measure", "Result")])), Some(CellValue::Number(1.0)) ); } #[test] fn if_false_branch() { let mut m = Model::new("Test"); m.add_category("Measure").unwrap(); if let Some(cat) = m.category_mut("Measure") { cat.add_item("X"); cat.add_item("Result"); } m.set_cell(coord(&[("Measure", "X")]), CellValue::Number(3.0)); m.add_formula(parse_formula("Result = IF(X > 5, 1, 0)", "Measure").unwrap()); assert_eq!( m.evaluate(&coord(&[("Measure", "Result")])), Some(CellValue::Number(0.0)) ); } // ── Bug regression tests ───────────────────────────────────────────────── /// Bug: WHERE filter falls through when its category is absent from the key. /// A formula `Profit = 42 WHERE Region = "East"` evaluated against a key /// with no Region coordinate should NOT apply the formula — the WHERE /// condition cannot be satisfied, so the raw cell value (Empty) must be /// returned. Currently the `if let Some(item_val)` in eval_formula fails /// to bind (category absent → None) and falls through, applying the formula /// unconditionally. #[test] fn where_filter_absent_category_does_not_apply_formula() { let mut m = Model::new("Test"); m.add_category("Measure").unwrap(); m.add_category("Region").unwrap(); if let Some(cat) = m.category_mut("Measure") { cat.add_item("Profit"); } if let Some(cat) = m.category_mut("Region") { cat.add_item("East"); } // Formula only applies WHERE Region = "East" m.add_formula(parse_formula("Profit = 42 WHERE Region = \"East\"", "Measure").unwrap()); // Key has no Region coordinate — WHERE clause cannot be satisfied let key_no_region = coord(&[("Measure", "Profit")]); // Expected: Empty (formula should not apply) // Bug: returns Number(42) — formula applied because absent category falls through assert_eq!(m.evaluate(&key_no_region), None); } /// Bug: SUM(Revenue) ignores its inner expression and sums all numeric /// cells matching the partial key, including unrelated items (e.g. Cost). /// With Revenue=100 and Cost=50 both stored for Region=East, evaluating /// `Total = SUM(Revenue)` at {Measure=Total, Region=East} should return /// 100 (only Revenue), not 150 (Revenue + Cost). #[test] fn sum_inner_expression_constrains_which_cells_are_summed() { let mut m = Model::new("Test"); m.add_category("Measure").unwrap(); m.add_category("Region").unwrap(); if let Some(cat) = m.category_mut("Measure") { cat.add_item("Revenue"); cat.add_item("Cost"); cat.add_item("Total"); } if let Some(cat) = m.category_mut("Region") { cat.add_item("East"); } m.set_cell( coord(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(100.0), ); m.set_cell( coord(&[("Measure", "Cost"), ("Region", "East")]), CellValue::Number(50.0), ); m.add_formula(parse_formula("Total = SUM(Revenue)", "Measure").unwrap()); // Expected: 100 (SUM constrainted to Revenue only) // Bug: returns 150 — inner Ref("Revenue") is ignored, Cost is also summed assert_eq!( m.evaluate(&coord(&[("Measure", "Total"), ("Region", "East")])), Some(CellValue::Number(100.0)), ); } /// Bug: add_formula deduplicates by `target` name alone, ignoring /// `target_category`. Two formulas for the same item name in different /// categories should coexist; adding the second should not silently /// replace the first. #[test] fn add_formula_same_target_name_different_category_both_coexist() { let mut m = Model::new("Test"); m.add_category("Measure").unwrap(); m.add_category("KPI").unwrap(); if let Some(c) = m.category_mut("Measure") { c.add_item("Profit"); } if let Some(c) = m.category_mut("KPI") { c.add_item("Profit"); } m.add_formula(parse_formula("Profit = 1", "Measure").unwrap()); m.add_formula(parse_formula("Profit = 2", "KPI").unwrap()); // Both formulas target different categories — they must coexist. // Bug: len == 1 because the second replaced the first. assert_eq!(m.formulas.len(), 2); } /// Consequence of the same bug: evaluating the formula that was silently /// dropped returns Empty instead of the expected computed value. #[test] fn add_formula_same_target_name_different_category_evaluates_independently() { let mut m = Model::new("Test"); m.add_category("Measure").unwrap(); m.add_category("KPI").unwrap(); if let Some(c) = m.category_mut("Measure") { c.add_item("Profit"); } if let Some(c) = m.category_mut("KPI") { c.add_item("Profit"); } m.add_formula(parse_formula("Profit = 1", "Measure").unwrap()); m.add_formula(parse_formula("Profit = 2", "KPI").unwrap()); // Measure formula → 1, KPI formula → 2 // Bug: first formula was replaced; {Measure=Profit} evaluates to Empty. assert_eq!( m.evaluate(&coord(&[("Measure", "Profit")])), Some(CellValue::Number(1.0)) ); assert_eq!( m.evaluate(&coord(&[("KPI", "Profit")])), Some(CellValue::Number(2.0)) ); } /// Bug: remove_formula matches by target name alone, so removing "Profit" /// in "Measure" also destroys the "Profit" formula in "KPI". /// After targeted removal, the other category's formula must survive. #[test] fn remove_formula_only_removes_specified_target_category() { let mut m = Model::new("Test"); m.add_category("Measure").unwrap(); m.add_category("KPI").unwrap(); if let Some(c) = m.category_mut("Measure") { c.add_item("Profit"); } if let Some(c) = m.category_mut("KPI") { c.add_item("Profit"); } m.add_formula(parse_formula("Profit = 1", "Measure").unwrap()); m.add_formula(parse_formula("Profit = 2", "KPI").unwrap()); // Remove only the Measure formula m.remove_formula("Profit", "Measure"); // KPI formula must survive // Bug: remove_formula("Profit") wipes both; formulas.len() == 0 assert_eq!(m.formulas.len(), 1); assert_eq!( m.evaluate(&coord(&[("KPI", "Profit")])), Some(CellValue::Number(2.0)) ); } } #[cfg(test)] mod five_category { use super::Model; use crate::formula::parse_formula; use crate::model::cell::{CellKey, CellValue}; use crate::view::Axis; const DATA: &[(&str, &str, &str, &str, f64, f64)] = &[ ("East", "Shirts", "Online", "Q1", 1_000.0, 600.0), ("East", "Shirts", "Online", "Q2", 1_200.0, 700.0), ("East", "Shirts", "Retail", "Q1", 800.0, 500.0), ("East", "Shirts", "Retail", "Q2", 900.0, 540.0), ("East", "Pants", "Online", "Q1", 500.0, 300.0), ("East", "Pants", "Online", "Q2", 600.0, 360.0), ("East", "Pants", "Retail", "Q1", 400.0, 240.0), ("East", "Pants", "Retail", "Q2", 450.0, 270.0), ("West", "Shirts", "Online", "Q1", 700.0, 420.0), ("West", "Shirts", "Online", "Q2", 750.0, 450.0), ("West", "Shirts", "Retail", "Q1", 600.0, 360.0), ("West", "Shirts", "Retail", "Q2", 650.0, 390.0), ("West", "Pants", "Online", "Q1", 300.0, 180.0), ("West", "Pants", "Online", "Q2", 350.0, 210.0), ("West", "Pants", "Retail", "Q1", 250.0, 150.0), ("West", "Pants", "Retail", "Q2", 280.0, 168.0), ]; fn coord(region: &str, product: &str, channel: &str, time: &str, measure: &str) -> CellKey { CellKey::new(vec![ ("Channel".to_string(), channel.to_string()), ("Measure".to_string(), measure.to_string()), ("Product".to_string(), product.to_string()), ("Region".to_string(), region.to_string()), ("Time".to_string(), time.to_string()), ]) } fn build_model() -> Model { let mut m = Model::new("Sales"); for cat in ["Region", "Product", "Channel", "Time", "Measure"] { m.add_category(cat).unwrap(); } for cat in ["Region", "Product", "Channel", "Time"] { let items: &[&str] = match cat { "Region" => &["East", "West"], "Product" => &["Shirts", "Pants"], "Channel" => &["Online", "Retail"], "Time" => &["Q1", "Q2"], _ => &[], }; if let Some(c) = m.category_mut(cat) { for &item in items { c.add_item(item); } } } if let Some(c) = m.category_mut("Measure") { for &item in &["Revenue", "Cost", "Profit", "Margin", "Total"] { c.add_item(item); } } for &(region, product, channel, time, rev, cost) in DATA { m.set_cell( coord(region, product, channel, time, "Revenue"), CellValue::Number(rev), ); m.set_cell( coord(region, product, channel, time, "Cost"), CellValue::Number(cost), ); } m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap()); m.add_formula(parse_formula("Margin = Profit / Revenue", "Measure").unwrap()); m.add_formula(parse_formula("Total = SUM(Revenue)", "Measure").unwrap()); m } fn approx(a: f64, b: f64) -> bool { (a - b).abs() < 1e-9 } #[test] fn all_sixteen_revenue_cells_stored() { let m = build_model(); let count = DATA .iter() .filter(|&&(r, p, c, t, _, _)| !m.get_cell(&coord(r, p, c, t, "Revenue")).is_none()) .count(); assert_eq!(count, 16); } #[test] fn all_sixteen_cost_cells_stored() { let m = build_model(); let count = DATA .iter() .filter(|&&(r, p, c, t, _, _)| !m.get_cell(&coord(r, p, c, t, "Cost")).is_none()) .count(); assert_eq!(count, 16); } #[test] fn spot_check_raw_revenue() { let m = build_model(); assert_eq!( m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")), Some(&CellValue::Number(1_000.0)) ); assert_eq!( m.get_cell(&coord("West", "Pants", "Retail", "Q2", "Revenue")), Some(&CellValue::Number(280.0)) ); } #[test] fn distinct_cells_do_not_alias() { let m = build_model(); let a = m .get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")) .clone(); let b = m .get_cell(&coord("West", "Pants", "Retail", "Q2", "Revenue")) .clone(); assert_ne!(a, b); } #[test] fn profit_formula_correct_at_every_intersection() { let m = build_model(); for &(region, product, channel, time, rev, cost) in DATA { let expected = rev - cost; let actual = m .evaluate(&coord(region, product, channel, time, "Profit")) .and_then(|v| v.as_f64()) .unwrap_or_else(|| panic!("Profit empty at {region}/{product}/{channel}/{time}")); assert!( approx(actual, expected), "Profit at {region}/{product}/{channel}/{time}: expected {expected}, got {actual}" ); } } #[test] fn margin_formula_correct_at_every_intersection() { let m = build_model(); for &(region, product, channel, time, rev, cost) in DATA { let expected = (rev - cost) / rev; let actual = m .evaluate(&coord(region, product, channel, time, "Margin")) .and_then(|v| v.as_f64()) .unwrap_or_else(|| panic!("Margin empty at {region}/{product}/{channel}/{time}")); assert!(approx(actual, expected), "Margin at {region}/{product}/{channel}/{time}: expected {expected:.4}, got {actual:.4}"); } } #[test] fn chained_formula_profit_feeds_margin() { let m = build_model(); let margin = m .evaluate(&coord("East", "Shirts", "Online", "Q1", "Margin")) .and_then(|v| v.as_f64()) .unwrap(); assert!(approx(margin, 0.4), "expected 0.4, got {margin}"); } #[test] fn update_revenue_updates_profit_and_margin() { let mut m = build_model(); m.set_cell( coord("East", "Shirts", "Online", "Q1", "Revenue"), CellValue::Number(1_500.0), ); let profit = m .evaluate(&coord("East", "Shirts", "Online", "Q1", "Profit")) .and_then(|v| v.as_f64()) .unwrap(); assert!(approx(profit, 900.0), "expected 900, got {profit}"); let margin = m .evaluate(&coord("East", "Shirts", "Online", "Q1", "Margin")) .and_then(|v| v.as_f64()) .unwrap(); assert!(approx(margin, 0.6), "expected 0.6, got {margin}"); } #[test] fn sum_revenue_for_east_region() { let m = build_model(); let key = CellKey::new(vec![ ("Measure".to_string(), "Total".to_string()), ("Region".to_string(), "East".to_string()), ]); let total = m.evaluate(&key).and_then(|v| v.as_f64()).unwrap(); let expected: f64 = DATA .iter() .filter(|&&(r, _, _, _, _, _)| r == "East") .map(|&(_, _, _, _, rev, _)| rev) .sum(); assert!(approx(total, expected), "expected {expected}, got {total}"); } #[test] fn sum_revenue_for_online_channel() { let m = build_model(); let key = CellKey::new(vec![ ("Channel".to_string(), "Online".to_string()), ("Measure".to_string(), "Total".to_string()), ]); let total = m.evaluate(&key).and_then(|v| v.as_f64()).unwrap(); let expected: f64 = DATA .iter() .filter(|&&(_, _, ch, _, _, _)| ch == "Online") .map(|&(_, _, _, _, rev, _)| rev) .sum(); assert!(approx(total, expected), "expected {expected}, got {total}"); } #[test] fn sum_revenue_for_shirts_q1() { let m = build_model(); let key = CellKey::new(vec![ ("Measure".to_string(), "Total".to_string()), ("Product".to_string(), "Shirts".to_string()), ("Time".to_string(), "Q1".to_string()), ]); let total = m.evaluate(&key).and_then(|v| v.as_f64()).unwrap(); let expected: f64 = DATA .iter() .filter(|&&(_, p, _, t, _, _)| p == "Shirts" && t == "Q1") .map(|&(_, _, _, _, rev, _)| rev) .sum(); assert!(approx(total, expected), "expected {expected}, got {total}"); } #[test] fn sum_all_revenue_equals_grand_total() { let m = build_model(); let key = CellKey::new(vec![("Measure".to_string(), "Total".to_string())]); let total = m.evaluate(&key).and_then(|v| v.as_f64()).unwrap(); let expected: f64 = DATA.iter().map(|&(_, _, _, _, rev, _)| rev).sum(); assert!(approx(total, expected), "expected {expected}, got {total}"); } #[test] fn default_view_first_two_on_axes_rest_on_page() { let m = build_model(); let v = m.active_view(); assert_eq!(v.axis_of("Region"), Axis::Row); assert_eq!(v.axis_of("Product"), Axis::Column); assert_eq!(v.axis_of("Channel"), Axis::Page); assert_eq!(v.axis_of("Time"), Axis::Page); assert_eq!(v.axis_of("Measure"), Axis::Page); } #[test] fn rearranging_axes_does_not_affect_data() { let mut m = build_model(); { let v = m.active_view_mut(); v.set_axis("Region", Axis::Page); v.set_axis("Product", Axis::Page); v.set_axis("Channel", Axis::Row); v.set_axis("Time", Axis::Column); v.set_axis("Measure", Axis::Page); } assert_eq!( m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")), Some(&CellValue::Number(1_000.0)) ); } #[test] fn two_views_have_independent_axis_assignments() { let mut m = build_model(); m.create_view("Pivot"); { let v = m.views.get_mut("Pivot").unwrap(); v.set_axis("Time", Axis::Row); v.set_axis("Channel", Axis::Column); v.set_axis("Region", Axis::Page); v.set_axis("Product", Axis::Page); v.set_axis("Measure", Axis::Page); } assert_eq!(m.views.get("Default").unwrap().axis_of("Region"), Axis::Row); assert_eq!(m.views.get("Pivot").unwrap().axis_of("Time"), Axis::Row); assert_eq!( m.views.get("Pivot").unwrap().axis_of("Channel"), Axis::Column ); } #[test] fn page_selections_are_per_view() { let mut m = build_model(); m.create_view("West only"); if let Some(v) = m.views.get_mut("West only") { v.set_page_selection("Region", "West"); } assert_eq!( m.views.get("Default").unwrap().page_selection("Region"), None ); assert_eq!( m.views.get("West only").unwrap().page_selection("Region"), Some("West") ); } #[test] fn five_categories_well_within_limit() { let m = build_model(); // 5 regular + 2 virtual (_Index, _Dim) assert_eq!(m.category_names().len(), 7); let mut m2 = build_model(); for i in 0..7 { m2.add_category(format!("Extra{i}")).unwrap(); } // 12 regular + 2 virtuals = 14 assert_eq!(m2.category_names().len(), 14); assert!(m2.add_category("OneMore").is_err()); } } #[cfg(test)] mod prop_tests { use super::Model; use crate::formula::parse_formula; use crate::model::cell::{CellKey, CellValue}; use proptest::prelude::*; fn finite_f64() -> impl Strategy { prop::num::f64::NORMAL.prop_filter("finite", |f| f.is_finite()) } proptest! { /// evaluate() on a plain cell (no formula) returns exactly what was stored. #[test] fn evaluate_returns_stored_value_when_no_formula_applies( item_a in "[a-z]{1,6}", item_b in "[a-z]{1,6}", val in finite_f64(), ) { let mut m = Model::new("T"); m.add_category("Cat").unwrap(); if let Some(c) = m.category_mut("Cat") { c.add_item(&item_a); c.add_item(&item_b); } let key = CellKey::new(vec![("Cat".into(), item_a.clone())]); m.set_cell(key.clone(), CellValue::Number(val)); prop_assert_eq!(m.evaluate(&key), Some(CellValue::Number(val))); } /// Writing to cell A does not change cell B when the keys differ. #[test] fn cells_with_different_items_are_independent( item_a in "[a-z]{1,5}", item_b in "[g-z]{1,5}", val_a in finite_f64(), val_b in finite_f64(), ) { prop_assume!(item_a != item_b); let mut m = Model::new("T"); m.add_category("Cat").unwrap(); if let Some(c) = m.category_mut("Cat") { c.add_item(&item_a); c.add_item(&item_b); } let key_a = CellKey::new(vec![("Cat".into(), item_a)]); let key_b = CellKey::new(vec![("Cat".into(), item_b)]); m.set_cell(key_a.clone(), CellValue::Number(val_a)); m.set_cell(key_b.clone(), CellValue::Number(val_b)); prop_assert_eq!(m.evaluate(&key_a), Some(CellValue::Number(val_a))); prop_assert_eq!(m.evaluate(&key_b), Some(CellValue::Number(val_b))); } /// Adding a category does not overwrite previously stored cell values. #[test] fn adding_category_preserves_existing_cells( val in finite_f64(), new_cat in "[g-z]{1,6}", ) { let mut m = Model::new("T"); m.add_category("Measure").unwrap(); if let Some(c) = m.category_mut("Measure") { c.add_item("Revenue"); } let key = CellKey::new(vec![("Measure".into(), "Revenue".into())]); m.set_cell(key.clone(), CellValue::Number(val)); let _ = m.add_category(&new_cat); // may succeed or hit the 12-cat limit prop_assert_eq!(m.evaluate(&key), Some(CellValue::Number(val))); } /// evaluate() is deterministic: calling it twice on the same key and model /// yields the same result. #[test] fn evaluate_is_deterministic(val_rev in finite_f64(), val_cost in finite_f64()) { let mut m = Model::new("T"); m.add_category("Measure").unwrap(); m.add_category("Region").unwrap(); if let Some(c) = m.category_mut("Measure") { c.add_item("Revenue"); c.add_item("Cost"); c.add_item("Profit"); } if let Some(c) = m.category_mut("Region") { c.add_item("East"); } m.set_cell( CellKey::new(vec![("Measure".into(),"Revenue".into()),("Region".into(),"East".into())]), CellValue::Number(val_rev), ); m.set_cell( CellKey::new(vec![("Measure".into(),"Cost".into()),("Region".into(),"East".into())]), CellValue::Number(val_cost), ); m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap()); let key = CellKey::new(vec![ ("Measure".into(), "Profit".into()), ("Region".into(), "East".into()), ]); prop_assert_eq!(m.evaluate(&key), m.evaluate(&key)); } /// Formula result equals Revenue − Cost for arbitrary finite inputs. #[test] fn formula_arithmetic_correct( rev in finite_f64(), cost in finite_f64(), ) { let mut m = Model::new("T"); m.add_category("Measure").unwrap(); m.add_category("Region").unwrap(); if let Some(c) = m.category_mut("Measure") { c.add_item("Revenue"); c.add_item("Cost"); c.add_item("Profit"); } if let Some(c) = m.category_mut("Region") { c.add_item("East"); } let rev_key = CellKey::new(vec![("Measure".into(),"Revenue".into()),("Region".into(),"East".into())]); let cost_key = CellKey::new(vec![("Measure".into(),"Cost".into()),("Region".into(),"East".into())]); let profit_key = CellKey::new(vec![("Measure".into(),"Profit".into()),("Region".into(),"East".into())]); m.set_cell(rev_key, CellValue::Number(rev)); m.set_cell(cost_key, CellValue::Number(cost)); m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap()); let expected = rev - cost; prop_assert_eq!(m.evaluate(&profit_key), Some(CellValue::Number(expected))); } /// Removing a formula restores the raw stored value (or Empty). #[test] fn removing_formula_restores_raw_value(rev in finite_f64(), cost in finite_f64()) { let mut m = Model::new("T"); m.add_category("Measure").unwrap(); m.add_category("Region").unwrap(); if let Some(c) = m.category_mut("Measure") { c.add_item("Revenue"); c.add_item("Cost"); c.add_item("Profit"); } if let Some(c) = m.category_mut("Region") { c.add_item("East"); } let profit_key = CellKey::new(vec![ ("Measure".into(),"Profit".into()),("Region".into(),"East".into()), ]); m.set_cell( CellKey::new(vec![("Measure".into(),"Revenue".into()),("Region".into(),"East".into())]), CellValue::Number(rev), ); m.set_cell( CellKey::new(vec![("Measure".into(),"Cost".into()),("Region".into(),"East".into())]), CellValue::Number(cost), ); m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap()); // Formula active — result is rev - cost let with_formula = m.evaluate(&profit_key); prop_assert_eq!(with_formula, Some(CellValue::Number(rev - cost))); // Remove formula — cell has no raw value, so Empty m.remove_formula("Profit", "Measure"); prop_assert_eq!(m.evaluate(&profit_key), None); } } }