Test: add unit tests co-located with the code they cover
- model/cell.rs: CellKey (sorting, get, with, without, matches_partial, display) and DataStore (set/get, overwrite, empty-removes-key, sum_matching, matching_cells, text exclusion) - model/category.rs: item ids, deduplication, group assignment, top_level_groups, item_index insertion order - formula/parser.rs: subtraction, WHERE clause, SUM/AVG, IF, numeric literal, chained arithmetic, error on missing = - view/view.rs: auto-axis assignment, set_axis, categories_on, page_selection, group collapse toggle, hide/show, cycle_axis (all transitions + scroll/selection reset) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@ -304,3 +304,60 @@ fn parse_comparison(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
|
||||
let right = parse_add_sub(tokens, pos)?;
|
||||
Ok(Expr::BinOp(op.to_string(), Box::new(left), Box::new(right)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_formula;
|
||||
use crate::formula::{Expr, AggFunc};
|
||||
|
||||
#[test]
|
||||
fn parse_simple_subtraction() {
|
||||
let f = parse_formula("Profit = Revenue - Cost", "Measure").unwrap();
|
||||
assert_eq!(f.target, "Profit");
|
||||
assert_eq!(f.target_category, "Measure");
|
||||
assert!(matches!(f.expr, Expr::BinOp(ref op, _, _) if op == "-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_where_clause() {
|
||||
let f = parse_formula("EastRev = Revenue WHERE Region = \"East\"", "Measure").unwrap();
|
||||
assert_eq!(f.target, "EastRev");
|
||||
let filter = f.filter.as_ref().unwrap();
|
||||
assert_eq!(filter.category, "Region");
|
||||
assert_eq!(filter.item, "East");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sum_aggregation() {
|
||||
let f = parse_formula("Total = SUM(Revenue)", "Measure").unwrap();
|
||||
assert!(matches!(f.expr, Expr::Agg(AggFunc::Sum, _, _)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_avg_aggregation() {
|
||||
let f = parse_formula("Avg = AVG(Revenue)", "Measure").unwrap();
|
||||
assert!(matches!(f.expr, Expr::Agg(AggFunc::Avg, _, _)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_if_expression() {
|
||||
let f = parse_formula("Capped = IF(Revenue > 1000, 1000, Revenue)", "Measure").unwrap();
|
||||
assert!(matches!(f.expr, Expr::If(_, _, _)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_numeric_literal() {
|
||||
let f = parse_formula("Fixed = 42", "Measure").unwrap();
|
||||
assert!(matches!(f.expr, Expr::Number(n) if (n - 42.0).abs() < 1e-10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_chained_arithmetic() {
|
||||
parse_formula("X = (A + B) * (C - D)", "Cat").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_missing_equals_returns_error() {
|
||||
assert!(parse_formula("BadFormula Revenue Cost", "Cat").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,3 +119,76 @@ impl Category {
|
||||
seen
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Category, Group};
|
||||
|
||||
fn cat() -> Category {
|
||||
Category::new(0, "Region")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_item_returns_sequential_ids() {
|
||||
let mut c = cat();
|
||||
let id0 = c.add_item("East");
|
||||
let id1 = c.add_item("West");
|
||||
assert_eq!(id0, 0);
|
||||
assert_eq!(id1, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_item_duplicate_returns_same_id() {
|
||||
let mut c = cat();
|
||||
let id1 = c.add_item("East");
|
||||
let id2 = c.add_item("East");
|
||||
assert_eq!(id1, id2);
|
||||
assert_eq!(c.items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_item_in_group_sets_group() {
|
||||
let mut c = cat();
|
||||
c.add_item_in_group("Jan", "Q1");
|
||||
let item = c.item_by_name("Jan").unwrap();
|
||||
assert_eq!(item.group.as_deref(), Some("Q1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_item_in_group_duplicate_returns_same_id() {
|
||||
let mut c = cat();
|
||||
let id1 = c.add_item_in_group("Jan", "Q1");
|
||||
let id2 = c.add_item_in_group("Jan", "Q1");
|
||||
assert_eq!(id1, id2);
|
||||
assert_eq!(c.items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_group_deduplicates() {
|
||||
let mut c = cat();
|
||||
c.add_group(Group::new("Q1"));
|
||||
c.add_group(Group::new("Q1"));
|
||||
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 item_index_reflects_insertion_order() {
|
||||
let mut c = cat();
|
||||
c.add_item("East");
|
||||
c.add_item("West");
|
||||
c.add_item("North");
|
||||
assert_eq!(c.item_index("East"), Some(0));
|
||||
assert_eq!(c.item_index("West"), Some(1));
|
||||
assert_eq!(c.item_index("North"), Some(2));
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,3 +156,177 @@ impl DataStore {
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cell_key {
|
||||
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())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coords_are_sorted_by_category_name() {
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_returns_item_for_known_category() {
|
||||
let k = key(&[("Region", "East"), ("Product", "Shirts")]);
|
||||
assert_eq!(k.get("Region"), Some("East"));
|
||||
assert_eq!(k.get("Product"), Some("Shirts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_returns_none_for_unknown_category() {
|
||||
let k = key(&[("Region", "East")]);
|
||||
assert_eq!(k.get("Measure"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_adds_new_coordinate_in_sorted_order() {
|
||||
let k = key(&[("Region", "East")]).with("Measure", "Revenue");
|
||||
assert_eq!(k.get("Measure"), Some("Revenue"));
|
||||
assert_eq!(k.get("Region"), Some("East"));
|
||||
assert_eq!(k.0[0].0, "Measure");
|
||||
assert_eq!(k.0[1].0, "Region");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_replaces_existing_coordinate() {
|
||||
let k = key(&[("Region", "East"), ("Product", "Shirts")]).with("Region", "West");
|
||||
assert_eq!(k.get("Region"), Some("West"));
|
||||
assert_eq!(k.0.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_removes_coordinate() {
|
||||
let k = key(&[("Region", "East"), ("Product", "Shirts")]).without("Region");
|
||||
assert_eq!(k.get("Region"), None);
|
||||
assert_eq!(k.get("Product"), Some("Shirts"));
|
||||
assert_eq!(k.0.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_missing_category_is_noop() {
|
||||
let k = key(&[("Region", "East")]).without("Measure");
|
||||
assert_eq!(k.0.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_partial_full_match() {
|
||||
let k = key(&[("Region", "East"), ("Product", "Shirts")]);
|
||||
let partial = vec![("Region".to_string(), "East".to_string())];
|
||||
assert!(k.matches_partial(&partial));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_partial_empty_matches_all() {
|
||||
let k = key(&[("Region", "East"), ("Product", "Shirts")]);
|
||||
assert!(k.matches_partial(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_partial_wrong_item_no_match() {
|
||||
let k = key(&[("Region", "East"), ("Product", "Shirts")]);
|
||||
let partial = vec![("Region".to_string(), "West".to_string())];
|
||||
assert!(!k.matches_partial(&partial));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_partial_missing_category_no_match() {
|
||||
let k = key(&[("Region", "East")]);
|
||||
let partial = vec![("Product".to_string(), "Shirts".to_string())];
|
||||
assert!(!k.matches_partial(&partial));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_format() {
|
||||
let k = key(&[("Region", "East")]);
|
||||
assert_eq!(k.to_string(), "{Region=East}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_missing_returns_empty() {
|
||||
let store = DataStore::new();
|
||||
assert_eq!(store.get(&key(&[("Region", "East")])), &CellValue::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_get_roundtrip() {
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_value() {
|
||||
let mut store = DataStore::new();
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_empty_removes_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);
|
||||
assert!(store.cells().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_matching_sums_across_dimension() {
|
||||
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));
|
||||
let partial = vec![("Measure".to_string(), "Revenue".to_string())];
|
||||
assert_eq!(store.sum_matching(&partial), 300.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_matching_empty_partial_sums_everything() {
|
||||
let mut store = DataStore::new();
|
||||
store.set(key(&[("Region", "East")]), CellValue::Number(10.0));
|
||||
store.set(key(&[("Region", "West")]), CellValue::Number(20.0));
|
||||
assert_eq!(store.sum_matching(&[]), 30.0);
|
||||
}
|
||||
|
||||
#[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));
|
||||
let partial = vec![("Measure".to_string(), "Revenue".to_string())];
|
||||
let cells = store.matching_cells(&partial);
|
||||
assert_eq!(cells.len(), 2);
|
||||
let values: Vec<f64> = cells.iter().filter_map(|(_, v)| v.as_f64()).collect();
|
||||
assert!(values.contains(&100.0));
|
||||
assert!(values.contains(&200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_values_excluded_from_sum() {
|
||||
let mut store = DataStore::new();
|
||||
store.set(key(&[("Cat", "A")]), CellValue::Number(10.0));
|
||||
store.set(key(&[("Cat", "B")]), CellValue::Text("hello".into()));
|
||||
assert_eq!(store.sum_matching(&[]), 10.0);
|
||||
}
|
||||
}
|
||||
|
||||
114
src/view/view.rs
114
src/view/view.rs
@ -125,3 +125,117 @@ impl View {
|
||||
self.col_offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::View;
|
||||
use crate::view::Axis;
|
||||
|
||||
fn view_with_cats(cats: &[&str]) -> View {
|
||||
let mut v = View::new("Test");
|
||||
for &c in cats { v.on_category_added(c); }
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_category_assigned_to_row() {
|
||||
let v = view_with_cats(&["Region"]);
|
||||
assert_eq!(v.axis_of("Region"), Axis::Row);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_category_assigned_to_column() {
|
||||
let v = view_with_cats(&["Region", "Product"]);
|
||||
assert_eq!(v.axis_of("Product"), Axis::Column);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn third_and_later_categories_assigned_to_page() {
|
||||
let v = view_with_cats(&["Region", "Product", "Time", "Scenario"]);
|
||||
assert_eq!(v.axis_of("Time"), Axis::Page);
|
||||
assert_eq!(v.axis_of("Scenario"), Axis::Page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_axis_changes_assignment() {
|
||||
let mut v = view_with_cats(&["Region", "Product"]);
|
||||
v.set_axis("Region", Axis::Column);
|
||||
assert_eq!(v.axis_of("Region"), Axis::Column);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn categories_on_returns_correct_list() {
|
||||
let v = view_with_cats(&["Region", "Product", "Time"]);
|
||||
assert_eq!(v.categories_on(Axis::Row), vec!["Region"]);
|
||||
assert_eq!(v.categories_on(Axis::Column), vec!["Product"]);
|
||||
assert_eq!(v.categories_on(Axis::Page), vec!["Time"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_of_unknown_category_returns_unassigned() {
|
||||
let v = View::new("Test");
|
||||
assert_eq!(v.axis_of("Ghost"), Axis::Unassigned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_selection_set_and_get() {
|
||||
let mut v = view_with_cats(&["Region", "Product", "Time"]);
|
||||
v.set_page_selection("Time", "Q1");
|
||||
assert_eq!(v.page_selection("Time"), Some("Q1"));
|
||||
assert_eq!(v.page_selection("Region"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_group_collapse_toggles_twice() {
|
||||
let mut v = View::new("Test");
|
||||
assert!(!v.is_group_collapsed("Time", "Q1"));
|
||||
v.toggle_group_collapse("Time", "Q1");
|
||||
assert!(v.is_group_collapsed("Time", "Q1"));
|
||||
v.toggle_group_collapse("Time", "Q1");
|
||||
assert!(!v.is_group_collapsed("Time", "Q1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_and_show_item() {
|
||||
let mut v = View::new("Test");
|
||||
assert!(!v.is_hidden("Region", "East"));
|
||||
v.hide_item("Region", "East");
|
||||
assert!(v.is_hidden("Region", "East"));
|
||||
v.show_item("Region", "East");
|
||||
assert!(!v.is_hidden("Region", "East"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_axis_row_to_column() {
|
||||
let mut v = view_with_cats(&["Region", "Product"]);
|
||||
v.cycle_axis("Region");
|
||||
assert_eq!(v.axis_of("Region"), Axis::Column);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_axis_column_to_page() {
|
||||
let mut v = view_with_cats(&["Region", "Product"]);
|
||||
v.set_axis("Product", Axis::Column);
|
||||
v.cycle_axis("Product");
|
||||
assert_eq!(v.axis_of("Product"), Axis::Page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_axis_page_to_row() {
|
||||
let mut v = view_with_cats(&["Region", "Product", "Time"]);
|
||||
v.cycle_axis("Time");
|
||||
assert_eq!(v.axis_of("Time"), Axis::Row);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_axis_resets_scroll_and_selection() {
|
||||
let mut v = view_with_cats(&["Region"]);
|
||||
v.row_offset = 5;
|
||||
v.col_offset = 3;
|
||||
v.selected = (2, 2);
|
||||
v.cycle_axis("Region");
|
||||
assert_eq!(v.row_offset, 0);
|
||||
assert_eq!(v.col_offset, 0);
|
||||
assert_eq!(v.selected, (0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user