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()))
|
||||
.collect();
|
||||
let key = CellKey::new(kv);
|
||||
model.set_cell(key, CellValue::Empty);
|
||||
model.clear_cell(&key);
|
||||
CommandResult::ok()
|
||||
}
|
||||
|
||||
|
||||
@ -370,8 +370,8 @@ mod tests {
|
||||
("Measure".to_string(), "revenue".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_west).as_f64(), Some(200.0));
|
||||
assert_eq!(model.get_cell(&k_east).and_then(|v| v.as_f64()), Some(100.0));
|
||||
assert_eq!(model.get_cell(&k_west).and_then(|v| v.as_f64()), Some(200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -48,20 +48,15 @@ impl std::fmt::Display for CellKey {
|
||||
pub enum CellValue {
|
||||
Number(f64),
|
||||
Text(String),
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl CellValue {
|
||||
pub fn as_f64(&self) -> Option<f64> {
|
||||
match self {
|
||||
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 {
|
||||
@ -75,17 +70,10 @@ impl std::fmt::Display for CellValue {
|
||||
}
|
||||
}
|
||||
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
|
||||
/// to implement the `Serialize`-as-string requirement for JSON object keys.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@ -118,15 +106,11 @@ impl DataStore {
|
||||
}
|
||||
|
||||
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 {
|
||||
self.cells.get(key).unwrap_or(&CellValue::Empty)
|
||||
pub fn get(&self, key: &CellKey) -> Option<&CellValue> {
|
||||
self.cells.get(key)
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, key: &CellKey) -> Option<&mut CellValue> {
|
||||
@ -261,7 +245,7 @@ mod data_store {
|
||||
#[test]
|
||||
fn get_missing_returns_empty() {
|
||||
let store = DataStore::new();
|
||||
assert_eq!(store.get(&key(&[("Region", "East")])), &CellValue::Empty);
|
||||
assert_eq!(store.get(&key(&[("Region", "East")])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -269,7 +253,7 @@ mod data_store {
|
||||
let mut store = DataStore::new();
|
||||
let k = key(&[("Region", "East"), ("Product", "Shirts")]);
|
||||
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]
|
||||
@ -278,15 +262,15 @@ mod data_store {
|
||||
let k = key(&[("Region", "East")]);
|
||||
store.set(k.clone(), CellValue::Number(1.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]
|
||||
fn setting_empty_removes_key() {
|
||||
fn remove_evicts_key() {
|
||||
let mut store = DataStore::new();
|
||||
let k = key(&[("Region", "East")]);
|
||||
store.set(k.clone(), CellValue::Number(5.0));
|
||||
store.set(k.clone(), CellValue::Empty);
|
||||
store.remove(&k);
|
||||
assert!(store.cells().is_empty());
|
||||
}
|
||||
|
||||
@ -433,17 +417,17 @@ mod prop_tests {
|
||||
let key = CellKey::new(pairs);
|
||||
let mut store = DataStore::default();
|
||||
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]
|
||||
fn datastore_empty_evicts_key(pairs in pairs_map(), val in finite_f64()) {
|
||||
let key = CellKey::new(pairs);
|
||||
let mut store = DataStore::default();
|
||||
store.set(key.clone(), CellValue::Number(val));
|
||||
store.set(key.clone(), CellValue::Empty);
|
||||
prop_assert_eq!(store.get(&key), &CellValue::Empty);
|
||||
store.remove(&key);
|
||||
prop_assert_eq!(store.get(&key), None);
|
||||
}
|
||||
|
||||
/// The last write to a key wins.
|
||||
@ -457,7 +441,7 @@ mod prop_tests {
|
||||
let mut store = DataStore::default();
|
||||
store.set(key.clone(), CellValue::Number(v1));
|
||||
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.
|
||||
@ -480,9 +464,9 @@ mod prop_tests {
|
||||
let mut store = DataStore::default();
|
||||
store.set(key1.clone(), CellValue::Number(v1));
|
||||
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);
|
||||
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.
|
||||
|
||||
@ -67,7 +67,11 @@ impl Model {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -144,9 +148,9 @@ impl Model {
|
||||
self.categories.keys().map(|s| s.as_str()).collect()
|
||||
}
|
||||
|
||||
/// Evaluate a computed value at a given key, considering formulas
|
||||
pub fn evaluate(&self, key: &CellKey) -> CellValue {
|
||||
// Check if the last category dimension in the key corresponds to a formula target
|
||||
/// 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 {
|
||||
@ -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};
|
||||
|
||||
// Check WHERE filter first
|
||||
@ -166,7 +170,7 @@ impl Model {
|
||||
.map(|v| v == filter.item.as_str())
|
||||
.unwrap_or(false);
|
||||
if !matches {
|
||||
return self.data.get(context).clone();
|
||||
return self.data.get(context).cloned();
|
||||
}
|
||||
}
|
||||
|
||||
@ -190,7 +194,7 @@ impl Model {
|
||||
Expr::Ref(name) => {
|
||||
let cat = find_item_category(model, 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) => {
|
||||
use crate::formula::BinOp;
|
||||
@ -272,10 +276,8 @@ impl Model {
|
||||
}
|
||||
}
|
||||
|
||||
match eval_expr(&formula.expr, context, self, &formula.target_category) {
|
||||
Some(n) => CellValue::Number(n),
|
||||
None => CellValue::Empty,
|
||||
}
|
||||
eval_expr(&formula.expr, context, self, &formula.target_category)
|
||||
.map(CellValue::Number)
|
||||
}
|
||||
}
|
||||
|
||||
@ -332,14 +334,14 @@ mod model_tests {
|
||||
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), &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), &CellValue::Empty);
|
||||
assert_eq!(m.get_cell(&k), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -348,7 +350,7 @@ mod model_tests {
|
||||
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), &CellValue::Number(2.0));
|
||||
assert_eq!(m.get_cell(&k), Some(&CellValue::Number(2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -365,10 +367,10 @@ mod model_tests {
|
||||
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), &CellValue::Number(100.0));
|
||||
assert_eq!(m.get_cell(&k2), &CellValue::Number(200.0));
|
||||
assert_eq!(m.get_cell(&k3), &CellValue::Number(300.0));
|
||||
assert_eq!(m.get_cell(&k4), &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]
|
||||
@ -437,7 +439,7 @@ mod model_tests {
|
||||
m.create_view("Second");
|
||||
let k = coord(&[("Region", "East")]);
|
||||
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();
|
||||
m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap());
|
||||
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]
|
||||
@ -487,8 +489,8 @@ mod formula_tests {
|
||||
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, CellValue::Number(400.0));
|
||||
assert_eq!(west, CellValue::Number(300.0));
|
||||
assert_eq!(east, Some(CellValue::Number(400.0)));
|
||||
assert_eq!(west, Some(CellValue::Number(300.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -496,7 +498,7 @@ mod formula_tests {
|
||||
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")])).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));
|
||||
}
|
||||
|
||||
@ -509,7 +511,7 @@ mod formula_tests {
|
||||
cat.add_item("Profit");
|
||||
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));
|
||||
}
|
||||
|
||||
@ -527,7 +529,7 @@ mod formula_tests {
|
||||
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")])), CellValue::Empty);
|
||||
assert_eq!(m.evaluate(&coord(&[("Measure", "Result"), ("Region", "East")])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -536,7 +538,7 @@ mod formula_tests {
|
||||
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), CellValue::Number(-1000.0));
|
||||
assert_eq!(m.evaluate(&k), Some(CellValue::Number(-1000.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -549,7 +551,7 @@ 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")])), CellValue::Number(16.0));
|
||||
assert_eq!(m.evaluate(&coord(&[("Measure", "Squared")])), Some(CellValue::Number(16.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -558,7 +560,7 @@ mod formula_tests {
|
||||
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), CellValue::Empty);
|
||||
assert_eq!(m.evaluate(&k), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -566,7 +568,7 @@ mod formula_tests {
|
||||
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")])).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));
|
||||
}
|
||||
|
||||
@ -575,7 +577,7 @@ mod formula_tests {
|
||||
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")])), CellValue::Empty);
|
||||
assert_eq!(m.evaluate(&coord(&[("Measure", "EastOnly"), ("Region", "West")])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -585,7 +587,7 @@ mod formula_tests {
|
||||
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), CellValue::Number(300.0));
|
||||
assert_eq!(m.evaluate(&k), Some(CellValue::Number(300.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -595,7 +597,7 @@ mod formula_tests {
|
||||
m.remove_formula("Profit", "Measure");
|
||||
assert!(m.formulas.is_empty());
|
||||
let k = coord(&[("Measure", "Profit"), ("Region", "East")]);
|
||||
assert_eq!(m.evaluate(&k), CellValue::Empty);
|
||||
assert_eq!(m.evaluate(&k), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -603,7 +605,7 @@ mod formula_tests {
|
||||
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")])).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
|
||||
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.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);
|
||||
}
|
||||
|
||||
@ -635,7 +637,7 @@ 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")])), CellValue::Number(1.0));
|
||||
assert_eq!(m.evaluate(&coord(&[("Measure", "Result")])), Some(CellValue::Number(1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -648,7 +650,7 @@ 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")])), CellValue::Number(0.0));
|
||||
assert_eq!(m.evaluate(&coord(&[("Measure", "Result")])), Some(CellValue::Number(0.0)));
|
||||
}
|
||||
|
||||
// ── Bug regression tests ─────────────────────────────────────────────────
|
||||
@ -677,7 +679,7 @@ mod formula_tests {
|
||||
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), CellValue::Empty);
|
||||
assert_eq!(m.evaluate(&key_no_region), None);
|
||||
}
|
||||
|
||||
/// 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
|
||||
assert_eq!(
|
||||
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
|
||||
// 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(&[("KPI", "Profit")])), 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,7 +770,7 @@ 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")])), 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() {
|
||||
let m = build_model();
|
||||
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();
|
||||
assert_eq!(count, 16);
|
||||
}
|
||||
@ -855,7 +857,7 @@ mod five_category {
|
||||
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_empty())
|
||||
.filter(|&&(r, p, c, t, _, _)| !m.get_cell(&coord(r, p, c, t, "Cost")).is_none())
|
||||
.count();
|
||||
assert_eq!(count, 16);
|
||||
}
|
||||
@ -863,8 +865,8 @@ mod five_category {
|
||||
#[test]
|
||||
fn spot_check_raw_revenue() {
|
||||
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("West", "Pants", "Retail", "Q2", "Revenue")), &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]
|
||||
@ -881,7 +883,7 @@ mod five_category {
|
||||
for &(region, product, channel, time, rev, cost) in DATA {
|
||||
let expected = rev - cost;
|
||||
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}"));
|
||||
assert!(approx(actual, expected),
|
||||
"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 {
|
||||
let expected = (rev - cost) / rev;
|
||||
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}"));
|
||||
assert!(approx(actual, expected),
|
||||
"Margin at {region}/{product}/{channel}/{time}: expected {expected:.4}, got {actual:.4}");
|
||||
@ -904,7 +906,7 @@ mod five_category {
|
||||
#[test]
|
||||
fn chained_formula_profit_feeds_margin() {
|
||||
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}");
|
||||
}
|
||||
|
||||
@ -912,9 +914,9 @@ mod five_category {
|
||||
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")).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}");
|
||||
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}");
|
||||
}
|
||||
|
||||
@ -985,7 +987,7 @@ mod five_category {
|
||||
v.set_axis("Time", Axis::Column);
|
||||
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]
|
||||
@ -1053,7 +1055,7 @@ mod prop_tests {
|
||||
}
|
||||
let key = CellKey::new(vec![("Cat".into(), item_a.clone())]);
|
||||
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.
|
||||
@ -1075,8 +1077,8 @@ mod prop_tests {
|
||||
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), CellValue::Number(val_a));
|
||||
prop_assert_eq!(m.evaluate(&key_b), 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.
|
||||
@ -1091,7 +1093,7 @@ mod prop_tests {
|
||||
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), 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
|
||||
@ -1145,7 +1147,7 @@ mod prop_tests {
|
||||
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), CellValue::Number(expected));
|
||||
prop_assert_eq!(m.evaluate(&profit_key), Some(CellValue::Number(expected)));
|
||||
}
|
||||
|
||||
/// 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());
|
||||
// Formula active — result is rev - cost
|
||||
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
|
||||
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| {
|
||||
match (row_opt, col_opt) {
|
||||
(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(),
|
||||
_ => String::new(),
|
||||
}
|
||||
|
||||
@ -225,7 +225,8 @@ impl App {
|
||||
| (KeyCode::Char('i'), KeyModifiers::NONE)
|
||||
| (KeyCode::Char('a'), KeyModifiers::NONE) => {
|
||||
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();
|
||||
self.mode = AppMode::Editing { buffer: current };
|
||||
}
|
||||
@ -254,7 +255,6 @@ impl App {
|
||||
let cmd = match &value {
|
||||
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::Empty => Command::ClearCell { coords },
|
||||
};
|
||||
command::dispatch(&mut self.model, &cmd);
|
||||
self.dirty = true;
|
||||
@ -312,8 +312,7 @@ impl App {
|
||||
// yy = yank current cell
|
||||
('y', KeyCode::Char('y')) => {
|
||||
if let Some(key) = self.selected_cell_key() {
|
||||
let val = self.model.evaluate(&key);
|
||||
self.yanked = Some(val);
|
||||
self.yanked = self.model.evaluate(&key);
|
||||
self.status_msg = "Yanked".to_string();
|
||||
}
|
||||
}
|
||||
@ -996,11 +995,10 @@ impl App {
|
||||
Some(k) => k,
|
||||
None => return false,
|
||||
};
|
||||
let val = self.model.evaluate(&key);
|
||||
let s = match &val {
|
||||
CellValue::Number(n) => format!("{n}"),
|
||||
CellValue::Text(t) => t.clone(),
|
||||
CellValue::Empty => String::new(),
|
||||
let s = match self.model.evaluate(&key) {
|
||||
Some(CellValue::Number(n)) => format!("{n}"),
|
||||
Some(CellValue::Text(t)) => t,
|
||||
None => String::new(),
|
||||
};
|
||||
s.to_lowercase().contains(&query)
|
||||
}).collect();
|
||||
|
||||
@ -97,7 +97,9 @@ impl<'a> GridWidget<'a> {
|
||||
};
|
||||
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_search_match = !self.search_query.is_empty()
|
||||
&& 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)
|
||||
} else if is_search_match {
|
||||
Style::default().fg(Color::Black).bg(Color::Yellow)
|
||||
} else if matches!(value, CellValue::Empty) {
|
||||
} else if value.is_none() {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default()
|
||||
@ -149,7 +151,7 @@ impl<'a> GridWidget<'a> {
|
||||
if x >= area.x + area.width { break; }
|
||||
let total: f64 = (0..layout.row_count())
|
||||
.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();
|
||||
let total_str = format_f64(total, fmt_comma, fmt_decimals);
|
||||
buf.set_string(x, y,
|
||||
@ -202,7 +204,6 @@ fn format_value(v: &CellValue, comma: bool, decimals: u8) -> String {
|
||||
match v {
|
||||
CellValue::Number(n) => format_f64(*n, comma, decimals),
|
||||
CellValue::Text(s) => s.clone(),
|
||||
CellValue::Empty => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -162,7 +162,7 @@ mod tests {
|
||||
let layout = GridLayout::new(&m, m.active_view().unwrap());
|
||||
// Clothing = row 1, Feb = col 1
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user