Files
improvise/src/model/types.rs
Edward Langley 3fbf56ec8b refactor: break Model↔View cycle, introduce Workbook wrapper
Model is now pure data (categories, cells, formulas, measure_agg) with
no references to view/. The Workbook struct owns the Model together
with views and the active view name, and is responsible for cross-slice
operations (add/remove category → notify views, view management).

- New: src/workbook.rs with Workbook wrapper and cross-slice helpers
  (add_category, add_label_category, remove_category, create_view,
  switch_view, delete_view, normalize_view_state).
- Model: strip view state and view-touching methods. recompute_formulas
  remains on Model as a primitive; the view-derived none_cats list is
  gathered at each call site (App::rebuild_layout, persistence::load)
  so the view dependency is explicit, not hidden behind a wrapper.
- View: add View::none_cats() helper.
- CmdContext: add workbook and view fields so commands can reach both
  slices without threading Model + View through every call.
- App: rename `model` field to `workbook`.
- Persistence (save/load/format_md/parse_md/export_csv): take/return
  Workbook so the on-disk format carries model + views together.
- Widgets (GridWidget, TileBar, CategoryContent, ViewContent): take
  explicit &Model + &View instead of routing through Model.

Tests updated throughout to reflect the new shape. View-management
tests that previously lived on Model continue to cover the same
behaviour via a build_workbook() helper in model/types.rs. All 573
tests pass; clippy is clean.

This is Phase A of improvise-36h. Phase B will mechanically extract
crates/improvise-core/ containing model/, view/, format.rs, workbook.rs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:08:11 -07:00

2063 lines
77 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};
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<String, Category>,
pub data: DataStore,
formulas: Vec<Formula>,
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 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<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()));
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<String>) -> Result<CategoryId> {
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<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
}
/// 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};
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<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);
}
}
}