From bad43d138394fe93fc0dbcfed52a2b1f8b4691ce Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 4 Sep 2026 00:22:04 -0600 Subject: [PATCH] feat(readarr): add the history handler --- .../history/history_handler_tests.rs | 305 +++++++++++++++++- src/handlers/readarr_handlers/history/mod.rs | 120 +++++++ src/handlers/readarr_handlers/mod.rs | 4 + .../readarr_handlers/readarr_handler_tests.rs | 20 ++ 4 files changed, 448 insertions(+), 1 deletion(-) diff --git a/src/handlers/readarr_handlers/history/history_handler_tests.rs b/src/handlers/readarr_handlers/history/history_handler_tests.rs index 7a29b745..66ed7d16 100644 --- a/src/handlers/readarr_handlers/history/history_handler_tests.rs +++ b/src/handlers/readarr_handlers/history/history_handler_tests.rs @@ -4,9 +4,17 @@ mod tests { use chrono::DateTime; use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use strum::IntoEnumIterator; - use crate::handlers::readarr_handlers::history::history_sorting_options; + 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::history::{HistoryHandler, history_sorting_options}; use crate::models::readarr_models::{ReadarrHistoryEventType, ReadarrHistoryItem}; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, HISTORY_BLOCKS}; use crate::models::servarr_models::{Quality, QualityWrapper}; #[test] @@ -82,6 +90,301 @@ mod tests { assert_str_eq!(sort_option.name, "Date"); } + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_history_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(3); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Blocklist.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Blocklist.into()); + } + + #[rstest] + fn test_history_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(3); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::RootFolders.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::RootFolders.into()); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_history_submit() { + let mut app = App::test_default(); + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::History, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::HistoryItemDetails.into()); + } + + #[test] + fn test_history_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::History, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::History.into()); + } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_esc_history_item_details() { + let mut app = App::test_default(); + app + .data + .readarr_data + .history + .set_items(vec![ReadarrHistoryItem::default()]); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.push_navigation_stack(ActiveReadarrBlock::HistoryItemDetails.into()); + + HistoryHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::HistoryItemDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::History.into()); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app + .data + .readarr_data + .history + .set_items(vec![ReadarrHistoryItem::default()]); + + HistoryHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::History, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::History.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_navigation_pushed; + + #[test] + fn test_refresh_history_key() { + let mut app = App::test_default(); + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::History.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_history_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::History.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_search_history_key() { + let mut app = App::test_default(); + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SearchHistory.into()); + } + + #[test] + fn test_filter_history_key() { + let mut app = App::test_default(); + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::FilterHistory.into()); + } + } + + #[test] + fn test_history_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if HISTORY_BLOCKS.contains(&active_readarr_block) { + assert!(HistoryHandler::accepts(active_readarr_block)); + } else { + assert!(!HistoryHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_history_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 = HistoryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_history_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.is_loading = true; + + let handler = HistoryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::History, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_history_handler_not_ready_when_history_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.is_loading = false; + + let handler = HistoryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::History, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_history_handler_ready_when_not_loading_and_history_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.is_loading = false; + app + .data + .readarr_data + .history + .set_items(vec![ReadarrHistoryItem::default()]); + + let handler = HistoryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::History, + None, + ); + + assert!(handler.is_ready()); + } + fn history_vec() -> Vec { vec![ ReadarrHistoryItem { diff --git a/src/handlers/readarr_handlers/history/mod.rs b/src/handlers/readarr_handlers/history/mod.rs index 5ee320e4..128349de 100644 --- a/src/handlers/readarr_handlers/history/mod.rs +++ b/src/handlers/readarr_handlers/history/mod.rs @@ -1,10 +1,130 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_clear_errors}; +use crate::matches_key; +use crate::models::Route; use crate::models::readarr_models::ReadarrHistoryItem; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, HISTORY_BLOCKS}; use crate::models::stateful_table::SortOption; #[cfg(test)] #[path = "history_handler_tests.rs"] mod history_handler_tests; +pub(super) struct HistoryHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for HistoryHandler<'a, 'b> { + fn handle(&mut self) { + let history_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::History.into()) + .sorting_block(ActiveReadarrBlock::HistorySortPrompt.into()) + .sort_options(history_sorting_options()) + .searching_block(ActiveReadarrBlock::SearchHistory.into()) + .search_error_block(ActiveReadarrBlock::SearchHistoryError.into()) + .search_field_fn(|history| &history.source_title.text) + .filtering_block(ActiveReadarrBlock::FilterHistory.into()) + .filter_error_block(ActiveReadarrBlock::FilterHistoryError.into()) + .filter_field_fn(|history| &history.source_title.text); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.history, + history_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + HISTORY_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, + ) -> Self { + HistoryHandler { + key, + app, + active_readarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.readarr_data.history.is_empty() + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::History { + handle_change_tab_left_right_keys(self.app, self.key) + } + } + + fn handle_submit(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::History { + self + .app + .push_navigation_stack(ActiveReadarrBlock::HistoryItemDetails.into()); + } + } + + fn handle_esc(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::HistoryItemDetails { + self.app.pop_navigation_stack(); + } else { + handle_clear_errors(self.app); + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + if self.active_readarr_block == ActiveReadarrBlock::History { + match self.key { + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ => (), + } + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} + pub(in crate::handlers::readarr_handlers) fn history_sorting_options() -> Vec> { vec![ diff --git a/src/handlers/readarr_handlers/mod.rs b/src/handlers/readarr_handlers/mod.rs index 6233cffc..d571c990 100644 --- a/src/handlers/readarr_handlers/mod.rs +++ b/src/handlers/readarr_handlers/mod.rs @@ -1,6 +1,7 @@ use super::KeyEventHandler; use crate::handlers::readarr_handlers::blocklist::BlocklistHandler; use crate::handlers::readarr_handlers::downloads::DownloadsHandler; +use crate::handlers::readarr_handlers::history::HistoryHandler; use crate::handlers::readarr_handlers::library::LibraryHandler; use crate::models::Route; use crate::{ @@ -36,6 +37,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for ReadarrHandler<'a, _ if BlocklistHandler::accepts(self.active_readarr_block) => { BlocklistHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); } + _ if HistoryHandler::accepts(self.active_readarr_block) => { + HistoryHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); + } _ => self.handle_key_event(), } } diff --git a/src/handlers/readarr_handlers/readarr_handler_tests.rs b/src/handlers/readarr_handlers/readarr_handler_tests.rs index 6d8445c3..0c1d1d99 100644 --- a/src/handlers/readarr_handlers/readarr_handler_tests.rs +++ b/src/handlers/readarr_handlers/readarr_handler_tests.rs @@ -210,4 +210,24 @@ mod tests { active_readarr_block ); } + + #[rstest] + fn test_delegates_history_blocks_to_history_handler( + #[values( + ActiveReadarrBlock::History, + ActiveReadarrBlock::HistoryItemDetails, + ActiveReadarrBlock::HistorySortPrompt, + ActiveReadarrBlock::SearchHistory, + ActiveReadarrBlock::SearchHistoryError, + ActiveReadarrBlock::FilterHistory, + ActiveReadarrBlock::FilterHistoryError + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::History, + active_readarr_block + ); + } }