Skip to content

Commit e69e45a

Browse files
committed
Add proptests for aggregator::topological_sort_bottom_up
Two properties on top of the existing 4-node example test: 1. `forest_descendant_before_ancestor`: for any acyclic (id, parent_id) forest of up to 40 nodes, every node appears exactly once and every descendant lands before its (transitive) ancestor. 2. `arbitrary_input_is_panic_free_and_subset`: for any arbitrary input (including cycles, duplicates, and detached parent ids in the -50..50 range), the function returns without panicking, emits no duplicate ids, and emits only ids that appeared in the input. The second property pins the current "cycle nodes are silently dropped" behavior as a deliberate trade-off rather than a panic. If we ever decide cycles should be reported, we'll tighten this property.
1 parent 2e747bf commit e69e45a

1 file changed

Lines changed: 105 additions & 0 deletions

File tree

apps/desktop/src-tauri/src/indexing/aggregator.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,4 +985,109 @@ mod tests {
985985
assert!(pos_3 < pos_2);
986986
assert!(pos_2 < pos_1);
987987
}
988+
989+
// ── Property-based tests ─────────────────────────────────────────
990+
//
991+
// The function takes a slice of `(id, parent_id)` pairs and returns a
992+
// bottom-up ordering. The properties we pin here are the ones the callers
993+
// (`compute_all_aggregates`, the incremental aggregator paths) rely on:
994+
// each id appears at most once, descendants come before ancestors, and
995+
// pathological inputs (cycles, duplicates, large random forests) don't
996+
// panic or hang.
997+
998+
mod proptests {
999+
use super::*;
1000+
use proptest::prelude::*;
1001+
use std::collections::HashSet;
1002+
1003+
/// Generate an acyclic forest of `n` nodes where every node's parent
1004+
/// is either `0` (forest root, treated as "out of set") or one of
1005+
/// the already-emitted nodes. Returns `Vec<(id, parent_id)>` with
1006+
/// ids in `1..=n`.
1007+
fn forest_strategy(max_nodes: usize) -> impl Strategy<Value = Vec<(i64, i64)>> {
1008+
(1usize..=max_nodes).prop_flat_map(|n| {
1009+
// For each node i (1-indexed), pick a parent index in 0..i.
1010+
// Index 0 maps to parent_id 0 (sentinel for "no parent in set").
1011+
let parent_picks: Vec<_> = (0..n).map(|i| 0usize..=i).collect();
1012+
parent_picks.prop_map(move |picks| {
1013+
picks
1014+
.into_iter()
1015+
.enumerate()
1016+
.map(|(i, pick)| {
1017+
let id = (i as i64) + 1;
1018+
let parent_id = pick as i64; // 0 means "no parent in set"
1019+
(id, parent_id)
1020+
})
1021+
.collect::<Vec<_>>()
1022+
})
1023+
})
1024+
}
1025+
1026+
proptest! {
1027+
/// For any acyclic forest, the sort emits each node exactly once
1028+
/// and places every descendant before its ancestor.
1029+
#[test]
1030+
fn forest_descendant_before_ancestor(entries in forest_strategy(40)) {
1031+
let sorted = topological_sort_bottom_up(&entries);
1032+
1033+
// Every id appears exactly once.
1034+
let unique_ids: HashSet<i64> = entries.iter().map(|&(id, _)| id).collect();
1035+
prop_assert_eq!(sorted.len(), unique_ids.len(), "output length must match unique input ids");
1036+
let sorted_set: HashSet<i64> = sorted.iter().copied().collect();
1037+
prop_assert_eq!(&sorted_set, &unique_ids, "output must be a permutation of the input ids");
1038+
1039+
// Build position map and parent map.
1040+
let pos: HashMap<i64, usize> =
1041+
sorted.iter().enumerate().map(|(i, &id)| (id, i)).collect();
1042+
let parent_of: HashMap<i64, i64> =
1043+
entries.iter().copied().collect();
1044+
1045+
// For every (child, parent_in_set) pair, child must come first.
1046+
for &(id, pid) in &entries {
1047+
if pid != 0 && unique_ids.contains(&pid) {
1048+
let cp = pos[&id];
1049+
let pp = pos[&pid];
1050+
prop_assert!(
1051+
cp < pp,
1052+
"descendant {} at pos {} must come before ancestor {} at pos {}",
1053+
id, cp, pid, pp
1054+
);
1055+
}
1056+
// Transitively the same must hold for any ancestor — but
1057+
// chain through `parent_of` to be sure.
1058+
let mut cursor = pid;
1059+
let mut hops = 0;
1060+
while cursor != 0 && unique_ids.contains(&cursor) && hops < entries.len() + 1 {
1061+
prop_assert!(
1062+
pos[&id] < pos[&cursor],
1063+
"descendant {} must come before transitive ancestor {}",
1064+
id, cursor
1065+
);
1066+
cursor = *parent_of.get(&cursor).unwrap_or(&0);
1067+
hops += 1;
1068+
}
1069+
}
1070+
}
1071+
1072+
/// Robustness: the function must not panic and must produce a
1073+
/// subset of unique input ids, even on arbitrary (possibly
1074+
/// cyclic, duplicate, or detached) (id, parent_id) lists.
1075+
#[test]
1076+
fn arbitrary_input_is_panic_free_and_subset(
1077+
entries in proptest::collection::vec((-50i64..50i64, -50i64..50i64), 0..30)
1078+
) {
1079+
let sorted = topological_sort_bottom_up(&entries);
1080+
let unique_ids: HashSet<i64> = entries.iter().map(|&(id, _)| id).collect();
1081+
1082+
// No duplicates in output.
1083+
let sorted_set: HashSet<i64> = sorted.iter().copied().collect();
1084+
prop_assert_eq!(sorted.len(), sorted_set.len(), "output must have no duplicate ids");
1085+
1086+
// Output is a subset of unique input ids.
1087+
for id in &sorted_set {
1088+
prop_assert!(unique_ids.contains(id), "output id {} must come from input", id);
1089+
}
1090+
}
1091+
}
1092+
}
9881093
}

0 commit comments

Comments
 (0)