From fb465b06f8512e1490b14818166fbe8486829f73 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 00:55:36 -0600 Subject: [PATCH] feat(readarr): handle key events for the author details view --- .../history/history_handler_tests.rs | 125 ++ src/handlers/readarr_handlers/history/mod.rs | 44 + .../library/author_details_handler.rs | 448 +++++++ .../library/author_details_handler_tests.rs | 1183 +++++++++++++++++ .../library/library_handler_tests.rs | 10 +- src/handlers/readarr_handlers/library/mod.rs | 14 +- src/handlers/readarr_handlers/mod.rs | 1 + 7 files changed, 1821 insertions(+), 4 deletions(-) create mode 100644 src/handlers/readarr_handlers/history/history_handler_tests.rs create mode 100644 src/handlers/readarr_handlers/history/mod.rs create mode 100644 src/handlers/readarr_handlers/library/author_details_handler.rs create mode 100644 src/handlers/readarr_handlers/library/author_details_handler_tests.rs diff --git a/src/handlers/readarr_handlers/history/history_handler_tests.rs b/src/handlers/readarr_handlers/history/history_handler_tests.rs new file mode 100644 index 00000000..7a29b745 --- /dev/null +++ b/src/handlers/readarr_handlers/history/history_handler_tests.rs @@ -0,0 +1,125 @@ +#[cfg(test)] +mod tests { + use std::cmp::Ordering; + + use chrono::DateTime; + use pretty_assertions::{assert_eq, assert_str_eq}; + + use crate::handlers::readarr_handlers::history::history_sorting_options; + use crate::models::readarr_models::{ReadarrHistoryEventType, ReadarrHistoryItem}; + use crate::models::servarr_models::{Quality, QualityWrapper}; + + #[test] + fn test_history_sorting_options_source_title() { + let expected_cmp_fn: fn(&ReadarrHistoryItem, &ReadarrHistoryItem) -> Ordering = |a, b| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[0].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Source Title"); + } + + #[test] + fn test_history_sorting_options_event_type() { + let expected_cmp_fn: fn(&ReadarrHistoryItem, &ReadarrHistoryItem) -> Ordering = |a, b| { + a.event_type + .to_string() + .to_lowercase() + .cmp(&b.event_type.to_string().to_lowercase()) + }; + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[1].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Event Type"); + } + + #[test] + fn test_history_sorting_options_quality() { + let expected_cmp_fn: fn(&ReadarrHistoryItem, &ReadarrHistoryItem) -> Ordering = |a, b| { + a.quality + .quality + .name + .to_lowercase() + .cmp(&b.quality.quality.name.to_lowercase()) + }; + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[2].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Quality"); + } + + #[test] + fn test_history_sorting_options_date() { + let expected_cmp_fn: fn(&ReadarrHistoryItem, &ReadarrHistoryItem) -> Ordering = + |a, b| a.date.cmp(&b.date); + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[3].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Date"); + } + + fn history_vec() -> Vec { + vec![ + ReadarrHistoryItem { + id: 3, + source_title: "test 1".into(), + event_type: ReadarrHistoryEventType::Grabbed, + quality: QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-01-10T07:28:45Z").unwrap()), + ..ReadarrHistoryItem::default() + }, + ReadarrHistoryItem { + id: 2, + source_title: "test 2".into(), + event_type: ReadarrHistoryEventType::DownloadImported, + quality: QualityWrapper { + quality: Quality { + name: "AZW3".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + ..ReadarrHistoryItem::default() + }, + ReadarrHistoryItem { + id: 1, + source_title: "test 3".into(), + event_type: ReadarrHistoryEventType::BookFileDeleted, + quality: QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()), + ..ReadarrHistoryItem::default() + }, + ] + } +} diff --git a/src/handlers/readarr_handlers/history/mod.rs b/src/handlers/readarr_handlers/history/mod.rs new file mode 100644 index 00000000..5ee320e4 --- /dev/null +++ b/src/handlers/readarr_handlers/history/mod.rs @@ -0,0 +1,44 @@ +use crate::models::readarr_models::ReadarrHistoryItem; +use crate::models::stateful_table::SortOption; + +#[cfg(test)] +#[path = "history_handler_tests.rs"] +mod history_handler_tests; + +pub(in crate::handlers::readarr_handlers) fn history_sorting_options() +-> Vec> { + vec![ + SortOption { + name: "Source Title", + cmp_fn: Some(|a, b| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }), + }, + SortOption { + name: "Event Type", + cmp_fn: Some(|a, b| { + a.event_type + .to_string() + .to_lowercase() + .cmp(&b.event_type.to_string().to_lowercase()) + }), + }, + SortOption { + name: "Quality", + cmp_fn: Some(|a, b| { + a.quality + .quality + .name + .to_lowercase() + .cmp(&b.quality.quality.name.to_lowercase()) + }), + }, + SortOption { + name: "Date", + cmp_fn: Some(|a, b| a.date.cmp(&b.date)), + }, + ] +} diff --git a/src/handlers/readarr_handlers/library/author_details_handler.rs b/src/handlers/readarr_handlers/library/author_details_handler.rs new file mode 100644 index 00000000..d10758a8 --- /dev/null +++ b/src/handlers/readarr_handlers/library/author_details_handler.rs @@ -0,0 +1,448 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::history::history_sorting_options; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::readarr_models::{Book, ReadarrHistoryItem, ReadarrRelease}; +use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, DELETE_BOOK_SELECTION_BLOCKS, + EDIT_AUTHOR_SELECTION_BLOCKS, +}; +use crate::models::servarr_models::ReleaseDownloadBody; +use crate::models::stateful_table::SortOption; +use crate::models::{BlockSelectionState, Route}; +use crate::network::readarr_network::ReadarrEvent; +use serde_json::Number; + +#[cfg(test)] +#[path = "author_details_handler_tests.rs"] +mod author_details_handler_tests; + +pub(in crate::handlers::readarr_handlers) struct AuthorDetailsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl AuthorDetailsHandler<'_, '_> { + fn extract_author_id(&self) -> i64 { + self.app.data.readarr_data.authors.current_selection().id + } + + fn extract_book_id(&self) -> i64 { + self.app.data.readarr_data.books.current_selection().id + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for AuthorDetailsHandler<'a, 'b> { + fn handle(&mut self) { + let books_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::AuthorDetails.into()) + .searching_block(ActiveReadarrBlock::SearchBooks.into()) + .search_error_block(ActiveReadarrBlock::SearchBooksError.into()) + .search_field_fn(|book: &Book| &book.title.text); + + let author_history_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::AuthorHistory.into()) + .sorting_block(ActiveReadarrBlock::AuthorHistorySortPrompt.into()) + .sort_options(history_sorting_options()) + .searching_block(ActiveReadarrBlock::SearchAuthorHistory.into()) + .search_error_block(ActiveReadarrBlock::SearchAuthorHistoryError.into()) + .search_field_fn(|history_item: &ReadarrHistoryItem| &history_item.source_title.text) + .filtering_block(ActiveReadarrBlock::FilterAuthorHistory.into()) + .filter_error_block(ActiveReadarrBlock::FilterAuthorHistoryError.into()) + .filter_field_fn(|history_item: &ReadarrHistoryItem| &history_item.source_title.text); + + let author_releases_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::ManualAuthorSearch.into()) + .sorting_block(ActiveReadarrBlock::ManualAuthorSearchSortPrompt.into()) + .sort_options(releases_sorting_options()); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.books, + books_table_handling_config, + ) && !handle_table( + self, + |app| &mut app.data.readarr_data.author_history, + author_history_table_handling_config, + ) && !handle_table( + self, + |app| &mut app.data.readarr_data.author_releases, + author_releases_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + AUTHOR_DETAILS_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, + ) -> AuthorDetailsHandler<'a, 'b> { + AuthorDetailsHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.app.is_loading { + return false; + } + + match self.active_readarr_block { + ActiveReadarrBlock::AuthorHistory => !self.app.data.readarr_data.author_history.is_empty(), + ActiveReadarrBlock::ManualAuthorSearch => { + !self.app.data.readarr_data.author_releases.is_empty() + } + _ => true, + } + } + + 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::AuthorDetails { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AuthorDetails + | ActiveReadarrBlock::AuthorHistory + | ActiveReadarrBlock::ManualAuthorSearch => match self.key { + _ if matches_key!(left, self.key) => { + self.app.data.readarr_data.author_info_tabs.previous(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .readarr_data + .author_info_tabs + .get_active_route(), + ); + } + _ if matches_key!(right, self.key) => { + self.app.data.readarr_data.author_info_tabs.next(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .readarr_data + .author_info_tabs + .get_active_route(), + ); + } + _ => (), + }, + ActiveReadarrBlock::UpdateAndScanAuthorPrompt + | ActiveReadarrBlock::AutomaticallySearchAuthorPrompt + | ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt => { + handle_prompt_toggle(self.app, self.key); + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AuthorDetails if !self.app.data.readarr_data.books.is_empty() => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + } + ActiveReadarrBlock::AuthorHistory + if !self.app.data.readarr_data.author_history.is_empty() => + { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AuthorHistoryDetails.into()); + } + ActiveReadarrBlock::ManualAuthorSearch => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt.into()); + } + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + let ReadarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .readarr_data + .author_releases + .current_selection() + .clone(); + let params = ReleaseDownloadBody { guid, indexer_id }; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DownloadRelease(params)); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::TriggerAutomaticAuthorSearch(self.extract_author_id()), + ); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::UpdateAndScanAuthorPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::UpdateAndScanAuthor(self.extract_author_id())); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::UpdateAndScanAuthorPrompt + | ActiveReadarrBlock::AutomaticallySearchAuthorPrompt + | ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + ActiveReadarrBlock::AuthorHistoryDetails => { + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::AuthorHistory => { + if self + .app + .data + .readarr_data + .author_history + .filtered_items + .is_some() + { + self.app.data.readarr_data.author_history.reset_filter(); + } else { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.reset_author_info_tabs(); + } + } + ActiveReadarrBlock::AuthorDetails | ActiveReadarrBlock::ManualAuthorSearch => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.reset_author_info_tabs(); + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::AuthorDetails => match self.key { + _ if matches_key!(refresh, key) => self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()), + _ if matches_key!(auto_search, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchAuthorPrompt.into()); + } + _ if matches_key!(update, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::UpdateAndScanAuthorPrompt.into()); + } + _ if matches_key!(edit, key) => { + self.app.push_navigation_stack( + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(self.active_readarr_block), + ) + .into(), + ); + self.app.data.readarr_data.edit_author_modal = Some((&self.app.data.readarr_data).into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + } + _ if matches_key!(toggle_monitoring, key) + && !self.app.data.readarr_data.books.is_empty() => + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::ToggleBookMonitoring(self.extract_book_id())); + + self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()); + } + _ => (), + }, + ActiveReadarrBlock::AuthorHistory | ActiveReadarrBlock::ManualAuthorSearch => { + match self.key { + _ if matches_key!(refresh, key) => self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()), + _ if matches_key!(auto_search, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchAuthorPrompt.into()); + } + _ if matches_key!(edit, key) => { + self.app.push_navigation_stack( + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(self.active_readarr_block), + ) + .into(), + ); + self.app.data.readarr_data.edit_author_modal = + Some((&self.app.data.readarr_data).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::UpdateAndScanAuthorPrompt.into()); + } + _ => (), + } + } + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::TriggerAutomaticAuthorSearch(self.extract_author_id()), + ); + + self.app.pop_navigation_stack(); + } + } + ActiveReadarrBlock::UpdateAndScanAuthorPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::UpdateAndScanAuthor(self.extract_author_id())); + + self.app.pop_navigation_stack(); + } + } + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + let ReadarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .readarr_data + .author_releases + .current_selection() + .clone(); + let params = ReleaseDownloadBody { guid, indexer_id }; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DownloadRelease(params)); + + 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 releases_sorting_options() -> Vec> { + vec![ + SortOption { + name: "Source", + cmp_fn: Some(|a, b| a.protocol.cmp(&b.protocol)), + }, + SortOption { + name: "Age", + cmp_fn: Some(|a, b| a.age.cmp(&b.age)), + }, + SortOption { + name: "Rejected", + cmp_fn: Some(|a, b| a.rejected.cmp(&b.rejected)), + }, + SortOption { + name: "Title", + cmp_fn: Some(|a, b| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }), + }, + SortOption { + name: "Indexer", + cmp_fn: Some(|a, b| a.indexer.to_lowercase().cmp(&b.indexer.to_lowercase())), + }, + SortOption { + name: "Size", + cmp_fn: Some(|a, b| a.size.cmp(&b.size)), + }, + SortOption { + name: "Peers", + cmp_fn: Some(|a, b| { + let default_number = Number::from(i64::MAX); + let seeder_a = a + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + let seeder_b = b + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + + seeder_a.cmp(&seeder_b) + }), + }, + SortOption { + name: "Quality", + cmp_fn: Some(|a, b| a.quality.cmp(&b.quality)), + }, + ] +} diff --git a/src/handlers/readarr_handlers/library/author_details_handler_tests.rs b/src/handlers/readarr_handlers/library/author_details_handler_tests.rs new file mode 100644 index 00000000..aa028a8f --- /dev/null +++ b/src/handlers/readarr_handlers/library/author_details_handler_tests.rs @@ -0,0 +1,1183 @@ +#[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::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_details_handler::{ + AuthorDetailsHandler, releases_sorting_options, + }; + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{ReadarrHistoryItem, ReadarrRelease}; + use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, + }; + use crate::models::servarr_models::{Quality, QualityWrapper}; + + mod test_handle_delete { + use super::*; + use crate::assert_delete_prompt; + use crate::event::Key; + use crate::models::readarr_models::Book; + use crate::models::servarr_data::readarr::readarr_data::DELETE_BOOK_SELECTION_BLOCKS; + use pretty_assertions::assert_eq; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_book_delete() { + let mut app = App::test_default(); + app.data.readarr_data.books.set_items(vec![Book::default()]); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + assert_delete_prompt!( + AuthorDetailsHandler, + app, + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::DeleteBookPrompt + ); + assert_eq!( + app.data.readarr_data.selected_block.blocks, + DELETE_BOOK_SELECTION_BLOCKS + ); + } + } + + mod test_handle_left_right_action { + use rstest::rstest; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_details_handler::AuthorDetailsHandler; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use pretty_assertions::assert_eq; + + #[rstest] + fn test_left_right_prompt_toggle( + #[values( + ActiveReadarrBlock::UpdateAndScanAuthorPrompt, + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt + )] + active_readarr_block: ActiveReadarrBlock, + #[values(Key::Left, Key::Right)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + AuthorDetailsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + #[case(ActiveReadarrBlock::AuthorDetails, ActiveReadarrBlock::AuthorHistory)] + #[case( + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + #[case( + ActiveReadarrBlock::ManualAuthorSearch, + ActiveReadarrBlock::AuthorDetails + )] + fn test_author_details_tabs_left_right_action( + #[case] left_block: ActiveReadarrBlock, + #[case] right_block: ActiveReadarrBlock, + #[values(true, false)] is_loading: bool, + ) { + let mut app = App::test_default_fully_populated(); + app.is_loading = is_loading; + app.push_navigation_stack(right_block.into()); + app.data.readarr_data.author_info_tabs.index = app + .data + .readarr_data + .author_info_tabs + .tabs + .iter() + .position(|tab_route| tab_route.route == right_block.into()) + .unwrap_or_default(); + + AuthorDetailsHandler::new(DEFAULT_KEYBINDINGS.left.key, &mut app, right_block, None).handle(); + + assert_eq!( + app.get_current_route(), + app.data.readarr_data.author_info_tabs.get_active_route() + ); + assert_navigation_pushed!(app, left_block.into()); + + AuthorDetailsHandler::new(DEFAULT_KEYBINDINGS.right.key, &mut app, left_block, None).handle(); + + assert_eq!( + app.get_current_route(), + app.data.readarr_data.author_info_tabs.get_active_route() + ); + assert_navigation_pushed!(app, right_block.into()); + } + } + + mod test_handle_submit { + 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::author_details_handler::AuthorDetailsHandler; + use crate::models::readarr_models::{Author, ReadarrHistoryItem}; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::servarr_models::ReleaseDownloadBody; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::torrent_release; + use crate::{assert_navigation_popped, assert_navigation_pushed}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[rstest] + #[case( + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ReadarrEvent::TriggerAutomaticAuthorSearch(1) + )] + #[case( + ActiveReadarrBlock::UpdateAndScanAuthorPrompt, + ReadarrEvent::UpdateAndScanAuthor(1) + )] + fn test_author_details_prompt_confirm_submit( + #[case] prompt_block: ActiveReadarrBlock, + #[case] expected_action: ReadarrEvent, + ) { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + AuthorDetailsHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + } + + #[rstest] + fn test_author_details_prompt_decline_submit( + #[values( + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ActiveReadarrBlock::UpdateAndScanAuthorPrompt + )] + prompt_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + AuthorDetailsHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[test] + fn test_author_history_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .author_history + .set_items(vec![ReadarrHistoryItem::default()]); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AuthorHistoryDetails.into()); + } + + #[test] + fn test_author_history_submit_no_op_when_author_history_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistory.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AuthorHistory.into() + ); + } + + #[test] + fn test_author_history_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + } + + #[test] + fn test_manual_author_search_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .author_releases + .set_items(vec![torrent_release()]); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearch.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualAuthorSearch, + None, + ) + .handle(); + + assert_navigation_pushed!( + app, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt.into() + ); + } + + #[test] + fn test_manual_author_search_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearch.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualAuthorSearch, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::ManualAuthorSearch.into() + ); + } + + #[test] + fn test_manual_author_search_confirm_prompt_confirm_submit() { + let mut app = App::test_default(); + let release = torrent_release(); + app + .data + .readarr_data + .author_releases + .set_items(vec![release.clone()]); + app.data.readarr_data.prompt_confirm = true; + let expected_action = ReadarrEvent::DownloadRelease(ReleaseDownloadBody { + guid: release.guid, + indexer_id: release.indexer_id, + }); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::ManualAuthorSearch.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + } + + #[test] + fn test_manual_author_search_confirm_prompt_decline_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .author_releases + .set_items(vec![torrent_release()]); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::ManualAuthorSearch.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + } + + mod test_handle_esc { + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_popped; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_details_handler::AuthorDetailsHandler; + use crate::models::readarr_models::ReadarrHistoryItem; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::StatefulTable; + use pretty_assertions::assert_eq; + use ratatui::widgets::TableState; + use rstest::rstest; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_author_history_details_block_esc() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistory.into()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistoryDetails.into()); + + AuthorDetailsHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::AuthorHistoryDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorHistory.into()); + } + + #[test] + fn test_author_history_esc_resets_filter_if_one_is_set_instead_of_closing_the_window() { + let mut app = App::test_default(); + app.data.readarr_data.author_history = StatefulTable { + filter: Some("Test".into()), + filtered_items: Some(vec![ReadarrHistoryItem::default()]), + filtered_state: Some(TableState::default()), + ..StatefulTable::default() + }; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistory.into()); + + AuthorDetailsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::AuthorHistory, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AuthorHistory.into() + ); + assert_none!(app.data.readarr_data.author_history.filter); + assert_none!(app.data.readarr_data.author_history.filtered_items); + assert_none!(app.data.readarr_data.author_history.filtered_state); + } + + #[rstest] + fn test_author_details_esc( + #[values( + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ActiveReadarrBlock::UpdateAndScanAuthorPrompt, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt + )] + prompt_block: ActiveReadarrBlock, + #[values(true, false)] is_ready: bool, + ) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + AuthorDetailsHandler::new(ESC_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + } + + #[rstest] + fn test_author_details_blocks_esc( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.author_history.filter = None; + app.data.readarr_data.author_history.filtered_items = None; + app.data.readarr_data.author_history.filtered_state = None; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_is_empty!(app.data.readarr_data.books); + assert_is_empty!(app.data.readarr_data.author_releases); + assert_is_empty!(app.data.readarr_data.author_history); + assert_eq!(app.data.readarr_data.author_info_tabs.index, 0); + } + } + + mod test_handle_char_key_event { + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_details_handler::AuthorDetailsHandler; + use crate::models::readarr_models::Author; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_AUTHOR_SELECTION_BLOCKS, + }; + use crate::models::servarr_models::ReleaseDownloadBody; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::torrent_release; + use crate::{assert_modal_absent, assert_modal_present, assert_navigation_popped}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_author_details_edit_key( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!( + app, + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(active_readarr_block) + ) + .into() + ); + assert_modal_present!(app.data.readarr_data.edit_author_modal); + assert_eq!( + app.data.readarr_data.selected_block.blocks, + EDIT_AUTHOR_SELECTION_BLOCKS + ); + } + + #[rstest] + fn test_author_details_edit_key_no_op_when_not_ready( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_readarr_block.into()); + assert_modal_absent!(app.data.readarr_data.edit_author_modal); + } + + #[test] + fn test_author_details_toggle_monitoring_key() { + let mut app = App::test_default_fully_populated(); + app.is_routing = false; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AuthorDetails.into() + ); + assert!(app.data.readarr_data.prompt_confirm); + assert!(app.is_routing); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::ToggleBookMonitoring(1) + ); + } + + #[test] + fn test_author_details_toggle_monitoring_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.prompt_confirm = false; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AuthorDetails.into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[test] + fn test_author_details_toggle_monitoring_key_no_op_when_books_empty() { + let mut app = App::test_default(); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[rstest] + fn test_author_details_auto_search_key( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!( + app, + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt.into() + ); + } + + #[rstest] + fn test_author_details_auto_search_key_no_op_when_not_ready( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_readarr_block.into()); + } + + #[rstest] + fn test_author_details_update_key( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::UpdateAndScanAuthorPrompt.into()); + } + + #[rstest] + fn test_author_details_update_key_no_op_when_not_ready( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_readarr_block.into()); + } + + #[rstest] + fn test_author_details_refresh_key( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.is_routing = false; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!(app, active_readarr_block.into()); + assert!(app.is_routing); + } + + #[rstest] + fn test_author_details_refresh_key_no_op_when_not_ready( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(active_readarr_block.into()); + app.is_routing = false; + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_readarr_block.into()); + assert!(!app.is_routing); + } + + #[rstest] + #[case( + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ReadarrEvent::TriggerAutomaticAuthorSearch(1) + )] + #[case( + ActiveReadarrBlock::UpdateAndScanAuthorPrompt, + ReadarrEvent::UpdateAndScanAuthor(1) + )] + fn test_author_details_prompt_confirm_key( + #[case] prompt_block: ActiveReadarrBlock, + #[case] expected_action: ReadarrEvent, + #[values(ActiveReadarrBlock::AuthorDetails, ActiveReadarrBlock::AuthorHistory)] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + app.push_navigation_stack(prompt_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + prompt_block, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, active_readarr_block.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + } + + #[test] + fn test_manual_author_search_confirm_prompt_confirm_key() { + let mut app = App::test_default(); + let release = torrent_release(); + app + .data + .readarr_data + .author_releases + .set_items(vec![release.clone()]); + let expected_action = ReadarrEvent::DownloadRelease(ReleaseDownloadBody { + guid: release.guid, + indexer_id: release.indexer_id, + }); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::ManualAuthorSearch.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + } + } + + #[test] + fn test_author_details_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|readarr_block| { + if AUTHOR_DETAILS_BLOCKS.contains(&readarr_block) { + assert!( + AuthorDetailsHandler::accepts(readarr_block), + "{readarr_block} is not accepted by the AuthorDetailsHandler" + ); + } else { + assert!(!AuthorDetailsHandler::accepts(readarr_block)); + } + }); + } + + #[test] + fn test_extract_author_id() { + let mut app = App::test_default_fully_populated(); + + let author_id = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .extract_author_id(); + + assert_eq!(author_id, 1); + } + + #[test] + fn test_extract_book_id() { + let mut app = App::test_default_fully_populated(); + + let book_id = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .extract_book_id(); + + assert_eq!(book_id, 1); + } + + #[rstest] + fn test_author_details_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 = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_author_details_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.is_loading = true; + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_author_details_handler_is_ready_when_not_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.is_loading = false; + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_author_details_handler_is_not_ready_when_not_loading_and_author_history_is_none() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_author_details_handler_ready_when_not_loading_and_author_history_is_non_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .author_history + .set_items(vec![ReadarrHistoryItem::default()]); + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_author_details_handler_is_not_ready_when_not_loading_and_author_releases_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::ManualAuthorSearch, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_author_details_handler_ready_when_not_loading_and_author_releases_is_non_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .author_releases + .set_items(vec![ReadarrRelease::default()]); + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::ManualAuthorSearch, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_releases_sorting_options_source() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.protocol.cmp(&b.protocol); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[0].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Source"); + } + + #[test] + fn test_releases_sorting_options_age() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.age.cmp(&b.age); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[1].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Age"); + } + + #[test] + fn test_releases_sorting_options_rejected() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.rejected.cmp(&b.rejected); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[2].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Rejected"); + } + + #[test] + fn test_releases_sorting_options_title() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = |a, b| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }; + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[3].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Title"); + } + + #[test] + fn test_releases_sorting_options_indexer() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.indexer.to_lowercase().cmp(&b.indexer.to_lowercase()); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[4].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Indexer"); + } + + #[test] + fn test_releases_sorting_options_size() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.size.cmp(&b.size); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[5].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Size"); + } + + #[test] + fn test_releases_sorting_options_peers() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = |a, b| { + let default_number = Number::from(i64::MAX); + let seeder_a = a + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + let seeder_b = b + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + + seeder_a.cmp(&seeder_b) + }; + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[6].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Peers"); + } + + #[test] + fn test_releases_sorting_options_quality() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.quality.cmp(&b.quality); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[7].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Quality"); + } + + fn release_vec() -> Vec { + let release_a = ReadarrRelease { + protocol: "Protocol A".to_owned(), + age: 1, + title: HorizontallyScrollableText::from("Title A"), + indexer: "Indexer A".to_owned(), + size: 1, + rejected: true, + seeders: Some(Number::from(1)), + quality: QualityWrapper { + quality: Quality { + name: "Quality A".to_owned(), + }, + }, + ..ReadarrRelease::default() + }; + let release_b = ReadarrRelease { + protocol: "Protocol B".to_owned(), + age: 2, + title: HorizontallyScrollableText::from("title B"), + indexer: "indexer B".to_owned(), + size: 2, + rejected: false, + seeders: Some(Number::from(2)), + quality: QualityWrapper { + quality: Quality { + name: "Quality B".to_owned(), + }, + }, + ..ReadarrRelease::default() + }; + let release_c = ReadarrRelease { + protocol: "Protocol C".to_owned(), + age: 3, + title: HorizontallyScrollableText::from("Title C"), + indexer: "Indexer C".to_owned(), + size: 3, + rejected: false, + seeders: None, + quality: QualityWrapper { + quality: Quality { + name: "Quality C".to_owned(), + }, + }, + ..ReadarrRelease::default() + }; + + vec![release_a, release_b, release_c] + } +} diff --git a/src/handlers/readarr_handlers/library/library_handler_tests.rs b/src/handlers/readarr_handlers/library/library_handler_tests.rs index 8d7292b0..3918ca47 100644 --- a/src/handlers/readarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/library_handler_tests.rs @@ -13,7 +13,9 @@ mod tests { 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}; + use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, LIBRARY_BLOCKS, + }; mod test_handle_scroll_up_and_down { use pretty_assertions::assert_str_eq; @@ -878,8 +880,12 @@ mod tests { #[test] fn test_library_handler_accepts() { + let mut library_handler_blocks = Vec::new(); + library_handler_blocks.extend(LIBRARY_BLOCKS); + library_handler_blocks.extend(AUTHOR_DETAILS_BLOCKS); + ActiveReadarrBlock::iter().for_each(|readarr_block| { - if LIBRARY_BLOCKS.contains(&readarr_block) { + if library_handler_blocks.contains(&readarr_block) { assert!( LibraryHandler::accepts(readarr_block), "{readarr_block} is not accepted by the LibraryHandler" diff --git a/src/handlers/readarr_handlers/library/mod.rs b/src/handlers/readarr_handlers/library/mod.rs index 61bfbb95..69179113 100644 --- a/src/handlers/readarr_handlers/library/mod.rs +++ b/src/handlers/readarr_handlers/library/mod.rs @@ -19,6 +19,10 @@ use super::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::models::Route; +mod author_details_handler; + +pub(in crate::handlers::readarr_handlers) use author_details_handler::AuthorDetailsHandler; + #[cfg(test)] #[path = "library_handler_tests.rs"] mod library_handler_tests; @@ -54,12 +58,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, |app| &mut app.data.readarr_data.authors, authors_table_handling_config, ) { - self.handle_key_event(); + match self.active_readarr_block { + _ if AuthorDetailsHandler::accepts(self.active_readarr_block) => { + AuthorDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } + _ => self.handle_key_event(), + } } } fn accepts(active_block: ActiveReadarrBlock) -> bool { - LIBRARY_BLOCKS.contains(&active_block) + AuthorDetailsHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block) } fn ignore_special_keys(&self) -> bool { diff --git a/src/handlers/readarr_handlers/mod.rs b/src/handlers/readarr_handlers/mod.rs index 5ffee4ce..477598d5 100644 --- a/src/handlers/readarr_handlers/mod.rs +++ b/src/handlers/readarr_handlers/mod.rs @@ -6,6 +6,7 @@ use crate::{ models::servarr_data::readarr::readarr_data::ActiveReadarrBlock, }; +mod history; mod library; #[cfg(test)]