Files
improvise/src/model/types.rs
Edward Langley cb35e38df9 refactor(all): use let chains to flatten nested if statements
Replace nested if and if let blocks with combined if statements using let
chains. This reduces indentation and improves readability across the
project.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-13 21:58:14 -07:00

2171 lines
80 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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};
use crate::view::View;
const MAX_CATEGORIES: usize = 12;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Model {
pub name: String,
pub categories: IndexMap<String, Category>,
pub data: DataStore,
formulas: Vec<Formula>,
pub views: IndexMap<String, View>,
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<String, AggFunc>,
/// Cached formula evaluation results. Recomputed by `recompute_formulas`.
#[serde(skip)]
formula_cache: HashMap<CellKey, CellValue>,
}
impl Model {
pub fn new(name: impl Into<String>) -> 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),
);
categories.insert(
"_Measure".to_string(),
Category::new(2, "_Measure").with_kind(CategoryKind::VirtualMeasure),
);
let mut m = Self {
name,
categories,
data: DataStore::new(),
formulas: Vec::new(),
views,
active_view: "Default".to_string(),
next_category_id: 3,
measure_agg: HashMap::new(),
formula_cache: HashMap::new(),
};
// Add virtuals to existing views (default view).
// Start in records mode; on_category_added will reclaim Row/Column
// for the first two regular categories.
for view in m.views.values_mut() {
view.on_category_added("_Index");
view.on_category_added("_Dim");
view.on_category_added("_Measure");
view.set_axis("_Index", crate::view::Axis::Row);
view.set_axis("_Dim", crate::view::Axis::Column);
}
m
}
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
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()));
// Add to all views
for view in self.views.values_mut() {
view.on_category_added(&name);
}
Ok(id)
}
/// Add a Label-kind category: stored alongside regular categories so
/// records views can display it, but default to `Axis::None` and
/// excluded from the pivot-category count limit.
pub fn add_label_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
use crate::model::category::CategoryKind;
use crate::view::Axis;
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);
for view in self.views.values_mut() {
view.on_category_added(&name);
view.set_axis(&name, Axis::None);
}
Ok(id)
}
/// Remove a category and all cells that reference it.
pub fn remove_category(&mut self, name: &str) {
if !self.categories.contains_key(name) {
return;
}
self.categories.shift_remove(name);
// Remove from all views
for view in self.views.values_mut() {
view.on_category_removed(name);
}
// Remove cells that have a coord in this category
let to_remove: Vec<CellKey> = 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<CellKey> = 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
}
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<String>) -> &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()
}
/// 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<String> {
let mut names: Vec<String> = 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<String> {
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<CellValue> {
self.evaluate_depth(key, Self::MAX_EVAL_DEPTH)
}
fn evaluate_depth(&self, key: &CellKey, depth: u8) -> Option<CellValue> {
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<CellValue> {
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<f64> = 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::<f64>() / 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<CellValue> {
// 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<CellKey> = 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<CellValue> {
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<CellValue> {
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<f64, String> {
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<f64> = 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::<f64>() / 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<bool, String> {
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<CellValue> {
if none_cats.is_empty() {
return self.data.get(key).cloned();
}
let values: Vec<f64> = 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::<f64>() / 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<CellValue> {
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<f64, String> {
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<f64> = 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::<f64>() / 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<bool, String> {
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};
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 + 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 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 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"
);
// Views should no longer know about Region
// (axis_of would panic for unknown category, so check categories_on)
let v = m.active_view();
assert!(v.categories_on(crate::view::Axis::Row).is_empty());
}
#[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)));
}
/// 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;
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::None);
}
#[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();
// 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<Value = f64> {
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);
}
}
}