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

402
Cargo.lock generated
View File

@ -35,6 +35,21 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "2.11.0"
@ -131,7 +146,7 @@ dependencies = [
"crossterm_winapi",
"mio",
"parking_lot",
"rustix",
"rustix 0.38.44",
"signal-hook",
"signal-hook-mio",
"winapi",
@ -223,6 +238,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@ -239,6 +260,12 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
@ -256,6 +283,31 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@ -303,6 +355,12 @@ dependencies = [
"cc",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ident_case"
version = "1.0.1"
@ -319,6 +377,7 @@ dependencies = [
"dirs",
"flate2",
"indexmap",
"proptest",
"ratatui",
"serde",
"serde_json",
@ -385,6 +444,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.183"
@ -406,6 +471,12 @@ version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "lock_api"
version = "0.4.14"
@ -508,6 +579,25 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@ -517,6 +607,31 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "proptest"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
dependencies = [
"bit-set",
"bit-vec",
"bitflags",
"num-traits",
"rand",
"rand_chacha",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quote"
version = "1.0.45"
@ -526,6 +641,56 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core",
]
[[package]]
name = "ratatui"
version = "0.29.0"
@ -562,11 +727,17 @@ version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom",
"getrandom 0.2.17",
"libredox",
"thiserror",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustix"
version = "0.38.44"
@ -576,16 +747,41 @@ dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error",
"tempfile",
"wait-timeout",
]
[[package]]
name = "ryu"
version = "1.0.23"
@ -598,6 +794,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
@ -735,6 +937,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@ -755,6 +970,12 @@ dependencies = [
"syn",
]
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@ -790,12 +1011,45 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
@ -841,6 +1095,40 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "winapi"
version = "0.3.9"
@ -1070,6 +1358,114 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zerocopy"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"

View File

@ -22,6 +22,9 @@ flate2 = "1"
unicode-width = "0.2"
dirs = "5"
[dev-dependencies]
proptest = "1"
[profile.release]
opt-level = 3
lto = true

View File

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

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