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:
@ -330,3 +330,178 @@ mod data_store {
|
||||
assert_eq!(store.sum_matching(&[]), 10.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prop_tests {
|
||||
use proptest::prelude::*;
|
||||
use super::{CellKey, CellValue, DataStore};
|
||||
|
||||
/// Strategy: map of unique cat→item strings (HashMap guarantees unique keys).
|
||||
fn pairs_map() -> impl Strategy<Value = Vec<(String, String)>> {
|
||||
prop::collection::hash_map("[a-f]{1,5}", "[a-z]{1,5}", 1..6)
|
||||
.prop_map(|m| m.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Strategy: finite f64 (no NaN, no infinity).
|
||||
fn finite_f64() -> impl Strategy<Value = f64> {
|
||||
prop::num::f64::NORMAL.prop_filter("finite", |f| f.is_finite())
|
||||
}
|
||||
|
||||
proptest! {
|
||||
// ── CellKey invariants ────────────────────────────────────────────────
|
||||
|
||||
/// Pairs are always in ascending category-name order after construction.
|
||||
#[test]
|
||||
fn cellkey_always_sorted(pairs in pairs_map()) {
|
||||
let key = CellKey::new(pairs);
|
||||
for w in key.0.windows(2) {
|
||||
prop_assert!(w[0].0 <= w[1].0,
|
||||
"out of order: {:?} then {:?}", w[0].0, w[1].0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reversing the input produces an identical key (order-independence).
|
||||
#[test]
|
||||
fn cellkey_order_independent(pairs in pairs_map()) {
|
||||
let mut rev = pairs.clone();
|
||||
rev.reverse();
|
||||
prop_assert_eq!(CellKey::new(pairs), CellKey::new(rev));
|
||||
}
|
||||
|
||||
/// get(cat) finds every pair that was passed to new().
|
||||
#[test]
|
||||
fn cellkey_get_retrieves_all_pairs(pairs in pairs_map()) {
|
||||
let key = CellKey::new(pairs.clone());
|
||||
for (cat, item) in &pairs {
|
||||
prop_assert_eq!(key.get(cat), Some(item.as_str()),
|
||||
"missing {}={}", cat, item);
|
||||
}
|
||||
}
|
||||
|
||||
/// with(cat, val) — if cat already exists, it is updated in-place.
|
||||
#[test]
|
||||
fn cellkey_with_overwrites_existing(
|
||||
pairs in pairs_map(),
|
||||
new_item in "[a-z]{1,5}",
|
||||
) {
|
||||
let key = CellKey::new(pairs.clone());
|
||||
let cat = pairs[0].0.clone();
|
||||
let key2 = key.with(cat.clone(), new_item.clone());
|
||||
prop_assert_eq!(key2.get(&cat), Some(new_item.as_str()));
|
||||
// length unchanged when cat already exists
|
||||
prop_assert_eq!(key2.0.len(), pairs.len());
|
||||
}
|
||||
|
||||
/// with(fresh_cat, val) — a brand-new category is inserted and the
|
||||
/// result is still sorted.
|
||||
#[test]
|
||||
fn cellkey_with_adds_new_category(
|
||||
pairs in pairs_map(),
|
||||
// use g-z so it is unlikely to collide with a-f used in pairs_map
|
||||
fresh_cat in "[g-z]{1,5}",
|
||||
new_item in "[a-z]{1,5}",
|
||||
) {
|
||||
let key = CellKey::new(pairs.clone());
|
||||
// only run if fresh_cat is truly absent
|
||||
prop_assume!(!pairs.iter().any(|(c, _)| c == &fresh_cat));
|
||||
let key2 = key.with(fresh_cat.clone(), new_item.clone());
|
||||
prop_assert_eq!(key2.get(&fresh_cat), Some(new_item.as_str()));
|
||||
prop_assert_eq!(key2.0.len(), pairs.len() + 1);
|
||||
for w in key2.0.windows(2) {
|
||||
prop_assert!(w[0].0 <= w[1].0, "not sorted after with()");
|
||||
}
|
||||
}
|
||||
|
||||
/// without(cat) — the removed category is absent; all others survive.
|
||||
#[test]
|
||||
fn cellkey_without_removes_and_preserves(pairs in pairs_map()) {
|
||||
prop_assume!(pairs.len() >= 2);
|
||||
let removed_cat = pairs[0].0.clone();
|
||||
let key = CellKey::new(pairs.clone());
|
||||
let key2 = key.without(&removed_cat);
|
||||
prop_assert_eq!(key2.get(&removed_cat), None);
|
||||
for (cat, item) in pairs.iter().skip(1) {
|
||||
prop_assert_eq!(key2.get(cat), Some(item.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── DataStore invariants ──────────────────────────────────────────────
|
||||
|
||||
/// Setting a value and immediately getting it back returns the same value.
|
||||
#[test]
|
||||
fn datastore_set_get_roundtrip(pairs in pairs_map(), val in finite_f64()) {
|
||||
let key = CellKey::new(pairs);
|
||||
let mut store = DataStore::default();
|
||||
store.set(key.clone(), CellValue::Number(val));
|
||||
prop_assert_eq!(store.get(&key), &CellValue::Number(val));
|
||||
}
|
||||
|
||||
/// Setting Empty after a real value: get returns Empty (key is evicted).
|
||||
#[test]
|
||||
fn datastore_empty_evicts_key(pairs in pairs_map(), val in finite_f64()) {
|
||||
let key = CellKey::new(pairs);
|
||||
let mut store = DataStore::default();
|
||||
store.set(key.clone(), CellValue::Number(val));
|
||||
store.set(key.clone(), CellValue::Empty);
|
||||
prop_assert_eq!(store.get(&key), &CellValue::Empty);
|
||||
}
|
||||
|
||||
/// The last write to a key wins.
|
||||
#[test]
|
||||
fn datastore_last_write_wins(
|
||||
pairs in pairs_map(),
|
||||
v1 in finite_f64(),
|
||||
v2 in finite_f64(),
|
||||
) {
|
||||
let key = CellKey::new(pairs);
|
||||
let mut store = DataStore::default();
|
||||
store.set(key.clone(), CellValue::Number(v1));
|
||||
store.set(key.clone(), CellValue::Number(v2));
|
||||
prop_assert_eq!(store.get(&key), &CellValue::Number(v2));
|
||||
}
|
||||
|
||||
/// Two keys that differ by one coordinate are fully independent.
|
||||
#[test]
|
||||
fn datastore_distinct_keys_independent(
|
||||
pairs in pairs_map(),
|
||||
v1 in finite_f64(),
|
||||
v2 in finite_f64(),
|
||||
new_item in "[g-z]{1,5}",
|
||||
) {
|
||||
// key2 shares all categories with key1 but has a different item in
|
||||
// the first category, so key1 ≠ key2.
|
||||
let mut pairs2 = pairs.clone();
|
||||
let changed_cat = pairs2[0].0.clone();
|
||||
pairs2[0].1 = new_item.clone();
|
||||
prop_assume!(pairs[0].1 != new_item); // ensure they truly differ
|
||||
|
||||
let key1 = CellKey::new(pairs);
|
||||
let key2 = CellKey::new(pairs2);
|
||||
let mut store = DataStore::default();
|
||||
store.set(key1.clone(), CellValue::Number(v1));
|
||||
store.set(key2.clone(), CellValue::Number(v2));
|
||||
prop_assert_eq!(store.get(&key1), &CellValue::Number(v1),
|
||||
"key1 corrupted after writing key2 (diff in {})", changed_cat);
|
||||
prop_assert_eq!(store.get(&key2), &CellValue::Number(v2));
|
||||
}
|
||||
|
||||
/// Every cell returned by matching_cells actually satisfies the partial key.
|
||||
#[test]
|
||||
fn datastore_matching_cells_all_match_partial(
|
||||
pairs in pairs_map(),
|
||||
val in finite_f64(),
|
||||
) {
|
||||
prop_assume!(pairs.len() >= 2);
|
||||
let key = CellKey::new(pairs.clone());
|
||||
let mut store = DataStore::default();
|
||||
store.set(key, CellValue::Number(val));
|
||||
// partial = first pair only
|
||||
let partial = vec![pairs[0].clone()];
|
||||
let results = store.matching_cells(&partial);
|
||||
for (result_key, _) in &results {
|
||||
prop_assert!(result_key.matches_partial(&partial),
|
||||
"returned key {result_key} does not match partial {partial:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user