Files
gitlore/crates/lore-tui/tests/nav_property_tests.rs
teernisse 1e06cec3df feat(tui): add 10 navigation property tests (bd-3eis)
Deterministic seeded PRNG verifies NavigationStack invariants across
200k+ operations: depth >= 1, push/pop identity, forward cleared,
jump list only tracks detail screens, reset clears all, breadcrumbs
match depth, no panic under arbitrary sequences.
2026-02-19 01:13:20 -05:00

393 lines
11 KiB
Rust

//! Property tests for NavigationStack invariants (bd-3eis).
//!
//! Verifies that NavigationStack maintains its invariants under arbitrary
//! sequences of push/pop/forward/jump/reset operations, using deterministic
//! seeded random generation for reproducibility.
//!
//! All properties are tested across 10,000+ generated operation sequences.
use lore_tui::message::{EntityKey, Screen};
use lore_tui::navigation::NavigationStack;
// ---------------------------------------------------------------------------
// Seeded PRNG (xorshift64) — same as stress_tests.rs
// ---------------------------------------------------------------------------
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Self(seed.wrapping_add(1))
}
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
fn range(&mut self, max: u64) -> u64 {
self.next() % max
}
}
// ---------------------------------------------------------------------------
// Random Screen and Operation generators
// ---------------------------------------------------------------------------
fn random_screen(rng: &mut Rng) -> Screen {
match rng.range(12) {
0 => Screen::Dashboard,
1 => Screen::IssueList,
2 => Screen::IssueDetail(EntityKey::issue(1, rng.range(100) as i64)),
3 => Screen::MrList,
4 => Screen::MrDetail(EntityKey::mr(1, rng.range(100) as i64)),
5 => Screen::Search,
6 => Screen::Timeline,
7 => Screen::Who,
8 => Screen::Trace,
9 => Screen::FileHistory,
10 => Screen::Sync,
_ => Screen::Stats,
}
}
#[derive(Debug)]
enum NavOp {
Push(Screen),
Pop,
GoForward,
JumpBack,
JumpForward,
ResetTo(Screen),
}
fn random_op(rng: &mut Rng) -> NavOp {
match rng.range(10) {
// Push is the most common operation.
0..=4 => NavOp::Push(random_screen(rng)),
5 | 6 => NavOp::Pop,
7 => NavOp::GoForward,
8 => NavOp::JumpBack,
9 => NavOp::JumpForward,
_ => NavOp::ResetTo(random_screen(rng)),
}
}
fn apply_op(nav: &mut NavigationStack, op: &NavOp) {
match op {
NavOp::Push(screen) => {
nav.push(screen.clone());
}
NavOp::Pop => {
nav.pop();
}
NavOp::GoForward => {
nav.go_forward();
}
NavOp::JumpBack => {
nav.jump_back();
}
NavOp::JumpForward => {
nav.jump_forward();
}
NavOp::ResetTo(screen) => {
nav.reset_to(screen.clone());
}
}
}
// ---------------------------------------------------------------------------
// Properties
// ---------------------------------------------------------------------------
/// Property 1: Stack depth is always >= 1.
///
/// The NavigationStack always has at least one screen (the root).
/// No sequence of operations can empty it.
#[test]
fn prop_depth_always_at_least_one() {
for seed in 0..1000 {
let mut rng = Rng::new(seed);
let mut nav = NavigationStack::new();
for step in 0..100 {
let op = random_op(&mut rng);
apply_op(&mut nav, &op);
assert!(
nav.depth() >= 1,
"depth < 1 at seed={seed}, step={step}, op={op:?}"
);
}
}
}
/// Property 2: After push(X), current() == X.
///
/// Pushing a screen always makes it the current screen.
#[test]
fn prop_push_sets_current() {
for seed in 0..1000 {
let mut rng = Rng::new(seed);
let mut nav = NavigationStack::new();
for step in 0..100 {
let screen = random_screen(&mut rng);
nav.push(screen.clone());
assert_eq!(
nav.current(),
&screen,
"push didn't set current at seed={seed}, step={step}"
);
// Also do some random ops to make sequences varied.
if rng.range(3) == 0 {
let op = random_op(&mut rng);
apply_op(&mut nav, &op);
}
}
}
}
/// Property 3: After push(X) then pop(), current returns to previous.
///
/// Push-then-pop is identity on current() when no intermediate ops occur.
#[test]
fn prop_push_pop_returns_to_previous() {
for seed in 0..1000 {
let mut rng = Rng::new(seed);
let mut nav = NavigationStack::new();
// Do some random setup ops.
for _ in 0..rng.range(20) {
let op = random_op(&mut rng);
apply_op(&mut nav, &op);
}
let before = nav.current().clone();
let screen = random_screen(&mut rng);
nav.push(screen);
let pop_result = nav.pop();
assert!(pop_result.is_some(), "pop after push should succeed");
assert_eq!(
nav.current(),
&before,
"push-pop should restore previous at seed={seed}"
);
}
}
/// Property 4: Forward stack is cleared after any push.
///
/// Browser semantics: navigating to a new page clears the forward history.
#[test]
fn prop_forward_cleared_after_push() {
for seed in 0..1000 {
let mut rng = Rng::new(seed);
let mut nav = NavigationStack::new();
// Build up some forward stack via push-pop sequences.
for _ in 0..rng.range(10) + 2 {
nav.push(random_screen(&mut rng));
}
for _ in 0..rng.range(5) + 1 {
nav.pop();
}
// Now we might have forward entries.
// Push clears forward.
nav.push(random_screen(&mut rng));
assert!(
!nav.can_go_forward(),
"forward stack should be empty after push at seed={seed}"
);
}
}
/// Property 5: Jump list only records detail/entity screens.
///
/// The jump list (vim Ctrl+O/Ctrl+I) only tracks IssueDetail and MrDetail.
/// After many operations, every jump_back/jump_forward target should be
/// a detail screen.
#[test]
fn prop_jump_list_only_details() {
for seed in 0..500 {
let mut rng = Rng::new(seed);
let mut nav = NavigationStack::new();
// Do many operations to build up jump list.
for _ in 0..200 {
let op = random_op(&mut rng);
apply_op(&mut nav, &op);
}
// Walk the jump list backward and forward — every screen reached
// should be a detail screen.
let saved_current = nav.current().clone();
let mut visited = Vec::new();
while let Some(screen) = nav.jump_back() {
visited.push(screen.clone());
}
while let Some(screen) = nav.jump_forward() {
visited.push(screen.clone());
}
for screen in &visited {
assert!(
screen.is_detail_or_entity(),
"jump list contained non-detail screen {screen:?} at seed={seed}"
);
}
// Restore (this is a property test, not behavior test — we don't
// care about restoring, just checking the invariant).
let _ = saved_current;
}
}
/// Property 6: reset_to(X) clears all history, current() == X.
///
/// After reset, depth == 1, no back, no forward, empty jump list.
#[test]
fn prop_reset_clears_all() {
for seed in 0..1000 {
let mut rng = Rng::new(seed);
let mut nav = NavigationStack::new();
// Build up complex state.
for _ in 0..rng.range(50) + 10 {
let op = random_op(&mut rng);
apply_op(&mut nav, &op);
}
let target = random_screen(&mut rng);
nav.reset_to(target.clone());
assert_eq!(
nav.current(),
&target,
"reset didn't set current at seed={seed}"
);
assert_eq!(
nav.depth(),
1,
"reset didn't clear back stack at seed={seed}"
);
assert!(!nav.can_go_back(), "reset didn't clear back at seed={seed}");
assert!(
!nav.can_go_forward(),
"reset didn't clear forward at seed={seed}"
);
}
}
/// Property 7: Breadcrumbs length == depth.
///
/// breadcrumbs() always returns exactly as many entries as the navigation
/// depth (back_stack + 1 for current).
#[test]
fn prop_breadcrumbs_match_depth() {
for seed in 0..1000 {
let mut rng = Rng::new(seed);
let mut nav = NavigationStack::new();
for step in 0..100 {
let op = random_op(&mut rng);
apply_op(&mut nav, &op);
assert_eq!(
nav.breadcrumbs().len(),
nav.depth(),
"breadcrumbs length != depth at seed={seed}, step={step}, op={op:?}"
);
}
}
}
/// Property 8: No panic for any sequence of operations.
///
/// This is the "chaos monkey" property — the most important invariant.
#[test]
fn prop_no_panic_any_sequence() {
for seed in 0..1000 {
let mut rng = Rng::new(seed);
let mut nav = NavigationStack::new();
for _ in 0..200 {
let op = random_op(&mut rng);
apply_op(&mut nav, &op);
}
// If we got here, no panic occurred.
assert!(nav.depth() >= 1);
}
}
/// Property 9: Pop at root is always safe and returns None.
///
/// Repeated pops from any state eventually reach root and stop.
#[test]
fn prop_repeated_pop_reaches_root() {
for seed in 0..1000 {
let mut rng = Rng::new(seed);
let mut nav = NavigationStack::new();
// Push random depth.
let pushes = rng.range(20) + 1;
for _ in 0..pushes {
nav.push(random_screen(&mut rng));
}
// Pop until we can't.
let mut pops = 0;
while nav.pop().is_some() {
pops += 1;
assert!(pops <= pushes, "more pops than pushes at seed={seed}");
}
assert_eq!(
nav.depth(),
1,
"should be at root after exhaustive pop at seed={seed}"
);
// One more pop should be None.
assert!(nav.pop().is_none());
}
}
/// Property 10: Go forward after go back restores screen.
///
/// Pop-then-forward is identity (when no intermediate push).
#[test]
fn prop_pop_forward_identity() {
for seed in 0..1000 {
let mut rng = Rng::new(seed);
let mut nav = NavigationStack::new();
// Build some state.
for _ in 0..rng.range(10) + 2 {
nav.push(random_screen(&mut rng));
}
let before_pop = nav.current().clone();
if nav.pop().is_some() {
let result = nav.go_forward();
assert!(
result.is_some(),
"forward should succeed after pop at seed={seed}"
);
assert_eq!(
nav.current(),
&before_pop,
"pop-forward should restore screen at seed={seed}"
);
}
}
}