From 14deed6b813bed5aa45a8c017c15c5931627b741 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 11:43:49 -0600 Subject: [PATCH] feat(readarr): handle the book details tabs and open the edition details popup Add BookDetailsHandler, covering the editions, history and manual search tables, the automatic search, manual download and delete book file prompts, and tab movement across the book details modal. Selecting an edition builds the EditionDetailsModal client side from the already loaded row and pushes the popup block. Readarr has no edition detail endpoint, so there is no network request and no event to dispatch. The delete book file prompt is guarded on the book files table being populated. Unlike Lidarr, where a track row carries its own file id, an edition carries none and the file id comes from a separate table that can be empty while editions are present. --- .../library/author_details_handler.rs | 3 +- .../library/book_details_handler.rs | 492 ++++++++ .../library/book_details_handler_tests.rs | 1084 +++++++++++++++++ .../library/library_handler_tests.rs | 13 +- src/handlers/readarr_handlers/library/mod.rs | 13 +- 5 files changed, 1601 insertions(+), 4 deletions(-) create mode 100644 src/handlers/readarr_handlers/library/book_details_handler.rs create mode 100644 src/handlers/readarr_handlers/library/book_details_handler_tests.rs diff --git a/src/handlers/readarr_handlers/library/author_details_handler.rs b/src/handlers/readarr_handlers/library/author_details_handler.rs index d10758a8..26fa82a8 100644 --- a/src/handlers/readarr_handlers/library/author_details_handler.rs +++ b/src/handlers/readarr_handlers/library/author_details_handler.rs @@ -389,7 +389,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for AuthorDetailsHandle } } -fn releases_sorting_options() -> Vec> { +pub(in crate::handlers::readarr_handlers::library) fn releases_sorting_options() +-> Vec> { vec![ SortOption { name: "Source", diff --git a/src/handlers/readarr_handlers/library/book_details_handler.rs b/src/handlers/readarr_handlers/library/book_details_handler.rs new file mode 100644 index 00000000..ee16c610 --- /dev/null +++ b/src/handlers/readarr_handlers/library/book_details_handler.rs @@ -0,0 +1,492 @@ +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::{Edition, Ratings, ReadarrHistoryItem, ReadarrRelease}; +use crate::models::servarr_data::readarr::modals::EditionDetailsModal; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BOOK_DETAILS_BLOCKS}; +use crate::models::servarr_models::ReleaseDownloadBody; +use crate::models::{Route, ScrollableText}; +use crate::network::readarr_network::ReadarrEvent; +use indoc::formatdoc; + +use super::author_details_handler::releases_sorting_options; + +#[cfg(test)] +#[path = "book_details_handler_tests.rs"] +mod book_details_handler_tests; + +pub(in crate::handlers::readarr_handlers) struct BookDetailsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl BookDetailsHandler<'_, '_> { + fn extract_book_id(&self) -> i64 { + self.app.data.readarr_data.books.current_selection().id + } + + fn extract_book_file_id(&self) -> i64 { + self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_files + .current_selection() + .id + } + + fn book_files_are_populated(&self) -> bool { + self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .is_some_and(|modal| !modal.book_files.is_empty()) + } + + fn editions_are_populated(&self) -> bool { + self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .is_some_and(|modal| !modal.editions.is_empty()) + } + + fn build_edition_details_modal(&mut self) { + let Edition { + title, + overview, + format, + publisher, + page_count, + release_date, + isbn13, + asin, + ratings, + language, + monitored, + .. + } = self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .editions + .current_selection() + .clone(); + let format = format.unwrap_or_default(); + let language = language.unwrap_or_default(); + let publisher = publisher.unwrap_or_default(); + let page_count = page_count + .map(|count| count.to_string()) + .unwrap_or_default(); + let release_date = release_date + .map(|date| date.to_string()) + .unwrap_or_default(); + let isbn13 = isbn13.unwrap_or_default(); + let asin = asin.unwrap_or_default(); + let ratings = ratings + .map(|Ratings { votes, value, .. }| format!("{value} ({votes} votes)")) + .unwrap_or_default(); + let overview = overview.unwrap_or_default(); + let details = formatdoc!( + " + Title: {title} + Format: {format} + Language: {language} + Publisher: {publisher} + Page Count: {page_count} + Release Date: {release_date} + ISBN13: {isbn13} + ASIN: {asin} + Ratings: {ratings} + Monitored: {monitored} + Overview: {overview} + " + ); + + self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .edition_details_modal = Some(EditionDetailsModal { + edition_details: ScrollableText::with_string(details), + }); + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for BookDetailsHandler<'a, 'b> { + fn handle(&mut self) { + let editions_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::BookDetails.into()) + .searching_block(ActiveReadarrBlock::SearchEditions.into()) + .search_error_block(ActiveReadarrBlock::SearchEditionsError.into()) + .search_field_fn(|edition: &Edition| &edition.title); + + let book_history_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::BookHistory.into()) + .sorting_block(ActiveReadarrBlock::BookHistorySortPrompt.into()) + .sort_options(history_sorting_options()) + .searching_block(ActiveReadarrBlock::SearchBookHistory.into()) + .search_error_block(ActiveReadarrBlock::SearchBookHistoryError.into()) + .search_field_fn(|history_item: &ReadarrHistoryItem| &history_item.source_title.text) + .filtering_block(ActiveReadarrBlock::FilterBookHistory.into()) + .filter_error_block(ActiveReadarrBlock::FilterBookHistoryError.into()) + .filter_field_fn(|history_item: &ReadarrHistoryItem| &history_item.source_title.text); + + let book_releases_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::ManualBookSearch.into()) + .sorting_block(ActiveReadarrBlock::ManualBookSearchSortPrompt.into()) + .sort_options(releases_sorting_options()); + + if !handle_table( + self, + |app| { + &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .editions + }, + editions_table_handling_config, + ) && !handle_table( + self, + |app| { + &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_history + }, + book_history_table_handling_config, + ) && !handle_table( + self, + |app| { + &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_releases + }, + book_releases_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + BOOK_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, + ) -> BookDetailsHandler<'a, 'b> { + BookDetailsHandler { + 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; + } + + let Some(book_details_modal) = &self.app.data.readarr_data.book_details_modal else { + return false; + }; + + match self.active_readarr_block { + ActiveReadarrBlock::BookDetails => !book_details_modal.editions.is_empty(), + ActiveReadarrBlock::BookHistory => !book_details_modal.book_history.is_empty(), + ActiveReadarrBlock::BookFileInfo => !book_details_modal.book_files.is_empty(), + ActiveReadarrBlock::ManualBookSearch => !book_details_modal.book_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::BookDetails + && self.book_files_are_populated() + { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteBookFilePrompt.into()); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::BookDetails + | ActiveReadarrBlock::BookHistory + | ActiveReadarrBlock::BookFileInfo + | ActiveReadarrBlock::ManualBookSearch => match self.key { + _ if matches_key!(left, self.key) => { + self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_details_tabs + .previous(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_details_tabs + .get_active_route(), + ); + } + _ if matches_key!(right, self.key) => { + self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_details_tabs + .next(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_details_tabs + .get_active_route(), + ); + } + _ => (), + }, + ActiveReadarrBlock::AutomaticallySearchBookPrompt + | ActiveReadarrBlock::ManualBookSearchConfirmPrompt + | ActiveReadarrBlock::DeleteBookFilePrompt => { + handle_prompt_toggle(self.app, self.key); + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::BookDetails if self.editions_are_populated() => { + self.build_edition_details_modal(); + self + .app + .push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + } + ActiveReadarrBlock::BookHistory => self + .app + .push_navigation_stack(ActiveReadarrBlock::BookHistoryDetails.into()), + ActiveReadarrBlock::ManualBookSearch => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into()); + } + ActiveReadarrBlock::ManualBookSearchConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + let ReadarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_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::AutomaticallySearchBookPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::TriggerAutomaticBookSearch(self.extract_book_id()), + ); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::DeleteBookFilePrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteBookFile(self.extract_book_file_id())); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::BookDetails + | ActiveReadarrBlock::BookFileInfo + | ActiveReadarrBlock::ManualBookSearch => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.book_details_modal = None; + } + ActiveReadarrBlock::BookHistoryDetails => { + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::BookHistory => { + if self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_history + .filtered_items + .is_some() + { + self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_history + .reset_filter(); + } else { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.book_details_modal = None; + } + } + ActiveReadarrBlock::AutomaticallySearchBookPrompt + | ActiveReadarrBlock::ManualBookSearchConfirmPrompt + | ActiveReadarrBlock::DeleteBookFilePrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::BookDetails + | ActiveReadarrBlock::BookHistory + | ActiveReadarrBlock::BookFileInfo + | ActiveReadarrBlock::ManualBookSearch => match 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::AutomaticallySearchBookPrompt.into()); + } + _ => (), + }, + ActiveReadarrBlock::AutomaticallySearchBookPrompt if matches_key!(confirm, key) => { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::TriggerAutomaticBookSearch(self.extract_book_id()), + ); + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::DeleteBookFilePrompt if matches_key!(confirm, key) => { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteBookFile(self.extract_book_file_id())); + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::ManualBookSearchConfirmPrompt if matches_key!(confirm, key) => { + self.app.data.readarr_data.prompt_confirm = true; + let ReadarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_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() + } +} diff --git a/src/handlers/readarr_handlers/library/book_details_handler_tests.rs b/src/handlers/readarr_handlers/library/book_details_handler_tests.rs new file mode 100644 index 00000000..bd43b874 --- /dev/null +++ b/src/handlers/readarr_handlers/library/book_details_handler_tests.rs @@ -0,0 +1,1084 @@ +#[cfg(test)] +mod tests { + 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::book_details_handler::BookDetailsHandler; + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{ + Book, BookFile, Edition, Ratings, ReadarrHistoryItem, ReadarrRelease, + }; + use crate::models::servarr_data::readarr::modals::BookDetailsModal; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, + }; + use crate::models::servarr_models::{Quality, QualityWrapper}; + + mod test_handle_scroll_up_and_down { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use pretty_assertions::assert_str_eq; + use rstest::rstest; + + #[rstest] + fn test_editions_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(key, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_str_eq!(selected_edition(&app).title, "Test Edition 2"); + } + + #[rstest] + fn test_editions_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.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(key, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_str_eq!(selected_edition(&app).title, "Test Edition 1"); + } + + #[rstest] + fn test_book_history_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new(key, &mut app, ActiveReadarrBlock::BookHistory, None).handle(); + + assert_str_eq!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_history + .current_selection() + .source_title + .text, + "Test History 2" + ); + } + + #[rstest] + fn test_book_releases_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); + + BookDetailsHandler::new(key, &mut app, ActiveReadarrBlock::ManualBookSearch, None).handle(); + + assert_str_eq!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_releases + .current_selection() + .title + .text, + "Test Release 2" + ); + } + } + + mod test_handle_home_end { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use pretty_assertions::assert_str_eq; + + #[test] + fn test_editions_home_end() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + assert_str_eq!(selected_edition(&app).title, "Test Edition 2"); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + assert_str_eq!(selected_edition(&app).title, "Test Edition 1"); + } + + #[test] + fn test_editions_home_end_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + assert_str_eq!(selected_edition(&app).title, "Test Edition 1"); + } + } + + mod test_handle_delete { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_delete_book_file_prompt() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::DeleteBookFilePrompt.into()); + } + + #[test] + fn test_delete_book_file_prompt_is_a_no_op_when_book_files_are_empty() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.book_files.set_items(Vec::new()); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_delete_book_file_prompt_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + } + + mod test_handle_left_right_action { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use rstest::rstest; + + #[rstest] + #[case(ActiveReadarrBlock::BookDetails, ActiveReadarrBlock::BookHistory)] + #[case(ActiveReadarrBlock::BookHistory, ActiveReadarrBlock::ManualBookSearch)] + #[case(ActiveReadarrBlock::ManualBookSearch, ActiveReadarrBlock::BookDetails)] + fn test_book_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()); + let tab_index = app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_details_tabs + .tabs + .iter() + .position(|tab_route| tab_route.route == right_block.into()) + .unwrap_or_default(); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .book_details_tabs + .index = tab_index; + + BookDetailsHandler::new(DEFAULT_KEYBINDINGS.left.key, &mut app, right_block, None).handle(); + + assert_navigation_pushed!(app, left_block.into()); + + BookDetailsHandler::new(DEFAULT_KEYBINDINGS.right.key, &mut app, left_block, None).handle(); + + assert_navigation_pushed!(app, right_block.into()); + } + + #[rstest] + fn test_left_right_prompt_toggle( + #[values( + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + ActiveReadarrBlock::DeleteBookFilePrompt + )] + active_readarr_block: ActiveReadarrBlock, + #[values(Key::Left, Key::Right)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + BookDetailsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + BookDetailsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::models::servarr_models::ReleaseDownloadBody; + use crate::network::readarr_network::ReadarrEvent; + use crate::{assert_modal_absent, assert_modal_present, assert_navigation_pushed}; + use pretty_assertions::assert_eq; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_book_details_submit_builds_the_edition_details_modal_and_pushes_the_popup() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookEditionDetails.into()); + assert_modal_present!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + ); + assert!(edition_details_text(&app).contains("Title: Test Edition 1")); + assert!(edition_details_text(&app).contains("Format: Paperback")); + assert!(edition_details_text(&app).contains("ISBN13: isbn-1")); + assert!(edition_details_text(&app).contains("ASIN: asin-1")); + assert!(edition_details_text(&app).contains("Overview: Overview 1")); + } + + #[test] + fn test_book_details_submit_dispatches_no_network_event() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[test] + fn test_book_details_submit_rebuilds_the_modal_when_a_different_edition_is_selected() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert!(edition_details_text(&app).contains("Title: Test Edition 1")); + assert!(edition_details_text(&app).contains("Format: Paperback")); + + app.pop_navigation_stack(); + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert!(edition_details_text(&app).contains("Title: Test Edition 2")); + assert!(edition_details_text(&app).contains("Format: Hardcover")); + assert!(edition_details_text(&app).contains("ISBN13: isbn-2")); + assert!(!edition_details_text(&app).contains("Test Edition 1")); + } + + #[test] + fn test_book_details_submit_is_a_no_op_when_the_editions_table_is_empty() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.editions.set_items(Vec::new()); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + assert_modal_absent!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + ); + } + + #[test] + fn test_book_details_submit_is_a_no_op_when_the_book_details_modal_is_absent() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + assert_modal_absent!(app.data.readarr_data.book_details_modal); + } + + #[test] + fn test_book_details_submit_with_a_single_edition() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.editions.set_items(vec![editions_vec()[0].clone()]); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookEditionDetails.into()); + assert!(edition_details_text(&app).contains("Title: Test Edition 1")); + } + + #[test] + fn test_book_details_submit_renders_every_optional_field_when_absent() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.editions.set_items(vec![Edition { + title: "Bare Edition".to_owned(), + ..Edition::default() + }]); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookEditionDetails.into()); + assert!(edition_details_text(&app).contains("Title: Bare Edition")); + assert!(edition_details_text(&app).contains("Format: \n")); + assert!(edition_details_text(&app).contains("Ratings: \n")); + } + + #[test] + fn test_book_details_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + assert_modal_absent!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + ); + } + + #[test] + fn test_book_history_submit() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookHistory, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookHistoryDetails.into()); + } + + #[test] + fn test_book_history_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookHistory, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookHistory.into()); + } + + #[test] + fn test_manual_book_search_submit() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualBookSearch, + None, + ) + .handle(); + + assert_navigation_pushed!( + app, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into() + ); + } + + #[test] + fn test_manual_book_search_confirm_prompt_submit() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DownloadRelease(ReleaseDownloadBody { + guid: "guid-1".to_owned(), + indexer_id: 1, + })) + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::ManualBookSearch.into()); + } + + #[test] + fn test_manual_book_search_confirm_prompt_submit_no_confirmation() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + None, + ) + .handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_pushed!(app, ActiveReadarrBlock::ManualBookSearch.into()); + } + + #[test] + fn test_automatically_search_book_prompt_submit() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchBookPrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::TriggerAutomaticBookSearch(7)) + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_automatically_search_book_prompt_submit_no_confirmation() { + let mut app = App::test_default(); + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchBookPrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + None, + ) + .handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_delete_book_file_prompt_submit() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookFilePrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookFilePrompt, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DeleteBookFile(11)) + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_delete_book_file_prompt_submit_no_confirmation() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookFilePrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookFilePrompt, + None, + ) + .handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + } + + mod test_handle_esc { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::{assert_modal_absent, assert_modal_present, assert_navigation_popped}; + use rstest::rstest; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_book_details_tabs_esc_tears_down_the_book_details_modal( + #[values( + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookFileInfo, + ActiveReadarrBlock::ManualBookSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(active_readarr_block.into()); + + BookDetailsHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_modal_absent!(app.data.readarr_data.book_details_modal); + } + + #[test] + fn test_book_history_esc_tears_down_the_book_details_modal() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::BookHistory, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_modal_absent!(app.data.readarr_data.book_details_modal); + } + + #[test] + fn test_book_history_esc_resets_the_filter_first() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.book_history.filtered_items = Some(history_vec()); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::BookHistory, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::BookHistory.into()); + assert_none!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_history + .filtered_items + ); + } + + #[test] + fn test_book_history_details_esc() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistoryDetails.into()); + + BookDetailsHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::BookHistoryDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::BookHistory.into()); + assert_modal_present!(app.data.readarr_data.book_details_modal); + } + + #[rstest] + fn test_prompt_esc( + #[values( + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + ActiveReadarrBlock::DeleteBookFilePrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(active_readarr_block.into()); + + BookDetailsHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::BookDetails.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_key_char { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::models::servarr_models::ReleaseDownloadBody; + use crate::network::readarr_network::ReadarrEvent; + use crate::{assert_navigation_popped, assert_navigation_pushed}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_refresh_key( + #[values( + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookHistory, + ActiveReadarrBlock::ManualBookSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(active_readarr_block.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!(app, active_readarr_block.into()); + } + + #[rstest] + fn test_auto_search_key( + #[values( + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookHistory, + ActiveReadarrBlock::ManualBookSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(active_readarr_block.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!( + app, + ActiveReadarrBlock::AutomaticallySearchBookPrompt.into() + ); + } + + #[test] + fn test_auto_search_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_automatically_search_book_prompt_confirm() { + let mut app = App::test_default(); + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchBookPrompt.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::TriggerAutomaticBookSearch(7)) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_delete_book_file_prompt_confirm() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookFilePrompt.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::DeleteBookFilePrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DeleteBookFile(11)) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_manual_book_search_confirm_prompt_confirm() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DownloadRelease(ReleaseDownloadBody { + guid: "guid-1".to_owned(), + indexer_id: 1, + })) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::ManualBookSearch.into()); + } + } + + #[test] + fn test_book_details_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if BOOK_DETAILS_BLOCKS.contains(&active_readarr_block) { + assert!( + BookDetailsHandler::accepts(active_readarr_block), + "{active_readarr_block} is not accepted by the BookDetailsHandler" + ); + } else { + assert!( + !BookDetailsHandler::accepts(active_readarr_block), + "{active_readarr_block} is wrongly accepted by the BookDetailsHandler" + ); + } + }); + } + + #[test] + fn test_book_details_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + + let handler = + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_book_details_handler_is_not_ready_when_the_modal_is_absent() { + let mut app = App::test_default(); + + let handler = + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_book_details_handler_is_ready_per_tab() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + + for active_readarr_block in [ + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookHistory, + ActiveReadarrBlock::BookFileInfo, + ActiveReadarrBlock::ManualBookSearch, + ] { + let handler = BookDetailsHandler::new(DELETE_KEY, &mut app, active_readarr_block, None); + + assert!(handler.is_ready(), "{active_readarr_block} should be ready"); + } + } + + #[test] + fn test_book_details_handler_is_not_ready_when_its_tab_table_is_empty() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(BookDetailsModal::default()); + + for active_readarr_block in [ + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookHistory, + ActiveReadarrBlock::BookFileInfo, + ActiveReadarrBlock::ManualBookSearch, + ] { + let handler = BookDetailsHandler::new(DELETE_KEY, &mut app, active_readarr_block, None); + + assert!( + !handler.is_ready(), + "{active_readarr_block} should not be ready" + ); + } + } + + #[test] + fn test_book_details_handler_ignores_special_keys_for_textbox_input() { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = true; + + let handler = + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None); + + assert!(handler.ignore_special_keys()); + } + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + fn selected_edition(app: &App<'_>) -> Edition { + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .editions + .current_selection() + .clone() + } + + fn edition_details_text(app: &App<'_>) -> String { + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + .as_ref() + .unwrap() + .edition_details + .get_text() + } + + fn book_details_modal() -> BookDetailsModal { + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal.editions.set_items(editions_vec()); + book_details_modal.book_files.set_items(book_files_vec()); + book_details_modal.book_history.set_items(history_vec()); + book_details_modal.book_releases.set_items(release_vec()); + + book_details_modal + } + + fn editions_vec() -> Vec { + vec![ + Edition { + id: 1, + book_id: 1, + title: "Test Edition 1".to_owned(), + format: Some("Paperback".to_owned()), + isbn13: Some("isbn-1".to_owned()), + asin: Some("asin-1".to_owned()), + publisher: Some("Publisher 1".to_owned()), + language: Some("eng".to_owned()), + overview: Some("Overview 1".to_owned()), + page_count: Some(662), + monitored: true, + ratings: Some(Ratings { + votes: 10, + value: 4.5, + popularity: None, + }), + ..Edition::default() + }, + Edition { + id: 2, + book_id: 1, + title: "Test Edition 2".to_owned(), + format: Some("Hardcover".to_owned()), + isbn13: Some("isbn-2".to_owned()), + asin: Some("asin-2".to_owned()), + publisher: Some("Publisher 2".to_owned()), + language: Some("eng".to_owned()), + overview: Some("Overview 2".to_owned()), + page_count: Some(722), + monitored: false, + ..Edition::default() + }, + ] + } + + fn book_files_vec() -> Vec { + vec![BookFile { + id: 11, + author_id: 1, + book_id: 1, + path: "/nfs/books/Test Author/Test Book.epub".to_owned(), + size: 1024, + quality: QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }, + ..BookFile::default() + }] + } + + fn books_vec() -> Vec { + vec![Book { + id: 7, + ..Book::default() + }] + } + + fn history_vec() -> Vec { + vec![ + ReadarrHistoryItem { + id: 1, + source_title: HorizontallyScrollableText::from("Test History 1"), + ..ReadarrHistoryItem::default() + }, + ReadarrHistoryItem { + id: 2, + source_title: HorizontallyScrollableText::from("Test History 2"), + ..ReadarrHistoryItem::default() + }, + ] + } + + fn release_vec() -> Vec { + vec![ + ReadarrRelease { + guid: "guid-1".to_owned(), + indexer_id: 1, + title: HorizontallyScrollableText::from("Test Release 1"), + ..ReadarrRelease::default() + }, + ReadarrRelease { + guid: "guid-2".to_owned(), + indexer_id: 2, + title: HorizontallyScrollableText::from("Test Release 2"), + ..ReadarrRelease::default() + }, + ] + } +} diff --git a/src/handlers/readarr_handlers/library/library_handler_tests.rs b/src/handlers/readarr_handlers/library/library_handler_tests.rs index 3918ca47..f2af8d74 100644 --- a/src/handlers/readarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/readarr_handlers/library/library_handler_tests.rs @@ -14,8 +14,9 @@ mod tests { use crate::models::HorizontallyScrollableText; use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; use crate::models::servarr_data::readarr::readarr_data::{ - AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, LIBRARY_BLOCKS, + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, LIBRARY_BLOCKS, }; + use crate::test_handler_delegation; mod test_handle_scroll_up_and_down { use pretty_assertions::assert_str_eq; @@ -878,11 +879,21 @@ mod tests { assert_str_eq!(sort_option.name, "Tags"); } + #[test] + fn test_delegates_book_details_blocks_to_book_details_handler() { + test_handler_delegation!( + LibraryHandler, + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::BookDetails + ); + } + #[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); + library_handler_blocks.extend(BOOK_DETAILS_BLOCKS); ActiveReadarrBlock::iter().for_each(|readarr_block| { if library_handler_blocks.contains(&readarr_block) { diff --git a/src/handlers/readarr_handlers/library/mod.rs b/src/handlers/readarr_handlers/library/mod.rs index 69179113..9dd3fe36 100644 --- a/src/handlers/readarr_handlers/library/mod.rs +++ b/src/handlers/readarr_handlers/library/mod.rs @@ -20,8 +20,11 @@ use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::models::Route; mod author_details_handler; +mod book_details_handler; -pub(in crate::handlers::readarr_handlers) use author_details_handler::AuthorDetailsHandler; +pub(in crate::handlers::readarr_handlers) use { + author_details_handler::AuthorDetailsHandler, book_details_handler::BookDetailsHandler, +}; #[cfg(test)] #[path = "library_handler_tests.rs"] @@ -63,13 +66,19 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, AuthorDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context) .handle(); } + _ if BookDetailsHandler::accepts(self.active_readarr_block) => { + BookDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } _ => self.handle_key_event(), } } } fn accepts(active_block: ActiveReadarrBlock) -> bool { - AuthorDetailsHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block) + AuthorDetailsHandler::accepts(active_block) + || BookDetailsHandler::accepts(active_block) + || LIBRARY_BLOCKS.contains(&active_block) } fn ignore_special_keys(&self) -> bool {