use std::collections::HashMap; use anyhow::{Result, anyhow}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use super::category::{Category, CategoryId}; use super::cell::{CellKey, CellValue, DataStore}; use crate::formula::{AggFunc, Formula}; const MAX_CATEGORIES: usize = 12; /// Pure-data document model: categories, cells, and formulas. /// /// `Model` intentionally does **not** know about views. The view axes and /// per-view state live in [`crate::workbook::Workbook`], which wraps a /// `Model` with the view ensemble. Cross-slice operations — adding a /// category and registering it on every view, for example — are therefore /// methods on `Workbook`, not `Model`. This breaks the former `Model ↔ View` /// cycle so the `model/` and `view/` modules can be lifted into a shared /// `improvise-core` crate without pulling view code into pure data types. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Model { pub name: String, pub categories: IndexMap, pub data: DataStore, formulas: Vec, 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, /// Cached formula evaluation results. Recomputed by `recompute_formulas`. #[serde(skip)] formula_cache: HashMap, } impl Model { pub fn new(name: impl Into) -> Self { use crate::model::category::CategoryKind; let name = name.into(); let mut categories = IndexMap::new(); // Virtual categories — always present. 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), ); categories.insert( "_Measure".to_string(), Category::new(2, "_Measure").with_kind(CategoryKind::VirtualMeasure), ); Self { name, categories, data: DataStore::new(), formulas: Vec::new(), next_category_id: 3, measure_agg: HashMap::new(), formula_cache: HashMap::new(), } } /// Add a pivot category. Enforces the `MAX_CATEGORIES` limit for regular /// categories. The caller (typically [`crate::workbook::Workbook`]) is /// responsible for registering the new category on every view. pub fn add_category(&mut self, name: impl Into) -> Result { let name = name.into(); // Only regular pivot categories count against the limit. let regular_count = self .categories .values() .filter(|c| c.kind.is_regular()) .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())); Ok(id) } /// Add a Label-kind category: stored alongside regular categories so /// records views can display it, but excluded from the pivot-category /// count limit. The caller is responsible for setting the view axis /// (typically to `Axis::None`). pub fn add_label_category(&mut self, name: impl Into) -> Result { use crate::model::category::CategoryKind; let name = name.into(); if self.categories.contains_key(&name) { return Ok(self.categories[&name].id); } let id = self.next_category_id; self.next_category_id += 1; let cat = Category::new(id, name.clone()).with_kind(CategoryKind::Label); self.categories.insert(name.clone(), cat); Ok(id) } /// Remove a category and all cells that reference it. The caller is /// responsible for removing the category from any views that referenced /// it. pub fn remove_category(&mut self, name: &str) { if !self.categories.contains_key(name) { return; } self.categories.shift_remove(name); // Remove cells that have a coord in this category let to_remove: Vec = self .data .iter_cells() .filter(|(k, _)| k.get(name).is_some()) .map(|(k, _)| k) .collect(); for k in to_remove { self.data.remove(&k); } // Remove formulas targeting this category self.formulas.retain(|f| f.target_category != name); } /// Remove an item from a category and all cells that reference it. pub fn remove_item(&mut self, cat_name: &str, item_name: &str) { if let Some(cat) = self.categories.get_mut(cat_name) { cat.remove_item(item_name); } let to_remove: Vec = self .data .iter_cells() .filter(|(k, _)| k.get(cat_name) == Some(item_name)) .map(|(k, _)| k) .collect(); for k in to_remove { self.data.remove(&k); } } 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) { // For non-_Measure target categories, add the target as a category item // so it appears in the grid. _Measure targets are dynamically included // via measure_item_names(). if formula.target_category != "_Measure" && let Some(cat) = self.categories.get_mut(&formula.target_category) { cat.add_item(&formula.target); } // 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 } /// 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() } /// Effective item names for the _Measure category: the union of items /// explicitly in the category plus all formula targets (for formulas /// targeting _Measure). Preserves insertion order of explicit items, /// then appends formula targets not already present. pub fn measure_item_names(&self) -> Vec { let mut names: Vec = self .category("_Measure") .map(|c| { c.ordered_item_names() .iter() .map(|s| s.to_string()) .collect() }) .unwrap_or_default(); for f in &self.formulas { if f.target_category == "_Measure" && !names.iter().any(|n| n == &f.target) { names.push(f.target.clone()); } } names } /// Effective item names for any category. For _Measure, this includes /// formula targets dynamically. For all others, delegates to /// `ordered_item_names()`. pub fn effective_item_names(&self, cat_name: &str) -> Vec { if cat_name == "_Measure" { self.measure_item_names() } else { self.category(cat_name) .map(|c| { c.ordered_item_names() .iter() .map(|s| s.to_string()) .collect() }) .unwrap_or_default() } } /// Category names excluding virtual categories (_Index, _Dim). pub fn regular_category_names(&self) -> Vec<&str> { self.categories .iter() .filter(|(_, c)| c.kind.is_regular()) .map(|(name, _)| name.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). /// Maximum formula evaluation depth. Circular references return None /// instead of stack-overflowing. const MAX_EVAL_DEPTH: u8 = 16; pub fn evaluate(&self, key: &CellKey) -> Option { self.evaluate_depth(key, Self::MAX_EVAL_DEPTH) } fn evaluate_depth(&self, key: &CellKey, depth: u8) -> Option { if depth == 0 { return Some(CellValue::Error("circular".into())); } for formula in &self.formulas { if let Some(item_val) = key.get(&formula.target_category) && item_val == formula.target { return self.eval_formula_depth(formula, key, depth - 1); } } self.data.get(key).cloned() } /// 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 formula cache first if let Some(cached) = self.formula_cache.get(key) { return Some(cached.clone()); } // 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) } //pub fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option { // self.eval_formula_depth(formula, context, Self::MAX_EVAL_DEPTH) //} /// Recompute all formula cells until values stabilize (fixed point). /// Call this before rendering or exporting — it populates `formula_cache` /// so that `evaluate_aggregated` can look up formula results without /// recursive evaluation. pub fn recompute_formulas(&mut self, none_cats: &[String]) { self.formula_cache.clear(); if self.formulas.is_empty() { return; } // Collect all unique coordinate "stems" from raw data by stripping // the formula's target category. Each stem × formula target = one // cell to evaluate. let formula_cats: Vec<(String, String)> = self .formulas .iter() .map(|f| (f.target_category.clone(), f.target.clone())) .collect(); // Gather all unique partial keys (stems) for each formula category let mut stems: Vec = Vec::new(); for (target_cat, _) in &formula_cats { for (key, _) in self.data.iter_cells() { let stem = key.without(target_cat); // Also strip none_cats from the stem since the grid queries // without those coordinates let mut stripped = stem; for nc in none_cats { stripped = stripped.without(nc); } if !stems.contains(&stripped) { stems.push(stripped); } } } // Fixed-point iteration for _pass in 0..Self::MAX_EVAL_DEPTH { let mut changed = false; for (target_cat, target_name) in &formula_cats { for stem in &stems { let key = stem.clone().with(target_cat, target_name); // Evaluate this formula cell using current state // (raw data aggregation + existing cache values) let new_val = self.evaluate_formula_cell(&key, none_cats); let old_val = self.formula_cache.get(&key); if old_val != new_val.as_ref() { changed = true; if let Some(v) = new_val { self.formula_cache.insert(key, v); } } } } if !changed { break; } } } /// Evaluate a single formula cell for the fixed-point pass. /// Uses raw data aggregation for non-formula refs and the cache for formula refs. fn evaluate_formula_cell(&self, key: &CellKey, none_cats: &[String]) -> Option { for formula in &self.formulas { if let Some(item_val) = key.get(&formula.target_category) && item_val == formula.target { return self.eval_formula_with_cache(formula, key, none_cats); } } None } /// Evaluate a formula using the formula_cache for ref resolution /// and raw data aggregation for non-formula values. fn eval_formula_with_cache( &self, formula: &Formula, context: &CellKey, none_cats: &[String], ) -> Option { use crate::formula::Expr; if let Some(filter) = &formula.filter { let matches = context .get(&filter.category) .map(|v| v == filter.item.as_str()) .unwrap_or(false); if !matches { // Fall back to aggregated raw data return self.aggregate_raw(context, none_cats); } } 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()); } } // Fall back to formula targets: if a formula defines this item // in _Measure, resolve it there. for f in model.formulas() { if f.target == item_name && f.target_category == "_Measure" { return Some("_Measure"); } } None } fn eval_expr_cached( expr: &Expr, context: &CellKey, model: &Model, target_category: &str, none_cats: &[String], ) -> Result { use crate::formula::BinOp; match expr { Expr::Number(n) => Ok(*n), Expr::Ref(name) => { let cat = find_item_category(model, name).ok_or_else(|| format!("ref:{name}"))?; let ref_key = context.clone().with(cat, name); // Check formula cache first, then aggregate raw data if let Some(cached) = model.formula_cache.get(&ref_key) { return match cached { CellValue::Number(n) => Ok(*n), CellValue::Error(e) => Err(e.clone()), _ => Err(format!("ref:{name}")), }; } // Not a formula result — aggregate raw data match model.aggregate_raw(&ref_key, none_cats) { Some(CellValue::Number(n)) => Ok(n), Some(CellValue::Error(e)) => Err(e), _ => Err(format!("ref:{name}")), } } Expr::BinOp(op, l, r) => { let lv = eval_expr_cached(l, context, model, target_category, none_cats)?; let rv = eval_expr_cached(r, context, model, target_category, none_cats)?; match op { BinOp::Add => Ok(lv + rv), BinOp::Sub => Ok(lv - rv), BinOp::Mul => Ok(lv * rv), BinOp::Div => { if rv == 0.0 { Err("div/0".into()) } else { Ok(lv / rv) } } BinOp::Pow => Ok(lv.powf(rv)), BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => { Err("type".into()) } } } Expr::UnaryMinus(e) => Ok(-eval_expr_cached( e, context, model, target_category, none_cats, )?), Expr::Agg(func, inner, agg_filter) => { use crate::formula::AggFunc; let mut partial = context.without(target_category); if let Expr::Ref(item_name) = inner.as_ref() && 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 => Ok(values.iter().sum()), AggFunc::Avg => { if values.is_empty() { Err("empty".into()) } else { Ok(values.iter().sum::() / values.len() as f64) } } AggFunc::Min => values .iter() .cloned() .reduce(f64::min) .ok_or_else(|| "empty".into()), AggFunc::Max => values .iter() .cloned() .reduce(f64::max) .ok_or_else(|| "empty".into()), AggFunc::Count => Ok(values.len() as f64), } } Expr::If(cond, then, else_) => { let cv = eval_bool_cached(cond, context, model, target_category, none_cats)?; if cv { eval_expr_cached(then, context, model, target_category, none_cats) } else { eval_expr_cached(else_, context, model, target_category, none_cats) } } } } fn eval_bool_cached( expr: &Expr, context: &CellKey, model: &Model, target_category: &str, none_cats: &[String], ) -> Result { use crate::formula::BinOp; match expr { Expr::BinOp(op, l, r) => { let lv = eval_expr_cached(l, context, model, target_category, none_cats)?; let rv = eval_expr_cached(r, context, model, target_category, none_cats)?; match op { BinOp::Eq => Ok((lv - rv).abs() < 1e-10), BinOp::Ne => Ok((lv - rv).abs() >= 1e-10), BinOp::Lt => Ok(lv < rv), BinOp::Gt => Ok(lv > rv), BinOp::Le => Ok(lv <= rv), BinOp::Ge => Ok(lv >= rv), _ => Err("type".into()), } } _ => Err("type".into()), } } match eval_expr_cached( &formula.expr, context, self, &formula.target_category, none_cats, ) { Ok(n) => Some(CellValue::Number(n)), Err(e) => Some(CellValue::Error(e)), } } /// Aggregate raw data (not formulas) over hidden dimensions. fn aggregate_raw(&self, key: &CellKey, none_cats: &[String]) -> Option { if none_cats.is_empty() { return self.data.get(key).cloned(); } let values: Vec = self .data .matching_values(&key.0) .into_iter() .filter_map(|v| v.as_f64()) .collect(); if values.is_empty() { return None; } 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)) } fn eval_formula_depth( &self, formula: &Formula, context: &CellKey, depth: u8, ) -> 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()); } } // Fall back to formula targets: if a formula defines this item // in _Measure, resolve it there. for f in model.formulas() { if f.target == item_name && f.target_category == "_Measure" { return Some("_Measure"); } } None } /// Evaluate an expression, returning Ok(f64) or Err(reason). /// Errors propagate immediately — a circular reference in any /// sub-expression short-circuits the entire formula. fn eval_expr( expr: &Expr, context: &CellKey, model: &Model, target_category: &str, depth: u8, ) -> Result { match expr { Expr::Number(n) => Ok(*n), Expr::Ref(name) => { let cat = find_item_category(model, name).ok_or_else(|| format!("ref:{name}"))?; let new_key = context.clone().with(cat, name); match model.evaluate_depth(&new_key, depth) { Some(CellValue::Number(n)) => Ok(n), Some(CellValue::Error(e)) => Err(e), _ => Err(format!("ref:{name}")), } } Expr::BinOp(op, l, r) => { use crate::formula::BinOp; let lv = eval_expr(l, context, model, target_category, depth)?; let rv = eval_expr(r, context, model, target_category, depth)?; match op { BinOp::Add => Ok(lv + rv), BinOp::Sub => Ok(lv - rv), BinOp::Mul => Ok(lv * rv), BinOp::Div => { if rv == 0.0 { Err("div/0".into()) } else { Ok(lv / rv) } } BinOp::Pow => Ok(lv.powf(rv)), BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => { Err("type".into()) } } } Expr::UnaryMinus(e) => Ok(-eval_expr(e, context, model, target_category, depth)?), Expr::Agg(func, inner, agg_filter) => { let mut partial = context.without(target_category); if let Expr::Ref(item_name) = inner.as_ref() && 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 => Ok(values.iter().sum()), AggFunc::Avg => { if values.is_empty() { Err("empty".into()) } else { Ok(values.iter().sum::() / values.len() as f64) } } AggFunc::Min => values .iter() .cloned() .reduce(f64::min) .ok_or_else(|| "empty".into()), AggFunc::Max => values .iter() .cloned() .reduce(f64::max) .ok_or_else(|| "empty".into()), AggFunc::Count => Ok(values.len() as f64), } } Expr::If(cond, then, else_) => { let cv = eval_bool(cond, context, model, target_category, depth)?; if cv { eval_expr(then, context, model, target_category, depth) } else { eval_expr(else_, context, model, target_category, depth) } } } } fn eval_bool( expr: &Expr, context: &CellKey, model: &Model, target_category: &str, depth: u8, ) -> Result { use crate::formula::BinOp; match expr { Expr::BinOp(op, l, r) => { let lv = eval_expr(l, context, model, target_category, depth)?; let rv = eval_expr(r, context, model, target_category, depth)?; match op { BinOp::Eq => Ok((lv - rv).abs() < 1e-10), BinOp::Ne => Ok((lv - rv).abs() >= 1e-10), BinOp::Lt => Ok(lv < rv), BinOp::Gt => Ok(lv > rv), BinOp::Le => Ok(lv <= rv), BinOp::Ge => Ok(lv >= rv), BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Pow => { Err("type".into()) } } } _ => Err("type".into()), } } match eval_expr( &formula.expr, context, self, &formula.target_category, depth, ) { Ok(n) => Some(CellValue::Number(n)), Err(e) => Some(CellValue::Error(e)), } } } #[cfg(test)] mod model_tests { use super::Model; 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(), ) } #[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 + 3 virtuals (_Index, _Dim, _Measure) assert_eq!(m.category_names().len(), 4); } #[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 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 remove_category_deletes_category_and_cells() { let mut m = Model::new("Test"); m.add_category("Region").unwrap(); m.add_category("Product").unwrap(); m.category_mut("Region").unwrap().add_item("East"); m.category_mut("Product").unwrap().add_item("Shirts"); m.set_cell( coord(&[("Region", "East"), ("Product", "Shirts")]), CellValue::Number(42.0), ); m.remove_category("Region"); assert!(m.category("Region").is_none()); // Cells referencing Region should be gone assert_eq!( m.data.iter_cells().count(), 0, "all cells with Region coord should be removed" ); } #[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))); } /// Formula targets should appear in measure_item_names() without being /// explicitly added to the _Measure category. _Measure is dynamically /// computed from data items + formula targets. #[test] fn measure_item_names_includes_formula_targets() { use crate::formula::parse_formula; let mut m = Model::new("Test"); m.add_category("Region").unwrap(); m.category_mut("Region").unwrap().add_item("East"); m.category_mut("_Measure").unwrap().add_item("Revenue"); m.category_mut("_Measure").unwrap().add_item("Cost"); m.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap()); let names = m.measure_item_names(); assert!( names.contains(&"Revenue".to_string()), "measure_item_names should include data items" ); assert!( names.contains(&"Profit".to_string()), "measure_item_names should include formula targets" ); // Formula target should NOT be in the category's own items assert!( !m.category("_Measure") .unwrap() .ordered_item_names() .contains(&"Profit"), "formula targets should not be added to the category directly" ); } #[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 yields an error, not a blank or misleading zero. assert_eq!( m.evaluate(&coord(&[("_Measure", "Result"), ("Region", "East")])), Some(CellValue::Error("div/0".into())) ); } #[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!( matches!(m.evaluate(&k), Some(CellValue::Error(_))), "missing ref should produce an error, got: {:?}", m.evaluate(&k) ); } /// Circular formula references must produce an error, not stack overflow. #[test] fn circular_formula_returns_error() { let mut m = Model::new("Test"); m.add_category("_Measure").unwrap(); if let Some(cat) = m.category_mut("_Measure") { cat.add_item("A"); cat.add_item("B"); } m.add_formula(parse_formula("A = B + 1", "_Measure").unwrap()); m.add_formula(parse_formula("B = A + 1", "_Measure").unwrap()); let result = m.evaluate(&coord(&[("_Measure", "A")])); assert!( matches!(result, Some(CellValue::Error(_))), "circular reference should produce an error, got: {:?}", result ); } /// Self-referencing formula must produce an error. #[test] fn self_referencing_formula_returns_error() { let mut m = Model::new("Test"); m.add_category("_Measure").unwrap(); if let Some(cat) = m.category_mut("_Measure") { cat.add_item("X"); } m.add_formula(parse_formula("X = X + 1", "_Measure").unwrap()); let result = m.evaluate(&coord(&[("_Measure", "X")])); assert!( matches!(result, Some(CellValue::Error(_))), "self-reference should produce an error, got: {:?}", result ); } #[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)) ); } // ── Precision guarantee: formulas compute at full f64 precision ──────── /// Display rounds to the view's decimal setting, but the underlying /// model must keep full f64 precision so chained calculations don't /// accumulate rounding errors. #[test] fn formula_chain_preserves_full_precision() { let mut m = Model::new("Test"); m.add_category("_Measure").unwrap(); if let Some(cat) = m.category_mut("_Measure") { cat.add_item("Price"); cat.add_item("Tax"); cat.add_item("Total"); } // Price = 10.0, Tax = Price * 0.075 = 0.75, Total = Price + Tax = 10.75 m.set_cell(coord(&[("_Measure", "Price")]), CellValue::Number(10.0)); m.add_formula(parse_formula("Tax = Price * 0.075", "_Measure").unwrap()); m.add_formula(parse_formula("Total = Price + Tax", "_Measure").unwrap()); let tax = m .evaluate(&coord(&[("_Measure", "Tax")])) .and_then(|v| v.as_f64()) .unwrap(); let total = m .evaluate(&coord(&[("_Measure", "Total")])) .and_then(|v| v.as_f64()) .unwrap(); // Full precision: Tax is exactly 0.75, Total is exactly 10.75 assert!( approx_eq(tax, 0.75), "Tax should be 0.75 (full prec), got {tax}" ); assert!( approx_eq(total, 10.75), "Total should be 10.75 (full prec), got {total}" ); // If display rounded Tax to 0 decimals (showing "1"), and the formula // used that rounded value, Total would be 11 instead of 10.75. // This proves the formula uses the raw f64, not the display string. use crate::format::format_f64; let tax_display = format_f64(tax, true, 0); assert_eq!(tax_display, "1", "display rounds 0.75 → 1"); // But the computed Total is 10.75, not 10 + 1 = 11 assert!( approx_eq(total, 10.75), "Total must use full-precision Tax (0.75), not display-rounded (1)" ); } /// 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; use crate::workbook::Workbook; 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 } /// Build a Workbook whose model matches `build_model()`. Used by the /// view-management tests in this module: view state lives on Workbook, /// not Model, so those tests need the wrapper. fn build_workbook() -> Workbook { let mut wb = Workbook::new("Sales"); for cat in ["Region", "Product", "Channel", "Time", "_Measure"] { wb.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) = wb.model.category_mut(cat) { for &item in items { c.add_item(item); } } } if let Some(c) = wb.model.category_mut("_Measure") { for &item in &["Revenue", "Cost", "Profit", "Margin", "Total"] { c.add_item(item); } } for &(region, product, channel, time, rev, cost) in DATA { wb.model.set_cell( coord(region, product, channel, time, "Revenue"), CellValue::Number(rev), ); wb.model.set_cell( coord(region, product, channel, time, "Cost"), CellValue::Number(cost), ); } wb.model .add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap()); wb.model .add_formula(parse_formula("Margin = Profit / Revenue", "_Measure").unwrap()); wb.model .add_formula(parse_formula("Total = SUM(Revenue)", "_Measure").unwrap()); wb } 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_some()) .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_some()) .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")); let b = m.get_cell(&coord("West", "Pants", "Retail", "Q2", "Revenue")); 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 wb = build_workbook(); let v = wb.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::None); } #[test] fn rearranging_axes_does_not_affect_data() { let mut wb = build_workbook(); { let v = wb.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!( wb.model .get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")), Some(&CellValue::Number(1_000.0)) ); } #[test] fn two_views_have_independent_axis_assignments() { let mut wb = build_workbook(); wb.create_view("Pivot"); { let v = wb.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!( wb.views.get("Default").unwrap().axis_of("Region"), Axis::Row ); assert_eq!(wb.views.get("Pivot").unwrap().axis_of("Time"), Axis::Row); assert_eq!( wb.views.get("Pivot").unwrap().axis_of("Channel"), Axis::Column ); } #[test] fn page_selections_are_per_view() { let mut wb = build_workbook(); wb.create_view("West only"); if let Some(v) = wb.views.get_mut("West only") { v.set_page_selection("Region", "West"); } assert_eq!( wb.views.get("Default").unwrap().page_selection("Region"), None ); assert_eq!( wb.views.get("West only").unwrap().page_selection("Region"), Some("West") ); } #[test] fn five_categories_well_within_limit() { let m = build_model(); // 4 regular (Region, Product, Channel, Time) + 3 virtual (_Index, _Dim, _Measure) assert_eq!(m.category_names().len(), 7); let mut m2 = build_model(); for i in 0..8 { m2.add_category(format!("Extra{i}")).unwrap(); } // 12 regular + 3 virtuals = 15 assert_eq!(m2.category_names().len(), 15); 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); } } }