From da076eadc8d4b5890e65ef9590ff1fd5925b0ed8 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 8 Apr 2026 22:27:37 -0700 Subject: [PATCH] test(model,ui): add tests for precision and cat tree Add unit tests for model precision and category tree rendering: - `src/model/types.rs` : Added `formula_chain_preserves_full_precision` to ensure formulas use full `f64` precision for calculations, even when display is rounded. - `src/ui/cat_tree.rs` : Added comprehensive tests for `build_cat_tree` , covering empty models (virtual categories), expanded/collapsed states, and item rendering. Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL) --- src/model/types.rs | 48 +++++++++++++++++++ src/ui/cat_tree.rs | 114 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) diff --git a/src/model/types.rs b/src/model/types.rs index e20515f..0450268 100644 --- a/src/model/types.rs +++ b/src/model/types.rs @@ -1136,6 +1136,54 @@ mod formula_tests { ); } + // ── Precision guarantee: formulas compute at full f64 precision ──────── + + /// Display rounds to the view's decimal setting, but the underlying + /// model must keep full f64 precision so chained calculations don't + /// accumulate rounding errors. + #[test] + fn formula_chain_preserves_full_precision() { + let mut m = Model::new("Test"); + m.add_category("Measure").unwrap(); + if let Some(cat) = m.category_mut("Measure") { + cat.add_item("Price"); + cat.add_item("Tax"); + cat.add_item("Total"); + } + // Price = 10.0, Tax = Price * 0.075 = 0.75, Total = Price + Tax = 10.75 + m.set_cell(coord(&[("Measure", "Price")]), CellValue::Number(10.0)); + m.add_formula(parse_formula("Tax = Price * 0.075", "Measure").unwrap()); + m.add_formula(parse_formula("Total = Price + Tax", "Measure").unwrap()); + + let tax = m + .evaluate(&coord(&[("Measure", "Tax")])) + .and_then(|v| v.as_f64()) + .unwrap(); + let total = m + .evaluate(&coord(&[("Measure", "Total")])) + .and_then(|v| v.as_f64()) + .unwrap(); + + // Full precision: Tax is exactly 0.75, Total is exactly 10.75 + assert!(approx_eq(tax, 0.75), "Tax should be 0.75 (full prec), got {tax}"); + assert!( + approx_eq(total, 10.75), + "Total should be 10.75 (full prec), got {total}" + ); + + // If display rounded Tax to 0 decimals (showing "1"), and the formula + // used that rounded value, Total would be 11 instead of 10.75. + // This proves the formula uses the raw f64, not the display string. + use crate::format::format_f64; + let tax_display = format_f64(tax, true, 0); + assert_eq!(tax_display, "1", "display rounds 0.75 → 1"); + // But the computed Total is 10.75, not 10 + 1 = 11 + assert!( + approx_eq(total, 10.75), + "Total must use full-precision Tax (0.75), not display-rounded (1)" + ); + } + /// Bug: remove_formula matches by target name alone, so removing "Profit" /// in "Measure" also destroys the "Profit" formula in "KPI". /// After targeted removal, the other category's formula must survive. diff --git a/src/ui/cat_tree.rs b/src/ui/cat_tree.rs index 6d589e9..c768998 100644 --- a/src/ui/cat_tree.rs +++ b/src/ui/cat_tree.rs @@ -49,3 +49,117 @@ pub fn build_cat_tree(model: &Model, expanded: &HashSet) -> Vec Model { + let mut m = Model::new("Test"); + for &(cat_name, items) in cats { + m.add_category(cat_name).unwrap(); + let cat = m.category_mut(cat_name).unwrap(); + for &item in items { + cat.add_item(item); + } + } + m + } + + #[test] + fn empty_model_has_only_virtual_categories() { + let m = Model::new("Test"); + let tree = build_cat_tree(&m, &HashSet::new()); + // Virtual categories (_Index, _Dim) should appear + let names: Vec<&str> = tree.iter().map(|e| e.cat_name()).collect(); + assert!(names.contains(&"_Index")); + assert!(names.contains(&"_Dim")); + } + + #[test] + fn collapsed_category_shows_header_only() { + let m = make_model_with_categories(&[("Region", &["North", "South"])]); + let tree = build_cat_tree(&m, &HashSet::new()); + let region_entries: Vec<_> = tree + .iter() + .filter(|e| e.cat_name() == "Region") + .collect(); + assert_eq!(region_entries.len(), 1); // just the header + assert!(matches!( + region_entries[0], + CatTreeEntry::Category { + expanded: false, + item_count: 2, + .. + } + )); + } + + #[test] + fn expanded_category_shows_items() { + let m = make_model_with_categories(&[("Region", &["North", "South"])]); + let mut expanded = HashSet::new(); + expanded.insert("Region".to_string()); + let tree = build_cat_tree(&m, &expanded); + let region_entries: Vec<_> = tree + .iter() + .filter(|e| e.cat_name() == "Region") + .collect(); + // Header + 2 items + assert_eq!(region_entries.len(), 3); + assert!(matches!(region_entries[0], CatTreeEntry::Category { expanded: true, .. })); + assert!(matches!(region_entries[1], CatTreeEntry::Item { .. })); + assert!(matches!(region_entries[2], CatTreeEntry::Item { .. })); + } + + #[test] + fn mixed_expanded_and_collapsed() { + let m = make_model_with_categories(&[ + ("Region", &["North", "South"]), + ("Product", &["Shirts", "Pants", "Hats"]), + ]); + let mut expanded = HashSet::new(); + expanded.insert("Product".to_string()); + let tree = build_cat_tree(&m, &expanded); + + let region_items: Vec<_> = tree + .iter() + .filter(|e| { + e.cat_name() == "Region" && matches!(e, CatTreeEntry::Item { .. }) + }) + .collect(); + let product_items: Vec<_> = tree + .iter() + .filter(|e| { + e.cat_name() == "Product" && matches!(e, CatTreeEntry::Item { .. }) + }) + .collect(); + assert_eq!(region_items.len(), 0); // collapsed + assert_eq!(product_items.len(), 3); // expanded + } + + #[test] + fn cat_name_works_for_both_variants() { + let header = CatTreeEntry::Category { + name: "Region".into(), + item_count: 2, + expanded: false, + }; + let item = CatTreeEntry::Item { + cat_name: "Region".into(), + item_name: "North".into(), + }; + assert_eq!(header.cat_name(), "Region"); + assert_eq!(item.cat_name(), "Region"); + } + + #[test] + fn expanding_nonexistent_category_is_harmless() { + let m = Model::new("Test"); + let mut expanded = HashSet::new(); + expanded.insert("DoesNotExist".to_string()); + let tree = build_cat_tree(&m, &expanded); + // Should just have virtual categories, no crash + assert!(!tree.is_empty()); + } +}