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.
393 lines
11 KiB
Rust
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}"
|
|
);
|
|
}
|
|
}
|
|
}
|