diff --git a/Cargo.lock b/Cargo.lock index 30203df..e40d413 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 0e9daef..01e4281 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,9 @@ flate2 = "1" unicode-width = "0.2" dirs = "5" +[dev-dependencies] +proptest = "1" + [profile.release] opt-level = 3 lto = true diff --git a/src/model/cell.rs b/src/model/cell.rs index d36285f..d0b19f3 100644 --- a/src/model/cell.rs +++ b/src/model/cell.rs @@ -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> { + 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 { + 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:?}"); + } + } + } +} diff --git a/src/view/view.rs b/src/view/view.rs index c3385f2..d123336 100644 --- a/src/view/view.rs +++ b/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> { + prop::collection::hash_set("[A-Za-z][a-z]{1,7}", 1usize..=8) + .prop_map(|s| s.into_iter().collect::>()) + } + + 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); + } + } +}