Files
improvise/src/model/types.rs
Edward Langley 19645a34cf feat: add records mode (long-format view) for drill-down
Implement records mode (long-format view) when drilling into aggregated cells.

Key changes:
- DrillIntoCell now creates views with _Index on Row and _Dim on Column
- GridLayout detects records mode and builds a records list instead of
  cross-product row/col items
- Added records_display() to render individual cell values in records mode
- GridWidget and CSV export updated to handle records mode rendering
- category_names() now includes virtual categories (_Index, _Dim)
- Tests updated to reflect virtual categories counting toward limits

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-05 11:10:41 -07:00

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