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>
2063 lines
77 KiB
Rust
2063 lines
77 KiB
Rust
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);
|
||
}
|
||
}
|
||
}
|