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:
Ed L
2026-03-24 00:10:44 -07:00
parent 45b848dc67
commit 09caf815d3
4 changed files with 720 additions and 3 deletions

View File

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