diff --git a/src/handlers/handlers_tests.rs b/src/handlers/handlers_tests.rs index 51519b95..72b879d0 100644 --- a/src/handlers/handlers_tests.rs +++ b/src/handlers/handlers_tests.rs @@ -3,6 +3,7 @@ mod tests { use crate::assert_modal_absent; use crate::assert_navigation_pushed; use crate::models::radarr_models::Movie; + use crate::models::readarr_models::Author; use crate::models::sonarr_models::Series; use crate::{assert_modal_present, assert_navigation_popped}; use pretty_assertions::assert_eq; @@ -41,6 +42,7 @@ mod tests { #[rstest] #[case(ActiveRadarrBlock::Movies.into(), ActiveRadarrBlock::SearchMovie.into())] #[case(ActiveSonarrBlock::Series.into(), ActiveSonarrBlock::SearchSeries.into())] + #[case(ActiveReadarrBlock::Authors.into(), ActiveReadarrBlock::SearchAuthors.into())] fn test_handle_events(#[case] base_block: Route, #[case] top_block: Route) { let mut app = App::test_default(); app.push_navigation_stack(base_block); @@ -55,6 +57,11 @@ mod tests { .radarr_data .movies .set_items(vec![Movie::default()]); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); handle_events(DEFAULT_KEYBINDINGS.esc.key, &mut app); diff --git a/src/handlers/readarr_handlers/library/library_handler_tests.rs b/src/handlers/readarr_handlers/library/library_handler_tests.rs new file mode 100644 index 00000000..8d7292b0 --- /dev/null +++ b/src/handlers/readarr_handlers/library/library_handler_tests.rs @@ -0,0 +1,1016 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use serde_json::Number; + use std::cmp::Ordering; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::{LibraryHandler, authors_sorting_options}; + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, LIBRARY_BLOCKS}; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_str_eq; + + use super::*; + use crate::simple_stateful_iterable_vec; + + #[rstest] + fn test_authors_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(simple_stateful_iterable_vec!( + Author, + HorizontallyScrollableText, + author_name + )); + + LibraryHandler::new(key, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 2" + ); + + LibraryHandler::new(key, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 1" + ); + } + + #[rstest] + fn test_authors_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(simple_stateful_iterable_vec!( + Author, + HorizontallyScrollableText, + author_name + )); + + LibraryHandler::new(key, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 1" + ); + } + } + + mod test_handle_home_end { + use pretty_assertions::assert_str_eq; + + use super::*; + use crate::extended_stateful_iterable_vec; + + #[test] + fn test_authors_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(extended_stateful_iterable_vec!( + Author, + HorizontallyScrollableText, + author_name + )); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 3" + ); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 1" + ); + } + + #[test] + fn test_authors_home_end_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(extended_stateful_iterable_vec!( + Author, + HorizontallyScrollableText, + author_name + )); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 1" + ); + } + } + + mod test_handle_delete { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_delete_prompt; + use crate::models::servarr_data::readarr::readarr_data::DELETE_AUTHOR_SELECTION_BLOCKS; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_author_delete() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + assert_delete_prompt!( + LibraryHandler, + app, + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::DeleteAuthorPrompt + ); + assert_eq!( + app.data.readarr_data.selected_block.blocks, + DELETE_AUTHOR_SELECTION_BLOCKS + ); + } + + #[test] + fn test_author_delete_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_authors_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(0); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::System.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::System.into()); + } + + #[rstest] + fn test_authors_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(0); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Downloads.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Downloads.into()); + } + + #[rstest] + fn test_left_right_update_all_authors_prompt_toggle( + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + key, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + LibraryHandler::new( + key, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_navigation_popped; + use crate::assert_navigation_pushed; + use crate::network::readarr_network::ReadarrEvent; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_author_details_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AuthorDetails.into()); + } + + #[test] + fn test_author_details_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + } + + #[test] + fn test_update_all_authors_prompt_confirm_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + + LibraryHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::UpdateAllAuthors + ); + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + } + + #[test] + fn test_update_all_authors_prompt_decline_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + + LibraryHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + } + } + + mod test_handle_esc { + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_update_all_authors_prompt_blocks_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + + LibraryHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use super::*; + use crate::models::servarr_data::readarr::readarr_data::EDIT_AUTHOR_SELECTION_BLOCKS; + use crate::network::readarr_network::ReadarrEvent; + use crate::{ + assert_modal_absent, assert_modal_present, assert_navigation_popped, assert_navigation_pushed, + }; + + #[test] + fn test_author_add_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AddAuthorSearchInput.into()); + assert!(app.ignore_special_keys_for_textbox_input); + assert_modal_present!(app.data.readarr_data.add_author_search); + } + + #[test] + fn test_author_add_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert!(!app.ignore_special_keys_for_textbox_input); + assert_modal_absent!(app.data.readarr_data.add_author_search); + } + + #[test] + fn test_author_edit_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.data.readarr_data.quality_profile_map = + bimap::BiMap::from_iter([(0i64, "Default Quality".to_owned())]); + app.data.readarr_data.metadata_profile_map = + bimap::BiMap::from_iter([(0i64, "Default Metadata".to_owned())]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::EditAuthorPrompt.into()); + assert_modal_present!(app.data.readarr_data.edit_author_modal); + assert_eq!( + app.data.readarr_data.selected_block.blocks, + EDIT_AUTHOR_SELECTION_BLOCKS + ); + } + + #[test] + fn test_author_edit_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert_modal_absent!(app.data.readarr_data.edit_author_modal); + } + + #[test] + fn test_toggle_monitoring_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_routing = false; + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert!(app.data.readarr_data.prompt_confirm); + assert!(app.is_routing); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::ToggleAuthorMonitoring(0) + ); + } + + #[test] + fn test_toggle_monitoring_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_routing = false; + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.is_routing); + } + + #[test] + fn test_update_all_authors_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + } + + #[test] + fn test_update_all_authors_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + } + + #[test] + fn test_refresh_authors_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_authors_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_update_all_authors_prompt_confirm() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::UpdateAllAuthors + ); + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + } + } + + #[test] + fn test_authors_sorting_options_name() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| { + a.author_name + .text + .to_lowercase() + .cmp(&b.author_name.text.to_lowercase()) + }; + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[0].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Name"); + } + + #[test] + fn test_authors_sorting_options_status() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| { + a.status + .to_string() + .to_lowercase() + .cmp(&b.status.to_string().to_lowercase()) + }; + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[1].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Status"); + } + + #[test] + fn test_authors_sorting_options_quality_profile() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = + |a, b| a.quality_profile_id.cmp(&b.quality_profile_id); + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[2].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Quality Profile"); + } + + #[test] + fn test_authors_sorting_options_metadata_profile() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = + |a, b| a.metadata_profile_id.cmp(&b.metadata_profile_id); + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[3].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Metadata Profile"); + } + + #[test] + fn test_authors_sorting_options_books() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.book_count) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.book_count)) + }; + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[4].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Books"); + } + + #[test] + fn test_authors_sorting_options_size() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.size_on_disk) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.size_on_disk)) + }; + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[5].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Size"); + } + + #[test] + fn test_authors_sorting_options_monitored() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| a.monitored.cmp(&b.monitored); + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[6].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Monitored"); + } + + #[test] + fn test_authors_sorting_options_tags() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| { + let a_str = a + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + let b_str = b + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + + a_str.cmp(&b_str) + }; + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[7].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Tags"); + } + + #[test] + fn test_library_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|readarr_block| { + if LIBRARY_BLOCKS.contains(&readarr_block) { + assert!( + LibraryHandler::accepts(readarr_block), + "{readarr_block} is not accepted by the LibraryHandler" + ); + } else { + assert!(!LibraryHandler::accepts(readarr_block)); + } + }); + } + + #[rstest] + fn test_library_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_library_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + let handler = LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_library_handler_not_ready_when_authors_is_empty() { + let mut app = App::test_default(); + app.is_loading = false; + + let handler = LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_library_handler_ready_when_not_loading_and_authors_is_not_empty() { + let mut app = App::test_default(); + app.is_loading = false; + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + let handler = LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ); + + assert!(handler.is_ready()); + } + + fn authors_vec() -> Vec { + vec![ + Author { + id: 3, + author_name: "Test Author 1".into(), + status: AuthorStatus::Ended, + quality_profile_id: 1, + metadata_profile_id: 1, + monitored: false, + tags: vec![Number::from(1), Number::from(2)], + statistics: Some(AuthorStatistics { + book_count: 5, + size_on_disk: 789, + ..AuthorStatistics::default() + }), + ..Author::default() + }, + Author { + id: 2, + author_name: "Test Author 2".into(), + status: AuthorStatus::Continuing, + quality_profile_id: 2, + metadata_profile_id: 2, + monitored: false, + tags: vec![Number::from(1), Number::from(3)], + statistics: Some(AuthorStatistics { + book_count: 10, + size_on_disk: 456, + ..AuthorStatistics::default() + }), + ..Author::default() + }, + Author { + id: 1, + author_name: "Test Author 3".into(), + status: AuthorStatus::Deleted, + quality_profile_id: 3, + metadata_profile_id: 3, + monitored: true, + tags: vec![Number::from(2), Number::from(3)], + statistics: Some(AuthorStatistics { + book_count: 3, + size_on_disk: 123, + ..AuthorStatistics::default() + }), + ..Author::default() + }, + ] + } +} diff --git a/src/handlers/readarr_handlers/library/mod.rs b/src/handlers/readarr_handlers/library/mod.rs new file mode 100644 index 00000000..61bfbb95 --- /dev/null +++ b/src/handlers/readarr_handlers/library/mod.rs @@ -0,0 +1,278 @@ +use crate::{ + app::App, + event::Key, + handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}, + matches_key, + models::{ + BlockSelectionState, HorizontallyScrollableText, + readarr_models::Author, + servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_AUTHOR_SELECTION_BLOCKS, EDIT_AUTHOR_SELECTION_BLOCKS, + LIBRARY_BLOCKS, + }, + stateful_table::SortOption, + }, + network::readarr_network::ReadarrEvent, +}; + +use super::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::models::Route; + +#[cfg(test)] +#[path = "library_handler_tests.rs"] +mod library_handler_tests; + +pub(super) struct LibraryHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + context: Option, +} + +impl LibraryHandler<'_, '_> { + fn extract_author_id(&self) -> i64 { + self.app.data.readarr_data.authors.current_selection().id + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, 'b> { + fn handle(&mut self) { + let authors_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::Authors.into()) + .sorting_block(ActiveReadarrBlock::AuthorsSortPrompt.into()) + .sort_options(authors_sorting_options()) + .searching_block(ActiveReadarrBlock::SearchAuthors.into()) + .search_error_block(ActiveReadarrBlock::SearchAuthorsError.into()) + .search_field_fn(|author| &author.author_name.text) + .filtering_block(ActiveReadarrBlock::FilterAuthors.into()) + .filter_error_block(ActiveReadarrBlock::FilterAuthorsError.into()) + .filter_field_fn(|author| &author.author_name.text); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.authors, + authors_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + LIBRARY_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> LibraryHandler<'a, 'b> { + LibraryHandler { + key, + app, + active_readarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.readarr_data.authors.is_empty() + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::Authors { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::Authors => handle_change_tab_left_right_keys(self.app, self.key), + ActiveReadarrBlock::UpdateAllAuthorsPrompt => handle_prompt_toggle(self.app, self.key), + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::Authors => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + } + ActiveReadarrBlock::UpdateAllAuthorsPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::UpdateAllAuthors); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::UpdateAllAuthorsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + _ => { + handle_clear_errors(self.app); + } + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::Authors => match key { + _ if matches_key!(add, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into()); + self.app.data.readarr_data.add_author_search = + Some(HorizontallyScrollableText::default()); + self.app.ignore_special_keys_for_textbox_input = true; + } + _ if matches_key!(toggle_monitoring, key) => { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::ToggleAuthorMonitoring(self.extract_author_id()), + ); + + self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()); + } + _ if matches_key!(edit, key) => { + self.app.data.readarr_data.edit_author_modal = Some((&self.app.data.readarr_data).into()); + self + .app + .push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + } + _ if matches_key!(update, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + } + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ => (), + }, + ActiveReadarrBlock::UpdateAllAuthorsPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::UpdateAllAuthors); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} + +fn authors_sorting_options() -> Vec> { + vec![ + SortOption { + name: "Name", + cmp_fn: Some(|a, b| { + a.author_name + .text + .to_lowercase() + .cmp(&b.author_name.text.to_lowercase()) + }), + }, + SortOption { + name: "Status", + cmp_fn: Some(|a, b| { + a.status + .to_string() + .to_lowercase() + .cmp(&b.status.to_string().to_lowercase()) + }), + }, + SortOption { + name: "Quality Profile", + cmp_fn: Some(|a, b| a.quality_profile_id.cmp(&b.quality_profile_id)), + }, + SortOption { + name: "Metadata Profile", + cmp_fn: Some(|a, b| a.metadata_profile_id.cmp(&b.metadata_profile_id)), + }, + SortOption { + name: "Books", + cmp_fn: Some(|a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.book_count) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.book_count)) + }), + }, + SortOption { + name: "Size", + cmp_fn: Some(|a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.size_on_disk) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.size_on_disk)) + }), + }, + SortOption { + name: "Monitored", + cmp_fn: Some(|a, b| a.monitored.cmp(&b.monitored)), + }, + SortOption { + name: "Tags", + cmp_fn: Some(|a, b| { + let a_str = a + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + let b_str = b + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + + a_str.cmp(&b_str) + }), + }, + ] +} diff --git a/src/handlers/readarr_handlers/mod.rs b/src/handlers/readarr_handlers/mod.rs index 9f389546..5ffee4ce 100644 --- a/src/handlers/readarr_handlers/mod.rs +++ b/src/handlers/readarr_handlers/mod.rs @@ -1,9 +1,13 @@ use super::KeyEventHandler; +use crate::handlers::readarr_handlers::library::LibraryHandler; use crate::models::Route; use crate::{ - app::App, event::Key, models::servarr_data::readarr::readarr_data::ActiveReadarrBlock, + app::App, event::Key, matches_key, + models::servarr_data::readarr::readarr_data::ActiveReadarrBlock, }; +mod library; + #[cfg(test)] #[path = "readarr_handler_tests.rs"] mod readarr_handler_tests; @@ -16,6 +20,15 @@ pub(super) struct ReadarrHandler<'a, 'b> { } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for ReadarrHandler<'a, 'b> { + fn handle(&mut self) { + match self.active_readarr_block { + _ if LibraryHandler::accepts(self.active_readarr_block) => { + LibraryHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); + } + _ => self.handle_key_event(), + } + } + fn accepts(_active_block: ActiveReadarrBlock) -> bool { true } @@ -72,3 +85,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for ReadarrHandler<'a, self.app.get_current_route() } } + +pub fn handle_change_tab_left_right_keys(app: &mut App<'_>, key: Key) { + let key_ref = key; + match key_ref { + _ if matches_key!(left, key, app.ignore_special_keys_for_textbox_input) => { + app.data.readarr_data.main_tabs.previous(); + app.pop_and_push_navigation_stack(app.data.readarr_data.main_tabs.get_active_route()); + } + _ if matches_key!(right, key, app.ignore_special_keys_for_textbox_input) => { + app.data.readarr_data.main_tabs.next(); + app.pop_and_push_navigation_stack(app.data.readarr_data.main_tabs.get_active_route()); + } + _ => (), + } +} diff --git a/src/handlers/readarr_handlers/readarr_handler_tests.rs b/src/handlers/readarr_handlers/readarr_handler_tests.rs index 64c4df03..d467d874 100644 --- a/src/handlers/readarr_handlers/readarr_handler_tests.rs +++ b/src/handlers/readarr_handlers/readarr_handler_tests.rs @@ -3,8 +3,9 @@ mod tests { use crate::app::App; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::handlers::KeyEventHandler; - use crate::handlers::readarr_handlers::ReadarrHandler; + use crate::handlers::readarr_handlers::{ReadarrHandler, handle_change_tab_left_right_keys}; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::{assert_navigation_pushed, test_handler_delegation}; use pretty_assertions::assert_eq; use rstest::rstest; use strum::IntoEnumIterator; @@ -49,4 +50,130 @@ mod tests { assert!(ReadarrHandler::accepts(readarr_block)); } } + + #[rstest] + #[case(0, ActiveReadarrBlock::System, ActiveReadarrBlock::Downloads)] + #[case(1, ActiveReadarrBlock::Authors, ActiveReadarrBlock::Blocklist)] + #[case(2, ActiveReadarrBlock::Downloads, ActiveReadarrBlock::History)] + #[case(3, ActiveReadarrBlock::Blocklist, ActiveReadarrBlock::RootFolders)] + #[case(4, ActiveReadarrBlock::History, ActiveReadarrBlock::Indexers)] + #[case(5, ActiveReadarrBlock::RootFolders, ActiveReadarrBlock::System)] + #[case(6, ActiveReadarrBlock::Indexers, ActiveReadarrBlock::Authors)] + fn test_readarr_handler_change_tab_left_right_keys( + #[case] index: usize, + #[case] left_block: ActiveReadarrBlock, + #[case] right_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.key); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + left_block.into() + ); + assert_navigation_pushed!(app, left_block.into()); + + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.key); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + right_block.into() + ); + assert_navigation_pushed!(app, right_block.into()); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::System, ActiveReadarrBlock::Downloads)] + #[case(1, ActiveReadarrBlock::Authors, ActiveReadarrBlock::Blocklist)] + #[case(2, ActiveReadarrBlock::Downloads, ActiveReadarrBlock::History)] + #[case(3, ActiveReadarrBlock::Blocklist, ActiveReadarrBlock::RootFolders)] + #[case(4, ActiveReadarrBlock::History, ActiveReadarrBlock::Indexers)] + #[case(5, ActiveReadarrBlock::RootFolders, ActiveReadarrBlock::System)] + #[case(6, ActiveReadarrBlock::Indexers, ActiveReadarrBlock::Authors)] + fn test_readarr_handler_change_tab_left_right_keys_alt_navigation( + #[case] index: usize, + #[case] left_block: ActiveReadarrBlock, + #[case] right_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.alt.unwrap()); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + left_block.into() + ); + assert_navigation_pushed!(app, left_block.into()); + + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.alt.unwrap()); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + right_block.into() + ); + assert_navigation_pushed!(app, right_block.into()); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::Authors)] + #[case(1, ActiveReadarrBlock::Downloads)] + #[case(2, ActiveReadarrBlock::Blocklist)] + #[case(3, ActiveReadarrBlock::History)] + #[case(4, ActiveReadarrBlock::RootFolders)] + #[case(5, ActiveReadarrBlock::Indexers)] + #[case(6, ActiveReadarrBlock::System)] + fn test_readarr_handler_change_tab_left_right_keys_alt_navigation_no_op_when_ignoring_quit_key( + #[case] index: usize, + #[case] block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(block.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.alt.unwrap()); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + block.into() + ); + assert_eq!(app.get_current_route(), block.into()); + + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.alt.unwrap()); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + block.into() + ); + assert_eq!(app.get_current_route(), block.into()); + } + + #[rstest] + fn test_delegates_library_blocks_to_library_handler( + #[values( + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::AuthorsSortPrompt, + ActiveReadarrBlock::FilterAuthors, + ActiveReadarrBlock::FilterAuthorsError, + ActiveReadarrBlock::SearchAuthors, + ActiveReadarrBlock::SearchAuthorsError, + ActiveReadarrBlock::UpdateAllAuthorsPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::Authors, + active_readarr_block + ); + } }