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)
1560 lines
57 KiB
Rust
1560 lines
57 KiB
Rust
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);
|
||
}
|
||
}
|
||
}
|