A page is 20 rows and the index wrapped modulo one less than the row count, so the last row was unreachable by paging, 21-row and 5-row tables did nothing on page down from the first row, and 4-row tables cycled 0, 2, 1 and never reached row 3. Page down now clamps to the last row and page up clamps to the first. With no division left, a single-row table is safe, so the early return only guards the empty table again, matching scroll_to_top and scroll_to_bottom.
242 lines
6.7 KiB
Rust
242 lines
6.7 KiB
Rust
#[cfg(test)]
|
|
mod property_tests {
|
|
use proptest::prelude::*;
|
|
|
|
use crate::app::App;
|
|
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
|
|
use crate::event::Key;
|
|
use crate::handlers::handle_events;
|
|
use crate::handlers::handler_test_utils::test_utils::proptest_helpers::{
|
|
list_size, text_input_string,
|
|
};
|
|
use crate::models::radarr_models::Movie;
|
|
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
|
|
use crate::models::stateful_table::StatefulTable;
|
|
use crate::models::{Paginated, Scrollable, strip_non_search_characters};
|
|
|
|
proptest! {
|
|
#[test]
|
|
fn test_table_index_selection_safety(
|
|
list_size in list_size(),
|
|
index in 0usize..1000
|
|
) {
|
|
let mut table = StatefulTable::<Movie>::default();
|
|
let movies: Vec<Movie> = (0..list_size)
|
|
.map(|i| Movie {
|
|
id: i as i64,
|
|
..Movie::default()
|
|
})
|
|
.collect();
|
|
|
|
table.set_items(movies);
|
|
|
|
if index < list_size {
|
|
table.select_index(Some(index));
|
|
let selected = table.current_selection();
|
|
prop_assert_eq!(selected.id, index as i64);
|
|
} else {
|
|
table.select_index(Some(index));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_table_scroll_consistency(
|
|
list_size in list_size(),
|
|
scroll_amount in 0usize..20
|
|
) {
|
|
let mut table = StatefulTable::<Movie>::default();
|
|
let movies: Vec<Movie> = (0..list_size)
|
|
.map(|i| Movie {
|
|
id: i as i64,
|
|
..Movie::default()
|
|
})
|
|
.collect();
|
|
|
|
table.set_items(movies);
|
|
let initial_id = table.current_selection().id;
|
|
|
|
for _ in 0..scroll_amount {
|
|
table.scroll_down();
|
|
}
|
|
let after_down_id = table.current_selection().id;
|
|
|
|
prop_assert!(after_down_id >= initial_id);
|
|
prop_assert!(after_down_id < list_size as i64);
|
|
|
|
for _ in 0..scroll_amount {
|
|
table.scroll_up();
|
|
}
|
|
|
|
prop_assert!(table.current_selection().id <= initial_id);
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_table_safety(_scroll_ops in 0usize..50) {
|
|
let table = StatefulTable::<Movie>::default();
|
|
|
|
prop_assert!(table.is_empty());
|
|
prop_assert!(table.items.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_navigation_consistency(pushes in 1usize..20) {
|
|
let mut app = App::test_default();
|
|
let initial_route = app.get_current_route();
|
|
|
|
let routes = [
|
|
ActiveRadarrBlock::Movies,
|
|
ActiveRadarrBlock::Collections,
|
|
ActiveRadarrBlock::Downloads,
|
|
ActiveRadarrBlock::Blocklist,
|
|
];
|
|
|
|
for i in 0..pushes {
|
|
let route = routes[i % routes.len()];
|
|
app.push_navigation_stack(route.into());
|
|
}
|
|
|
|
let last_pushed = routes[(pushes - 1) % routes.len()];
|
|
prop_assert_eq!(app.get_current_route(), last_pushed.into());
|
|
|
|
for _ in 0..pushes {
|
|
app.pop_navigation_stack();
|
|
}
|
|
|
|
prop_assert_eq!(app.get_current_route(), initial_route);
|
|
}
|
|
|
|
#[test]
|
|
fn test_string_input_safety(input in text_input_string()) {
|
|
let mut app = App::test_default();
|
|
app.data.radarr_data.movies.set_items(vec![Movie::default()]);
|
|
app.push_navigation_stack(ActiveRadarrBlock::Movies.into());
|
|
handle_events(DEFAULT_KEYBINDINGS.filter.key, &mut app);
|
|
|
|
for character in input.chars() {
|
|
handle_events(Key::Char(character), &mut app);
|
|
}
|
|
|
|
prop_assert_eq!(app.get_current_route(), ActiveRadarrBlock::FilterMovies.into());
|
|
prop_assert_eq!(
|
|
app.data.radarr_data.movies.filter.as_ref().unwrap().text.as_str(),
|
|
input.as_str()
|
|
);
|
|
|
|
for _ in 0..input.chars().count() {
|
|
handle_events(DEFAULT_KEYBINDINGS.backspace.key, &mut app);
|
|
}
|
|
|
|
prop_assert_eq!(
|
|
app.data.radarr_data.movies.filter.as_ref().unwrap().text.as_str(),
|
|
""
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_table_data_integrity(
|
|
list_size in list_size()
|
|
) {
|
|
let mut table = StatefulTable::<Movie>::default();
|
|
let movies: Vec<Movie> = (0..list_size)
|
|
.map(|i| Movie {
|
|
id: i as i64,
|
|
title: format!("Movie {i}").into(),
|
|
..Movie::default()
|
|
})
|
|
.collect();
|
|
|
|
table.set_items(movies.clone());
|
|
let original_count = table.items.len();
|
|
|
|
prop_assert_eq!(table.items.len(), original_count);
|
|
|
|
for movie in &movies {
|
|
prop_assert!(table.items.iter().any(|m| m.id == movie.id));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_page_navigation_bounds(
|
|
list_size in list_size(),
|
|
page_ops in 0usize..10
|
|
) {
|
|
let mut table = StatefulTable::<Movie>::default();
|
|
let movies: Vec<Movie> = (0..list_size)
|
|
.map(|i| Movie {
|
|
id: i as i64,
|
|
..Movie::default()
|
|
})
|
|
.collect();
|
|
|
|
table.set_items(movies);
|
|
|
|
for i in 0..page_ops {
|
|
let previous_index = table.state.selected().unwrap();
|
|
|
|
if i % 2 == 0 {
|
|
table.page_down();
|
|
|
|
prop_assert_eq!(
|
|
table.state.selected(),
|
|
Some((previous_index + 20).min(list_size - 1))
|
|
);
|
|
} else {
|
|
table.page_up();
|
|
|
|
prop_assert_eq!(table.state.selected(), Some(previous_index.saturating_sub(20)));
|
|
}
|
|
|
|
let current = table.current_selection();
|
|
prop_assert!(current.id >= 0);
|
|
prop_assert!(current.id < list_size as i64);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_table_filter_size_invariant(
|
|
list_size in list_size(),
|
|
filter_term in text_input_string()
|
|
) {
|
|
let mut table = StatefulTable::<Movie>::default();
|
|
let movies: Vec<Movie> = (0..list_size)
|
|
.map(|i| Movie {
|
|
id: i as i64,
|
|
title: format!("Test Movie {}", i % 10).into(),
|
|
..Movie::default()
|
|
})
|
|
.collect();
|
|
table.set_items(movies.clone());
|
|
table.filter = Some(filter_term.clone().into());
|
|
let scrubbed_filter = strip_non_search_characters(&filter_term);
|
|
let matches_filter = |movie: &Movie| {
|
|
strip_non_search_characters(&movie.title.text).contains(&scrubbed_filter)
|
|
};
|
|
|
|
let has_match = table.apply_filter(|movie| &movie.title.text);
|
|
|
|
prop_assert_eq!(&table.items, &movies);
|
|
prop_assert!(table.filter.is_none());
|
|
|
|
if has_match {
|
|
let filtered_items = table.filtered_items.as_ref().unwrap();
|
|
|
|
prop_assert!(!filtered_items.is_empty());
|
|
prop_assert!(filtered_items.len() <= movies.len());
|
|
|
|
for movie in filtered_items {
|
|
prop_assert!(matches_filter(movie));
|
|
}
|
|
|
|
for movie in movies.iter().filter(|movie| !filtered_items.contains(movie)) {
|
|
prop_assert!(!matches_filter(movie));
|
|
}
|
|
} else {
|
|
prop_assert!(table.filtered_items.is_none());
|
|
prop_assert!(
|
|
filter_term.is_empty() || movies.iter().all(|movie| !matches_filter(movie))
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|