chore: reformat
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@ -14,7 +14,11 @@ pub struct Item {
|
||||
|
||||
impl Item {
|
||||
pub fn new(id: ItemId, name: impl Into<String>) -> Self {
|
||||
Self { id, name: name.into(), group: None }
|
||||
Self {
|
||||
id,
|
||||
name: name.into(),
|
||||
group: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_group(mut self, group: impl Into<String>) -> Self {
|
||||
@ -32,7 +36,10 @@ pub struct Group {
|
||||
|
||||
impl Group {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self { name: name.into(), parent: None }
|
||||
Self {
|
||||
name: name.into(),
|
||||
parent: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_parent(mut self, parent: impl Into<String>) -> Self {
|
||||
@ -75,7 +82,11 @@ impl Category {
|
||||
id
|
||||
}
|
||||
|
||||
pub fn add_item_in_group(&mut self, name: impl Into<String>, group: impl Into<String>) -> ItemId {
|
||||
pub fn add_item_in_group(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
group: impl Into<String>,
|
||||
) -> ItemId {
|
||||
let name = name.into();
|
||||
let group = group.into();
|
||||
if let Some(item) = self.items.get(&name) {
|
||||
@ -83,7 +94,8 @@ impl Category {
|
||||
}
|
||||
let id = self.next_item_id;
|
||||
self.next_item_id += 1;
|
||||
self.items.insert(name.clone(), Item::new(id, name).with_group(group));
|
||||
self.items
|
||||
.insert(name.clone(), Item::new(id, name).with_group(group));
|
||||
id
|
||||
}
|
||||
|
||||
@ -106,18 +118,18 @@ impl Category {
|
||||
self.items.keys().map(|s| s.as_str()).collect()
|
||||
}
|
||||
|
||||
// /// Returns unique group names at the top level
|
||||
// pub fn top_level_groups(&self) -> Vec<&str> {
|
||||
// let mut seen = Vec::new();
|
||||
// for item in self.items.values() {
|
||||
// if let Some(g) = &item.group {
|
||||
// if !seen.contains(&g.as_str()) {
|
||||
// seen.push(g.as_str());
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// seen
|
||||
// }
|
||||
/// Returns unique group names in insertion order, derived from item.group fields.
|
||||
pub fn top_level_groups(&self) -> Vec<&str> {
|
||||
let mut seen = Vec::new();
|
||||
for item in self.items.values() {
|
||||
if let Some(g) = &item.group {
|
||||
if !seen.contains(&g.as_str()) {
|
||||
seen.push(g.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -150,7 +162,10 @@ mod tests {
|
||||
fn add_item_in_group_sets_group() {
|
||||
let mut c = cat();
|
||||
c.add_item_in_group("Jan", "Q1");
|
||||
assert_eq!(c.items.get("Jan").and_then(|i| i.group.as_deref()), Some("Q1"));
|
||||
assert_eq!(
|
||||
c.items.get("Jan").and_then(|i| i.group.as_deref()),
|
||||
Some("Q1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -170,15 +185,29 @@ mod tests {
|
||||
assert_eq!(c.groups.len(), 1);
|
||||
}
|
||||
|
||||
// #[test]
|
||||
// fn top_level_groups_returns_unique_groups_in_insertion_order() {
|
||||
// let mut c = cat();
|
||||
// c.add_item_in_group("Jan", "Q1");
|
||||
// c.add_item_in_group("Feb", "Q1");
|
||||
// c.add_item_in_group("Apr", "Q2");
|
||||
// let groups = c.top_level_groups();
|
||||
// assert_eq!(groups, vec!["Q1", "Q2"]);
|
||||
// }
|
||||
#[test]
|
||||
fn top_level_groups_returns_unique_groups_in_insertion_order() {
|
||||
let mut c = cat();
|
||||
c.add_item_in_group("Jan", "Q1");
|
||||
c.add_item_in_group("Feb", "Q1");
|
||||
c.add_item_in_group("Apr", "Q2");
|
||||
assert_eq!(c.top_level_groups(), vec!["Q1", "Q2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_groups_empty_for_ungrouped_category() {
|
||||
let mut c = cat();
|
||||
c.add_item("East");
|
||||
c.add_item("West");
|
||||
assert!(c.top_level_groups().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_groups_only_reflects_item_group_fields_not_groups_vec() {
|
||||
let mut c = cat();
|
||||
c.add_group(Group::new("Orphan"));
|
||||
assert!(c.top_level_groups().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_index_reflects_insertion_order() {
|
||||
@ -186,8 +215,8 @@ mod tests {
|
||||
c.add_item("East");
|
||||
c.add_item("West");
|
||||
c.add_item("North");
|
||||
assert_eq!(c.items.get_index_of("East"), Some(0));
|
||||
assert_eq!(c.items.get_index_of("West"), Some(1));
|
||||
assert_eq!(c.items.get_index_of("East"), Some(0));
|
||||
assert_eq!(c.items.get_index_of("West"), Some(1));
|
||||
assert_eq!(c.items.get_index_of("North"), Some(2));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A cell key is a sorted vector of (category_name, item_name) pairs.
|
||||
/// Sorted by category name for canonical form.
|
||||
@ -13,7 +13,10 @@ impl CellKey {
|
||||
}
|
||||
|
||||
pub fn get(&self, category: &str) -> Option<&str> {
|
||||
self.0.iter().find(|(c, _)| c == category).map(|(_, v)| v.as_str())
|
||||
self.0
|
||||
.iter()
|
||||
.find(|(c, _)| c == category)
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
pub fn with(mut self, category: impl Into<String>, item: impl Into<String>) -> Self {
|
||||
@ -29,11 +32,19 @@ impl CellKey {
|
||||
}
|
||||
|
||||
pub fn without(&self, category: &str) -> Self {
|
||||
Self(self.0.iter().filter(|(c, _)| c != category).cloned().collect())
|
||||
Self(
|
||||
self.0
|
||||
.iter()
|
||||
.filter(|(c, _)| c != category)
|
||||
.cloned()
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn matches_partial(&self, partial: &[(String, String)]) -> bool {
|
||||
partial.iter().all(|(cat, item)| self.get(cat) == Some(item.as_str()))
|
||||
partial
|
||||
.iter()
|
||||
.all(|(cat, item)| self.get(cat) == Some(item.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,7 +134,8 @@ impl DataStore {
|
||||
|
||||
/// All cells where partial coords match
|
||||
pub fn matching_cells(&self, partial: &[(String, String)]) -> Vec<(&CellKey, &CellValue)> {
|
||||
self.cells.iter()
|
||||
self.cells
|
||||
.iter()
|
||||
.filter(|(key, _)| key.matches_partial(partial))
|
||||
.collect()
|
||||
}
|
||||
@ -134,12 +146,21 @@ mod cell_key {
|
||||
use super::CellKey;
|
||||
|
||||
fn key(pairs: &[(&str, &str)]) -> CellKey {
|
||||
CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect())
|
||||
CellKey::new(
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(c, i)| (c.to_string(), i.to_string()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coords_are_sorted_by_category_name() {
|
||||
let k = key(&[("Region", "East"), ("Measure", "Revenue"), ("Product", "Shirts")]);
|
||||
let k = key(&[
|
||||
("Region", "East"),
|
||||
("Measure", "Revenue"),
|
||||
("Product", "Shirts"),
|
||||
]);
|
||||
assert_eq!(k.0[0].0, "Measure");
|
||||
assert_eq!(k.0[1].0, "Product");
|
||||
assert_eq!(k.0[2].0, "Region");
|
||||
@ -227,7 +248,12 @@ mod data_store {
|
||||
use super::{CellKey, CellValue, DataStore};
|
||||
|
||||
fn key(pairs: &[(&str, &str)]) -> CellKey {
|
||||
CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect())
|
||||
CellKey::new(
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(c, i)| (c.to_string(), i.to_string()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -265,9 +291,18 @@ mod data_store {
|
||||
#[test]
|
||||
fn matching_cells_returns_correct_subset() {
|
||||
let mut store = DataStore::new();
|
||||
store.set(key(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(100.0));
|
||||
store.set(key(&[("Measure", "Revenue"), ("Region", "West")]), CellValue::Number(200.0));
|
||||
store.set(key(&[("Measure", "Cost"), ("Region", "East")]), CellValue::Number(50.0));
|
||||
store.set(
|
||||
key(&[("Measure", "Revenue"), ("Region", "East")]),
|
||||
CellValue::Number(100.0),
|
||||
);
|
||||
store.set(
|
||||
key(&[("Measure", "Revenue"), ("Region", "West")]),
|
||||
CellValue::Number(200.0),
|
||||
);
|
||||
store.set(
|
||||
key(&[("Measure", "Cost"), ("Region", "East")]),
|
||||
CellValue::Number(50.0),
|
||||
);
|
||||
let partial = vec![("Measure".to_string(), "Revenue".to_string())];
|
||||
let cells = store.matching_cells(&partial);
|
||||
assert_eq!(cells.len(), 2);
|
||||
@ -275,13 +310,12 @@ mod data_store {
|
||||
assert!(values.contains(&100.0));
|
||||
assert!(values.contains(&200.0));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prop_tests {
|
||||
use proptest::prelude::*;
|
||||
use super::{CellKey, CellValue, DataStore};
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// Strategy: map of unique cat→item strings (HashMap guarantees unique keys).
|
||||
fn pairs_map() -> impl Strategy<Value = Vec<(String, String)>> {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
use super::category::{Category, CategoryId};
|
||||
use super::cell::{CellKey, CellValue, DataStore};
|
||||
@ -47,7 +47,8 @@ impl Model {
|
||||
}
|
||||
let id = self.next_category_id;
|
||||
self.next_category_id += 1;
|
||||
self.categories.insert(name.clone(), Category::new(id, name.clone()));
|
||||
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);
|
||||
@ -87,7 +88,8 @@ impl Model {
|
||||
}
|
||||
|
||||
pub fn remove_formula(&mut self, target: &str, target_category: &str) {
|
||||
self.formulas.retain(|f| !(f.target == target && f.target_category == target_category));
|
||||
self.formulas
|
||||
.retain(|f| !(f.target == target && f.target_category == target_category));
|
||||
}
|
||||
|
||||
pub fn formulas(&self) -> &[Formula] {
|
||||
@ -95,12 +97,14 @@ impl Model {
|
||||
}
|
||||
|
||||
pub fn active_view(&self) -> &View {
|
||||
self.views.get(&self.active_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)
|
||||
self.views
|
||||
.get_mut(&self.active_view)
|
||||
.expect("active_view always names an existing view")
|
||||
}
|
||||
|
||||
@ -169,11 +173,12 @@ impl Model {
|
||||
}
|
||||
|
||||
fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option<CellValue> {
|
||||
use crate::formula::{Expr, AggFunc};
|
||||
use crate::formula::{AggFunc, Expr};
|
||||
|
||||
// Check WHERE filter first
|
||||
if let Some(filter) = &formula.filter {
|
||||
let matches = context.get(&filter.category)
|
||||
let matches = context
|
||||
.get(&filter.category)
|
||||
.map(|v| v == filter.item.as_str())
|
||||
.unwrap_or(false);
|
||||
if !matches {
|
||||
@ -211,12 +216,18 @@ impl Model {
|
||||
BinOp::Add => lv + rv,
|
||||
BinOp::Sub => lv - rv,
|
||||
BinOp::Mul => lv * rv,
|
||||
BinOp::Div => { if rv == 0.0 { return None; } 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,
|
||||
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)?),
|
||||
@ -230,15 +241,20 @@ impl Model {
|
||||
if let Some(f) = agg_filter {
|
||||
partial = partial.with(&f.category, &f.item);
|
||||
}
|
||||
let values: Vec<f64> = model.data.matching_cells(&partial.0)
|
||||
let values: Vec<f64> = model
|
||||
.data
|
||||
.matching_cells(&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) }
|
||||
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),
|
||||
@ -275,16 +291,16 @@ impl Model {
|
||||
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,
|
||||
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)
|
||||
eval_expr(&formula.expr, context, self, &formula.target_category).map(CellValue::Number)
|
||||
}
|
||||
}
|
||||
|
||||
@ -295,7 +311,12 @@ mod model_tests {
|
||||
use crate::view::Axis;
|
||||
|
||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||
CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect())
|
||||
CellKey::new(
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(c, i)| (c.to_string(), i.to_string()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -324,7 +345,9 @@ mod model_tests {
|
||||
#[test]
|
||||
fn add_category_max_limit() {
|
||||
let mut m = Model::new("Test");
|
||||
for i in 0..12 { m.add_category(format!("Cat{i}")).unwrap(); }
|
||||
for i in 0..12 {
|
||||
m.add_category(format!("Cat{i}")).unwrap();
|
||||
}
|
||||
assert!(m.add_category("TooMany").is_err());
|
||||
}
|
||||
|
||||
@ -368,10 +391,26 @@ mod model_tests {
|
||||
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")]);
|
||||
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));
|
||||
@ -438,9 +477,9 @@ mod model_tests {
|
||||
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("Region"), Axis::Row);
|
||||
assert_eq!(v.axis_of("Product"), Axis::Column);
|
||||
assert_eq!(v.axis_of("Time"), Axis::Page);
|
||||
assert_eq!(v.axis_of("Time"), Axis::Page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -456,14 +495,21 @@ mod model_tests {
|
||||
#[cfg(test)]
|
||||
mod formula_tests {
|
||||
use super::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
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())
|
||||
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 approx_eq(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
fn revenue_cost_model() -> Model {
|
||||
let mut m = Model::new("Test");
|
||||
@ -478,10 +524,22 @@ mod formula_tests {
|
||||
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.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
|
||||
}
|
||||
|
||||
@ -507,8 +565,13 @@ mod formula_tests {
|
||||
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();
|
||||
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));
|
||||
}
|
||||
|
||||
@ -521,7 +584,10 @@ mod formula_tests {
|
||||
cat.add_item("Profit");
|
||||
cat.add_item("Margin");
|
||||
}
|
||||
let val = m.evaluate(&coord(&[("Measure", "Margin"), ("Region", "East")])).and_then(|v| v.as_f64()).unwrap();
|
||||
let val = m
|
||||
.evaluate(&coord(&[("Measure", "Margin"), ("Region", "East")]))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap();
|
||||
assert!(approx_eq(val, 0.4));
|
||||
}
|
||||
|
||||
@ -535,18 +601,29 @@ mod formula_tests {
|
||||
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.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);
|
||||
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"); }
|
||||
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)));
|
||||
}
|
||||
@ -561,14 +638,19 @@ mod formula_tests {
|
||||
}
|
||||
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)));
|
||||
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"); }
|
||||
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);
|
||||
}
|
||||
@ -576,18 +658,32 @@ mod formula_tests {
|
||||
#[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();
|
||||
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);
|
||||
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]
|
||||
@ -614,8 +710,13 @@ mod formula_tests {
|
||||
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();
|
||||
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);
|
||||
}
|
||||
@ -630,10 +731,16 @@ mod formula_tests {
|
||||
cat.add_item("Count");
|
||||
}
|
||||
for region in ["East", "West", "North"] {
|
||||
m.set_cell(coord(&[("Measure", "Sales"), ("Region", region)]), CellValue::Number(100.0));
|
||||
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();
|
||||
let val = m
|
||||
.evaluate(&coord(&[("Measure", "Count"), ("Region", "East")]))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap();
|
||||
assert!(val >= 1.0);
|
||||
}
|
||||
|
||||
@ -647,7 +754,10 @@ mod formula_tests {
|
||||
}
|
||||
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)));
|
||||
assert_eq!(
|
||||
m.evaluate(&coord(&[("Measure", "Result")])),
|
||||
Some(CellValue::Number(1.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -660,7 +770,10 @@ mod formula_tests {
|
||||
}
|
||||
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)));
|
||||
assert_eq!(
|
||||
m.evaluate(&coord(&[("Measure", "Result")])),
|
||||
Some(CellValue::Number(0.0))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bug regression tests ─────────────────────────────────────────────────
|
||||
@ -710,8 +823,14 @@ mod formula_tests {
|
||||
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.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
|
||||
@ -730,8 +849,12 @@ mod formula_tests {
|
||||
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"); }
|
||||
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());
|
||||
@ -748,16 +871,26 @@ mod formula_tests {
|
||||
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"); }
|
||||
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)));
|
||||
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"
|
||||
@ -768,8 +901,12 @@ mod formula_tests {
|
||||
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"); }
|
||||
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());
|
||||
@ -780,34 +917,37 @@ mod formula_tests {
|
||||
// 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)));
|
||||
assert_eq!(
|
||||
m.evaluate(&coord(&[("KPI", "Profit")])),
|
||||
Some(CellValue::Number(2.0))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod five_category {
|
||||
use super::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
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),
|
||||
("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 {
|
||||
@ -815,8 +955,8 @@ mod five_category {
|
||||
("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()),
|
||||
("Region".to_string(), region.to_string()),
|
||||
("Time".to_string(), time.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
@ -827,14 +967,16 @@ mod five_category {
|
||||
}
|
||||
for cat in ["Region", "Product", "Channel", "Time"] {
|
||||
let items: &[&str] = match cat {
|
||||
"Region" => &["East", "West"],
|
||||
"Region" => &["East", "West"],
|
||||
"Product" => &["Shirts", "Pants"],
|
||||
"Channel" => &["Online", "Retail"],
|
||||
"Time" => &["Q1", "Q2"],
|
||||
_ => &[],
|
||||
"Time" => &["Q1", "Q2"],
|
||||
_ => &[],
|
||||
};
|
||||
if let Some(c) = m.category_mut(cat) {
|
||||
for &item in items { c.add_item(item); }
|
||||
for &item in items {
|
||||
c.add_item(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(c) = m.category_mut("Measure") {
|
||||
@ -843,21 +985,30 @@ mod five_category {
|
||||
}
|
||||
}
|
||||
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.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("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.add_formula(parse_formula("Total = SUM(Revenue)", "Measure").unwrap());
|
||||
m
|
||||
}
|
||||
|
||||
fn approx(a: f64, b: f64) -> bool { (a - b).abs() < 1e-9 }
|
||||
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()
|
||||
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);
|
||||
@ -866,7 +1017,8 @@ mod five_category {
|
||||
#[test]
|
||||
fn all_sixteen_cost_cells_stored() {
|
||||
let m = build_model();
|
||||
let count = DATA.iter()
|
||||
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);
|
||||
@ -875,15 +1027,25 @@ mod five_category {
|
||||
#[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)));
|
||||
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();
|
||||
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);
|
||||
}
|
||||
|
||||
@ -892,11 +1054,14 @@ mod five_category {
|
||||
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}");
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -905,9 +1070,10 @@ mod five_category {
|
||||
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}"));
|
||||
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}");
|
||||
}
|
||||
@ -916,17 +1082,29 @@ mod five_category {
|
||||
#[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();
|
||||
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();
|
||||
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();
|
||||
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}");
|
||||
}
|
||||
|
||||
@ -935,10 +1113,14 @@ mod five_category {
|
||||
let m = build_model();
|
||||
let key = CellKey::new(vec![
|
||||
("Measure".to_string(), "Total".to_string()),
|
||||
("Region".to_string(), "East".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();
|
||||
let expected: f64 = DATA
|
||||
.iter()
|
||||
.filter(|&&(r, _, _, _, _, _)| r == "East")
|
||||
.map(|&(_, _, _, _, rev, _)| rev)
|
||||
.sum();
|
||||
assert!(approx(total, expected), "expected {expected}, got {total}");
|
||||
}
|
||||
|
||||
@ -950,7 +1132,11 @@ mod five_category {
|
||||
("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();
|
||||
let expected: f64 = DATA
|
||||
.iter()
|
||||
.filter(|&&(_, _, ch, _, _, _)| ch == "Online")
|
||||
.map(|&(_, _, _, _, rev, _)| rev)
|
||||
.sum();
|
||||
assert!(approx(total, expected), "expected {expected}, got {total}");
|
||||
}
|
||||
|
||||
@ -960,19 +1146,21 @@ mod five_category {
|
||||
let key = CellKey::new(vec![
|
||||
("Measure".to_string(), "Total".to_string()),
|
||||
("Product".to_string(), "Shirts".to_string()),
|
||||
("Time".to_string(), "Q1".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();
|
||||
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 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}");
|
||||
@ -982,10 +1170,10 @@ mod five_category {
|
||||
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("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("Time"), Axis::Page);
|
||||
assert_eq!(v.axis_of("Measure"), Axis::Page);
|
||||
}
|
||||
|
||||
@ -994,13 +1182,16 @@ mod five_category {
|
||||
let mut m = build_model();
|
||||
{
|
||||
let v = m.active_view_mut();
|
||||
v.set_axis("Region", Axis::Page);
|
||||
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("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)));
|
||||
assert_eq!(
|
||||
m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")),
|
||||
Some(&CellValue::Number(1_000.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1009,15 +1200,18 @@ mod five_category {
|
||||
m.create_view("Pivot");
|
||||
{
|
||||
let v = m.views.get_mut("Pivot").unwrap();
|
||||
v.set_axis("Time", Axis::Row);
|
||||
v.set_axis("Time", Axis::Row);
|
||||
v.set_axis("Channel", Axis::Column);
|
||||
v.set_axis("Region", Axis::Page);
|
||||
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);
|
||||
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]
|
||||
@ -1027,8 +1221,14 @@ mod five_category {
|
||||
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"));
|
||||
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]
|
||||
@ -1036,7 +1236,9 @@ mod five_category {
|
||||
let m = build_model();
|
||||
assert_eq!(m.categories.len(), 5);
|
||||
let mut m2 = build_model();
|
||||
for i in 0..7 { m2.add_category(format!("Extra{i}")).unwrap(); }
|
||||
for i in 0..7 {
|
||||
m2.add_category(format!("Extra{i}")).unwrap();
|
||||
}
|
||||
assert_eq!(m2.categories.len(), 12);
|
||||
assert!(m2.add_category("OneMore").is_err());
|
||||
}
|
||||
@ -1044,10 +1246,10 @@ mod five_category {
|
||||
|
||||
#[cfg(test)]
|
||||
mod prop_tests {
|
||||
use proptest::prelude::*;
|
||||
use super::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user