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:
Ed L
2026-03-21 23:28:48 -07:00
parent 6a9d28ecd6
commit 56d11aee74
4 changed files with 418 additions and 0 deletions

View File

@ -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));
}
}

View File

@ -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);
}
}