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)
This commit is contained in:
@ -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"
|
/// Bug: remove_formula matches by target name alone, so removing "Profit"
|
||||||
/// in "Measure" also destroys the "Profit" formula in "KPI".
|
/// in "Measure" also destroys the "Profit" formula in "KPI".
|
||||||
/// After targeted removal, the other category's formula must survive.
|
/// After targeted removal, the other category's formula must survive.
|
||||||
|
|||||||
@ -49,3 +49,117 @@ pub fn build_cat_tree(model: &Model, expanded: &HashSet<String>) -> Vec<CatTreeE
|
|||||||
}
|
}
|
||||||
entries
|
entries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_model_with_categories(cats: &[(&str, &[&str])]) -> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user