test: add proptest property-based tests
Add proptest dependency and property tests for: - CellKey: key normalization invariants (sort order, dedup, round-trip, prefix non-equality, merge commutativity) - View: axis exclusivity, set_axis, idempotency, page_selection roundtrip, hide/show roundtrip, toggle_group_collapse involution Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
143
src/view/view.rs
143
src/view/view.rs
@ -239,3 +239,146 @@ mod tests {
|
||||
assert_eq!(v.selected, (0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prop_tests {
|
||||
use super::View;
|
||||
use crate::view::Axis;
|
||||
use proptest::prelude::*;
|
||||
|
||||
|
||||
fn unique_cat_names() -> impl Strategy<Value = Vec<String>> {
|
||||
prop::collection::hash_set("[A-Za-z][a-z]{1,7}", 1usize..=8)
|
||||
.prop_map(|s| s.into_iter().collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// axis_of and categories_on are consistent: cat is in categories_on(axis_of(cat))
|
||||
#[test]
|
||||
fn axis_of_and_categories_on_consistent(cats in unique_cat_names()) {
|
||||
let mut v = View::new("T");
|
||||
for c in &cats { v.on_category_added(c); }
|
||||
for c in &cats {
|
||||
let axis = v.axis_of(c);
|
||||
prop_assert_ne!(axis, Axis::Unassigned,
|
||||
"category '{}' should be assigned after on_category_added", c);
|
||||
let on_axis = v.categories_on(axis);
|
||||
prop_assert!(on_axis.contains(&c.as_str()),
|
||||
"categories_on({:?}) should contain '{}'", axis, c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each known category appears on exactly one axis
|
||||
#[test]
|
||||
fn each_category_on_exactly_one_axis(cats in unique_cat_names()) {
|
||||
let mut v = View::new("T");
|
||||
for c in &cats { v.on_category_added(c); }
|
||||
let all_axes = [Axis::Row, Axis::Column, Axis::Page];
|
||||
for c in &cats {
|
||||
let count = all_axes.iter()
|
||||
.filter(|&&ax| v.categories_on(ax).contains(&c.as_str()))
|
||||
.count();
|
||||
prop_assert_eq!(count, 1,
|
||||
"category '{}' should be on exactly one axis, found {}", c, count);
|
||||
}
|
||||
}
|
||||
|
||||
/// on_category_added is idempotent: adding same cat twice keeps original axis
|
||||
#[test]
|
||||
fn on_category_added_idempotent(cats in unique_cat_names()) {
|
||||
let mut v = View::new("T");
|
||||
for c in &cats { v.on_category_added(c); }
|
||||
let axes_before: Vec<_> = cats.iter().map(|c| v.axis_of(c)).collect();
|
||||
for c in &cats { v.on_category_added(c); }
|
||||
let axes_after: Vec<_> = cats.iter().map(|c| v.axis_of(c)).collect();
|
||||
prop_assert_eq!(axes_before, axes_after);
|
||||
}
|
||||
|
||||
/// set_axis updates axis_of for the target category
|
||||
#[test]
|
||||
fn set_axis_updates_axis_of(
|
||||
cats in unique_cat_names(),
|
||||
target_idx in 0usize..8,
|
||||
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page)],
|
||||
) {
|
||||
let mut v = View::new("T");
|
||||
for c in &cats { v.on_category_added(c); }
|
||||
let idx = target_idx % cats.len();
|
||||
let cat = &cats[idx];
|
||||
v.set_axis(cat, axis);
|
||||
prop_assert_eq!(v.axis_of(cat), axis);
|
||||
}
|
||||
|
||||
/// After set_axis(cat, X), cat is NOT in categories_on(Y) for Y ≠ X
|
||||
#[test]
|
||||
fn set_axis_exclusive(
|
||||
cats in unique_cat_names(),
|
||||
target_idx in 0usize..8,
|
||||
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page)],
|
||||
) {
|
||||
let mut v = View::new("T");
|
||||
for c in &cats { v.on_category_added(c); }
|
||||
let idx = target_idx % cats.len();
|
||||
let cat = &cats[idx];
|
||||
v.set_axis(cat, axis);
|
||||
let other_axes = [Axis::Row, Axis::Column, Axis::Page]
|
||||
.into_iter()
|
||||
.filter(|&a| a != axis);
|
||||
for other in other_axes {
|
||||
prop_assert!(!v.categories_on(other).contains(&cat.as_str()),
|
||||
"after set_axis({:?}), '{}' should not be in categories_on({:?})",
|
||||
axis, cat, other);
|
||||
}
|
||||
}
|
||||
|
||||
/// No two categories share the same axis entry (map guarantees uniqueness by key)
|
||||
/// — equivalently, total count across all axes equals number of known categories
|
||||
#[test]
|
||||
fn total_category_count_consistent(cats in unique_cat_names()) {
|
||||
let mut v = View::new("T");
|
||||
for c in &cats { v.on_category_added(c); }
|
||||
let total: usize = [Axis::Row, Axis::Column, Axis::Page]
|
||||
.iter()
|
||||
.map(|&ax| v.categories_on(ax).len())
|
||||
.sum();
|
||||
prop_assert_eq!(total, cats.len());
|
||||
}
|
||||
|
||||
/// page_selection round-trips: set then get returns the same value
|
||||
#[test]
|
||||
fn page_selection_roundtrip(
|
||||
cat in "[A-Za-z][a-z]{1,7}",
|
||||
item in "[A-Za-z][a-z]{1,7}",
|
||||
) {
|
||||
let mut v = View::new("T");
|
||||
v.set_page_selection(&cat, &item);
|
||||
prop_assert_eq!(v.page_selection(&cat), Some(item.as_str()));
|
||||
}
|
||||
|
||||
/// hide/show round-trip: hiding then showing leaves item visible
|
||||
#[test]
|
||||
fn hide_show_roundtrip(
|
||||
cat in "[A-Za-z][a-z]{1,7}",
|
||||
item in "[A-Za-z][a-z]{1,7}",
|
||||
) {
|
||||
let mut v = View::new("T");
|
||||
v.hide_item(&cat, &item);
|
||||
prop_assert!(v.is_hidden(&cat, &item));
|
||||
v.show_item(&cat, &item);
|
||||
prop_assert!(!v.is_hidden(&cat, &item));
|
||||
}
|
||||
|
||||
/// toggle_group_collapse is its own inverse
|
||||
#[test]
|
||||
fn toggle_group_collapse_involutive(
|
||||
cat in "[A-Za-z][a-z]{1,7}",
|
||||
group in "[A-Za-z][a-z]{1,7}",
|
||||
) {
|
||||
let mut v = View::new("T");
|
||||
let initial = v.is_group_collapsed(&cat, &group);
|
||||
v.toggle_group_collapse(&cat, &group);
|
||||
v.toggle_group_collapse(&cat, &group);
|
||||
prop_assert_eq!(v.is_group_collapsed(&cat, &group), initial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user