From 06e2d7841efbfb26a375b72302291914b4c76962 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 2 Sep 2026 16:57:47 -0600 Subject: [PATCH] feat(readarr): handle key events for the authors library table Add LibraryHandler, which owns the authors table and its sort, search, and filter overlays through TableHandlingConfig, along with the add, edit, toggle monitoring, update all, and refresh key actions and the delete prompt. Dispatch into it from ReadarrHandler and add the main tab left and right movement helper. authors_sorting_options offers eight sorts: name, status, quality profile, metadata profile, books, size, monitored, and tags. It omits the type and tracks sorts that the artists table has. GET /author never returns authorType, so that column would be empty on every row, and author statistics carry no track count. Also cover the Readarr arm of handle_events, which was previously unreachable from any test: neutering the arm entirely left the suite green. --- src/handlers/handlers_tests.rs | 7 + .../library/library_handler_tests.rs | 1016 +++++++++++++++++ src/handlers/readarr_handlers/library/mod.rs | 278 +++++ src/handlers/readarr_handlers/mod.rs | 30 +- .../readarr_handlers/readarr_handler_tests.rs | 129 ++- 5 files changed, 1458 insertions(+), 2 deletions(-) create mode 100644 src/handlers/readarr_handlers/library/library_handler_tests.rs create mode 100644 src/handlers/readarr_handlers/library/mod.rs 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 + ); + } }