refactor: replace CellValue::Empty with Option<CellValue>
Previously CellValue had three variants: Number, Text, and Empty. The Empty variant acted as a null sentinel, but the compiler could not distinguish between "this is a real value" and "this might be empty". Code that received a CellValue could use it without checking for Empty, because there was no type-level enforcement. Now CellValue has only Number and Text. The absence of a value is represented as None at every API boundary: DataStore::get() → Option<&CellValue> (was &CellValue / Empty) Model::get_cell() → Option<&CellValue> (was &CellValue / Empty) Model::evaluate() → Option<CellValue> (was CellValue::Empty) eval_formula() → Option<CellValue> (was CellValue::Empty) Model gains clear_cell() for explicit key removal; ClearCell dispatch calls it instead of set_cell(key, CellValue::Empty). The compiler now forces every caller of evaluate/get_cell to handle the None case explicitly — accidental use of an empty value as if it were real is caught at compile time rather than silently computing wrong results. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@ -59,7 +59,7 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
|
|||||||
.map(|pair| (pair[0].clone(), pair[1].clone()))
|
.map(|pair| (pair[0].clone(), pair[1].clone()))
|
||||||
.collect();
|
.collect();
|
||||||
let key = CellKey::new(kv);
|
let key = CellKey::new(kv);
|
||||||
model.set_cell(key, CellValue::Empty);
|
model.clear_cell(&key);
|
||||||
CommandResult::ok()
|
CommandResult::ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -370,8 +370,8 @@ mod tests {
|
|||||||
("Measure".to_string(), "revenue".to_string()),
|
("Measure".to_string(), "revenue".to_string()),
|
||||||
("region".to_string(), "West".to_string()),
|
("region".to_string(), "West".to_string()),
|
||||||
]);
|
]);
|
||||||
assert_eq!(model.get_cell(&k_east).as_f64(), Some(100.0));
|
assert_eq!(model.get_cell(&k_east).and_then(|v| v.as_f64()), Some(100.0));
|
||||||
assert_eq!(model.get_cell(&k_west).as_f64(), Some(200.0));
|
assert_eq!(model.get_cell(&k_west).and_then(|v| v.as_f64()), Some(200.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -48,20 +48,15 @@ impl std::fmt::Display for CellKey {
|
|||||||
pub enum CellValue {
|
pub enum CellValue {
|
||||||
Number(f64),
|
Number(f64),
|
||||||
Text(String),
|
Text(String),
|
||||||
Empty,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CellValue {
|
impl CellValue {
|
||||||
pub fn as_f64(&self) -> Option<f64> {
|
pub fn as_f64(&self) -> Option<f64> {
|
||||||
match self {
|
match self {
|
||||||
CellValue::Number(n) => Some(*n),
|
CellValue::Number(n) => Some(*n),
|
||||||
_ => None,
|
CellValue::Text(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
matches!(self, CellValue::Empty)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for CellValue {
|
impl std::fmt::Display for CellValue {
|
||||||
@ -75,17 +70,10 @@ impl std::fmt::Display for CellValue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
CellValue::Text(s) => write!(f, "{s}"),
|
CellValue::Text(s) => write!(f, "{s}"),
|
||||||
CellValue::Empty => write!(f, ""),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CellValue {
|
|
||||||
fn default() -> Self {
|
|
||||||
CellValue::Empty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialized as a list of (key, value) pairs so CellKey doesn't need
|
/// Serialized as a list of (key, value) pairs so CellKey doesn't need
|
||||||
/// to implement the `Serialize`-as-string requirement for JSON object keys.
|
/// to implement the `Serialize`-as-string requirement for JSON object keys.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@ -118,15 +106,11 @@ impl DataStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set(&mut self, key: CellKey, value: CellValue) {
|
pub fn set(&mut self, key: CellKey, value: CellValue) {
|
||||||
if value.is_empty() {
|
|
||||||
self.cells.remove(&key);
|
|
||||||
} else {
|
|
||||||
self.cells.insert(key, value);
|
self.cells.insert(key, value);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, key: &CellKey) -> &CellValue {
|
pub fn get(&self, key: &CellKey) -> Option<&CellValue> {
|
||||||
self.cells.get(key).unwrap_or(&CellValue::Empty)
|
self.cells.get(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_mut(&mut self, key: &CellKey) -> Option<&mut CellValue> {
|
pub fn get_mut(&mut self, key: &CellKey) -> Option<&mut CellValue> {
|
||||||
@ -261,7 +245,7 @@ mod data_store {
|
|||||||
#[test]
|
#[test]
|
||||||
fn get_missing_returns_empty() {
|
fn get_missing_returns_empty() {
|
||||||
let store = DataStore::new();
|
let store = DataStore::new();
|
||||||
assert_eq!(store.get(&key(&[("Region", "East")])), &CellValue::Empty);
|
assert_eq!(store.get(&key(&[("Region", "East")])), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -269,7 +253,7 @@ mod data_store {
|
|||||||
let mut store = DataStore::new();
|
let mut store = DataStore::new();
|
||||||
let k = key(&[("Region", "East"), ("Product", "Shirts")]);
|
let k = key(&[("Region", "East"), ("Product", "Shirts")]);
|
||||||
store.set(k.clone(), CellValue::Number(42.0));
|
store.set(k.clone(), CellValue::Number(42.0));
|
||||||
assert_eq!(store.get(&k), &CellValue::Number(42.0));
|
assert_eq!(store.get(&k), Some(&CellValue::Number(42.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -278,15 +262,15 @@ mod data_store {
|
|||||||
let k = key(&[("Region", "East")]);
|
let k = key(&[("Region", "East")]);
|
||||||
store.set(k.clone(), CellValue::Number(1.0));
|
store.set(k.clone(), CellValue::Number(1.0));
|
||||||
store.set(k.clone(), CellValue::Number(99.0));
|
store.set(k.clone(), CellValue::Number(99.0));
|
||||||
assert_eq!(store.get(&k), &CellValue::Number(99.0));
|
assert_eq!(store.get(&k), Some(&CellValue::Number(99.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn setting_empty_removes_key() {
|
fn remove_evicts_key() {
|
||||||
let mut store = DataStore::new();
|
let mut store = DataStore::new();
|
||||||
let k = key(&[("Region", "East")]);
|
let k = key(&[("Region", "East")]);
|
||||||
store.set(k.clone(), CellValue::Number(5.0));
|
store.set(k.clone(), CellValue::Number(5.0));
|
||||||
store.set(k.clone(), CellValue::Empty);
|
store.remove(&k);
|
||||||
assert!(store.cells().is_empty());
|
assert!(store.cells().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -433,17 +417,17 @@ mod prop_tests {
|
|||||||
let key = CellKey::new(pairs);
|
let key = CellKey::new(pairs);
|
||||||
let mut store = DataStore::default();
|
let mut store = DataStore::default();
|
||||||
store.set(key.clone(), CellValue::Number(val));
|
store.set(key.clone(), CellValue::Number(val));
|
||||||
prop_assert_eq!(store.get(&key), &CellValue::Number(val));
|
prop_assert_eq!(store.get(&key), Some(&CellValue::Number(val)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Setting Empty after a real value: get returns Empty (key is evicted).
|
/// Removing after a real value: get returns None (key is evicted).
|
||||||
#[test]
|
#[test]
|
||||||
fn datastore_empty_evicts_key(pairs in pairs_map(), val in finite_f64()) {
|
fn datastore_empty_evicts_key(pairs in pairs_map(), val in finite_f64()) {
|
||||||
let key = CellKey::new(pairs);
|
let key = CellKey::new(pairs);
|
||||||
let mut store = DataStore::default();
|
let mut store = DataStore::default();
|
||||||
store.set(key.clone(), CellValue::Number(val));
|
store.set(key.clone(), CellValue::Number(val));
|
||||||
store.set(key.clone(), CellValue::Empty);
|
store.remove(&key);
|
||||||
prop_assert_eq!(store.get(&key), &CellValue::Empty);
|
prop_assert_eq!(store.get(&key), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The last write to a key wins.
|
/// The last write to a key wins.
|
||||||
@ -457,7 +441,7 @@ mod prop_tests {
|
|||||||
let mut store = DataStore::default();
|
let mut store = DataStore::default();
|
||||||
store.set(key.clone(), CellValue::Number(v1));
|
store.set(key.clone(), CellValue::Number(v1));
|
||||||
store.set(key.clone(), CellValue::Number(v2));
|
store.set(key.clone(), CellValue::Number(v2));
|
||||||
prop_assert_eq!(store.get(&key), &CellValue::Number(v2));
|
prop_assert_eq!(store.get(&key), Some(&CellValue::Number(v2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Two keys that differ by one coordinate are fully independent.
|
/// Two keys that differ by one coordinate are fully independent.
|
||||||
@ -480,9 +464,9 @@ mod prop_tests {
|
|||||||
let mut store = DataStore::default();
|
let mut store = DataStore::default();
|
||||||
store.set(key1.clone(), CellValue::Number(v1));
|
store.set(key1.clone(), CellValue::Number(v1));
|
||||||
store.set(key2.clone(), CellValue::Number(v2));
|
store.set(key2.clone(), CellValue::Number(v2));
|
||||||
prop_assert_eq!(store.get(&key1), &CellValue::Number(v1),
|
prop_assert_eq!(store.get(&key1), Some(&CellValue::Number(v1)),
|
||||||
"key1 corrupted after writing key2 (diff in {})", changed_cat);
|
"key1 corrupted after writing key2 (diff in {})", changed_cat);
|
||||||
prop_assert_eq!(store.get(&key2), &CellValue::Number(v2));
|
prop_assert_eq!(store.get(&key2), Some(&CellValue::Number(v2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every cell returned by matching_cells actually satisfies the partial key.
|
/// Every cell returned by matching_cells actually satisfies the partial key.
|
||||||
|
|||||||
@ -67,7 +67,11 @@ impl Model {
|
|||||||
self.data.set(key, value);
|
self.data.set(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_cell(&self, key: &CellKey) -> &CellValue {
|
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)
|
self.data.get(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,9 +148,9 @@ impl Model {
|
|||||||
self.categories.keys().map(|s| s.as_str()).collect()
|
self.categories.keys().map(|s| s.as_str()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluate a computed value at a given key, considering formulas
|
/// Evaluate a computed value at a given key, considering formulas.
|
||||||
pub fn evaluate(&self, key: &CellKey) -> CellValue {
|
/// Returns None when the cell is empty (no stored value, no applicable formula).
|
||||||
// Check if the last category dimension in the key corresponds to a formula target
|
pub fn evaluate(&self, key: &CellKey) -> Option<CellValue> {
|
||||||
for formula in &self.formulas {
|
for formula in &self.formulas {
|
||||||
if let Some(item_val) = key.get(&formula.target_category) {
|
if let Some(item_val) = key.get(&formula.target_category) {
|
||||||
if item_val == formula.target {
|
if item_val == formula.target {
|
||||||
@ -154,10 +158,10 @@ impl Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.data.get(key).clone()
|
self.data.get(key).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn eval_formula(&self, formula: &Formula, context: &CellKey) -> CellValue {
|
fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option<CellValue> {
|
||||||
use crate::formula::{Expr, AggFunc};
|
use crate::formula::{Expr, AggFunc};
|
||||||
|
|
||||||
// Check WHERE filter first
|
// Check WHERE filter first
|
||||||
@ -166,7 +170,7 @@ impl Model {
|
|||||||
.map(|v| v == filter.item.as_str())
|
.map(|v| v == filter.item.as_str())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !matches {
|
if !matches {
|
||||||
return self.data.get(context).clone();
|
return self.data.get(context).cloned();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,7 +194,7 @@ impl Model {
|
|||||||
Expr::Ref(name) => {
|
Expr::Ref(name) => {
|
||||||
let cat = find_item_category(model, name)?;
|
let cat = find_item_category(model, name)?;
|
||||||
let new_key = context.clone().with(cat, name);
|
let new_key = context.clone().with(cat, name);
|
||||||
model.evaluate(&new_key).as_f64()
|
model.evaluate(&new_key).and_then(|v| v.as_f64())
|
||||||
}
|
}
|
||||||
Expr::BinOp(op, l, r) => {
|
Expr::BinOp(op, l, r) => {
|
||||||
use crate::formula::BinOp;
|
use crate::formula::BinOp;
|
||||||
@ -272,10 +276,8 @@ impl Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match eval_expr(&formula.expr, context, self, &formula.target_category) {
|
eval_expr(&formula.expr, context, self, &formula.target_category)
|
||||||
Some(n) => CellValue::Number(n),
|
.map(CellValue::Number)
|
||||||
None => CellValue::Empty,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -332,14 +334,14 @@ mod model_tests {
|
|||||||
m.add_category("Measure").unwrap();
|
m.add_category("Measure").unwrap();
|
||||||
let k = coord(&[("Region", "East"), ("Measure", "Revenue")]);
|
let k = coord(&[("Region", "East"), ("Measure", "Revenue")]);
|
||||||
m.set_cell(k.clone(), CellValue::Number(500.0));
|
m.set_cell(k.clone(), CellValue::Number(500.0));
|
||||||
assert_eq!(m.get_cell(&k), &CellValue::Number(500.0));
|
assert_eq!(m.get_cell(&k), Some(&CellValue::Number(500.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_unset_cell_returns_empty() {
|
fn get_unset_cell_returns_empty() {
|
||||||
let m = Model::new("Test");
|
let m = Model::new("Test");
|
||||||
let k = coord(&[("Region", "East")]);
|
let k = coord(&[("Region", "East")]);
|
||||||
assert_eq!(m.get_cell(&k), &CellValue::Empty);
|
assert_eq!(m.get_cell(&k), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -348,7 +350,7 @@ mod model_tests {
|
|||||||
let k = coord(&[("Region", "East")]);
|
let k = coord(&[("Region", "East")]);
|
||||||
m.set_cell(k.clone(), CellValue::Number(1.0));
|
m.set_cell(k.clone(), CellValue::Number(1.0));
|
||||||
m.set_cell(k.clone(), CellValue::Number(2.0));
|
m.set_cell(k.clone(), CellValue::Number(2.0));
|
||||||
assert_eq!(m.get_cell(&k), &CellValue::Number(2.0));
|
assert_eq!(m.get_cell(&k), Some(&CellValue::Number(2.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -365,10 +367,10 @@ mod model_tests {
|
|||||||
m.set_cell(k2.clone(), CellValue::Number(200.0));
|
m.set_cell(k2.clone(), CellValue::Number(200.0));
|
||||||
m.set_cell(k3.clone(), CellValue::Number(300.0));
|
m.set_cell(k3.clone(), CellValue::Number(300.0));
|
||||||
m.set_cell(k4.clone(), CellValue::Number(40.0));
|
m.set_cell(k4.clone(), CellValue::Number(40.0));
|
||||||
assert_eq!(m.get_cell(&k1), &CellValue::Number(100.0));
|
assert_eq!(m.get_cell(&k1), Some(&CellValue::Number(100.0)));
|
||||||
assert_eq!(m.get_cell(&k2), &CellValue::Number(200.0));
|
assert_eq!(m.get_cell(&k2), Some(&CellValue::Number(200.0)));
|
||||||
assert_eq!(m.get_cell(&k3), &CellValue::Number(300.0));
|
assert_eq!(m.get_cell(&k3), Some(&CellValue::Number(300.0)));
|
||||||
assert_eq!(m.get_cell(&k4), &CellValue::Number(40.0));
|
assert_eq!(m.get_cell(&k4), Some(&CellValue::Number(40.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -437,7 +439,7 @@ mod model_tests {
|
|||||||
m.create_view("Second");
|
m.create_view("Second");
|
||||||
let k = coord(&[("Region", "East")]);
|
let k = coord(&[("Region", "East")]);
|
||||||
m.set_cell(k.clone(), CellValue::Number(77.0));
|
m.set_cell(k.clone(), CellValue::Number(77.0));
|
||||||
assert_eq!(m.get_cell(&k), &CellValue::Number(77.0));
|
assert_eq!(m.get_cell(&k), Some(&CellValue::Number(77.0)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -478,7 +480,7 @@ mod formula_tests {
|
|||||||
let mut m = revenue_cost_model();
|
let mut m = revenue_cost_model();
|
||||||
m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap());
|
m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap());
|
||||||
let k = coord(&[("Measure", "Profit"), ("Region", "East")]);
|
let k = coord(&[("Measure", "Profit"), ("Region", "East")]);
|
||||||
assert_eq!(m.evaluate(&k), CellValue::Number(400.0));
|
assert_eq!(m.evaluate(&k), Some(CellValue::Number(400.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -487,8 +489,8 @@ mod formula_tests {
|
|||||||
m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap());
|
m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap());
|
||||||
let east = m.evaluate(&coord(&[("Measure", "Profit"), ("Region", "East")]));
|
let east = m.evaluate(&coord(&[("Measure", "Profit"), ("Region", "East")]));
|
||||||
let west = m.evaluate(&coord(&[("Measure", "Profit"), ("Region", "West")]));
|
let west = m.evaluate(&coord(&[("Measure", "Profit"), ("Region", "West")]));
|
||||||
assert_eq!(east, CellValue::Number(400.0));
|
assert_eq!(east, Some(CellValue::Number(400.0)));
|
||||||
assert_eq!(west, CellValue::Number(300.0));
|
assert_eq!(west, Some(CellValue::Number(300.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -496,7 +498,7 @@ mod formula_tests {
|
|||||||
let mut m = revenue_cost_model();
|
let mut m = revenue_cost_model();
|
||||||
m.add_formula(parse_formula("Tax = Revenue * 0.1", "Measure").unwrap());
|
m.add_formula(parse_formula("Tax = Revenue * 0.1", "Measure").unwrap());
|
||||||
if let Some(cat) = m.category_mut("Measure") { cat.add_item("Tax"); }
|
if let Some(cat) = m.category_mut("Measure") { cat.add_item("Tax"); }
|
||||||
let val = m.evaluate(&coord(&[("Measure", "Tax"), ("Region", "East")])).as_f64().unwrap();
|
let val = m.evaluate(&coord(&[("Measure", "Tax"), ("Region", "East")])).and_then(|v| v.as_f64()).unwrap();
|
||||||
assert!(approx_eq(val, 100.0));
|
assert!(approx_eq(val, 100.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -509,7 +511,7 @@ mod formula_tests {
|
|||||||
cat.add_item("Profit");
|
cat.add_item("Profit");
|
||||||
cat.add_item("Margin");
|
cat.add_item("Margin");
|
||||||
}
|
}
|
||||||
let val = m.evaluate(&coord(&[("Measure", "Margin"), ("Region", "East")])).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));
|
assert!(approx_eq(val, 0.4));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -527,7 +529,7 @@ mod formula_tests {
|
|||||||
m.set_cell(coord(&[("Measure", "Zero"), ("Region", "East")]), CellValue::Number(0.0));
|
m.set_cell(coord(&[("Measure", "Zero"), ("Region", "East")]), CellValue::Number(0.0));
|
||||||
m.add_formula(parse_formula("Result = Revenue / Zero", "Measure").unwrap());
|
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.
|
// 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")])), CellValue::Empty);
|
assert_eq!(m.evaluate(&coord(&[("Measure", "Result"), ("Region", "East")])), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -536,7 +538,7 @@ mod formula_tests {
|
|||||||
m.add_formula(parse_formula("NegRevenue = -Revenue", "Measure").unwrap());
|
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")]);
|
let k = coord(&[("Measure", "NegRevenue"), ("Region", "East")]);
|
||||||
assert_eq!(m.evaluate(&k), CellValue::Number(-1000.0));
|
assert_eq!(m.evaluate(&k), Some(CellValue::Number(-1000.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -549,7 +551,7 @@ mod formula_tests {
|
|||||||
}
|
}
|
||||||
m.set_cell(coord(&[("Measure", "Base")]), CellValue::Number(4.0));
|
m.set_cell(coord(&[("Measure", "Base")]), CellValue::Number(4.0));
|
||||||
m.add_formula(parse_formula("Squared = Base ^ 2", "Measure").unwrap());
|
m.add_formula(parse_formula("Squared = Base ^ 2", "Measure").unwrap());
|
||||||
assert_eq!(m.evaluate(&coord(&[("Measure", "Squared")])), CellValue::Number(16.0));
|
assert_eq!(m.evaluate(&coord(&[("Measure", "Squared")])), Some(CellValue::Number(16.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -558,7 +560,7 @@ mod formula_tests {
|
|||||||
m.add_formula(parse_formula("Ghost = NoSuchField - Cost", "Measure").unwrap());
|
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")]);
|
let k = coord(&[("Measure", "Ghost"), ("Region", "East")]);
|
||||||
assert_eq!(m.evaluate(&k), CellValue::Empty);
|
assert_eq!(m.evaluate(&k), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -566,7 +568,7 @@ mod formula_tests {
|
|||||||
let mut m = revenue_cost_model();
|
let mut m = revenue_cost_model();
|
||||||
m.add_formula(parse_formula("EastOnly = Revenue WHERE Region = \"East\"", "Measure").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"); }
|
if let Some(cat) = m.category_mut("Measure") { cat.add_item("EastOnly"); }
|
||||||
let val = m.evaluate(&coord(&[("Measure", "EastOnly"), ("Region", "East")])).as_f64().unwrap();
|
let val = m.evaluate(&coord(&[("Measure", "EastOnly"), ("Region", "East")])).and_then(|v| v.as_f64()).unwrap();
|
||||||
assert!(approx_eq(val, 1000.0));
|
assert!(approx_eq(val, 1000.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -575,7 +577,7 @@ mod formula_tests {
|
|||||||
let mut m = revenue_cost_model();
|
let mut m = revenue_cost_model();
|
||||||
m.add_formula(parse_formula("EastOnly = Revenue WHERE Region = \"East\"", "Measure").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"); }
|
if let Some(cat) = m.category_mut("Measure") { cat.add_item("EastOnly"); }
|
||||||
assert_eq!(m.evaluate(&coord(&[("Measure", "EastOnly"), ("Region", "West")])), CellValue::Empty);
|
assert_eq!(m.evaluate(&coord(&[("Measure", "EastOnly"), ("Region", "West")])), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -585,7 +587,7 @@ mod formula_tests {
|
|||||||
m.add_formula(parse_formula("Profit = Revenue - Cost - 100", "Measure").unwrap());
|
m.add_formula(parse_formula("Profit = Revenue - Cost - 100", "Measure").unwrap());
|
||||||
assert_eq!(m.formulas.len(), 1);
|
assert_eq!(m.formulas.len(), 1);
|
||||||
let k = coord(&[("Measure", "Profit"), ("Region", "East")]);
|
let k = coord(&[("Measure", "Profit"), ("Region", "East")]);
|
||||||
assert_eq!(m.evaluate(&k), CellValue::Number(300.0));
|
assert_eq!(m.evaluate(&k), Some(CellValue::Number(300.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -595,7 +597,7 @@ mod formula_tests {
|
|||||||
m.remove_formula("Profit", "Measure");
|
m.remove_formula("Profit", "Measure");
|
||||||
assert!(m.formulas.is_empty());
|
assert!(m.formulas.is_empty());
|
||||||
let k = coord(&[("Measure", "Profit"), ("Region", "East")]);
|
let k = coord(&[("Measure", "Profit"), ("Region", "East")]);
|
||||||
assert_eq!(m.evaluate(&k), CellValue::Empty);
|
assert_eq!(m.evaluate(&k), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -603,7 +605,7 @@ mod formula_tests {
|
|||||||
let mut m = revenue_cost_model();
|
let mut m = revenue_cost_model();
|
||||||
m.add_formula(parse_formula("Total = SUM(Revenue)", "Measure").unwrap());
|
m.add_formula(parse_formula("Total = SUM(Revenue)", "Measure").unwrap());
|
||||||
if let Some(cat) = m.category_mut("Measure") { cat.add_item("Total"); }
|
if let Some(cat) = m.category_mut("Measure") { cat.add_item("Total"); }
|
||||||
let val = m.evaluate(&coord(&[("Measure", "Total"), ("Region", "East")])).as_f64().unwrap();
|
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
|
// Revenue(East)=1000 only — Cost must not be included
|
||||||
assert_eq!(val, 1000.0);
|
assert_eq!(val, 1000.0);
|
||||||
}
|
}
|
||||||
@ -621,7 +623,7 @@ mod formula_tests {
|
|||||||
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());
|
m.add_formula(parse_formula("Count = COUNT(Sales)", "Measure").unwrap());
|
||||||
let val = m.evaluate(&coord(&[("Measure", "Count"), ("Region", "East")])).as_f64().unwrap();
|
let val = m.evaluate(&coord(&[("Measure", "Count"), ("Region", "East")])).and_then(|v| v.as_f64()).unwrap();
|
||||||
assert!(val >= 1.0);
|
assert!(val >= 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -635,7 +637,7 @@ mod formula_tests {
|
|||||||
}
|
}
|
||||||
m.set_cell(coord(&[("Measure", "X")]), CellValue::Number(10.0));
|
m.set_cell(coord(&[("Measure", "X")]), CellValue::Number(10.0));
|
||||||
m.add_formula(parse_formula("Result = IF(X > 5, 1, 0)", "Measure").unwrap());
|
m.add_formula(parse_formula("Result = IF(X > 5, 1, 0)", "Measure").unwrap());
|
||||||
assert_eq!(m.evaluate(&coord(&[("Measure", "Result")])), CellValue::Number(1.0));
|
assert_eq!(m.evaluate(&coord(&[("Measure", "Result")])), Some(CellValue::Number(1.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -648,7 +650,7 @@ mod formula_tests {
|
|||||||
}
|
}
|
||||||
m.set_cell(coord(&[("Measure", "X")]), CellValue::Number(3.0));
|
m.set_cell(coord(&[("Measure", "X")]), CellValue::Number(3.0));
|
||||||
m.add_formula(parse_formula("Result = IF(X > 5, 1, 0)", "Measure").unwrap());
|
m.add_formula(parse_formula("Result = IF(X > 5, 1, 0)", "Measure").unwrap());
|
||||||
assert_eq!(m.evaluate(&coord(&[("Measure", "Result")])), CellValue::Number(0.0));
|
assert_eq!(m.evaluate(&coord(&[("Measure", "Result")])), Some(CellValue::Number(0.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bug regression tests ─────────────────────────────────────────────────
|
// ── Bug regression tests ─────────────────────────────────────────────────
|
||||||
@ -677,7 +679,7 @@ mod formula_tests {
|
|||||||
let key_no_region = coord(&[("Measure", "Profit")]);
|
let key_no_region = coord(&[("Measure", "Profit")]);
|
||||||
// Expected: Empty (formula should not apply)
|
// Expected: Empty (formula should not apply)
|
||||||
// Bug: returns Number(42) — formula applied because absent category falls through
|
// Bug: returns Number(42) — formula applied because absent category falls through
|
||||||
assert_eq!(m.evaluate(&key_no_region), CellValue::Empty);
|
assert_eq!(m.evaluate(&key_no_region), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bug: SUM(Revenue) ignores its inner expression and sums all numeric
|
/// Bug: SUM(Revenue) ignores its inner expression and sums all numeric
|
||||||
@ -705,7 +707,7 @@ mod formula_tests {
|
|||||||
// Bug: returns 150 — inner Ref("Revenue") is ignored, Cost is also summed
|
// Bug: returns 150 — inner Ref("Revenue") is ignored, Cost is also summed
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
m.evaluate(&coord(&[("Measure", "Total"), ("Region", "East")])),
|
m.evaluate(&coord(&[("Measure", "Total"), ("Region", "East")])),
|
||||||
CellValue::Number(100.0),
|
Some(CellValue::Number(100.0)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -744,8 +746,8 @@ mod formula_tests {
|
|||||||
|
|
||||||
// Measure formula → 1, KPI formula → 2
|
// Measure formula → 1, KPI formula → 2
|
||||||
// Bug: first formula was replaced; {Measure=Profit} evaluates to Empty.
|
// Bug: first formula was replaced; {Measure=Profit} evaluates to Empty.
|
||||||
assert_eq!(m.evaluate(&coord(&[("Measure", "Profit")])), CellValue::Number(1.0));
|
assert_eq!(m.evaluate(&coord(&[("Measure", "Profit")])), Some(CellValue::Number(1.0)));
|
||||||
assert_eq!(m.evaluate(&coord(&[("KPI", "Profit")])), CellValue::Number(2.0));
|
assert_eq!(m.evaluate(&coord(&[("KPI", "Profit")])), Some(CellValue::Number(2.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bug: remove_formula matches by target name alone, so removing "Profit"
|
/// Bug: remove_formula matches by target name alone, so removing "Profit"
|
||||||
@ -768,7 +770,7 @@ mod formula_tests {
|
|||||||
// KPI formula must survive
|
// KPI formula must survive
|
||||||
// Bug: remove_formula("Profit") wipes both; formulas.len() == 0
|
// Bug: remove_formula("Profit") wipes both; formulas.len() == 0
|
||||||
assert_eq!(m.formulas.len(), 1);
|
assert_eq!(m.formulas.len(), 1);
|
||||||
assert_eq!(m.evaluate(&coord(&[("KPI", "Profit")])), CellValue::Number(2.0));
|
assert_eq!(m.evaluate(&coord(&[("KPI", "Profit")])), Some(CellValue::Number(2.0)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -846,7 +848,7 @@ mod five_category {
|
|||||||
fn all_sixteen_revenue_cells_stored() {
|
fn all_sixteen_revenue_cells_stored() {
|
||||||
let m = build_model();
|
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_empty())
|
.filter(|&&(r, p, c, t, _, _)| !m.get_cell(&coord(r, p, c, t, "Revenue")).is_none())
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(count, 16);
|
assert_eq!(count, 16);
|
||||||
}
|
}
|
||||||
@ -855,7 +857,7 @@ mod five_category {
|
|||||||
fn all_sixteen_cost_cells_stored() {
|
fn all_sixteen_cost_cells_stored() {
|
||||||
let m = build_model();
|
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_empty())
|
.filter(|&&(r, p, c, t, _, _)| !m.get_cell(&coord(r, p, c, t, "Cost")).is_none())
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(count, 16);
|
assert_eq!(count, 16);
|
||||||
}
|
}
|
||||||
@ -863,8 +865,8 @@ mod five_category {
|
|||||||
#[test]
|
#[test]
|
||||||
fn spot_check_raw_revenue() {
|
fn spot_check_raw_revenue() {
|
||||||
let m = build_model();
|
let m = build_model();
|
||||||
assert_eq!(m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")), &CellValue::Number(1_000.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")), &CellValue::Number(280.0));
|
assert_eq!(m.get_cell(&coord("West", "Pants", "Retail", "Q2", "Revenue")), Some(&CellValue::Number(280.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -881,7 +883,7 @@ mod five_category {
|
|||||||
for &(region, product, channel, time, rev, cost) in DATA {
|
for &(region, product, channel, time, rev, cost) in DATA {
|
||||||
let expected = rev - cost;
|
let expected = rev - cost;
|
||||||
let actual = m.evaluate(&coord(region, product, channel, time, "Profit"))
|
let actual = m.evaluate(&coord(region, product, channel, time, "Profit"))
|
||||||
.as_f64()
|
.and_then(|v| v.as_f64())
|
||||||
.unwrap_or_else(|| panic!("Profit empty at {region}/{product}/{channel}/{time}"));
|
.unwrap_or_else(|| panic!("Profit empty at {region}/{product}/{channel}/{time}"));
|
||||||
assert!(approx(actual, expected),
|
assert!(approx(actual, expected),
|
||||||
"Profit at {region}/{product}/{channel}/{time}: expected {expected}, got {actual}");
|
"Profit at {region}/{product}/{channel}/{time}: expected {expected}, got {actual}");
|
||||||
@ -894,7 +896,7 @@ mod five_category {
|
|||||||
for &(region, product, channel, time, rev, cost) in DATA {
|
for &(region, product, channel, time, rev, cost) in DATA {
|
||||||
let expected = (rev - cost) / rev;
|
let expected = (rev - cost) / rev;
|
||||||
let actual = m.evaluate(&coord(region, product, channel, time, "Margin"))
|
let actual = m.evaluate(&coord(region, product, channel, time, "Margin"))
|
||||||
.as_f64()
|
.and_then(|v| v.as_f64())
|
||||||
.unwrap_or_else(|| panic!("Margin empty at {region}/{product}/{channel}/{time}"));
|
.unwrap_or_else(|| panic!("Margin empty at {region}/{product}/{channel}/{time}"));
|
||||||
assert!(approx(actual, expected),
|
assert!(approx(actual, expected),
|
||||||
"Margin at {region}/{product}/{channel}/{time}: expected {expected:.4}, got {actual:.4}");
|
"Margin at {region}/{product}/{channel}/{time}: expected {expected:.4}, got {actual:.4}");
|
||||||
@ -904,7 +906,7 @@ mod five_category {
|
|||||||
#[test]
|
#[test]
|
||||||
fn chained_formula_profit_feeds_margin() {
|
fn chained_formula_profit_feeds_margin() {
|
||||||
let m = build_model();
|
let m = build_model();
|
||||||
let margin = m.evaluate(&coord("East", "Shirts", "Online", "Q1", "Margin")).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}");
|
assert!(approx(margin, 0.4), "expected 0.4, got {margin}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -912,9 +914,9 @@ mod five_category {
|
|||||||
fn update_revenue_updates_profit_and_margin() {
|
fn update_revenue_updates_profit_and_margin() {
|
||||||
let mut m = build_model();
|
let mut m = build_model();
|
||||||
m.set_cell(coord("East", "Shirts", "Online", "Q1", "Revenue"), CellValue::Number(1_500.0));
|
m.set_cell(coord("East", "Shirts", "Online", "Q1", "Revenue"), CellValue::Number(1_500.0));
|
||||||
let profit = m.evaluate(&coord("East", "Shirts", "Online", "Q1", "Profit")).as_f64().unwrap();
|
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}");
|
assert!(approx(profit, 900.0), "expected 900, got {profit}");
|
||||||
let margin = m.evaluate(&coord("East", "Shirts", "Online", "Q1", "Margin")).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}");
|
assert!(approx(margin, 0.6), "expected 0.6, got {margin}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -985,7 +987,7 @@ mod five_category {
|
|||||||
v.set_axis("Time", Axis::Column);
|
v.set_axis("Time", Axis::Column);
|
||||||
v.set_axis("Measure", Axis::Page);
|
v.set_axis("Measure", Axis::Page);
|
||||||
}
|
}
|
||||||
assert_eq!(m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")), &CellValue::Number(1_000.0));
|
assert_eq!(m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")), Some(&CellValue::Number(1_000.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1053,7 +1055,7 @@ mod prop_tests {
|
|||||||
}
|
}
|
||||||
let key = CellKey::new(vec![("Cat".into(), item_a.clone())]);
|
let key = CellKey::new(vec![("Cat".into(), item_a.clone())]);
|
||||||
m.set_cell(key.clone(), CellValue::Number(val));
|
m.set_cell(key.clone(), CellValue::Number(val));
|
||||||
prop_assert_eq!(m.evaluate(&key), 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.
|
/// Writing to cell A does not change cell B when the keys differ.
|
||||||
@ -1075,8 +1077,8 @@ mod prop_tests {
|
|||||||
let key_b = CellKey::new(vec![("Cat".into(), item_b)]);
|
let key_b = CellKey::new(vec![("Cat".into(), item_b)]);
|
||||||
m.set_cell(key_a.clone(), CellValue::Number(val_a));
|
m.set_cell(key_a.clone(), CellValue::Number(val_a));
|
||||||
m.set_cell(key_b.clone(), CellValue::Number(val_b));
|
m.set_cell(key_b.clone(), CellValue::Number(val_b));
|
||||||
prop_assert_eq!(m.evaluate(&key_a), CellValue::Number(val_a));
|
prop_assert_eq!(m.evaluate(&key_a), Some(CellValue::Number(val_a)));
|
||||||
prop_assert_eq!(m.evaluate(&key_b), CellValue::Number(val_b));
|
prop_assert_eq!(m.evaluate(&key_b), Some(CellValue::Number(val_b)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adding a category does not overwrite previously stored cell values.
|
/// Adding a category does not overwrite previously stored cell values.
|
||||||
@ -1091,7 +1093,7 @@ mod prop_tests {
|
|||||||
let key = CellKey::new(vec![("Measure".into(), "Revenue".into())]);
|
let key = CellKey::new(vec![("Measure".into(), "Revenue".into())]);
|
||||||
m.set_cell(key.clone(), CellValue::Number(val));
|
m.set_cell(key.clone(), CellValue::Number(val));
|
||||||
let _ = m.add_category(&new_cat); // may succeed or hit the 12-cat limit
|
let _ = m.add_category(&new_cat); // may succeed or hit the 12-cat limit
|
||||||
prop_assert_eq!(m.evaluate(&key), CellValue::Number(val));
|
prop_assert_eq!(m.evaluate(&key), Some(CellValue::Number(val)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// evaluate() is deterministic: calling it twice on the same key and model
|
/// evaluate() is deterministic: calling it twice on the same key and model
|
||||||
@ -1145,7 +1147,7 @@ mod prop_tests {
|
|||||||
m.set_cell(cost_key, CellValue::Number(cost));
|
m.set_cell(cost_key, CellValue::Number(cost));
|
||||||
m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap());
|
m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap());
|
||||||
let expected = rev - cost;
|
let expected = rev - cost;
|
||||||
prop_assert_eq!(m.evaluate(&profit_key), CellValue::Number(expected));
|
prop_assert_eq!(m.evaluate(&profit_key), Some(CellValue::Number(expected)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removing a formula restores the raw stored value (or Empty).
|
/// Removing a formula restores the raw stored value (or Empty).
|
||||||
@ -1172,10 +1174,10 @@ mod prop_tests {
|
|||||||
m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap());
|
m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap());
|
||||||
// Formula active — result is rev - cost
|
// Formula active — result is rev - cost
|
||||||
let with_formula = m.evaluate(&profit_key);
|
let with_formula = m.evaluate(&profit_key);
|
||||||
prop_assert_eq!(with_formula, CellValue::Number(rev - cost));
|
prop_assert_eq!(with_formula, Some(CellValue::Number(rev - cost)));
|
||||||
// Remove formula — cell has no raw value, so Empty
|
// Remove formula — cell has no raw value, so Empty
|
||||||
m.remove_formula("Profit", "Measure");
|
m.remove_formula("Profit", "Measure");
|
||||||
prop_assert_eq!(m.evaluate(&profit_key), CellValue::Empty);
|
prop_assert_eq!(m.evaluate(&profit_key), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,7 +100,8 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
|
|||||||
let row_values: Vec<String> = col_indices.iter().map(|&col_opt| {
|
let row_values: Vec<String> = col_indices.iter().map(|&col_opt| {
|
||||||
match (row_opt, col_opt) {
|
match (row_opt, col_opt) {
|
||||||
(Some(ri), Some(ci)) => layout.cell_key(ri, ci)
|
(Some(ri), Some(ci)) => layout.cell_key(ri, ci)
|
||||||
.map(|key| model.evaluate(&key).to_string())
|
.and_then(|key| model.evaluate(&key))
|
||||||
|
.map(|v| v.to_string())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -225,7 +225,8 @@ impl App {
|
|||||||
| (KeyCode::Char('i'), KeyModifiers::NONE)
|
| (KeyCode::Char('i'), KeyModifiers::NONE)
|
||||||
| (KeyCode::Char('a'), KeyModifiers::NONE) => {
|
| (KeyCode::Char('a'), KeyModifiers::NONE) => {
|
||||||
let current = self.selected_cell_key()
|
let current = self.selected_cell_key()
|
||||||
.map(|k| self.model.get_cell(&k).to_string())
|
.and_then(|k| self.model.get_cell(&k).cloned())
|
||||||
|
.map(|v| v.to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
self.mode = AppMode::Editing { buffer: current };
|
self.mode = AppMode::Editing { buffer: current };
|
||||||
}
|
}
|
||||||
@ -254,7 +255,6 @@ impl App {
|
|||||||
let cmd = match &value {
|
let cmd = match &value {
|
||||||
CellValue::Number(n) => Command::SetCell { coords, value: crate::command::types::CellValueArg::Number { number: *n } },
|
CellValue::Number(n) => Command::SetCell { coords, value: crate::command::types::CellValueArg::Number { number: *n } },
|
||||||
CellValue::Text(t) => Command::SetCell { coords, value: crate::command::types::CellValueArg::Text { text: t.clone() } },
|
CellValue::Text(t) => Command::SetCell { coords, value: crate::command::types::CellValueArg::Text { text: t.clone() } },
|
||||||
CellValue::Empty => Command::ClearCell { coords },
|
|
||||||
};
|
};
|
||||||
command::dispatch(&mut self.model, &cmd);
|
command::dispatch(&mut self.model, &cmd);
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
@ -312,8 +312,7 @@ impl App {
|
|||||||
// yy = yank current cell
|
// yy = yank current cell
|
||||||
('y', KeyCode::Char('y')) => {
|
('y', KeyCode::Char('y')) => {
|
||||||
if let Some(key) = self.selected_cell_key() {
|
if let Some(key) = self.selected_cell_key() {
|
||||||
let val = self.model.evaluate(&key);
|
self.yanked = self.model.evaluate(&key);
|
||||||
self.yanked = Some(val);
|
|
||||||
self.status_msg = "Yanked".to_string();
|
self.status_msg = "Yanked".to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -996,11 +995,10 @@ impl App {
|
|||||||
Some(k) => k,
|
Some(k) => k,
|
||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
let val = self.model.evaluate(&key);
|
let s = match self.model.evaluate(&key) {
|
||||||
let s = match &val {
|
Some(CellValue::Number(n)) => format!("{n}"),
|
||||||
CellValue::Number(n) => format!("{n}"),
|
Some(CellValue::Text(t)) => t,
|
||||||
CellValue::Text(t) => t.clone(),
|
None => String::new(),
|
||||||
CellValue::Empty => String::new(),
|
|
||||||
};
|
};
|
||||||
s.to_lowercase().contains(&query)
|
s.to_lowercase().contains(&query)
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|||||||
@ -97,7 +97,9 @@ impl<'a> GridWidget<'a> {
|
|||||||
};
|
};
|
||||||
let value = self.model.evaluate(&key);
|
let value = self.model.evaluate(&key);
|
||||||
|
|
||||||
let cell_str = format_value(&value, fmt_comma, fmt_decimals);
|
let cell_str = value.as_ref()
|
||||||
|
.map(|v| format_value(v, fmt_comma, fmt_decimals))
|
||||||
|
.unwrap_or_default();
|
||||||
let is_selected = ri == sel_row && ci == sel_col;
|
let is_selected = ri == sel_row && ci == sel_col;
|
||||||
let is_search_match = !self.search_query.is_empty()
|
let is_search_match = !self.search_query.is_empty()
|
||||||
&& cell_str.to_lowercase().contains(&self.search_query.to_lowercase());
|
&& cell_str.to_lowercase().contains(&self.search_query.to_lowercase());
|
||||||
@ -106,7 +108,7 @@ impl<'a> GridWidget<'a> {
|
|||||||
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
|
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||||
} else if is_search_match {
|
} else if is_search_match {
|
||||||
Style::default().fg(Color::Black).bg(Color::Yellow)
|
Style::default().fg(Color::Black).bg(Color::Yellow)
|
||||||
} else if matches!(value, CellValue::Empty) {
|
} else if value.is_none() {
|
||||||
Style::default().fg(Color::DarkGray)
|
Style::default().fg(Color::DarkGray)
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
@ -149,7 +151,7 @@ impl<'a> GridWidget<'a> {
|
|||||||
if x >= area.x + area.width { break; }
|
if x >= area.x + area.width { break; }
|
||||||
let total: f64 = (0..layout.row_count())
|
let total: f64 = (0..layout.row_count())
|
||||||
.filter_map(|ri| layout.cell_key(ri, ci))
|
.filter_map(|ri| layout.cell_key(ri, ci))
|
||||||
.map(|key| self.model.evaluate(&key).as_f64().unwrap_or(0.0))
|
.map(|key| self.model.evaluate(&key).and_then(|v| v.as_f64()).unwrap_or(0.0))
|
||||||
.sum();
|
.sum();
|
||||||
let total_str = format_f64(total, fmt_comma, fmt_decimals);
|
let total_str = format_f64(total, fmt_comma, fmt_decimals);
|
||||||
buf.set_string(x, y,
|
buf.set_string(x, y,
|
||||||
@ -202,7 +204,6 @@ fn format_value(v: &CellValue, comma: bool, decimals: u8) -> String {
|
|||||||
match v {
|
match v {
|
||||||
CellValue::Number(n) => format_f64(*n, comma, decimals),
|
CellValue::Number(n) => format_f64(*n, comma, decimals),
|
||||||
CellValue::Text(s) => s.clone(),
|
CellValue::Text(s) => s.clone(),
|
||||||
CellValue::Empty => String::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -162,7 +162,7 @@ mod tests {
|
|||||||
let layout = GridLayout::new(&m, m.active_view().unwrap());
|
let layout = GridLayout::new(&m, m.active_view().unwrap());
|
||||||
// Clothing = row 1, Feb = col 1
|
// Clothing = row 1, Feb = col 1
|
||||||
let key = layout.cell_key(1, 1).unwrap();
|
let key = layout.cell_key(1, 1).unwrap();
|
||||||
assert_eq!(m.evaluate(&key), CellValue::Number(42.0));
|
assert_eq!(m.evaluate(&key), Some(CellValue::Number(42.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user