From f139db07d9bea6282311c2ca9f29fd4d702add20 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 27 Nov 2024 17:06:20 -0700 Subject: [PATCH 01/82] feat(app): Dispatch support for all relevant Sonarr blocks --- src/app/app_tests.rs | 41 +- src/app/context_clues.rs | 55 ++ src/app/context_clues_tests.rs | 146 ++++- src/app/key_binding.rs | 10 + src/app/key_binding_tests.rs | 2 + src/app/mod.rs | 25 +- src/app/radarr/mod.rs | 23 +- src/app/radarr/radarr_context_clues.rs | 54 -- src/app/radarr/radarr_context_clues_tests.rs | 144 +---- src/app/radarr/radarr_tests.rs | 35 +- src/app/sonarr/mod.rs | 197 ++++++ src/app/sonarr/sonarr_context_clues.rs | 33 + src/app/sonarr/sonarr_context_clues_tests.rs | 101 +++ src/app/sonarr/sonarr_tests.rs | 606 ++++++++++++++++++ src/event/key.rs | 7 + src/event/key_tests.rs | 14 + src/handlers/handler_test_utils.rs | 42 +- src/handlers/handlers_tests.rs | 33 +- src/handlers/mod.rs | 48 +- .../blocklist/blocklist_handler_tests.rs | 240 +++---- src/handlers/radarr_handlers/blocklist/mod.rs | 30 +- .../collections/collection_details_handler.rs | 34 +- .../collection_details_handler_tests.rs | 88 +-- .../collections/collections_handler_tests.rs | 385 ++++++----- .../collections/edit_collection_handler.rs | 30 +- .../edit_collection_handler_tests.rs | 256 ++++---- .../radarr_handlers/collections/mod.rs | 32 +- .../downloads/downloads_handler_tests.rs | 144 ++--- src/handlers/radarr_handlers/downloads/mod.rs | 36 +- .../indexers/edit_indexer_handler.rs | 30 +- .../indexers/edit_indexer_handler_tests.rs | 396 ++++++------ .../indexers/edit_indexer_settings_handler.rs | 30 +- .../edit_indexer_settings_handler_tests.rs | 232 +++---- .../indexers/indexers_handler_tests.rs | 210 +++--- src/handlers/radarr_handlers/indexers/mod.rs | 40 +- .../indexers/test_all_indexers_handler.rs | 28 +- .../test_all_indexers_handler_tests.rs | 68 +- .../library/add_movie_handler.rs | 34 +- .../library/add_movie_handler_tests.rs | 390 ++++++----- .../library/delete_movie_handler.rs | 34 +- .../library/delete_movie_handler_tests.rs | 90 ++- .../library/edit_movie_handler.rs | 30 +- .../library/edit_movie_handler_tests.rs | 262 ++++---- .../library/library_handler_tests.rs | 396 ++++++------ src/handlers/radarr_handlers/library/mod.rs | 36 +- .../library/movie_details_handler.rs | 48 +- .../library/movie_details_handler_tests.rs | 386 ++++++----- src/handlers/radarr_handlers/mod.rs | 26 +- .../radarr_handler_test_utils.rs | 32 +- .../radarr_handlers/radarr_handler_tests.rs | 20 +- .../radarr_handlers/root_folders/mod.rs | 32 +- .../root_folders_handler_tests.rs | 221 +++---- src/handlers/radarr_handlers/system/mod.rs | 32 +- .../system/system_details_handler.rs | 30 +- .../system/system_details_handler_tests.rs | 336 +++++----- .../system/system_handler_tests.rs | 138 ++-- src/main.rs | 5 +- src/models/mod.rs | 8 +- src/models/model_tests.rs | 28 +- src/models/servarr_data/radarr/radarr_data.rs | 11 +- .../servarr_data/radarr/radarr_data_tests.rs | 12 +- src/models/servarr_data/sonarr/modals.rs | 12 +- src/models/servarr_data/sonarr/sonarr_data.rs | 105 ++- .../servarr_data/sonarr/sonarr_data_tests.rs | 124 +++- src/network/radarr_network.rs | 2 +- src/network/radarr_network_tests.rs | 4 +- src/network/sonarr_network.rs | 18 +- src/network/sonarr_network_tests.rs | 115 +++- src/ui/mod.rs | 2 +- src/ui/radarr_ui/blocklist/mod.rs | 4 +- .../collections/collection_details_ui.rs | 2 +- .../collections/edit_collection_ui.rs | 16 +- src/ui/radarr_ui/collections/mod.rs | 4 +- src/ui/radarr_ui/downloads/mod.rs | 2 +- src/ui/radarr_ui/indexers/edit_indexer_ui.rs | 24 +- .../radarr_ui/indexers/indexer_settings_ui.rs | 20 +- src/ui/radarr_ui/indexers/mod.rs | 2 +- src/ui/radarr_ui/library/add_movie_ui.rs | 20 +- src/ui/radarr_ui/library/delete_movie_ui.rs | 8 +- src/ui/radarr_ui/library/edit_movie_ui.rs | 16 +- src/ui/radarr_ui/library/mod.rs | 4 +- src/ui/radarr_ui/library/movie_details_ui.rs | 6 +- src/ui/radarr_ui/mod.rs | 2 +- src/ui/radarr_ui/root_folders/mod.rs | 2 +- src/ui/radarr_ui/system/mod.rs | 2 +- src/ui/radarr_ui/system/system_details_ui.rs | 2 +- 86 files changed, 4075 insertions(+), 3005 deletions(-) create mode 100644 src/app/sonarr/mod.rs create mode 100644 src/app/sonarr/sonarr_context_clues.rs create mode 100644 src/app/sonarr/sonarr_context_clues_tests.rs create mode 100644 src/app/sonarr/sonarr_tests.rs diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index 2fb02a0..61e2685 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -5,9 +5,9 @@ mod tests { use tokio::sync::mpsc; use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES}; - use crate::app::{App, AppConfig, Data, ServarrConfig, DEFAULT_ROUTE}; - use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; - use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; + use crate::app::{App, AppConfig, ServarrConfig, DEFAULT_ROUTE}; + use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::{HorizontallyScrollableText, TabRoute}; use crate::network::radarr_network::RadarrEvent; use crate::network::NetworkEvent; @@ -19,6 +19,7 @@ mod tests { assert_eq!(app.navigation_stack, vec![DEFAULT_ROUTE]); assert!(app.network_tx.is_none()); assert!(!app.cancellation_token.is_cancelled()); + assert!(app.is_first_render); assert_eq!(app.error, HorizontallyScrollableText::default()); assert_eq!(app.server_tabs.index, 0); assert_eq!( @@ -55,14 +56,11 @@ mod tests { fn test_navigation_stack_methods() { let mut app = App::default(); - assert_eq!(app.get_current_route(), &DEFAULT_ROUTE); + assert_eq!(app.get_current_route(), DEFAULT_ROUTE); app.push_navigation_stack(ActiveRadarrBlock::Downloads.into()); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Downloads.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Downloads.into()); assert!(app.is_routing); app.is_routing = false; @@ -70,20 +68,20 @@ mod tests { assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert!(app.is_routing); app.is_routing = false; app.pop_navigation_stack(); - assert_eq!(app.get_current_route(), &DEFAULT_ROUTE); + assert_eq!(app.get_current_route(), DEFAULT_ROUTE); assert!(app.is_routing); app.is_routing = false; app.pop_navigation_stack(); - assert_eq!(app.get_current_route(), &DEFAULT_ROUTE); + assert_eq!(app.get_current_route(), DEFAULT_ROUTE); assert!(app.is_routing); } @@ -123,16 +121,7 @@ mod tests { let mut app = App { tick_count: 2, error: "Test error".to_owned().into(), - data: Data { - radarr_data: RadarrData { - version: "test".to_owned(), - ..RadarrData::default() - }, - sonarr_data: SonarrData { - version: "test".to_owned(), - ..SonarrData::default() - }, - }, + is_first_render: false, ..App::default() }; @@ -140,8 +129,7 @@ mod tests { assert_eq!(app.tick_count, 0); assert_eq!(app.error, HorizontallyScrollableText::default()); - assert!(app.data.radarr_data.version.is_empty()); - assert!(app.data.sonarr_data.version.is_empty()); + assert!(app.is_first_render); } #[test] @@ -188,12 +176,13 @@ mod tests { let mut app = App { tick_until_poll: 2, network_tx: Some(sync_network_tx), + is_first_render: true, ..App::default() }; assert_eq!(app.tick_count, 0); - app.on_tick(true).await; + app.on_tick().await; assert_eq!( sync_network_rx.recv().await.unwrap(), @@ -237,7 +226,7 @@ mod tests { ..App::default() }; - app.on_tick(false).await; + app.on_tick().await; assert!(!app.is_routing); } @@ -250,7 +239,7 @@ mod tests { ..App::default() }; - app.on_tick(false).await; + app.on_tick().await; assert!(!app.should_refresh); } diff --git a/src/app/context_clues.rs b/src/app/context_clues.rs index 6725eaf..1f19fa8 100644 --- a/src/app/context_clues.rs +++ b/src/app/context_clues.rs @@ -21,3 +21,58 @@ pub static SERVARR_CONTEXT_CLUES: [ContextClue; 2] = [ pub static BARE_POPUP_CONTEXT_CLUES: [ContextClue; 1] = [(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)]; + +pub static BLOCKLIST_CONTEXT_CLUES: [ContextClue; 5] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc), + (DEFAULT_KEYBINDINGS.clear, "clear blocklist"), +]; + +pub static DOWNLOADS_CONTEXT_CLUES: [ContextClue; 3] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc), + (DEFAULT_KEYBINDINGS.update, "update downloads"), +]; + +pub static ROOT_FOLDERS_CONTEXT_CLUES: [ContextClue; 3] = [ + (DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc), + (DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc), + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), +]; + +pub static INDEXERS_CONTEXT_CLUES: [ContextClue; 6] = [ + (DEFAULT_KEYBINDINGS.submit, "edit indexer"), + ( + DEFAULT_KEYBINDINGS.settings, + DEFAULT_KEYBINDINGS.settings.desc, + ), + (DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc), + (DEFAULT_KEYBINDINGS.test, "test indexer"), + (DEFAULT_KEYBINDINGS.test_all, "test all indexers"), + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), +]; + +pub static SYSTEM_CONTEXT_CLUES: [ContextClue; 5] = [ + (DEFAULT_KEYBINDINGS.tasks, "open tasks"), + (DEFAULT_KEYBINDINGS.events, "open events"), + (DEFAULT_KEYBINDINGS.logs, "open logs"), + (DEFAULT_KEYBINDINGS.update, "open updates"), + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), +]; diff --git a/src/app/context_clues_tests.rs b/src/app/context_clues_tests.rs index 58699ec..5e164d9 100644 --- a/src/app/context_clues_tests.rs +++ b/src/app/context_clues_tests.rs @@ -2,7 +2,11 @@ mod test { use pretty_assertions::{assert_eq, assert_str_eq}; - use crate::app::context_clues::{BARE_POPUP_CONTEXT_CLUES, SERVARR_CONTEXT_CLUES}; + use crate::app::context_clues::{ + BARE_POPUP_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, + INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SERVARR_CONTEXT_CLUES, + SYSTEM_CONTEXT_CLUES, + }; use crate::app::{context_clues::build_context_clue_string, key_binding::DEFAULT_KEYBINDINGS}; #[test] @@ -44,4 +48,144 @@ mod test { assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc); assert_eq!(bare_popup_context_clues_iter.next(), None); } + + #[test] + fn test_downloads_context_clues() { + let mut downloads_context_clues_iter = DOWNLOADS_CONTEXT_CLUES.iter(); + + let (key_binding, description) = downloads_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); + + let (key_binding, description) = downloads_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc); + + let (key_binding, description) = downloads_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update); + assert_str_eq!(*description, "update downloads"); + assert_eq!(downloads_context_clues_iter.next(), None); + } + + #[test] + fn test_blocklist_context_clues() { + let mut blocklist_context_clues_iter = BLOCKLIST_CONTEXT_CLUES.iter(); + + let (key_binding, description) = blocklist_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); + + let (key_binding, description) = blocklist_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc); + + let (key_binding, description) = blocklist_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); + assert_str_eq!(*description, "details"); + + let (key_binding, description) = blocklist_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc); + + let (key_binding, description) = blocklist_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.clear); + assert_str_eq!(*description, "clear blocklist"); + assert_eq!(blocklist_context_clues_iter.next(), None); + } + + #[test] + fn test_root_folders_context_clues() { + let mut root_folders_context_clues_iter = ROOT_FOLDERS_CONTEXT_CLUES.iter(); + + let (key_binding, description) = root_folders_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.add); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.add.desc); + + let (key_binding, description) = root_folders_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc); + + let (key_binding, description) = root_folders_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); + assert_eq!(root_folders_context_clues_iter.next(), None); + } + + #[test] + fn test_indexers_context_clues() { + let mut indexers_context_clues_iter = INDEXERS_CONTEXT_CLUES.iter(); + + let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); + assert_str_eq!(*description, "edit indexer"); + + let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.settings); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.settings.desc); + + let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc); + + let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.test); + assert_str_eq!(*description, "test indexer"); + + let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.test_all); + assert_str_eq!(*description, "test all indexers"); + + let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); + assert_eq!(indexers_context_clues_iter.next(), None); + } + + #[test] + fn test_system_context_clues() { + let mut system_context_clues_iter = SYSTEM_CONTEXT_CLUES.iter(); + + let (key_binding, description) = system_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.tasks); + assert_str_eq!(*description, "open tasks"); + + let (key_binding, description) = system_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.events); + assert_str_eq!(*description, "open events"); + + let (key_binding, description) = system_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.logs); + assert_str_eq!(*description, "open logs"); + + let (key_binding, description) = system_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update); + assert_str_eq!(*description, "open updates"); + + let (key_binding, description) = system_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); + assert_eq!(system_context_clues_iter.next(), None); + } } diff --git a/src/app/key_binding.rs b/src/app/key_binding.rs index 44f0518..cc171a1 100644 --- a/src/app/key_binding.rs +++ b/src/app/key_binding.rs @@ -15,6 +15,8 @@ generate_keybindings! { left, right, backspace, + next_servarr, + previous_servarr, clear, search, settings, @@ -69,6 +71,14 @@ pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { key: Key::Backspace, desc: "backspace", }, + next_servarr: KeyBinding { + key: Key::Tab, + desc: "next servarr", + }, + previous_servarr: KeyBinding { + key: Key::BackTab, + desc: "previous servarr", + }, clear: KeyBinding { key: Key::Char('c'), desc: "clear", diff --git a/src/app/key_binding_tests.rs b/src/app/key_binding_tests.rs index 1a17a95..f4270dc 100644 --- a/src/app/key_binding_tests.rs +++ b/src/app/key_binding_tests.rs @@ -13,6 +13,8 @@ mod test { #[case(DEFAULT_KEYBINDINGS.left, Key::Left, "left")] #[case(DEFAULT_KEYBINDINGS.right, Key::Right, "right")] #[case(DEFAULT_KEYBINDINGS.backspace, Key::Backspace, "backspace")] + #[case(DEFAULT_KEYBINDINGS.next_servarr, Key::Tab, "next servarr")] + #[case(DEFAULT_KEYBINDINGS.previous_servarr, Key::BackTab, "previous servarr")] #[case(DEFAULT_KEYBINDINGS.clear, Key::Char('c'), "clear")] #[case(DEFAULT_KEYBINDINGS.search, Key::Char('s'), "search")] #[case(DEFAULT_KEYBINDINGS.settings, Key::Char('s'), "settings")] diff --git a/src/app/mod.rs b/src/app/mod.rs index f401e1d..fe13eed 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,6 +1,6 @@ use std::process; -use anyhow::anyhow; +use anyhow::{anyhow, Error}; use colored::Colorize; use log::{debug, error}; use serde::{Deserialize, Serialize}; @@ -21,6 +21,7 @@ pub mod context_clues; pub mod key_binding; mod key_binding_tests; pub mod radarr; +pub mod sonarr; const DEFAULT_ROUTE: Route = Route::Radarr(ActiveRadarrBlock::Movies, None); @@ -28,6 +29,7 @@ pub struct App<'a> { navigation_stack: Vec, network_tx: Option>, cancellation_token: CancellationToken, + pub is_first_render: bool, pub server_tabs: TabState, pub error: HorizontallyScrollableText, pub tick_until_poll: u64, @@ -81,21 +83,21 @@ impl<'a> App<'a> { pub fn reset(&mut self) { self.reset_tick_count(); self.error = HorizontallyScrollableText::default(); - self.data = Data::default(); + self.is_first_render = true; } - pub fn handle_error(&mut self, error: anyhow::Error) { + pub fn handle_error(&mut self, error: Error) { if self.error.text.is_empty() { self.error = error.to_string().into(); } } - pub async fn on_tick(&mut self, is_first_render: bool) { + pub async fn on_tick(&mut self) { if self.tick_count % self.tick_until_poll == 0 || self.is_routing || self.should_refresh { - if let Route::Radarr(active_radarr_block, _) = self.get_current_route() { - self - .radarr_on_tick(*active_radarr_block, is_first_render) - .await; + match self.get_current_route() { + Route::Radarr(active_radarr_block, _) => self.radarr_on_tick(active_radarr_block).await, + Route::Sonarr(active_sonarr_block, _) => self.sonarr_on_tick(active_sonarr_block).await, + _ => (), } self.is_routing = false; @@ -130,8 +132,8 @@ impl<'a> App<'a> { self.push_navigation_stack(route); } - pub fn get_current_route(&self) -> &Route { - self.navigation_stack.last().unwrap_or(&DEFAULT_ROUTE) + pub fn get_current_route(&self) -> Route { + *self.navigation_stack.last().unwrap_or(&DEFAULT_ROUTE) } } @@ -142,6 +144,7 @@ impl<'a> Default for App<'a> { network_tx: None, cancellation_token: CancellationToken::new(), error: HorizontallyScrollableText::default(), + is_first_render: true, server_tabs: TabState::new(vec![ TabRoute { title: "Radarr", @@ -176,7 +179,7 @@ impl<'a> Default for App<'a> { #[derive(Default)] pub struct Data<'a> { pub radarr_data: RadarrData<'a>, - pub sonarr_data: SonarrData, + pub sonarr_data: SonarrData<'a>, } #[derive(Debug, Deserialize, Serialize, Default, Clone)] diff --git a/src/app/radarr/mod.rs b/src/app/radarr/mod.rs index 1c7f181..542de61 100644 --- a/src/app/radarr/mod.rs +++ b/src/app/radarr/mod.rs @@ -119,11 +119,11 @@ impl<'a> App<'a> { _ => (), } - self.check_for_prompt_action().await; + self.check_for_radarr_prompt_action().await; self.reset_tick_count(); } - async fn check_for_prompt_action(&mut self) { + async fn check_for_radarr_prompt_action(&mut self) { if self.data.radarr_data.prompt_confirm { self.data.radarr_data.prompt_confirm = false; if let Some(radarr_event) = &self.data.radarr_data.prompt_confirm_action { @@ -136,19 +136,16 @@ impl<'a> App<'a> { } } - pub(super) async fn radarr_on_tick( - &mut self, - active_radarr_block: ActiveRadarrBlock, - is_first_render: bool, - ) { - if is_first_render { - self.refresh_metadata().await; + pub(super) async fn radarr_on_tick(&mut self, active_radarr_block: ActiveRadarrBlock) { + if self.is_first_render { + self.refresh_radarr_metadata().await; self.dispatch_by_radarr_block(&active_radarr_block).await; + self.is_first_render = false; } if self.should_refresh { self.dispatch_by_radarr_block(&active_radarr_block).await; - self.refresh_metadata().await; + self.refresh_radarr_metadata().await; } if self.is_routing { @@ -156,16 +153,16 @@ impl<'a> App<'a> { self.cancellation_token.cancel(); } else { self.dispatch_by_radarr_block(&active_radarr_block).await; - self.refresh_metadata().await; + self.refresh_radarr_metadata().await; } } if self.tick_count % self.tick_until_poll == 0 { - self.refresh_metadata().await; + self.refresh_radarr_metadata().await; } } - async fn refresh_metadata(&mut self) { + async fn refresh_radarr_metadata(&mut self) { self .dispatch_network_event(RadarrEvent::GetQualityProfiles.into()) .await; diff --git a/src/app/radarr/radarr_context_clues.rs b/src/app/radarr/radarr_context_clues.rs index a9d6a40..27a10af 100644 --- a/src/app/radarr/radarr_context_clues.rs +++ b/src/app/radarr/radarr_context_clues.rs @@ -35,60 +35,6 @@ pub static COLLECTIONS_CONTEXT_CLUES: [ContextClue; 8] = [ (DEFAULT_KEYBINDINGS.esc, "cancel filter"), ]; -pub static DOWNLOADS_CONTEXT_CLUES: [ContextClue; 2] = [ - ( - DEFAULT_KEYBINDINGS.refresh, - DEFAULT_KEYBINDINGS.refresh.desc, - ), - (DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc), -]; - -pub static BLOCKLIST_CONTEXT_CLUES: [ContextClue; 5] = [ - ( - DEFAULT_KEYBINDINGS.refresh, - DEFAULT_KEYBINDINGS.refresh.desc, - ), - (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), - (DEFAULT_KEYBINDINGS.submit, "details"), - (DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc), - (DEFAULT_KEYBINDINGS.clear, "clear blocklist"), -]; - -pub static ROOT_FOLDERS_CONTEXT_CLUES: [ContextClue; 3] = [ - (DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc), - (DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc), - ( - DEFAULT_KEYBINDINGS.refresh, - DEFAULT_KEYBINDINGS.refresh.desc, - ), -]; - -pub static INDEXERS_CONTEXT_CLUES: [ContextClue; 6] = [ - (DEFAULT_KEYBINDINGS.submit, "edit indexer"), - ( - DEFAULT_KEYBINDINGS.settings, - DEFAULT_KEYBINDINGS.settings.desc, - ), - (DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc), - (DEFAULT_KEYBINDINGS.test, "test indexer"), - (DEFAULT_KEYBINDINGS.test_all, "test all indexers"), - ( - DEFAULT_KEYBINDINGS.refresh, - DEFAULT_KEYBINDINGS.refresh.desc, - ), -]; - -pub static SYSTEM_CONTEXT_CLUES: [ContextClue; 5] = [ - (DEFAULT_KEYBINDINGS.tasks, "open tasks"), - (DEFAULT_KEYBINDINGS.events, "open events"), - (DEFAULT_KEYBINDINGS.logs, "open logs"), - (DEFAULT_KEYBINDINGS.update, "open updates"), - ( - DEFAULT_KEYBINDINGS.refresh, - DEFAULT_KEYBINDINGS.refresh.desc, - ), -]; - pub static MOVIE_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [ ( DEFAULT_KEYBINDINGS.refresh, diff --git a/src/app/radarr/radarr_context_clues_tests.rs b/src/app/radarr/radarr_context_clues_tests.rs index b20475a..8aa0173 100644 --- a/src/app/radarr/radarr_context_clues_tests.rs +++ b/src/app/radarr/radarr_context_clues_tests.rs @@ -4,11 +4,10 @@ mod tests { use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::radarr::radarr_context_clues::{ - ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, COLLECTIONS_CONTEXT_CLUES, - COLLECTION_DETAILS_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, - INDEXERS_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, - MANUAL_MOVIE_SEARCH_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, - SYSTEM_CONTEXT_CLUES, SYSTEM_TASKS_CONTEXT_CLUES, + ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES, COLLECTIONS_CONTEXT_CLUES, + COLLECTION_DETAILS_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, + MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXT_CLUES, + MOVIE_DETAILS_CONTEXT_CLUES, SYSTEM_TASKS_CONTEXT_CLUES, }; #[test] @@ -113,141 +112,6 @@ mod tests { assert_eq!(collections_context_clues.next(), None); } - #[test] - fn test_downloads_context_clues() { - let mut downloads_context_clues_iter = DOWNLOADS_CONTEXT_CLUES.iter(); - - let (key_binding, description) = downloads_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); - assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); - - let (key_binding, description) = downloads_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); - assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc); - assert_eq!(downloads_context_clues_iter.next(), None); - } - - #[test] - fn test_blocklist_context_clues() { - let mut blocklist_context_clues_iter = BLOCKLIST_CONTEXT_CLUES.iter(); - - let (key_binding, description) = blocklist_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); - assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); - - let (key_binding, description) = blocklist_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort); - assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc); - - let (key_binding, description) = blocklist_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); - assert_str_eq!(*description, "details"); - - let (key_binding, description) = blocklist_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); - assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc); - - let (key_binding, description) = blocklist_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.clear); - assert_str_eq!(*description, "clear blocklist"); - assert_eq!(blocklist_context_clues_iter.next(), None); - } - - #[test] - fn test_root_folders_context_clues() { - let mut root_folders_context_clues_iter = ROOT_FOLDERS_CONTEXT_CLUES.iter(); - - let (key_binding, description) = root_folders_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.add); - assert_str_eq!(*description, DEFAULT_KEYBINDINGS.add.desc); - - let (key_binding, description) = root_folders_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); - assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc); - - let (key_binding, description) = root_folders_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); - assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); - assert_eq!(root_folders_context_clues_iter.next(), None); - } - - #[test] - fn test_indexers_context_clues() { - let mut indexers_context_clues_iter = INDEXERS_CONTEXT_CLUES.iter(); - - let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); - assert_str_eq!(*description, "edit indexer"); - - let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.settings); - assert_str_eq!(*description, DEFAULT_KEYBINDINGS.settings.desc); - - let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); - assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc); - - let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.test); - assert_str_eq!(*description, "test indexer"); - - let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.test_all); - assert_str_eq!(*description, "test all indexers"); - - let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); - assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); - assert_eq!(indexers_context_clues_iter.next(), None); - } - - #[test] - fn test_system_context_clues() { - let mut system_context_clues_iter = SYSTEM_CONTEXT_CLUES.iter(); - - let (key_binding, description) = system_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.tasks); - assert_str_eq!(*description, "open tasks"); - - let (key_binding, description) = system_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.events); - assert_str_eq!(*description, "open events"); - - let (key_binding, description) = system_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.logs); - assert_str_eq!(*description, "open logs"); - - let (key_binding, description) = system_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update); - assert_str_eq!(*description, "open updates"); - - let (key_binding, description) = system_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); - assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); - assert_eq!(system_context_clues_iter.next(), None); - } - #[test] fn test_movie_details_context_clues() { let mut movie_details_context_clues_iter = MOVIE_DETAILS_CONTEXT_CLUES.iter(); diff --git a/src/app/radarr/radarr_tests.rs b/src/app/radarr/radarr_tests.rs index 901ca12..8742f8f 100644 --- a/src/app/radarr/radarr_tests.rs +++ b/src/app/radarr/radarr_tests.rs @@ -459,22 +459,22 @@ mod tests { } #[tokio::test] - async fn test_check_for_prompt_action_no_prompt_confirm() { + async fn test_check_for_radarr_prompt_action_no_prompt_confirm() { let mut app = App::default(); app.data.radarr_data.prompt_confirm = false; - app.check_for_prompt_action().await; + app.check_for_radarr_prompt_action().await; assert!(!app.data.radarr_data.prompt_confirm); assert!(!app.should_refresh); } #[tokio::test] - async fn test_check_for_prompt_action() { + async fn test_check_for_radarr_prompt_action() { let (mut app, mut sync_network_rx) = construct_app_unit(); app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::GetStatus); - app.check_for_prompt_action().await; + app.check_for_radarr_prompt_action().await; assert!(!app.data.radarr_data.prompt_confirm); assert_eq!( @@ -490,7 +490,7 @@ mod tests { let (mut app, mut sync_network_rx) = construct_app_unit(); app.is_routing = true; - app.refresh_metadata().await; + app.refresh_radarr_metadata().await; assert_eq!( sync_network_rx.recv().await.unwrap(), @@ -522,8 +522,9 @@ mod tests { #[tokio::test] async fn test_radarr_on_tick_first_render() { let (mut app, mut sync_network_rx) = construct_app_unit(); + app.is_first_render = true; - app.radarr_on_tick(ActiveRadarrBlock::Downloads, true).await; + app.radarr_on_tick(ActiveRadarrBlock::Downloads).await; assert_eq!( sync_network_rx.recv().await.unwrap(), @@ -551,6 +552,7 @@ mod tests { ); assert!(app.is_loading); assert!(!app.data.radarr_data.prompt_confirm); + assert!(!app.is_first_render); } #[tokio::test] @@ -559,9 +561,7 @@ mod tests { app.is_routing = true; app.should_refresh = true; - app - .radarr_on_tick(ActiveRadarrBlock::Downloads, false) - .await; + app.radarr_on_tick(ActiveRadarrBlock::Downloads).await; assert_eq!( sync_network_rx.recv().await.unwrap(), @@ -592,9 +592,7 @@ mod tests { app.is_routing = true; app.should_refresh = false; - app - .radarr_on_tick(ActiveRadarrBlock::Downloads, false) - .await; + app.radarr_on_tick(ActiveRadarrBlock::Downloads).await; assert!(app.cancellation_token.is_cancelled()); } @@ -604,9 +602,7 @@ mod tests { let (mut app, mut sync_network_rx) = construct_app_unit(); app.should_refresh = true; - app - .radarr_on_tick(ActiveRadarrBlock::Downloads, false) - .await; + app.radarr_on_tick(ActiveRadarrBlock::Downloads).await; assert_eq!( sync_network_rx.recv().await.unwrap(), @@ -623,9 +619,7 @@ mod tests { app.is_routing = true; app.should_refresh = true; - app - .radarr_on_tick(ActiveRadarrBlock::Downloads, false) - .await; + app.radarr_on_tick(ActiveRadarrBlock::Downloads).await; assert_eq!( sync_network_rx.recv().await.unwrap(), @@ -643,9 +637,7 @@ mod tests { app.tick_count = 2; app.tick_until_poll = 2; - app - .radarr_on_tick(ActiveRadarrBlock::Downloads, false) - .await; + app.radarr_on_tick(ActiveRadarrBlock::Downloads).await; assert_eq!( sync_network_rx.recv().await.unwrap(), @@ -701,6 +693,7 @@ mod tests { let mut app = App { network_tx: Some(sync_network_tx), tick_count: 1, + is_first_render: false, ..App::default() }; app.data.radarr_data.prompt_confirm = true; diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs new file mode 100644 index 0000000..f1b443b --- /dev/null +++ b/src/app/sonarr/mod.rs @@ -0,0 +1,197 @@ +use crate::{ + models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, + network::sonarr_network::SonarrEvent, +}; + +use super::App; + +pub mod sonarr_context_clues; + +#[cfg(test)] +#[path = "sonarr_tests.rs"] +mod sonarr_tests; + +impl<'a> App<'a> { + pub(super) async fn dispatch_by_sonarr_block(&mut self, active_sonarr_block: &ActiveSonarrBlock) { + match active_sonarr_block { + ActiveSonarrBlock::Series => { + self + .dispatch_network_event(SonarrEvent::ListSeries.into()) + .await; + self + .dispatch_network_event(SonarrEvent::GetDownloads.into()) + .await; + } + ActiveSonarrBlock::SeriesDetails => { + self.is_loading = true; + self.populate_seasons_table().await; + self.is_loading = false; + } + ActiveSonarrBlock::SeriesHistory => { + self + .dispatch_network_event(SonarrEvent::GetSeriesHistory(None).into()) + .await; + } + ActiveSonarrBlock::SeasonDetails => { + self + .dispatch_network_event(SonarrEvent::GetEpisodes(None).into()) + .await; + } + ActiveSonarrBlock::ManualSeasonSearch => { + self + .dispatch_network_event(SonarrEvent::GetSeasonReleases(None).into()) + .await; + } + ActiveSonarrBlock::EpisodeDetails | ActiveSonarrBlock::EpisodeFile => { + self + .dispatch_network_event(SonarrEvent::GetEpisodeDetails(None).into()) + .await; + } + ActiveSonarrBlock::EpisodeHistory => { + self + .dispatch_network_event(SonarrEvent::GetEpisodeHistory(None).into()) + .await; + } + ActiveSonarrBlock::ManualEpisodeSearch => { + self + .dispatch_network_event(SonarrEvent::GetEpisodeReleases(None).into()) + .await; + } + ActiveSonarrBlock::Downloads => { + self + .dispatch_network_event(SonarrEvent::GetDownloads.into()) + .await; + } + ActiveSonarrBlock::Blocklist => { + self + .dispatch_network_event(SonarrEvent::GetBlocklist.into()) + .await; + } + ActiveSonarrBlock::History => { + self + .dispatch_network_event(SonarrEvent::GetHistory(None).into()) + .await; + } + ActiveSonarrBlock::RootFolders => { + self + .dispatch_network_event(SonarrEvent::GetRootFolders.into()) + .await; + } + ActiveSonarrBlock::Indexers => { + self + .dispatch_network_event(SonarrEvent::GetIndexers.into()) + .await; + } + ActiveSonarrBlock::AllIndexerSettingsPrompt => { + self + .dispatch_network_event(SonarrEvent::GetAllIndexerSettings.into()) + .await; + } + ActiveSonarrBlock::TestIndexer => { + self + .dispatch_network_event(SonarrEvent::TestIndexer(None).into()) + .await; + } + ActiveSonarrBlock::TestAllIndexers => { + self + .dispatch_network_event(SonarrEvent::TestAllIndexers.into()) + .await; + } + ActiveSonarrBlock::System => { + self + .dispatch_network_event(SonarrEvent::GetTasks.into()) + .await; + self + .dispatch_network_event(SonarrEvent::GetQueuedEvents.into()) + .await; + self + .dispatch_network_event(SonarrEvent::GetLogs(None).into()) + .await; + } + ActiveSonarrBlock::SystemUpdates => { + self + .dispatch_network_event(SonarrEvent::GetUpdates.into()) + .await; + } + _ => (), + } + + self.check_for_sonarr_prompt_action().await; + self.reset_tick_count(); + } + + async fn check_for_sonarr_prompt_action(&mut self) { + if self.data.sonarr_data.prompt_confirm { + self.data.sonarr_data.prompt_confirm = false; + if let Some(sonarr_event) = &self.data.sonarr_data.prompt_confirm_action { + self + .dispatch_network_event(sonarr_event.clone().into()) + .await; + self.should_refresh = true; + self.data.sonarr_data.prompt_confirm_action = None; + } + } + } + + pub(super) async fn sonarr_on_tick(&mut self, active_sonarr_block: ActiveSonarrBlock) { + if self.is_first_render { + self.refresh_sonarr_metadata().await; + self.dispatch_by_sonarr_block(&active_sonarr_block).await; + self.is_first_render = false; + } + + if self.should_refresh { + self.dispatch_by_sonarr_block(&active_sonarr_block).await; + self.refresh_sonarr_metadata().await; + } + + if self.is_routing { + if !self.should_refresh { + self.cancellation_token.cancel(); + } else { + self.dispatch_by_sonarr_block(&active_sonarr_block).await; + self.refresh_sonarr_metadata().await; + } + } + + if self.tick_count % self.tick_until_poll == 0 { + self.refresh_sonarr_metadata().await; + } + } + + async fn refresh_sonarr_metadata(&mut self) { + self + .dispatch_network_event(SonarrEvent::GetQualityProfiles.into()) + .await; + self + .dispatch_network_event(SonarrEvent::GetLanguageProfiles.into()) + .await; + self + .dispatch_network_event(SonarrEvent::GetTags.into()) + .await; + self + .dispatch_network_event(SonarrEvent::GetRootFolders.into()) + .await; + self + .dispatch_network_event(SonarrEvent::GetDownloads.into()) + .await; + self + .dispatch_network_event(SonarrEvent::GetDiskSpace.into()) + .await; + self + .dispatch_network_event(SonarrEvent::GetStatus.into()) + .await; + } + + async fn populate_seasons_table(&mut self) { + let seasons = self + .data + .sonarr_data + .series + .current_selection() + .clone() + .seasons + .unwrap_or_default(); + self.data.sonarr_data.seasons.set_items(seasons); + } +} diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs new file mode 100644 index 0000000..ecd89ea --- /dev/null +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -0,0 +1,33 @@ +use crate::app::{context_clues::ContextClue, key_binding::DEFAULT_KEYBINDINGS}; + +#[cfg(test)] +#[path = "sonarr_context_clues_tests.rs"] +mod sonarr_context_clues_tests; + +pub static SERIES_CONTEXT_CLUES: [ContextClue; 10] = [ + (DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc), + (DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.update, "update all"), + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.esc, "cancel filter"), +]; + +pub static HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [ + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.delete, "mark as failed"), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.esc, "cancel filter"), +]; diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs new file mode 100644 index 0000000..30de8b0 --- /dev/null +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -0,0 +1,101 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::{assert_eq, assert_str_eq}; + + use crate::app::{ + key_binding::DEFAULT_KEYBINDINGS, + sonarr::sonarr_context_clues::{HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES}, + }; + + #[test] + fn test_series_context_clues() { + let mut series_context_clues_iter = SERIES_CONTEXT_CLUES.iter(); + + let (key_binding, description) = series_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.add); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.add.desc); + + let (key_binding, description) = series_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.edit); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.edit.desc); + + let (key_binding, description) = series_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc); + + let (key_binding, description) = series_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc); + + let (key_binding, description) = series_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.search.desc); + + let (key_binding, description) = series_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.filter); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.filter.desc); + + let (key_binding, description) = series_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); + + let (key_binding, description) = series_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update); + assert_str_eq!(*description, "update all"); + + let (key_binding, description) = series_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); + assert_str_eq!(*description, "details"); + + let (key_binding, description) = series_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); + assert_str_eq!(*description, "cancel filter"); + assert_eq!(series_context_clues_iter.next(), None); + } + + #[test] + fn test_history_context_clues() { + let mut history_context_clues_iter = HISTORY_CONTEXT_CLUES.iter(); + + let (key_binding, description) = history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc); + + let (key_binding, description) = history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); + assert_str_eq!(*description, "mark as failed"); + + let (key_binding, description) = history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.search.desc); + + let (key_binding, description) = history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.filter); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.filter.desc); + + let (key_binding, description) = history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); + + let (key_binding, description) = history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); + assert_str_eq!(*description, "cancel filter"); + assert_eq!(history_context_clues_iter.next(), None); + } +} diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs new file mode 100644 index 0000000..13b743f --- /dev/null +++ b/src/app/sonarr/sonarr_tests.rs @@ -0,0 +1,606 @@ +#[cfg(test)] +mod tests { + mod sonarr_tests { + use pretty_assertions::assert_eq; + use tokio::sync::mpsc; + + use crate::{ + app::App, + models::{ + servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, + sonarr_models::{Season, Series}, + }, + network::{sonarr_network::SonarrEvent, NetworkEvent}, + }; + + #[tokio::test] + async fn test_dispatch_by_blocklist_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::Blocklist) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetBlocklist.into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_series_history_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::SeriesHistory) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetSeriesHistory(None).into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_series_details_block() { + let (mut app, _) = construct_app_unit(); + + app.data.sonarr_data.series.set_items(vec![Series { + seasons: Some(vec![Season::default()]), + ..Series::default() + }]); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::SeriesDetails) + .await; + + assert!(!app.is_loading); + assert!(!app.data.sonarr_data.seasons.items.is_empty()); + assert_eq!(app.tick_count, 0); + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[tokio::test] + async fn test_dispatch_by_season_details_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::SeasonDetails) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetEpisodes(None).into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_season_search_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualSeasonSearch) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetSeasonReleases(None).into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_episode_details_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::EpisodeDetails) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetEpisodeDetails(None).into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_episode_file_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::EpisodeFile) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetEpisodeDetails(None).into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_episode_history_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::EpisodeHistory) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetEpisodeHistory(None).into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_episode_search_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualEpisodeSearch) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetEpisodeReleases(None).into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_history_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::History) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetHistory(None).into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_downloads_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::Downloads) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDownloads.into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_root_folders_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::RootFolders) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetRootFolders.into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_series_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::Series) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::ListSeries.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDownloads.into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_indexers_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::Indexers) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetIndexers.into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_all_indexer_settings_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::AllIndexerSettingsPrompt) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetAllIndexerSettings.into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_test_indexer_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::TestIndexer) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::TestIndexer(None).into() + ); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_test_all_indexers_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::TestAllIndexers) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::TestAllIndexers.into() + ); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_system_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::System) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetTasks.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetQueuedEvents.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetLogs(None).into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_system_updates_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::SystemUpdates) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetUpdates.into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_check_for_sonarr_prompt_action_no_prompt_confirm() { + let mut app = App::default(); + app.data.sonarr_data.prompt_confirm = false; + + app.check_for_sonarr_prompt_action().await; + + assert!(!app.data.sonarr_data.prompt_confirm); + assert!(!app.should_refresh); + } + + #[tokio::test] + async fn test_check_for_sonarr_prompt_action() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::GetStatus); + + app.check_for_sonarr_prompt_action().await; + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetStatus.into() + ); + assert!(app.should_refresh); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + } + + #[tokio::test] + async fn test_sonarr_refresh_metadata() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + app.is_routing = true; + + app.refresh_sonarr_metadata().await; + + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetLanguageProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetTags.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetRootFolders.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDownloads.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDiskSpace.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetStatus.into() + ); + assert!(app.is_loading); + } + + #[tokio::test] + async fn test_sonarr_on_tick_first_render() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + app.is_first_render = true; + + app.sonarr_on_tick(ActiveSonarrBlock::Downloads).await; + + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetLanguageProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetTags.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetRootFolders.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDownloads.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDiskSpace.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetStatus.into() + ); + assert!(app.is_loading); + assert!(!app.data.sonarr_data.prompt_confirm); + assert!(!app.is_first_render); + } + + #[tokio::test] + async fn test_sonarr_on_tick_routing() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + app.is_routing = true; + app.should_refresh = true; + + app.sonarr_on_tick(ActiveSonarrBlock::Downloads).await; + + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDownloads.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetLanguageProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetTags.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetRootFolders.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDownloads.into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[tokio::test] + async fn test_sonarr_on_tick_routing_while_long_request_is_running_should_cancel_request() { + let (mut app, _) = construct_app_unit(); + app.is_routing = true; + app.should_refresh = false; + + app.sonarr_on_tick(ActiveSonarrBlock::Downloads).await; + + assert!(app.cancellation_token.is_cancelled()); + } + + #[tokio::test] + async fn test_sonarr_on_tick_should_refresh() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + app.should_refresh = true; + + app.sonarr_on_tick(ActiveSonarrBlock::Downloads).await; + + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDownloads.into() + ); + assert!(app.should_refresh); + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[tokio::test] + async fn test_sonarr_on_tick_should_refresh_does_not_cancel_prompt_requests() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + app.is_loading = true; + app.is_routing = true; + app.should_refresh = true; + + app.sonarr_on_tick(ActiveSonarrBlock::Downloads).await; + + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDownloads.into() + ); + assert!(app.is_loading); + assert!(app.should_refresh); + assert!(!app.data.sonarr_data.prompt_confirm); + assert!(!app.cancellation_token.is_cancelled()); + } + + #[tokio::test] + async fn test_sonarr_on_tick_network_tick_frequency() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + app.tick_count = 2; + app.tick_until_poll = 2; + + app.sonarr_on_tick(ActiveSonarrBlock::Downloads).await; + + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetLanguageProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetTags.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetRootFolders.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDownloads.into() + ); + assert!(app.is_loading); + } + + #[tokio::test] + async fn test_populate_seasons_table_unfiltered() { + let mut app = App::default(); + app.data.sonarr_data.series.set_items(vec![Series { + seasons: Some(vec![Season::default()]), + ..Series::default() + }]); + + app.populate_seasons_table().await; + + assert!(!app.data.sonarr_data.seasons.items.is_empty()); + } + + #[tokio::test] + async fn test_populate_seasons_table_filtered() { + let mut app = App::default(); + app.data.sonarr_data.series.set_filtered_items(vec![Series { + seasons: Some(vec![Season::default()]), + ..Series::default() + }]); + + app.populate_seasons_table().await; + + assert!(!app.data.sonarr_data.seasons.items.is_empty()); + } + + fn construct_app_unit<'a>() -> (App<'a>, mpsc::Receiver) { + let (sync_network_tx, sync_network_rx) = mpsc::channel::(500); + let mut app = App { + network_tx: Some(sync_network_tx), + tick_count: 1, + is_first_render: false, + ..App::default() + }; + app.data.sonarr_data.prompt_confirm = true; + + (app, sync_network_rx) + } + } +} diff --git a/src/event/key.rs b/src/event/key.rs index a217268..51e10fe 100644 --- a/src/event/key.rs +++ b/src/event/key.rs @@ -19,6 +19,7 @@ pub enum Key { Home, End, Tab, + BackTab, Delete, Ctrl(char), Char(char), @@ -40,6 +41,7 @@ impl Display for Key { Key::Home => write!(f, ""), Key::End => write!(f, ""), Key::Tab => write!(f, ""), + Key::BackTab => write!(f, ""), Key::Delete => write!(f, ""), _ => write!(f, "<{self:?}>"), } @@ -75,6 +77,11 @@ impl From for Key { KeyEvent { code: KeyCode::End, .. } => Key::End, + KeyEvent { + code: KeyCode::BackTab, + modifiers: KeyModifiers::SHIFT, + .. + } => Key::BackTab, KeyEvent { code: KeyCode::Tab, .. } => Key::Tab, diff --git a/src/event/key_tests.rs b/src/event/key_tests.rs index 5429aad..a06269c 100644 --- a/src/event/key_tests.rs +++ b/src/event/key_tests.rs @@ -17,6 +17,7 @@ mod tests { #[case(Key::Home, "home")] #[case(Key::End, "end")] #[case(Key::Tab, "tab")] + #[case(Key::BackTab, "shift-tab")] #[case(Key::Delete, "del")] #[case(Key::Char('q'), "q")] #[case(Key::Ctrl('q'), "ctrl-q")] @@ -67,6 +68,19 @@ mod tests { assert_eq!(Key::from(KeyEvent::from(KeyCode::Tab)), Key::Tab); } + #[test] + fn test_key_from_back_tab() { + assert_eq!( + Key::from(KeyEvent { + code: KeyCode::BackTab, + modifiers: KeyModifiers::SHIFT, + kind: KeyEventKind::Press, + state: KeyEventState::NONE + }), + Key::BackTab + ); + } + #[test] fn test_key_from_delete() { assert_eq!(Key::from(KeyEvent::from(KeyCode::Delete)), Key::Delete); diff --git a/src/handlers/handler_test_utils.rs b/src/handlers/handler_test_utils.rs index ca71192..82a2d97 100644 --- a/src/handlers/handler_test_utils.rs +++ b/src/handlers/handler_test_utils.rs @@ -129,14 +129,14 @@ mod test_utils { .$data_ref .set_items(simple_stateful_iterable_vec!($items)); - $handler::with(&key, &mut app, &$block, &$context).handle(); + $handler::with(key, &mut app, $block, $context).handle(); assert_str_eq!( app.data.radarr_data.$data_ref.current_selection().$field, "Test 2" ); - $handler::with(&key, &mut app, &$block, &$context).handle(); + $handler::with(key, &mut app, $block, $context).handle(); assert_str_eq!( app.data.radarr_data.$data_ref.current_selection().$field, @@ -151,14 +151,14 @@ mod test_utils { let mut app = App::default(); app.data.radarr_data.$data_ref.set_items($items); - $handler::with(&key, &mut app, &$block, &$context).handle(); + $handler::with(key, &mut app, $block, $context).handle(); assert_str_eq!( app.data.radarr_data.$data_ref.current_selection().$field, "Test 2" ); - $handler::with(&key, &mut app, &$block, &$context).handle(); + $handler::with(key, &mut app, $block, $context).handle(); assert_str_eq!( app.data.radarr_data.$data_ref.current_selection().$field, @@ -173,7 +173,7 @@ mod test_utils { let mut app = App::default(); app.data.radarr_data.$data_ref.set_items($items); - $handler::with(&key, &mut app, &$block, &$context).handle(); + $handler::with(key, &mut app, $block, $context).handle(); assert_str_eq!( app @@ -186,7 +186,7 @@ mod test_utils { "Test 2" ); - $handler::with(&key, &mut app, &$block, &$context).handle(); + $handler::with(key, &mut app, $block, $context).handle(); assert_str_eq!( app @@ -214,11 +214,11 @@ mod test_utils { "Test 3".to_owned(), ]); - $handler::with(&DEFAULT_KEYBINDINGS.end.key, &mut app, &$block, &$context).handle(); + $handler::with(DEFAULT_KEYBINDINGS.end.key, &mut app, $block, $context).handle(); assert_str_eq!(app.data.radarr_data.$data_ref.current_selection(), "Test 3"); - $handler::with(&DEFAULT_KEYBINDINGS.home.key, &mut app, &$block, &$context).handle(); + $handler::with(DEFAULT_KEYBINDINGS.home.key, &mut app, $block, $context).handle(); assert_str_eq!(app.data.radarr_data.$data_ref.current_selection(), "Test 1"); } @@ -234,14 +234,14 @@ mod test_utils { .$data_ref .set_items(extended_stateful_iterable_vec!($items)); - $handler::with(&DEFAULT_KEYBINDINGS.end.key, &mut app, &$block, &$context).handle(); + $handler::with(DEFAULT_KEYBINDINGS.end.key, &mut app, $block, $context).handle(); assert_str_eq!( app.data.radarr_data.$data_ref.current_selection().$field, "Test 3" ); - $handler::with(&DEFAULT_KEYBINDINGS.home.key, &mut app, &$block, &$context).handle(); + $handler::with(DEFAULT_KEYBINDINGS.home.key, &mut app, $block, $context).handle(); assert_str_eq!( app.data.radarr_data.$data_ref.current_selection().$field, @@ -256,14 +256,14 @@ mod test_utils { let mut app = App::default(); app.data.radarr_data.$data_ref.set_items($items); - $handler::with(&DEFAULT_KEYBINDINGS.end.key, &mut app, &$block, &$context).handle(); + $handler::with(DEFAULT_KEYBINDINGS.end.key, &mut app, $block, $context).handle(); assert_str_eq!( app.data.radarr_data.$data_ref.current_selection().$field, "Test 3" ); - $handler::with(&DEFAULT_KEYBINDINGS.home.key, &mut app, &$block, &$context).handle(); + $handler::with(DEFAULT_KEYBINDINGS.home.key, &mut app, $block, $context).handle(); assert_str_eq!( app.data.radarr_data.$data_ref.current_selection().$field, @@ -278,7 +278,7 @@ mod test_utils { let mut app = App::default(); app.data.radarr_data.$data_ref.set_items($items); - $handler::with(&DEFAULT_KEYBINDINGS.end.key, &mut app, &$block, &$context).handle(); + $handler::with(DEFAULT_KEYBINDINGS.end.key, &mut app, $block, $context).handle(); assert_str_eq!( app @@ -291,7 +291,7 @@ mod test_utils { "Test 3" ); - $handler::with(&DEFAULT_KEYBINDINGS.home.key, &mut app, &$block, &$context).handle(); + $handler::with(DEFAULT_KEYBINDINGS.home.key, &mut app, $block, $context).handle(); assert_str_eq!( app @@ -311,18 +311,12 @@ mod test_utils { macro_rules! test_handler_delegation { ($handler:ident, $base:expr, $active_block:expr) => { let mut app = App::default(); - app.push_navigation_stack($base.clone().into()); - app.push_navigation_stack($active_block.clone().into()); + app.push_navigation_stack($base.into()); + app.push_navigation_stack($active_block.into()); - $handler::with( - &DEFAULT_KEYBINDINGS.esc.key, - &mut app, - &$active_block, - &None, - ) - .handle(); + $handler::with(DEFAULT_KEYBINDINGS.esc.key, &mut app, $active_block, None).handle(); - assert_eq!(app.get_current_route(), &$base.into()); + assert_eq!(app.get_current_route(), $base.into()); }; } } diff --git a/src/handlers/handlers_tests.rs b/src/handlers/handlers_tests.rs index f203227..5f23b02 100644 --- a/src/handlers/handlers_tests.rs +++ b/src/handlers/handlers_tests.rs @@ -1,10 +1,16 @@ #[cfg(test)] mod tests { + use pretty_assertions::assert_eq; use rstest::rstest; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; + use crate::handlers::handle_events; use crate::handlers::{handle_clear_errors, handle_prompt_toggle}; + use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::Route; #[test] fn test_handle_clear_errors() { @@ -16,17 +22,40 @@ mod tests { assert!(app.error.text.is_empty()); } + #[rstest] + #[case(0, ActiveSonarrBlock::Series, ActiveSonarrBlock::Series)] + #[case(1, ActiveRadarrBlock::Movies, ActiveRadarrBlock::Movies)] + fn test_handle_change_tabs(#[case] index: usize, #[case] left_block: T, #[case] right_block: T) + where + T: Into + Copy, + { + let mut app = App::default(); + app.server_tabs.set_index(index); + + handle_events(DEFAULT_KEYBINDINGS.previous_servarr.key, &mut app); + + assert_eq!(app.server_tabs.get_active_route(), left_block.into()); + assert_eq!(app.get_current_route(), left_block.into()); + + app.server_tabs.set_index(index); + + handle_events(DEFAULT_KEYBINDINGS.next_servarr.key, &mut app); + + assert_eq!(app.server_tabs.get_active_route(), right_block.into()); + assert_eq!(app.get_current_route(), right_block.into()); + } + #[rstest] fn test_handle_prompt_toggle_left_right(#[values(Key::Left, Key::Right)] key: Key) { let mut app = App::default(); assert!(!app.data.radarr_data.prompt_confirm); - handle_prompt_toggle(&mut app, &key); + handle_prompt_toggle(&mut app, key); assert!(app.data.radarr_data.prompt_confirm); - handle_prompt_toggle(&mut app, &key); + handle_prompt_toggle(&mut app, key); assert!(!app.data.radarr_data.prompt_confirm); } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index d7064a1..196ce1c 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -15,44 +15,44 @@ mod handlers_tests; #[path = "handler_test_utils.rs"] pub mod handler_test_utils; -pub trait KeyEventHandler<'a, 'b, T: Into> { +pub trait KeyEventHandler<'a, 'b, T: Into + Copy> { fn handle_key_event(&mut self) { let key = self.get_key(); match key { - _ if *key == DEFAULT_KEYBINDINGS.up.key => { + _ if key == DEFAULT_KEYBINDINGS.up.key => { if self.is_ready() { self.handle_scroll_up(); } } - _ if *key == DEFAULT_KEYBINDINGS.down.key => { + _ if key == DEFAULT_KEYBINDINGS.down.key => { if self.is_ready() { self.handle_scroll_down(); } } - _ if *key == DEFAULT_KEYBINDINGS.home.key => { + _ if key == DEFAULT_KEYBINDINGS.home.key => { if self.is_ready() { self.handle_home(); } } - _ if *key == DEFAULT_KEYBINDINGS.end.key => { + _ if key == DEFAULT_KEYBINDINGS.end.key => { if self.is_ready() { self.handle_end(); } } - _ if *key == DEFAULT_KEYBINDINGS.delete.key => { + _ if key == DEFAULT_KEYBINDINGS.delete.key => { if self.is_ready() { self.handle_delete(); } } - _ if *key == DEFAULT_KEYBINDINGS.left.key || *key == DEFAULT_KEYBINDINGS.right.key => { + _ if key == DEFAULT_KEYBINDINGS.left.key || key == DEFAULT_KEYBINDINGS.right.key => { self.handle_left_right_action() } - _ if *key == DEFAULT_KEYBINDINGS.submit.key => { + _ if key == DEFAULT_KEYBINDINGS.submit.key => { if self.is_ready() { self.handle_submit(); } } - _ if *key == DEFAULT_KEYBINDINGS.esc.key => self.handle_esc(), + _ if key == DEFAULT_KEYBINDINGS.esc.key => self.handle_esc(), _ => { if self.is_ready() { self.handle_char_key_event(); @@ -65,9 +65,9 @@ pub trait KeyEventHandler<'a, 'b, T: Into> { self.handle_key_event(); } - fn accepts(active_block: &'a T) -> bool; - fn with(key: &'a Key, app: &'a mut App<'b>, active_block: &'a T, context: &'a Option) -> Self; - fn get_key(&self) -> &Key; + fn accepts(active_block: T) -> bool; + fn with(key: Key, app: &'a mut App<'b>, active_block: T, context: Option) -> Self; + fn get_key(&self) -> Key; fn is_ready(&self) -> bool; fn handle_scroll_up(&mut self); fn handle_scroll_down(&mut self); @@ -81,8 +81,14 @@ pub trait KeyEventHandler<'a, 'b, T: Into> { } pub fn handle_events(key: Key, app: &mut App<'_>) { - if let Route::Radarr(active_radarr_block, context) = *app.get_current_route() { - RadarrHandler::with(&key, app, &active_radarr_block, &context).handle() + if key == DEFAULT_KEYBINDINGS.next_servarr.key { + app.server_tabs.next(); + app.pop_and_push_navigation_stack(app.server_tabs.get_active_route()); + } else if key == DEFAULT_KEYBINDINGS.previous_servarr.key { + app.server_tabs.previous(); + app.pop_and_push_navigation_stack(app.server_tabs.get_active_route()); + } else if let Route::Radarr(active_radarr_block, context) = app.get_current_route() { + RadarrHandler::with(key, app, active_radarr_block, context).handle() } } @@ -92,10 +98,10 @@ fn handle_clear_errors(app: &mut App<'_>) { } } -fn handle_prompt_toggle(app: &mut App<'_>, key: &Key) { +fn handle_prompt_toggle(app: &mut App<'_>, key: Key) { match key { - _ if *key == DEFAULT_KEYBINDINGS.left.key || *key == DEFAULT_KEYBINDINGS.right.key => { - if let Route::Radarr(_, _) = *app.get_current_route() { + _ if key == DEFAULT_KEYBINDINGS.left.key || key == DEFAULT_KEYBINDINGS.right.key => { + if let Route::Radarr(_, _) = app.get_current_route() { app.data.radarr_data.prompt_confirm = !app.data.radarr_data.prompt_confirm; } } @@ -107,10 +113,10 @@ fn handle_prompt_toggle(app: &mut App<'_>, key: &Key) { macro_rules! handle_text_box_left_right_keys { ($self:expr, $key:expr, $input:expr) => { match $self.key { - _ if *$key == DEFAULT_KEYBINDINGS.left.key => { + _ if $key == DEFAULT_KEYBINDINGS.left.key => { $input.scroll_left(); } - _ if *$key == DEFAULT_KEYBINDINGS.right.key => { + _ if $key == DEFAULT_KEYBINDINGS.right.key => { $input.scroll_right(); } _ => (), @@ -122,11 +128,11 @@ macro_rules! handle_text_box_left_right_keys { macro_rules! handle_text_box_keys { ($self:expr, $key:expr, $input:expr) => { match $self.key { - _ if *$key == DEFAULT_KEYBINDINGS.backspace.key => { + _ if $key == DEFAULT_KEYBINDINGS.backspace.key => { $input.pop(); } Key::Char(character) => { - $input.push(*character); + $input.push(character); } _ => (), } diff --git a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs index 67d7dbf..812b30f 100644 --- a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs +++ b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs @@ -52,7 +52,7 @@ mod tests { source_title )); - BlocklistHandler::with(&key, &mut app, &ActiveRadarrBlock::Blocklist, &None).handle(); + BlocklistHandler::with(key, &mut app, ActiveRadarrBlock::Blocklist, None).handle(); assert_str_eq!( app @@ -65,7 +65,7 @@ mod tests { "Test 1" ); - BlocklistHandler::with(&key, &mut app, &ActiveRadarrBlock::Blocklist, &None).handle(); + BlocklistHandler::with(key, &mut app, ActiveRadarrBlock::Blocklist, None).handle(); assert_str_eq!( app @@ -89,13 +89,8 @@ mod tests { if key == Key::Up { for i in (0..blocklist_field_vec.len()).rev() { - BlocklistHandler::with( - &key, - &mut app, - &ActiveRadarrBlock::BlocklistSortPrompt, - &None, - ) - .handle(); + BlocklistHandler::with(key, &mut app, ActiveRadarrBlock::BlocklistSortPrompt, None) + .handle(); assert_eq!( app @@ -111,13 +106,8 @@ mod tests { } } else { for i in 0..blocklist_field_vec.len() { - BlocklistHandler::with( - &key, - &mut app, - &ActiveRadarrBlock::BlocklistSortPrompt, - &None, - ) - .handle(); + BlocklistHandler::with(key, &mut app, ActiveRadarrBlock::BlocklistSortPrompt, None) + .handle(); assert_eq!( app @@ -169,10 +159,10 @@ mod tests { )); BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::Blocklist, - &None, + ActiveRadarrBlock::Blocklist, + None, ) .handle(); @@ -188,10 +178,10 @@ mod tests { ); BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::Blocklist, - &None, + ActiveRadarrBlock::Blocklist, + None, ) .handle(); @@ -214,10 +204,10 @@ mod tests { app.data.radarr_data.blocklist.sorting(sort_options()); BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::BlocklistSortPrompt, - &None, + ActiveRadarrBlock::BlocklistSortPrompt, + None, ) .handle(); @@ -234,10 +224,10 @@ mod tests { ); BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::BlocklistSortPrompt, - &None, + ActiveRadarrBlock::BlocklistSortPrompt, + None, ) .handle(); @@ -267,11 +257,11 @@ mod tests { let mut app = App::default(); app.data.radarr_data.blocklist.set_items(blocklist_vec()); - BlocklistHandler::with(&DELETE_KEY, &mut app, &ActiveRadarrBlock::Blocklist, &None).handle(); + BlocklistHandler::with(DELETE_KEY, &mut app, ActiveRadarrBlock::Blocklist, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::DeleteBlocklistItemPrompt.into() + ActiveRadarrBlock::DeleteBlocklistItemPrompt.into() ); } @@ -282,12 +272,9 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Blocklist.into()); app.data.radarr_data.blocklist.set_items(blocklist_vec()); - BlocklistHandler::with(&DELETE_KEY, &mut app, &ActiveRadarrBlock::Blocklist, &None).handle(); + BlocklistHandler::with(DELETE_KEY, &mut app, ActiveRadarrBlock::Blocklist, None).handle(); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Blocklist.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); } } @@ -304,21 +291,18 @@ mod tests { app.data.radarr_data.main_tabs.set_index(3); BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::Blocklist, - &None, + ActiveRadarrBlock::Blocklist, + None, ) .handle(); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &ActiveRadarrBlock::Downloads.into() - ); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Downloads.into() + ActiveRadarrBlock::Downloads.into() ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Downloads.into()); } #[rstest] @@ -328,20 +312,20 @@ mod tests { app.data.radarr_data.main_tabs.set_index(3); BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::Blocklist, - &None, + ActiveRadarrBlock::Blocklist, + None, ) .handle(); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &ActiveRadarrBlock::RootFolders.into() + ActiveRadarrBlock::RootFolders.into() ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::RootFolders.into() + ActiveRadarrBlock::RootFolders.into() ); } @@ -356,11 +340,11 @@ mod tests { ) { let mut app = App::default(); - BlocklistHandler::with(&key, &mut app, &active_radarr_block, &None).handle(); + BlocklistHandler::with(key, &mut app, active_radarr_block, None).handle(); assert!(app.data.radarr_data.prompt_confirm); - BlocklistHandler::with(&key, &mut app, &active_radarr_block, &None).handle(); + BlocklistHandler::with(key, &mut app, active_radarr_block, None).handle(); assert!(!app.data.radarr_data.prompt_confirm); } @@ -382,11 +366,11 @@ mod tests { app.data.radarr_data.blocklist.set_items(blocklist_vec()); app.push_navigation_stack(ActiveRadarrBlock::Blocklist.into()); - BlocklistHandler::with(&SUBMIT_KEY, &mut app, &ActiveRadarrBlock::Blocklist, &None).handle(); + BlocklistHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::Blocklist, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::BlocklistItemDetails.into() + ActiveRadarrBlock::BlocklistItemDetails.into() ); } @@ -397,12 +381,9 @@ mod tests { app.data.radarr_data.blocklist.set_items(blocklist_vec()); app.push_navigation_stack(ActiveRadarrBlock::Blocklist.into()); - BlocklistHandler::with(&SUBMIT_KEY, &mut app, &ActiveRadarrBlock::Blocklist, &None).handle(); + BlocklistHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::Blocklist, None).handle(); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Blocklist.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); } #[rstest] @@ -427,14 +408,14 @@ mod tests { app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); - BlocklistHandler::with(&SUBMIT_KEY, &mut app, &prompt_block, &None).handle(); + BlocklistHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); assert!(app.data.radarr_data.prompt_confirm); assert_eq!( app.data.radarr_data.prompt_confirm_action, Some(expected_action) ); - assert_eq!(app.get_current_route(), &base_route.into()); + assert_eq!(app.get_current_route(), base_route.into()); } #[rstest] @@ -450,14 +431,11 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Blocklist.into()); app.push_navigation_stack(prompt_block.into()); - BlocklistHandler::with(&SUBMIT_KEY, &mut app, &prompt_block, &None).handle(); + BlocklistHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); assert!(!app.data.radarr_data.prompt_confirm); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Blocklist.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); } #[test] @@ -474,17 +452,14 @@ mod tests { expected_vec.reverse(); BlocklistHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::BlocklistSortPrompt, - &None, + ActiveRadarrBlock::BlocklistSortPrompt, + None, ) .handle(); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Blocklist.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); assert_eq!(app.data.radarr_data.blocklist.items, expected_vec); } } @@ -517,9 +492,9 @@ mod tests { app.push_navigation_stack(prompt_block.into()); app.data.radarr_data.prompt_confirm = true; - BlocklistHandler::with(&ESC_KEY, &mut app, &prompt_block, &None).handle(); + BlocklistHandler::with(ESC_KEY, &mut app, prompt_block, None).handle(); - assert_eq!(app.get_current_route(), &base_block.into()); + assert_eq!(app.get_current_route(), base_block.into()); assert!(!app.data.radarr_data.prompt_confirm); } @@ -530,17 +505,14 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::BlocklistItemDetails.into()); BlocklistHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::BlocklistItemDetails, - &None, + ActiveRadarrBlock::BlocklistItemDetails, + None, ) .handle(); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Blocklist.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); } #[test] @@ -550,17 +522,14 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::BlocklistSortPrompt.into()); BlocklistHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::BlocklistSortPrompt, - &None, + ActiveRadarrBlock::BlocklistSortPrompt, + None, ) .handle(); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Blocklist.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); } #[rstest] @@ -571,12 +540,9 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Blocklist.into()); app.push_navigation_stack(ActiveRadarrBlock::Blocklist.into()); - DownloadsHandler::with(&ESC_KEY, &mut app, &ActiveRadarrBlock::Blocklist, &None).handle(); + DownloadsHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::Blocklist, None).handle(); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Blocklist.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); assert!(app.error.text.is_empty()); } } @@ -596,17 +562,14 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Blocklist.into()); BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &ActiveRadarrBlock::Blocklist, - &None, + ActiveRadarrBlock::Blocklist, + None, ) .handle(); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Blocklist.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); assert!(app.should_refresh); } @@ -618,17 +581,14 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Blocklist.into()); BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &ActiveRadarrBlock::Blocklist, - &None, + ActiveRadarrBlock::Blocklist, + None, ) .handle(); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Blocklist.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); assert!(!app.should_refresh); } @@ -638,16 +598,16 @@ mod tests { app.data.radarr_data.blocklist.set_items(blocklist_vec()); BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.clear.key, + DEFAULT_KEYBINDINGS.clear.key, &mut app, - &ActiveRadarrBlock::Blocklist, - &None, + ActiveRadarrBlock::Blocklist, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::BlocklistClearAllItemsPrompt.into() + ActiveRadarrBlock::BlocklistClearAllItemsPrompt.into() ); } @@ -659,17 +619,14 @@ mod tests { app.data.radarr_data.blocklist.set_items(blocklist_vec()); BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.clear.key, + DEFAULT_KEYBINDINGS.clear.key, &mut app, - &ActiveRadarrBlock::Blocklist, - &None, + ActiveRadarrBlock::Blocklist, + None, ) .handle(); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Blocklist.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); } #[test] @@ -678,16 +635,16 @@ mod tests { app.data.radarr_data.blocklist.set_items(blocklist_vec()); BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.sort.key, + DEFAULT_KEYBINDINGS.sort.key, &mut app, - &ActiveRadarrBlock::Blocklist, - &None, + ActiveRadarrBlock::Blocklist, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::BlocklistSortPrompt.into() + ActiveRadarrBlock::BlocklistSortPrompt.into() ); assert_eq!( app.data.radarr_data.blocklist.sort.as_ref().unwrap().items, @@ -704,17 +661,14 @@ mod tests { app.data.radarr_data.blocklist.set_items(blocklist_vec()); BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.sort.key, + DEFAULT_KEYBINDINGS.sort.key, &mut app, - &ActiveRadarrBlock::Blocklist, - &None, + ActiveRadarrBlock::Blocklist, + None, ) .handle(); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Blocklist.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); assert!(app.data.radarr_data.blocklist.sort.is_none()); assert!(!app.data.radarr_data.blocklist.sort_asc); } @@ -741,10 +695,10 @@ mod tests { app.push_navigation_stack(prompt_block.into()); BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.confirm.key, + DEFAULT_KEYBINDINGS.confirm.key, &mut app, - &prompt_block, - &None, + prompt_block, + None, ) .handle(); @@ -753,7 +707,7 @@ mod tests { app.data.radarr_data.prompt_confirm_action, Some(expected_action) ); - assert_eq!(app.get_current_route(), &base_route.into()); + assert_eq!(app.get_current_route(), base_route.into()); } } @@ -896,9 +850,9 @@ mod tests { fn test_blocklist_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if BLOCKLIST_BLOCKS.contains(&active_radarr_block) { - assert!(BlocklistHandler::accepts(&active_radarr_block)); + assert!(BlocklistHandler::accepts(active_radarr_block)); } else { - assert!(!BlocklistHandler::accepts(&active_radarr_block)); + assert!(!BlocklistHandler::accepts(active_radarr_block)); } }) } @@ -909,10 +863,10 @@ mod tests { app.is_loading = true; let handler = BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Blocklist, - &None, + ActiveRadarrBlock::Blocklist, + None, ); assert!(!handler.is_ready()); @@ -924,10 +878,10 @@ mod tests { app.is_loading = false; let handler = BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Blocklist, - &None, + ActiveRadarrBlock::Blocklist, + None, ); assert!(!handler.is_ready()); @@ -944,10 +898,10 @@ mod tests { .set_items(vec![BlocklistItem::default()]); let handler = BlocklistHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Blocklist, - &None, + ActiveRadarrBlock::Blocklist, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/blocklist/mod.rs b/src/handlers/radarr_handlers/blocklist/mod.rs index 88bc21f..940e2b2 100644 --- a/src/handlers/radarr_handlers/blocklist/mod.rs +++ b/src/handlers/radarr_handlers/blocklist/mod.rs @@ -14,22 +14,22 @@ use crate::network::radarr_network::RadarrEvent; mod blocklist_handler_tests; pub(super) struct BlocklistHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + _context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a, 'b> { - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { - BLOCKLIST_BLOCKS.contains(active_block) + fn accepts(active_block: ActiveRadarrBlock) -> bool { + BLOCKLIST_BLOCKS.contains(&active_block) } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_block: ActiveRadarrBlock, + context: Option, ) -> Self { BlocklistHandler { key, @@ -39,7 +39,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a, } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -112,7 +112,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a, } fn handle_delete(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::Blocklist { + if self.active_radarr_block == ActiveRadarrBlock::Blocklist { self .app .push_navigation_stack(ActiveRadarrBlock::DeleteBlocklistItemPrompt.into()); @@ -184,15 +184,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a, let key = self.key; match self.active_radarr_block { ActiveRadarrBlock::Blocklist => match self.key { - _ if *key == DEFAULT_KEYBINDINGS.refresh.key => { + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { self.app.should_refresh = true; } - _ if *key == DEFAULT_KEYBINDINGS.clear.key => { + _ if key == DEFAULT_KEYBINDINGS.clear.key => { self .app .push_navigation_stack(ActiveRadarrBlock::BlocklistClearAllItemsPrompt.into()); } - _ if *key == DEFAULT_KEYBINDINGS.sort.key => { + _ if key == DEFAULT_KEYBINDINGS.sort.key => { self .app .data @@ -206,7 +206,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a, _ => (), }, ActiveRadarrBlock::DeleteBlocklistItemPrompt => { - if *key == DEFAULT_KEYBINDINGS.confirm.key { + if key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::DeleteBlocklistItem(None)); @@ -215,7 +215,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a, } } ActiveRadarrBlock::BlocklistClearAllItemsPrompt => { - if *key == DEFAULT_KEYBINDINGS.confirm.key { + if key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::ClearBlocklist); diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler.rs b/src/handlers/radarr_handlers/collections/collection_details_handler.rs index c9243d9..89f5308 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler.rs @@ -14,22 +14,22 @@ use crate::models::{BlockSelectionState, Scrollable}; mod collection_details_handler_tests; pub(super) struct CollectionDetailsHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + _context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHandler<'a, 'b> { - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { - COLLECTION_DETAILS_BLOCKS.contains(active_block) + fn accepts(active_block: ActiveRadarrBlock) -> bool { + COLLECTION_DETAILS_BLOCKS.contains(&active_block) } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_block: ActiveRadarrBlock, + _context: Option, ) -> CollectionDetailsHandler<'a, 'b> { CollectionDetailsHandler { key, @@ -39,7 +39,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHan } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -48,25 +48,25 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHan } fn handle_scroll_up(&mut self) { - if ActiveRadarrBlock::CollectionDetails == *self.active_radarr_block { + if ActiveRadarrBlock::CollectionDetails == self.active_radarr_block { self.app.data.radarr_data.collection_movies.scroll_up() } } fn handle_scroll_down(&mut self) { - if ActiveRadarrBlock::CollectionDetails == *self.active_radarr_block { + if ActiveRadarrBlock::CollectionDetails == self.active_radarr_block { self.app.data.radarr_data.collection_movies.scroll_down() } } fn handle_home(&mut self) { - if ActiveRadarrBlock::CollectionDetails == *self.active_radarr_block { + if ActiveRadarrBlock::CollectionDetails == self.active_radarr_block { self.app.data.radarr_data.collection_movies.scroll_to_top(); } } fn handle_end(&mut self) { - if ActiveRadarrBlock::CollectionDetails == *self.active_radarr_block { + if ActiveRadarrBlock::CollectionDetails == self.active_radarr_block { self .app .data @@ -81,7 +81,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHan fn handle_left_right_action(&mut self) {} fn handle_submit(&mut self) { - if ActiveRadarrBlock::CollectionDetails == *self.active_radarr_block { + if ActiveRadarrBlock::CollectionDetails == self.active_radarr_block { let tmdb_id = self .app .data @@ -129,13 +129,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHan } fn handle_char_key_event(&mut self) { - if *self.active_radarr_block == ActiveRadarrBlock::CollectionDetails - && *self.key == DEFAULT_KEYBINDINGS.edit.key + if self.active_radarr_block == ActiveRadarrBlock::CollectionDetails + && self.key == DEFAULT_KEYBINDINGS.edit.key { self.app.push_navigation_stack( ( ActiveRadarrBlock::EditCollectionPrompt, - Some(*self.active_radarr_block), + Some(self.active_radarr_block), ) .into(), ); diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs b/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs index 34659d5..4b46809 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs @@ -50,7 +50,7 @@ mod tests { HorizontallyScrollableText )); - CollectionDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::CollectionDetails, &None) + CollectionDetailsHandler::with(key, &mut app, ActiveRadarrBlock::CollectionDetails, None) .handle(); assert_str_eq!( @@ -64,7 +64,7 @@ mod tests { "Test 1" ); - CollectionDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::CollectionDetails, &None) + CollectionDetailsHandler::with(key, &mut app, ActiveRadarrBlock::CollectionDetails, None) .handle(); assert_str_eq!( @@ -110,10 +110,10 @@ mod tests { )); CollectionDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::CollectionDetails, - &None, + ActiveRadarrBlock::CollectionDetails, + None, ) .handle(); @@ -129,10 +129,10 @@ mod tests { ); CollectionDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::CollectionDetails, - &None, + ActiveRadarrBlock::CollectionDetails, + None, ) .handle(); @@ -179,16 +179,16 @@ mod tests { .set_index(ADD_MOVIE_SELECTION_BLOCKS.len() - 1); CollectionDetailsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::CollectionDetails, - &None, + ActiveRadarrBlock::CollectionDetails, + None, ) .handle(); assert_eq!( app.get_current_route(), - &( + ( ActiveRadarrBlock::AddMoviePrompt, Some(ActiveRadarrBlock::CollectionDetails) ) @@ -205,7 +205,7 @@ mod tests { .is_empty()); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::AddMovieSelectRootFolder + ActiveRadarrBlock::AddMovieSelectRootFolder ); assert!(!app .data @@ -250,16 +250,16 @@ mod tests { .set_items(vec![CollectionMovie::default()]); CollectionDetailsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::CollectionDetails, - &None, + ActiveRadarrBlock::CollectionDetails, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::CollectionDetails.into() + ActiveRadarrBlock::CollectionDetails.into() ); assert!(app.data.radarr_data.add_movie_modal.is_none()); } @@ -279,16 +279,16 @@ mod tests { .set_items(vec![Movie::default()]); CollectionDetailsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::CollectionDetails, - &None, + ActiveRadarrBlock::CollectionDetails, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::ViewMovieOverview.into() + ActiveRadarrBlock::ViewMovieOverview.into() ); } } @@ -313,16 +313,16 @@ mod tests { .set_items(vec![CollectionMovie::default()]); CollectionDetailsHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::CollectionDetails, - &None, + ActiveRadarrBlock::CollectionDetails, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert!(app.data.radarr_data.collection_movies.items.is_empty()); } @@ -334,16 +334,16 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into()); CollectionDetailsHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::ViewMovieOverview, - &None, + ActiveRadarrBlock::ViewMovieOverview, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::CollectionDetails.into() + ActiveRadarrBlock::CollectionDetails.into() ); } } @@ -388,16 +388,16 @@ mod tests { app.data.radarr_data = radarr_data; CollectionDetailsHandler::with( - &DEFAULT_KEYBINDINGS.edit.key, + DEFAULT_KEYBINDINGS.edit.key, &mut app, - &ActiveRadarrBlock::CollectionDetails, - &None, + ActiveRadarrBlock::CollectionDetails, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::CollectionDetails.into() + ActiveRadarrBlock::CollectionDetails.into() ); assert!(app.data.radarr_data.edit_collection_modal.is_none()); } @@ -407,9 +407,9 @@ mod tests { fn test_collection_details_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if COLLECTION_DETAILS_BLOCKS.contains(&active_radarr_block) { - assert!(CollectionDetailsHandler::accepts(&active_radarr_block)); + assert!(CollectionDetailsHandler::accepts(active_radarr_block)); } else { - assert!(!CollectionDetailsHandler::accepts(&active_radarr_block)); + assert!(!CollectionDetailsHandler::accepts(active_radarr_block)); } }); } @@ -420,10 +420,10 @@ mod tests { app.is_loading = true; let handler = CollectionDetailsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::CollectionDetails, - &None, + ActiveRadarrBlock::CollectionDetails, + None, ); assert!(!handler.is_ready()); @@ -435,10 +435,10 @@ mod tests { app.is_loading = false; let handler = CollectionDetailsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::CollectionDetails, - &None, + ActiveRadarrBlock::CollectionDetails, + None, ); assert!(!handler.is_ready()); @@ -455,10 +455,10 @@ mod tests { .set_items(vec![CollectionMovie::default()]); let handler = CollectionDetailsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::CollectionDetails, - &None, + ActiveRadarrBlock::CollectionDetails, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs index 1eb268b..a85b7ab 100644 --- a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs @@ -60,7 +60,7 @@ mod tests { HorizontallyScrollableText )); - CollectionsHandler::with(&key, &mut app, &ActiveRadarrBlock::Collections, &None).handle(); + CollectionsHandler::with(key, &mut app, ActiveRadarrBlock::Collections, None).handle(); assert_str_eq!( app @@ -73,7 +73,7 @@ mod tests { "Test 1" ); - CollectionsHandler::with(&key, &mut app, &ActiveRadarrBlock::Collections, &None).handle(); + CollectionsHandler::with(key, &mut app, ActiveRadarrBlock::Collections, None).handle(); assert_str_eq!( app @@ -98,10 +98,10 @@ mod tests { if key == Key::Up { for i in (0..collection_field_vec.len()).rev() { CollectionsHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::CollectionsSortPrompt, - &None, + ActiveRadarrBlock::CollectionsSortPrompt, + None, ) .handle(); @@ -120,10 +120,10 @@ mod tests { } else { for i in 0..collection_field_vec.len() { CollectionsHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::CollectionsSortPrompt, - &None, + ActiveRadarrBlock::CollectionsSortPrompt, + None, ) .handle(); @@ -175,10 +175,10 @@ mod tests { )); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); @@ -194,10 +194,10 @@ mod tests { ); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); @@ -224,10 +224,10 @@ mod tests { app.data.radarr_data.collections.search = Some("Test".into()); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::SearchCollection, - &None, + ActiveRadarrBlock::SearchCollection, + None, ) .handle(); @@ -245,10 +245,10 @@ mod tests { ); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::SearchCollection, - &None, + ActiveRadarrBlock::SearchCollection, + None, ) .handle(); @@ -277,10 +277,10 @@ mod tests { app.data.radarr_data.collections.filter = Some("Test".into()); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::FilterCollections, - &None, + ActiveRadarrBlock::FilterCollections, + None, ) .handle(); @@ -298,10 +298,10 @@ mod tests { ); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::FilterCollections, - &None, + ActiveRadarrBlock::FilterCollections, + None, ) .handle(); @@ -326,10 +326,10 @@ mod tests { app.data.radarr_data.collections.sorting(sort_options()); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::CollectionsSortPrompt, - &None, + ActiveRadarrBlock::CollectionsSortPrompt, + None, ) .handle(); @@ -346,10 +346,10 @@ mod tests { ); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::CollectionsSortPrompt, - &None, + ActiveRadarrBlock::CollectionsSortPrompt, + None, ) .handle(); @@ -380,18 +380,18 @@ mod tests { app.data.radarr_data.main_tabs.set_index(1); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &ActiveRadarrBlock::Movies.into() + ActiveRadarrBlock::Movies.into() ); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } #[rstest] @@ -401,21 +401,18 @@ mod tests { app.data.radarr_data.main_tabs.set_index(1); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &ActiveRadarrBlock::Downloads.into() - ); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Downloads.into() + ActiveRadarrBlock::Downloads.into() ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Downloads.into()); } #[rstest] @@ -425,20 +422,20 @@ mod tests { let mut app = App::default(); CollectionsHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::UpdateAllCollectionsPrompt, - &None, + ActiveRadarrBlock::UpdateAllCollectionsPrompt, + None, ) .handle(); assert!(app.data.radarr_data.prompt_confirm); CollectionsHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::UpdateAllCollectionsPrompt, - &None, + ActiveRadarrBlock::UpdateAllCollectionsPrompt, + None, ) .handle(); @@ -451,10 +448,10 @@ mod tests { app.data.radarr_data.collections.search = Some("Test".into()); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::SearchCollection, - &None, + ActiveRadarrBlock::SearchCollection, + None, ) .handle(); @@ -472,10 +469,10 @@ mod tests { ); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::SearchCollection, - &None, + ActiveRadarrBlock::SearchCollection, + None, ) .handle(); @@ -499,10 +496,10 @@ mod tests { app.data.radarr_data.collections.filter = Some("Test".into()); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::FilterCollections, - &None, + ActiveRadarrBlock::FilterCollections, + None, ) .handle(); @@ -520,10 +517,10 @@ mod tests { ); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::FilterCollections, - &None, + ActiveRadarrBlock::FilterCollections, + None, ) .handle(); @@ -560,17 +557,11 @@ mod tests { .collections .set_items(vec![Collection::default()]); - CollectionsHandler::with( - &SUBMIT_KEY, - &mut app, - &ActiveRadarrBlock::Collections, - &None, - ) - .handle(); + CollectionsHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::Collections, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::CollectionDetails.into() + ActiveRadarrBlock::CollectionDetails.into() ); } @@ -585,17 +576,11 @@ mod tests { .collections .set_items(vec![Collection::default()]); - CollectionsHandler::with( - &SUBMIT_KEY, - &mut app, - &ActiveRadarrBlock::Collections, - &None, - ) - .handle(); + CollectionsHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::Collections, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); } @@ -615,10 +600,10 @@ mod tests { app.data.radarr_data.collections.search = Some("Test 2".into()); CollectionsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::SearchCollection, - &None, + ActiveRadarrBlock::SearchCollection, + None, ) .handle(); @@ -634,7 +619,7 @@ mod tests { ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); } @@ -654,10 +639,10 @@ mod tests { app.data.radarr_data.collections.search = Some("Test 5".into()); CollectionsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::SearchCollection, - &None, + ActiveRadarrBlock::SearchCollection, + None, ) .handle(); @@ -673,7 +658,7 @@ mod tests { ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::SearchCollectionError.into() + ActiveRadarrBlock::SearchCollectionError.into() ); } @@ -693,10 +678,10 @@ mod tests { app.data.radarr_data.collections.search = Some("Test 2".into()); CollectionsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::SearchCollection, - &None, + ActiveRadarrBlock::SearchCollection, + None, ) .handle(); @@ -712,7 +697,7 @@ mod tests { ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); } @@ -732,10 +717,10 @@ mod tests { app.data.radarr_data.collections.filter = Some("Test".into()); CollectionsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::FilterCollections, - &None, + ActiveRadarrBlock::FilterCollections, + None, ) .handle(); @@ -764,7 +749,7 @@ mod tests { ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); } @@ -784,10 +769,10 @@ mod tests { app.data.radarr_data.collections.filter = Some("Test 5".into()); CollectionsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::FilterCollections, - &None, + ActiveRadarrBlock::FilterCollections, + None, ) .handle(); @@ -795,7 +780,7 @@ mod tests { assert!(app.data.radarr_data.collections.filtered_items.is_none()); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::FilterCollectionsError.into() + ActiveRadarrBlock::FilterCollectionsError.into() ); } @@ -812,10 +797,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::UpdateAllCollectionsPrompt.into()); CollectionsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::UpdateAllCollectionsPrompt, - &None, + ActiveRadarrBlock::UpdateAllCollectionsPrompt, + None, ) .handle(); @@ -826,7 +811,7 @@ mod tests { ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); } @@ -842,10 +827,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::UpdateAllCollectionsPrompt.into()); CollectionsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::UpdateAllCollectionsPrompt, - &None, + ActiveRadarrBlock::UpdateAllCollectionsPrompt, + None, ) .handle(); @@ -853,7 +838,7 @@ mod tests { assert_eq!(app.data.radarr_data.prompt_confirm_action, None); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); } @@ -875,16 +860,16 @@ mod tests { expected_vec.reverse(); CollectionsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::CollectionsSortPrompt, - &None, + ActiveRadarrBlock::CollectionsSortPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert_eq!(app.data.radarr_data.collections.items, expected_vec); } @@ -916,11 +901,11 @@ mod tests { app.data.radarr_data = create_test_radarr_data(); app.data.radarr_data.collections.search = Some("Test".into()); - CollectionsHandler::with(&ESC_KEY, &mut app, &active_radarr_block, &None).handle(); + CollectionsHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert!(!app.should_ignore_quit_key); assert_eq!(app.data.radarr_data.collections.search, None); @@ -946,11 +931,11 @@ mod tests { ..StatefulTable::default() }; - CollectionsHandler::with(&ESC_KEY, &mut app, &active_radarr_block, &None).handle(); + CollectionsHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert!(!app.should_ignore_quit_key); assert_eq!(app.data.radarr_data.collections.filter, None); @@ -966,16 +951,16 @@ mod tests { app.data.radarr_data.prompt_confirm = true; CollectionsHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::UpdateAllCollectionsPrompt, - &None, + ActiveRadarrBlock::UpdateAllCollectionsPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert!(!app.data.radarr_data.prompt_confirm); } @@ -987,16 +972,16 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::CollectionsSortPrompt.into()); CollectionsHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::CollectionsSortPrompt, - &None, + ActiveRadarrBlock::CollectionsSortPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); } @@ -1016,11 +1001,11 @@ mod tests { ..StatefulTable::default() }; - CollectionsHandler::with(&ESC_KEY, &mut app, &ActiveRadarrBlock::Collections, &None).handle(); + CollectionsHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::Collections, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert!(app.error.text.is_empty()); assert_eq!(app.data.radarr_data.collections.search, None); @@ -1055,16 +1040,16 @@ mod tests { .set_items(vec![Collection::default()]); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.search.key, + DEFAULT_KEYBINDINGS.search.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::SearchCollection.into() + ActiveRadarrBlock::SearchCollection.into() ); assert!(app.should_ignore_quit_key); assert_eq!( @@ -1085,16 +1070,16 @@ mod tests { .set_items(vec![Collection::default()]); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.search.key, + DEFAULT_KEYBINDINGS.search.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert!(!app.should_ignore_quit_key); assert_eq!(app.data.radarr_data.collections.search, None); @@ -1110,16 +1095,16 @@ mod tests { .set_items(vec![Collection::default()]); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.filter.key, + DEFAULT_KEYBINDINGS.filter.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::FilterCollections.into() + ActiveRadarrBlock::FilterCollections.into() ); assert!(app.should_ignore_quit_key); assert!(app.data.radarr_data.collections.filter.is_some()); @@ -1137,16 +1122,16 @@ mod tests { .set_items(vec![Collection::default()]); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.filter.key, + DEFAULT_KEYBINDINGS.filter.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert!(!app.should_ignore_quit_key); assert!(app.data.radarr_data.collections.filter.is_none()); @@ -1166,16 +1151,16 @@ mod tests { app.data.radarr_data.collections.filter = Some("Test".into()); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.filter.key, + DEFAULT_KEYBINDINGS.filter.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::FilterCollections.into() + ActiveRadarrBlock::FilterCollections.into() ); assert!(app.should_ignore_quit_key); assert_eq!( @@ -1212,16 +1197,16 @@ mod tests { app.data.radarr_data = radarr_data; CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.edit.key, + DEFAULT_KEYBINDINGS.edit.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert!(app.data.radarr_data.edit_collection_modal.is_none()); } @@ -1236,16 +1221,16 @@ mod tests { .set_items(vec![Collection::default()]); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.update.key, + DEFAULT_KEYBINDINGS.update.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::UpdateAllCollectionsPrompt.into() + ActiveRadarrBlock::UpdateAllCollectionsPrompt.into() ); } @@ -1261,16 +1246,16 @@ mod tests { .set_items(vec![Collection::default()]); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.update.key, + DEFAULT_KEYBINDINGS.update.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); } @@ -1285,16 +1270,16 @@ mod tests { .set_items(vec![Collection::default()]); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert!(app.should_refresh); } @@ -1311,16 +1296,16 @@ mod tests { .set_items(vec![Collection::default()]); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert!(!app.should_refresh); } @@ -1336,10 +1321,10 @@ mod tests { app.data.radarr_data.collections.search = Some("Test".into()); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::SearchCollection, - &None, + ActiveRadarrBlock::SearchCollection, + None, ) .handle(); @@ -1367,10 +1352,10 @@ mod tests { app.data.radarr_data.collections.filter = Some("Test".into()); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::FilterCollections, - &None, + ActiveRadarrBlock::FilterCollections, + None, ) .handle(); @@ -1398,10 +1383,10 @@ mod tests { app.data.radarr_data.collections.search = Some(HorizontallyScrollableText::default()); CollectionsHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::SearchCollection, - &None, + ActiveRadarrBlock::SearchCollection, + None, ) .handle(); @@ -1429,10 +1414,10 @@ mod tests { app.data.radarr_data.collections.filter = Some(HorizontallyScrollableText::default()); CollectionsHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::FilterCollections, - &None, + ActiveRadarrBlock::FilterCollections, + None, ) .handle(); @@ -1459,16 +1444,16 @@ mod tests { .set_items(vec![Collection::default()]); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.sort.key, + DEFAULT_KEYBINDINGS.sort.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::CollectionsSortPrompt.into() + ActiveRadarrBlock::CollectionsSortPrompt.into() ); assert_eq!( app @@ -1496,16 +1481,16 @@ mod tests { .set_items(vec![Collection::default()]); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.sort.key, + DEFAULT_KEYBINDINGS.sort.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert!(app.data.radarr_data.collections.sort.is_none()); assert!(!app.data.radarr_data.collections.sort_asc); @@ -1523,10 +1508,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::UpdateAllCollectionsPrompt.into()); CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.confirm.key, + DEFAULT_KEYBINDINGS.confirm.key, &mut app, - &ActiveRadarrBlock::UpdateAllCollectionsPrompt, - &None, + ActiveRadarrBlock::UpdateAllCollectionsPrompt, + None, ) .handle(); @@ -1537,7 +1522,7 @@ mod tests { ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); } } @@ -1693,9 +1678,9 @@ mod tests { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if collections_handler_blocks.contains(&active_radarr_block) { - assert!(CollectionsHandler::accepts(&active_radarr_block)); + assert!(CollectionsHandler::accepts(active_radarr_block)); } else { - assert!(!CollectionsHandler::accepts(&active_radarr_block)); + assert!(!CollectionsHandler::accepts(active_radarr_block)); } }); } @@ -1706,10 +1691,10 @@ mod tests { app.is_loading = true; let handler = CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ); assert!(!handler.is_ready()); @@ -1721,10 +1706,10 @@ mod tests { app.is_loading = false; let handler = CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ); assert!(!handler.is_ready()); @@ -1741,10 +1726,10 @@ mod tests { .set_items(vec![Collection::default()]); let handler = CollectionsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Collections, - &None, + ActiveRadarrBlock::Collections, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/collections/edit_collection_handler.rs b/src/handlers/radarr_handlers/collections/edit_collection_handler.rs index 5a7c1bc..9754397 100644 --- a/src/handlers/radarr_handlers/collections/edit_collection_handler.rs +++ b/src/handlers/radarr_handlers/collections/edit_collection_handler.rs @@ -12,22 +12,22 @@ use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; mod edit_collection_handler_tests; pub(super) struct EditCollectionHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditCollectionHandler<'a, 'b> { - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { - EDIT_COLLECTION_BLOCKS.contains(active_block) + fn accepts(active_block: ActiveRadarrBlock) -> bool { + EDIT_COLLECTION_BLOCKS.contains(&active_block) } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_block: ActiveRadarrBlock, + context: Option, ) -> EditCollectionHandler<'a, 'b> { EditCollectionHandler { key, @@ -37,7 +37,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditCollectionHandle } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -203,8 +203,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditCollectionHandle | ActiveRadarrBlock::EditCollectionSelectQualityProfile => { self.app.push_navigation_stack( ( - *self.app.data.radarr_data.selected_block.get_active_block(), - *self.context, + self.app.data.radarr_data.selected_block.get_active_block(), + self.context, ) .into(), ) @@ -212,8 +212,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditCollectionHandle ActiveRadarrBlock::EditCollectionRootFolderPathInput => { self.app.push_navigation_stack( ( - *self.app.data.radarr_data.selected_block.get_active_block(), - *self.context, + self.app.data.radarr_data.selected_block.get_active_block(), + self.context, ) .into(), ); @@ -308,8 +308,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditCollectionHandle } ActiveRadarrBlock::EditCollectionPrompt => { if self.app.data.radarr_data.selected_block.get_active_block() - == &ActiveRadarrBlock::EditCollectionConfirmPrompt - && *key == DEFAULT_KEYBINDINGS.confirm.key + == ActiveRadarrBlock::EditCollectionConfirmPrompt + && key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::EditCollection(None)); diff --git a/src/handlers/radarr_handlers/collections/edit_collection_handler_tests.rs b/src/handlers/radarr_handlers/collections/edit_collection_handler_tests.rs index 395b574..32bb457 100644 --- a/src/handlers/radarr_handlers/collections/edit_collection_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/edit_collection_handler_tests.rs @@ -44,10 +44,10 @@ mod tests { if key == Key::Up { for i in (0..minimum_availability_vec.len()).rev() { EditCollectionHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::EditCollectionSelectMinimumAvailability, - &None, + ActiveRadarrBlock::EditCollectionSelectMinimumAvailability, + None, ) .handle(); @@ -66,10 +66,10 @@ mod tests { } else { for i in 0..minimum_availability_vec.len() { EditCollectionHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::EditCollectionSelectMinimumAvailability, - &None, + ActiveRadarrBlock::EditCollectionSelectMinimumAvailability, + None, ) .handle(); @@ -104,10 +104,10 @@ mod tests { .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); EditCollectionHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::EditCollectionSelectQualityProfile, - &None, + ActiveRadarrBlock::EditCollectionSelectQualityProfile, + None, ) .handle(); @@ -124,10 +124,10 @@ mod tests { ); EditCollectionHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::EditCollectionSelectQualityProfile, - &None, + ActiveRadarrBlock::EditCollectionSelectQualityProfile, + None, ) .handle(); @@ -152,23 +152,18 @@ mod tests { BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS); app.data.radarr_data.selected_block.next(); - EditCollectionHandler::with( - &key, - &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &None, - ) - .handle(); + EditCollectionHandler::with(key, &mut app, ActiveRadarrBlock::EditCollectionPrompt, None) + .handle(); if key == Key::Up { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::EditCollectionToggleMonitored + ActiveRadarrBlock::EditCollectionToggleMonitored ); } else { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::EditCollectionSelectQualityProfile + ActiveRadarrBlock::EditCollectionSelectQualityProfile ); } } @@ -184,17 +179,12 @@ mod tests { BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS); app.data.radarr_data.selected_block.next(); - EditCollectionHandler::with( - &key, - &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &None, - ) - .handle(); + EditCollectionHandler::with(key, &mut app, ActiveRadarrBlock::EditCollectionPrompt, None) + .handle(); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::EditCollectionSelectMinimumAvailability + ActiveRadarrBlock::EditCollectionSelectMinimumAvailability ); } } @@ -224,10 +214,10 @@ mod tests { .set_items(minimum_availability_vec.clone()); EditCollectionHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::EditCollectionSelectMinimumAvailability, - &None, + ActiveRadarrBlock::EditCollectionSelectMinimumAvailability, + None, ) .handle(); @@ -244,10 +234,10 @@ mod tests { ); EditCollectionHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::EditCollectionSelectMinimumAvailability, - &None, + ActiveRadarrBlock::EditCollectionSelectMinimumAvailability, + None, ) .handle(); @@ -282,10 +272,10 @@ mod tests { ]); EditCollectionHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::EditCollectionSelectQualityProfile, - &None, + ActiveRadarrBlock::EditCollectionSelectQualityProfile, + None, ) .handle(); @@ -302,10 +292,10 @@ mod tests { ); EditCollectionHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::EditCollectionSelectQualityProfile, - &None, + ActiveRadarrBlock::EditCollectionSelectQualityProfile, + None, ) .handle(); @@ -331,10 +321,10 @@ mod tests { }); EditCollectionHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::EditCollectionRootFolderPathInput, - &None, + ActiveRadarrBlock::EditCollectionRootFolderPathInput, + None, ) .handle(); @@ -352,10 +342,10 @@ mod tests { ); EditCollectionHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::EditCollectionRootFolderPathInput, - &None, + ActiveRadarrBlock::EditCollectionRootFolderPathInput, + None, ) .handle(); @@ -386,23 +376,13 @@ mod tests { fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { let mut app = App::default(); - EditCollectionHandler::with( - &key, - &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &None, - ) - .handle(); + EditCollectionHandler::with(key, &mut app, ActiveRadarrBlock::EditCollectionPrompt, None) + .handle(); assert!(app.data.radarr_data.prompt_confirm); - EditCollectionHandler::with( - &key, - &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &None, - ) - .handle(); + EditCollectionHandler::with(key, &mut app, ActiveRadarrBlock::EditCollectionPrompt, None) + .handle(); assert!(!app.data.radarr_data.prompt_confirm); } @@ -416,10 +396,10 @@ mod tests { }); EditCollectionHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::EditCollectionRootFolderPathInput, - &None, + ActiveRadarrBlock::EditCollectionRootFolderPathInput, + None, ) .handle(); @@ -437,10 +417,10 @@ mod tests { ); EditCollectionHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::EditCollectionRootFolderPathInput, - &None, + ActiveRadarrBlock::EditCollectionRootFolderPathInput, + None, ) .handle(); @@ -484,10 +464,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::EditCollectionRootFolderPathInput.into()); EditCollectionHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditCollectionRootFolderPathInput, - &None, + ActiveRadarrBlock::EditCollectionRootFolderPathInput, + None, ) .handle(); @@ -503,7 +483,7 @@ mod tests { .is_empty()); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditCollectionPrompt.into() + ActiveRadarrBlock::EditCollectionPrompt.into() ); } @@ -522,16 +502,16 @@ mod tests { .set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); EditCollectionHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &None, + ActiveRadarrBlock::EditCollectionPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); } @@ -552,16 +532,16 @@ mod tests { .set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); EditCollectionHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &None, + ActiveRadarrBlock::EditCollectionPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert_eq!( app.data.radarr_data.prompt_confirm_action, @@ -587,16 +567,16 @@ mod tests { .set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); EditCollectionHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &None, + ActiveRadarrBlock::EditCollectionPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditCollectionPrompt.into() + ActiveRadarrBlock::EditCollectionPrompt.into() ); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); assert!(!app.should_refresh); @@ -615,14 +595,14 @@ mod tests { app.push_navigation_stack(current_route); EditCollectionHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &Some(ActiveRadarrBlock::Collections), + ActiveRadarrBlock::EditCollectionPrompt, + Some(ActiveRadarrBlock::Collections), ) .handle(); - assert_eq!(app.get_current_route(), ¤t_route); + assert_eq!(app.get_current_route(), current_route); assert_eq!( app .data @@ -635,14 +615,14 @@ mod tests { ); EditCollectionHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &Some(ActiveRadarrBlock::Collections), + ActiveRadarrBlock::EditCollectionPrompt, + Some(ActiveRadarrBlock::Collections), ) .handle(); - assert_eq!(app.get_current_route(), ¤t_route); + assert_eq!(app.get_current_route(), current_route); assert_eq!( app .data @@ -673,14 +653,14 @@ mod tests { app.push_navigation_stack(current_route); EditCollectionHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &Some(ActiveRadarrBlock::Collections), + ActiveRadarrBlock::EditCollectionPrompt, + Some(ActiveRadarrBlock::Collections), ) .handle(); - assert_eq!(app.get_current_route(), ¤t_route); + assert_eq!(app.get_current_route(), current_route); assert_eq!( app .data @@ -693,14 +673,14 @@ mod tests { ); EditCollectionHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &Some(ActiveRadarrBlock::Collections), + ActiveRadarrBlock::EditCollectionPrompt, + Some(ActiveRadarrBlock::Collections), ) .handle(); - assert_eq!(app.get_current_route(), ¤t_route); + assert_eq!(app.get_current_route(), current_route); assert_eq!( app .data @@ -735,16 +715,16 @@ mod tests { app.data.radarr_data.selected_block.set_index(index); EditCollectionHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &Some(ActiveRadarrBlock::Collections), + ActiveRadarrBlock::EditCollectionPrompt, + Some(ActiveRadarrBlock::Collections), ) .handle(); assert_eq!( app.get_current_route(), - &(selected_block, Some(ActiveRadarrBlock::Collections)).into() + (selected_block, Some(ActiveRadarrBlock::Collections)).into() ); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); @@ -768,16 +748,16 @@ mod tests { app.push_navigation_stack(active_radarr_block.into()); EditCollectionHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &active_radarr_block, - &Some(ActiveRadarrBlock::Collections), + active_radarr_block, + Some(ActiveRadarrBlock::Collections), ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditCollectionPrompt.into() + ActiveRadarrBlock::EditCollectionPrompt.into() ); if active_radarr_block == ActiveRadarrBlock::EditCollectionRootFolderPathInput { @@ -806,17 +786,17 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::EditCollectionRootFolderPathInput.into()); EditCollectionHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::EditCollectionRootFolderPathInput, - &None, + ActiveRadarrBlock::EditCollectionRootFolderPathInput, + None, ) .handle(); assert!(!app.should_ignore_quit_key); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditCollectionPrompt.into() + ActiveRadarrBlock::EditCollectionPrompt.into() ); } @@ -828,16 +808,16 @@ mod tests { app.data.radarr_data = create_test_radarr_data(); EditCollectionHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &None, + ActiveRadarrBlock::EditCollectionPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); let radarr_data = &app.data.radarr_data; @@ -860,11 +840,11 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); app.push_navigation_stack(active_radarr_block.into()); - EditCollectionHandler::with(&ESC_KEY, &mut app, &active_radarr_block, &None).handle(); + EditCollectionHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); } } @@ -890,10 +870,10 @@ mod tests { }); EditCollectionHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::EditCollectionRootFolderPathInput, - &None, + ActiveRadarrBlock::EditCollectionRootFolderPathInput, + None, ) .handle(); @@ -916,10 +896,10 @@ mod tests { app.data.radarr_data.edit_collection_modal = Some(EditCollectionModal::default()); EditCollectionHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::EditCollectionRootFolderPathInput, - &None, + ActiveRadarrBlock::EditCollectionRootFolderPathInput, + None, ) .handle(); @@ -951,16 +931,16 @@ mod tests { .set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); EditCollectionHandler::with( - &DEFAULT_KEYBINDINGS.confirm.key, + DEFAULT_KEYBINDINGS.confirm.key, &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &None, + ActiveRadarrBlock::EditCollectionPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert_eq!( app.data.radarr_data.prompt_confirm_action, @@ -974,9 +954,9 @@ mod tests { fn test_edit_collection_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if EDIT_COLLECTION_BLOCKS.contains(&active_radarr_block) { - assert!(EditCollectionHandler::accepts(&active_radarr_block)); + assert!(EditCollectionHandler::accepts(active_radarr_block)); } else { - assert!(!EditCollectionHandler::accepts(&active_radarr_block)); + assert!(!EditCollectionHandler::accepts(active_radarr_block)); } }); } @@ -987,10 +967,10 @@ mod tests { app.is_loading = true; let handler = EditCollectionHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &None, + ActiveRadarrBlock::EditCollectionPrompt, + None, ); assert!(!handler.is_ready()); @@ -1002,10 +982,10 @@ mod tests { app.is_loading = false; let handler = EditCollectionHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &None, + ActiveRadarrBlock::EditCollectionPrompt, + None, ); assert!(!handler.is_ready()); @@ -1018,10 +998,10 @@ mod tests { app.data.radarr_data.edit_collection_modal = Some(EditCollectionModal::default()); let handler = EditCollectionHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::EditCollectionPrompt, - &None, + ActiveRadarrBlock::EditCollectionPrompt, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/collections/mod.rs b/src/handlers/radarr_handlers/collections/mod.rs index 595262f..7f266b6 100644 --- a/src/handlers/radarr_handlers/collections/mod.rs +++ b/src/handlers/radarr_handlers/collections/mod.rs @@ -22,10 +22,10 @@ mod edit_collection_handler; mod collections_handler_tests; pub(super) struct CollectionsHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<'a, 'b> { @@ -43,17 +43,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' } } - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { + fn accepts(active_block: ActiveRadarrBlock) -> bool { CollectionDetailsHandler::accepts(active_block) || EditCollectionHandler::accepts(active_block) - || COLLECTIONS_BLOCKS.contains(active_block) + || COLLECTIONS_BLOCKS.contains(&active_block) } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_block: ActiveRadarrBlock, + context: Option, ) -> CollectionsHandler<'a, 'b> { CollectionsHandler { key, @@ -63,7 +63,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -306,7 +306,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' let key = self.key; match self.active_radarr_block { ActiveRadarrBlock::Collections => match self.key { - _ if *key == DEFAULT_KEYBINDINGS.search.key => { + _ if key == DEFAULT_KEYBINDINGS.search.key => { self .app .push_navigation_stack(ActiveRadarrBlock::SearchCollection.into()); @@ -314,7 +314,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' Some(HorizontallyScrollableText::default()); self.app.should_ignore_quit_key = true; } - _ if *key == DEFAULT_KEYBINDINGS.filter.key => { + _ if key == DEFAULT_KEYBINDINGS.filter.key => { self .app .push_navigation_stack(ActiveRadarrBlock::FilterCollections.into()); @@ -323,7 +323,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' Some(HorizontallyScrollableText::default()); self.app.should_ignore_quit_key = true; } - _ if *key == DEFAULT_KEYBINDINGS.edit.key => { + _ if key == DEFAULT_KEYBINDINGS.edit.key => { self.app.push_navigation_stack( ( ActiveRadarrBlock::EditCollectionPrompt, @@ -336,15 +336,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' self.app.data.radarr_data.selected_block = BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS); } - _ if *key == DEFAULT_KEYBINDINGS.update.key => { + _ if key == DEFAULT_KEYBINDINGS.update.key => { self .app .push_navigation_stack(ActiveRadarrBlock::UpdateAllCollectionsPrompt.into()); } - _ if *key == DEFAULT_KEYBINDINGS.refresh.key => { + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { self.app.should_refresh = true; } - _ if *key == DEFAULT_KEYBINDINGS.sort.key => { + _ if key == DEFAULT_KEYBINDINGS.sort.key => { self .app .data @@ -386,7 +386,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' ) } ActiveRadarrBlock::UpdateAllCollectionsPrompt => { - if *key == DEFAULT_KEYBINDINGS.confirm.key { + if key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateCollections); diff --git a/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs index 17fed42..8dee3af 100644 --- a/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs +++ b/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs @@ -44,14 +44,14 @@ mod tests { .downloads .set_items(simple_stateful_iterable_vec!(DownloadRecord)); - DownloadsHandler::with(&key, &mut app, &ActiveRadarrBlock::Downloads, &None).handle(); + DownloadsHandler::with(key, &mut app, ActiveRadarrBlock::Downloads, None).handle(); assert_str_eq!( app.data.radarr_data.downloads.current_selection().title, "Test 1" ); - DownloadsHandler::with(&key, &mut app, &ActiveRadarrBlock::Downloads, &None).handle(); + DownloadsHandler::with(key, &mut app, ActiveRadarrBlock::Downloads, None).handle(); assert_str_eq!( app.data.radarr_data.downloads.current_selection().title, @@ -87,10 +87,10 @@ mod tests { .set_items(extended_stateful_iterable_vec!(DownloadRecord)); DownloadsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::Downloads, - &None, + ActiveRadarrBlock::Downloads, + None, ) .handle(); @@ -100,10 +100,10 @@ mod tests { ); DownloadsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::Downloads, - &None, + ActiveRadarrBlock::Downloads, + None, ) .handle(); @@ -130,11 +130,11 @@ mod tests { .downloads .set_items(vec![DownloadRecord::default()]); - DownloadsHandler::with(&DELETE_KEY, &mut app, &ActiveRadarrBlock::Downloads, &None).handle(); + DownloadsHandler::with(DELETE_KEY, &mut app, ActiveRadarrBlock::Downloads, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::DeleteDownloadPrompt.into() + ActiveRadarrBlock::DeleteDownloadPrompt.into() ); } @@ -149,12 +149,9 @@ mod tests { .downloads .set_items(vec![DownloadRecord::default()]); - DownloadsHandler::with(&DELETE_KEY, &mut app, &ActiveRadarrBlock::Downloads, &None).handle(); + DownloadsHandler::with(DELETE_KEY, &mut app, ActiveRadarrBlock::Downloads, None).handle(); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Downloads.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Downloads.into()); } } @@ -171,20 +168,20 @@ mod tests { app.data.radarr_data.main_tabs.set_index(2); DownloadsHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::Downloads, - &None, + ActiveRadarrBlock::Downloads, + None, ) .handle(); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); } @@ -195,21 +192,18 @@ mod tests { app.data.radarr_data.main_tabs.set_index(2); DownloadsHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::Downloads, - &None, + ActiveRadarrBlock::Downloads, + None, ) .handle(); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &ActiveRadarrBlock::Blocklist.into() - ); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Blocklist.into() + ActiveRadarrBlock::Blocklist.into() ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); } #[rstest] @@ -223,11 +217,11 @@ mod tests { ) { let mut app = App::default(); - DownloadsHandler::with(&key, &mut app, &active_radarr_block, &None).handle(); + DownloadsHandler::with(key, &mut app, active_radarr_block, None).handle(); assert!(app.data.radarr_data.prompt_confirm); - DownloadsHandler::with(&key, &mut app, &active_radarr_block, &None).handle(); + DownloadsHandler::with(key, &mut app, active_radarr_block, None).handle(); assert!(!app.data.radarr_data.prompt_confirm); } @@ -269,14 +263,14 @@ mod tests { app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); - DownloadsHandler::with(&SUBMIT_KEY, &mut app, &prompt_block, &None).handle(); + DownloadsHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); assert!(app.data.radarr_data.prompt_confirm); assert_eq!( app.data.radarr_data.prompt_confirm_action, Some(expected_action) ); - assert_eq!(app.get_current_route(), &base_route.into()); + assert_eq!(app.get_current_route(), base_route.into()); } #[rstest] @@ -295,11 +289,11 @@ mod tests { app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); - DownloadsHandler::with(&SUBMIT_KEY, &mut app, &prompt_block, &None).handle(); + DownloadsHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); assert!(!app.data.radarr_data.prompt_confirm); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); - assert_eq!(app.get_current_route(), &base_route.into()); + assert_eq!(app.get_current_route(), base_route.into()); } } @@ -323,9 +317,9 @@ mod tests { app.push_navigation_stack(prompt_block.into()); app.data.radarr_data.prompt_confirm = true; - DownloadsHandler::with(&ESC_KEY, &mut app, &prompt_block, &None).handle(); + DownloadsHandler::with(ESC_KEY, &mut app, prompt_block, None).handle(); - assert_eq!(app.get_current_route(), &base_block.into()); + assert_eq!(app.get_current_route(), base_block.into()); assert!(!app.data.radarr_data.prompt_confirm); } @@ -337,12 +331,9 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Downloads.into()); app.push_navigation_stack(ActiveRadarrBlock::Downloads.into()); - DownloadsHandler::with(&ESC_KEY, &mut app, &ActiveRadarrBlock::Downloads, &None).handle(); + DownloadsHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::Downloads, None).handle(); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Downloads.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Downloads.into()); assert!(app.error.text.is_empty()); } } @@ -365,16 +356,16 @@ mod tests { .set_items(vec![DownloadRecord::default()]); DownloadsHandler::with( - &DEFAULT_KEYBINDINGS.update.key, + DEFAULT_KEYBINDINGS.update.key, &mut app, - &ActiveRadarrBlock::Downloads, - &None, + ActiveRadarrBlock::Downloads, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::UpdateDownloadsPrompt.into() + ActiveRadarrBlock::UpdateDownloadsPrompt.into() ); } @@ -390,17 +381,14 @@ mod tests { .set_items(vec![DownloadRecord::default()]); DownloadsHandler::with( - &DEFAULT_KEYBINDINGS.update.key, + DEFAULT_KEYBINDINGS.update.key, &mut app, - &ActiveRadarrBlock::Downloads, - &None, + ActiveRadarrBlock::Downloads, + None, ) .handle(); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Downloads.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Downloads.into()); } #[test] @@ -414,17 +402,14 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Downloads.into()); DownloadsHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &ActiveRadarrBlock::Downloads, - &None, + ActiveRadarrBlock::Downloads, + None, ) .handle(); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Downloads.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Downloads.into()); assert!(app.should_refresh); } @@ -440,17 +425,14 @@ mod tests { .set_items(vec![DownloadRecord::default()]); DownloadsHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &ActiveRadarrBlock::Downloads, - &None, + ActiveRadarrBlock::Downloads, + None, ) .handle(); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Downloads.into() - ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Downloads.into()); assert!(!app.should_refresh); } @@ -480,10 +462,10 @@ mod tests { app.push_navigation_stack(prompt_block.into()); DownloadsHandler::with( - &DEFAULT_KEYBINDINGS.confirm.key, + DEFAULT_KEYBINDINGS.confirm.key, &mut app, - &prompt_block, - &None, + prompt_block, + None, ) .handle(); @@ -492,7 +474,7 @@ mod tests { app.data.radarr_data.prompt_confirm_action, Some(expected_action) ); - assert_eq!(app.get_current_route(), &base_route.into()); + assert_eq!(app.get_current_route(), base_route.into()); } } @@ -500,9 +482,9 @@ mod tests { fn test_downloads_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if DOWNLOADS_BLOCKS.contains(&active_radarr_block) { - assert!(DownloadsHandler::accepts(&active_radarr_block)); + assert!(DownloadsHandler::accepts(active_radarr_block)); } else { - assert!(!DownloadsHandler::accepts(&active_radarr_block)); + assert!(!DownloadsHandler::accepts(active_radarr_block)); } }) } @@ -513,10 +495,10 @@ mod tests { app.is_loading = true; let handler = DownloadsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Downloads, - &None, + ActiveRadarrBlock::Downloads, + None, ); assert!(!handler.is_ready()); @@ -528,10 +510,10 @@ mod tests { app.is_loading = false; let handler = DownloadsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Downloads, - &None, + ActiveRadarrBlock::Downloads, + None, ); assert!(!handler.is_ready()); @@ -548,10 +530,10 @@ mod tests { .downloads .set_items(vec![DownloadRecord::default()]); let handler = DownloadsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Downloads, - &None, + ActiveRadarrBlock::Downloads, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/downloads/mod.rs b/src/handlers/radarr_handlers/downloads/mod.rs index d3b64a8..b194efe 100644 --- a/src/handlers/radarr_handlers/downloads/mod.rs +++ b/src/handlers/radarr_handlers/downloads/mod.rs @@ -12,22 +12,22 @@ use crate::network::radarr_network::RadarrEvent; mod downloads_handler_tests; pub(super) struct DownloadsHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + _context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DownloadsHandler<'a, 'b> { - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { - DOWNLOADS_BLOCKS.contains(active_block) + fn accepts(active_block: ActiveRadarrBlock) -> bool { + DOWNLOADS_BLOCKS.contains(&active_block) } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_block: ActiveRadarrBlock, + _context: Option, ) -> DownloadsHandler<'a, 'b> { DownloadsHandler { key, @@ -37,7 +37,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DownloadsHandler<'a, } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -46,31 +46,31 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DownloadsHandler<'a, } fn handle_scroll_up(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::Downloads { + if self.active_radarr_block == ActiveRadarrBlock::Downloads { self.app.data.radarr_data.downloads.scroll_up() } } fn handle_scroll_down(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::Downloads { + if self.active_radarr_block == ActiveRadarrBlock::Downloads { self.app.data.radarr_data.downloads.scroll_down() } } fn handle_home(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::Downloads { + if self.active_radarr_block == ActiveRadarrBlock::Downloads { self.app.data.radarr_data.downloads.scroll_to_top() } } fn handle_end(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::Downloads { + if self.active_radarr_block == ActiveRadarrBlock::Downloads { self.app.data.radarr_data.downloads.scroll_to_bottom() } } fn handle_delete(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::Downloads { + if self.active_radarr_block == ActiveRadarrBlock::Downloads { self .app .push_navigation_stack(ActiveRadarrBlock::DeleteDownloadPrompt.into()) @@ -121,18 +121,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DownloadsHandler<'a, let key = self.key; match self.active_radarr_block { ActiveRadarrBlock::Downloads => match self.key { - _ if *key == DEFAULT_KEYBINDINGS.update.key => { + _ if key == DEFAULT_KEYBINDINGS.update.key => { self .app .push_navigation_stack(ActiveRadarrBlock::UpdateDownloadsPrompt.into()); } - _ if *key == DEFAULT_KEYBINDINGS.refresh.key => { + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { self.app.should_refresh = true; } _ => (), }, ActiveRadarrBlock::DeleteDownloadPrompt => { - if *key == DEFAULT_KEYBINDINGS.confirm.key { + if key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::DeleteDownload(None)); @@ -140,7 +140,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DownloadsHandler<'a, } } ActiveRadarrBlock::UpdateDownloadsPrompt => { - if *key == DEFAULT_KEYBINDINGS.confirm.key { + if key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateDownloads); diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs index 3ed6856..f641df3 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs @@ -11,22 +11,22 @@ use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; mod edit_indexer_handler_tests; pub(super) struct EditIndexerHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + _context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<'a, 'b> { - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { - EDIT_INDEXER_BLOCKS.contains(active_block) + fn accepts(active_block: ActiveRadarrBlock) -> bool { + EDIT_INDEXER_BLOCKS.contains(&active_block) } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_block: ActiveRadarrBlock, + _context: Option, ) -> EditIndexerHandler<'a, 'b> { EditIndexerHandler { key, @@ -36,7 +36,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<' } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -45,13 +45,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<' } fn handle_scroll_up(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::EditIndexerPrompt { + if self.active_radarr_block == ActiveRadarrBlock::EditIndexerPrompt { self.app.data.radarr_data.selected_block.previous(); } } fn handle_scroll_down(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::EditIndexerPrompt { + if self.active_radarr_block == ActiveRadarrBlock::EditIndexerPrompt { self.app.data.radarr_data.selected_block.next(); } } @@ -184,7 +184,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<' match self.active_radarr_block { ActiveRadarrBlock::EditIndexerPrompt => { if self.app.data.radarr_data.selected_block.get_active_block() - == &ActiveRadarrBlock::EditIndexerConfirmPrompt + == ActiveRadarrBlock::EditIndexerConfirmPrompt { handle_prompt_toggle(self.app, self.key); } else { @@ -270,7 +270,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<' fn handle_submit(&mut self) { match self.active_radarr_block { ActiveRadarrBlock::EditIndexerPrompt => { - let selected_block = *self.app.data.radarr_data.selected_block.get_active_block(); + let selected_block = self.app.data.radarr_data.selected_block.get_active_block(); match selected_block { ActiveRadarrBlock::EditIndexerConfirmPrompt => { let radarr_data = &mut self.app.data.radarr_data; @@ -431,8 +431,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<' } ActiveRadarrBlock::EditIndexerPrompt => { if self.app.data.radarr_data.selected_block.get_active_block() - == &ActiveRadarrBlock::EditIndexerConfirmPrompt - && *self.key == DEFAULT_KEYBINDINGS.confirm.key + == ActiveRadarrBlock::EditIndexerConfirmPrompt + && self.key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::EditIndexer(None)); diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs index 8c33809..1774b5e 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs @@ -28,18 +28,17 @@ mod tests { BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); app.data.radarr_data.selected_block.next(); - EditIndexerHandler::with(&key, &mut app, &ActiveRadarrBlock::EditIndexerPrompt, &None) - .handle(); + EditIndexerHandler::with(key, &mut app, ActiveRadarrBlock::EditIndexerPrompt, None).handle(); if key == Key::Up { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::EditIndexerNameInput + ActiveRadarrBlock::EditIndexerNameInput ); } else { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch + ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch ); } } @@ -55,12 +54,11 @@ mod tests { BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); app.data.radarr_data.selected_block.next(); - EditIndexerHandler::with(&key, &mut app, &ActiveRadarrBlock::EditIndexerPrompt, &None) - .handle(); + EditIndexerHandler::with(key, &mut app, ActiveRadarrBlock::EditIndexerPrompt, None).handle(); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::EditIndexerToggleEnableRss + ActiveRadarrBlock::EditIndexerToggleEnableRss ); } } @@ -83,10 +81,10 @@ mod tests { }); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::EditIndexerNameInput, - &None, + ActiveRadarrBlock::EditIndexerNameInput, + None, ) .handle(); @@ -104,10 +102,10 @@ mod tests { ); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::EditIndexerNameInput, - &None, + ActiveRadarrBlock::EditIndexerNameInput, + None, ) .handle(); @@ -134,10 +132,10 @@ mod tests { }); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::EditIndexerUrlInput, - &None, + ActiveRadarrBlock::EditIndexerUrlInput, + None, ) .handle(); @@ -155,10 +153,10 @@ mod tests { ); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::EditIndexerUrlInput, - &None, + ActiveRadarrBlock::EditIndexerUrlInput, + None, ) .handle(); @@ -185,10 +183,10 @@ mod tests { }); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::EditIndexerApiKeyInput, - &None, + ActiveRadarrBlock::EditIndexerApiKeyInput, + None, ) .handle(); @@ -206,10 +204,10 @@ mod tests { ); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::EditIndexerApiKeyInput, - &None, + ActiveRadarrBlock::EditIndexerApiKeyInput, + None, ) .handle(); @@ -236,10 +234,10 @@ mod tests { }); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::EditIndexerSeedRatioInput, - &None, + ActiveRadarrBlock::EditIndexerSeedRatioInput, + None, ) .handle(); @@ -257,10 +255,10 @@ mod tests { ); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::EditIndexerSeedRatioInput, - &None, + ActiveRadarrBlock::EditIndexerSeedRatioInput, + None, ) .handle(); @@ -287,10 +285,10 @@ mod tests { }); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::EditIndexerTagsInput, - &None, + ActiveRadarrBlock::EditIndexerTagsInput, + None, ) .handle(); @@ -308,10 +306,10 @@ mod tests { ); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::EditIndexerTagsInput, - &None, + ActiveRadarrBlock::EditIndexerTagsInput, + None, ) .handle(); @@ -351,13 +349,11 @@ mod tests { BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); app.data.radarr_data.selected_block.index = EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1; - EditIndexerHandler::with(&key, &mut app, &ActiveRadarrBlock::EditIndexerPrompt, &None) - .handle(); + EditIndexerHandler::with(key, &mut app, ActiveRadarrBlock::EditIndexerPrompt, None).handle(); assert!(app.data.radarr_data.prompt_confirm); - EditIndexerHandler::with(&key, &mut app, &ActiveRadarrBlock::EditIndexerPrompt, &None) - .handle(); + EditIndexerHandler::with(key, &mut app, ActiveRadarrBlock::EditIndexerPrompt, None).handle(); assert!(!app.data.radarr_data.prompt_confirm); } @@ -396,23 +392,21 @@ mod tests { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &left_block + left_block ); - EditIndexerHandler::with(&key, &mut app, &ActiveRadarrBlock::EditIndexerPrompt, &None) - .handle(); + EditIndexerHandler::with(key, &mut app, ActiveRadarrBlock::EditIndexerPrompt, None).handle(); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &right_block + right_block ); - EditIndexerHandler::with(&key, &mut app, &ActiveRadarrBlock::EditIndexerPrompt, &None) - .handle(); + EditIndexerHandler::with(key, &mut app, ActiveRadarrBlock::EditIndexerPrompt, None).handle(); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &left_block + left_block ); } @@ -445,23 +439,21 @@ mod tests { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &left_block + left_block ); - EditIndexerHandler::with(&key, &mut app, &ActiveRadarrBlock::EditIndexerPrompt, &None) - .handle(); + EditIndexerHandler::with(key, &mut app, ActiveRadarrBlock::EditIndexerPrompt, None).handle(); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &right_block + right_block ); - EditIndexerHandler::with(&key, &mut app, &ActiveRadarrBlock::EditIndexerPrompt, &None) - .handle(); + EditIndexerHandler::with(key, &mut app, ActiveRadarrBlock::EditIndexerPrompt, None).handle(); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &left_block + left_block ); } @@ -477,23 +469,21 @@ mod tests { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch + ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch ); - EditIndexerHandler::with(&key, &mut app, &ActiveRadarrBlock::EditIndexerPrompt, &None) - .handle(); + EditIndexerHandler::with(key, &mut app, ActiveRadarrBlock::EditIndexerPrompt, None).handle(); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::EditIndexerConfirmPrompt + ActiveRadarrBlock::EditIndexerConfirmPrompt ); - EditIndexerHandler::with(&key, &mut app, &ActiveRadarrBlock::EditIndexerPrompt, &None) - .handle(); + EditIndexerHandler::with(key, &mut app, ActiveRadarrBlock::EditIndexerPrompt, None).handle(); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::EditIndexerConfirmPrompt + ActiveRadarrBlock::EditIndexerConfirmPrompt ); assert!(app.data.radarr_data.prompt_confirm); } @@ -507,10 +497,10 @@ mod tests { }); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::EditIndexerNameInput, - &None, + ActiveRadarrBlock::EditIndexerNameInput, + None, ) .handle(); @@ -528,10 +518,10 @@ mod tests { ); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::EditIndexerNameInput, - &None, + ActiveRadarrBlock::EditIndexerNameInput, + None, ) .handle(); @@ -558,10 +548,10 @@ mod tests { }); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::EditIndexerUrlInput, - &None, + ActiveRadarrBlock::EditIndexerUrlInput, + None, ) .handle(); @@ -579,10 +569,10 @@ mod tests { ); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::EditIndexerUrlInput, - &None, + ActiveRadarrBlock::EditIndexerUrlInput, + None, ) .handle(); @@ -609,10 +599,10 @@ mod tests { }); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::EditIndexerApiKeyInput, - &None, + ActiveRadarrBlock::EditIndexerApiKeyInput, + None, ) .handle(); @@ -630,10 +620,10 @@ mod tests { ); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::EditIndexerApiKeyInput, - &None, + ActiveRadarrBlock::EditIndexerApiKeyInput, + None, ) .handle(); @@ -660,10 +650,10 @@ mod tests { }); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::EditIndexerSeedRatioInput, - &None, + ActiveRadarrBlock::EditIndexerSeedRatioInput, + None, ) .handle(); @@ -681,10 +671,10 @@ mod tests { ); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::EditIndexerSeedRatioInput, - &None, + ActiveRadarrBlock::EditIndexerSeedRatioInput, + None, ) .handle(); @@ -711,10 +701,10 @@ mod tests { }); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::EditIndexerTagsInput, - &None, + ActiveRadarrBlock::EditIndexerTagsInput, + None, ) .handle(); @@ -732,10 +722,10 @@ mod tests { ); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::EditIndexerTagsInput, - &None, + ActiveRadarrBlock::EditIndexerTagsInput, + None, ) .handle(); @@ -784,14 +774,14 @@ mod tests { app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); EditIndexerHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerPrompt, - &None, + ActiveRadarrBlock::EditIndexerPrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); assert!(!app.should_refresh); assert_eq!(app.data.radarr_data.edit_indexer_modal, None); @@ -813,14 +803,14 @@ mod tests { app.data.radarr_data.prompt_confirm = true; EditIndexerHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerPrompt, - &None, + ActiveRadarrBlock::EditIndexerPrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert!(app.data.radarr_data.edit_indexer_modal.is_some()); assert!(app.should_refresh); assert_eq!( @@ -839,16 +829,16 @@ mod tests { app.data.radarr_data.prompt_confirm = true; EditIndexerHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerPrompt, - &None, + ActiveRadarrBlock::EditIndexerPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditIndexerPrompt.into() + ActiveRadarrBlock::EditIndexerPrompt.into() ); assert!(app.data.radarr_data.edit_indexer_modal.is_some()); assert!(!app.should_refresh); @@ -877,14 +867,14 @@ mod tests { .set_index(starting_index); EditIndexerHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerPrompt, - &None, + ActiveRadarrBlock::EditIndexerPrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &block.into()); + assert_eq!(app.get_current_route(), block.into()); assert!(app.should_ignore_quit_key); } @@ -898,16 +888,16 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::EditIndexerPrompt.into()); EditIndexerHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerPrompt, - &None, + ActiveRadarrBlock::EditIndexerPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditIndexerPrompt.into() + ActiveRadarrBlock::EditIndexerPrompt.into() ); assert!(app .data @@ -919,16 +909,16 @@ mod tests { .unwrap()); EditIndexerHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerPrompt, - &None, + ActiveRadarrBlock::EditIndexerPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditIndexerPrompt.into() + ActiveRadarrBlock::EditIndexerPrompt.into() ); assert!(!app .data @@ -950,16 +940,16 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::EditIndexerPrompt.into()); EditIndexerHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerPrompt, - &None, + ActiveRadarrBlock::EditIndexerPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditIndexerPrompt.into() + ActiveRadarrBlock::EditIndexerPrompt.into() ); assert!(app .data @@ -971,16 +961,16 @@ mod tests { .unwrap()); EditIndexerHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerPrompt, - &None, + ActiveRadarrBlock::EditIndexerPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditIndexerPrompt.into() + ActiveRadarrBlock::EditIndexerPrompt.into() ); assert!(!app .data @@ -1002,16 +992,16 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::EditIndexerPrompt.into()); EditIndexerHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerPrompt, - &None, + ActiveRadarrBlock::EditIndexerPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditIndexerPrompt.into() + ActiveRadarrBlock::EditIndexerPrompt.into() ); assert!(app .data @@ -1023,16 +1013,16 @@ mod tests { .unwrap()); EditIndexerHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerPrompt, - &None, + ActiveRadarrBlock::EditIndexerPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditIndexerPrompt.into() + ActiveRadarrBlock::EditIndexerPrompt.into() ); assert!(!app .data @@ -1056,10 +1046,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::EditIndexerNameInput.into()); EditIndexerHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerNameInput, - &None, + ActiveRadarrBlock::EditIndexerNameInput, + None, ) .handle(); @@ -1075,7 +1065,7 @@ mod tests { .is_empty()); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditIndexerPrompt.into() + ActiveRadarrBlock::EditIndexerPrompt.into() ); } @@ -1091,10 +1081,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::EditIndexerUrlInput.into()); EditIndexerHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerUrlInput, - &None, + ActiveRadarrBlock::EditIndexerUrlInput, + None, ) .handle(); @@ -1110,7 +1100,7 @@ mod tests { .is_empty()); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditIndexerPrompt.into() + ActiveRadarrBlock::EditIndexerPrompt.into() ); } @@ -1126,10 +1116,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::EditIndexerApiKeyInput.into()); EditIndexerHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerApiKeyInput, - &None, + ActiveRadarrBlock::EditIndexerApiKeyInput, + None, ) .handle(); @@ -1145,7 +1135,7 @@ mod tests { .is_empty()); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditIndexerPrompt.into() + ActiveRadarrBlock::EditIndexerPrompt.into() ); } @@ -1161,10 +1151,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::EditIndexerSeedRatioInput.into()); EditIndexerHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerSeedRatioInput, - &None, + ActiveRadarrBlock::EditIndexerSeedRatioInput, + None, ) .handle(); @@ -1180,7 +1170,7 @@ mod tests { .is_empty()); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditIndexerPrompt.into() + ActiveRadarrBlock::EditIndexerPrompt.into() ); } @@ -1196,10 +1186,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::EditIndexerTagsInput.into()); EditIndexerHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerTagsInput, - &None, + ActiveRadarrBlock::EditIndexerTagsInput, + None, ) .handle(); @@ -1215,7 +1205,7 @@ mod tests { .is_empty()); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditIndexerPrompt.into() + ActiveRadarrBlock::EditIndexerPrompt.into() ); } } @@ -1239,14 +1229,14 @@ mod tests { app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); EditIndexerHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::EditIndexerPrompt, - &None, + ActiveRadarrBlock::EditIndexerPrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert!(!app.data.radarr_data.prompt_confirm); assert_eq!(app.data.radarr_data.edit_indexer_modal, None); } @@ -1268,9 +1258,9 @@ mod tests { app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); app.should_ignore_quit_key = true; - EditIndexerHandler::with(&ESC_KEY, &mut app, &active_radarr_block, &None).handle(); + EditIndexerHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert!(!app.should_ignore_quit_key); assert_eq!( app.data.radarr_data.edit_indexer_modal, @@ -1298,10 +1288,10 @@ mod tests { }); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::EditIndexerNameInput, - &None, + ActiveRadarrBlock::EditIndexerNameInput, + None, ) .handle(); @@ -1327,10 +1317,10 @@ mod tests { }); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::EditIndexerUrlInput, - &None, + ActiveRadarrBlock::EditIndexerUrlInput, + None, ) .handle(); @@ -1356,10 +1346,10 @@ mod tests { }); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::EditIndexerApiKeyInput, - &None, + ActiveRadarrBlock::EditIndexerApiKeyInput, + None, ) .handle(); @@ -1385,10 +1375,10 @@ mod tests { }); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::EditIndexerSeedRatioInput, - &None, + ActiveRadarrBlock::EditIndexerSeedRatioInput, + None, ) .handle(); @@ -1414,10 +1404,10 @@ mod tests { }); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::EditIndexerTagsInput, - &None, + ActiveRadarrBlock::EditIndexerTagsInput, + None, ) .handle(); @@ -1440,10 +1430,10 @@ mod tests { app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); EditIndexerHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::EditIndexerNameInput, - &None, + ActiveRadarrBlock::EditIndexerNameInput, + None, ) .handle(); @@ -1466,10 +1456,10 @@ mod tests { app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); EditIndexerHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::EditIndexerUrlInput, - &None, + ActiveRadarrBlock::EditIndexerUrlInput, + None, ) .handle(); @@ -1492,10 +1482,10 @@ mod tests { app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); EditIndexerHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::EditIndexerApiKeyInput, - &None, + ActiveRadarrBlock::EditIndexerApiKeyInput, + None, ) .handle(); @@ -1518,10 +1508,10 @@ mod tests { app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); EditIndexerHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::EditIndexerSeedRatioInput, - &None, + ActiveRadarrBlock::EditIndexerSeedRatioInput, + None, ) .handle(); @@ -1544,10 +1534,10 @@ mod tests { app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); EditIndexerHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::EditIndexerTagsInput, - &None, + ActiveRadarrBlock::EditIndexerTagsInput, + None, ) .handle(); @@ -1579,14 +1569,14 @@ mod tests { app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.confirm.key, + DEFAULT_KEYBINDINGS.confirm.key, &mut app, - &ActiveRadarrBlock::EditIndexerPrompt, - &None, + ActiveRadarrBlock::EditIndexerPrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert!(app.data.radarr_data.edit_indexer_modal.is_some()); assert!(app.should_refresh); assert_eq!( @@ -1600,9 +1590,9 @@ mod tests { fn test_indexer_settings_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if EDIT_INDEXER_BLOCKS.contains(&active_radarr_block) { - assert!(EditIndexerHandler::accepts(&active_radarr_block)); + assert!(EditIndexerHandler::accepts(active_radarr_block)); } else { - assert!(!EditIndexerHandler::accepts(&active_radarr_block)); + assert!(!EditIndexerHandler::accepts(active_radarr_block)); } }) } @@ -1613,10 +1603,10 @@ mod tests { app.is_loading = true; let handler = EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::EditIndexerPrompt, - &None, + ActiveRadarrBlock::EditIndexerPrompt, + None, ); assert!(!handler.is_ready()); @@ -1628,10 +1618,10 @@ mod tests { app.is_loading = false; let handler = EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::EditIndexerPrompt, - &None, + ActiveRadarrBlock::EditIndexerPrompt, + None, ); assert!(!handler.is_ready()); @@ -1644,10 +1634,10 @@ mod tests { app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); let handler = EditIndexerHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::EditIndexerPrompt, - &None, + ActiveRadarrBlock::EditIndexerPrompt, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs index 0d3641c..92875c9 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs @@ -13,22 +13,22 @@ use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; mod edit_indexer_settings_handler_tests; pub(super) struct IndexerSettingsHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + _context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandler<'a, 'b> { - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { - INDEXER_SETTINGS_BLOCKS.contains(active_block) + fn accepts(active_block: ActiveRadarrBlock) -> bool { + INDEXER_SETTINGS_BLOCKS.contains(&active_block) } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_block: ActiveRadarrBlock, + _context: Option, ) -> IndexerSettingsHandler<'a, 'b> { IndexerSettingsHandler { key, @@ -38,7 +38,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -105,7 +105,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl } fn handle_home(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput { + if self.active_radarr_block == ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput { self .app .data @@ -119,7 +119,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl } fn handle_end(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput { + if self.active_radarr_block == ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput { self .app .data @@ -138,7 +138,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl match self.active_radarr_block { ActiveRadarrBlock::AllIndexerSettingsPrompt => { if self.app.data.radarr_data.selected_block.get_active_block() - == &ActiveRadarrBlock::IndexerSettingsConfirmPrompt + == ActiveRadarrBlock::IndexerSettingsConfirmPrompt { handle_prompt_toggle(self.app, self.key); } else { @@ -187,7 +187,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl | ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput => { self.app.push_navigation_stack( ( - *self.app.data.radarr_data.selected_block.get_active_block(), + self.app.data.radarr_data.selected_block.get_active_block(), None, ) .into(), @@ -258,8 +258,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl } ActiveRadarrBlock::AllIndexerSettingsPrompt => { if self.app.data.radarr_data.selected_block.get_active_block() - == &ActiveRadarrBlock::IndexerSettingsConfirmPrompt - && *self.key == DEFAULT_KEYBINDINGS.confirm.key + == ActiveRadarrBlock::IndexerSettingsConfirmPrompt + && self.key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler_tests.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler_tests.rs index 9371ed5..e524ea2 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler_tests.rs @@ -27,7 +27,7 @@ mod tests { let mut app = App::default(); app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); - IndexerSettingsHandler::with(&$key, &mut app, &$block, &None).handle(); + IndexerSettingsHandler::with($key, &mut app, $block, None).handle(); if $key == Key::Up { assert_eq!( @@ -64,7 +64,7 @@ mod tests { 0 ); - IndexerSettingsHandler::with(&Key::Up, &mut app, &$block, &None).handle(); + IndexerSettingsHandler::with(Key::Up, &mut app, $block, None).handle(); assert_eq!( app @@ -77,7 +77,7 @@ mod tests { 1 ); - IndexerSettingsHandler::with(&$key, &mut app, &$block, &None).handle(); + IndexerSettingsHandler::with($key, &mut app, $block, None).handle(); assert_eq!( app .data @@ -102,22 +102,22 @@ mod tests { app.data.radarr_data.selected_block.next(); IndexerSettingsHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); if key == Key::Up { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::IndexerSettingsMinimumAgeInput + ActiveRadarrBlock::IndexerSettingsMinimumAgeInput ); } else { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::IndexerSettingsMaximumSizeInput + ActiveRadarrBlock::IndexerSettingsMaximumSizeInput ); } } @@ -134,16 +134,16 @@ mod tests { app.data.radarr_data.selected_block.next(); IndexerSettingsHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::IndexerSettingsRetentionInput + ActiveRadarrBlock::IndexerSettingsRetentionInput ); } @@ -218,10 +218,10 @@ mod tests { }); IndexerSettingsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, - &None, + ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, + None, ) .handle(); @@ -239,10 +239,10 @@ mod tests { ); IndexerSettingsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, - &None, + ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, + None, ) .handle(); @@ -280,20 +280,20 @@ mod tests { app.data.radarr_data.selected_block.index = INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1; IndexerSettingsHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); assert!(app.data.radarr_data.prompt_confirm); IndexerSettingsHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); @@ -334,33 +334,33 @@ mod tests { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &left_block + left_block ); IndexerSettingsHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &right_block + right_block ); IndexerSettingsHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &left_block + left_block ); } @@ -373,10 +373,10 @@ mod tests { }); IndexerSettingsHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, - &None, + ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, + None, ) .handle(); @@ -394,10 +394,10 @@ mod tests { ); IndexerSettingsHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, - &None, + ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, + None, ) .handle(); @@ -447,14 +447,14 @@ mod tests { app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); IndexerSettingsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); assert!(!app.should_refresh); assert_eq!(app.data.radarr_data.indexer_settings, None); @@ -476,14 +476,14 @@ mod tests { app.data.radarr_data.prompt_confirm = true; IndexerSettingsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert_eq!( app.data.radarr_data.prompt_confirm_action, Some(RadarrEvent::EditAllIndexerSettings(None)) @@ -502,16 +502,16 @@ mod tests { app.data.radarr_data.prompt_confirm = true; IndexerSettingsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AllIndexerSettingsPrompt.into() + ActiveRadarrBlock::AllIndexerSettingsPrompt.into() ); assert!(!app.should_refresh); } @@ -534,14 +534,14 @@ mod tests { app.data.radarr_data.selected_block.set_index(index); IndexerSettingsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &selected_block.into()); + assert_eq!(app.get_current_route(), selected_block.into()); } #[rstest] @@ -557,16 +557,16 @@ mod tests { app.data.radarr_data.selected_block.set_index(index); IndexerSettingsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AllIndexerSettingsPrompt.into() + ActiveRadarrBlock::AllIndexerSettingsPrompt.into() ); } @@ -580,16 +580,16 @@ mod tests { app.data.radarr_data.selected_block.set_index(7); IndexerSettingsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput.into() + ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput.into() ); assert!(app.should_ignore_quit_key); } @@ -604,16 +604,16 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into()); IndexerSettingsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AllIndexerSettingsPrompt.into() + ActiveRadarrBlock::AllIndexerSettingsPrompt.into() ); assert!( app @@ -626,16 +626,16 @@ mod tests { ); IndexerSettingsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AllIndexerSettingsPrompt.into() + ActiveRadarrBlock::AllIndexerSettingsPrompt.into() ); assert!( !app @@ -658,16 +658,16 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into()); IndexerSettingsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AllIndexerSettingsPrompt.into() + ActiveRadarrBlock::AllIndexerSettingsPrompt.into() ); assert!( app @@ -680,16 +680,16 @@ mod tests { ); IndexerSettingsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AllIndexerSettingsPrompt.into() + ActiveRadarrBlock::AllIndexerSettingsPrompt.into() ); assert!( !app @@ -716,10 +716,10 @@ mod tests { ); IndexerSettingsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, - &None, + ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, + None, ) .handle(); @@ -735,7 +735,7 @@ mod tests { .is_empty()); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AllIndexerSettingsPrompt.into() + ActiveRadarrBlock::AllIndexerSettingsPrompt.into() ); } @@ -755,11 +755,11 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into()); app.push_navigation_stack(active_radarr_block.into()); - IndexerSettingsHandler::with(&SUBMIT_KEY, &mut app, &active_radarr_block, &None).handle(); + IndexerSettingsHandler::with(SUBMIT_KEY, &mut app, active_radarr_block, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AllIndexerSettingsPrompt.into() + ActiveRadarrBlock::AllIndexerSettingsPrompt.into() ); } } @@ -783,14 +783,14 @@ mod tests { app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); IndexerSettingsHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert!(!app.data.radarr_data.prompt_confirm); assert_eq!(app.data.radarr_data.indexer_settings, None); } @@ -806,14 +806,14 @@ mod tests { app.should_ignore_quit_key = true; IndexerSettingsHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, - &None, + ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert!(!app.should_ignore_quit_key); assert_eq!( app.data.radarr_data.indexer_settings, @@ -838,9 +838,9 @@ mod tests { app.push_navigation_stack(active_radarr_block.into()); app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); - IndexerSettingsHandler::with(&ESC_KEY, &mut app, &active_radarr_block, &None).handle(); + IndexerSettingsHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert_eq!( app.data.radarr_data.indexer_settings, Some(IndexerSettings::default()) @@ -870,10 +870,10 @@ mod tests { }); IndexerSettingsHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, - &None, + ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, + None, ) .handle(); @@ -896,10 +896,10 @@ mod tests { app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); IndexerSettingsHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, - &None, + ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, + None, ) .handle(); @@ -931,14 +931,14 @@ mod tests { app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); IndexerSettingsHandler::with( - &DEFAULT_KEYBINDINGS.confirm.key, + DEFAULT_KEYBINDINGS.confirm.key, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert_eq!( app.data.radarr_data.prompt_confirm_action, Some(RadarrEvent::EditAllIndexerSettings(None)) @@ -952,9 +952,9 @@ mod tests { fn test_indexer_settings_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if INDEXER_SETTINGS_BLOCKS.contains(&active_radarr_block) { - assert!(IndexerSettingsHandler::accepts(&active_radarr_block)); + assert!(IndexerSettingsHandler::accepts(active_radarr_block)); } else { - assert!(!IndexerSettingsHandler::accepts(&active_radarr_block)); + assert!(!IndexerSettingsHandler::accepts(active_radarr_block)); } }) } @@ -965,10 +965,10 @@ mod tests { app.is_loading = true; let handler = IndexerSettingsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ); assert!(!handler.is_ready()); @@ -980,10 +980,10 @@ mod tests { app.is_loading = false; let handler = IndexerSettingsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ); assert!(!handler.is_ready()); @@ -996,10 +996,10 @@ mod tests { app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); let handler = IndexerSettingsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::AllIndexerSettingsPrompt, - &None, + ActiveRadarrBlock::AllIndexerSettingsPrompt, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs index 5fd3b1d..0f55986 100644 --- a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs @@ -47,14 +47,14 @@ mod tests { .indexers .set_items(simple_stateful_iterable_vec!(Indexer, String, protocol)); - IndexersHandler::with(&key, &mut app, &ActiveRadarrBlock::Indexers, &None).handle(); + IndexersHandler::with(key, &mut app, ActiveRadarrBlock::Indexers, None).handle(); assert_str_eq!( app.data.radarr_data.indexers.current_selection().protocol, "Test 1" ); - IndexersHandler::with(&key, &mut app, &ActiveRadarrBlock::Indexers, &None).handle(); + IndexersHandler::with(key, &mut app, ActiveRadarrBlock::Indexers, None).handle(); assert_str_eq!( app.data.radarr_data.indexers.current_selection().protocol, @@ -89,10 +89,10 @@ mod tests { .set_items(extended_stateful_iterable_vec!(Indexer, String, protocol)); IndexersHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ) .handle(); @@ -102,10 +102,10 @@ mod tests { ); IndexersHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ) .handle(); @@ -132,11 +132,11 @@ mod tests { .indexers .set_items(vec![Indexer::default()]); - IndexersHandler::with(&DELETE_KEY, &mut app, &ActiveRadarrBlock::Indexers, &None).handle(); + IndexersHandler::with(DELETE_KEY, &mut app, ActiveRadarrBlock::Indexers, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::DeleteIndexerPrompt.into() + ActiveRadarrBlock::DeleteIndexerPrompt.into() ); } @@ -151,9 +151,9 @@ mod tests { .indexers .set_items(vec![Indexer::default()]); - IndexersHandler::with(&DELETE_KEY, &mut app, &ActiveRadarrBlock::Indexers, &None).handle(); + IndexersHandler::with(DELETE_KEY, &mut app, ActiveRadarrBlock::Indexers, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); } } @@ -170,20 +170,20 @@ mod tests { app.data.radarr_data.main_tabs.set_index(5); IndexersHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ) .handle(); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &ActiveRadarrBlock::RootFolders.into() + ActiveRadarrBlock::RootFolders.into() ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::RootFolders.into() + ActiveRadarrBlock::RootFolders.into() ); } @@ -194,18 +194,18 @@ mod tests { app.data.radarr_data.main_tabs.set_index(5); IndexersHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ) .handle(); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &ActiveRadarrBlock::System.into() + ActiveRadarrBlock::System.into() ); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::System.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::System.into()); } #[rstest] @@ -214,23 +214,11 @@ mod tests { ) { let mut app = App::default(); - IndexersHandler::with( - &key, - &mut app, - &ActiveRadarrBlock::DeleteIndexerPrompt, - &None, - ) - .handle(); + IndexersHandler::with(key, &mut app, ActiveRadarrBlock::DeleteIndexerPrompt, None).handle(); assert!(app.data.radarr_data.prompt_confirm); - IndexersHandler::with( - &key, - &mut app, - &ActiveRadarrBlock::DeleteIndexerPrompt, - &None, - ) - .handle(); + IndexersHandler::with(key, &mut app, ActiveRadarrBlock::DeleteIndexerPrompt, None).handle(); assert!(!app.data.radarr_data.prompt_confirm); } @@ -306,11 +294,11 @@ mod tests { radarr_data.indexers.set_items(vec![indexer]); app.data.radarr_data = radarr_data; - IndexersHandler::with(&SUBMIT_KEY, &mut app, &ActiveRadarrBlock::Indexers, &None).handle(); + IndexersHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::Indexers, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditIndexerPrompt.into() + ActiveRadarrBlock::EditIndexerPrompt.into() ); assert_eq!( app.data.radarr_data.edit_indexer_modal, @@ -344,9 +332,9 @@ mod tests { .indexers .set_items(vec![Indexer::default()]); - IndexersHandler::with(&SUBMIT_KEY, &mut app, &ActiveRadarrBlock::Indexers, &None).handle(); + IndexersHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::Indexers, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert_eq!(app.data.radarr_data.edit_indexer_modal, None); } @@ -363,10 +351,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::DeleteIndexerPrompt.into()); IndexersHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::DeleteIndexerPrompt, - &None, + ActiveRadarrBlock::DeleteIndexerPrompt, + None, ) .handle(); @@ -375,7 +363,7 @@ mod tests { app.data.radarr_data.prompt_confirm_action, Some(RadarrEvent::DeleteIndexer(None)) ); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); } #[test] @@ -390,16 +378,16 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::DeleteIndexerPrompt.into()); IndexersHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::DeleteIndexerPrompt, - &None, + ActiveRadarrBlock::DeleteIndexerPrompt, + None, ) .handle(); assert!(!app.data.radarr_data.prompt_confirm); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); } } @@ -419,14 +407,14 @@ mod tests { app.data.radarr_data.prompt_confirm = true; IndexersHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::DeleteIndexerPrompt, - &None, + ActiveRadarrBlock::DeleteIndexerPrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert!(!app.data.radarr_data.prompt_confirm); } @@ -438,9 +426,9 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.push_navigation_stack(ActiveRadarrBlock::TestIndexer.into()); - IndexersHandler::with(&ESC_KEY, &mut app, &ActiveRadarrBlock::TestIndexer, &None).handle(); + IndexersHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::TestIndexer, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert_eq!(app.data.radarr_data.indexer_test_error, None); } @@ -452,9 +440,9 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); - IndexersHandler::with(&ESC_KEY, &mut app, &ActiveRadarrBlock::Indexers, &None).handle(); + IndexersHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::Indexers, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert!(app.error.text.is_empty()); } } @@ -479,16 +467,16 @@ mod tests { .set_items(vec![Indexer::default()]); IndexersHandler::with( - &DEFAULT_KEYBINDINGS.add.key, + DEFAULT_KEYBINDINGS.add.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddIndexer.into() + ActiveRadarrBlock::AddIndexer.into() ); } @@ -504,14 +492,14 @@ mod tests { .set_items(vec![Indexer::default()]); IndexersHandler::with( - &DEFAULT_KEYBINDINGS.add.key, + DEFAULT_KEYBINDINGS.add.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); } #[test] @@ -525,14 +513,14 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); IndexersHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert!(app.should_refresh); } @@ -548,14 +536,14 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); IndexersHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert!(!app.should_refresh); } @@ -569,16 +557,16 @@ mod tests { .set_items(vec![Indexer::default()]); IndexersHandler::with( - &DEFAULT_KEYBINDINGS.settings.key, + DEFAULT_KEYBINDINGS.settings.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AllIndexerSettingsPrompt.into() + ActiveRadarrBlock::AllIndexerSettingsPrompt.into() ); assert_eq!( app.data.radarr_data.selected_block.blocks, @@ -598,14 +586,14 @@ mod tests { .set_items(vec![Indexer::default()]); IndexersHandler::with( - &DEFAULT_KEYBINDINGS.settings.key, + DEFAULT_KEYBINDINGS.settings.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); } #[test] @@ -618,16 +606,16 @@ mod tests { .set_items(vec![Indexer::default()]); IndexersHandler::with( - &DEFAULT_KEYBINDINGS.test.key, + DEFAULT_KEYBINDINGS.test.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::TestIndexer.into() + ActiveRadarrBlock::TestIndexer.into() ); } @@ -643,14 +631,14 @@ mod tests { .set_items(vec![Indexer::default()]); IndexersHandler::with( - &DEFAULT_KEYBINDINGS.test.key, + DEFAULT_KEYBINDINGS.test.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); } #[test] @@ -663,16 +651,16 @@ mod tests { .set_items(vec![Indexer::default()]); IndexersHandler::with( - &DEFAULT_KEYBINDINGS.test_all.key, + DEFAULT_KEYBINDINGS.test_all.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::TestAllIndexers.into() + ActiveRadarrBlock::TestAllIndexers.into() ); } @@ -688,14 +676,14 @@ mod tests { .set_items(vec![Indexer::default()]); IndexersHandler::with( - &DEFAULT_KEYBINDINGS.test_all.key, + DEFAULT_KEYBINDINGS.test_all.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); } #[test] @@ -710,10 +698,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::DeleteIndexerPrompt.into()); IndexersHandler::with( - &DEFAULT_KEYBINDINGS.confirm.key, + DEFAULT_KEYBINDINGS.confirm.key, &mut app, - &ActiveRadarrBlock::DeleteIndexerPrompt, - &None, + ActiveRadarrBlock::DeleteIndexerPrompt, + None, ) .handle(); @@ -722,7 +710,7 @@ mod tests { app.data.radarr_data.prompt_confirm_action, Some(RadarrEvent::DeleteIndexer(None)) ); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); } } @@ -791,9 +779,9 @@ mod tests { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if indexers_blocks.contains(&active_radarr_block) { - assert!(IndexersHandler::accepts(&active_radarr_block)); + assert!(IndexersHandler::accepts(active_radarr_block)); } else { - assert!(!IndexersHandler::accepts(&active_radarr_block)); + assert!(!IndexersHandler::accepts(active_radarr_block)); } }) } @@ -804,10 +792,10 @@ mod tests { app.is_loading = true; let handler = IndexersHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ); assert!(!handler.is_ready()); @@ -819,10 +807,10 @@ mod tests { app.is_loading = false; let handler = IndexersHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ); assert!(!handler.is_ready()); @@ -839,10 +827,10 @@ mod tests { .set_items(vec![Indexer::default()]); let handler = IndexersHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Indexers, - &None, + ActiveRadarrBlock::Indexers, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/indexers/mod.rs b/src/handlers/radarr_handlers/indexers/mod.rs index 84d4832..9994fa4 100644 --- a/src/handlers/radarr_handlers/indexers/mod.rs +++ b/src/handlers/radarr_handlers/indexers/mod.rs @@ -23,10 +23,10 @@ mod test_all_indexers_handler; mod indexers_handler_tests; pub(super) struct IndexersHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a, 'b> { @@ -48,18 +48,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a, } } - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { + fn accepts(active_block: ActiveRadarrBlock) -> bool { EditIndexerHandler::accepts(active_block) || IndexerSettingsHandler::accepts(active_block) || TestAllIndexersHandler::accepts(active_block) - || INDEXERS_BLOCKS.contains(active_block) + || INDEXERS_BLOCKS.contains(&active_block) } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_block: ActiveRadarrBlock, + context: Option, ) -> IndexersHandler<'a, 'b> { IndexersHandler { key, @@ -69,7 +69,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a, } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -78,31 +78,31 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a, } fn handle_scroll_up(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::Indexers { + if self.active_radarr_block == ActiveRadarrBlock::Indexers { self.app.data.radarr_data.indexers.scroll_up(); } } fn handle_scroll_down(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::Indexers { + if self.active_radarr_block == ActiveRadarrBlock::Indexers { self.app.data.radarr_data.indexers.scroll_down(); } } fn handle_home(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::Indexers { + if self.active_radarr_block == ActiveRadarrBlock::Indexers { self.app.data.radarr_data.indexers.scroll_to_top(); } } fn handle_end(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::Indexers { + if self.active_radarr_block == ActiveRadarrBlock::Indexers { self.app.data.radarr_data.indexers.scroll_to_bottom(); } } fn handle_delete(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::Indexers { + if self.active_radarr_block == ActiveRadarrBlock::Indexers { self .app .push_navigation_stack(ActiveRadarrBlock::DeleteIndexerPrompt.into()); @@ -169,25 +169,25 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a, let key = self.key; match self.active_radarr_block { ActiveRadarrBlock::Indexers => match self.key { - _ if *key == DEFAULT_KEYBINDINGS.add.key => { + _ if key == DEFAULT_KEYBINDINGS.add.key => { self .app .push_navigation_stack(ActiveRadarrBlock::AddIndexer.into()); } - _ if *key == DEFAULT_KEYBINDINGS.refresh.key => { + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { self.app.should_refresh = true; } - _ if *key == DEFAULT_KEYBINDINGS.test.key => { + _ if key == DEFAULT_KEYBINDINGS.test.key => { self .app .push_navigation_stack(ActiveRadarrBlock::TestIndexer.into()); } - _ if *key == DEFAULT_KEYBINDINGS.test_all.key => { + _ if key == DEFAULT_KEYBINDINGS.test_all.key => { self .app .push_navigation_stack(ActiveRadarrBlock::TestAllIndexers.into()); } - _ if *key == DEFAULT_KEYBINDINGS.settings.key => { + _ if key == DEFAULT_KEYBINDINGS.settings.key => { self .app .push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into()); @@ -197,7 +197,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a, _ => (), }, ActiveRadarrBlock::DeleteIndexerPrompt => { - if *key == DEFAULT_KEYBINDINGS.confirm.key { + if key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::DeleteIndexer(None)); diff --git a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler.rs b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler.rs index 307008a..50cac84 100644 --- a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler.rs +++ b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler.rs @@ -9,22 +9,22 @@ use crate::models::Scrollable; mod test_all_indexers_handler_tests; pub(super) struct TestAllIndexersHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + _context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for TestAllIndexersHandler<'a, 'b> { - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { - active_block == &ActiveRadarrBlock::TestAllIndexers + fn accepts(active_block: ActiveRadarrBlock) -> bool { + active_block == ActiveRadarrBlock::TestAllIndexers } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_block: ActiveRadarrBlock, + _context: Option, ) -> TestAllIndexersHandler<'a, 'b> { TestAllIndexersHandler { key, @@ -34,7 +34,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for TestAllIndexersHandl } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -49,7 +49,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for TestAllIndexersHandl } fn handle_scroll_up(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::TestAllIndexers { + if self.active_radarr_block == ActiveRadarrBlock::TestAllIndexers { self .app .data @@ -62,7 +62,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for TestAllIndexersHandl } fn handle_scroll_down(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::TestAllIndexers { + if self.active_radarr_block == ActiveRadarrBlock::TestAllIndexers { self .app .data @@ -75,7 +75,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for TestAllIndexersHandl } fn handle_home(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::TestAllIndexers { + if self.active_radarr_block == ActiveRadarrBlock::TestAllIndexers { self .app .data @@ -88,7 +88,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for TestAllIndexersHandl } fn handle_end(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::TestAllIndexers { + if self.active_radarr_block == ActiveRadarrBlock::TestAllIndexers { self .app .data @@ -107,7 +107,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for TestAllIndexersHandl fn handle_submit(&mut self) {} fn handle_esc(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::TestAllIndexers { + if self.active_radarr_block == ActiveRadarrBlock::TestAllIndexers { self.app.pop_navigation_stack(); self.app.data.radarr_data.indexer_test_all_results = None; } diff --git a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs index 708e22d..7e1a4f3 100644 --- a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs @@ -33,7 +33,7 @@ mod tests { )); app.data.radarr_data.indexer_test_all_results = Some(indexer_test_results); - TestAllIndexersHandler::with(&key, &mut app, &ActiveRadarrBlock::TestAllIndexers, &None) + TestAllIndexersHandler::with(key, &mut app, ActiveRadarrBlock::TestAllIndexers, None) .handle(); assert_str_eq!( @@ -48,7 +48,7 @@ mod tests { "Test 2" ); - TestAllIndexersHandler::with(&key, &mut app, &ActiveRadarrBlock::TestAllIndexers, &None) + TestAllIndexersHandler::with(key, &mut app, ActiveRadarrBlock::TestAllIndexers, None) .handle(); assert_str_eq!( @@ -78,7 +78,7 @@ mod tests { )); app.data.radarr_data.indexer_test_all_results = Some(indexer_test_results); - TestAllIndexersHandler::with(&key, &mut app, &ActiveRadarrBlock::TestAllIndexers, &None) + TestAllIndexersHandler::with(key, &mut app, ActiveRadarrBlock::TestAllIndexers, None) .handle(); assert_str_eq!( @@ -93,7 +93,7 @@ mod tests { "Test 1" ); - TestAllIndexersHandler::with(&key, &mut app, &ActiveRadarrBlock::TestAllIndexers, &None) + TestAllIndexersHandler::with(key, &mut app, ActiveRadarrBlock::TestAllIndexers, None) .handle(); assert_str_eq!( @@ -130,10 +130,10 @@ mod tests { app.data.radarr_data.indexer_test_all_results = Some(indexer_test_results); TestAllIndexersHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::TestAllIndexers, - &None, + ActiveRadarrBlock::TestAllIndexers, + None, ) .handle(); @@ -150,10 +150,10 @@ mod tests { ); TestAllIndexersHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::TestAllIndexers, - &None, + ActiveRadarrBlock::TestAllIndexers, + None, ) .handle(); @@ -183,10 +183,10 @@ mod tests { app.data.radarr_data.indexer_test_all_results = Some(indexer_test_results); TestAllIndexersHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::TestAllIndexers, - &None, + ActiveRadarrBlock::TestAllIndexers, + None, ) .handle(); @@ -203,10 +203,10 @@ mod tests { ); TestAllIndexersHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::TestAllIndexers, - &None, + ActiveRadarrBlock::TestAllIndexers, + None, ) .handle(); @@ -239,14 +239,14 @@ mod tests { app.data.radarr_data.indexer_test_all_results = Some(StatefulTable::default()); TestAllIndexersHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::TestAllIndexers, - &None, + ActiveRadarrBlock::TestAllIndexers, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); assert!(!app.data.radarr_data.prompt_confirm); assert!(app.data.radarr_data.indexer_test_all_results.is_none()); } @@ -256,9 +256,9 @@ mod tests { fn test_test_all_indexers_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if active_radarr_block == ActiveRadarrBlock::TestAllIndexers { - assert!(TestAllIndexersHandler::accepts(&active_radarr_block)); + assert!(TestAllIndexersHandler::accepts(active_radarr_block)); } else { - assert!(!TestAllIndexersHandler::accepts(&active_radarr_block)); + assert!(!TestAllIndexersHandler::accepts(active_radarr_block)); } }); } @@ -269,10 +269,10 @@ mod tests { app.is_loading = true; let handler = TestAllIndexersHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::TestAllIndexers, - &None, + ActiveRadarrBlock::TestAllIndexers, + None, ); assert!(!handler.is_ready()); @@ -284,10 +284,10 @@ mod tests { app.is_loading = false; let handler = TestAllIndexersHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::TestAllIndexers, - &None, + ActiveRadarrBlock::TestAllIndexers, + None, ); assert!(!handler.is_ready()); @@ -300,10 +300,10 @@ mod tests { app.data.radarr_data.indexer_test_all_results = Some(StatefulTable::default()); let handler = TestAllIndexersHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::TestAllIndexers, - &None, + ActiveRadarrBlock::TestAllIndexers, + None, ); assert!(!handler.is_ready()); @@ -318,10 +318,10 @@ mod tests { app.data.radarr_data.indexer_test_all_results = Some(indexer_test_results); let handler = TestAllIndexersHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::TestAllIndexers, - &None, + ActiveRadarrBlock::TestAllIndexers, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/library/add_movie_handler.rs b/src/handlers/radarr_handlers/library/add_movie_handler.rs index c47e22a..67da66d 100644 --- a/src/handlers/radarr_handlers/library/add_movie_handler.rs +++ b/src/handlers/radarr_handlers/library/add_movie_handler.rs @@ -12,22 +12,22 @@ use crate::{handle_text_box_keys, handle_text_box_left_right_keys, App, Key}; mod add_movie_handler_tests; pub(super) struct AddMovieHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a, 'b> { - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { - ADD_MOVIE_BLOCKS.contains(active_block) + fn accepts(active_block: ActiveRadarrBlock) -> bool { + ADD_MOVIE_BLOCKS.contains(&active_block) } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_block: ActiveRadarrBlock, + context: Option, ) -> AddMovieHandler<'a, 'b> { AddMovieHandler { key, @@ -37,7 +37,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a, } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -313,7 +313,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a, fn handle_submit(&mut self) { match self.active_radarr_block { - _ if *self.active_radarr_block == ActiveRadarrBlock::AddMovieSearchInput + _ if self.active_radarr_block == ActiveRadarrBlock::AddMovieSearchInput && !self .app .data @@ -329,7 +329,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a, .push_navigation_stack(ActiveRadarrBlock::AddMovieSearchResults.into()); self.app.should_ignore_quit_key = false; } - _ if *self.active_radarr_block == ActiveRadarrBlock::AddMovieSearchResults + _ if self.active_radarr_block == ActiveRadarrBlock::AddMovieSearchResults && self.app.data.radarr_data.add_searched_movies.is_some() => { let tmdb_id = self @@ -377,16 +377,16 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a, | ActiveRadarrBlock::AddMovieSelectQualityProfile | ActiveRadarrBlock::AddMovieSelectRootFolder => self.app.push_navigation_stack( ( - *self.app.data.radarr_data.selected_block.get_active_block(), - *self.context, + self.app.data.radarr_data.selected_block.get_active_block(), + self.context, ) .into(), ), ActiveRadarrBlock::AddMovieTagsInput => { self.app.push_navigation_stack( ( - *self.app.data.radarr_data.selected_block.get_active_block(), - *self.context, + self.app.data.radarr_data.selected_block.get_active_block(), + self.context, ) .into(), ); @@ -463,8 +463,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a, } ActiveRadarrBlock::AddMoviePrompt => { if self.app.data.radarr_data.selected_block.get_active_block() - == &ActiveRadarrBlock::AddMovieConfirmPrompt - && *key == DEFAULT_KEYBINDINGS.confirm.key + == ActiveRadarrBlock::AddMovieConfirmPrompt + && key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::AddMovie(None)); diff --git a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs index 2800832..e33e48d 100644 --- a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs @@ -39,10 +39,10 @@ mod tests { app.data.radarr_data.add_searched_movies = Some(add_searched_movies); AddMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AddMovieSearchResults, - &None, + ActiveRadarrBlock::AddMovieSearchResults, + None, ) .handle(); @@ -60,10 +60,10 @@ mod tests { ); AddMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AddMovieSearchResults, - &None, + ActiveRadarrBlock::AddMovieSearchResults, + None, ) .handle(); @@ -95,10 +95,10 @@ mod tests { app.data.radarr_data.add_searched_movies = Some(add_searched_movies); AddMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AddMovieSearchResults, - &None, + ActiveRadarrBlock::AddMovieSearchResults, + None, ) .handle(); @@ -116,10 +116,10 @@ mod tests { ); AddMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AddMovieSearchResults, - &None, + ActiveRadarrBlock::AddMovieSearchResults, + None, ) .handle(); @@ -156,10 +156,10 @@ mod tests { if key == Key::Up { for i in (0..monitor_vec.len()).rev() { AddMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AddMovieSelectMonitor, - &None, + ActiveRadarrBlock::AddMovieSelectMonitor, + None, ) .handle(); @@ -178,10 +178,10 @@ mod tests { } else { for i in 0..monitor_vec.len() { AddMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AddMovieSelectMonitor, - &None, + ActiveRadarrBlock::AddMovieSelectMonitor, + None, ) .handle(); @@ -219,10 +219,10 @@ mod tests { if key == Key::Up { for i in (0..minimum_availability_vec.len()).rev() { AddMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AddMovieSelectMinimumAvailability, - &None, + ActiveRadarrBlock::AddMovieSelectMinimumAvailability, + None, ) .handle(); @@ -241,10 +241,10 @@ mod tests { } else { for i in 0..minimum_availability_vec.len() { AddMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AddMovieSelectMinimumAvailability, - &None, + ActiveRadarrBlock::AddMovieSelectMinimumAvailability, + None, ) .handle(); @@ -279,10 +279,10 @@ mod tests { .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); AddMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AddMovieSelectQualityProfile, - &None, + ActiveRadarrBlock::AddMovieSelectQualityProfile, + None, ) .handle(); @@ -299,10 +299,10 @@ mod tests { ); AddMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AddMovieSelectQualityProfile, - &None, + ActiveRadarrBlock::AddMovieSelectQualityProfile, + None, ) .handle(); @@ -335,10 +335,10 @@ mod tests { .set_items(simple_stateful_iterable_vec!(RootFolder, String, path)); AddMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AddMovieSelectRootFolder, - &None, + ActiveRadarrBlock::AddMovieSelectRootFolder, + None, ) .handle(); @@ -356,10 +356,10 @@ mod tests { ); AddMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::AddMovieSelectRootFolder, - &None, + ActiveRadarrBlock::AddMovieSelectRootFolder, + None, ) .handle(); @@ -383,17 +383,17 @@ mod tests { app.data.radarr_data.selected_block = BlockSelectionState::new(&ADD_MOVIE_SELECTION_BLOCKS); app.data.radarr_data.selected_block.next(); - AddMovieHandler::with(&key, &mut app, &ActiveRadarrBlock::AddMoviePrompt, &None).handle(); + AddMovieHandler::with(key, &mut app, ActiveRadarrBlock::AddMoviePrompt, None).handle(); if key == Key::Up { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::AddMovieSelectRootFolder + ActiveRadarrBlock::AddMovieSelectRootFolder ); } else { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::AddMovieSelectMinimumAvailability + ActiveRadarrBlock::AddMovieSelectMinimumAvailability ); } } @@ -405,11 +405,11 @@ mod tests { app.data.radarr_data.selected_block = BlockSelectionState::new(&ADD_MOVIE_SELECTION_BLOCKS); app.data.radarr_data.selected_block.next(); - AddMovieHandler::with(&key, &mut app, &ActiveRadarrBlock::AddMoviePrompt, &None).handle(); + AddMovieHandler::with(key, &mut app, ActiveRadarrBlock::AddMoviePrompt, None).handle(); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::AddMovieSelectMonitor + ActiveRadarrBlock::AddMovieSelectMonitor ); } } @@ -436,10 +436,10 @@ mod tests { app.data.radarr_data.add_searched_movies = Some(add_searched_movies); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::AddMovieSearchResults, - &None, + ActiveRadarrBlock::AddMovieSearchResults, + None, ) .handle(); @@ -457,10 +457,10 @@ mod tests { ); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::AddMovieSearchResults, - &None, + ActiveRadarrBlock::AddMovieSearchResults, + None, ) .handle(); @@ -490,10 +490,10 @@ mod tests { app.data.radarr_data.add_searched_movies = Some(add_searched_movies); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::AddMovieSearchResults, - &None, + ActiveRadarrBlock::AddMovieSearchResults, + None, ) .handle(); @@ -511,10 +511,10 @@ mod tests { ); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::AddMovieSearchResults, - &None, + ActiveRadarrBlock::AddMovieSearchResults, + None, ) .handle(); @@ -547,10 +547,10 @@ mod tests { .set_items(monitor_vec.clone()); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::AddMovieSelectMonitor, - &None, + ActiveRadarrBlock::AddMovieSelectMonitor, + None, ) .handle(); @@ -567,10 +567,10 @@ mod tests { ); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::AddMovieSelectMonitor, - &None, + ActiveRadarrBlock::AddMovieSelectMonitor, + None, ) .handle(); @@ -602,10 +602,10 @@ mod tests { .set_items(minimum_availability_vec.clone()); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::AddMovieSelectMinimumAvailability, - &None, + ActiveRadarrBlock::AddMovieSelectMinimumAvailability, + None, ) .handle(); @@ -622,10 +622,10 @@ mod tests { ); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::AddMovieSelectMinimumAvailability, - &None, + ActiveRadarrBlock::AddMovieSelectMinimumAvailability, + None, ) .handle(); @@ -660,10 +660,10 @@ mod tests { ]); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::AddMovieSelectQualityProfile, - &None, + ActiveRadarrBlock::AddMovieSelectQualityProfile, + None, ) .handle(); @@ -680,10 +680,10 @@ mod tests { ); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::AddMovieSelectQualityProfile, - &None, + ActiveRadarrBlock::AddMovieSelectQualityProfile, + None, ) .handle(); @@ -714,10 +714,10 @@ mod tests { .set_items(extended_stateful_iterable_vec!(RootFolder, String, path)); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::AddMovieSelectRootFolder, - &None, + ActiveRadarrBlock::AddMovieSelectRootFolder, + None, ) .handle(); @@ -735,10 +735,10 @@ mod tests { ); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::AddMovieSelectRootFolder, - &None, + ActiveRadarrBlock::AddMovieSelectRootFolder, + None, ) .handle(); @@ -762,10 +762,10 @@ mod tests { app.data.radarr_data.add_movie_search = Some("Test".into()); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::AddMovieSearchInput, - &None, + ActiveRadarrBlock::AddMovieSearchInput, + None, ) .handle(); @@ -782,10 +782,10 @@ mod tests { ); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::AddMovieSearchInput, - &None, + ActiveRadarrBlock::AddMovieSearchInput, + None, ) .handle(); @@ -811,10 +811,10 @@ mod tests { }); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::AddMovieTagsInput, - &None, + ActiveRadarrBlock::AddMovieTagsInput, + None, ) .handle(); @@ -832,10 +832,10 @@ mod tests { ); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::AddMovieTagsInput, - &None, + ActiveRadarrBlock::AddMovieTagsInput, + None, ) .handle(); @@ -866,11 +866,11 @@ mod tests { fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { let mut app = App::default(); - AddMovieHandler::with(&key, &mut app, &ActiveRadarrBlock::AddMoviePrompt, &None).handle(); + AddMovieHandler::with(key, &mut app, ActiveRadarrBlock::AddMoviePrompt, None).handle(); assert!(app.data.radarr_data.prompt_confirm); - AddMovieHandler::with(&key, &mut app, &ActiveRadarrBlock::AddMoviePrompt, &None).handle(); + AddMovieHandler::with(key, &mut app, ActiveRadarrBlock::AddMoviePrompt, None).handle(); assert!(!app.data.radarr_data.prompt_confirm); } @@ -881,10 +881,10 @@ mod tests { app.data.radarr_data.add_movie_search = Some("Test".into()); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::AddMovieSearchInput, - &None, + ActiveRadarrBlock::AddMovieSearchInput, + None, ) .handle(); @@ -901,10 +901,10 @@ mod tests { ); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::AddMovieSearchInput, - &None, + ActiveRadarrBlock::AddMovieSearchInput, + None, ) .handle(); @@ -930,10 +930,10 @@ mod tests { }); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::AddMovieTagsInput, - &None, + ActiveRadarrBlock::AddMovieTagsInput, + None, ) .handle(); @@ -951,10 +951,10 @@ mod tests { ); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::AddMovieTagsInput, - &None, + ActiveRadarrBlock::AddMovieTagsInput, + None, ) .handle(); @@ -996,17 +996,17 @@ mod tests { app.data.radarr_data.add_movie_search = Some("test".into()); AddMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AddMovieSearchInput, - &None, + ActiveRadarrBlock::AddMovieSearchInput, + None, ) .handle(); assert!(!app.should_ignore_quit_key); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddMovieSearchResults.into() + ActiveRadarrBlock::AddMovieSearchResults.into() ); } @@ -1018,17 +1018,17 @@ mod tests { app.should_ignore_quit_key = true; AddMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AddMovieSearchInput, - &None, + ActiveRadarrBlock::AddMovieSearchInput, + None, ) .handle(); assert!(app.should_ignore_quit_key); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddMovieSearchInput.into() + ActiveRadarrBlock::AddMovieSearchInput.into() ); } @@ -1042,20 +1042,20 @@ mod tests { BiMap::from_iter([(1, "B - Test 2".to_owned()), (0, "A - Test 1".to_owned())]); AddMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AddMovieSearchResults, - &None, + ActiveRadarrBlock::AddMovieSearchResults, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddMoviePrompt.into() + ActiveRadarrBlock::AddMoviePrompt.into() ); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::AddMovieSelectRootFolder + ActiveRadarrBlock::AddMovieSelectRootFolder ); assert!(app.data.radarr_data.add_movie_modal.is_some()); assert!(!app @@ -1107,16 +1107,16 @@ mod tests { add_searched_movies.set_items(vec![AddMovieSearchResult::default()]); AddMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AddMovieSearchResults, - &None, + ActiveRadarrBlock::AddMovieSearchResults, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddMovieSearchResults.into() + ActiveRadarrBlock::AddMovieSearchResults.into() ); assert!(app.data.radarr_data.add_movie_modal.is_none()); } @@ -1126,16 +1126,16 @@ mod tests { let mut app = App::default(); app.push_navigation_stack(ActiveRadarrBlock::AddMovieSearchResults.into()); AddMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AddMovieSearchResults, - &None, + ActiveRadarrBlock::AddMovieSearchResults, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddMovieSearchResults.into() + ActiveRadarrBlock::AddMovieSearchResults.into() ); } @@ -1152,16 +1152,16 @@ mod tests { .set_items(vec![Movie::default()]); AddMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AddMovieSearchResults, - &None, + ActiveRadarrBlock::AddMovieSearchResults, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddMovieAlreadyInLibrary.into() + ActiveRadarrBlock::AddMovieAlreadyInLibrary.into() ); } @@ -1178,14 +1178,14 @@ mod tests { .set_index(ADD_MOVIE_SELECTION_BLOCKS.len() - 1); AddMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AddMoviePrompt, - &None, + ActiveRadarrBlock::AddMoviePrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); } @@ -1204,14 +1204,14 @@ mod tests { .set_index(ADD_MOVIE_SELECTION_BLOCKS.len() - 1); AddMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AddMoviePrompt, - &None, + ActiveRadarrBlock::AddMoviePrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert_eq!( app.data.radarr_data.prompt_confirm_action, Some(RadarrEvent::AddMovie(None)) @@ -1241,16 +1241,16 @@ mod tests { app.data.radarr_data.selected_block.set_index(index); AddMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AddMoviePrompt, - &Some(ActiveRadarrBlock::CollectionDetails), + ActiveRadarrBlock::AddMoviePrompt, + Some(ActiveRadarrBlock::CollectionDetails), ) .handle(); assert_eq!( app.get_current_route(), - &(selected_block, Some(ActiveRadarrBlock::CollectionDetails)).into() + (selected_block, Some(ActiveRadarrBlock::CollectionDetails)).into() ); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); @@ -1275,16 +1275,16 @@ mod tests { app.push_navigation_stack(active_radarr_block.into()); AddMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &active_radarr_block, - &Some(ActiveRadarrBlock::CollectionDetails), + active_radarr_block, + Some(ActiveRadarrBlock::CollectionDetails), ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddMoviePrompt.into() + ActiveRadarrBlock::AddMoviePrompt.into() ); if active_radarr_block == ActiveRadarrBlock::AddMovieTagsInput { @@ -1315,15 +1315,15 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::AddMovieSearchInput.into()); AddMovieHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::AddMovieSearchInput, - &None, + ActiveRadarrBlock::AddMovieSearchInput, + None, ) .handle(); assert!(!app.should_ignore_quit_key); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert_eq!(app.data.radarr_data.add_movie_search, None); } @@ -1336,17 +1336,17 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::AddMovieTagsInput.into()); AddMovieHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::AddMovieTagsInput, - &None, + ActiveRadarrBlock::AddMovieTagsInput, + None, ) .handle(); assert!(!app.should_ignore_quit_key); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddMoviePrompt.into() + ActiveRadarrBlock::AddMoviePrompt.into() ); } @@ -1368,11 +1368,11 @@ mod tests { )); app.data.radarr_data.add_searched_movies = Some(add_searched_movies); - AddMovieHandler::with(&ESC_KEY, &mut app, &active_radarr_block, &None).handle(); + AddMovieHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddMovieSearchInput.into() + ActiveRadarrBlock::AddMovieSearchInput.into() ); assert!(app.data.radarr_data.add_searched_movies.is_none()); assert!(app.should_ignore_quit_key); @@ -1386,16 +1386,16 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::AddMovieAlreadyInLibrary.into()); AddMovieHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::AddMovieAlreadyInLibrary, - &None, + ActiveRadarrBlock::AddMovieAlreadyInLibrary, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddMovieSearchResults.into() + ActiveRadarrBlock::AddMovieSearchResults.into() ); } @@ -1407,18 +1407,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::AddMovieSearchResults.into()); app.push_navigation_stack(ActiveRadarrBlock::AddMoviePrompt.into()); - AddMovieHandler::with( - &ESC_KEY, - &mut app, - &ActiveRadarrBlock::AddMoviePrompt, - &None, - ) - .handle(); + AddMovieHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::AddMoviePrompt, None).handle(); assert!(!app.data.radarr_data.prompt_confirm); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddMovieSearchResults.into() + ActiveRadarrBlock::AddMovieSearchResults.into() ); assert!(app.data.radarr_data.add_movie_modal.is_none()); } @@ -1432,17 +1426,17 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::AddMovieTagsInput.into()); AddMovieHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::AddMovieTagsInput, - &None, + ActiveRadarrBlock::AddMovieTagsInput, + None, ) .handle(); assert!(!app.should_ignore_quit_key); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddMoviePrompt.into() + ActiveRadarrBlock::AddMoviePrompt.into() ); } @@ -1473,16 +1467,16 @@ mod tests { ); AddMovieHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &active_radarr_block, - &Some(ActiveRadarrBlock::CollectionDetails), + active_radarr_block, + Some(ActiveRadarrBlock::CollectionDetails), ) .handle(); assert_eq!( app.get_current_route(), - &( + ( ActiveRadarrBlock::AddMoviePrompt, Some(ActiveRadarrBlock::CollectionDetails), ) @@ -1507,10 +1501,10 @@ mod tests { app.data.radarr_data.add_movie_search = Some("Test".into()); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::AddMovieSearchInput, - &None, + ActiveRadarrBlock::AddMovieSearchInput, + None, ) .handle(); @@ -1529,10 +1523,10 @@ mod tests { }); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::AddMovieTagsInput, - &None, + ActiveRadarrBlock::AddMovieTagsInput, + None, ) .handle(); @@ -1555,10 +1549,10 @@ mod tests { app.data.radarr_data.add_movie_search = Some(HorizontallyScrollableText::default()); AddMovieHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::AddMovieSearchInput, - &None, + ActiveRadarrBlock::AddMovieSearchInput, + None, ) .handle(); @@ -1574,10 +1568,10 @@ mod tests { app.data.radarr_data.add_movie_modal = Some(AddMovieModal::default()); AddMovieHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::AddMovieTagsInput, - &None, + ActiveRadarrBlock::AddMovieTagsInput, + None, ) .handle(); @@ -1608,14 +1602,14 @@ mod tests { .set_index(ADD_MOVIE_SELECTION_BLOCKS.len() - 1); AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.confirm.key, + DEFAULT_KEYBINDINGS.confirm.key, &mut app, - &ActiveRadarrBlock::AddMoviePrompt, - &None, + ActiveRadarrBlock::AddMoviePrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert_eq!( app.data.radarr_data.prompt_confirm_action, Some(RadarrEvent::AddMovie(None)) @@ -1628,9 +1622,9 @@ mod tests { fn test_add_movie_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if ADD_MOVIE_BLOCKS.contains(&active_radarr_block) { - assert!(AddMovieHandler::accepts(&active_radarr_block)); + assert!(AddMovieHandler::accepts(active_radarr_block)); } else { - assert!(!AddMovieHandler::accepts(&active_radarr_block)); + assert!(!AddMovieHandler::accepts(active_radarr_block)); } }); } @@ -1641,10 +1635,10 @@ mod tests { app.is_loading = true; let handler = AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::AddMoviePrompt, - &None, + ActiveRadarrBlock::AddMoviePrompt, + None, ); assert!(!handler.is_ready()); @@ -1656,10 +1650,10 @@ mod tests { app.is_loading = false; let handler = AddMovieHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::AddMoviePrompt, - &None, + ActiveRadarrBlock::AddMoviePrompt, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/library/delete_movie_handler.rs b/src/handlers/radarr_handlers/library/delete_movie_handler.rs index 2113be7..a90529c 100644 --- a/src/handlers/radarr_handlers/library/delete_movie_handler.rs +++ b/src/handlers/radarr_handlers/library/delete_movie_handler.rs @@ -10,22 +10,22 @@ use crate::network::radarr_network::RadarrEvent; mod delete_movie_handler_tests; pub(super) struct DeleteMovieHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + _context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DeleteMovieHandler<'a, 'b> { - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { - DELETE_MOVIE_BLOCKS.contains(active_block) + fn accepts(active_block: ActiveRadarrBlock) -> bool { + DELETE_MOVIE_BLOCKS.contains(&active_block) } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_block: ActiveRadarrBlock, + _context: Option, ) -> Self { DeleteMovieHandler { key, @@ -35,7 +35,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DeleteMovieHandler<' } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -44,13 +44,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DeleteMovieHandler<' } fn handle_scroll_up(&mut self) { - if *self.active_radarr_block == ActiveRadarrBlock::DeleteMoviePrompt { + if self.active_radarr_block == ActiveRadarrBlock::DeleteMoviePrompt { self.app.data.radarr_data.selected_block.previous(); } } fn handle_scroll_down(&mut self) { - if *self.active_radarr_block == ActiveRadarrBlock::DeleteMoviePrompt { + if self.active_radarr_block == ActiveRadarrBlock::DeleteMoviePrompt { self.app.data.radarr_data.selected_block.next(); } } @@ -62,13 +62,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DeleteMovieHandler<' fn handle_delete(&mut self) {} fn handle_left_right_action(&mut self) { - if *self.active_radarr_block == ActiveRadarrBlock::DeleteMoviePrompt { + if self.active_radarr_block == ActiveRadarrBlock::DeleteMoviePrompt { handle_prompt_toggle(self.app, self.key); } } fn handle_submit(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::DeleteMoviePrompt { + if self.active_radarr_block == ActiveRadarrBlock::DeleteMoviePrompt { match self.app.data.radarr_data.selected_block.get_active_block() { ActiveRadarrBlock::DeleteMovieConfirmPrompt => { if self.app.data.radarr_data.prompt_confirm { @@ -94,7 +94,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DeleteMovieHandler<' } fn handle_esc(&mut self) { - if *self.active_radarr_block == ActiveRadarrBlock::DeleteMoviePrompt { + if self.active_radarr_block == ActiveRadarrBlock::DeleteMoviePrompt { self.app.pop_navigation_stack(); self.app.data.radarr_data.reset_delete_movie_preferences(); self.app.data.radarr_data.prompt_confirm = false; @@ -102,10 +102,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DeleteMovieHandler<' } fn handle_char_key_event(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::DeleteMoviePrompt + if self.active_radarr_block == ActiveRadarrBlock::DeleteMoviePrompt && self.app.data.radarr_data.selected_block.get_active_block() - == &ActiveRadarrBlock::DeleteMovieConfirmPrompt - && *self.key == DEFAULT_KEYBINDINGS.confirm.key + == ActiveRadarrBlock::DeleteMovieConfirmPrompt + && self.key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::DeleteMovie(None)); diff --git a/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs b/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs index 65cda31..437799a 100644 --- a/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs @@ -25,18 +25,17 @@ mod tests { BlockSelectionState::new(&DELETE_MOVIE_SELECTION_BLOCKS); app.data.radarr_data.selected_block.next(); - DeleteMovieHandler::with(&key, &mut app, &ActiveRadarrBlock::DeleteMoviePrompt, &None) - .handle(); + DeleteMovieHandler::with(key, &mut app, ActiveRadarrBlock::DeleteMoviePrompt, None).handle(); if key == Key::Up { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::DeleteMovieToggleDeleteFile + ActiveRadarrBlock::DeleteMovieToggleDeleteFile ); } else { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::DeleteMovieConfirmPrompt + ActiveRadarrBlock::DeleteMovieConfirmPrompt ); } } @@ -51,12 +50,11 @@ mod tests { BlockSelectionState::new(&DELETE_MOVIE_SELECTION_BLOCKS); app.data.radarr_data.selected_block.next(); - DeleteMovieHandler::with(&key, &mut app, &ActiveRadarrBlock::DeleteMoviePrompt, &None) - .handle(); + DeleteMovieHandler::with(key, &mut app, ActiveRadarrBlock::DeleteMoviePrompt, None).handle(); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::DeleteMovieToggleAddListExclusion + ActiveRadarrBlock::DeleteMovieToggleAddListExclusion ); } } @@ -70,13 +68,11 @@ mod tests { fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { let mut app = App::default(); - DeleteMovieHandler::with(&key, &mut app, &ActiveRadarrBlock::DeleteMoviePrompt, &None) - .handle(); + DeleteMovieHandler::with(key, &mut app, ActiveRadarrBlock::DeleteMoviePrompt, None).handle(); assert!(app.data.radarr_data.prompt_confirm); - DeleteMovieHandler::with(&key, &mut app, &ActiveRadarrBlock::DeleteMoviePrompt, &None) - .handle(); + DeleteMovieHandler::with(key, &mut app, ActiveRadarrBlock::DeleteMoviePrompt, None).handle(); assert!(!app.data.radarr_data.prompt_confirm); } @@ -109,14 +105,14 @@ mod tests { app.data.radarr_data.add_list_exclusion = true; DeleteMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::DeleteMoviePrompt, - &None, + ActiveRadarrBlock::DeleteMoviePrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); assert!(!app.data.radarr_data.prompt_confirm); assert!(!app.data.radarr_data.delete_movie_files); @@ -140,14 +136,14 @@ mod tests { .set_index(DELETE_MOVIE_SELECTION_BLOCKS.len() - 1); DeleteMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::DeleteMoviePrompt, - &None, + ActiveRadarrBlock::DeleteMoviePrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert_eq!( app.data.radarr_data.prompt_confirm_action, Some(RadarrEvent::DeleteMovie(None)) @@ -169,16 +165,16 @@ mod tests { app.data.radarr_data.add_list_exclusion = true; DeleteMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::DeleteMoviePrompt, - &None, + ActiveRadarrBlock::DeleteMoviePrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::DeleteMoviePrompt.into() + ActiveRadarrBlock::DeleteMoviePrompt.into() ); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); assert!(!app.should_refresh); @@ -196,25 +192,25 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::DeleteMoviePrompt.into()); DeleteMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::DeleteMoviePrompt, - &None, + ActiveRadarrBlock::DeleteMoviePrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), ¤t_route); + assert_eq!(app.get_current_route(), current_route); assert_eq!(app.data.radarr_data.delete_movie_files, true); DeleteMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::DeleteMoviePrompt, - &None, + ActiveRadarrBlock::DeleteMoviePrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), ¤t_route); + assert_eq!(app.get_current_route(), current_route); assert_eq!(app.data.radarr_data.delete_movie_files, false); } } @@ -236,14 +232,14 @@ mod tests { app.data.radarr_data.add_list_exclusion = true; DeleteMovieHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::DeleteMoviePrompt, - &None, + ActiveRadarrBlock::DeleteMoviePrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert!(!app.data.radarr_data.prompt_confirm); assert!(!app.data.radarr_data.delete_movie_files); assert!(!app.data.radarr_data.add_list_exclusion); @@ -276,14 +272,14 @@ mod tests { .set_index(DELETE_MOVIE_SELECTION_BLOCKS.len() - 1); DeleteMovieHandler::with( - &DEFAULT_KEYBINDINGS.confirm.key, + DEFAULT_KEYBINDINGS.confirm.key, &mut app, - &ActiveRadarrBlock::DeleteMoviePrompt, - &None, + ActiveRadarrBlock::DeleteMoviePrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert_eq!( app.data.radarr_data.prompt_confirm_action, Some(RadarrEvent::DeleteMovie(None)) @@ -299,9 +295,9 @@ mod tests { fn test_delete_movie_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if DELETE_MOVIE_BLOCKS.contains(&active_radarr_block) { - assert!(DeleteMovieHandler::accepts(&active_radarr_block)); + assert!(DeleteMovieHandler::accepts(active_radarr_block)); } else { - assert!(!DeleteMovieHandler::accepts(&active_radarr_block)); + assert!(!DeleteMovieHandler::accepts(active_radarr_block)); } }); } @@ -312,10 +308,10 @@ mod tests { app.is_loading = true; let handler = DeleteMovieHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::DeleteMoviePrompt, - &None, + ActiveRadarrBlock::DeleteMoviePrompt, + None, ); assert!(!handler.is_ready()); @@ -327,10 +323,10 @@ mod tests { app.is_loading = false; let handler = DeleteMovieHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::DeleteMoviePrompt, - &None, + ActiveRadarrBlock::DeleteMoviePrompt, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/library/edit_movie_handler.rs b/src/handlers/radarr_handlers/library/edit_movie_handler.rs index 0664668..7b85e96 100644 --- a/src/handlers/radarr_handlers/library/edit_movie_handler.rs +++ b/src/handlers/radarr_handlers/library/edit_movie_handler.rs @@ -12,22 +12,22 @@ use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; mod edit_movie_handler_tests; pub(super) struct EditMovieHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditMovieHandler<'a, 'b> { - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { - EDIT_MOVIE_BLOCKS.contains(active_block) + fn accepts(active_block: ActiveRadarrBlock) -> bool { + EDIT_MOVIE_BLOCKS.contains(&active_block) } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_block: ActiveRadarrBlock, + context: Option, ) -> EditMovieHandler<'a, 'b> { EditMovieHandler { key, @@ -37,7 +37,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditMovieHandler<'a, } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -231,16 +231,16 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditMovieHandler<'a, ActiveRadarrBlock::EditMovieSelectMinimumAvailability | ActiveRadarrBlock::EditMovieSelectQualityProfile => self.app.push_navigation_stack( ( - *self.app.data.radarr_data.selected_block.get_active_block(), - *self.context, + self.app.data.radarr_data.selected_block.get_active_block(), + self.context, ) .into(), ), ActiveRadarrBlock::EditMoviePathInput | ActiveRadarrBlock::EditMovieTagsInput => { self.app.push_navigation_stack( ( - *self.app.data.radarr_data.selected_block.get_active_block(), - *self.context, + self.app.data.radarr_data.selected_block.get_active_block(), + self.context, ) .into(), ); @@ -329,8 +329,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditMovieHandler<'a, } ActiveRadarrBlock::EditMoviePrompt => { if self.app.data.radarr_data.selected_block.get_active_block() - == &ActiveRadarrBlock::EditMovieConfirmPrompt - && *key == DEFAULT_KEYBINDINGS.confirm.key + == ActiveRadarrBlock::EditMovieConfirmPrompt + && key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::EditMovie(None)); diff --git a/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs b/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs index ab8181d..86b3ed3 100644 --- a/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs @@ -42,10 +42,10 @@ mod tests { if key == Key::Up { for i in (0..minimum_availability_vec.len()).rev() { EditMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::EditMovieSelectMinimumAvailability, - &None, + ActiveRadarrBlock::EditMovieSelectMinimumAvailability, + None, ) .handle(); @@ -64,10 +64,10 @@ mod tests { } else { for i in 0..minimum_availability_vec.len() { EditMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::EditMovieSelectMinimumAvailability, - &None, + ActiveRadarrBlock::EditMovieSelectMinimumAvailability, + None, ) .handle(); @@ -102,10 +102,10 @@ mod tests { .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); EditMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::EditMovieSelectQualityProfile, - &None, + ActiveRadarrBlock::EditMovieSelectQualityProfile, + None, ) .handle(); @@ -122,10 +122,10 @@ mod tests { ); EditMovieHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::EditMovieSelectQualityProfile, - &None, + ActiveRadarrBlock::EditMovieSelectQualityProfile, + None, ) .handle(); @@ -149,17 +149,17 @@ mod tests { app.data.radarr_data.selected_block = BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS); app.data.radarr_data.selected_block.next(); - EditMovieHandler::with(&key, &mut app, &ActiveRadarrBlock::EditMoviePrompt, &None).handle(); + EditMovieHandler::with(key, &mut app, ActiveRadarrBlock::EditMoviePrompt, None).handle(); if key == Key::Up { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::EditMovieToggleMonitored + ActiveRadarrBlock::EditMovieToggleMonitored ); } else { assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::EditMovieSelectQualityProfile + ActiveRadarrBlock::EditMovieSelectQualityProfile ); } } @@ -172,11 +172,11 @@ mod tests { app.data.radarr_data.selected_block = BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS); app.data.radarr_data.selected_block.next(); - EditMovieHandler::with(&key, &mut app, &ActiveRadarrBlock::EditMoviePrompt, &None).handle(); + EditMovieHandler::with(key, &mut app, ActiveRadarrBlock::EditMoviePrompt, None).handle(); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::EditMovieSelectMinimumAvailability + ActiveRadarrBlock::EditMovieSelectMinimumAvailability ); } } @@ -205,10 +205,10 @@ mod tests { .set_items(minimum_availability_vec.clone()); EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::EditMovieSelectMinimumAvailability, - &None, + ActiveRadarrBlock::EditMovieSelectMinimumAvailability, + None, ) .handle(); @@ -225,10 +225,10 @@ mod tests { ); EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::EditMovieSelectMinimumAvailability, - &None, + ActiveRadarrBlock::EditMovieSelectMinimumAvailability, + None, ) .handle(); @@ -263,10 +263,10 @@ mod tests { ]); EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::EditMovieSelectQualityProfile, - &None, + ActiveRadarrBlock::EditMovieSelectQualityProfile, + None, ) .handle(); @@ -283,10 +283,10 @@ mod tests { ); EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::EditMovieSelectQualityProfile, - &None, + ActiveRadarrBlock::EditMovieSelectQualityProfile, + None, ) .handle(); @@ -312,10 +312,10 @@ mod tests { }); EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::EditMoviePathInput, - &None, + ActiveRadarrBlock::EditMoviePathInput, + None, ) .handle(); @@ -333,10 +333,10 @@ mod tests { ); EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::EditMoviePathInput, - &None, + ActiveRadarrBlock::EditMoviePathInput, + None, ) .handle(); @@ -363,10 +363,10 @@ mod tests { }); EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::EditMovieTagsInput, - &None, + ActiveRadarrBlock::EditMovieTagsInput, + None, ) .handle(); @@ -384,10 +384,10 @@ mod tests { ); EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::EditMovieTagsInput, - &None, + ActiveRadarrBlock::EditMovieTagsInput, + None, ) .handle(); @@ -418,11 +418,11 @@ mod tests { fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { let mut app = App::default(); - EditMovieHandler::with(&key, &mut app, &ActiveRadarrBlock::EditMoviePrompt, &None).handle(); + EditMovieHandler::with(key, &mut app, ActiveRadarrBlock::EditMoviePrompt, None).handle(); assert!(app.data.radarr_data.prompt_confirm); - EditMovieHandler::with(&key, &mut app, &ActiveRadarrBlock::EditMoviePrompt, &None).handle(); + EditMovieHandler::with(key, &mut app, ActiveRadarrBlock::EditMoviePrompt, None).handle(); assert!(!app.data.radarr_data.prompt_confirm); } @@ -436,10 +436,10 @@ mod tests { }); EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::EditMoviePathInput, - &None, + ActiveRadarrBlock::EditMoviePathInput, + None, ) .handle(); @@ -457,10 +457,10 @@ mod tests { ); EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::EditMoviePathInput, - &None, + ActiveRadarrBlock::EditMoviePathInput, + None, ) .handle(); @@ -487,10 +487,10 @@ mod tests { }); EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::EditMovieTagsInput, - &None, + ActiveRadarrBlock::EditMovieTagsInput, + None, ) .handle(); @@ -508,10 +508,10 @@ mod tests { ); EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::EditMovieTagsInput, - &None, + ActiveRadarrBlock::EditMovieTagsInput, + None, ) .handle(); @@ -557,10 +557,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::EditMoviePathInput.into()); EditMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditMoviePathInput, - &None, + ActiveRadarrBlock::EditMoviePathInput, + None, ) .handle(); @@ -576,7 +576,7 @@ mod tests { .is_empty()); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditMoviePrompt.into() + ActiveRadarrBlock::EditMoviePrompt.into() ); } @@ -592,10 +592,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::EditMoviePathInput.into()); EditMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditMovieTagsInput, - &None, + ActiveRadarrBlock::EditMovieTagsInput, + None, ) .handle(); @@ -611,7 +611,7 @@ mod tests { .is_empty()); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditMoviePrompt.into() + ActiveRadarrBlock::EditMoviePrompt.into() ); } @@ -629,14 +629,14 @@ mod tests { .set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); EditMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditMoviePrompt, - &None, + ActiveRadarrBlock::EditMoviePrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); } @@ -655,14 +655,14 @@ mod tests { .set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); EditMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditMoviePrompt, - &None, + ActiveRadarrBlock::EditMoviePrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert_eq!( app.data.radarr_data.prompt_confirm_action, Some(RadarrEvent::EditMovie(None)) @@ -681,16 +681,16 @@ mod tests { app.data.radarr_data.prompt_confirm = true; EditMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditMoviePrompt, - &None, + ActiveRadarrBlock::EditMoviePrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditMoviePrompt.into() + ActiveRadarrBlock::EditMoviePrompt.into() ); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); assert!(!app.should_refresh); @@ -708,14 +708,14 @@ mod tests { app.push_navigation_stack(current_route); EditMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditMoviePrompt, - &Some(ActiveRadarrBlock::Movies), + ActiveRadarrBlock::EditMoviePrompt, + Some(ActiveRadarrBlock::Movies), ) .handle(); - assert_eq!(app.get_current_route(), ¤t_route); + assert_eq!(app.get_current_route(), current_route); assert_eq!( app .data @@ -728,14 +728,14 @@ mod tests { ); EditMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditMoviePrompt, - &Some(ActiveRadarrBlock::Movies), + ActiveRadarrBlock::EditMoviePrompt, + Some(ActiveRadarrBlock::Movies), ) .handle(); - assert_eq!(app.get_current_route(), ¤t_route); + assert_eq!(app.get_current_route(), current_route); assert_eq!( app .data @@ -770,16 +770,16 @@ mod tests { app.data.radarr_data.selected_block.set_index(index); EditMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditMoviePrompt, - &Some(ActiveRadarrBlock::Movies), + ActiveRadarrBlock::EditMoviePrompt, + Some(ActiveRadarrBlock::Movies), ) .handle(); assert_eq!( app.get_current_route(), - &(selected_block, Some(ActiveRadarrBlock::Movies)).into() + (selected_block, Some(ActiveRadarrBlock::Movies)).into() ); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); @@ -808,16 +808,16 @@ mod tests { app.data.radarr_data.selected_block.set_index(index); EditMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::EditMoviePrompt, - &Some(ActiveRadarrBlock::Movies), + ActiveRadarrBlock::EditMoviePrompt, + Some(ActiveRadarrBlock::Movies), ) .handle(); assert_eq!( app.get_current_route(), - &( + ( ActiveRadarrBlock::EditMoviePrompt, Some(ActiveRadarrBlock::Movies), ) @@ -843,16 +843,16 @@ mod tests { app.push_navigation_stack(active_radarr_block.into()); EditMovieHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &active_radarr_block, - &Some(ActiveRadarrBlock::Movies), + active_radarr_block, + Some(ActiveRadarrBlock::Movies), ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditMoviePrompt.into() + ActiveRadarrBlock::EditMoviePrompt.into() ); if active_radarr_block == ActiveRadarrBlock::EditMoviePathInput @@ -888,12 +888,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::EditMoviePrompt.into()); app.push_navigation_stack(active_radarr_block.into()); - EditMovieHandler::with(&ESC_KEY, &mut app, &active_radarr_block, &None).handle(); + EditMovieHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); assert!(!app.should_ignore_quit_key); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::EditMoviePrompt.into() + ActiveRadarrBlock::EditMoviePrompt.into() ); } @@ -904,15 +904,9 @@ mod tests { app.data.radarr_data = create_test_radarr_data(); app.data.radarr_data.edit_movie_modal = Some(EditMovieModal::default()); - EditMovieHandler::with( - &ESC_KEY, - &mut app, - &ActiveRadarrBlock::EditMoviePrompt, - &None, - ) - .handle(); + EditMovieHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::EditMoviePrompt, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert!(app.data.radarr_data.edit_movie_modal.is_none()); assert!(!app.data.radarr_data.prompt_confirm); @@ -932,9 +926,9 @@ mod tests { app.data.radarr_data = create_test_radarr_data(); app.push_navigation_stack(active_radarr_block.into()); - EditMovieHandler::with(&ESC_KEY, &mut app, &active_radarr_block, &None).handle(); + EditMovieHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } } @@ -960,10 +954,10 @@ mod tests { }); EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::EditMoviePathInput, - &None, + ActiveRadarrBlock::EditMoviePathInput, + None, ) .handle(); @@ -989,10 +983,10 @@ mod tests { }); EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::EditMovieTagsInput, - &None, + ActiveRadarrBlock::EditMovieTagsInput, + None, ) .handle(); @@ -1015,10 +1009,10 @@ mod tests { app.data.radarr_data.edit_movie_modal = Some(EditMovieModal::default()); EditMovieHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::EditMoviePathInput, - &None, + ActiveRadarrBlock::EditMoviePathInput, + None, ) .handle(); @@ -1041,10 +1035,10 @@ mod tests { app.data.radarr_data.edit_movie_modal = Some(EditMovieModal::default()); EditMovieHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::EditMovieTagsInput, - &None, + ActiveRadarrBlock::EditMovieTagsInput, + None, ) .handle(); @@ -1075,14 +1069,14 @@ mod tests { .set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.confirm.key, + DEFAULT_KEYBINDINGS.confirm.key, &mut app, - &ActiveRadarrBlock::EditMoviePrompt, - &None, + ActiveRadarrBlock::EditMoviePrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert_eq!( app.data.radarr_data.prompt_confirm_action, Some(RadarrEvent::EditMovie(None)) @@ -1096,9 +1090,9 @@ mod tests { fn test_edit_movie_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if EDIT_MOVIE_BLOCKS.contains(&active_radarr_block) { - assert!(EditMovieHandler::accepts(&active_radarr_block)); + assert!(EditMovieHandler::accepts(active_radarr_block)); } else { - assert!(!EditMovieHandler::accepts(&active_radarr_block)); + assert!(!EditMovieHandler::accepts(active_radarr_block)); } }); } @@ -1109,10 +1103,10 @@ mod tests { app.is_loading = true; let handler = EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::EditMoviePrompt, - &None, + ActiveRadarrBlock::EditMoviePrompt, + None, ); assert!(!handler.is_ready()); @@ -1124,10 +1118,10 @@ mod tests { app.is_loading = false; let handler = EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::EditMoviePrompt, - &None, + ActiveRadarrBlock::EditMoviePrompt, + None, ); assert!(!handler.is_ready()); @@ -1140,10 +1134,10 @@ mod tests { app.data.radarr_data.edit_movie_modal = Some(EditMovieModal::default()); let handler = EditMovieHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::EditMoviePrompt, - &None, + ActiveRadarrBlock::EditMoviePrompt, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/library/library_handler_tests.rs b/src/handlers/radarr_handlers/library/library_handler_tests.rs index 22a4cd1..a955659 100644 --- a/src/handlers/radarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/library_handler_tests.rs @@ -53,7 +53,7 @@ mod tests { HorizontallyScrollableText )); - LibraryHandler::with(&key, &mut app, &ActiveRadarrBlock::Movies, &None).handle(); + LibraryHandler::with(key, &mut app, ActiveRadarrBlock::Movies, None).handle(); assert_str_eq!( app @@ -66,7 +66,7 @@ mod tests { "Test 1" ); - LibraryHandler::with(&key, &mut app, &ActiveRadarrBlock::Movies, &None).handle(); + LibraryHandler::with(key, &mut app, ActiveRadarrBlock::Movies, None).handle(); assert_str_eq!( app @@ -90,8 +90,7 @@ mod tests { if key == Key::Up { for i in (0..movie_field_vec.len()).rev() { - LibraryHandler::with(&key, &mut app, &ActiveRadarrBlock::MoviesSortPrompt, &None) - .handle(); + LibraryHandler::with(key, &mut app, ActiveRadarrBlock::MoviesSortPrompt, None).handle(); assert_eq!( app @@ -107,8 +106,7 @@ mod tests { } } else { for i in 0..movie_field_vec.len() { - LibraryHandler::with(&key, &mut app, &ActiveRadarrBlock::MoviesSortPrompt, &None) - .handle(); + LibraryHandler::with(key, &mut app, ActiveRadarrBlock::MoviesSortPrompt, None).handle(); assert_eq!( app @@ -158,10 +156,10 @@ mod tests { )); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); @@ -177,10 +175,10 @@ mod tests { ); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); @@ -207,10 +205,10 @@ mod tests { app.data.radarr_data.movies.search = Some("Test".into()); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::SearchMovie, - &None, + ActiveRadarrBlock::SearchMovie, + None, ) .handle(); @@ -228,10 +226,10 @@ mod tests { ); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::SearchMovie, - &None, + ActiveRadarrBlock::SearchMovie, + None, ) .handle(); @@ -260,10 +258,10 @@ mod tests { app.data.radarr_data.movies.filter = Some("Test".into()); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::FilterMovies, - &None, + ActiveRadarrBlock::FilterMovies, + None, ) .handle(); @@ -281,10 +279,10 @@ mod tests { ); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::FilterMovies, - &None, + ActiveRadarrBlock::FilterMovies, + None, ) .handle(); @@ -309,10 +307,10 @@ mod tests { app.data.radarr_data.movies.sorting(sort_options()); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::MoviesSortPrompt, - &None, + ActiveRadarrBlock::MoviesSortPrompt, + None, ) .handle(); @@ -329,10 +327,10 @@ mod tests { ); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::MoviesSortPrompt, - &None, + ActiveRadarrBlock::MoviesSortPrompt, + None, ) .handle(); @@ -392,9 +390,9 @@ mod tests { .movies .set_items(vec![Movie::default()]); - LibraryHandler::with(&DELETE_KEY, &mut app, &ActiveRadarrBlock::Movies, &None).handle(); + LibraryHandler::with(DELETE_KEY, &mut app, ActiveRadarrBlock::Movies, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } } @@ -411,18 +409,18 @@ mod tests { app.data.radarr_data.main_tabs.set_index(0); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &ActiveRadarrBlock::System.into() + ActiveRadarrBlock::System.into() ); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::System.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::System.into()); } #[rstest] @@ -432,20 +430,20 @@ mod tests { app.data.radarr_data.main_tabs.set_index(0); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::Collections.into() + ActiveRadarrBlock::Collections.into() ); } @@ -456,20 +454,20 @@ mod tests { let mut app = App::default(); LibraryHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::UpdateAllMoviesPrompt, - &None, + ActiveRadarrBlock::UpdateAllMoviesPrompt, + None, ) .handle(); assert!(app.data.radarr_data.prompt_confirm); LibraryHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::UpdateAllMoviesPrompt, - &None, + ActiveRadarrBlock::UpdateAllMoviesPrompt, + None, ) .handle(); @@ -482,10 +480,10 @@ mod tests { app.data.radarr_data.movies.search = Some("Test".into()); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::SearchMovie, - &None, + ActiveRadarrBlock::SearchMovie, + None, ) .handle(); @@ -503,10 +501,10 @@ mod tests { ); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::SearchMovie, - &None, + ActiveRadarrBlock::SearchMovie, + None, ) .handle(); @@ -530,10 +528,10 @@ mod tests { app.data.radarr_data.movies.filter = Some("Test".into()); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::FilterMovies, - &None, + ActiveRadarrBlock::FilterMovies, + None, ) .handle(); @@ -551,10 +549,10 @@ mod tests { ); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::FilterMovies, - &None, + ActiveRadarrBlock::FilterMovies, + None, ) .handle(); @@ -592,11 +590,11 @@ mod tests { .movies .set_items(vec![Movie::default()]); - LibraryHandler::with(&SUBMIT_KEY, &mut app, &ActiveRadarrBlock::Movies, &None).handle(); + LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::Movies, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::MovieDetails.into() + ActiveRadarrBlock::MovieDetails.into() ); } @@ -611,9 +609,9 @@ mod tests { .movies .set_items(vec![Movie::default()]); - LibraryHandler::with(&SUBMIT_KEY, &mut app, &ActiveRadarrBlock::Movies, &None).handle(); + LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::Movies, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } #[test] @@ -631,19 +629,13 @@ mod tests { )); app.data.radarr_data.movies.search = Some("Test 2".into()); - LibraryHandler::with( - &SUBMIT_KEY, - &mut app, - &ActiveRadarrBlock::SearchMovie, - &None, - ) - .handle(); + LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::SearchMovie, None).handle(); assert_str_eq!( app.data.radarr_data.movies.current_selection().title.text, "Test 2" ); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } #[test] @@ -661,13 +653,7 @@ mod tests { )); app.data.radarr_data.movies.search = Some("Test 5".into()); - LibraryHandler::with( - &SUBMIT_KEY, - &mut app, - &ActiveRadarrBlock::SearchMovie, - &None, - ) - .handle(); + LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::SearchMovie, None).handle(); assert_str_eq!( app.data.radarr_data.movies.current_selection().title.text, @@ -675,7 +661,7 @@ mod tests { ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::SearchMovieError.into() + ActiveRadarrBlock::SearchMovieError.into() ); } @@ -699,19 +685,13 @@ mod tests { )); app.data.radarr_data.movies.search = Some("Test 2".into()); - LibraryHandler::with( - &SUBMIT_KEY, - &mut app, - &ActiveRadarrBlock::SearchMovie, - &None, - ) - .handle(); + LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::SearchMovie, None).handle(); assert_str_eq!( app.data.radarr_data.movies.current_selection().title.text, "Test 2" ); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } #[test] @@ -729,13 +709,7 @@ mod tests { )); app.data.radarr_data.movies.filter = Some("Test".into()); - LibraryHandler::with( - &SUBMIT_KEY, - &mut app, - &ActiveRadarrBlock::FilterMovies, - &None, - ) - .handle(); + LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::FilterMovies, None).handle(); assert!(app.data.radarr_data.movies.filtered_items.is_some()); assert!(!app.should_ignore_quit_key); @@ -754,7 +728,7 @@ mod tests { app.data.radarr_data.movies.current_selection().title.text, "Test 1" ); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } #[test] @@ -772,19 +746,13 @@ mod tests { )); app.data.radarr_data.movies.filter = Some("Test 5".into()); - LibraryHandler::with( - &SUBMIT_KEY, - &mut app, - &ActiveRadarrBlock::FilterMovies, - &None, - ) - .handle(); + LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::FilterMovies, None).handle(); assert!(!app.should_ignore_quit_key); assert!(app.data.radarr_data.movies.filtered_items.is_none()); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::FilterMoviesError.into() + ActiveRadarrBlock::FilterMoviesError.into() ); } @@ -801,10 +769,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::UpdateAllMoviesPrompt.into()); LibraryHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::UpdateAllMoviesPrompt, - &None, + ActiveRadarrBlock::UpdateAllMoviesPrompt, + None, ) .handle(); @@ -813,7 +781,7 @@ mod tests { app.data.radarr_data.prompt_confirm_action, Some(RadarrEvent::UpdateAllMovies) ); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } #[test] @@ -828,16 +796,16 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::UpdateAllMoviesPrompt.into()); LibraryHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::UpdateAllMoviesPrompt, - &None, + ActiveRadarrBlock::UpdateAllMoviesPrompt, + None, ) .handle(); assert!(!app.data.radarr_data.prompt_confirm); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } #[test] @@ -854,14 +822,14 @@ mod tests { expected_vec.reverse(); LibraryHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::MoviesSortPrompt, - &None, + ActiveRadarrBlock::MoviesSortPrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert_eq!(app.data.radarr_data.movies.items, expected_vec); } } @@ -889,9 +857,9 @@ mod tests { app.data.radarr_data = create_test_radarr_data(); app.data.radarr_data.movies.search = Some("Test".into()); - LibraryHandler::with(&ESC_KEY, &mut app, &active_radarr_block, &None).handle(); + LibraryHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert!(!app.should_ignore_quit_key); assert_eq!(app.data.radarr_data.movies.search, None); } @@ -913,9 +881,9 @@ mod tests { ..StatefulTable::default() }; - LibraryHandler::with(&ESC_KEY, &mut app, &active_radarr_block, &None).handle(); + LibraryHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert!(!app.should_ignore_quit_key); assert_eq!(app.data.radarr_data.movies.filter, None); assert_eq!(app.data.radarr_data.movies.filtered_items, None); @@ -930,14 +898,14 @@ mod tests { app.data.radarr_data.prompt_confirm = true; LibraryHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::UpdateAllMoviesPrompt, - &None, + ActiveRadarrBlock::UpdateAllMoviesPrompt, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert!(!app.data.radarr_data.prompt_confirm); } @@ -947,15 +915,9 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(ActiveRadarrBlock::MoviesSortPrompt.into()); - LibraryHandler::with( - &ESC_KEY, - &mut app, - &ActiveRadarrBlock::MoviesSortPrompt, - &None, - ) - .handle(); + LibraryHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::MoviesSortPrompt, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } #[rstest] @@ -974,9 +936,9 @@ mod tests { ..StatefulTable::default() }; - LibraryHandler::with(&ESC_KEY, &mut app, &ActiveRadarrBlock::Movies, &None).handle(); + LibraryHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::Movies, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert!(app.error.text.is_empty()); assert_eq!(app.data.radarr_data.movies.search, None); assert_eq!(app.data.radarr_data.movies.filter, None); @@ -1012,16 +974,16 @@ mod tests { .set_items(vec![Movie::default()]); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.search.key, + DEFAULT_KEYBINDINGS.search.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::SearchMovie.into() + ActiveRadarrBlock::SearchMovie.into() ); assert!(app.should_ignore_quit_key); assert_eq!( @@ -1042,14 +1004,14 @@ mod tests { .set_items(vec![Movie::default()]); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.search.key, + DEFAULT_KEYBINDINGS.search.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert!(!app.should_ignore_quit_key); assert_eq!(app.data.radarr_data.movies.search, None); } @@ -1064,16 +1026,16 @@ mod tests { .set_items(vec![Movie::default()]); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.filter.key, + DEFAULT_KEYBINDINGS.filter.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::FilterMovies.into() + ActiveRadarrBlock::FilterMovies.into() ); assert!(app.should_ignore_quit_key); assert!(app.data.radarr_data.movies.filter.is_some()); @@ -1091,14 +1053,14 @@ mod tests { .set_items(vec![Movie::default()]); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.filter.key, + DEFAULT_KEYBINDINGS.filter.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert!(!app.should_ignore_quit_key); assert!(app.data.radarr_data.movies.filter.is_none()); } @@ -1117,16 +1079,16 @@ mod tests { app.data.radarr_data.movies.filter = Some("Test".into()); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.filter.key, + DEFAULT_KEYBINDINGS.filter.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::FilterMovies.into() + ActiveRadarrBlock::FilterMovies.into() ); assert!(app.should_ignore_quit_key); assert_eq!( @@ -1147,16 +1109,16 @@ mod tests { .set_items(vec![Movie::default()]); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.add.key, + DEFAULT_KEYBINDINGS.add.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddMovieSearchInput.into() + ActiveRadarrBlock::AddMovieSearchInput.into() ); assert!(app.should_ignore_quit_key); assert!(app.data.radarr_data.add_movie_search.is_some()); @@ -1174,14 +1136,14 @@ mod tests { .set_items(vec![Movie::default()]); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.add.key, + DEFAULT_KEYBINDINGS.add.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert!(!app.should_ignore_quit_key); assert!(app.data.radarr_data.add_movie_search.is_none()); } @@ -1207,14 +1169,14 @@ mod tests { .set_items(vec![Movie::default()]); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.edit.key, + DEFAULT_KEYBINDINGS.edit.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert!(app.data.radarr_data.edit_movie_modal.is_none()); } @@ -1228,16 +1190,16 @@ mod tests { .set_items(vec![Movie::default()]); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.update.key, + DEFAULT_KEYBINDINGS.update.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::UpdateAllMoviesPrompt.into() + ActiveRadarrBlock::UpdateAllMoviesPrompt.into() ); } @@ -1253,14 +1215,14 @@ mod tests { .set_items(vec![Movie::default()]); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.update.key, + DEFAULT_KEYBINDINGS.update.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } #[test] @@ -1274,14 +1236,14 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert!(app.should_refresh); } @@ -1297,14 +1259,14 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert!(!app.should_refresh); } @@ -1319,10 +1281,10 @@ mod tests { .set_items(vec![Movie::default()]); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::SearchMovie, - &None, + ActiveRadarrBlock::SearchMovie, + None, ) .handle(); @@ -1343,10 +1305,10 @@ mod tests { app.data.radarr_data.movies.filter = Some("Test".into()); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::FilterMovies, - &None, + ActiveRadarrBlock::FilterMovies, + None, ) .handle(); @@ -1367,10 +1329,10 @@ mod tests { app.data.radarr_data.movies.search = Some(HorizontallyScrollableText::default()); LibraryHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::SearchMovie, - &None, + ActiveRadarrBlock::SearchMovie, + None, ) .handle(); @@ -1391,10 +1353,10 @@ mod tests { app.data.radarr_data.movies.filter = Some(HorizontallyScrollableText::default()); LibraryHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::FilterMovies, - &None, + ActiveRadarrBlock::FilterMovies, + None, ) .handle(); @@ -1414,16 +1376,16 @@ mod tests { .set_items(vec![Movie::default()]); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.sort.key, + DEFAULT_KEYBINDINGS.sort.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::MoviesSortPrompt.into() + ActiveRadarrBlock::MoviesSortPrompt.into() ); assert_eq!( app.data.radarr_data.movies.sort.as_ref().unwrap().items, @@ -1444,14 +1406,14 @@ mod tests { .set_items(vec![Movie::default()]); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.sort.key, + DEFAULT_KEYBINDINGS.sort.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert!(app.data.radarr_data.movies.sort.is_none()); } @@ -1467,10 +1429,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::UpdateAllMoviesPrompt.into()); LibraryHandler::with( - &DEFAULT_KEYBINDINGS.confirm.key, + DEFAULT_KEYBINDINGS.confirm.key, &mut app, - &ActiveRadarrBlock::UpdateAllMoviesPrompt, - &None, + ActiveRadarrBlock::UpdateAllMoviesPrompt, + None, ) .handle(); @@ -1479,7 +1441,7 @@ mod tests { app.data.radarr_data.prompt_confirm_action, Some(RadarrEvent::UpdateAllMovies) ); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } } @@ -1744,9 +1706,9 @@ mod tests { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if library_handler_blocks.contains(&active_radarr_block) { - assert!(LibraryHandler::accepts(&active_radarr_block)); + assert!(LibraryHandler::accepts(active_radarr_block)); } else { - assert!(!LibraryHandler::accepts(&active_radarr_block)); + assert!(!LibraryHandler::accepts(active_radarr_block)); } }); } @@ -1757,10 +1719,10 @@ mod tests { app.is_loading = true; let handler = LibraryHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ); assert!(!handler.is_ready()); @@ -1772,10 +1734,10 @@ mod tests { app.is_loading = false; let handler = LibraryHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ); assert!(!handler.is_ready()); @@ -1792,10 +1754,10 @@ mod tests { .set_items(vec![Movie::default()]); let handler = LibraryHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Movies, - &None, + ActiveRadarrBlock::Movies, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/library/mod.rs b/src/handlers/radarr_handlers/library/mod.rs index acb88b1..2d73248 100644 --- a/src/handlers/radarr_handlers/library/mod.rs +++ b/src/handlers/radarr_handlers/library/mod.rs @@ -27,10 +27,10 @@ mod movie_details_handler; mod library_handler_tests; pub(super) struct LibraryHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, 'b> { @@ -54,19 +54,19 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' } } - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { + fn accepts(active_block: ActiveRadarrBlock) -> bool { AddMovieHandler::accepts(active_block) || DeleteMovieHandler::accepts(active_block) || EditMovieHandler::accepts(active_block) || MovieDetailsHandler::accepts(active_block) - || LIBRARY_BLOCKS.contains(active_block) + || LIBRARY_BLOCKS.contains(&active_block) } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_block: ActiveRadarrBlock, + context: Option, ) -> LibraryHandler<'a, 'b> { LibraryHandler { key, @@ -76,7 +76,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -189,7 +189,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' } fn handle_delete(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::Movies { + if self.active_radarr_block == ActiveRadarrBlock::Movies { self .app .push_navigation_stack(ActiveRadarrBlock::DeleteMoviePrompt.into()); @@ -317,14 +317,14 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' let key = self.key; match self.active_radarr_block { ActiveRadarrBlock::Movies => match self.key { - _ if *key == DEFAULT_KEYBINDINGS.search.key => { + _ if key == DEFAULT_KEYBINDINGS.search.key => { self .app .push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); self.app.data.radarr_data.movies.search = Some(HorizontallyScrollableText::default()); self.app.should_ignore_quit_key = true; } - _ if *key == DEFAULT_KEYBINDINGS.filter.key => { + _ if key == DEFAULT_KEYBINDINGS.filter.key => { self .app .push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); @@ -332,7 +332,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' self.app.data.radarr_data.movies.filter = Some(HorizontallyScrollableText::default()); self.app.should_ignore_quit_key = true; } - _ if *key == DEFAULT_KEYBINDINGS.edit.key => { + _ if key == DEFAULT_KEYBINDINGS.edit.key => { self.app.push_navigation_stack( ( ActiveRadarrBlock::EditMoviePrompt, @@ -344,22 +344,22 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' self.app.data.radarr_data.selected_block = BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS); } - _ if *key == DEFAULT_KEYBINDINGS.add.key => { + _ if key == DEFAULT_KEYBINDINGS.add.key => { self .app .push_navigation_stack(ActiveRadarrBlock::AddMovieSearchInput.into()); self.app.data.radarr_data.add_movie_search = Some(HorizontallyScrollableText::default()); self.app.should_ignore_quit_key = true; } - _ if *key == DEFAULT_KEYBINDINGS.update.key => { + _ if key == DEFAULT_KEYBINDINGS.update.key => { self .app .push_navigation_stack(ActiveRadarrBlock::UpdateAllMoviesPrompt.into()); } - _ if *key == DEFAULT_KEYBINDINGS.refresh.key => { + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { self.app.should_refresh = true; } - _ if *key == DEFAULT_KEYBINDINGS.sort.key => { + _ if key == DEFAULT_KEYBINDINGS.sort.key => { self .app .data @@ -387,7 +387,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' ) } ActiveRadarrBlock::UpdateAllMoviesPrompt => { - if *key == DEFAULT_KEYBINDINGS.confirm.key { + if key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateAllMovies); diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index 097599a..c08846a 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -18,22 +18,22 @@ use crate::network::radarr_network::RadarrEvent; mod movie_details_handler_tests; pub(super) struct MovieDetailsHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + _context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler<'a, 'b> { - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { - MOVIE_DETAILS_BLOCKS.contains(active_block) + fn accepts(active_block: ActiveRadarrBlock) -> bool { + MOVIE_DETAILS_BLOCKS.contains(&active_block) } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_block: ActiveRadarrBlock, + _context: Option, ) -> MovieDetailsHandler<'a, 'b> { MovieDetailsHandler { key, @@ -43,7 +43,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -334,16 +334,16 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< | ActiveRadarrBlock::Cast | ActiveRadarrBlock::Crew | ActiveRadarrBlock::ManualSearch => match self.key { - _ if *self.key == DEFAULT_KEYBINDINGS.left.key => { + _ if self.key == DEFAULT_KEYBINDINGS.left.key => { self.app.data.radarr_data.movie_info_tabs.previous(); self.app.pop_and_push_navigation_stack( - *self.app.data.radarr_data.movie_info_tabs.get_active_route(), + self.app.data.radarr_data.movie_info_tabs.get_active_route(), ); } - _ if *self.key == DEFAULT_KEYBINDINGS.right.key => { + _ if self.key == DEFAULT_KEYBINDINGS.right.key => { self.app.data.radarr_data.movie_info_tabs.next(); self.app.pop_and_push_navigation_stack( - *self.app.data.radarr_data.movie_info_tabs.get_active_route(), + self.app.data.radarr_data.movie_info_tabs.get_active_route(), ); } _ => (), @@ -425,23 +425,23 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< fn handle_char_key_event(&mut self) { let key = self.key; - match *self.active_radarr_block { + match self.active_radarr_block { ActiveRadarrBlock::MovieDetails | ActiveRadarrBlock::MovieHistory | ActiveRadarrBlock::FileInfo | ActiveRadarrBlock::Cast | ActiveRadarrBlock::Crew | ActiveRadarrBlock::ManualSearch => match self.key { - _ if *key == DEFAULT_KEYBINDINGS.search.key => { + _ if key == DEFAULT_KEYBINDINGS.search.key => { self .app .push_navigation_stack(ActiveRadarrBlock::AutomaticallySearchMoviePrompt.into()); } - _ if *key == DEFAULT_KEYBINDINGS.edit.key => { + _ if key == DEFAULT_KEYBINDINGS.edit.key => { self.app.push_navigation_stack( ( ActiveRadarrBlock::EditMoviePrompt, - Some(*self.active_radarr_block), + Some(self.active_radarr_block), ) .into(), ); @@ -449,17 +449,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< self.app.data.radarr_data.selected_block = BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS); } - _ if *key == DEFAULT_KEYBINDINGS.update.key => { + _ if key == DEFAULT_KEYBINDINGS.update.key => { self .app .push_navigation_stack(ActiveRadarrBlock::UpdateAndScanPrompt.into()); } - _ if *key == DEFAULT_KEYBINDINGS.refresh.key => { + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { self .app - .pop_and_push_navigation_stack((*self.active_radarr_block).into()); + .pop_and_push_navigation_stack((self.active_radarr_block).into()); } - _ if *key == DEFAULT_KEYBINDINGS.sort.key => { + _ if key == DEFAULT_KEYBINDINGS.sort.key => { self .app .data @@ -476,7 +476,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< _ => (), }, ActiveRadarrBlock::AutomaticallySearchMoviePrompt => { - if *key == DEFAULT_KEYBINDINGS.confirm.key { + if key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::TriggerAutomaticSearch(None)); @@ -485,7 +485,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< } } ActiveRadarrBlock::UpdateAndScanPrompt => { - if *key == DEFAULT_KEYBINDINGS.confirm.key { + if key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateAndScan(None)); @@ -493,7 +493,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< } } ActiveRadarrBlock::ManualSearchConfirmPrompt => { - if *key == DEFAULT_KEYBINDINGS.confirm.key { + if key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::DownloadRelease(None)); diff --git a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs index e789ad4..7f84f11 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs @@ -40,10 +40,10 @@ mod tests { }); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.up.key, + DEFAULT_KEYBINDINGS.up.key, &mut app, - &ActiveRadarrBlock::MovieDetails, - &None, + ActiveRadarrBlock::MovieDetails, + None, ) .handle(); @@ -60,10 +60,10 @@ mod tests { ); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.down.key, + DEFAULT_KEYBINDINGS.down.key, &mut app, - &ActiveRadarrBlock::MovieDetails, - &None, + ActiveRadarrBlock::MovieDetails, + None, ) .handle(); @@ -90,10 +90,10 @@ mod tests { }); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.up.key, + DEFAULT_KEYBINDINGS.up.key, &mut app, - &ActiveRadarrBlock::MovieDetails, - &None, + ActiveRadarrBlock::MovieDetails, + None, ) .handle(); @@ -110,10 +110,10 @@ mod tests { ); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.down.key, + DEFAULT_KEYBINDINGS.down.key, &mut app, - &ActiveRadarrBlock::MovieDetails, - &None, + ActiveRadarrBlock::MovieDetails, + None, ) .handle(); @@ -145,7 +145,7 @@ mod tests { )); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::MovieHistory, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::MovieHistory, None).handle(); assert_str_eq!( app @@ -161,7 +161,7 @@ mod tests { "Test 2" ); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::MovieHistory, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::MovieHistory, None).handle(); assert_str_eq!( app @@ -194,7 +194,7 @@ mod tests { )); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::MovieHistory, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::MovieHistory, None).handle(); assert_str_eq!( app @@ -210,7 +210,7 @@ mod tests { "Test 1" ); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::MovieHistory, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::MovieHistory, None).handle(); assert_str_eq!( app @@ -238,7 +238,7 @@ mod tests { .set_items(simple_stateful_iterable_vec!(Credit, String, person_name)); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::Cast, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Cast, None).handle(); assert_str_eq!( app @@ -253,7 +253,7 @@ mod tests { "Test 2" ); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::Cast, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Cast, None).handle(); assert_str_eq!( app @@ -281,7 +281,7 @@ mod tests { .set_items(simple_stateful_iterable_vec!(Credit, String, person_name)); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::Cast, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Cast, None).handle(); assert_str_eq!( app @@ -296,7 +296,7 @@ mod tests { "Test 1" ); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::Cast, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Cast, None).handle(); assert_str_eq!( app @@ -323,7 +323,7 @@ mod tests { .set_items(simple_stateful_iterable_vec!(Credit, String, person_name)); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::Crew, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Crew, None).handle(); assert_str_eq!( app @@ -338,7 +338,7 @@ mod tests { "Test 2" ); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::Crew, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Crew, None).handle(); assert_str_eq!( app @@ -366,7 +366,7 @@ mod tests { .set_items(simple_stateful_iterable_vec!(Credit, String, person_name)); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::Crew, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Crew, None).handle(); assert_str_eq!( app @@ -381,7 +381,7 @@ mod tests { "Test 1" ); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::Crew, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Crew, None).handle(); assert_str_eq!( app @@ -411,7 +411,7 @@ mod tests { )); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::ManualSearch, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::ManualSearch, None).handle(); assert_str_eq!( app @@ -427,7 +427,7 @@ mod tests { "Test 2" ); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::ManualSearch, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::ManualSearch, None).handle(); assert_str_eq!( app @@ -459,7 +459,7 @@ mod tests { )); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::ManualSearch, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::ManualSearch, None).handle(); assert_str_eq!( app @@ -475,7 +475,7 @@ mod tests { "Test 1" ); - MovieDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::ManualSearch, &None).handle(); + MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::ManualSearch, None).handle(); assert_str_eq!( app @@ -505,10 +505,10 @@ mod tests { if key == Key::Up { for i in (0..release_field_vec.len()).rev() { MovieDetailsHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::ManualSearchSortPrompt, - &None, + ActiveRadarrBlock::ManualSearchSortPrompt, + None, ) .handle(); @@ -530,10 +530,10 @@ mod tests { } else { for i in 0..release_field_vec.len() { MovieDetailsHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::ManualSearchSortPrompt, - &None, + ActiveRadarrBlock::ManualSearchSortPrompt, + None, ) .handle(); @@ -572,10 +572,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(movie_details_modal); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::MovieDetails, - &None, + ActiveRadarrBlock::MovieDetails, + None, ) .handle(); @@ -592,10 +592,10 @@ mod tests { ); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::MovieDetails, - &None, + ActiveRadarrBlock::MovieDetails, + None, ) .handle(); @@ -623,10 +623,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(movie_details_modal); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::MovieDetails, - &None, + ActiveRadarrBlock::MovieDetails, + None, ) .handle(); @@ -643,10 +643,10 @@ mod tests { ); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::MovieDetails, - &None, + ActiveRadarrBlock::MovieDetails, + None, ) .handle(); @@ -677,10 +677,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(movie_details_modal); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::MovieHistory, - &None, + ActiveRadarrBlock::MovieHistory, + None, ) .handle(); @@ -699,10 +699,10 @@ mod tests { ); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::MovieHistory, - &None, + ActiveRadarrBlock::MovieHistory, + None, ) .handle(); @@ -736,10 +736,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(movie_details_modal); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::MovieHistory, - &None, + ActiveRadarrBlock::MovieHistory, + None, ) .handle(); @@ -758,10 +758,10 @@ mod tests { ); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::MovieHistory, - &None, + ActiveRadarrBlock::MovieHistory, + None, ) .handle(); @@ -790,10 +790,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(movie_details_modal); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::Cast, - &None, + ActiveRadarrBlock::Cast, + None, ) .handle(); @@ -811,10 +811,10 @@ mod tests { ); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::Cast, - &None, + ActiveRadarrBlock::Cast, + None, ) .handle(); @@ -843,10 +843,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(movie_details_modal); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::Cast, - &None, + ActiveRadarrBlock::Cast, + None, ) .handle(); @@ -864,10 +864,10 @@ mod tests { ); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::Cast, - &None, + ActiveRadarrBlock::Cast, + None, ) .handle(); @@ -895,10 +895,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(movie_details_modal); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::Crew, - &None, + ActiveRadarrBlock::Crew, + None, ) .handle(); @@ -916,10 +916,10 @@ mod tests { ); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::Crew, - &None, + ActiveRadarrBlock::Crew, + None, ) .handle(); @@ -948,10 +948,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(movie_details_modal); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::Crew, - &None, + ActiveRadarrBlock::Crew, + None, ) .handle(); @@ -969,10 +969,10 @@ mod tests { ); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::Crew, - &None, + ActiveRadarrBlock::Crew, + None, ) .handle(); @@ -1003,10 +1003,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(movie_details_modal); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::ManualSearch, - &None, + ActiveRadarrBlock::ManualSearch, + None, ) .handle(); @@ -1025,10 +1025,10 @@ mod tests { ); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::ManualSearch, - &None, + ActiveRadarrBlock::ManualSearch, + None, ) .handle(); @@ -1061,10 +1061,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(movie_details_modal); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::ManualSearch, - &None, + ActiveRadarrBlock::ManualSearch, + None, ) .handle(); @@ -1083,10 +1083,10 @@ mod tests { ); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::ManualSearch, - &None, + ActiveRadarrBlock::ManualSearch, + None, ) .handle(); @@ -1114,10 +1114,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(movie_details_modal); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::ManualSearchSortPrompt, - &None, + ActiveRadarrBlock::ManualSearchSortPrompt, + None, ) .handle(); @@ -1137,10 +1137,10 @@ mod tests { ); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::ManualSearchSortPrompt, - &None, + ActiveRadarrBlock::ManualSearchSortPrompt, + None, ) .handle(); @@ -1179,11 +1179,11 @@ mod tests { ) { let mut app = App::default(); - MovieDetailsHandler::with(&key, &mut app, &active_radarr_block, &None).handle(); + MovieDetailsHandler::with(key, &mut app, active_radarr_block, None).handle(); assert!(app.data.radarr_data.prompt_confirm); - MovieDetailsHandler::with(&key, &mut app, &active_radarr_block, &None).handle(); + MovieDetailsHandler::with(key, &mut app, active_radarr_block, None).handle(); assert!(!app.data.radarr_data.prompt_confirm); } @@ -1212,23 +1212,21 @@ mod tests { .position(|tab_route| tab_route.route == right_block.into()) .unwrap_or_default(); - MovieDetailsHandler::with(&DEFAULT_KEYBINDINGS.left.key, &mut app, &right_block, &None) - .handle(); + MovieDetailsHandler::with(DEFAULT_KEYBINDINGS.left.key, &mut app, right_block, None).handle(); assert_eq!( app.get_current_route(), app.data.radarr_data.movie_info_tabs.get_active_route() ); - assert_eq!(app.get_current_route(), &left_block.into()); + assert_eq!(app.get_current_route(), left_block.into()); - MovieDetailsHandler::with(&DEFAULT_KEYBINDINGS.right.key, &mut app, &left_block, &None) - .handle(); + MovieDetailsHandler::with(DEFAULT_KEYBINDINGS.right.key, &mut app, left_block, None).handle(); assert_eq!( app.get_current_route(), app.data.radarr_data.movie_info_tabs.get_active_route() ); - assert_eq!(app.get_current_route(), &right_block.into()); + assert_eq!(app.get_current_route(), right_block.into()); } } @@ -1256,17 +1254,12 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(modal); app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into()); - MovieDetailsHandler::with( - &SUBMIT_KEY, - &mut app, - &ActiveRadarrBlock::ManualSearch, - &None, - ) - .handle(); + MovieDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::ManualSearch, None) + .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::ManualSearchConfirmPrompt.into() + ActiveRadarrBlock::ManualSearchConfirmPrompt.into() ); } @@ -1280,17 +1273,12 @@ mod tests { }); app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into()); - MovieDetailsHandler::with( - &SUBMIT_KEY, - &mut app, - &ActiveRadarrBlock::ManualSearch, - &None, - ) - .handle(); + MovieDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::ManualSearch, None) + .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::ManualSearch.into() + ActiveRadarrBlock::ManualSearch.into() ); } @@ -1320,12 +1308,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::MovieDetails.into()); app.push_navigation_stack(prompt_block.into()); - MovieDetailsHandler::with(&SUBMIT_KEY, &mut app, &prompt_block, &None).handle(); + MovieDetailsHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); assert!(app.data.radarr_data.prompt_confirm); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::MovieDetails.into() + ActiveRadarrBlock::MovieDetails.into() ); assert_eq!( app.data.radarr_data.prompt_confirm_action, @@ -1350,12 +1338,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::MovieDetails.into()); app.push_navigation_stack(prompt_block.into()); - MovieDetailsHandler::with(&SUBMIT_KEY, &mut app, &prompt_block, &None).handle(); + MovieDetailsHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); assert!(!app.data.radarr_data.prompt_confirm); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::MovieDetails.into() + ActiveRadarrBlock::MovieDetails.into() ); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); } @@ -1375,16 +1363,16 @@ mod tests { expected_vec.reverse(); MovieDetailsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::ManualSearchSortPrompt, - &None, + ActiveRadarrBlock::ManualSearchSortPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::ManualSearch.into() + ActiveRadarrBlock::ManualSearch.into() ); assert_eq!( app @@ -1430,9 +1418,9 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(active_radarr_block.into()); - MovieDetailsHandler::with(&ESC_KEY, &mut app, &active_radarr_block, &None).handle(); + MovieDetailsHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert_movie_info_tabs_reset!(app.data.radarr_data); } @@ -1453,10 +1441,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(prompt_block.into()); - MovieDetailsHandler::with(&ESC_KEY, &mut app, &prompt_block, &None).handle(); + MovieDetailsHandler::with(ESC_KEY, &mut app, prompt_block, None).handle(); assert!(!app.data.radarr_data.prompt_confirm); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } } @@ -1508,16 +1496,16 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(modal); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.search.key, + DEFAULT_KEYBINDINGS.search.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AutomaticallySearchMoviePrompt.into() + ActiveRadarrBlock::AutomaticallySearchMoviePrompt.into() ); } @@ -1542,14 +1530,14 @@ mod tests { }); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.search.key, + DEFAULT_KEYBINDINGS.search.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); - assert_eq!(app.get_current_route(), &active_radarr_block.into()); + assert_eq!(app.get_current_route(), active_radarr_block.into()); } #[test] @@ -1560,16 +1548,16 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(modal); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.sort.key, + DEFAULT_KEYBINDINGS.sort.key, &mut app, - &ActiveRadarrBlock::ManualSearch, - &None, + ActiveRadarrBlock::ManualSearch, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::ManualSearchSortPrompt.into() + ActiveRadarrBlock::ManualSearchSortPrompt.into() ); assert_eq!( app @@ -1608,16 +1596,16 @@ mod tests { }); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.sort.key, + DEFAULT_KEYBINDINGS.sort.key, &mut app, - &ActiveRadarrBlock::ManualSearch, - &None, + ActiveRadarrBlock::ManualSearch, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::ManualSearch.into() + ActiveRadarrBlock::ManualSearch.into() ); } @@ -1661,14 +1649,14 @@ mod tests { }); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.edit.key, + DEFAULT_KEYBINDINGS.edit.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); - assert_eq!(app.get_current_route(), &active_radarr_block.into()); + assert_eq!(app.get_current_route(), active_radarr_block.into()); assert!(app.data.radarr_data.edit_movie_modal.is_none()); } @@ -1700,16 +1688,16 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(modal); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.update.key, + DEFAULT_KEYBINDINGS.update.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::UpdateAndScanPrompt.into() + ActiveRadarrBlock::UpdateAndScanPrompt.into() ); } @@ -1734,14 +1722,14 @@ mod tests { }); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.update.key, + DEFAULT_KEYBINDINGS.update.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); - assert_eq!(app.get_current_route(), &active_radarr_block.into()); + assert_eq!(app.get_current_route(), active_radarr_block.into()); } #[rstest] @@ -1772,14 +1760,14 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(modal); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); - assert_eq!(app.get_current_route(), &active_radarr_block.into()); + assert_eq!(app.get_current_route(), active_radarr_block.into()); assert!(app.is_routing); } @@ -1804,14 +1792,14 @@ mod tests { }); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); - assert_eq!(app.get_current_route(), &active_radarr_block.into()); + assert_eq!(app.get_current_route(), active_radarr_block.into()); assert!(app.is_routing); } @@ -1841,17 +1829,17 @@ mod tests { app.push_navigation_stack(prompt_block.into()); MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.confirm.key, + DEFAULT_KEYBINDINGS.confirm.key, &mut app, - &prompt_block, - &None, + prompt_block, + None, ) .handle(); assert!(app.data.radarr_data.prompt_confirm); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::MovieDetails.into() + ActiveRadarrBlock::MovieDetails.into() ); assert_eq!( app.data.radarr_data.prompt_confirm_action, @@ -2025,9 +2013,9 @@ mod tests { fn test_movie_details_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if MOVIE_DETAILS_BLOCKS.contains(&active_radarr_block) { - assert!(MovieDetailsHandler::accepts(&active_radarr_block)); + assert!(MovieDetailsHandler::accepts(active_radarr_block)); } else { - assert!(!MovieDetailsHandler::accepts(&active_radarr_block)); + assert!(!MovieDetailsHandler::accepts(active_radarr_block)); } }); } @@ -2062,10 +2050,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(modal); let handler = MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &movie_details_block, - &None, + movie_details_block, + None, ); assert!(!handler.is_ready()); @@ -2078,10 +2066,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal::default()); let handler = MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::MovieDetails, - &None, + ActiveRadarrBlock::MovieDetails, + None, ); assert!(!handler.is_ready()); @@ -2097,10 +2085,10 @@ mod tests { }); let handler = MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::MovieDetails, - &None, + ActiveRadarrBlock::MovieDetails, + None, ); assert!(handler.is_ready()); @@ -2117,10 +2105,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(modal); let handler = MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::MovieHistory, - &None, + ActiveRadarrBlock::MovieHistory, + None, ); assert!(handler.is_ready()); @@ -2135,10 +2123,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(modal); let handler = MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Cast, - &None, + ActiveRadarrBlock::Cast, + None, ); assert!(handler.is_ready()); @@ -2153,10 +2141,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(modal); let handler = MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::Crew, - &None, + ActiveRadarrBlock::Crew, + None, ); assert!(handler.is_ready()); @@ -2173,10 +2161,10 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(modal); let handler = MovieDetailsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::ManualSearch, - &None, + ActiveRadarrBlock::ManualSearch, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/mod.rs b/src/handlers/radarr_handlers/mod.rs index 843f2c4..1f14d74 100644 --- a/src/handlers/radarr_handlers/mod.rs +++ b/src/handlers/radarr_handlers/mod.rs @@ -27,10 +27,10 @@ mod radarr_handler_tests; mod radarr_handler_test_utils; pub(super) struct RadarrHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RadarrHandler<'a, 'b> { @@ -63,15 +63,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RadarrHandler<'a, 'b } } - fn accepts(_active_block: &'a ActiveRadarrBlock) -> bool { + fn accepts(_active_block: ActiveRadarrBlock) -> bool { true } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_block: ActiveRadarrBlock, + context: Option, ) -> RadarrHandler<'a, 'b> { RadarrHandler { key, @@ -81,7 +81,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RadarrHandler<'a, 'b } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -108,16 +108,16 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RadarrHandler<'a, 'b fn handle_char_key_event(&mut self) {} } -pub fn handle_change_tab_left_right_keys(app: &mut App<'_>, key: &Key) { +pub fn handle_change_tab_left_right_keys(app: &mut App<'_>, key: Key) { let key_ref = key; match key_ref { - _ if *key == DEFAULT_KEYBINDINGS.left.key => { + _ if key == DEFAULT_KEYBINDINGS.left.key => { app.data.radarr_data.main_tabs.previous(); - app.pop_and_push_navigation_stack(*app.data.radarr_data.main_tabs.get_active_route()); + app.pop_and_push_navigation_stack(app.data.radarr_data.main_tabs.get_active_route()); } - _ if *key == DEFAULT_KEYBINDINGS.right.key => { + _ if key == DEFAULT_KEYBINDINGS.right.key => { app.data.radarr_data.main_tabs.next(); - app.pop_and_push_navigation_stack(*app.data.radarr_data.main_tabs.get_active_route()); + app.pop_and_push_navigation_stack(app.data.radarr_data.main_tabs.get_active_route()); } _ => (), } diff --git a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs index 88d6795..afb6761 100644 --- a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs +++ b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs @@ -23,15 +23,15 @@ mod utils { }]); app.data.radarr_data = radarr_data; - $handler::with(&DEFAULT_KEYBINDINGS.edit.key, &mut app, &$block, &None).handle(); + $handler::with(DEFAULT_KEYBINDINGS.edit.key, &mut app, $block, None).handle(); assert_eq!( app.get_current_route(), - &(ActiveRadarrBlock::EditMoviePrompt, Some($context)).into() + (ActiveRadarrBlock::EditMoviePrompt, Some($context)).into() ); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::EditMovieToggleMonitored + ActiveRadarrBlock::EditMovieToggleMonitored ); assert_eq!( app @@ -137,15 +137,15 @@ mod utils { }]); app.data.radarr_data = radarr_data; - $handler::with(&DEFAULT_KEYBINDINGS.edit.key, &mut app, &$block, &None).handle(); + $handler::with(DEFAULT_KEYBINDINGS.edit.key, &mut app, $block, None).handle(); assert_eq!( app.get_current_route(), - &(ActiveRadarrBlock::EditCollectionPrompt, Some($context)).into() + (ActiveRadarrBlock::EditCollectionPrompt, Some($context)).into() ); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - &ActiveRadarrBlock::EditCollectionToggleMonitored + ActiveRadarrBlock::EditCollectionToggleMonitored ); assert_eq!( app @@ -234,29 +234,29 @@ mod utils { ($block:expr, $expected_block:expr) => { let mut app = App::default(); - RadarrHandler::with(&DELETE_KEY, &mut app, &$block, &None).handle(); + RadarrHandler::with(DELETE_KEY, &mut app, $block, None).handle(); - assert_eq!(app.get_current_route(), &$expected_block.into()); + assert_eq!(app.get_current_route(), $expected_block.into()); }; ($handler:ident, $block:expr, $expected_block:expr) => { let mut app = App::default(); - $handler::with(&DELETE_KEY, &mut app, &$block, &None).handle(); + $handler::with(DELETE_KEY, &mut app, $block, None).handle(); - assert_eq!(app.get_current_route(), &$expected_block.into()); + assert_eq!(app.get_current_route(), $expected_block.into()); }; ($app:expr, $block:expr, $expected_block:expr) => { - RadarrHandler::with(&DELETE_KEY, &mut $app, &$block, &None).handle(); + RadarrHandler::with(DELETE_KEY, &mut $app, $block, None).handle(); - assert_eq!($app.get_current_route(), &$expected_block.into()); + assert_eq!($app.get_current_route(), $expected_block.into()); }; ($handler:ident, $app:expr, $block:expr, $expected_block:expr) => { - $handler::with(&DELETE_KEY, &mut $app, &$block, &None).handle(); + $handler::with(DELETE_KEY, &mut $app, $block, None).handle(); - assert_eq!($app.get_current_route(), &$expected_block.into()); + assert_eq!($app.get_current_route(), $expected_block.into()); }; } @@ -266,9 +266,9 @@ mod utils { let mut app = App::default(); app.push_navigation_stack($block.into()); - $handler::with(&DEFAULT_KEYBINDINGS.refresh.key, &mut app, &$block, &None).handle(); + $handler::with(DEFAULT_KEYBINDINGS.refresh.key, &mut app, $block, None).handle(); - assert_eq!(app.get_current_route(), &$block.into()); + assert_eq!(app.get_current_route(), $block.into()); assert!(app.should_refresh); }; } diff --git a/src/handlers/radarr_handlers/radarr_handler_tests.rs b/src/handlers/radarr_handlers/radarr_handler_tests.rs index 5641d83..758677c 100644 --- a/src/handlers/radarr_handlers/radarr_handler_tests.rs +++ b/src/handlers/radarr_handlers/radarr_handler_tests.rs @@ -27,23 +27,23 @@ mod tests { let mut app = App::default(); app.data.radarr_data.main_tabs.set_index(index); - handle_change_tab_left_right_keys(&mut app, &DEFAULT_KEYBINDINGS.left.key); + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.key); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &left_block.into() + left_block.into() ); - assert_eq!(app.get_current_route(), &left_block.into()); + assert_eq!(app.get_current_route(), left_block.into()); app.data.radarr_data.main_tabs.set_index(index); - handle_change_tab_left_right_keys(&mut app, &DEFAULT_KEYBINDINGS.right.key); + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.key); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &right_block.into() + right_block.into() ); - assert_eq!(app.get_current_route(), &right_block.into()); + assert_eq!(app.get_current_route(), right_block.into()); } #[rstest] @@ -213,7 +213,7 @@ mod tests { #[test] fn test_radarr_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { - assert!(RadarrHandler::accepts(&active_radarr_block)); + assert!(RadarrHandler::accepts(active_radarr_block)); }) } @@ -223,10 +223,10 @@ mod tests { app.is_loading = true; let handler = RadarrHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/root_folders/mod.rs b/src/handlers/radarr_handlers/root_folders/mod.rs index ef357ae..52cc66b 100644 --- a/src/handlers/radarr_handlers/root_folders/mod.rs +++ b/src/handlers/radarr_handlers/root_folders/mod.rs @@ -13,22 +13,22 @@ use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; mod root_folders_handler_tests; pub(super) struct RootFoldersHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + _context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RootFoldersHandler<'a, 'b> { - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { - ROOT_FOLDERS_BLOCKS.contains(active_block) + fn accepts(active_block: ActiveRadarrBlock) -> bool { + ROOT_FOLDERS_BLOCKS.contains(&active_block) } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_block: ActiveRadarrBlock, + _context: Option, ) -> RootFoldersHandler<'a, 'b> { RootFoldersHandler { key, @@ -38,7 +38,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RootFoldersHandler<' } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -47,13 +47,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RootFoldersHandler<' } fn handle_scroll_up(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::RootFolders { + if self.active_radarr_block == ActiveRadarrBlock::RootFolders { self.app.data.radarr_data.root_folders.scroll_up() } } fn handle_scroll_down(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::RootFolders { + if self.active_radarr_block == ActiveRadarrBlock::RootFolders { self.app.data.radarr_data.root_folders.scroll_down() } } @@ -89,7 +89,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RootFoldersHandler<' } fn handle_delete(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::RootFolders { + if self.active_radarr_block == ActiveRadarrBlock::RootFolders { self .app .push_navigation_stack(ActiveRadarrBlock::DeleteRootFolderPrompt.into()) @@ -121,7 +121,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RootFoldersHandler<' self.app.pop_navigation_stack(); } - _ if *self.active_radarr_block == ActiveRadarrBlock::AddRootFolderPrompt + _ if self.active_radarr_block == ActiveRadarrBlock::AddRootFolderPrompt && !self .app .data @@ -161,10 +161,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RootFoldersHandler<' let key = self.key; match self.active_radarr_block { ActiveRadarrBlock::RootFolders => match self.key { - _ if *key == DEFAULT_KEYBINDINGS.refresh.key => { + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { self.app.should_refresh = true; } - _ if *key == DEFAULT_KEYBINDINGS.add.key => { + _ if key == DEFAULT_KEYBINDINGS.add.key => { self .app .push_navigation_stack(ActiveRadarrBlock::AddRootFolderPrompt.into()); @@ -181,7 +181,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RootFoldersHandler<' ) } ActiveRadarrBlock::DeleteRootFolderPrompt => { - if *key == DEFAULT_KEYBINDINGS.confirm.key { + if key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::DeleteRootFolder(None)); diff --git a/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs b/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs index fc9bd65..356fc41 100644 --- a/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs +++ b/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs @@ -42,14 +42,14 @@ mod tests { .root_folders .set_items(simple_stateful_iterable_vec!(RootFolder, String, path)); - RootFoldersHandler::with(&key, &mut app, &ActiveRadarrBlock::RootFolders, &None).handle(); + RootFoldersHandler::with(key, &mut app, ActiveRadarrBlock::RootFolders, None).handle(); assert_str_eq!( app.data.radarr_data.root_folders.current_selection().path, "Test 1" ); - RootFoldersHandler::with(&key, &mut app, &ActiveRadarrBlock::RootFolders, &None).handle(); + RootFoldersHandler::with(key, &mut app, ActiveRadarrBlock::RootFolders, None).handle(); assert_str_eq!( app.data.radarr_data.root_folders.current_selection().path, @@ -89,10 +89,10 @@ mod tests { .set_items(extended_stateful_iterable_vec!(RootFolder, String, path)); RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::RootFolders, - &None, + ActiveRadarrBlock::RootFolders, + None, ) .handle(); @@ -102,10 +102,10 @@ mod tests { ); RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::RootFolders, - &None, + ActiveRadarrBlock::RootFolders, + None, ) .handle(); @@ -126,10 +126,10 @@ mod tests { app.data.radarr_data.edit_root_folder = Some("Test".into()); RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::AddRootFolderPrompt, - &None, + ActiveRadarrBlock::AddRootFolderPrompt, + None, ) .handle(); @@ -146,10 +146,10 @@ mod tests { ); RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::AddRootFolderPrompt, - &None, + ActiveRadarrBlock::AddRootFolderPrompt, + None, ) .handle(); @@ -183,17 +183,11 @@ mod tests { .root_folders .set_items(vec![RootFolder::default()]); - RootFoldersHandler::with( - &DELETE_KEY, - &mut app, - &ActiveRadarrBlock::RootFolders, - &None, - ) - .handle(); + RootFoldersHandler::with(DELETE_KEY, &mut app, ActiveRadarrBlock::RootFolders, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::DeleteRootFolderPrompt.into() + ActiveRadarrBlock::DeleteRootFolderPrompt.into() ); } @@ -208,17 +202,11 @@ mod tests { .root_folders .set_items(vec![RootFolder::default()]); - RootFoldersHandler::with( - &DELETE_KEY, - &mut app, - &ActiveRadarrBlock::RootFolders, - &None, - ) - .handle(); + RootFoldersHandler::with(DELETE_KEY, &mut app, ActiveRadarrBlock::RootFolders, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::RootFolders.into() + ActiveRadarrBlock::RootFolders.into() ); } } @@ -238,21 +226,18 @@ mod tests { app.data.radarr_data.main_tabs.set_index(4); RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::RootFolders, - &None, + ActiveRadarrBlock::RootFolders, + None, ) .handle(); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &ActiveRadarrBlock::Blocklist.into() - ); - assert_eq!( - app.get_current_route(), - &ActiveRadarrBlock::Blocklist.into() + ActiveRadarrBlock::Blocklist.into() ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); } #[rstest] @@ -262,18 +247,18 @@ mod tests { app.data.radarr_data.main_tabs.set_index(4); RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::RootFolders, - &None, + ActiveRadarrBlock::RootFolders, + None, ) .handle(); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &ActiveRadarrBlock::Indexers.into() + ActiveRadarrBlock::Indexers.into() ); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); } #[rstest] @@ -283,20 +268,20 @@ mod tests { let mut app = App::default(); RootFoldersHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::DeleteRootFolderPrompt, - &None, + ActiveRadarrBlock::DeleteRootFolderPrompt, + None, ) .handle(); assert!(app.data.radarr_data.prompt_confirm); RootFoldersHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::DeleteRootFolderPrompt, - &None, + ActiveRadarrBlock::DeleteRootFolderPrompt, + None, ) .handle(); @@ -309,10 +294,10 @@ mod tests { app.data.radarr_data.edit_root_folder = Some("Test".into()); RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::AddRootFolderPrompt, - &None, + ActiveRadarrBlock::AddRootFolderPrompt, + None, ) .handle(); @@ -329,10 +314,10 @@ mod tests { ); RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::AddRootFolderPrompt, - &None, + ActiveRadarrBlock::AddRootFolderPrompt, + None, ) .handle(); @@ -374,10 +359,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::AddRootFolderPrompt.into()); RootFoldersHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AddRootFolderPrompt, - &None, + ActiveRadarrBlock::AddRootFolderPrompt, + None, ) .handle(); @@ -389,7 +374,7 @@ mod tests { ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::RootFolders.into() + ActiveRadarrBlock::RootFolders.into() ); } @@ -403,10 +388,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::AddRootFolderPrompt.into()); RootFoldersHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::AddRootFolderPrompt, - &None, + ActiveRadarrBlock::AddRootFolderPrompt, + None, ) .handle(); @@ -415,7 +400,7 @@ mod tests { assert!(app.data.radarr_data.prompt_confirm_action.is_none()); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddRootFolderPrompt.into() + ActiveRadarrBlock::AddRootFolderPrompt.into() ); } @@ -432,10 +417,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::DeleteRootFolderPrompt.into()); RootFoldersHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::DeleteRootFolderPrompt, - &None, + ActiveRadarrBlock::DeleteRootFolderPrompt, + None, ) .handle(); @@ -446,7 +431,7 @@ mod tests { ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::RootFolders.into() + ActiveRadarrBlock::RootFolders.into() ); } @@ -462,10 +447,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::DeleteRootFolderPrompt.into()); RootFoldersHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::DeleteRootFolderPrompt, - &None, + ActiveRadarrBlock::DeleteRootFolderPrompt, + None, ) .handle(); @@ -473,7 +458,7 @@ mod tests { assert_eq!(app.data.radarr_data.prompt_confirm_action, None); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::RootFolders.into() + ActiveRadarrBlock::RootFolders.into() ); } } @@ -493,16 +478,16 @@ mod tests { app.data.radarr_data.prompt_confirm = true; RootFoldersHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::DeleteRootFolderPrompt, - &None, + ActiveRadarrBlock::DeleteRootFolderPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::RootFolders.into() + ActiveRadarrBlock::RootFolders.into() ); assert!(!app.data.radarr_data.prompt_confirm); } @@ -516,16 +501,16 @@ mod tests { app.should_ignore_quit_key = true; RootFoldersHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::AddRootFolderPrompt, - &None, + ActiveRadarrBlock::AddRootFolderPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::RootFolders.into() + ActiveRadarrBlock::RootFolders.into() ); assert!(app.data.radarr_data.edit_root_folder.is_none()); @@ -541,11 +526,11 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::RootFolders.into()); app.push_navigation_stack(ActiveRadarrBlock::RootFolders.into()); - RootFoldersHandler::with(&ESC_KEY, &mut app, &ActiveRadarrBlock::RootFolders, &None).handle(); + RootFoldersHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::RootFolders, None).handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::RootFolders.into() + ActiveRadarrBlock::RootFolders.into() ); assert!(app.error.text.is_empty()); } @@ -568,16 +553,16 @@ mod tests { .set_items(vec![RootFolder::default()]); RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.add.key, + DEFAULT_KEYBINDINGS.add.key, &mut app, - &ActiveRadarrBlock::RootFolders, - &None, + ActiveRadarrBlock::RootFolders, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::AddRootFolderPrompt.into() + ActiveRadarrBlock::AddRootFolderPrompt.into() ); assert!(app.should_ignore_quit_key); assert!(app.data.radarr_data.edit_root_folder.is_some()); @@ -595,16 +580,16 @@ mod tests { .set_items(vec![RootFolder::default()]); RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.add.key, + DEFAULT_KEYBINDINGS.add.key, &mut app, - &ActiveRadarrBlock::RootFolders, - &None, + ActiveRadarrBlock::RootFolders, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::RootFolders.into() + ActiveRadarrBlock::RootFolders.into() ); assert!(!app.should_ignore_quit_key); assert!(app.data.radarr_data.edit_root_folder.is_none()); @@ -621,16 +606,16 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::RootFolders.into()); RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &ActiveRadarrBlock::RootFolders, - &None, + ActiveRadarrBlock::RootFolders, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::RootFolders.into() + ActiveRadarrBlock::RootFolders.into() ); assert!(app.should_refresh); } @@ -647,16 +632,16 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::RootFolders.into()); RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &ActiveRadarrBlock::RootFolders, - &None, + ActiveRadarrBlock::RootFolders, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::RootFolders.into() + ActiveRadarrBlock::RootFolders.into() ); assert!(!app.should_refresh); } @@ -672,10 +657,10 @@ mod tests { app.data.radarr_data.edit_root_folder = Some("/nfs/test".into()); RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.backspace.key, + DEFAULT_KEYBINDINGS.backspace.key, &mut app, - &ActiveRadarrBlock::AddRootFolderPrompt, - &None, + ActiveRadarrBlock::AddRootFolderPrompt, + None, ) .handle(); @@ -696,10 +681,10 @@ mod tests { app.data.radarr_data.edit_root_folder = Some(HorizontallyScrollableText::default()); RootFoldersHandler::with( - &Key::Char('h'), + Key::Char('h'), &mut app, - &ActiveRadarrBlock::AddRootFolderPrompt, - &None, + ActiveRadarrBlock::AddRootFolderPrompt, + None, ) .handle(); @@ -721,10 +706,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::DeleteRootFolderPrompt.into()); RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.confirm.key, + DEFAULT_KEYBINDINGS.confirm.key, &mut app, - &ActiveRadarrBlock::DeleteRootFolderPrompt, - &None, + ActiveRadarrBlock::DeleteRootFolderPrompt, + None, ) .handle(); @@ -735,7 +720,7 @@ mod tests { ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::RootFolders.into() + ActiveRadarrBlock::RootFolders.into() ); } } @@ -744,9 +729,9 @@ mod tests { fn test_root_folders_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if ROOT_FOLDERS_BLOCKS.contains(&active_radarr_block) { - assert!(RootFoldersHandler::accepts(&active_radarr_block)); + assert!(RootFoldersHandler::accepts(active_radarr_block)); } else { - assert!(!RootFoldersHandler::accepts(&active_radarr_block)); + assert!(!RootFoldersHandler::accepts(active_radarr_block)); } }) } @@ -757,10 +742,10 @@ mod tests { app.is_loading = true; let handler = RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::RootFolders, - &None, + ActiveRadarrBlock::RootFolders, + None, ); assert!(!handler.is_ready()); @@ -772,10 +757,10 @@ mod tests { app.is_loading = false; let handler = RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::RootFolders, - &None, + ActiveRadarrBlock::RootFolders, + None, ); assert!(!handler.is_ready()); @@ -792,10 +777,10 @@ mod tests { .root_folders .set_items(vec![RootFolder::default()]); let handler = RootFoldersHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::RootFolders, - &None, + ActiveRadarrBlock::RootFolders, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/system/mod.rs b/src/handlers/radarr_handlers/system/mod.rs index e015915..bf94dd9 100644 --- a/src/handlers/radarr_handlers/system/mod.rs +++ b/src/handlers/radarr_handlers/system/mod.rs @@ -14,10 +14,10 @@ mod system_details_handler; mod system_handler_tests; pub(super) struct SystemHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemHandler<'a, 'b> { @@ -31,15 +31,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemHandler<'a, 'b } } - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { - SystemDetailsHandler::accepts(active_block) || active_block == &ActiveRadarrBlock::System + fn accepts(active_block: ActiveRadarrBlock) -> bool { + SystemDetailsHandler::accepts(active_block) || active_block == ActiveRadarrBlock::System } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_block: ActiveRadarrBlock, + context: Option, ) -> SystemHandler<'a, 'b> { SystemHandler { key, @@ -49,7 +49,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemHandler<'a, 'b } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -71,7 +71,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemHandler<'a, 'b fn handle_delete(&mut self) {} fn handle_left_right_action(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::System { + if self.active_radarr_block == ActiveRadarrBlock::System { handle_change_tab_left_right_keys(self.app, self.key); } } @@ -83,18 +83,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemHandler<'a, 'b } fn handle_char_key_event(&mut self) { - if self.active_radarr_block == &ActiveRadarrBlock::System { + if self.active_radarr_block == ActiveRadarrBlock::System { let key = self.key; match self.key { - _ if *key == DEFAULT_KEYBINDINGS.refresh.key => { + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { self.app.should_refresh = true; } - _ if *key == DEFAULT_KEYBINDINGS.events.key => { + _ if key == DEFAULT_KEYBINDINGS.events.key => { self .app .push_navigation_stack(ActiveRadarrBlock::SystemQueuedEvents.into()); } - _ if *key == DEFAULT_KEYBINDINGS.logs.key => { + _ if key == DEFAULT_KEYBINDINGS.logs.key => { self .app .push_navigation_stack(ActiveRadarrBlock::SystemLogs.into()); @@ -106,12 +106,12 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemHandler<'a, 'b .set_items(self.app.data.radarr_data.logs.items.to_vec()); self.app.data.radarr_data.log_details.scroll_to_bottom(); } - _ if *key == DEFAULT_KEYBINDINGS.tasks.key => { + _ if key == DEFAULT_KEYBINDINGS.tasks.key => { self .app .push_navigation_stack(ActiveRadarrBlock::SystemTasks.into()); } - _ if *key == DEFAULT_KEYBINDINGS.update.key => { + _ if key == DEFAULT_KEYBINDINGS.update.key => { self .app .push_navigation_stack(ActiveRadarrBlock::SystemUpdates.into()); diff --git a/src/handlers/radarr_handlers/system/system_details_handler.rs b/src/handlers/radarr_handlers/system/system_details_handler.rs index 3cbe7c9..38707fb 100644 --- a/src/handlers/radarr_handlers/system/system_details_handler.rs +++ b/src/handlers/radarr_handlers/system/system_details_handler.rs @@ -12,22 +12,22 @@ use crate::network::radarr_network::RadarrEvent; mod system_details_handler_tests; pub(super) struct SystemDetailsHandler<'a, 'b> { - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_radarr_block: &'a ActiveRadarrBlock, - _context: &'a Option, + active_radarr_block: ActiveRadarrBlock, + _context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemDetailsHandler<'a, 'b> { - fn accepts(active_block: &'a ActiveRadarrBlock) -> bool { - SYSTEM_DETAILS_BLOCKS.contains(active_block) + fn accepts(active_block: ActiveRadarrBlock) -> bool { + SYSTEM_DETAILS_BLOCKS.contains(&active_block) } fn with( - key: &'a Key, + key: Key, app: &'a mut App<'b>, - active_block: &'a ActiveRadarrBlock, - context: &'a Option, + active_block: ActiveRadarrBlock, + context: Option, ) -> SystemDetailsHandler<'a, 'b> { SystemDetailsHandler { key, @@ -37,7 +37,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemDetailsHandler } } - fn get_key(&self) -> &Key { + fn get_key(&self) -> Key { self.key } @@ -100,7 +100,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemDetailsHandler match self.active_radarr_block { ActiveRadarrBlock::SystemLogs => match self.key { - _ if *key == DEFAULT_KEYBINDINGS.left.key => { + _ if key == DEFAULT_KEYBINDINGS.left.key => { self .app .data @@ -110,7 +110,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemDetailsHandler .iter() .for_each(|log| log.scroll_right()); } - _ if *key == DEFAULT_KEYBINDINGS.right.key => { + _ if key == DEFAULT_KEYBINDINGS.right.key => { self .app .data @@ -163,14 +163,14 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemDetailsHandler } fn handle_char_key_event(&mut self) { - if SYSTEM_DETAILS_BLOCKS.contains(self.active_radarr_block) - && self.key == &DEFAULT_KEYBINDINGS.refresh.key + if SYSTEM_DETAILS_BLOCKS.contains(&self.active_radarr_block) + && self.key == DEFAULT_KEYBINDINGS.refresh.key { self.app.should_refresh = true; } - if self.active_radarr_block == &ActiveRadarrBlock::SystemTaskStartConfirmPrompt - && *self.key == DEFAULT_KEYBINDINGS.confirm.key + if self.active_radarr_block == ActiveRadarrBlock::SystemTaskStartConfirmPrompt + && self.key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::StartTask(None)); diff --git a/src/handlers/radarr_handlers/system/system_details_handler_tests.rs b/src/handlers/radarr_handlers/system/system_details_handler_tests.rs index 65c9497..a6b5bf4 100644 --- a/src/handlers/radarr_handlers/system/system_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/system/system_details_handler_tests.rs @@ -49,14 +49,14 @@ mod tests { text )); - SystemDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::SystemLogs, &None).handle(); + SystemDetailsHandler::with(key, &mut app, ActiveRadarrBlock::SystemLogs, None).handle(); assert_str_eq!( app.data.radarr_data.log_details.current_selection().text, "Test 1" ); - SystemDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::SystemLogs, &None).handle(); + SystemDetailsHandler::with(key, &mut app, ActiveRadarrBlock::SystemLogs, None).handle(); assert_str_eq!( app.data.radarr_data.log_details.current_selection().text, @@ -76,14 +76,14 @@ mod tests { .tasks .set_items(simple_stateful_iterable_vec!(RadarrTask, String, name)); - SystemDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::SystemTasks, &None).handle(); + SystemDetailsHandler::with(key, &mut app, ActiveRadarrBlock::SystemTasks, None).handle(); assert_str_eq!( app.data.radarr_data.tasks.current_selection().name, "Test 2" ); - SystemDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::SystemTasks, &None).handle(); + SystemDetailsHandler::with(key, &mut app, ActiveRadarrBlock::SystemTasks, None).handle(); assert_str_eq!( app.data.radarr_data.tasks.current_selection().name, @@ -104,14 +104,14 @@ mod tests { .tasks .set_items(simple_stateful_iterable_vec!(RadarrTask, String, name)); - SystemDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::SystemTasks, &None).handle(); + SystemDetailsHandler::with(key, &mut app, ActiveRadarrBlock::SystemTasks, None).handle(); assert_str_eq!( app.data.radarr_data.tasks.current_selection().name, "Test 1" ); - SystemDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::SystemTasks, &None).handle(); + SystemDetailsHandler::with(key, &mut app, ActiveRadarrBlock::SystemTasks, None).handle(); assert_str_eq!( app.data.radarr_data.tasks.current_selection().name, @@ -131,26 +131,16 @@ mod tests { .queued_events .set_items(simple_stateful_iterable_vec!(QueueEvent, String, name)); - SystemDetailsHandler::with( - &key, - &mut app, - &ActiveRadarrBlock::SystemQueuedEvents, - &None, - ) - .handle(); + SystemDetailsHandler::with(key, &mut app, ActiveRadarrBlock::SystemQueuedEvents, None) + .handle(); assert_str_eq!( app.data.radarr_data.queued_events.current_selection().name, "Test 2" ); - SystemDetailsHandler::with( - &key, - &mut app, - &ActiveRadarrBlock::SystemQueuedEvents, - &None, - ) - .handle(); + SystemDetailsHandler::with(key, &mut app, ActiveRadarrBlock::SystemQueuedEvents, None) + .handle(); assert_str_eq!( app.data.radarr_data.queued_events.current_selection().name, @@ -171,26 +161,16 @@ mod tests { .queued_events .set_items(simple_stateful_iterable_vec!(QueueEvent, String, name)); - SystemDetailsHandler::with( - &key, - &mut app, - &ActiveRadarrBlock::SystemQueuedEvents, - &None, - ) - .handle(); + SystemDetailsHandler::with(key, &mut app, ActiveRadarrBlock::SystemQueuedEvents, None) + .handle(); assert_str_eq!( app.data.radarr_data.queued_events.current_selection().name, "Test 1" ); - SystemDetailsHandler::with( - &key, - &mut app, - &ActiveRadarrBlock::SystemQueuedEvents, - &None, - ) - .handle(); + SystemDetailsHandler::with(key, &mut app, ActiveRadarrBlock::SystemQueuedEvents, None) + .handle(); assert_str_eq!( app.data.radarr_data.queued_events.current_selection().name, @@ -204,20 +184,20 @@ mod tests { app.data.radarr_data.updates = ScrollableText::with_string("Test 1\nTest 2".to_owned()); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.up.key, + DEFAULT_KEYBINDINGS.up.key, &mut app, - &ActiveRadarrBlock::SystemUpdates, - &None, + ActiveRadarrBlock::SystemUpdates, + None, ) .handle(); assert_eq!(app.data.radarr_data.updates.offset, 0); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.down.key, + DEFAULT_KEYBINDINGS.down.key, &mut app, - &ActiveRadarrBlock::SystemUpdates, - &None, + ActiveRadarrBlock::SystemUpdates, + None, ) .handle(); @@ -231,20 +211,20 @@ mod tests { app.data.radarr_data.updates = ScrollableText::with_string("Test 1\nTest 2".to_owned()); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.up.key, + DEFAULT_KEYBINDINGS.up.key, &mut app, - &ActiveRadarrBlock::SystemUpdates, - &None, + ActiveRadarrBlock::SystemUpdates, + None, ) .handle(); assert_eq!(app.data.radarr_data.updates.offset, 0); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.down.key, + DEFAULT_KEYBINDINGS.down.key, &mut app, - &ActiveRadarrBlock::SystemUpdates, - &None, + ActiveRadarrBlock::SystemUpdates, + None, ) .handle(); @@ -283,10 +263,10 @@ mod tests { )); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::SystemLogs, - &None, + ActiveRadarrBlock::SystemLogs, + None, ) .handle(); @@ -296,10 +276,10 @@ mod tests { ); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::SystemLogs, - &None, + ActiveRadarrBlock::SystemLogs, + None, ) .handle(); @@ -321,10 +301,10 @@ mod tests { .set_items(extended_stateful_iterable_vec!(RadarrTask, String, name)); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::SystemTasks, - &None, + ActiveRadarrBlock::SystemTasks, + None, ) .handle(); @@ -334,10 +314,10 @@ mod tests { ); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::SystemTasks, - &None, + ActiveRadarrBlock::SystemTasks, + None, ) .handle(); @@ -360,10 +340,10 @@ mod tests { .set_items(extended_stateful_iterable_vec!(RadarrTask, String, name)); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::SystemTasks, - &None, + ActiveRadarrBlock::SystemTasks, + None, ) .handle(); @@ -373,10 +353,10 @@ mod tests { ); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::SystemTasks, - &None, + ActiveRadarrBlock::SystemTasks, + None, ) .handle(); @@ -398,10 +378,10 @@ mod tests { .set_items(extended_stateful_iterable_vec!(QueueEvent, String, name)); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::SystemQueuedEvents, - &None, + ActiveRadarrBlock::SystemQueuedEvents, + None, ) .handle(); @@ -411,10 +391,10 @@ mod tests { ); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::SystemQueuedEvents, - &None, + ActiveRadarrBlock::SystemQueuedEvents, + None, ) .handle(); @@ -437,10 +417,10 @@ mod tests { .set_items(extended_stateful_iterable_vec!(QueueEvent, String, name)); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::SystemQueuedEvents, - &None, + ActiveRadarrBlock::SystemQueuedEvents, + None, ) .handle(); @@ -450,10 +430,10 @@ mod tests { ); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::SystemQueuedEvents, - &None, + ActiveRadarrBlock::SystemQueuedEvents, + None, ) .handle(); @@ -469,20 +449,20 @@ mod tests { app.data.radarr_data.updates = ScrollableText::with_string("Test 1\nTest 2".to_owned()); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::SystemUpdates, - &None, + ActiveRadarrBlock::SystemUpdates, + None, ) .handle(); assert_eq!(app.data.radarr_data.updates.offset, 1); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::SystemUpdates, - &None, + ActiveRadarrBlock::SystemUpdates, + None, ) .handle(); @@ -496,20 +476,20 @@ mod tests { app.data.radarr_data.updates = ScrollableText::with_string("Test 1\nTest 2".to_owned()); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.end.key, &mut app, - &ActiveRadarrBlock::SystemUpdates, - &None, + ActiveRadarrBlock::SystemUpdates, + None, ) .handle(); assert_eq!(app.data.radarr_data.updates.offset, 0); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.home.key, &mut app, - &ActiveRadarrBlock::SystemUpdates, - &None, + ActiveRadarrBlock::SystemUpdates, + None, ) .handle(); @@ -534,10 +514,10 @@ mod tests { .set_items(vec!["t1".into(), "t22".into()]); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); @@ -545,10 +525,10 @@ mod tests { assert_eq!(app.data.radarr_data.log_details.items[1].to_string(), "t22"); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); @@ -556,10 +536,10 @@ mod tests { assert_eq!(app.data.radarr_data.log_details.items[1].to_string(), "22"); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); @@ -567,10 +547,10 @@ mod tests { assert_eq!(app.data.radarr_data.log_details.items[1].to_string(), "2"); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); @@ -578,10 +558,10 @@ mod tests { assert_eq!(app.data.radarr_data.log_details.items[1].to_string(), ""); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); @@ -589,10 +569,10 @@ mod tests { assert_eq!(app.data.radarr_data.log_details.items[1].to_string(), ""); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); @@ -600,10 +580,10 @@ mod tests { assert_eq!(app.data.radarr_data.log_details.items[1].to_string(), "2"); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); @@ -611,10 +591,10 @@ mod tests { assert_eq!(app.data.radarr_data.log_details.items[1].to_string(), "22"); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); @@ -629,20 +609,20 @@ mod tests { let mut app = App::default(); SystemDetailsHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::SystemTaskStartConfirmPrompt, - &None, + ActiveRadarrBlock::SystemTaskStartConfirmPrompt, + None, ) .handle(); assert!(app.data.radarr_data.prompt_confirm); SystemDetailsHandler::with( - &key, + key, &mut app, - &ActiveRadarrBlock::SystemTaskStartConfirmPrompt, - &None, + ActiveRadarrBlock::SystemTaskStartConfirmPrompt, + None, ) .handle(); @@ -664,17 +644,12 @@ mod tests { let mut app = App::default(); app.data.radarr_data.updates = ScrollableText::with_string("Test".to_owned()); - SystemDetailsHandler::with( - &SUBMIT_KEY, - &mut app, - &ActiveRadarrBlock::SystemTasks, - &None, - ) - .handle(); + SystemDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::SystemTasks, None) + .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::SystemTaskStartConfirmPrompt.into() + ActiveRadarrBlock::SystemTaskStartConfirmPrompt.into() ); } @@ -685,17 +660,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::SystemTasks.into()); app.data.radarr_data.updates = ScrollableText::with_string("Test".to_owned()); - SystemDetailsHandler::with( - &SUBMIT_KEY, - &mut app, - &ActiveRadarrBlock::SystemTasks, - &None, - ) - .handle(); + SystemDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::SystemTasks, None) + .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::SystemTasks.into() + ActiveRadarrBlock::SystemTasks.into() ); } @@ -708,10 +678,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::SystemTaskStartConfirmPrompt.into()); SystemDetailsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::SystemTaskStartConfirmPrompt, - &None, + ActiveRadarrBlock::SystemTaskStartConfirmPrompt, + None, ) .handle(); @@ -722,7 +692,7 @@ mod tests { ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::SystemTasks.into() + ActiveRadarrBlock::SystemTasks.into() ); } @@ -734,10 +704,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::SystemTaskStartConfirmPrompt.into()); SystemDetailsHandler::with( - &SUBMIT_KEY, + SUBMIT_KEY, &mut app, - &ActiveRadarrBlock::SystemTaskStartConfirmPrompt, - &None, + ActiveRadarrBlock::SystemTaskStartConfirmPrompt, + None, ) .handle(); @@ -745,7 +715,7 @@ mod tests { assert_eq!(app.data.radarr_data.prompt_confirm_action, None); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::SystemTasks.into() + ActiveRadarrBlock::SystemTasks.into() ); } } @@ -776,10 +746,9 @@ mod tests { .log_details .set_items(vec![HorizontallyScrollableText::default()]); - SystemDetailsHandler::with(&ESC_KEY, &mut app, &ActiveRadarrBlock::SystemLogs, &None) - .handle(); + SystemDetailsHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::SystemLogs, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::System.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::System.into()); assert!(app.data.radarr_data.log_details.items.is_empty()); } @@ -795,10 +764,9 @@ mod tests { .tasks .set_items(vec![RadarrTask::default()]); - SystemDetailsHandler::with(&ESC_KEY, &mut app, &ActiveRadarrBlock::SystemTasks, &None) - .handle(); + SystemDetailsHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::SystemTasks, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::System.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::System.into()); } #[rstest] @@ -814,14 +782,14 @@ mod tests { .set_items(vec![QueueEvent::default()]); SystemDetailsHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::SystemQueuedEvents, - &None, + ActiveRadarrBlock::SystemQueuedEvents, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::System.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::System.into()); } #[rstest] @@ -831,10 +799,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::System.into()); app.push_navigation_stack(ActiveRadarrBlock::SystemUpdates.into()); - SystemDetailsHandler::with(&ESC_KEY, &mut app, &ActiveRadarrBlock::SystemUpdates, &None) + SystemDetailsHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::SystemUpdates, None) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::System.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::System.into()); } #[test] @@ -845,16 +813,16 @@ mod tests { app.data.radarr_data.prompt_confirm = true; SystemDetailsHandler::with( - &ESC_KEY, + ESC_KEY, &mut app, - &ActiveRadarrBlock::SystemTaskStartConfirmPrompt, - &None, + ActiveRadarrBlock::SystemTaskStartConfirmPrompt, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::SystemTasks.into() + ActiveRadarrBlock::SystemTasks.into() ); assert!(!app.data.radarr_data.prompt_confirm); } @@ -882,14 +850,14 @@ mod tests { app.push_navigation_stack(active_radarr_block.into()); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); - assert_eq!(app.get_current_route(), &active_radarr_block.into()); + assert_eq!(app.get_current_route(), active_radarr_block.into()); assert!(app.should_refresh); } @@ -909,14 +877,14 @@ mod tests { app.push_navigation_stack(active_radarr_block.into()); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &active_radarr_block, - &None, + active_radarr_block, + None, ) .handle(); - assert_eq!(app.get_current_route(), &active_radarr_block.into()); + assert_eq!(app.get_current_route(), active_radarr_block.into()); assert!(!app.should_refresh); } @@ -928,10 +896,10 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::SystemTaskStartConfirmPrompt.into()); SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.confirm.key, + DEFAULT_KEYBINDINGS.confirm.key, &mut app, - &ActiveRadarrBlock::SystemTaskStartConfirmPrompt, - &None, + ActiveRadarrBlock::SystemTaskStartConfirmPrompt, + None, ) .handle(); @@ -942,7 +910,7 @@ mod tests { ); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::SystemTasks.into() + ActiveRadarrBlock::SystemTasks.into() ); } } @@ -951,9 +919,9 @@ mod tests { fn test_system_details_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if SYSTEM_DETAILS_BLOCKS.contains(&active_radarr_block) { - assert!(SystemDetailsHandler::accepts(&active_radarr_block)); + assert!(SystemDetailsHandler::accepts(active_radarr_block)); } else { - assert!(!SystemDetailsHandler::accepts(&active_radarr_block)); + assert!(!SystemDetailsHandler::accepts(active_radarr_block)); } }) } @@ -964,10 +932,10 @@ mod tests { app.is_loading = true; let handler = SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::SystemUpdates, - &None, + ActiveRadarrBlock::SystemUpdates, + None, ); assert!(!handler.is_ready()); @@ -979,10 +947,10 @@ mod tests { app.is_loading = false; let handler = SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::SystemUpdates, - &None, + ActiveRadarrBlock::SystemUpdates, + None, ); assert!(!handler.is_ready()); @@ -999,10 +967,10 @@ mod tests { .set_items(vec![HorizontallyScrollableText::default()]); let handler = SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::SystemUpdates, - &None, + ActiveRadarrBlock::SystemUpdates, + None, ); assert!(handler.is_ready()); @@ -1015,10 +983,10 @@ mod tests { app.data.radarr_data.updates = ScrollableText::with_string("Test".to_owned()); let handler = SystemDetailsHandler::with( - &DEFAULT_KEYBINDINGS.esc.key, + DEFAULT_KEYBINDINGS.esc.key, &mut app, - &ActiveRadarrBlock::SystemUpdates, - &None, + ActiveRadarrBlock::SystemUpdates, + None, ); assert!(handler.is_ready()); diff --git a/src/handlers/radarr_handlers/system/system_handler_tests.rs b/src/handlers/radarr_handlers/system/system_handler_tests.rs index 03b3aec..8ee7065 100644 --- a/src/handlers/radarr_handlers/system/system_handler_tests.rs +++ b/src/handlers/radarr_handlers/system/system_handler_tests.rs @@ -28,18 +28,18 @@ mod tests { app.data.radarr_data.main_tabs.set_index(6); SystemHandler::with( - &DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.left.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ) .handle(); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &ActiveRadarrBlock::Indexers.into() + ActiveRadarrBlock::Indexers.into() ); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); } #[rstest] @@ -49,18 +49,18 @@ mod tests { app.data.radarr_data.main_tabs.set_index(6); SystemHandler::with( - &DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.right.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ) .handle(); assert_eq!( app.data.radarr_data.main_tabs.get_active_route(), - &ActiveRadarrBlock::Movies.into() + ActiveRadarrBlock::Movies.into() ); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } } @@ -79,9 +79,9 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::System.into()); app.push_navigation_stack(ActiveRadarrBlock::System.into()); - SystemHandler::with(&ESC_KEY, &mut app, &ActiveRadarrBlock::System, &None).handle(); + SystemHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::System, None).handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::System.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::System.into()); assert!(app.error.text.is_empty()); } } @@ -112,16 +112,16 @@ mod tests { .set_items(vec![RadarrTask::default()]); SystemHandler::with( - &DEFAULT_KEYBINDINGS.update.key, + DEFAULT_KEYBINDINGS.update.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::SystemUpdates.into() + ActiveRadarrBlock::SystemUpdates.into() ); } @@ -146,14 +146,14 @@ mod tests { .set_items(vec![RadarrTask::default()]); SystemHandler::with( - &DEFAULT_KEYBINDINGS.update.key, + DEFAULT_KEYBINDINGS.update.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::System.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::System.into()); } #[test] @@ -175,16 +175,16 @@ mod tests { .set_items(vec![RadarrTask::default()]); SystemHandler::with( - &DEFAULT_KEYBINDINGS.events.key, + DEFAULT_KEYBINDINGS.events.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::SystemQueuedEvents.into() + ActiveRadarrBlock::SystemQueuedEvents.into() ); } @@ -209,14 +209,14 @@ mod tests { .set_items(vec![RadarrTask::default()]); SystemHandler::with( - &DEFAULT_KEYBINDINGS.events.key, + DEFAULT_KEYBINDINGS.events.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::System.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::System.into()); } #[test] @@ -239,14 +239,14 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::System.into()); SystemHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::System.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::System.into()); assert!(app.should_refresh); } @@ -272,14 +272,14 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::System.into()); SystemHandler::with( - &DEFAULT_KEYBINDINGS.refresh.key, + DEFAULT_KEYBINDINGS.refresh.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::System.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::System.into()); assert!(!app.should_refresh); } @@ -302,16 +302,16 @@ mod tests { .set_items(vec![RadarrTask::default()]); SystemHandler::with( - &DEFAULT_KEYBINDINGS.logs.key, + DEFAULT_KEYBINDINGS.logs.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::SystemLogs.into() + ActiveRadarrBlock::SystemLogs.into() ); assert_eq!( app.data.radarr_data.log_details.items, @@ -344,14 +344,14 @@ mod tests { .set_items(vec![RadarrTask::default()]); SystemHandler::with( - &DEFAULT_KEYBINDINGS.logs.key, + DEFAULT_KEYBINDINGS.logs.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::System.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::System.into()); assert!(app.data.radarr_data.log_details.is_empty()); } @@ -374,16 +374,16 @@ mod tests { .set_items(vec![RadarrTask::default()]); SystemHandler::with( - &DEFAULT_KEYBINDINGS.tasks.key, + DEFAULT_KEYBINDINGS.tasks.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ) .handle(); assert_eq!( app.get_current_route(), - &ActiveRadarrBlock::SystemTasks.into() + ActiveRadarrBlock::SystemTasks.into() ); } @@ -408,14 +408,14 @@ mod tests { .set_items(vec![RadarrTask::default()]); SystemHandler::with( - &DEFAULT_KEYBINDINGS.tasks.key, + DEFAULT_KEYBINDINGS.tasks.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ) .handle(); - assert_eq!(app.get_current_route(), &ActiveRadarrBlock::System.into()); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::System.into()); } } @@ -444,9 +444,9 @@ mod tests { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if system_blocks.contains(&active_radarr_block) { - assert!(SystemHandler::accepts(&active_radarr_block)); + assert!(SystemHandler::accepts(active_radarr_block)); } else { - assert!(!SystemHandler::accepts(&active_radarr_block)); + assert!(!SystemHandler::accepts(active_radarr_block)); } }) } @@ -457,10 +457,10 @@ mod tests { app.is_loading = true; let system_handler = SystemHandler::with( - &DEFAULT_KEYBINDINGS.update.key, + DEFAULT_KEYBINDINGS.update.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ); assert!(!system_handler.is_ready()); @@ -482,10 +482,10 @@ mod tests { .set_items(vec![QueueEvent::default()]); let system_handler = SystemHandler::with( - &DEFAULT_KEYBINDINGS.update.key, + DEFAULT_KEYBINDINGS.update.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ); assert!(!system_handler.is_ready()); @@ -503,10 +503,10 @@ mod tests { .set_items(vec![QueueEvent::default()]); let system_handler = SystemHandler::with( - &DEFAULT_KEYBINDINGS.update.key, + DEFAULT_KEYBINDINGS.update.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ); assert!(!system_handler.is_ready()); @@ -524,10 +524,10 @@ mod tests { .set_items(vec![RadarrTask::default()]); let system_handler = SystemHandler::with( - &DEFAULT_KEYBINDINGS.update.key, + DEFAULT_KEYBINDINGS.update.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ); assert!(!system_handler.is_ready()); @@ -550,10 +550,10 @@ mod tests { .set_items(vec![QueueEvent::default()]); let system_handler = SystemHandler::with( - &DEFAULT_KEYBINDINGS.update.key, + DEFAULT_KEYBINDINGS.update.key, &mut app, - &ActiveRadarrBlock::System, - &None, + ActiveRadarrBlock::System, + None, ); assert!(system_handler.is_ready()); diff --git a/src/main.rs b/src/main.rs index d828b28..a047046 100644 --- a/src/main.rs +++ b/src/main.rs @@ -177,7 +177,6 @@ async fn start_ui(app: &Arc>>) -> Result<()> { terminal.hide_cursor()?; let input_events = Events::new(); - let mut is_first_render = true; loop { let mut app = app.lock().await; @@ -193,10 +192,8 @@ async fn start_ui(app: &Arc>>) -> Result<()> { handlers::handle_events(key, &mut app); } - InputEvent::Tick => app.on_tick(is_first_render).await, + InputEvent::Tick => app.on_tick().await, } - - is_first_render = false; } terminal.show_cursor()?; diff --git a/src/models/mod.rs b/src/models/mod.rs index d0aa2c9..267024a 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -290,8 +290,8 @@ impl TabState { &self.tabs[self.index] } - pub fn get_active_route(&self) -> &Route { - &self.tabs[self.index].route + pub fn get_active_route(&self) -> Route { + self.tabs[self.index].route } pub fn get_active_tab_help(&self) -> &str { @@ -332,8 +332,8 @@ where BlockSelectionState { blocks, index: 0 } } - pub fn get_active_block(&self) -> &T { - &self.blocks[self.index] + pub fn get_active_block(&self) -> T { + self.blocks[self.index] } pub fn next(&mut self) { diff --git a/src/models/model_tests.rs b/src/models/model_tests.rs index 58880fc..2d40a50 100644 --- a/src/models/model_tests.rs +++ b/src/models/model_tests.rs @@ -517,7 +517,7 @@ mod tests { let active_route = tab_state.get_active_route(); - assert_eq!(active_route, &second_tab); + assert_eq!(active_route, second_tab); } #[test] @@ -548,15 +548,15 @@ mod tests { let tab_routes = create_test_tab_routes(); let mut tab_state = TabState::new(create_test_tab_routes()); - assert_eq!(tab_state.get_active_route(), &tab_routes[0].route); + assert_eq!(tab_state.get_active_route(), tab_routes[0].route); tab_state.next(); - assert_eq!(tab_state.get_active_route(), &tab_routes[1].route); + assert_eq!(tab_state.get_active_route(), tab_routes[1].route); tab_state.next(); - assert_eq!(tab_state.get_active_route(), &tab_routes[0].route); + assert_eq!(tab_state.get_active_route(), tab_routes[0].route); } #[test] @@ -564,15 +564,15 @@ mod tests { let tab_routes = create_test_tab_routes(); let mut tab_state = TabState::new(create_test_tab_routes()); - assert_eq!(tab_state.get_active_route(), &tab_routes[0].route); + assert_eq!(tab_state.get_active_route(), tab_routes[0].route); tab_state.previous(); - assert_eq!(tab_state.get_active_route(), &tab_routes[1].route); + assert_eq!(tab_state.get_active_route(), tab_routes[1].route); tab_state.previous(); - assert_eq!(tab_state.get_active_route(), &tab_routes[0].route); + assert_eq!(tab_state.get_active_route(), tab_routes[0].route); } #[test] @@ -592,7 +592,7 @@ mod tests { let active_block = block_selection_state.get_active_block(); - assert_eq!(active_block, &second_block); + assert_eq!(active_block, second_block); } #[test] @@ -603,15 +603,15 @@ mod tests { ]; let mut block_selection_state = BlockSelectionState::new(&blocks); - assert_eq!(block_selection_state.get_active_block(), &blocks[0]); + assert_eq!(block_selection_state.get_active_block(), blocks[0]); block_selection_state.next(); - assert_eq!(block_selection_state.get_active_block(), &blocks[1]); + assert_eq!(block_selection_state.get_active_block(), blocks[1]); block_selection_state.next(); - assert_eq!(block_selection_state.get_active_block(), &blocks[0]); + assert_eq!(block_selection_state.get_active_block(), blocks[0]); } #[test] @@ -622,15 +622,15 @@ mod tests { ]; let mut block_selection_state = BlockSelectionState::new(&blocks); - assert_eq!(block_selection_state.get_active_block(), &blocks[0]); + assert_eq!(block_selection_state.get_active_block(), blocks[0]); block_selection_state.previous(); - assert_eq!(block_selection_state.get_active_block(), &blocks[1]); + assert_eq!(block_selection_state.get_active_block(), blocks[1]); block_selection_state.previous(); - assert_eq!(block_selection_state.get_active_block(), &blocks[0]); + assert_eq!(block_selection_state.get_active_block(), blocks[0]); } #[test] diff --git a/src/models/servarr_data/radarr/radarr_data.rs b/src/models/servarr_data/radarr/radarr_data.rs index dcae501..c377df8 100644 --- a/src/models/servarr_data/radarr/radarr_data.rs +++ b/src/models/servarr_data/radarr/radarr_data.rs @@ -1,9 +1,10 @@ -use crate::app::context_clues::build_context_clue_string; +use crate::app::context_clues::{ + build_context_clue_string, BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, + INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, +}; use crate::app::radarr::radarr_context_clues::{ - BLOCKLIST_CONTEXT_CLUES, COLLECTIONS_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, - INDEXERS_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, - MANUAL_MOVIE_SEARCH_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, - SYSTEM_CONTEXT_CLUES, + COLLECTIONS_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, + MANUAL_MOVIE_SEARCH_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES, }; use crate::models::radarr_models::{ AddMovieSearchResult, BlocklistItem, Collection, CollectionMovie, DownloadRecord, diff --git a/src/models/servarr_data/radarr/radarr_data_tests.rs b/src/models/servarr_data/radarr/radarr_data_tests.rs index 1c42d9f..15eebb5 100644 --- a/src/models/servarr_data/radarr/radarr_data_tests.rs +++ b/src/models/servarr_data/radarr/radarr_data_tests.rs @@ -4,12 +4,14 @@ mod tests { use chrono::{DateTime, Utc}; use pretty_assertions::{assert_eq, assert_str_eq}; - use crate::app::context_clues::build_context_clue_string; + use crate::app::context_clues::{ + build_context_clue_string, BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, + INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, + }; use crate::app::radarr::radarr_context_clues::{ - BLOCKLIST_CONTEXT_CLUES, COLLECTIONS_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, - INDEXERS_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, - MANUAL_MOVIE_SEARCH_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, - SYSTEM_CONTEXT_CLUES, + COLLECTIONS_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, + MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXT_CLUES, + MOVIE_DETAILS_CONTEXT_CLUES, }; use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils; diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index c41b58b..96538fa 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -26,8 +26,8 @@ pub struct AddSeriesModal { pub tags: HorizontallyScrollableText, } -impl From<&SonarrData> for AddSeriesModal { - fn from(sonarr_data: &SonarrData) -> AddSeriesModal { +impl From<&SonarrData<'_>> for AddSeriesModal { + fn from(sonarr_data: &SonarrData<'_>) -> AddSeriesModal { let mut add_series_modal = AddSeriesModal { use_season_folder: true, ..AddSeriesModal::default() @@ -64,8 +64,8 @@ impl From<&SonarrData> for AddSeriesModal { } } -impl From<&SonarrData> for EditIndexerModal { - fn from(sonarr_data: &SonarrData) -> EditIndexerModal { +impl From<&SonarrData<'_>> for EditIndexerModal { + fn from(sonarr_data: &SonarrData<'_>) -> EditIndexerModal { let mut edit_indexer_modal = EditIndexerModal::default(); let Indexer { name, @@ -153,8 +153,8 @@ pub struct EditSeriesModal { pub tags: HorizontallyScrollableText, } -impl From<&SonarrData> for EditSeriesModal { - fn from(sonarr_data: &SonarrData) -> EditSeriesModal { +impl From<&SonarrData<'_>> for EditSeriesModal { + fn from(sonarr_data: &SonarrData<'_>) -> EditSeriesModal { let mut edit_series_modal = EditSeriesModal::default(); let Series { path, diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 82b7198..1cf7664 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -2,16 +2,26 @@ use bimap::BiMap; use chrono::{DateTime, Utc}; use strum::EnumIter; -use crate::models::{ - servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem}, - servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder}, - sonarr_models::{ - AddSeriesSearchResult, BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, - SonarrHistoryItem, SonarrTask, +use crate::{ + app::{ + context_clues::{ + build_context_clue_string, BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, + INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, + }, + sonarr::sonarr_context_clues::{HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES}, }, - stateful_list::StatefulList, - stateful_table::StatefulTable, - HorizontallyScrollableText, Route, ScrollableText, + models::{ + servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem}, + servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder}, + sonarr_models::{ + AddSeriesSearchResult, BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, + SonarrHistoryItem, SonarrTask, + }, + stateful_list::StatefulList, + stateful_table::StatefulTable, + BlockSelectionState, HorizontallyScrollableText, Route, ScrollableText, TabRoute, TabState, + }, + network::sonarr_network::SonarrEvent, }; use super::modals::{AddSeriesModal, EditSeriesModal, SeasonDetailsModal}; @@ -20,7 +30,7 @@ use super::modals::{AddSeriesModal, EditSeriesModal, SeasonDetailsModal}; #[path = "sonarr_data_tests.rs"] mod sonarr_data_tests; -pub struct SonarrData { +pub struct SonarrData<'a> { pub add_list_exclusion: bool, pub add_searched_series: Option>, pub add_series_modal: Option, @@ -39,11 +49,15 @@ pub struct SonarrData { pub indexer_test_error: Option, pub language_profiles_map: BiMap, pub logs: StatefulList, + pub main_tabs: TabState, + pub prompt_confirm: bool, + pub prompt_confirm_action: Option, pub quality_profile_map: BiMap, pub queued_events: StatefulTable, pub root_folders: StatefulTable, pub seasons: StatefulTable, pub season_details_modal: Option, + pub selected_block: BlockSelectionState<'a, ActiveSonarrBlock>, pub series: StatefulTable, pub series_history: Option>, pub start_time: DateTime, @@ -53,15 +67,15 @@ pub struct SonarrData { pub version: String, } -impl SonarrData { +impl<'a> SonarrData<'a> { pub fn reset_delete_series_preferences(&mut self) { self.delete_series_files = false; self.add_list_exclusion = false; } } -impl Default for SonarrData { - fn default() -> SonarrData { +impl<'a> Default for SonarrData<'a> { + fn default() -> SonarrData<'a> { SonarrData { add_list_exclusion: false, add_searched_series: None, @@ -81,11 +95,14 @@ impl Default for SonarrData { indexer_test_all_results: None, language_profiles_map: BiMap::new(), logs: StatefulList::default(), + prompt_confirm: false, + prompt_confirm_action: None, quality_profile_map: BiMap::new(), queued_events: StatefulTable::default(), root_folders: StatefulTable::default(), seasons: StatefulTable::default(), season_details_modal: None, + selected_block: BlockSelectionState::default(), series: StatefulTable::default(), series_history: None, start_time: DateTime::default(), @@ -93,6 +110,50 @@ impl Default for SonarrData { tasks: StatefulTable::default(), updates: ScrollableText::default(), version: String::new(), + main_tabs: TabState::new(vec![ + TabRoute { + title: "Library", + route: ActiveSonarrBlock::Series.into(), + help: String::new(), + contextual_help: Some(build_context_clue_string(&SERIES_CONTEXT_CLUES)), + }, + TabRoute { + title: "Downloads", + route: ActiveSonarrBlock::Downloads.into(), + help: String::new(), + contextual_help: Some(build_context_clue_string(&DOWNLOADS_CONTEXT_CLUES)), + }, + TabRoute { + title: "Blocklist", + route: ActiveSonarrBlock::Blocklist.into(), + help: String::new(), + contextual_help: Some(build_context_clue_string(&BLOCKLIST_CONTEXT_CLUES)), + }, + TabRoute { + title: "History", + route: ActiveSonarrBlock::History.into(), + help: String::new(), + contextual_help: Some(build_context_clue_string(&HISTORY_CONTEXT_CLUES)), + }, + TabRoute { + title: "Root Folders", + route: ActiveSonarrBlock::RootFolders.into(), + help: String::new(), + contextual_help: Some(build_context_clue_string(&ROOT_FOLDERS_CONTEXT_CLUES)), + }, + TabRoute { + title: "Indexers", + route: ActiveSonarrBlock::Indexers.into(), + help: String::new(), + contextual_help: Some(build_context_clue_string(&INDEXERS_CONTEXT_CLUES)), + }, + TabRoute { + title: "System", + route: ActiveSonarrBlock::System.into(), + help: String::new(), + contextual_help: Some(build_context_clue_string(&SYSTEM_CONTEXT_CLUES)), + }, + ]), } } } @@ -182,7 +243,6 @@ pub enum ActiveSonarrBlock { SearchSeriesHistory, SearchSeriesHistoryError, SeasonDetails, - SeasonHistory, #[default] Series, SeriesDetails, @@ -199,8 +259,25 @@ pub enum ActiveSonarrBlock { TestIndexer, UpdateAllSeriesPrompt, UpdateAndScanSeriesPrompt, + UpdateDownloadsPrompt, } +pub static SERIES_BLOCKS: [ActiveSonarrBlock; 7] = [ + ActiveSonarrBlock::Series, + ActiveSonarrBlock::SeriesSortPrompt, + ActiveSonarrBlock::SearchSeries, + ActiveSonarrBlock::SearchSeriesError, + ActiveSonarrBlock::FilterSeries, + ActiveSonarrBlock::FilterSeriesError, + ActiveSonarrBlock::UpdateAllSeriesPrompt, +]; + +pub static DOWNLOADS_BLOCKS: [ActiveSonarrBlock; 3] = [ + ActiveSonarrBlock::Downloads, + ActiveSonarrBlock::DeleteDownloadPrompt, + ActiveSonarrBlock::UpdateDownloadsPrompt, +]; + impl From for Route { fn from(active_sonarr_block: ActiveSonarrBlock) -> Route { Route::Sonarr(active_sonarr_block, None) diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index de9b25a..2d76b91 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -2,10 +2,20 @@ mod tests { mod sonarr_data_tests { use chrono::{DateTime, Utc}; + use pretty_assertions::{assert_eq, assert_str_eq}; - use crate::models::{ - servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}, - Route, + use crate::{ + app::{ + context_clues::{ + build_context_clue_string, BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, + INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, + }, + sonarr::sonarr_context_clues::{HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES}, + }, + models::{ + servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}, + BlockSelectionState, Route, + }, }; #[test] @@ -66,11 +76,14 @@ mod tests { assert!(sonarr_data.indexer_test_all_results.is_none()); assert!(sonarr_data.language_profiles_map.is_empty()); assert!(sonarr_data.logs.is_empty()); + assert!(!sonarr_data.prompt_confirm); + assert!(sonarr_data.prompt_confirm_action.is_none()); assert!(sonarr_data.quality_profile_map.is_empty()); assert!(sonarr_data.queued_events.is_empty()); assert!(sonarr_data.root_folders.is_empty()); assert!(sonarr_data.seasons.is_empty()); assert!(sonarr_data.season_details_modal.is_none()); + assert_eq!(sonarr_data.selected_block, BlockSelectionState::default()); assert!(sonarr_data.series.is_empty()); assert!(sonarr_data.series_history.is_none()); assert_eq!(sonarr_data.start_time, >::default()); @@ -78,6 +91,111 @@ mod tests { assert!(sonarr_data.tasks.is_empty()); assert!(sonarr_data.updates.is_empty()); assert!(sonarr_data.version.is_empty()); + + assert_eq!(sonarr_data.main_tabs.tabs.len(), 7); + + assert_str_eq!(sonarr_data.main_tabs.tabs[0].title, "Library"); + assert_eq!( + sonarr_data.main_tabs.tabs[0].route, + ActiveSonarrBlock::Series.into() + ); + assert!(sonarr_data.main_tabs.tabs[0].help.is_empty()); + assert_eq!( + sonarr_data.main_tabs.tabs[0].contextual_help, + Some(build_context_clue_string(&SERIES_CONTEXT_CLUES)) + ); + + assert_str_eq!(sonarr_data.main_tabs.tabs[1].title, "Downloads"); + assert_eq!( + sonarr_data.main_tabs.tabs[1].route, + ActiveSonarrBlock::Downloads.into() + ); + assert!(sonarr_data.main_tabs.tabs[1].help.is_empty()); + assert_eq!( + sonarr_data.main_tabs.tabs[1].contextual_help, + Some(build_context_clue_string(&DOWNLOADS_CONTEXT_CLUES)) + ); + + assert_str_eq!(sonarr_data.main_tabs.tabs[2].title, "Blocklist"); + assert_eq!( + sonarr_data.main_tabs.tabs[2].route, + ActiveSonarrBlock::Blocklist.into() + ); + assert!(sonarr_data.main_tabs.tabs[2].help.is_empty()); + assert_eq!( + sonarr_data.main_tabs.tabs[2].contextual_help, + Some(build_context_clue_string(&BLOCKLIST_CONTEXT_CLUES)) + ); + + assert_str_eq!(sonarr_data.main_tabs.tabs[3].title, "History"); + assert_eq!( + sonarr_data.main_tabs.tabs[3].route, + ActiveSonarrBlock::History.into() + ); + assert!(sonarr_data.main_tabs.tabs[3].help.is_empty()); + assert_eq!( + sonarr_data.main_tabs.tabs[3].contextual_help, + Some(build_context_clue_string(&HISTORY_CONTEXT_CLUES)) + ); + + assert_str_eq!(sonarr_data.main_tabs.tabs[4].title, "Root Folders"); + assert_eq!( + sonarr_data.main_tabs.tabs[4].route, + ActiveSonarrBlock::RootFolders.into() + ); + assert!(sonarr_data.main_tabs.tabs[4].help.is_empty()); + assert_eq!( + sonarr_data.main_tabs.tabs[4].contextual_help, + Some(build_context_clue_string(&ROOT_FOLDERS_CONTEXT_CLUES)) + ); + + assert_str_eq!(sonarr_data.main_tabs.tabs[5].title, "Indexers"); + assert_eq!( + sonarr_data.main_tabs.tabs[5].route, + ActiveSonarrBlock::Indexers.into() + ); + assert!(sonarr_data.main_tabs.tabs[5].help.is_empty()); + assert_eq!( + sonarr_data.main_tabs.tabs[5].contextual_help, + Some(build_context_clue_string(&INDEXERS_CONTEXT_CLUES)) + ); + + assert_str_eq!(sonarr_data.main_tabs.tabs[6].title, "System"); + assert_eq!( + sonarr_data.main_tabs.tabs[6].route, + ActiveSonarrBlock::System.into() + ); + assert!(sonarr_data.main_tabs.tabs[6].help.is_empty()); + assert_eq!( + sonarr_data.main_tabs.tabs[6].contextual_help, + Some(build_context_clue_string(&SYSTEM_CONTEXT_CLUES)) + ); + } + } + + mod active_sonarr_block_tests { + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, DOWNLOADS_BLOCKS, SERIES_BLOCKS, + }; + + #[test] + fn test_series_blocks_contents() { + assert_eq!(SERIES_BLOCKS.len(), 7); + assert!(SERIES_BLOCKS.contains(&ActiveSonarrBlock::Series)); + assert!(SERIES_BLOCKS.contains(&ActiveSonarrBlock::SeriesSortPrompt)); + assert!(SERIES_BLOCKS.contains(&ActiveSonarrBlock::SearchSeries)); + assert!(SERIES_BLOCKS.contains(&ActiveSonarrBlock::SearchSeriesError)); + assert!(SERIES_BLOCKS.contains(&ActiveSonarrBlock::FilterSeries)); + assert!(SERIES_BLOCKS.contains(&ActiveSonarrBlock::FilterSeriesError)); + assert!(SERIES_BLOCKS.contains(&ActiveSonarrBlock::UpdateAllSeriesPrompt)); + } + + #[test] + fn test_downloads_blocks_contents() { + assert_eq!(DOWNLOADS_BLOCKS.len(), 3); + assert!(DOWNLOADS_BLOCKS.contains(&ActiveSonarrBlock::Downloads)); + assert!(DOWNLOADS_BLOCKS.contains(&ActiveSonarrBlock::DeleteDownloadPrompt)); + assert!(DOWNLOADS_BLOCKS.contains(&ActiveSonarrBlock::UpdateDownloadsPrompt)); } } } diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 0c5a8c3..7722006 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -309,7 +309,7 @@ impl<'a, 'b> Network<'a, 'b> { quality_profile_list, .. } = app.data.radarr_data.add_movie_modal.as_ref().unwrap(); - let (tmdb_id, title) = if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() + let (tmdb_id, title) = if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { if active_radarr_block == ActiveRadarrBlock::CollectionDetails { let CollectionMovie { tmdb_id, title, .. } = app diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index c75ffd3..b1fb1cf 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -780,7 +780,7 @@ mod test { .is_none()); assert_eq!( app_arc.lock().await.get_current_route(), - &ActiveRadarrBlock::AddMovieEmptySearchResults.into() + ActiveRadarrBlock::AddMovieEmptySearchResults.into() ); } @@ -831,7 +831,7 @@ mod test { .is_none()); assert_eq!( app_arc.lock().await.get_current_route(), - &ActiveRadarrBlock::Movies.into() + ActiveRadarrBlock::Movies.into() ); } diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 6a0d84a..d87fbaa 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -1357,6 +1357,22 @@ impl<'a, 'b> Network<'a, 'b> { app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); } + let season_episodes_vec = if !app.data.sonarr_data.seasons.is_empty() { + let season_number = app + .data + .sonarr_data + .seasons + .current_selection() + .season_number; + + episode_vec + .into_iter() + .filter(|episode| episode.season_number == season_number) + .collect() + } else { + episode_vec + }; + app .data .sonarr_data @@ -1364,7 +1380,7 @@ impl<'a, 'b> Network<'a, 'b> { .as_mut() .unwrap() .episodes - .set_items(episode_vec.clone()); + .set_items(season_episodes_vec); app .data .sonarr_data diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 3b5e058..d417562 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -2214,12 +2214,21 @@ mod test { episode_file: None, ..episode() }; - let expected_episodes = vec![episode_1.clone(), episode_2.clone()]; - let mut expected_sorted_episodes = vec![episode_1.clone(), episode_2.clone()]; + let episode_3 = Episode { + id: 3, + title: Some("A test".to_owned()), + episode_file_id: 3, + season_number: 1, + episode_number: 2, + episode_file: None, + ..episode() + }; + let expected_episodes = vec![episode_1.clone(), episode_2.clone(), episode_3.clone()]; + let mut expected_sorted_episodes = vec![episode_1.clone(), episode_3.clone()]; let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, - Some(json!([episode_1, episode_2])), + Some(json!([episode_1, episode_2, episode_3])), None, SonarrEvent::GetEpisodes(None), None, @@ -2256,6 +2265,16 @@ mod test { id: 1, ..Series::default() }]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![Season { + season_number: 1, + ..Season::default() + }]); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); if let SonarrSerdeable::Episodes(episodes) = network @@ -2293,6 +2312,92 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_episodes_event_empty_seasons_table_returns_all_episodes_by_default() { + let episode_1 = Episode { + title: Some("z test".to_owned()), + episode_file: None, + ..episode() + }; + let episode_2 = Episode { + id: 2, + title: Some("A test".to_owned()), + episode_file_id: 2, + season_number: 2, + episode_number: 2, + episode_file: None, + ..episode() + }; + let episode_3 = Episode { + id: 3, + title: Some("A test".to_owned()), + episode_file_id: 3, + season_number: 1, + episode_number: 2, + episode_file: None, + ..episode() + }; + let expected_episodes = vec![episode_1.clone(), episode_2.clone(), episode_3.clone()]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([episode_1, episode_2, episode_3])), + None, + SonarrEvent::GetEpisodes(None), + None, + Some("seriesId=1"), + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.sort_asc = true; + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .items, + expected_episodes + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .sort_asc + ); + assert_eq!(episodes, expected_episodes); + } + } + #[tokio::test] async fn test_handle_get_episodes_event_empty_season_details_modal() { let (async_server, app_arc, _server) = mock_servarr_api( @@ -5474,7 +5579,7 @@ mod test { .is_none()); assert_eq!( app_arc.lock().await.get_current_route(), - &ActiveSonarrBlock::AddSeriesEmptySearchResults.into() + ActiveSonarrBlock::AddSeriesEmptySearchResults.into() ); } @@ -5529,7 +5634,7 @@ mod test { .is_none()); assert_eq!( app_arc.lock().await.get_current_route(), - &ActiveSonarrBlock::Series.into() + ActiveSonarrBlock::Series.into() ); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 5aa982b..70b2637 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -57,7 +57,7 @@ pub fn ui(f: &mut Frame<'_>, app: &mut App<'_>) { draw_header_row(f, app, header_area); - if RadarrUi::accepts(*app.get_current_route()) { + if RadarrUi::accepts(app.get_current_route()) { RadarrUi::draw_context_row(f, app, context_area); RadarrUi::draw(f, app, table_area); } diff --git a/src/ui/radarr_ui/blocklist/mod.rs b/src/ui/radarr_ui/blocklist/mod.rs index 733f02c..ff9f0ae 100644 --- a/src/ui/radarr_ui/blocklist/mod.rs +++ b/src/ui/radarr_ui/blocklist/mod.rs @@ -31,7 +31,7 @@ impl DrawUi for BlocklistUi { } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { match active_radarr_block { ActiveRadarrBlock::Blocklist | ActiveRadarrBlock::BlocklistSortPrompt => { draw_blocklist_table(f, app, area) @@ -77,7 +77,7 @@ impl DrawUi for BlocklistUi { } fn draw_blocklist_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { let current_selection = if app.data.radarr_data.blocklist.items.is_empty() { BlocklistItem::default() } else { diff --git a/src/ui/radarr_ui/collections/collection_details_ui.rs b/src/ui/radarr_ui/collections/collection_details_ui.rs index 917018c..a19e6fd 100644 --- a/src/ui/radarr_ui/collections/collection_details_ui.rs +++ b/src/ui/radarr_ui/collections/collection_details_ui.rs @@ -39,7 +39,7 @@ impl DrawUi for CollectionDetailsUi { } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - if let Route::Radarr(active_radarr_block, context_option) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, context_option) = app.get_current_route() { let draw_collection_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| match context_option .unwrap_or(active_radarr_block) diff --git a/src/ui/radarr_ui/collections/edit_collection_ui.rs b/src/ui/radarr_ui/collections/edit_collection_ui.rs index eac0943..7827427 100644 --- a/src/ui/radarr_ui/collections/edit_collection_ui.rs +++ b/src/ui/radarr_ui/collections/edit_collection_ui.rs @@ -41,7 +41,7 @@ impl DrawUi for EditCollectionUi { } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - if let Route::Radarr(active_radarr_block, context_option) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, context_option) = app.get_current_route() { let draw_edit_collection_prompt = |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| match active_radarr_block { ActiveRadarrBlock::EditCollectionSelectMinimumAvailability => { @@ -102,7 +102,7 @@ fn draw_edit_collection_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_> let title = format!("Edit - {collection_title}"); let yes_no_value = app.data.radarr_data.prompt_confirm; let selected_block = app.data.radarr_data.selected_block.get_active_block(); - let highlight_yes_no = selected_block == &ActiveRadarrBlock::EditCollectionConfirmPrompt; + let highlight_yes_no = selected_block == ActiveRadarrBlock::EditCollectionConfirmPrompt; let EditCollectionModal { minimum_availability_list, quality_profile_list, @@ -135,30 +135,30 @@ fn draw_edit_collection_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_> let help_paragraph = Paragraph::new(help_text).centered(); let prompt_paragraph = layout_paragraph_borderless(&collection_overview); let monitored_checkbox = Checkbox::new("Monitored") - .highlighted(selected_block == &ActiveRadarrBlock::EditCollectionToggleMonitored) + .highlighted(selected_block == ActiveRadarrBlock::EditCollectionToggleMonitored) .checked(monitored.unwrap_or_default()); let min_availability_drop_down_button = Button::new() .title(selected_minimum_availability.to_display_str()) .label("Minimum Availability") .icon("▼") - .selected(selected_block == &ActiveRadarrBlock::EditCollectionSelectMinimumAvailability); + .selected(selected_block == ActiveRadarrBlock::EditCollectionSelectMinimumAvailability); let quality_profile_drop_down_button = Button::new() .title(selected_quality_profile) .label("Quality Profile") .icon("▼") - .selected(selected_block == &ActiveRadarrBlock::EditCollectionSelectQualityProfile); + .selected(selected_block == ActiveRadarrBlock::EditCollectionSelectQualityProfile); - if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { let root_folder_input_box = InputBox::new(&path.text) .offset(path.offset.load(Ordering::SeqCst)) .label("Root Folder") - .highlighted(selected_block == &ActiveRadarrBlock::EditCollectionRootFolderPathInput) + .highlighted(selected_block == ActiveRadarrBlock::EditCollectionRootFolderPathInput) .selected(active_radarr_block == ActiveRadarrBlock::EditCollectionRootFolderPathInput); render_selectable_input_box!(root_folder_input_box, f, root_folder_area); } let search_on_add_checkbox = Checkbox::new("Search on Add") - .highlighted(selected_block == &ActiveRadarrBlock::EditCollectionToggleSearchOnAdd) + .highlighted(selected_block == ActiveRadarrBlock::EditCollectionToggleSearchOnAdd) .checked(search_on_add.unwrap_or_default()); let save_button = Button::new() .title("Save") diff --git a/src/ui/radarr_ui/collections/mod.rs b/src/ui/radarr_ui/collections/mod.rs index c3fa10b..e0f1bcd 100644 --- a/src/ui/radarr_ui/collections/mod.rs +++ b/src/ui/radarr_ui/collections/mod.rs @@ -38,7 +38,7 @@ impl DrawUi for CollectionsUi { } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - let route = *app.get_current_route(); + let route = app.get_current_route(); let mut collections_ui_matcher = |active_radarr_block| match active_radarr_block { ActiveRadarrBlock::Collections | ActiveRadarrBlock::CollectionsSortPrompt => { draw_collections(f, app, area) @@ -100,7 +100,7 @@ impl DrawUi for CollectionsUi { } pub(super) fn draw_collections(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { let current_selection = if !app.data.radarr_data.collections.items.is_empty() { app.data.radarr_data.collections.current_selection().clone() } else { diff --git a/src/ui/radarr_ui/downloads/mod.rs b/src/ui/radarr_ui/downloads/mod.rs index fa17417..8d61bc6 100644 --- a/src/ui/radarr_ui/downloads/mod.rs +++ b/src/ui/radarr_ui/downloads/mod.rs @@ -30,7 +30,7 @@ impl DrawUi for DownloadsUi { } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { match active_radarr_block { ActiveRadarrBlock::Downloads => draw_downloads(f, app, area), ActiveRadarrBlock::DeleteDownloadPrompt => { diff --git a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs index b185adb..9e681a1 100644 --- a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs +++ b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs @@ -51,7 +51,7 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let block = title_block_centered("Edit Indexer"); let yes_no_value = app.data.radarr_data.prompt_confirm; let selected_block = app.data.radarr_data.selected_block.get_active_block(); - let highlight_yes_no = selected_block == &ActiveRadarrBlock::EditIndexerConfirmPrompt; + let highlight_yes_no = selected_block == ActiveRadarrBlock::EditIndexerConfirmPrompt; let edit_indexer_modal_option = &app.data.radarr_data.edit_indexer_modal; let protocol = &app.data.radarr_data.indexers.current_selection().protocol; let help_text = Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help()); @@ -87,26 +87,26 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { ]) .areas(right_side_area); - if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { let name_input_box = InputBox::new(&edit_indexer_modal.name.text) .offset(edit_indexer_modal.name.offset.load(Ordering::SeqCst)) .label("Name") - .highlighted(selected_block == &ActiveRadarrBlock::EditIndexerNameInput) + .highlighted(selected_block == ActiveRadarrBlock::EditIndexerNameInput) .selected(active_radarr_block == ActiveRadarrBlock::EditIndexerNameInput); let url_input_box = InputBox::new(&edit_indexer_modal.url.text) .offset(edit_indexer_modal.url.offset.load(Ordering::SeqCst)) .label("URL") - .highlighted(selected_block == &ActiveRadarrBlock::EditIndexerUrlInput) + .highlighted(selected_block == ActiveRadarrBlock::EditIndexerUrlInput) .selected(active_radarr_block == ActiveRadarrBlock::EditIndexerUrlInput); let api_key_input_box = InputBox::new(&edit_indexer_modal.api_key.text) .offset(edit_indexer_modal.api_key.offset.load(Ordering::SeqCst)) .label("API Key") - .highlighted(selected_block == &ActiveRadarrBlock::EditIndexerApiKeyInput) + .highlighted(selected_block == ActiveRadarrBlock::EditIndexerApiKeyInput) .selected(active_radarr_block == ActiveRadarrBlock::EditIndexerApiKeyInput); let tags_input_box = InputBox::new(&edit_indexer_modal.tags.text) .offset(edit_indexer_modal.tags.offset.load(Ordering::SeqCst)) .label("Tags") - .highlighted(selected_block == &ActiveRadarrBlock::EditIndexerTagsInput) + .highlighted(selected_block == ActiveRadarrBlock::EditIndexerTagsInput) .selected(active_radarr_block == ActiveRadarrBlock::EditIndexerTagsInput); render_selectable_input_box!(name_input_box, f, name_area); @@ -117,12 +117,12 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let seed_ratio_input_box = InputBox::new(&edit_indexer_modal.seed_ratio.text) .offset(edit_indexer_modal.seed_ratio.offset.load(Ordering::SeqCst)) .label("Seed Ratio") - .highlighted(selected_block == &ActiveRadarrBlock::EditIndexerSeedRatioInput) + .highlighted(selected_block == ActiveRadarrBlock::EditIndexerSeedRatioInput) .selected(active_radarr_block == ActiveRadarrBlock::EditIndexerSeedRatioInput); let tags_input_box = InputBox::new(&edit_indexer_modal.tags.text) .offset(edit_indexer_modal.tags.offset.load(Ordering::SeqCst)) .label("Tags") - .highlighted(selected_block == &ActiveRadarrBlock::EditIndexerTagsInput) + .highlighted(selected_block == ActiveRadarrBlock::EditIndexerTagsInput) .selected(active_radarr_block == ActiveRadarrBlock::EditIndexerTagsInput); render_selectable_input_box!(seed_ratio_input_box, f, seed_ratio_area); @@ -133,23 +133,21 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let rss_checkbox = Checkbox::new("Enable RSS") .checked(edit_indexer_modal.enable_rss.unwrap_or_default()) - .highlighted(selected_block == &ActiveRadarrBlock::EditIndexerToggleEnableRss); + .highlighted(selected_block == ActiveRadarrBlock::EditIndexerToggleEnableRss); let auto_search_checkbox = Checkbox::new("Enable Automatic Search") .checked( edit_indexer_modal .enable_automatic_search .unwrap_or_default(), ) - .highlighted(selected_block == &ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch); + .highlighted(selected_block == ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch); let interactive_search_checkbox = Checkbox::new("Enable Interactive Search") .checked( edit_indexer_modal .enable_interactive_search .unwrap_or_default(), ) - .highlighted( - selected_block == &ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch, - ); + .highlighted(selected_block == ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch); let [save_area, cancel_area] = Layout::horizontal([Constraint::Percentage(25), Constraint::Percentage(25)]) diff --git a/src/ui/radarr_ui/indexers/indexer_settings_ui.rs b/src/ui/radarr_ui/indexers/indexer_settings_ui.rs index a9bc0ea..34affd1 100644 --- a/src/ui/radarr_ui/indexers/indexer_settings_ui.rs +++ b/src/ui/radarr_ui/indexers/indexer_settings_ui.rs @@ -54,7 +54,7 @@ fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: let block = title_block_centered("Configure All Indexer Settings"); let yes_no_value = app.data.radarr_data.prompt_confirm; let selected_block = app.data.radarr_data.selected_block.get_active_block(); - let highlight_yes_no = selected_block == &ActiveRadarrBlock::IndexerSettingsConfirmPrompt; + let highlight_yes_no = selected_block == ActiveRadarrBlock::IndexerSettingsConfirmPrompt; let indexer_settings_option = &app.data.radarr_data.indexer_settings; let help_text = Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help()); let help_paragraph = Paragraph::new(help_text).centered(); @@ -90,7 +90,7 @@ fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: ]) .areas(right_side_area); - if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { let min_age = indexer_settings.minimum_age.to_string(); let retention = indexer_settings.retention.to_string(); let max_size = indexer_settings.maximum_size.to_string(); @@ -100,27 +100,27 @@ fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: let min_age_text_box = InputBox::new(&min_age) .cursor_after_string(false) .label("Minimum Age (minutes) ▴▾") - .highlighted(selected_block == &ActiveRadarrBlock::IndexerSettingsMinimumAgeInput) + .highlighted(selected_block == ActiveRadarrBlock::IndexerSettingsMinimumAgeInput) .selected(active_radarr_block == ActiveRadarrBlock::IndexerSettingsMinimumAgeInput); let retention_input_box = InputBox::new(&retention) .cursor_after_string(false) .label("Retention (days) ▴▾") - .highlighted(selected_block == &ActiveRadarrBlock::IndexerSettingsRetentionInput) + .highlighted(selected_block == ActiveRadarrBlock::IndexerSettingsRetentionInput) .selected(active_radarr_block == ActiveRadarrBlock::IndexerSettingsRetentionInput); let max_size_input_box = InputBox::new(&max_size) .cursor_after_string(false) .label("Maximum Size (MB) ▴▾") - .highlighted(selected_block == &ActiveRadarrBlock::IndexerSettingsMaximumSizeInput) + .highlighted(selected_block == ActiveRadarrBlock::IndexerSettingsMaximumSizeInput) .selected(active_radarr_block == ActiveRadarrBlock::IndexerSettingsMaximumSizeInput); let availability_delay_input_box = InputBox::new(&availability_delay) .cursor_after_string(false) .label("Availability Delay (days) ▴▾") - .highlighted(selected_block == &ActiveRadarrBlock::IndexerSettingsAvailabilityDelayInput) + .highlighted(selected_block == ActiveRadarrBlock::IndexerSettingsAvailabilityDelayInput) .selected(active_radarr_block == ActiveRadarrBlock::IndexerSettingsAvailabilityDelayInput); let rss_sync_interval_input_box = InputBox::new(&rss_sync_interval) .cursor_after_string(false) .label("RSS Sync Interval (minutes) ▴▾") - .highlighted(selected_block == &ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput) + .highlighted(selected_block == ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput) .selected(active_radarr_block == ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput); let whitelisted_subs_input_box = InputBox::new(&indexer_settings.whitelisted_hardcoded_subs.text) @@ -132,7 +132,7 @@ fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: ) .label("Whitelisted Subtitle Tags") .highlighted( - selected_block == &ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, + selected_block == ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, ) .selected( active_radarr_block == ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, @@ -147,10 +147,10 @@ fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: } let prefer_indexer_flags_checkbox = Checkbox::new("Prefer Indexer Flags") - .highlighted(selected_block == &ActiveRadarrBlock::IndexerSettingsTogglePreferIndexerFlags) + .highlighted(selected_block == ActiveRadarrBlock::IndexerSettingsTogglePreferIndexerFlags) .checked(indexer_settings.prefer_indexer_flags); let allow_hardcoded_subs_checkbox = Checkbox::new("Allow Hardcoded Subs") - .highlighted(selected_block == &ActiveRadarrBlock::IndexerSettingsToggleAllowHardcodedSubs) + .highlighted(selected_block == ActiveRadarrBlock::IndexerSettingsToggleAllowHardcodedSubs) .checked(indexer_settings.allow_hardcoded_subs); let [save_area, cancel_area] = diff --git a/src/ui/radarr_ui/indexers/mod.rs b/src/ui/radarr_ui/indexers/mod.rs index d79c84f..aaa4193 100644 --- a/src/ui/radarr_ui/indexers/mod.rs +++ b/src/ui/radarr_ui/indexers/mod.rs @@ -43,7 +43,7 @@ impl DrawUi for IndexersUi { } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - let route = *app.get_current_route(); + let route = app.get_current_route(); let mut indexers_matchers = |active_radarr_block| match active_radarr_block { ActiveRadarrBlock::Indexers => draw_indexers(f, app, area), ActiveRadarrBlock::TestIndexer => { diff --git a/src/ui/radarr_ui/library/add_movie_ui.rs b/src/ui/radarr_ui/library/add_movie_ui.rs index 6903a6a..31009ec 100644 --- a/src/ui/radarr_ui/library/add_movie_ui.rs +++ b/src/ui/radarr_ui/library/add_movie_ui.rs @@ -46,7 +46,7 @@ impl DrawUi for AddMovieUi { } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - if let Route::Radarr(active_radarr_block, context_option) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, context_option) = app.get_current_route() { let draw_add_movie_search_popup = |f: &mut Frame<'_>, app: &mut App<'_>, area: Rect| match active_radarr_block { ActiveRadarrBlock::AddMovieSearchInput @@ -202,7 +202,7 @@ fn draw_add_movie_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .primary() }; - if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { match active_radarr_block { ActiveRadarrBlock::AddMovieSearchInput => { let search_box = InputBox::new(block_content) @@ -284,7 +284,7 @@ fn draw_add_movie_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } fn draw_confirmation_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { match active_radarr_block { ActiveRadarrBlock::AddMovieSelectMonitor => { draw_confirmation_prompt(f, app, area); @@ -354,7 +354,7 @@ fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let prompt = movie_overview; let yes_no_value = app.data.radarr_data.prompt_confirm; let selected_block = app.data.radarr_data.selected_block.get_active_block(); - let highlight_yes_no = selected_block == &ActiveRadarrBlock::AddMovieConfirmPrompt; + let highlight_yes_no = selected_block == ActiveRadarrBlock::AddMovieConfirmPrompt; let AddMovieModal { monitor_list, minimum_availability_list, @@ -400,33 +400,33 @@ fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .title(&selected_root_folder.path) .label("Root Folder") .icon("▼") - .selected(selected_block == &ActiveRadarrBlock::AddMovieSelectRootFolder); + .selected(selected_block == ActiveRadarrBlock::AddMovieSelectRootFolder); let monitor_drop_down_button = Button::new() .title(selected_monitor.to_display_str()) .label("Monitor") .icon("▼") - .selected(selected_block == &ActiveRadarrBlock::AddMovieSelectMonitor); + .selected(selected_block == ActiveRadarrBlock::AddMovieSelectMonitor); let min_availability_drop_down_button = Button::new() .title(selected_minimum_availability.to_display_str()) .label("Minimum Availability") .icon("▼") - .selected(selected_block == &ActiveRadarrBlock::AddMovieSelectMinimumAvailability); + .selected(selected_block == ActiveRadarrBlock::AddMovieSelectMinimumAvailability); let quality_profile_drop_down_button = Button::new() .title(selected_quality_profile) .label("Quality Profile") .icon("▼") - .selected(selected_block == &ActiveRadarrBlock::AddMovieSelectQualityProfile); + .selected(selected_block == ActiveRadarrBlock::AddMovieSelectQualityProfile); f.render_widget(root_folder_drop_down_button, root_folder_area); f.render_widget(monitor_drop_down_button, monitor_area); f.render_widget(min_availability_drop_down_button, min_availability_area); f.render_widget(quality_profile_drop_down_button, quality_profile_area); - if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { let tags_input_box = InputBox::new(&tags.text) .offset(tags.offset.load(Ordering::SeqCst)) .label("Tags") - .highlighted(selected_block == &ActiveRadarrBlock::AddMovieTagsInput) + .highlighted(selected_block == ActiveRadarrBlock::AddMovieTagsInput) .selected(active_radarr_block == ActiveRadarrBlock::AddMovieTagsInput); render_selectable_input_box!(tags_input_box, f, tags_area); } diff --git a/src/ui/radarr_ui/library/delete_movie_ui.rs b/src/ui/radarr_ui/library/delete_movie_ui.rs index 3dd0d61..4cdc52f 100644 --- a/src/ui/radarr_ui/library/delete_movie_ui.rs +++ b/src/ui/radarr_ui/library/delete_movie_ui.rs @@ -27,7 +27,7 @@ impl DrawUi for DeleteMovieUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if matches!( - *app.get_current_route(), + app.get_current_route(), Route::Radarr(ActiveRadarrBlock::DeleteMoviePrompt, _) ) { let selected_block = app.data.radarr_data.selected_block.get_active_block(); @@ -38,16 +38,16 @@ impl DrawUi for DeleteMovieUi { let checkboxes = vec![ Checkbox::new("Delete Movie File") .checked(app.data.radarr_data.delete_movie_files) - .highlighted(selected_block == &ActiveRadarrBlock::DeleteMovieToggleDeleteFile), + .highlighted(selected_block == ActiveRadarrBlock::DeleteMovieToggleDeleteFile), Checkbox::new("Add List Exclusion") .checked(app.data.radarr_data.add_list_exclusion) - .highlighted(selected_block == &ActiveRadarrBlock::DeleteMovieToggleAddListExclusion), + .highlighted(selected_block == ActiveRadarrBlock::DeleteMovieToggleAddListExclusion), ]; let confirmation_prompt = ConfirmationPrompt::new() .title("Delete Movie") .prompt(&prompt) .checkboxes(checkboxes) - .yes_no_highlighted(selected_block == &ActiveRadarrBlock::DeleteMovieConfirmPrompt) + .yes_no_highlighted(selected_block == ActiveRadarrBlock::DeleteMovieConfirmPrompt) .yes_no_value(app.data.radarr_data.prompt_confirm); draw_library(f, app, area); diff --git a/src/ui/radarr_ui/library/edit_movie_ui.rs b/src/ui/radarr_ui/library/edit_movie_ui.rs index 520bf2d..b923833 100644 --- a/src/ui/radarr_ui/library/edit_movie_ui.rs +++ b/src/ui/radarr_ui/library/edit_movie_ui.rs @@ -43,7 +43,7 @@ impl DrawUi for EditMovieUi { } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - if let Route::Radarr(active_radarr_block, context_option) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, context_option) = app.get_current_route() { let draw_edit_movie_prompt = |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| match active_radarr_block { ActiveRadarrBlock::EditMovieSelectMinimumAvailability => { @@ -105,7 +105,7 @@ fn draw_edit_movie_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, are let title = format!("Edit - {movie_title}"); let yes_no_value = app.data.radarr_data.prompt_confirm; let selected_block = app.data.radarr_data.selected_block.get_active_block(); - let highlight_yes_no = selected_block == &ActiveRadarrBlock::EditMovieConfirmPrompt; + let highlight_yes_no = selected_block == ActiveRadarrBlock::EditMovieConfirmPrompt; let EditMovieModal { minimum_availability_list, quality_profile_list, @@ -139,28 +139,28 @@ fn draw_edit_movie_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, are let prompt_paragraph = layout_paragraph_borderless(&movie_overview); let monitored_checkbox = Checkbox::new("Monitored") .checked(monitored.unwrap_or_default()) - .highlighted(selected_block == &ActiveRadarrBlock::EditMovieToggleMonitored); + .highlighted(selected_block == ActiveRadarrBlock::EditMovieToggleMonitored); let min_availability_drop_down_button = Button::new() .title(selected_minimum_availability.to_display_str()) .label("Minimum Availability") .icon("▼") - .selected(selected_block == &ActiveRadarrBlock::EditMovieSelectMinimumAvailability); + .selected(selected_block == ActiveRadarrBlock::EditMovieSelectMinimumAvailability); let quality_profile_drop_down_button = Button::new() .title(selected_quality_profile) .label("Quality Profile") .icon("▼") - .selected(selected_block == &ActiveRadarrBlock::EditMovieSelectQualityProfile); + .selected(selected_block == ActiveRadarrBlock::EditMovieSelectQualityProfile); - if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { let path_input_box = InputBox::new(&path.text) .offset(path.offset.load(Ordering::SeqCst)) .label("Path") - .highlighted(selected_block == &ActiveRadarrBlock::EditMoviePathInput) + .highlighted(selected_block == ActiveRadarrBlock::EditMoviePathInput) .selected(active_radarr_block == ActiveRadarrBlock::EditMoviePathInput); let tags_input_box = InputBox::new(&tags.text) .offset(tags.offset.load(Ordering::SeqCst)) .label("Tags") - .highlighted(selected_block == &ActiveRadarrBlock::EditMovieTagsInput) + .highlighted(selected_block == ActiveRadarrBlock::EditMovieTagsInput) .selected(active_radarr_block == ActiveRadarrBlock::EditMovieTagsInput); match active_radarr_block { diff --git a/src/ui/radarr_ui/library/mod.rs b/src/ui/radarr_ui/library/mod.rs index e34b443..500a4ea 100644 --- a/src/ui/radarr_ui/library/mod.rs +++ b/src/ui/radarr_ui/library/mod.rs @@ -44,7 +44,7 @@ impl DrawUi for LibraryUi { } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - let route = *app.get_current_route(); + let route = app.get_current_route(); let mut library_ui_matchers = |active_radarr_block: ActiveRadarrBlock| match active_radarr_block { ActiveRadarrBlock::Movies | ActiveRadarrBlock::MoviesSortPrompt => draw_library(f, app, area), @@ -103,7 +103,7 @@ impl DrawUi for LibraryUi { } pub(super) fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { let current_selection = if !app.data.radarr_data.movies.items.is_empty() { app.data.radarr_data.movies.current_selection().clone() } else { diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index ff7dd77..819e43c 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -39,7 +39,7 @@ impl DrawUi for MovieDetailsUi { } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - if let Route::Radarr(active_radarr_block, context_option) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, context_option) = app.get_current_route() { let draw_movie_info_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { let content_area = draw_tabs( f, @@ -371,7 +371,7 @@ fn draw_movie_crew(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } fn draw_movie_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { let (current_selection, is_empty) = match app.data.radarr_data.movie_details_modal.as_ref() { Some(movie_details_modal) if !movie_details_modal.movie_releases.items.is_empty() => ( movie_details_modal @@ -382,7 +382,7 @@ fn draw_movie_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { ), _ => (RadarrRelease::default(), true), }; - let current_route = *app.get_current_route(); + let current_route = app.get_current_route(); let mut default_movie_details_modal = MovieDetailsModal::default(); let help_footer = app .data diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index d19fabc..0d2f10c 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -51,7 +51,7 @@ impl DrawUi for RadarrUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let content_area = draw_tabs(f, area, "Movies", &app.data.radarr_data.main_tabs); - let route = *app.get_current_route(); + let route = app.get_current_route(); match route { _ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area), diff --git a/src/ui/radarr_ui/root_folders/mod.rs b/src/ui/radarr_ui/root_folders/mod.rs index 381b37c..97da280 100644 --- a/src/ui/radarr_ui/root_folders/mod.rs +++ b/src/ui/radarr_ui/root_folders/mod.rs @@ -30,7 +30,7 @@ impl DrawUi for RootFoldersUi { } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { match active_radarr_block { ActiveRadarrBlock::RootFolders => draw_root_folders(f, app, area), ActiveRadarrBlock::AddRootFolderPrompt => draw_popup_over( diff --git a/src/ui/radarr_ui/system/mod.rs b/src/ui/radarr_ui/system/mod.rs index c46653e..016f474 100644 --- a/src/ui/radarr_ui/system/mod.rs +++ b/src/ui/radarr_ui/system/mod.rs @@ -61,7 +61,7 @@ impl DrawUi for SystemUi { } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - let route = *app.get_current_route(); + let route = app.get_current_route(); match route { _ if SystemDetailsUi::accepts(route) => SystemDetailsUi::draw(f, app, area), diff --git a/src/ui/radarr_ui/system/system_details_ui.rs b/src/ui/radarr_ui/system/system_details_ui.rs index cfc3c5c..68ac459 100644 --- a/src/ui/radarr_ui/system/system_details_ui.rs +++ b/src/ui/radarr_ui/system/system_details_ui.rs @@ -39,7 +39,7 @@ impl DrawUi for SystemDetailsUi { } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { + if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { match active_radarr_block { ActiveRadarrBlock::SystemLogs => { draw_system_ui_layout(f, app, area); From 4d1b0fe3011cad9d343555cf9a916410166445db Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 27 Nov 2024 17:14:40 -0700 Subject: [PATCH 02/82] docs(context): Updated the Servarr context clues to say how to switch Servarr tabs via TAB and SHIFT+TAB --- src/app/context_clues.rs | 11 +++++++++-- src/app/context_clues_tests.rs | 9 +++++++-- src/app/key_binding.rs | 5 ----- src/app/key_binding_tests.rs | 1 - 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/app/context_clues.rs b/src/app/context_clues.rs index 1f19fa8..d1c00ed 100644 --- a/src/app/context_clues.rs +++ b/src/app/context_clues.rs @@ -14,8 +14,15 @@ pub fn build_context_clue_string(context_clues: &[(KeyBinding, &str)]) -> String .join(" | ") } -pub static SERVARR_CONTEXT_CLUES: [ContextClue; 2] = [ - (DEFAULT_KEYBINDINGS.tab, "change servarr"), +pub static SERVARR_CONTEXT_CLUES: [ContextClue; 3] = [ + ( + DEFAULT_KEYBINDINGS.next_servarr, + DEFAULT_KEYBINDINGS.next_servarr.desc, + ), + ( + DEFAULT_KEYBINDINGS.previous_servarr, + DEFAULT_KEYBINDINGS.previous_servarr.desc, + ), (DEFAULT_KEYBINDINGS.quit, DEFAULT_KEYBINDINGS.quit.desc), ]; diff --git a/src/app/context_clues_tests.rs b/src/app/context_clues_tests.rs index 5e164d9..ac76c8b 100644 --- a/src/app/context_clues_tests.rs +++ b/src/app/context_clues_tests.rs @@ -28,8 +28,13 @@ mod test { let (key_binding, description) = servarr_context_clues_iter.next().unwrap(); - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.tab); - assert_str_eq!(*description, "change servarr"); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.next_servarr); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.next_servarr.desc); + + let (key_binding, description) = servarr_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.previous_servarr); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.previous_servarr.desc); let (key_binding, description) = servarr_context_clues_iter.next().unwrap(); diff --git a/src/app/key_binding.rs b/src/app/key_binding.rs index cc171a1..f58a71d 100644 --- a/src/app/key_binding.rs +++ b/src/app/key_binding.rs @@ -32,7 +32,6 @@ generate_keybindings! { events, home, end, - tab, delete, submit, confirm, @@ -139,10 +138,6 @@ pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { key: Key::End, desc: "end", }, - tab: KeyBinding { - key: Key::Tab, - desc: "tab", - }, delete: KeyBinding { key: Key::Delete, desc: "delete", diff --git a/src/app/key_binding_tests.rs b/src/app/key_binding_tests.rs index f4270dc..64612ce 100644 --- a/src/app/key_binding_tests.rs +++ b/src/app/key_binding_tests.rs @@ -30,7 +30,6 @@ mod test { #[case(DEFAULT_KEYBINDINGS.update, Key::Char('u'), "update")] #[case(DEFAULT_KEYBINDINGS.home, Key::Home, "home")] #[case(DEFAULT_KEYBINDINGS.end, Key::End, "end")] - #[case(DEFAULT_KEYBINDINGS.tab, Key::Tab, "tab")] #[case(DEFAULT_KEYBINDINGS.delete, Key::Delete, "delete")] #[case(DEFAULT_KEYBINDINGS.submit, Key::Enter, "submit")] #[case(DEFAULT_KEYBINDINGS.confirm, Key::Ctrl('s'), "submit")] From 08f190fc6e22d05a1c5c8c9be7ee18044525d526 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 29 Nov 2024 15:58:19 -0700 Subject: [PATCH 03/82] feat(ui): Initial UI support for switching to Sonarr tabs --- src/app/app_tests.rs | 21 +- src/app/mod.rs | 2 +- src/app/radarr/mod.rs | 1 + src/app/sonarr/mod.rs | 1 + src/handlers/handlers_tests.rs | 8 + src/handlers/mod.rs | 2 + src/logos.rs | 17 +- src/ui/mod.rs | 15 +- src/ui/radarr_ui/mod.rs | 8 +- src/ui/sonarr_ui/library/library_ui_tests.rs | 75 +++++++ src/ui/sonarr_ui/library/mod.rs | 179 ++++++++++++++++ src/ui/sonarr_ui/mod.rs | 211 +++++++++++++++++++ src/ui/sonarr_ui/sonarr_ui_tests.rs | 16 ++ 13 files changed, 537 insertions(+), 19 deletions(-) create mode 100644 src/ui/sonarr_ui/library/library_ui_tests.rs create mode 100644 src/ui/sonarr_ui/library/mod.rs create mode 100644 src/ui/sonarr_ui/mod.rs create mode 100644 src/ui/sonarr_ui/sonarr_ui_tests.rs diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index 61e2685..1e92f3b 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -5,9 +5,9 @@ mod tests { use tokio::sync::mpsc; use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES}; - use crate::app::{App, AppConfig, ServarrConfig, DEFAULT_ROUTE}; - use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; - use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::app::{App, AppConfig, Data, ServarrConfig, DEFAULT_ROUTE}; + use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::{HorizontallyScrollableText, TabRoute}; use crate::network::radarr_network::RadarrEvent; use crate::network::NetworkEvent; @@ -118,10 +118,23 @@ mod tests { #[test] fn test_reset() { + let radarr_data = RadarrData { + version: "test".into(), + ..RadarrData::default() + }; + let sonarr_data = SonarrData { + version: "test".into(), + ..SonarrData::default() + }; + let data = Data { + radarr_data, + sonarr_data, + }; let mut app = App { tick_count: 2, error: "Test error".to_owned().into(), is_first_render: false, + data, ..App::default() }; @@ -130,6 +143,8 @@ mod tests { assert_eq!(app.tick_count, 0); assert_eq!(app.error, HorizontallyScrollableText::default()); assert!(app.is_first_render); + assert!(app.data.radarr_data.version.is_empty()); + assert!(app.data.sonarr_data.version.is_empty()); } #[test] diff --git a/src/app/mod.rs b/src/app/mod.rs index fe13eed..3579ad8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -78,12 +78,12 @@ impl<'a> App<'a> { self.tick_count = 0; } - // Allowing this code for now since we'll eventually be implementing additional Servarr support and we'll need it then #[allow(dead_code)] pub fn reset(&mut self) { self.reset_tick_count(); self.error = HorizontallyScrollableText::default(); self.is_first_render = true; + self.data = Data::default(); } pub fn handle_error(&mut self, error: Error) { diff --git a/src/app/radarr/mod.rs b/src/app/radarr/mod.rs index 542de61..1ecbfda 100644 --- a/src/app/radarr/mod.rs +++ b/src/app/radarr/mod.rs @@ -141,6 +141,7 @@ impl<'a> App<'a> { self.refresh_radarr_metadata().await; self.dispatch_by_radarr_block(&active_radarr_block).await; self.is_first_render = false; + return; } if self.should_refresh { diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index f1b443b..0cefe77 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -138,6 +138,7 @@ impl<'a> App<'a> { self.refresh_sonarr_metadata().await; self.dispatch_by_sonarr_block(&active_sonarr_block).await; self.is_first_render = false; + return; } if self.should_refresh { diff --git a/src/handlers/handlers_tests.rs b/src/handlers/handlers_tests.rs index 5f23b02..7b2503e 100644 --- a/src/handlers/handlers_tests.rs +++ b/src/handlers/handlers_tests.rs @@ -10,6 +10,7 @@ mod tests { use crate::handlers::{handle_clear_errors, handle_prompt_toggle}; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::HorizontallyScrollableText; use crate::models::Route; #[test] @@ -30,19 +31,26 @@ mod tests { T: Into + Copy, { let mut app = App::default(); + app.error = "Test".into(); app.server_tabs.set_index(index); handle_events(DEFAULT_KEYBINDINGS.previous_servarr.key, &mut app); assert_eq!(app.server_tabs.get_active_route(), left_block.into()); assert_eq!(app.get_current_route(), left_block.into()); + assert!(app.is_first_render); + assert_eq!(app.error, HorizontallyScrollableText::default()); app.server_tabs.set_index(index); + app.is_first_render = false; + app.error = "Test".into(); handle_events(DEFAULT_KEYBINDINGS.next_servarr.key, &mut app); assert_eq!(app.server_tabs.get_active_route(), right_block.into()); assert_eq!(app.get_current_route(), right_block.into()); + assert!(app.is_first_render); + assert_eq!(app.error, HorizontallyScrollableText::default()); } #[rstest] diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 196ce1c..358c127 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -82,9 +82,11 @@ pub trait KeyEventHandler<'a, 'b, T: Into + Copy> { pub fn handle_events(key: Key, app: &mut App<'_>) { if key == DEFAULT_KEYBINDINGS.next_servarr.key { + app.reset(); app.server_tabs.next(); app.pop_and_push_navigation_stack(app.server_tabs.get_active_route()); } else if key == DEFAULT_KEYBINDINGS.previous_servarr.key { + app.reset(); app.server_tabs.previous(); app.pop_and_push_navigation_stack(app.server_tabs.get_active_route()); } else if let Route::Radarr(active_radarr_block, context) = app.get_current_route() { diff --git a/src/logos.rs b/src/logos.rs index d7ae5db..f0c6815 100644 --- a/src/logos.rs +++ b/src/logos.rs @@ -6,15 +6,14 @@ pub const RADARR_LOGO: &str = "⠀⣠⣶⢶⣶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⢿⡇⠀⠀⣀⣤⣶⡿⠛⠉⠀⠀⠀⠀ ⠀⠀⠰⠶⡿⠟⠋⠁⠀⠀⠀⠀⠀⠀⠀ "; -// Allowing this code for now since we'll eventually be implementing additional Servarr support and we'll need it then -#[allow(dead_code)] -pub const SONARR_LOGO: &str = "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⢀⣄⠙⠻⠟⠋⣤⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⢸⣿⠆⢾⡗⢸⣿⡇⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠈⠋⣠⣴⣦⣄⠛⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +pub const SONARR_LOGO: &str = "⠀⠀⠀⣠⣴⣶⣿⣿⣿⣿⣶⣦⣄⠀⠀⠀ +⠀⣠⡀⠈⠻⣿⣿⣿⣿⣿⣿⠟⠁⢀⣀⠀ +⢰⣿⣿⣦⠐⠄⠉⠉⠉⠉⠠⠂⣰⣿⣿⡆ +⣿⣿⣿⣿⡆⠀⣴⣿⣿⣦⠀⢰⣿⣿⣿⣿ +⣿⣿⣿⣿⡇⠀⠻⣿⣿⠟⠀⠸⣿⣿⣿⣿ +⠸⣿⣿⠟⠠⠂⠀⢀⡀⠀⠐⠄⠻⣿⣿⠇ +⠀⠙⠁⢀⣴⣾⣿⣿⣿⣿⣷⣦⡀⠈⠋⠀ +⠀⠀⠀⠘⠻⠿⣿⣿⣿⣿⠿⠟⠋⠀⠀⠀ "; // Allowing this code for now since we'll eventually be implementing additional Servarr support and we'll need it then #[allow(dead_code)] diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 70b2637..61bc4a4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -8,6 +8,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Tabs; use ratatui::widgets::Wrap; use ratatui::Frame; +use sonarr_ui::SonarrUi; use crate::app::App; use crate::models::{HorizontallyScrollableText, Route, TabState}; @@ -20,6 +21,7 @@ use crate::ui::widgets::input_box::InputBox; use crate::ui::widgets::popup::Size; mod radarr_ui; +mod sonarr_ui; mod styles; mod utils; mod widgets; @@ -57,9 +59,16 @@ pub fn ui(f: &mut Frame<'_>, app: &mut App<'_>) { draw_header_row(f, app, header_area); - if RadarrUi::accepts(app.get_current_route()) { - RadarrUi::draw_context_row(f, app, context_area); - RadarrUi::draw(f, app, table_area); + match app.get_current_route() { + route if RadarrUi::accepts(route) => { + RadarrUi::draw_context_row(f, app, context_area); + RadarrUi::draw(f, app, table_area); + } + route if SonarrUi::accepts(route) => { + SonarrUi::draw_context_row(f, app, context_area); + SonarrUi::draw(f, app, table_area); + } + _ => (), } } diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index 0d2f10c..ddbb1a5 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -1,4 +1,4 @@ -use std::iter; +use std::{cmp, iter}; use chrono::{Duration, Utc}; use ratatui::layout::{Constraint, Layout, Rect}; @@ -178,15 +178,17 @@ fn draw_downloads_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { if !downloads_vec.is_empty() { f.render_widget(block, area); + let max_items = (((area.height as f64 / 2.0).floor() * 2.0) as usize) / 2; + let items = cmp::min(downloads_vec.len(), max_items - 1); let download_item_areas = Layout::vertical( iter::repeat(Constraint::Length(2)) - .take(downloads_vec.len()) + .take(items) .collect::>(), ) .margin(1) .split(area); - for i in 0..downloads_vec.len() { + for i in 0..items { let DownloadRecord { title, sizeleft, diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs new file mode 100644 index 0000000..31c40fc --- /dev/null +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -0,0 +1,75 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::{ + servarr_data::sonarr::sonarr_data::SERIES_BLOCKS, sonarr_models::SeriesStatus, + }; + use crate::ui::sonarr_ui::library::LibraryUi; + use crate::ui::styles::ManagarrStyle; + use crate::ui::DrawUi; + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::{ + models::sonarr_models::{Series, SeriesStatistics}, + ui::sonarr_ui::library::decorate_series_row_with_style, + }; + + #[test] + fn test_library_ui_accepts() { + let mut library_ui_blocks = Vec::new(); + library_ui_blocks.extend(SERIES_BLOCKS); + + ActiveSonarrBlock::iter().for_each(|active_radarr_block| { + if library_ui_blocks.contains(&active_radarr_block) { + assert!(LibraryUi::accepts(active_radarr_block.into())); + } else { + assert!(!LibraryUi::accepts(active_radarr_block.into())); + } + }); + } + + #[rstest] + #[case(SeriesStatus::Ended, None, RowStyle::Missing)] + #[case(SeriesStatus::Ended, Some(59.0), RowStyle::Missing)] + #[case(SeriesStatus::Ended, Some(100.0), RowStyle::Downloaded)] + #[case(SeriesStatus::Continuing, None, RowStyle::Missing)] + #[case(SeriesStatus::Continuing, Some(59.0), RowStyle::Missing)] + #[case(SeriesStatus::Continuing, Some(100.0), RowStyle::Unreleased)] + #[case(SeriesStatus::Upcoming, None, RowStyle::Unreleased)] + #[case(SeriesStatus::Deleted, None, RowStyle::Missing)] + fn test_decorate_series_row_with_style( + #[case] series_status: SeriesStatus, + #[case] percent_of_episodes: Option, + #[case] expected_row_style: RowStyle, + ) { + let mut series = Series { + status: series_status, + ..Series::default() + }; + if let Some(percentage) = percent_of_episodes { + series.statistics = Some(SeriesStatistics { + percent_of_episodes: percentage, + ..SeriesStatistics::default() + }); + } + + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_series_row_with_style(&series, row.clone()); + + match expected_row_style { + RowStyle::Downloaded => assert_eq!(style, row.downloaded()), + RowStyle::Missing => assert_eq!(style, row.missing()), + RowStyle::Unreleased => assert_eq!(style, row.unreleased()), + } + } + + enum RowStyle { + Downloaded, + Missing, + Unreleased, + } +} diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs new file mode 100644 index 0000000..6b77a47 --- /dev/null +++ b/src/ui/sonarr_ui/library/mod.rs @@ -0,0 +1,179 @@ +use ratatui::{ + layout::{Constraint, Rect}, + widgets::{Cell, Row}, + Frame, +}; + +use crate::{ + app::App, + models::{ + servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SERIES_BLOCKS}, + sonarr_models::{Series, SeriesStatus}, + EnumDisplayStyle, Route, + }, + ui::{ + styles::ManagarrStyle, + utils::{get_width_from_percentage, layout_block_top_border}, + widgets::managarr_table::ManagarrTable, + DrawUi, + }, + utils::convert_runtime, +}; + +#[cfg(test)] +#[path = "library_ui_tests.rs"] +mod library_ui_tests; + +pub(super) struct LibraryUi; + +impl DrawUi for LibraryUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return SERIES_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let route = app.get_current_route(); + let mut series_ui_matchers = |active_sonarr_block: ActiveSonarrBlock| match active_sonarr_block + { + ActiveSonarrBlock::Series | ActiveSonarrBlock::SeriesSortPrompt => draw_series(f, app, area), + _ => (), + }; + + match route { + Route::Sonarr(active_sonarr_block, _) if SERIES_BLOCKS.contains(&active_sonarr_block) => { + series_ui_matchers(active_sonarr_block) + } + _ => (), + } + } +} + +pub(super) fn draw_series(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let current_selection = if !app.data.sonarr_data.series.items.is_empty() { + app.data.sonarr_data.series.current_selection().clone() + } else { + Series::default() + }; + let quality_profile_map = &app.data.sonarr_data.quality_profile_map; + let language_profile_map = &app.data.sonarr_data.language_profiles_map; + let tags_map = &app.data.sonarr_data.tags_map; + let content = Some(&mut app.data.sonarr_data.series); + let help_footer = app + .data + .sonarr_data + .main_tabs + .get_active_tab_contextual_help(); + + let series_table_row_mapping = |series: &Series| { + series.title.scroll_left_or_reset( + get_width_from_percentage(area, 27), + *series == current_selection, + app.tick_count % app.ticks_until_scroll == 0, + ); + let monitored = if series.monitored { "🏷" } else { "" }; + let (hours, minutes) = convert_runtime(series.runtime); + let certification = series.certification.clone().unwrap_or_default(); + let network = series.network.clone().unwrap_or_default(); + let quality_profile = quality_profile_map + .get_by_left(&series.quality_profile_id) + .unwrap() + .to_owned(); + let language_profile = language_profile_map + .get_by_left(&series.language_profile_id) + .unwrap() + .to_owned(); + let tags = if !series.tags.is_empty() { + series + .tags + .iter() + .map(|tag_id| { + tags_map + .get_by_left(&tag_id.as_i64().unwrap()) + .unwrap() + .clone() + }) + .collect::>() + .join(", ") + } else { + String::new() + }; + + decorate_series_row_with_style( + series, + Row::new(vec![ + Cell::from(series.title.to_string()), + Cell::from(series.year.to_string()), + Cell::from(network), + Cell::from(format!("{hours}h {minutes}m")), + Cell::from(certification), + Cell::from(series.series_type.to_display_str()), + Cell::from(quality_profile), + Cell::from(language_profile), + Cell::from(monitored.to_owned()), + Cell::from(tags), + ]), + ) + }; + let series_table = ManagarrTable::new(content, series_table_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(help_footer) + .sorting(active_sonarr_block == ActiveSonarrBlock::SeriesSortPrompt) + .headers([ + "Title", + "Year", + "Network", + "Runtime", + "Rating", + "Type", + "Quality Profile", + "Language Profile", + "Monitored", + "Tags", + ]) + .constraints([ + Constraint::Percentage(27), + Constraint::Percentage(4), + Constraint::Percentage(10), + Constraint::Percentage(6), + Constraint::Percentage(6), + Constraint::Percentage(6), + Constraint::Percentage(13), + Constraint::Percentage(10), + Constraint::Percentage(6), + Constraint::Percentage(12), + ]); + + f.render_widget(series_table, area); + } +} + +fn decorate_series_row_with_style<'a>(series: &Series, row: Row<'a>) -> Row<'a> { + match series.status { + SeriesStatus::Ended => { + if let Some(ref stats) = series.statistics { + if stats.percent_of_episodes == 100.0 { + return row.downloaded(); + } + } + + row.missing() + } + SeriesStatus::Continuing => { + if let Some(ref stats) = series.statistics { + if stats.percent_of_episodes == 100.0 { + return row.unreleased(); + } + } + + row.missing() + } + SeriesStatus::Upcoming => row.unreleased(), + _ => row.missing(), + } +} diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs new file mode 100644 index 0000000..dbe45f5 --- /dev/null +++ b/src/ui/sonarr_ui/mod.rs @@ -0,0 +1,211 @@ +use std::{cmp, iter}; + +use chrono::{Duration, Utc}; +use library::LibraryUi; +use log::debug; +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::Stylize, + text::Text, + widgets::Paragraph, + Frame, +}; + +use crate::{ + app::App, + logos::SONARR_LOGO, + models::{ + servarr_data::sonarr::sonarr_data::SonarrData, + servarr_models::{DiskSpace, RootFolder}, + sonarr_models::DownloadRecord, + Route, + }, + utils::convert_to_gb, +}; + +use super::{ + draw_tabs, + styles::ManagarrStyle, + utils::{ + borderless_block, layout_block, line_gauge_with_label, line_gauge_with_title, title_block, + }, + widgets::loading_block::LoadingBlock, + DrawUi, +}; + +mod library; + +#[cfg(test)] +#[path = "sonarr_ui_tests.rs"] +mod sonarr_ui_tests; + +pub(super) struct SonarrUi; + +impl DrawUi for SonarrUi { + fn accepts(route: Route) -> bool { + matches!(route, Route::Sonarr(_, _)) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let content_area = draw_tabs(f, area, "Series", &app.data.sonarr_data.main_tabs); + let route = app.get_current_route(); + + match route { + _ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area), + _ => (), + } + } + + fn draw_context_row(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let [main_area, logo_area] = + Layout::horizontal([Constraint::Fill(0), Constraint::Length(20)]).areas(area); + + let [stats_area, downloads_area] = + Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(main_area); + + draw_stats_context(f, app, stats_area); + draw_downloads_context(f, app, downloads_area); + draw_sonarr_logo(f, logo_area); + } +} + +fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block("Stats"); + + if !app.data.sonarr_data.version.is_empty() { + f.render_widget(block, area); + let SonarrData { + root_folders, + disk_space_vec, + start_time, + .. + } = &app.data.sonarr_data; + + let mut constraints = vec![ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]; + + constraints.append( + &mut iter::repeat(Constraint::Length(1)) + .take(disk_space_vec.len() + root_folders.items.len() + 1) + .collect(), + ); + + let stat_item_areas = Layout::vertical(constraints).margin(1).split(area); + + let version_paragraph = Paragraph::new(Text::from(format!( + "Sonarr Version: {}", + app.data.sonarr_data.version + ))) + .block(borderless_block()) + .bold(); + + let uptime = Utc::now() - start_time.to_owned(); + let days = uptime.num_days(); + let day_difference = uptime - Duration::days(days); + let hours = day_difference.num_hours(); + let hour_difference = day_difference - Duration::hours(hours); + let minutes = hour_difference.num_minutes(); + let seconds = (hour_difference - Duration::minutes(minutes)).num_seconds(); + + let uptime_paragraph = Paragraph::new(Text::from(format!( + "Uptime: {days}d {hours:0width$}:{minutes:0width$}:{seconds:0width$}", + width = 2 + ))) + .block(borderless_block()) + .bold(); + + let storage = Paragraph::new(Text::from("Storage:")).block(borderless_block().bold()); + let folders = Paragraph::new(Text::from("Root Folders:")).block(borderless_block().bold()); + + f.render_widget(version_paragraph, stat_item_areas[0]); + f.render_widget(uptime_paragraph, stat_item_areas[1]); + f.render_widget(storage, stat_item_areas[2]); + + for i in 0..disk_space_vec.len() { + let DiskSpace { + free_space, + total_space, + } = &disk_space_vec[i]; + let title = format!("Disk {}", i + 1); + let ratio = if *total_space == 0 { + 0f64 + } else { + 1f64 - (*free_space as f64 / *total_space as f64) + }; + + let space_gauge = line_gauge_with_label(title.as_str(), ratio); + + f.render_widget(space_gauge, stat_item_areas[i + 3]); + } + + f.render_widget(folders, stat_item_areas[disk_space_vec.len() + 3]); + + for i in 0..root_folders.items.len() { + let RootFolder { + path, free_space, .. + } = &root_folders.items[i]; + let space: f64 = convert_to_gb(*free_space); + let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) + .block(borderless_block()) + .default(); + + f.render_widget( + root_folder_space, + stat_item_areas[i + disk_space_vec.len() + 4], + ) + } + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} + +fn draw_downloads_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block("Downloads"); + let downloads_vec = &app.data.sonarr_data.downloads.items; + + if !downloads_vec.is_empty() { + f.render_widget(block, area); + let max_items = (((area.height as f64 / 2.0).floor() * 2.0) as usize) / 2; + + let items = cmp::min(downloads_vec.len(), max_items - 1); + debug!("Items: {items}"); + let download_item_areas = Layout::vertical( + iter::repeat(Constraint::Length(2)) + .take(items) + .collect::>(), + ) + .margin(1) + .split(area); + + for i in 0..items { + let DownloadRecord { + title, + sizeleft, + size, + .. + } = &downloads_vec[i]; + let percent = if *size == 0.0 { + 0.0 + } else { + 1f64 - (*sizeleft / *size) + }; + let download_gauge = line_gauge_with_title(title, percent); + + f.render_widget(download_gauge, download_item_areas[i]); + } + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} + +fn draw_sonarr_logo(f: &mut Frame<'_>, area: Rect) { + let logo_text = Text::from(SONARR_LOGO); + let logo = Paragraph::new(logo_text) + .light_cyan() + .block(layout_block().default()) + .centered(); + f.render_widget(logo, area); +} diff --git a/src/ui/sonarr_ui/sonarr_ui_tests.rs b/src/ui/sonarr_ui/sonarr_ui_tests.rs new file mode 100644 index 0000000..6a1630e --- /dev/null +++ b/src/ui/sonarr_ui/sonarr_ui_tests.rs @@ -0,0 +1,16 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::{ + models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, + ui::{sonarr_ui::SonarrUi, DrawUi}, + }; + + #[test] + fn test_sonarr_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + assert!(SonarrUi::accepts(active_sonarr_block.into())); + }); + } +} From 9b2040059d46c41644f5d83dcb12fed8560a9cce Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 29 Nov 2024 16:31:51 -0700 Subject: [PATCH 04/82] fix(ui): Fixed a potential rare bug in the UI where the application would panic if the height of the downloads window is 0. --- src/ui/radarr_ui/mod.rs | 4 ++-- src/ui/sonarr_ui/mod.rs | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index ddbb1a5..36d6337 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -178,8 +178,8 @@ fn draw_downloads_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { if !downloads_vec.is_empty() { f.render_widget(block, area); - let max_items = (((area.height as f64 / 2.0).floor() * 2.0) as usize) / 2; - let items = cmp::min(downloads_vec.len(), max_items - 1); + let max_items = ((((area.height as f32 / 2.0).floor() * 2.0) as i32) / 2) - 1; + let items = cmp::min(downloads_vec.len(), max_items.unsigned_abs() as usize); let download_item_areas = Layout::vertical( iter::repeat(Constraint::Length(2)) .take(items) diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index dbe45f5..fe95448 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -2,7 +2,6 @@ use std::{cmp, iter}; use chrono::{Duration, Utc}; use library::LibraryUi; -use log::debug; use ratatui::{ layout::{Constraint, Layout, Rect}, style::Stylize, @@ -168,10 +167,9 @@ fn draw_downloads_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { if !downloads_vec.is_empty() { f.render_widget(block, area); - let max_items = (((area.height as f64 / 2.0).floor() * 2.0) as usize) / 2; - let items = cmp::min(downloads_vec.len(), max_items - 1); - debug!("Items: {items}"); + let max_items = ((((area.height as f64 / 2.0).floor() * 2.0) as i64) / 2) - 1; + let items = cmp::min(downloads_vec.len(), max_items.abs() as usize); let download_item_areas = Layout::vertical( iter::repeat(Constraint::Length(2)) .take(items) From f7c96d81e91e89ba47804d79416320148454d818 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sat, 30 Nov 2024 12:22:46 -0700 Subject: [PATCH 05/82] refactor(BlockSelectionState): Refactored so selection of blocks in 2x2 grids is more intuitive and added left() and right() methods to aid this effort. --- src/handlers/mod.rs | 13 ++ .../collections/collection_details_handler.rs | 4 +- .../collection_details_handler_tests.rs | 4 +- .../collections/edit_collection_handler.rs | 6 +- .../edit_collection_handler_tests.rs | 34 ++-- .../radarr_handlers/collections/mod.rs | 2 +- .../indexers/edit_indexer_handler.rs | 20 +- .../indexers/edit_indexer_handler_tests.rs | 69 +++---- .../indexers/edit_indexer_settings_handler.rs | 20 +- .../edit_indexer_settings_handler_tests.rs | 73 ++++---- .../indexers/indexers_handler_tests.rs | 6 +- src/handlers/radarr_handlers/indexers/mod.rs | 6 +- .../library/add_movie_handler.rs | 6 +- .../library/add_movie_handler_tests.rs | 26 +-- .../library/delete_movie_handler.rs | 4 +- .../library/delete_movie_handler_tests.rs | 28 ++- .../library/edit_movie_handler.rs | 4 +- .../library/edit_movie_handler_tests.rs | 34 ++-- .../library/library_handler_tests.rs | 2 +- src/handlers/radarr_handlers/library/mod.rs | 4 +- .../library/movie_details_handler.rs | 2 +- .../radarr_handler_test_utils.rs | 4 +- src/models/mod.rs | 44 +++-- src/models/model_tests.rs | 119 ++++++++---- src/models/servarr_data/radarr/radarr_data.rs | 146 +++++++++------ .../servarr_data/radarr/radarr_data_tests.rs | 177 ++++++++---------- src/ui/sonarr_ui/mod.rs | 2 +- 27 files changed, 472 insertions(+), 387 deletions(-) diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 358c127..95a8a33 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -140,3 +140,16 @@ macro_rules! handle_text_box_keys { } }; } + +#[macro_export] +macro_rules! handle_prompt_left_right_keys { + ($self:expr, $confirm_prompt:expr, $data:ident) => { + if $self.app.data.$data.selected_block.get_active_block() == $confirm_prompt { + handle_prompt_toggle($self.app, $self.key); + } else if $self.key == DEFAULT_KEYBINDINGS.left.key { + $self.app.data.$data.selected_block.left(); + } else { + $self.app.data.$data.selected_block.right(); + } + }; +} diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler.rs b/src/handlers/radarr_handlers/collections/collection_details_handler.rs index 89f5308..8d09807 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler.rs @@ -111,7 +111,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHan .into(), ); self.app.data.radarr_data.selected_block = - BlockSelectionState::new(&ADD_MOVIE_SELECTION_BLOCKS); + BlockSelectionState::new(ADD_MOVIE_SELECTION_BLOCKS); self.app.data.radarr_data.add_movie_modal = Some((&self.app.data.radarr_data).into()); } } @@ -141,7 +141,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHan ); self.app.data.radarr_data.edit_collection_modal = Some((&self.app.data.radarr_data).into()); self.app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_COLLECTION_SELECTION_BLOCKS); } } } diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs b/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs index 4b46809..32c40b4 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs @@ -171,12 +171,12 @@ mod tests { .set_items(vec![CollectionMovie::default()]); app.data.radarr_data.quality_profile_map = BiMap::from_iter([(1, "B - Test 2".to_owned()), (0, "A - Test 1".to_owned())]); - app.data.radarr_data.selected_block = BlockSelectionState::new(&ADD_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block = BlockSelectionState::new(ADD_MOVIE_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(ADD_MOVIE_SELECTION_BLOCKS.len() - 1); + .set_index(0, ADD_MOVIE_SELECTION_BLOCKS.len() - 1); CollectionDetailsHandler::with( SUBMIT_KEY, diff --git a/src/handlers/radarr_handlers/collections/edit_collection_handler.rs b/src/handlers/radarr_handlers/collections/edit_collection_handler.rs index 9754397..f29f8fd 100644 --- a/src/handlers/radarr_handlers/collections/edit_collection_handler.rs +++ b/src/handlers/radarr_handlers/collections/edit_collection_handler.rs @@ -65,9 +65,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditCollectionHandle .unwrap() .quality_profile_list .scroll_up(), - ActiveRadarrBlock::EditCollectionPrompt => { - self.app.data.radarr_data.selected_block.previous() - } + ActiveRadarrBlock::EditCollectionPrompt => self.app.data.radarr_data.selected_block.up(), _ => (), } } @@ -92,7 +90,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditCollectionHandle .unwrap() .quality_profile_list .scroll_down(), - ActiveRadarrBlock::EditCollectionPrompt => self.app.data.radarr_data.selected_block.next(), + ActiveRadarrBlock::EditCollectionPrompt => self.app.data.radarr_data.selected_block.down(), _ => (), } } diff --git a/src/handlers/radarr_handlers/collections/edit_collection_handler_tests.rs b/src/handlers/radarr_handlers/collections/edit_collection_handler_tests.rs index 32bb457..d8f9f5b 100644 --- a/src/handlers/radarr_handlers/collections/edit_collection_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/edit_collection_handler_tests.rs @@ -149,8 +149,8 @@ mod tests { let mut app = App::default(); app.data.radarr_data.edit_collection_modal = Some(EditCollectionModal::default()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.next(); + BlockSelectionState::new(EDIT_COLLECTION_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.down(); EditCollectionHandler::with(key, &mut app, ActiveRadarrBlock::EditCollectionPrompt, None) .handle(); @@ -176,8 +176,8 @@ mod tests { app.is_loading = true; app.data.radarr_data.edit_collection_modal = Some(EditCollectionModal::default()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.next(); + BlockSelectionState::new(EDIT_COLLECTION_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.down(); EditCollectionHandler::with(key, &mut app, ActiveRadarrBlock::EditCollectionPrompt, None) .handle(); @@ -494,12 +494,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); app.push_navigation_stack(ActiveRadarrBlock::EditCollectionPrompt.into()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_COLLECTION_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); + .set_index(0, EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); EditCollectionHandler::with( SUBMIT_KEY, @@ -524,12 +524,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::EditCollectionPrompt.into()); app.data.radarr_data.prompt_confirm = true; app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_COLLECTION_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); + .set_index(0, EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); EditCollectionHandler::with( SUBMIT_KEY, @@ -559,12 +559,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::EditCollectionPrompt.into()); app.data.radarr_data.prompt_confirm = true; app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_COLLECTION_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); + .set_index(0, EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); EditCollectionHandler::with( SUBMIT_KEY, @@ -591,7 +591,7 @@ mod tests { let mut app = App::default(); app.data.radarr_data.edit_collection_modal = Some(EditCollectionModal::default()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_COLLECTION_SELECTION_BLOCKS); app.push_navigation_stack(current_route); EditCollectionHandler::with( @@ -644,12 +644,12 @@ mod tests { let mut app = App::default(); app.data.radarr_data.edit_collection_modal = Some(EditCollectionModal::default()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_COLLECTION_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 2); + .set_index(0, EDIT_COLLECTION_SELECTION_BLOCKS.len() - 2); app.push_navigation_stack(current_route); EditCollectionHandler::with( @@ -711,8 +711,8 @@ mod tests { .into(), ); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.set_index(index); + BlockSelectionState::new(EDIT_COLLECTION_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.set_index(0, index); EditCollectionHandler::with( SUBMIT_KEY, @@ -923,12 +923,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); app.push_navigation_stack(ActiveRadarrBlock::EditCollectionPrompt.into()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_COLLECTION_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); + .set_index(0, EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); EditCollectionHandler::with( DEFAULT_KEYBINDINGS.confirm.key, diff --git a/src/handlers/radarr_handlers/collections/mod.rs b/src/handlers/radarr_handlers/collections/mod.rs index 7f266b6..de1a987 100644 --- a/src/handlers/radarr_handlers/collections/mod.rs +++ b/src/handlers/radarr_handlers/collections/mod.rs @@ -334,7 +334,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' self.app.data.radarr_data.edit_collection_modal = Some((&self.app.data.radarr_data).into()); self.app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_COLLECTION_SELECTION_BLOCKS); } _ if key == DEFAULT_KEYBINDINGS.update.key => { self diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs index f641df3..daf054c 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs @@ -4,7 +4,7 @@ use crate::event::Key; use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_INDEXER_BLOCKS}; use crate::network::radarr_network::RadarrEvent; -use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; +use crate::{handle_prompt_left_right_keys, handle_text_box_keys, handle_text_box_left_right_keys}; #[cfg(test)] #[path = "edit_indexer_handler_tests.rs"] @@ -46,13 +46,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<' fn handle_scroll_up(&mut self) { if self.active_radarr_block == ActiveRadarrBlock::EditIndexerPrompt { - self.app.data.radarr_data.selected_block.previous(); + self.app.data.radarr_data.selected_block.up(); } } fn handle_scroll_down(&mut self) { if self.active_radarr_block == ActiveRadarrBlock::EditIndexerPrompt { - self.app.data.radarr_data.selected_block.next(); + self.app.data.radarr_data.selected_block.down(); } } @@ -183,15 +183,11 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<' fn handle_left_right_action(&mut self) { match self.active_radarr_block { ActiveRadarrBlock::EditIndexerPrompt => { - if self.app.data.radarr_data.selected_block.get_active_block() - == ActiveRadarrBlock::EditIndexerConfirmPrompt - { - handle_prompt_toggle(self.app, self.key); - } else { - let len = self.app.data.radarr_data.selected_block.blocks.len(); - let idx = self.app.data.radarr_data.selected_block.index; - self.app.data.radarr_data.selected_block.index = (idx + 5) % len; - } + handle_prompt_left_right_keys!( + self, + ActiveRadarrBlock::EditIndexerConfirmPrompt, + radarr_data + ); } ActiveRadarrBlock::EditIndexerNameInput => { handle_text_box_left_right_keys!( diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs index 1774b5e..507de3c 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs @@ -25,8 +25,8 @@ mod tests { let mut app = App::default(); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.next(); + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.down(); EditIndexerHandler::with(key, &mut app, ActiveRadarrBlock::EditIndexerPrompt, None).handle(); @@ -51,8 +51,8 @@ mod tests { app.is_loading = true; app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.next(); + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.down(); EditIndexerHandler::with(key, &mut app, ActiveRadarrBlock::EditIndexerPrompt, None).handle(); @@ -346,8 +346,8 @@ mod tests { fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { let mut app = App::default(); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.index = EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1; + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.y = EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1; EditIndexerHandler::with(key, &mut app, ActiveRadarrBlock::EditIndexerPrompt, None).handle(); @@ -381,14 +381,14 @@ mod tests { )] fn test_left_right_block_toggle_torrents( #[values(Key::Left, Key::Right)] key: Key, - #[case] starting_index: usize, + #[case] starting_y_index: usize, #[case] left_block: ActiveRadarrBlock, #[case] right_block: ActiveRadarrBlock, ) { let mut app = App::default(); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.index = starting_index; + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.y = starting_y_index; assert_eq!( app.data.radarr_data.selected_block.get_active_block(), @@ -428,14 +428,14 @@ mod tests { )] fn test_left_right_block_toggle_nzb( #[values(Key::Left, Key::Right)] key: Key, - #[case] starting_index: usize, + #[case] starting_y_index: usize, #[case] left_block: ActiveRadarrBlock, #[case] right_block: ActiveRadarrBlock, ) { let mut app = App::default(); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_INDEXER_NZB_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.index = starting_index; + BlockSelectionState::new(EDIT_INDEXER_NZB_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.y = starting_y_index; assert_eq!( app.data.radarr_data.selected_block.get_active_block(), @@ -463,8 +463,8 @@ mod tests { ) { let mut app = App::default(); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_INDEXER_NZB_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.index = 3; + BlockSelectionState::new(EDIT_INDEXER_NZB_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.y = 3; app.data.radarr_data.prompt_confirm = false; assert_eq!( @@ -765,12 +765,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.push_navigation_stack(ActiveRadarrBlock::EditIndexerPrompt.into()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1); + .set_index(0, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); EditIndexerHandler::with( @@ -793,12 +793,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.push_navigation_stack(ActiveRadarrBlock::EditIndexerPrompt.into()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1); + .set_index(0, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); app.data.radarr_data.prompt_confirm = true; @@ -846,25 +846,26 @@ mod tests { } #[rstest] - #[case(0, ActiveRadarrBlock::EditIndexerNameInput)] - #[case(5, ActiveRadarrBlock::EditIndexerUrlInput)] - #[case(6, ActiveRadarrBlock::EditIndexerApiKeyInput)] - #[case(7, ActiveRadarrBlock::EditIndexerSeedRatioInput)] - #[case(8, ActiveRadarrBlock::EditIndexerTagsInput)] + #[case(0, 0, ActiveRadarrBlock::EditIndexerNameInput)] + #[case(0, 1, ActiveRadarrBlock::EditIndexerUrlInput)] + #[case(1, 1, ActiveRadarrBlock::EditIndexerApiKeyInput)] + #[case(2, 1, ActiveRadarrBlock::EditIndexerSeedRatioInput)] + #[case(3, 1, ActiveRadarrBlock::EditIndexerTagsInput)] fn test_edit_indexer_prompt_submit_input_fields( - #[case] starting_index: usize, + #[case] starting_y: usize, + #[case] starting_x: usize, #[case] block: ActiveRadarrBlock, ) { let mut app = App::default(); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); app.push_navigation_stack(ActiveRadarrBlock::EditIndexerPrompt.into()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(starting_index); + .set_index(starting_x, starting_y); EditIndexerHandler::with( SUBMIT_KEY, @@ -883,8 +884,8 @@ mod tests { let mut app = App::default(); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.set_index(1); + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.set_index(0, 1); app.push_navigation_stack(ActiveRadarrBlock::EditIndexerPrompt.into()); EditIndexerHandler::with( @@ -935,8 +936,8 @@ mod tests { let mut app = App::default(); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.set_index(2); + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.set_index(0, 2); app.push_navigation_stack(ActiveRadarrBlock::EditIndexerPrompt.into()); EditIndexerHandler::with( @@ -987,8 +988,8 @@ mod tests { let mut app = App::default(); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.set_index(3); + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.set_index(0, 3); app.push_navigation_stack(ActiveRadarrBlock::EditIndexerPrompt.into()); EditIndexerHandler::with( @@ -1560,12 +1561,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.push_navigation_stack(ActiveRadarrBlock::EditIndexerPrompt.into()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1); + .set_index(0, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); EditIndexerHandler::with( diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs index 92875c9..046e7b5 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs @@ -6,7 +6,7 @@ use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, INDEXER_SETTINGS_BLOCKS, }; use crate::network::radarr_network::RadarrEvent; -use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; +use crate::{handle_prompt_left_right_keys, handle_text_box_keys, handle_text_box_left_right_keys}; #[cfg(test)] #[path = "edit_indexer_settings_handler_tests.rs"] @@ -50,7 +50,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl let indexer_settings = self.app.data.radarr_data.indexer_settings.as_mut().unwrap(); match self.active_radarr_block { ActiveRadarrBlock::AllIndexerSettingsPrompt => { - self.app.data.radarr_data.selected_block.previous(); + self.app.data.radarr_data.selected_block.up(); } ActiveRadarrBlock::IndexerSettingsMinimumAgeInput => { indexer_settings.minimum_age += 1; @@ -75,7 +75,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl let indexer_settings = self.app.data.radarr_data.indexer_settings.as_mut().unwrap(); match self.active_radarr_block { ActiveRadarrBlock::AllIndexerSettingsPrompt => { - self.app.data.radarr_data.selected_block.next() + self.app.data.radarr_data.selected_block.down() } ActiveRadarrBlock::IndexerSettingsMinimumAgeInput => { if indexer_settings.minimum_age > 0 { @@ -137,15 +137,11 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl fn handle_left_right_action(&mut self) { match self.active_radarr_block { ActiveRadarrBlock::AllIndexerSettingsPrompt => { - if self.app.data.radarr_data.selected_block.get_active_block() - == ActiveRadarrBlock::IndexerSettingsConfirmPrompt - { - handle_prompt_toggle(self.app, self.key); - } else { - let len = self.app.data.radarr_data.selected_block.blocks.len(); - let idx = self.app.data.radarr_data.selected_block.index; - self.app.data.radarr_data.selected_block.index = (idx + 5) % len; - } + handle_prompt_left_right_keys!( + self, + ActiveRadarrBlock::IndexerSettingsConfirmPrompt, + radarr_data + ); } ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput => { handle_text_box_left_right_keys!( diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler_tests.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler_tests.rs index e524ea2..8d3bddf 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler_tests.rs @@ -98,8 +98,8 @@ mod tests { let mut app = App::default(); app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.next(); + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.down(); IndexerSettingsHandler::with( key, @@ -130,8 +130,8 @@ mod tests { app.is_loading = true; app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.next(); + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.down(); IndexerSettingsHandler::with( key, @@ -276,8 +276,8 @@ mod tests { fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { let mut app = App::default(); app.data.radarr_data.selected_block = - BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.index = INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1; + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.y = INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1; IndexerSettingsHandler::with( key, @@ -323,14 +323,14 @@ mod tests { )] fn test_left_right_block_toggle( #[values(Key::Left, Key::Right)] key: Key, - #[case] starting_index: usize, + #[case] starting_y_index: usize, #[case] left_block: ActiveRadarrBlock, #[case] right_block: ActiveRadarrBlock, ) { let mut app = App::default(); app.data.radarr_data.selected_block = - BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.index = starting_index; + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.y = starting_y_index; assert_eq!( app.data.radarr_data.selected_block.get_active_block(), @@ -438,12 +438,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS); + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1); + .set_index(0, INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1); app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); IndexerSettingsHandler::with( @@ -466,12 +466,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS); + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1); + .set_index(0, INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1); app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); app.data.radarr_data.prompt_confirm = true; @@ -517,21 +517,26 @@ mod tests { } #[rstest] - #[case(ActiveRadarrBlock::IndexerSettingsMinimumAgeInput, 0)] - #[case(ActiveRadarrBlock::IndexerSettingsRetentionInput, 1)] - #[case(ActiveRadarrBlock::IndexerSettingsMaximumSizeInput, 2)] - #[case(ActiveRadarrBlock::IndexerSettingsAvailabilityDelayInput, 5)] - #[case(ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput, 6)] + #[case(ActiveRadarrBlock::IndexerSettingsMinimumAgeInput, 0, 0)] + #[case(ActiveRadarrBlock::IndexerSettingsRetentionInput, 1, 0)] + #[case(ActiveRadarrBlock::IndexerSettingsMaximumSizeInput, 2, 0)] + #[case(ActiveRadarrBlock::IndexerSettingsAvailabilityDelayInput, 0, 1)] + #[case(ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput, 1, 1)] fn test_edit_indexer_settings_prompt_submit_selected_block( #[case] selected_block: ActiveRadarrBlock, - #[case] index: usize, + #[case] y_index: usize, + #[case] x_index: usize, ) { let mut app = App::default(); app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); app.push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.set_index(index); + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app + .data + .radarr_data + .selected_block + .set_index(x_index, y_index); IndexerSettingsHandler::with( SUBMIT_KEY, @@ -546,15 +551,19 @@ mod tests { #[rstest] fn test_edit_indexer_settings_prompt_submit_selected_block_no_op_when_not_ready( - #[values(0, 1, 2, 5, 6)] index: usize, + #[values((0, 0), (1, 0), (2, 0), (0, 1), (1, 1))] index: (usize, usize), ) { let mut app = App::default(); app.is_loading = true; app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); app.push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.set_index(index); + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app + .data + .radarr_data + .selected_block + .set_index(index.1, index.0); IndexerSettingsHandler::with( SUBMIT_KEY, @@ -576,8 +585,8 @@ mod tests { app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); app.push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.set_index(7); + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.set_index(1, 2); IndexerSettingsHandler::with( SUBMIT_KEY, @@ -599,8 +608,8 @@ mod tests { let mut app = App::default(); app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.set_index(3); + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.set_index(0, 3); app.push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into()); IndexerSettingsHandler::with( @@ -653,8 +662,8 @@ mod tests { let mut app = App::default(); app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.set_index(8); + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.set_index(1, 3); app.push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into()); IndexerSettingsHandler::with( @@ -922,12 +931,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into()); app.data.radarr_data.selected_block = - BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS); + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1); + .set_index(0, INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1); app.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); IndexerSettingsHandler::with( diff --git a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs index 0f55986..aa875f5 100644 --- a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs @@ -311,12 +311,12 @@ mod tests { if torrent_protocol { assert_eq!( app.data.radarr_data.selected_block.blocks, - &EDIT_INDEXER_TORRENT_SELECTION_BLOCKS + EDIT_INDEXER_TORRENT_SELECTION_BLOCKS ); } else { assert_eq!( app.data.radarr_data.selected_block.blocks, - &EDIT_INDEXER_NZB_SELECTION_BLOCKS + EDIT_INDEXER_NZB_SELECTION_BLOCKS ); } } @@ -570,7 +570,7 @@ mod tests { ); assert_eq!( app.data.radarr_data.selected_block.blocks, - &INDEXER_SETTINGS_SELECTION_BLOCKS + INDEXER_SETTINGS_SELECTION_BLOCKS ); } diff --git a/src/handlers/radarr_handlers/indexers/mod.rs b/src/handlers/radarr_handlers/indexers/mod.rs index 9994fa4..49c39ce 100644 --- a/src/handlers/radarr_handlers/indexers/mod.rs +++ b/src/handlers/radarr_handlers/indexers/mod.rs @@ -141,10 +141,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a, .protocol; if protocol == "torrent" { self.app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); } else { self.app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_INDEXER_NZB_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_INDEXER_NZB_SELECTION_BLOCKS); } } _ => (), @@ -192,7 +192,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a, .app .push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into()); self.app.data.radarr_data.selected_block = - BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS); + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); } _ => (), }, diff --git a/src/handlers/radarr_handlers/library/add_movie_handler.rs b/src/handlers/radarr_handlers/library/add_movie_handler.rs index 67da66d..ed555f6 100644 --- a/src/handlers/radarr_handlers/library/add_movie_handler.rs +++ b/src/handlers/radarr_handlers/library/add_movie_handler.rs @@ -91,7 +91,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a, .unwrap() .root_folder_list .scroll_up(), - ActiveRadarrBlock::AddMoviePrompt => self.app.data.radarr_data.selected_block.previous(), + ActiveRadarrBlock::AddMoviePrompt => self.app.data.radarr_data.selected_block.up(), _ => (), } } @@ -142,7 +142,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a, .unwrap() .root_folder_list .scroll_down(), - ActiveRadarrBlock::AddMoviePrompt => self.app.data.radarr_data.selected_block.next(), + ActiveRadarrBlock::AddMoviePrompt => self.app.data.radarr_data.selected_block.down(), _ => (), } } @@ -360,7 +360,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a, .push_navigation_stack(ActiveRadarrBlock::AddMoviePrompt.into()); self.app.data.radarr_data.add_movie_modal = Some((&self.app.data.radarr_data).into()); self.app.data.radarr_data.selected_block = - BlockSelectionState::new(&ADD_MOVIE_SELECTION_BLOCKS); + BlockSelectionState::new(ADD_MOVIE_SELECTION_BLOCKS); } } ActiveRadarrBlock::AddMoviePrompt => { diff --git a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs index e33e48d..18b4036 100644 --- a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs @@ -380,8 +380,8 @@ mod tests { #[rstest] fn test_add_movie_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { let mut app = App::default(); - app.data.radarr_data.selected_block = BlockSelectionState::new(&ADD_MOVIE_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.next(); + app.data.radarr_data.selected_block = BlockSelectionState::new(ADD_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.down(); AddMovieHandler::with(key, &mut app, ActiveRadarrBlock::AddMoviePrompt, None).handle(); @@ -402,8 +402,8 @@ mod tests { fn test_add_movie_prompt_scroll_no_op_when_not_ready(#[values(Key::Up, Key::Down)] key: Key) { let mut app = App::default(); app.is_loading = true; - app.data.radarr_data.selected_block = BlockSelectionState::new(&ADD_MOVIE_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.next(); + app.data.radarr_data.selected_block = BlockSelectionState::new(ADD_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.down(); AddMovieHandler::with(key, &mut app, ActiveRadarrBlock::AddMoviePrompt, None).handle(); @@ -1170,12 +1170,12 @@ mod tests { let mut app = App::default(); app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(ActiveRadarrBlock::AddMoviePrompt.into()); - app.data.radarr_data.selected_block = BlockSelectionState::new(&ADD_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block = BlockSelectionState::new(ADD_MOVIE_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(ADD_MOVIE_SELECTION_BLOCKS.len() - 1); + .set_index(0, ADD_MOVIE_SELECTION_BLOCKS.len() - 1); AddMovieHandler::with( SUBMIT_KEY, @@ -1196,12 +1196,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(ActiveRadarrBlock::AddMoviePrompt.into()); app.data.radarr_data.prompt_confirm = true; - app.data.radarr_data.selected_block = BlockSelectionState::new(&ADD_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block = BlockSelectionState::new(ADD_MOVIE_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(ADD_MOVIE_SELECTION_BLOCKS.len() - 1); + .set_index(0, ADD_MOVIE_SELECTION_BLOCKS.len() - 1); AddMovieHandler::with( SUBMIT_KEY, @@ -1227,7 +1227,7 @@ mod tests { #[case(ActiveRadarrBlock::AddMovieTagsInput, 4)] fn test_add_movie_prompt_selected_block_submit( #[case] selected_block: ActiveRadarrBlock, - #[case] index: usize, + #[case] y_index: usize, ) { let mut app = App::default(); app.push_navigation_stack( @@ -1237,8 +1237,8 @@ mod tests { ) .into(), ); - app.data.radarr_data.selected_block = BlockSelectionState::new(&ADD_MOVIE_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.set_index(index); + app.data.radarr_data.selected_block = BlockSelectionState::new(ADD_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.set_index(0, y_index); AddMovieHandler::with( SUBMIT_KEY, @@ -1594,12 +1594,12 @@ mod tests { app.data.radarr_data.add_movie_modal = Some(AddMovieModal::default()); app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(ActiveRadarrBlock::AddMoviePrompt.into()); - app.data.radarr_data.selected_block = BlockSelectionState::new(&ADD_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block = BlockSelectionState::new(ADD_MOVIE_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(ADD_MOVIE_SELECTION_BLOCKS.len() - 1); + .set_index(0, ADD_MOVIE_SELECTION_BLOCKS.len() - 1); AddMovieHandler::with( DEFAULT_KEYBINDINGS.confirm.key, diff --git a/src/handlers/radarr_handlers/library/delete_movie_handler.rs b/src/handlers/radarr_handlers/library/delete_movie_handler.rs index a90529c..6e78552 100644 --- a/src/handlers/radarr_handlers/library/delete_movie_handler.rs +++ b/src/handlers/radarr_handlers/library/delete_movie_handler.rs @@ -45,13 +45,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DeleteMovieHandler<' fn handle_scroll_up(&mut self) { if self.active_radarr_block == ActiveRadarrBlock::DeleteMoviePrompt { - self.app.data.radarr_data.selected_block.previous(); + self.app.data.radarr_data.selected_block.up(); } } fn handle_scroll_down(&mut self) { if self.active_radarr_block == ActiveRadarrBlock::DeleteMoviePrompt { - self.app.data.radarr_data.selected_block.next(); + self.app.data.radarr_data.selected_block.down(); } } diff --git a/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs b/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs index 437799a..214aa46 100644 --- a/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs @@ -21,9 +21,8 @@ mod tests { #[rstest] fn test_delete_movie_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { let mut app = App::default(); - app.data.radarr_data.selected_block = - BlockSelectionState::new(&DELETE_MOVIE_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.next(); + app.data.radarr_data.selected_block = BlockSelectionState::new(DELETE_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.down(); DeleteMovieHandler::with(key, &mut app, ActiveRadarrBlock::DeleteMoviePrompt, None).handle(); @@ -46,9 +45,8 @@ mod tests { ) { let mut app = App::default(); app.is_loading = true; - app.data.radarr_data.selected_block = - BlockSelectionState::new(&DELETE_MOVIE_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.next(); + app.data.radarr_data.selected_block = BlockSelectionState::new(DELETE_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.down(); DeleteMovieHandler::with(key, &mut app, ActiveRadarrBlock::DeleteMoviePrompt, None).handle(); @@ -94,13 +92,12 @@ mod tests { let mut app = App::default(); app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(ActiveRadarrBlock::DeleteMoviePrompt.into()); - app.data.radarr_data.selected_block = - BlockSelectionState::new(&DELETE_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block = BlockSelectionState::new(DELETE_MOVIE_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(DELETE_MOVIE_SELECTION_BLOCKS.len() - 1); + .set_index(0, DELETE_MOVIE_SELECTION_BLOCKS.len() - 1); app.data.radarr_data.delete_movie_files = true; app.data.radarr_data.add_list_exclusion = true; @@ -127,13 +124,12 @@ mod tests { app.data.radarr_data.prompt_confirm = true; app.data.radarr_data.delete_movie_files = true; app.data.radarr_data.add_list_exclusion = true; - app.data.radarr_data.selected_block = - BlockSelectionState::new(&DELETE_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block = BlockSelectionState::new(DELETE_MOVIE_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(DELETE_MOVIE_SELECTION_BLOCKS.len() - 1); + .set_index(0, DELETE_MOVIE_SELECTION_BLOCKS.len() - 1); DeleteMovieHandler::with( SUBMIT_KEY, @@ -187,8 +183,7 @@ mod tests { fn test_delete_movie_toggle_delete_files_submit() { let current_route = ActiveRadarrBlock::DeleteMoviePrompt.into(); let mut app = App::default(); - app.data.radarr_data.selected_block = - BlockSelectionState::new(&DELETE_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block = BlockSelectionState::new(DELETE_MOVIE_SELECTION_BLOCKS); app.push_navigation_stack(ActiveRadarrBlock::DeleteMoviePrompt.into()); DeleteMovieHandler::with( @@ -263,13 +258,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::DeleteMoviePrompt.into()); app.data.radarr_data.delete_movie_files = true; app.data.radarr_data.add_list_exclusion = true; - app.data.radarr_data.selected_block = - BlockSelectionState::new(&DELETE_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block = BlockSelectionState::new(DELETE_MOVIE_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(DELETE_MOVIE_SELECTION_BLOCKS.len() - 1); + .set_index(0, DELETE_MOVIE_SELECTION_BLOCKS.len() - 1); DeleteMovieHandler::with( DEFAULT_KEYBINDINGS.confirm.key, diff --git a/src/handlers/radarr_handlers/library/edit_movie_handler.rs b/src/handlers/radarr_handlers/library/edit_movie_handler.rs index 7b85e96..c11e908 100644 --- a/src/handlers/radarr_handlers/library/edit_movie_handler.rs +++ b/src/handlers/radarr_handlers/library/edit_movie_handler.rs @@ -65,7 +65,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditMovieHandler<'a, .unwrap() .quality_profile_list .scroll_up(), - ActiveRadarrBlock::EditMoviePrompt => self.app.data.radarr_data.selected_block.previous(), + ActiveRadarrBlock::EditMoviePrompt => self.app.data.radarr_data.selected_block.up(), _ => (), } } @@ -90,7 +90,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditMovieHandler<'a, .unwrap() .quality_profile_list .scroll_down(), - ActiveRadarrBlock::EditMoviePrompt => self.app.data.radarr_data.selected_block.next(), + ActiveRadarrBlock::EditMoviePrompt => self.app.data.radarr_data.selected_block.down(), _ => (), } } diff --git a/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs b/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs index 86b3ed3..c63c2a7 100644 --- a/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs @@ -146,8 +146,8 @@ mod tests { fn test_edit_movie_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { let mut app = App::default(); app.data.radarr_data.edit_movie_modal = Some(EditMovieModal::default()); - app.data.radarr_data.selected_block = BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.next(); + app.data.radarr_data.selected_block = BlockSelectionState::new(EDIT_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.down(); EditMovieHandler::with(key, &mut app, ActiveRadarrBlock::EditMoviePrompt, None).handle(); @@ -169,8 +169,8 @@ mod tests { let mut app = App::default(); app.is_loading = true; app.data.radarr_data.edit_movie_modal = Some(EditMovieModal::default()); - app.data.radarr_data.selected_block = BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.next(); + app.data.radarr_data.selected_block = BlockSelectionState::new(EDIT_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.down(); EditMovieHandler::with(key, &mut app, ActiveRadarrBlock::EditMoviePrompt, None).handle(); @@ -621,12 +621,12 @@ mod tests { app.data.radarr_data.edit_movie_modal = Some(EditMovieModal::default()); app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(ActiveRadarrBlock::EditMoviePrompt.into()); - app.data.radarr_data.selected_block = BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block = BlockSelectionState::new(EDIT_MOVIE_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); + .set_index(0, EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); EditMovieHandler::with( SUBMIT_KEY, @@ -647,12 +647,12 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(ActiveRadarrBlock::EditMoviePrompt.into()); app.data.radarr_data.prompt_confirm = true; - app.data.radarr_data.selected_block = BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block = BlockSelectionState::new(EDIT_MOVIE_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); + .set_index(0, EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); EditMovieHandler::with( SUBMIT_KEY, @@ -704,7 +704,7 @@ mod tests { )); let mut app = App::default(); app.data.radarr_data.edit_movie_modal = Some(EditMovieModal::default()); - app.data.radarr_data.selected_block = BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block = BlockSelectionState::new(EDIT_MOVIE_SELECTION_BLOCKS); app.push_navigation_stack(current_route); EditMovieHandler::with( @@ -755,7 +755,7 @@ mod tests { #[case(ActiveRadarrBlock::EditMovieTagsInput, 4)] fn test_edit_movie_prompt_selected_block_submit( #[case] selected_block: ActiveRadarrBlock, - #[case] index: usize, + #[case] y_index: usize, ) { let mut app = App::default(); app.data.radarr_data.edit_movie_modal = Some(EditMovieModal::default()); @@ -766,8 +766,8 @@ mod tests { ) .into(), ); - app.data.radarr_data.selected_block = BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.set_index(index); + app.data.radarr_data.selected_block = BlockSelectionState::new(EDIT_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.set_index(0, y_index); EditMovieHandler::with( SUBMIT_KEY, @@ -792,7 +792,7 @@ mod tests { #[rstest] fn test_edit_movie_prompt_selected_block_submit_no_op_when_not_ready( - #[values(1, 2, 3, 4)] index: usize, + #[values(1, 2, 3, 4)] y_index: usize, ) { let mut app = App::default(); app.is_loading = true; @@ -804,8 +804,8 @@ mod tests { ) .into(), ); - app.data.radarr_data.selected_block = BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.set_index(index); + app.data.radarr_data.selected_block = BlockSelectionState::new(EDIT_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.set_index(0, y_index); EditMovieHandler::with( SUBMIT_KEY, @@ -1061,12 +1061,12 @@ mod tests { app.data.radarr_data.edit_movie_modal = Some(EditMovieModal::default()); app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(ActiveRadarrBlock::EditMoviePrompt.into()); - app.data.radarr_data.selected_block = BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS); + app.data.radarr_data.selected_block = BlockSelectionState::new(EDIT_MOVIE_SELECTION_BLOCKS); app .data .radarr_data .selected_block - .set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); + .set_index(0, EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); EditMovieHandler::with( DEFAULT_KEYBINDINGS.confirm.key, diff --git a/src/handlers/radarr_handlers/library/library_handler_tests.rs b/src/handlers/radarr_handlers/library/library_handler_tests.rs index a955659..f79ba5a 100644 --- a/src/handlers/radarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/library_handler_tests.rs @@ -375,7 +375,7 @@ mod tests { ); assert_eq!( app.data.radarr_data.selected_block.blocks, - &DELETE_MOVIE_SELECTION_BLOCKS + DELETE_MOVIE_SELECTION_BLOCKS ); } diff --git a/src/handlers/radarr_handlers/library/mod.rs b/src/handlers/radarr_handlers/library/mod.rs index 2d73248..f8ae0b9 100644 --- a/src/handlers/radarr_handlers/library/mod.rs +++ b/src/handlers/radarr_handlers/library/mod.rs @@ -194,7 +194,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' .app .push_navigation_stack(ActiveRadarrBlock::DeleteMoviePrompt.into()); self.app.data.radarr_data.selected_block = - BlockSelectionState::new(&DELETE_MOVIE_SELECTION_BLOCKS); + BlockSelectionState::new(DELETE_MOVIE_SELECTION_BLOCKS); } } @@ -342,7 +342,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' ); self.app.data.radarr_data.edit_movie_modal = Some((&self.app.data.radarr_data).into()); self.app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_MOVIE_SELECTION_BLOCKS); } _ if key == DEFAULT_KEYBINDINGS.add.key => { self diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index c08846a..fc81c2f 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -447,7 +447,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< ); self.app.data.radarr_data.edit_movie_modal = Some((&self.app.data.radarr_data).into()); self.app.data.radarr_data.selected_block = - BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS); + BlockSelectionState::new(EDIT_MOVIE_SELECTION_BLOCKS); } _ if key == DEFAULT_KEYBINDINGS.update.key => { self diff --git a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs index afb6761..b14cd28 100644 --- a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs +++ b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs @@ -111,7 +111,7 @@ mod utils { ); assert_eq!( app.data.radarr_data.selected_block.blocks, - &EDIT_MOVIE_SELECTION_BLOCKS + EDIT_MOVIE_SELECTION_BLOCKS ); }; } @@ -224,7 +224,7 @@ mod utils { ); assert_eq!( app.data.radarr_data.selected_block.blocks, - &EDIT_COLLECTION_SELECTION_BLOCKS + EDIT_COLLECTION_SELECTION_BLOCKS ); }; } diff --git a/src/models/mod.rs b/src/models/mod.rs index 267024a..ba2a3f9 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -320,33 +320,46 @@ pub struct BlockSelectionState<'a, T> where T: Sized + Clone + Copy + Default, { - pub blocks: &'a [T], - pub index: usize, + pub blocks: &'a [&'a [T]], + pub x: usize, + pub y: usize, } impl<'a, T> BlockSelectionState<'a, T> where T: Sized + Clone + Copy + Default, { - pub fn new(blocks: &'a [T]) -> BlockSelectionState<'a, T> { - BlockSelectionState { blocks, index: 0 } + pub fn new(blocks: &'a [&'a [T]]) -> BlockSelectionState<'a, T> { + BlockSelectionState { blocks, x: 0, y: 0 } } pub fn get_active_block(&self) -> T { - self.blocks[self.index] + self.blocks[self.y][self.x] } - pub fn next(&mut self) { - self.index = (self.index + 1) % self.blocks.len(); - } - - pub fn previous(&mut self) { - if self.index > 0 { - self.index -= 1; + pub fn left(&mut self) { + if self.x > 0 { + self.x -= 1; } else { - self.index = self.blocks.len() - 1; + self.x = self.blocks[0].len() - 1; } } + + pub fn right(&mut self) { + self.x = (self.x + 1) % self.blocks[0].len(); + } + + pub fn up(&mut self) { + if self.y > 0 { + self.y -= 1; + } else { + self.y = self.blocks.len() - 1; + } + } + + pub fn down(&mut self) { + self.y = (self.y + 1) % self.blocks.len(); + } } #[cfg(test)] @@ -354,8 +367,9 @@ impl<'a, T> BlockSelectionState<'a, T> where T: Sized + Clone + Copy + Default, { - pub fn set_index(&mut self, index: usize) { - self.index = index; + pub fn set_index(&mut self, x: usize, y: usize) { + self.x = x; + self.y = y; } } diff --git a/src/models/model_tests.rs b/src/models/model_tests.rs index 2d40a50..0514577 100644 --- a/src/models/model_tests.rs +++ b/src/models/model_tests.rs @@ -17,14 +17,7 @@ mod tests { BlockSelectionState, HorizontallyScrollableText, Scrollable, ScrollableText, TabRoute, TabState, }; - const BLOCKS: [ActiveRadarrBlock; 6] = [ - ActiveRadarrBlock::AddMovieSelectRootFolder, - ActiveRadarrBlock::AddMovieSelectMonitor, - ActiveRadarrBlock::AddMovieSelectMinimumAvailability, - ActiveRadarrBlock::AddMovieSelectQualityProfile, - ActiveRadarrBlock::AddMovieTagsInput, - ActiveRadarrBlock::AddMovieConfirmPrompt, - ]; + const BLOCKS: &[&[i32]] = &[&[11, 12], &[21, 22], &[31, 32]]; #[test] fn test_scrollable_text_with_string() { @@ -577,17 +570,19 @@ mod tests { #[test] fn test_block_selection_state_new() { - let block_selection_state = BlockSelectionState::new(&BLOCKS); + let block_selection_state = BlockSelectionState::new(BLOCKS); - assert_eq!(block_selection_state.index, 0); + assert_eq!(block_selection_state.x, 0); + assert_eq!(block_selection_state.y, 0); } #[test] fn test_block_selection_state_get_active_block() { - let second_block = BLOCKS[1]; + let second_block = BLOCKS[1][1]; let block_selection_state = BlockSelectionState { - blocks: &BLOCKS, - index: 1, + blocks: BLOCKS, + x: 1, + y: 1, }; let active_block = block_selection_state.get_active_block(); @@ -596,41 +591,95 @@ mod tests { } #[test] - fn test_block_selection_state_next() { - let blocks = [ - ActiveRadarrBlock::AddMovieSelectRootFolder, - ActiveRadarrBlock::AddMovieSelectMonitor, - ]; - let mut block_selection_state = BlockSelectionState::new(&blocks); + fn test_block_selection_state_down() { + let mut block_selection_state = BlockSelectionState::new(BLOCKS); - assert_eq!(block_selection_state.get_active_block(), blocks[0]); + assert_eq!(block_selection_state.get_active_block(), BLOCKS[0][0]); - block_selection_state.next(); + block_selection_state.down(); - assert_eq!(block_selection_state.get_active_block(), blocks[1]); + assert_eq!(block_selection_state.get_active_block(), BLOCKS[1][0]); - block_selection_state.next(); + block_selection_state.down(); - assert_eq!(block_selection_state.get_active_block(), blocks[0]); + assert_eq!(block_selection_state.get_active_block(), BLOCKS[2][0]); + + block_selection_state.down(); + + assert_eq!(block_selection_state.get_active_block(), BLOCKS[0][0]); } #[test] - fn test_block_selection_state_previous() { - let blocks = [ - ActiveRadarrBlock::AddMovieSelectRootFolder, - ActiveRadarrBlock::AddMovieSelectMonitor, - ]; - let mut block_selection_state = BlockSelectionState::new(&blocks); + fn test_block_selection_state_up() { + let mut block_selection_state = BlockSelectionState::new(BLOCKS); - assert_eq!(block_selection_state.get_active_block(), blocks[0]); + assert_eq!(block_selection_state.get_active_block(), BLOCKS[0][0]); - block_selection_state.previous(); + block_selection_state.up(); - assert_eq!(block_selection_state.get_active_block(), blocks[1]); + assert_eq!(block_selection_state.get_active_block(), BLOCKS[2][0]); - block_selection_state.previous(); + block_selection_state.up(); - assert_eq!(block_selection_state.get_active_block(), blocks[0]); + assert_eq!(block_selection_state.get_active_block(), BLOCKS[1][0]); + + block_selection_state.up(); + + assert_eq!(block_selection_state.get_active_block(), BLOCKS[0][0]); + } + + #[test] + fn test_block_selection_state_left() { + let mut block_selection_state = BlockSelectionState::new(BLOCKS); + + assert_eq!(block_selection_state.get_active_block(), BLOCKS[0][0]); + + block_selection_state.left(); + + assert_eq!(block_selection_state.get_active_block(), BLOCKS[0][1]); + + block_selection_state.left(); + + assert_eq!(block_selection_state.get_active_block(), BLOCKS[0][0]); + + block_selection_state.set_index(0, 1); + + assert_eq!(block_selection_state.get_active_block(), BLOCKS[1][0]); + + block_selection_state.left(); + + assert_eq!(block_selection_state.get_active_block(), BLOCKS[1][1]); + + block_selection_state.left(); + + assert_eq!(block_selection_state.get_active_block(), BLOCKS[1][0]); + } + + #[test] + fn test_block_selection_state_right() { + let mut block_selection_state = BlockSelectionState::new(BLOCKS); + + assert_eq!(block_selection_state.get_active_block(), BLOCKS[0][0]); + + block_selection_state.right(); + + assert_eq!(block_selection_state.get_active_block(), BLOCKS[0][1]); + + block_selection_state.right(); + + assert_eq!(block_selection_state.get_active_block(), BLOCKS[0][0]); + + block_selection_state.set_index(0, 1); + + assert_eq!(block_selection_state.get_active_block(), BLOCKS[1][0]); + + block_selection_state.right(); + + assert_eq!(block_selection_state.get_active_block(), BLOCKS[1][1]); + + block_selection_state.right(); + + assert_eq!(block_selection_state.get_active_block(), BLOCKS[1][0]); } #[test] diff --git a/src/models/servarr_data/radarr/radarr_data.rs b/src/models/servarr_data/radarr/radarr_data.rs index c377df8..0d9c621 100644 --- a/src/models/servarr_data/radarr/radarr_data.rs +++ b/src/models/servarr_data/radarr/radarr_data.rs @@ -357,13 +357,13 @@ pub static ADD_MOVIE_BLOCKS: [ActiveRadarrBlock; 10] = [ ActiveRadarrBlock::AddMovieAlreadyInLibrary, ActiveRadarrBlock::AddMovieTagsInput, ]; -pub static ADD_MOVIE_SELECTION_BLOCKS: [ActiveRadarrBlock; 6] = [ - ActiveRadarrBlock::AddMovieSelectRootFolder, - ActiveRadarrBlock::AddMovieSelectMonitor, - ActiveRadarrBlock::AddMovieSelectMinimumAvailability, - ActiveRadarrBlock::AddMovieSelectQualityProfile, - ActiveRadarrBlock::AddMovieTagsInput, - ActiveRadarrBlock::AddMovieConfirmPrompt, +pub const ADD_MOVIE_SELECTION_BLOCKS: &[&[ActiveRadarrBlock]] = &[ + &[ActiveRadarrBlock::AddMovieSelectRootFolder], + &[ActiveRadarrBlock::AddMovieSelectMonitor], + &[ActiveRadarrBlock::AddMovieSelectMinimumAvailability], + &[ActiveRadarrBlock::AddMovieSelectQualityProfile], + &[ActiveRadarrBlock::AddMovieTagsInput], + &[ActiveRadarrBlock::AddMovieConfirmPrompt], ]; pub static EDIT_COLLECTION_BLOCKS: [ActiveRadarrBlock; 7] = [ ActiveRadarrBlock::EditCollectionPrompt, @@ -374,13 +374,13 @@ pub static EDIT_COLLECTION_BLOCKS: [ActiveRadarrBlock; 7] = [ ActiveRadarrBlock::EditCollectionToggleSearchOnAdd, ActiveRadarrBlock::EditCollectionToggleMonitored, ]; -pub static EDIT_COLLECTION_SELECTION_BLOCKS: [ActiveRadarrBlock; 6] = [ - ActiveRadarrBlock::EditCollectionToggleMonitored, - ActiveRadarrBlock::EditCollectionSelectMinimumAvailability, - ActiveRadarrBlock::EditCollectionSelectQualityProfile, - ActiveRadarrBlock::EditCollectionRootFolderPathInput, - ActiveRadarrBlock::EditCollectionToggleSearchOnAdd, - ActiveRadarrBlock::EditCollectionConfirmPrompt, +pub const EDIT_COLLECTION_SELECTION_BLOCKS: &[&[ActiveRadarrBlock]] = &[ + &[ActiveRadarrBlock::EditCollectionToggleMonitored], + &[ActiveRadarrBlock::EditCollectionSelectMinimumAvailability], + &[ActiveRadarrBlock::EditCollectionSelectQualityProfile], + &[ActiveRadarrBlock::EditCollectionRootFolderPathInput], + &[ActiveRadarrBlock::EditCollectionToggleSearchOnAdd], + &[ActiveRadarrBlock::EditCollectionConfirmPrompt], ]; pub static EDIT_MOVIE_BLOCKS: [ActiveRadarrBlock; 7] = [ ActiveRadarrBlock::EditMoviePrompt, @@ -391,13 +391,13 @@ pub static EDIT_MOVIE_BLOCKS: [ActiveRadarrBlock; 7] = [ ActiveRadarrBlock::EditMovieTagsInput, ActiveRadarrBlock::EditMovieToggleMonitored, ]; -pub static EDIT_MOVIE_SELECTION_BLOCKS: [ActiveRadarrBlock; 6] = [ - ActiveRadarrBlock::EditMovieToggleMonitored, - ActiveRadarrBlock::EditMovieSelectMinimumAvailability, - ActiveRadarrBlock::EditMovieSelectQualityProfile, - ActiveRadarrBlock::EditMoviePathInput, - ActiveRadarrBlock::EditMovieTagsInput, - ActiveRadarrBlock::EditMovieConfirmPrompt, +pub const EDIT_MOVIE_SELECTION_BLOCKS: &[&[ActiveRadarrBlock]] = &[ + &[ActiveRadarrBlock::EditMovieToggleMonitored], + &[ActiveRadarrBlock::EditMovieSelectMinimumAvailability], + &[ActiveRadarrBlock::EditMovieSelectQualityProfile], + &[ActiveRadarrBlock::EditMoviePathInput], + &[ActiveRadarrBlock::EditMovieTagsInput], + &[ActiveRadarrBlock::EditMovieConfirmPrompt], ]; pub static DOWNLOADS_BLOCKS: [ActiveRadarrBlock; 3] = [ ActiveRadarrBlock::Downloads, @@ -426,10 +426,10 @@ pub static DELETE_MOVIE_BLOCKS: [ActiveRadarrBlock; 4] = [ ActiveRadarrBlock::DeleteMovieToggleDeleteFile, ActiveRadarrBlock::DeleteMovieToggleAddListExclusion, ]; -pub static DELETE_MOVIE_SELECTION_BLOCKS: [ActiveRadarrBlock; 3] = [ - ActiveRadarrBlock::DeleteMovieToggleDeleteFile, - ActiveRadarrBlock::DeleteMovieToggleAddListExclusion, - ActiveRadarrBlock::DeleteMovieConfirmPrompt, +pub const DELETE_MOVIE_SELECTION_BLOCKS: &[&[ActiveRadarrBlock]] = &[ + &[ActiveRadarrBlock::DeleteMovieToggleDeleteFile], + &[ActiveRadarrBlock::DeleteMovieToggleAddListExclusion], + &[ActiveRadarrBlock::DeleteMovieConfirmPrompt], ]; pub static EDIT_INDEXER_BLOCKS: [ActiveRadarrBlock; 10] = [ ActiveRadarrBlock::EditIndexerPrompt, @@ -443,29 +443,49 @@ pub static EDIT_INDEXER_BLOCKS: [ActiveRadarrBlock; 10] = [ ActiveRadarrBlock::EditIndexerUrlInput, ActiveRadarrBlock::EditIndexerTagsInput, ]; -pub static EDIT_INDEXER_TORRENT_SELECTION_BLOCKS: [ActiveRadarrBlock; 10] = [ - ActiveRadarrBlock::EditIndexerNameInput, - ActiveRadarrBlock::EditIndexerToggleEnableRss, - ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch, - ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch, - ActiveRadarrBlock::EditIndexerConfirmPrompt, - ActiveRadarrBlock::EditIndexerUrlInput, - ActiveRadarrBlock::EditIndexerApiKeyInput, - ActiveRadarrBlock::EditIndexerSeedRatioInput, - ActiveRadarrBlock::EditIndexerTagsInput, - ActiveRadarrBlock::EditIndexerConfirmPrompt, +pub const EDIT_INDEXER_TORRENT_SELECTION_BLOCKS: &[&[ActiveRadarrBlock]] = &[ + &[ + ActiveRadarrBlock::EditIndexerNameInput, + ActiveRadarrBlock::EditIndexerUrlInput, + ], + &[ + ActiveRadarrBlock::EditIndexerToggleEnableRss, + ActiveRadarrBlock::EditIndexerApiKeyInput, + ], + &[ + ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveRadarrBlock::EditIndexerSeedRatioInput, + ], + &[ + ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveRadarrBlock::EditIndexerTagsInput, + ], + &[ + ActiveRadarrBlock::EditIndexerConfirmPrompt, + ActiveRadarrBlock::EditIndexerConfirmPrompt, + ], ]; -pub static EDIT_INDEXER_NZB_SELECTION_BLOCKS: [ActiveRadarrBlock; 10] = [ - ActiveRadarrBlock::EditIndexerNameInput, - ActiveRadarrBlock::EditIndexerToggleEnableRss, - ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch, - ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch, - ActiveRadarrBlock::EditIndexerConfirmPrompt, - ActiveRadarrBlock::EditIndexerUrlInput, - ActiveRadarrBlock::EditIndexerApiKeyInput, - ActiveRadarrBlock::EditIndexerTagsInput, - ActiveRadarrBlock::EditIndexerConfirmPrompt, - ActiveRadarrBlock::EditIndexerConfirmPrompt, +pub const EDIT_INDEXER_NZB_SELECTION_BLOCKS: &[&[ActiveRadarrBlock]] = &[ + &[ + ActiveRadarrBlock::EditIndexerNameInput, + ActiveRadarrBlock::EditIndexerUrlInput, + ], + &[ + ActiveRadarrBlock::EditIndexerToggleEnableRss, + ActiveRadarrBlock::EditIndexerApiKeyInput, + ], + &[ + ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveRadarrBlock::EditIndexerTagsInput, + ], + &[ + ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveRadarrBlock::EditIndexerConfirmPrompt, + ], + &[ + ActiveRadarrBlock::EditIndexerConfirmPrompt, + ActiveRadarrBlock::EditIndexerConfirmPrompt, + ], ]; pub static INDEXER_SETTINGS_BLOCKS: [ActiveRadarrBlock; 10] = [ ActiveRadarrBlock::AllIndexerSettingsPrompt, @@ -479,17 +499,27 @@ pub static INDEXER_SETTINGS_BLOCKS: [ActiveRadarrBlock; 10] = [ ActiveRadarrBlock::IndexerSettingsTogglePreferIndexerFlags, ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, ]; -pub static INDEXER_SETTINGS_SELECTION_BLOCKS: [ActiveRadarrBlock; 10] = [ - ActiveRadarrBlock::IndexerSettingsMinimumAgeInput, - ActiveRadarrBlock::IndexerSettingsRetentionInput, - ActiveRadarrBlock::IndexerSettingsMaximumSizeInput, - ActiveRadarrBlock::IndexerSettingsTogglePreferIndexerFlags, - ActiveRadarrBlock::IndexerSettingsConfirmPrompt, - ActiveRadarrBlock::IndexerSettingsAvailabilityDelayInput, - ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput, - ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, - ActiveRadarrBlock::IndexerSettingsToggleAllowHardcodedSubs, - ActiveRadarrBlock::IndexerSettingsConfirmPrompt, +pub const INDEXER_SETTINGS_SELECTION_BLOCKS: &[&[ActiveRadarrBlock]] = &[ + &[ + ActiveRadarrBlock::IndexerSettingsMinimumAgeInput, + ActiveRadarrBlock::IndexerSettingsAvailabilityDelayInput, + ], + &[ + ActiveRadarrBlock::IndexerSettingsRetentionInput, + ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput, + ], + &[ + ActiveRadarrBlock::IndexerSettingsMaximumSizeInput, + ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, + ], + &[ + ActiveRadarrBlock::IndexerSettingsTogglePreferIndexerFlags, + ActiveRadarrBlock::IndexerSettingsToggleAllowHardcodedSubs, + ], + &[ + ActiveRadarrBlock::IndexerSettingsConfirmPrompt, + ActiveRadarrBlock::IndexerSettingsConfirmPrompt, + ], ]; pub static SYSTEM_DETAILS_BLOCKS: [ActiveRadarrBlock; 5] = [ ActiveRadarrBlock::SystemLogs, diff --git a/src/models/servarr_data/radarr/radarr_data_tests.rs b/src/models/servarr_data/radarr/radarr_data_tests.rs index 15eebb5..6d4a07d 100644 --- a/src/models/servarr_data/radarr/radarr_data_tests.rs +++ b/src/models/servarr_data/radarr/radarr_data_tests.rs @@ -468,27 +468,27 @@ mod tests { assert_eq!( add_movie_block_iter.next().unwrap(), - &ActiveRadarrBlock::AddMovieSelectRootFolder + &[ActiveRadarrBlock::AddMovieSelectRootFolder] ); assert_eq!( add_movie_block_iter.next().unwrap(), - &ActiveRadarrBlock::AddMovieSelectMonitor + &[ActiveRadarrBlock::AddMovieSelectMonitor] ); assert_eq!( add_movie_block_iter.next().unwrap(), - &ActiveRadarrBlock::AddMovieSelectMinimumAvailability + &[ActiveRadarrBlock::AddMovieSelectMinimumAvailability] ); assert_eq!( add_movie_block_iter.next().unwrap(), - &ActiveRadarrBlock::AddMovieSelectQualityProfile + &[ActiveRadarrBlock::AddMovieSelectQualityProfile] ); assert_eq!( add_movie_block_iter.next().unwrap(), - &ActiveRadarrBlock::AddMovieTagsInput + &[ActiveRadarrBlock::AddMovieTagsInput] ); assert_eq!( add_movie_block_iter.next().unwrap(), - &ActiveRadarrBlock::AddMovieConfirmPrompt + &[ActiveRadarrBlock::AddMovieConfirmPrompt] ); assert_eq!(add_movie_block_iter.next(), None); } @@ -499,27 +499,27 @@ mod tests { assert_eq!( edit_movie_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditMovieToggleMonitored + &[ActiveRadarrBlock::EditMovieToggleMonitored] ); assert_eq!( edit_movie_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditMovieSelectMinimumAvailability + &[ActiveRadarrBlock::EditMovieSelectMinimumAvailability] ); assert_eq!( edit_movie_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditMovieSelectQualityProfile + &[ActiveRadarrBlock::EditMovieSelectQualityProfile] ); assert_eq!( edit_movie_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditMoviePathInput + &[ActiveRadarrBlock::EditMoviePathInput] ); assert_eq!( edit_movie_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditMovieTagsInput + &[ActiveRadarrBlock::EditMovieTagsInput] ); assert_eq!( edit_movie_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditMovieConfirmPrompt + &[ActiveRadarrBlock::EditMovieConfirmPrompt] ); assert_eq!(edit_movie_block_iter.next(), None); } @@ -530,27 +530,27 @@ mod tests { assert_eq!( edit_collection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditCollectionToggleMonitored + &[ActiveRadarrBlock::EditCollectionToggleMonitored] ); assert_eq!( edit_collection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditCollectionSelectMinimumAvailability + &[ActiveRadarrBlock::EditCollectionSelectMinimumAvailability] ); assert_eq!( edit_collection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditCollectionSelectQualityProfile + &[ActiveRadarrBlock::EditCollectionSelectQualityProfile] ); assert_eq!( edit_collection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditCollectionRootFolderPathInput + &[ActiveRadarrBlock::EditCollectionRootFolderPathInput] ); assert_eq!( edit_collection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditCollectionToggleSearchOnAdd + &[ActiveRadarrBlock::EditCollectionToggleSearchOnAdd] ); assert_eq!( edit_collection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditCollectionConfirmPrompt + &[ActiveRadarrBlock::EditCollectionConfirmPrompt] ); assert_eq!(edit_collection_block_iter.next(), None); } @@ -561,15 +561,15 @@ mod tests { assert_eq!( delete_movie_block_iter.next().unwrap(), - &ActiveRadarrBlock::DeleteMovieToggleDeleteFile + &[ActiveRadarrBlock::DeleteMovieToggleDeleteFile] ); assert_eq!( delete_movie_block_iter.next().unwrap(), - &ActiveRadarrBlock::DeleteMovieToggleAddListExclusion + &[ActiveRadarrBlock::DeleteMovieToggleAddListExclusion] ); assert_eq!( delete_movie_block_iter.next().unwrap(), - &ActiveRadarrBlock::DeleteMovieConfirmPrompt + &[ActiveRadarrBlock::DeleteMovieConfirmPrompt] ); assert_eq!(delete_movie_block_iter.next(), None); } @@ -581,43 +581,38 @@ mod tests { assert_eq!( edit_indexer_torrent_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerNameInput + &[ + ActiveRadarrBlock::EditIndexerNameInput, + ActiveRadarrBlock::EditIndexerUrlInput, + ] ); assert_eq!( edit_indexer_torrent_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerToggleEnableRss + &[ + ActiveRadarrBlock::EditIndexerToggleEnableRss, + ActiveRadarrBlock::EditIndexerApiKeyInput, + ] ); assert_eq!( edit_indexer_torrent_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch + &[ + ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveRadarrBlock::EditIndexerSeedRatioInput, + ] ); assert_eq!( edit_indexer_torrent_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch + &[ + ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveRadarrBlock::EditIndexerTagsInput, + ] ); assert_eq!( edit_indexer_torrent_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerConfirmPrompt - ); - assert_eq!( - edit_indexer_torrent_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerUrlInput - ); - assert_eq!( - edit_indexer_torrent_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerApiKeyInput - ); - assert_eq!( - edit_indexer_torrent_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerSeedRatioInput - ); - assert_eq!( - edit_indexer_torrent_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerTagsInput - ); - assert_eq!( - edit_indexer_torrent_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerConfirmPrompt + &[ + ActiveRadarrBlock::EditIndexerConfirmPrompt, + ActiveRadarrBlock::EditIndexerConfirmPrompt, + ] ); assert_eq!(edit_indexer_torrent_selection_block_iter.next(), None); } @@ -628,43 +623,38 @@ mod tests { assert_eq!( edit_indexer_nzb_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerNameInput + &[ + ActiveRadarrBlock::EditIndexerNameInput, + ActiveRadarrBlock::EditIndexerUrlInput, + ] ); assert_eq!( edit_indexer_nzb_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerToggleEnableRss + &[ + ActiveRadarrBlock::EditIndexerToggleEnableRss, + ActiveRadarrBlock::EditIndexerApiKeyInput, + ] ); assert_eq!( edit_indexer_nzb_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch + &[ + ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveRadarrBlock::EditIndexerTagsInput, + ] ); assert_eq!( edit_indexer_nzb_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch + &[ + ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveRadarrBlock::EditIndexerConfirmPrompt, + ] ); assert_eq!( edit_indexer_nzb_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerConfirmPrompt - ); - assert_eq!( - edit_indexer_nzb_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerUrlInput - ); - assert_eq!( - edit_indexer_nzb_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerApiKeyInput - ); - assert_eq!( - edit_indexer_nzb_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerTagsInput - ); - assert_eq!( - edit_indexer_nzb_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerConfirmPrompt - ); - assert_eq!( - edit_indexer_nzb_selection_block_iter.next().unwrap(), - &ActiveRadarrBlock::EditIndexerConfirmPrompt + &[ + ActiveRadarrBlock::EditIndexerConfirmPrompt, + ActiveRadarrBlock::EditIndexerConfirmPrompt, + ] ); assert_eq!(edit_indexer_nzb_selection_block_iter.next(), None); } @@ -675,43 +665,38 @@ mod tests { assert_eq!( indexer_settings_block_iter.next().unwrap(), - &ActiveRadarrBlock::IndexerSettingsMinimumAgeInput + &[ + ActiveRadarrBlock::IndexerSettingsMinimumAgeInput, + ActiveRadarrBlock::IndexerSettingsAvailabilityDelayInput, + ] ); assert_eq!( indexer_settings_block_iter.next().unwrap(), - &ActiveRadarrBlock::IndexerSettingsRetentionInput + &[ + ActiveRadarrBlock::IndexerSettingsRetentionInput, + ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput, + ] ); assert_eq!( indexer_settings_block_iter.next().unwrap(), - &ActiveRadarrBlock::IndexerSettingsMaximumSizeInput + &[ + ActiveRadarrBlock::IndexerSettingsMaximumSizeInput, + ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput, + ] ); assert_eq!( indexer_settings_block_iter.next().unwrap(), - &ActiveRadarrBlock::IndexerSettingsTogglePreferIndexerFlags + &[ + ActiveRadarrBlock::IndexerSettingsTogglePreferIndexerFlags, + ActiveRadarrBlock::IndexerSettingsToggleAllowHardcodedSubs, + ] ); assert_eq!( indexer_settings_block_iter.next().unwrap(), - &ActiveRadarrBlock::IndexerSettingsConfirmPrompt - ); - assert_eq!( - indexer_settings_block_iter.next().unwrap(), - &ActiveRadarrBlock::IndexerSettingsAvailabilityDelayInput - ); - assert_eq!( - indexer_settings_block_iter.next().unwrap(), - &ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput - ); - assert_eq!( - indexer_settings_block_iter.next().unwrap(), - &ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput - ); - assert_eq!( - indexer_settings_block_iter.next().unwrap(), - &ActiveRadarrBlock::IndexerSettingsToggleAllowHardcodedSubs - ); - assert_eq!( - indexer_settings_block_iter.next().unwrap(), - &ActiveRadarrBlock::IndexerSettingsConfirmPrompt + &[ + ActiveRadarrBlock::IndexerSettingsConfirmPrompt, + ActiveRadarrBlock::IndexerSettingsConfirmPrompt, + ] ); assert_eq!(indexer_settings_block_iter.next(), None); } diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index fe95448..18a8fce 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -169,7 +169,7 @@ fn draw_downloads_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { f.render_widget(block, area); let max_items = ((((area.height as f64 / 2.0).floor() * 2.0) as i64) / 2) - 1; - let items = cmp::min(downloads_vec.len(), max_items.abs() as usize); + let items = cmp::min(downloads_vec.len(), max_items.unsigned_abs() as usize); let download_item_areas = Layout::vertical( iter::repeat(Constraint::Length(2)) .take(items) From 21911f93d1280bcd7a332807d3461b5267f7e254 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 1 Dec 2024 12:05:20 -0700 Subject: [PATCH 06/82] feat(models): Added the necessary contextual help and tabs for the Sonarr UI --- src/app/sonarr/sonarr_context_clues.rs | 55 ++++++ src/app/sonarr/sonarr_context_clues_tests.rs | 158 +++++++++++++++++- src/models/servarr_data/sonarr/modals.rs | 100 +++++++++-- .../servarr_data/sonarr/modals_tests.rs | 137 ++++++++++++++- src/models/servarr_data/sonarr/sonarr_data.rs | 25 ++- .../servarr_data/sonarr/sonarr_data_tests.rs | 29 +++- .../servarr_data/sonarr/sonarr_test_utils.rs | 58 +++++++ 7 files changed, 545 insertions(+), 17 deletions(-) create mode 100644 src/models/servarr_data/sonarr/sonarr_test_utils.rs diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index ecd89ea..2a7ceab 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -31,3 +31,58 @@ pub static HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [ ), (DEFAULT_KEYBINDINGS.esc, "cancel filter"), ]; + +pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc), + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.search, "auto search"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub static SEASON_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.search, "auto search"), + (DEFAULT_KEYBINDINGS.delete, "delete episode"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub static MANUAL_SEASON_SEARCH_CONTEXT_CLUES: [ContextClue; 5] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.search, "auto search"), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub static MANUAL_EPISODE_SEARCH_CONTEXT_CLUES: [ContextClue; 4] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.search, "auto search"), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub static MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES: [ContextClue; 1] = + [(DEFAULT_KEYBINDINGS.submit, "details")]; + +pub static EPISODE_DETAILS_CONTEXT_CLUES: [ContextClue; 3] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.search, "auto search"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index 30de8b0..6f93de0 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -4,7 +4,12 @@ mod tests { use crate::app::{ key_binding::DEFAULT_KEYBINDINGS, - sonarr::sonarr_context_clues::{HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES}, + sonarr::sonarr_context_clues::{ + EPISODE_DETAILS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, + MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, + MANUAL_SEASON_SEARCH_CONTEXT_CLUES, SEASON_DETAILS_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, + SERIES_DETAILS_CONTEXT_CLUES, + }, }; #[test] @@ -98,4 +103,155 @@ mod tests { assert_str_eq!(*description, "cancel filter"); assert_eq!(history_context_clues_iter.next(), None); } + + #[test] + fn test_series_details_context_clues() { + let mut series_details_context_clues_iter = SERIES_DETAILS_CONTEXT_CLUES.iter(); + + let (key_binding, description) = series_details_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); + + let (key_binding, description) = series_details_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.update.desc); + + let (key_binding, description) = series_details_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); + assert_str_eq!(*description, "details"); + + let (key_binding, description) = series_details_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); + assert_str_eq!(*description, "auto search"); + + let (key_binding, description) = series_details_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc); + assert_eq!(series_details_context_clues_iter.next(), None); + } + + #[test] + fn test_season_details_context_clues() { + let mut season_details_context_clues_iter = SEASON_DETAILS_CONTEXT_CLUES.iter(); + + let (key_binding, description) = season_details_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); + + let (key_binding, description) = season_details_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); + assert_str_eq!(*description, "details"); + + let (key_binding, description) = season_details_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); + assert_str_eq!(*description, "auto search"); + + let (key_binding, description) = season_details_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); + assert_str_eq!(*description, "delete episode"); + + let (key_binding, description) = season_details_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc); + assert_eq!(season_details_context_clues_iter.next(), None); + } + + #[test] + fn test_manual_season_search_context_clues() { + let mut manual_season_search_context_clues_iter = MANUAL_SEASON_SEARCH_CONTEXT_CLUES.iter(); + + let (key_binding, description) = manual_season_search_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); + + let (key_binding, description) = manual_season_search_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); + assert_str_eq!(*description, "auto search"); + + let (key_binding, description) = manual_season_search_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc); + + let (key_binding, description) = manual_season_search_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); + assert_str_eq!(*description, "details"); + + let (key_binding, description) = manual_season_search_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc); + assert_eq!(manual_season_search_context_clues_iter.next(), None); + } + + #[test] + fn test_manual_episode_search_context_clues() { + let mut manual_episode_search_context_clues_iter = MANUAL_EPISODE_SEARCH_CONTEXT_CLUES.iter(); + + let (key_binding, description) = manual_episode_search_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); + + let (key_binding, description) = manual_episode_search_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); + assert_str_eq!(*description, "auto search"); + + let (key_binding, description) = manual_episode_search_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc); + + let (key_binding, description) = manual_episode_search_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc); + assert_eq!(manual_episode_search_context_clues_iter.next(), None); + } + + #[test] + fn test_manual_episode_search_contextual_context_clues() { + let mut manual_search_contextual_context_clues_iter = + MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES.iter(); + let (key_binding, description) = manual_search_contextual_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); + assert_str_eq!(*description, "details"); + assert_eq!(manual_search_contextual_context_clues_iter.next(), None); + } + + #[test] + fn test_episode_details_context_clues() { + let mut episode_details_context_clues_iter = EPISODE_DETAILS_CONTEXT_CLUES.iter(); + + let (key_binding, description) = episode_details_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); + + let (key_binding, description) = episode_details_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); + assert_str_eq!(*description, "auto search"); + + let (key_binding, description) = episode_details_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc); + assert_eq!(episode_details_context_clues_iter.next(), None); + } } diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 96538fa..848f199 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -1,15 +1,25 @@ use strum::IntoEnumIterator; -use crate::models::{ - servarr_data::modals::EditIndexerModal, - servarr_models::{Indexer, RootFolder}, - sonarr_models::{Episode, Series, SeriesMonitor, SeriesType, SonarrHistoryItem, SonarrRelease}, - stateful_list::StatefulList, - stateful_table::StatefulTable, - HorizontallyScrollableText, ScrollableText, +use crate::{ + app::{ + context_clues::build_context_clue_string, + sonarr::sonarr_context_clues::{ + EPISODE_DETAILS_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, + MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, + SEASON_DETAILS_CONTEXT_CLUES, + }, + }, + models::{ + servarr_data::modals::EditIndexerModal, + servarr_models::{Indexer, RootFolder}, + sonarr_models::{Episode, Series, SeriesMonitor, SeriesType, SonarrHistoryItem, SonarrRelease}, + stateful_list::StatefulList, + stateful_table::StatefulTable, + HorizontallyScrollableText, ScrollableText, TabRoute, TabState, + }, }; -use super::sonarr_data::SonarrData; +use super::sonarr_data::{ActiveSonarrBlock, SonarrData}; #[cfg(test)] #[path = "modals_tests.rs"] @@ -246,22 +256,86 @@ impl From<&SonarrData<'_>> for EditSeriesModal { } } -#[derive(Default)] pub struct EpisodeDetailsModal { - // Temporarily allowing this, since the value is only current written and not read. - // This will be read from once I begin the UI work for Sonarr - #[allow(dead_code)] pub episode_details: ScrollableText, pub file_details: String, pub audio_details: String, pub video_details: String, pub episode_history: StatefulTable, pub episode_releases: StatefulTable, + pub episode_details_tabs: TabState, +} + +impl Default for EpisodeDetailsModal { + fn default() -> EpisodeDetailsModal { + EpisodeDetailsModal { + episode_details: ScrollableText::default(), + file_details: String::new(), + audio_details: String::new(), + video_details: String::new(), + episode_history: StatefulTable::default(), + episode_releases: StatefulTable::default(), + episode_details_tabs: TabState::new(vec![ + TabRoute { + title: "Details", + route: ActiveSonarrBlock::EpisodeDetails.into(), + help: build_context_clue_string(&EPISODE_DETAILS_CONTEXT_CLUES), + contextual_help: None, + }, + TabRoute { + title: "History", + route: ActiveSonarrBlock::EpisodeHistory.into(), + help: build_context_clue_string(&EPISODE_DETAILS_CONTEXT_CLUES), + contextual_help: None, + }, + TabRoute { + title: "File", + route: ActiveSonarrBlock::EpisodeFile.into(), + help: build_context_clue_string(&EPISODE_DETAILS_CONTEXT_CLUES), + contextual_help: None, + }, + TabRoute { + title: "Manual Search", + route: ActiveSonarrBlock::ManualEpisodeSearch.into(), + help: build_context_clue_string(&MANUAL_EPISODE_SEARCH_CONTEXT_CLUES), + contextual_help: Some(build_context_clue_string( + &MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, + )), + }, + ]), + } + } } -#[derive(Default)] pub struct SeasonDetailsModal { pub episodes: StatefulTable, pub episode_details_modal: Option, pub season_releases: StatefulTable, + pub season_details_tabs: TabState, +} + +impl Default for SeasonDetailsModal { + fn default() -> SeasonDetailsModal { + SeasonDetailsModal { + episodes: StatefulTable::default(), + episode_details_modal: None, + season_releases: StatefulTable::default(), + season_details_tabs: TabState::new(vec![ + TabRoute { + title: "Episodes", + route: ActiveSonarrBlock::SeasonDetails.into(), + help: String::new(), + contextual_help: Some(build_context_clue_string(&SEASON_DETAILS_CONTEXT_CLUES)), + }, + TabRoute { + title: "Manual Search", + route: ActiveSonarrBlock::ManualSeasonSearch.into(), + help: String::new(), + contextual_help: Some(build_context_clue_string( + &MANUAL_SEASON_SEARCH_CONTEXT_CLUES, + )), + }, + ]), + } + } } diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs index 96af39f..cbaecd8 100644 --- a/src/models/servarr_data/sonarr/modals_tests.rs +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -5,7 +5,16 @@ mod tests { use rstest::rstest; use strum::IntoEnumIterator; - use crate::models::servarr_data::sonarr::modals::EditSeriesModal; + use crate::app::context_clues::build_context_clue_string; + use crate::app::sonarr::sonarr_context_clues::{ + EPISODE_DETAILS_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, + MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, + SEASON_DETAILS_CONTEXT_CLUES, + }; + use crate::models::servarr_data::sonarr::modals::{ + EditSeriesModal, EpisodeDetailsModal, SeasonDetailsModal, + }; + use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{Indexer, IndexerField}; use crate::models::{ servarr_data::sonarr::{modals::AddSeriesModal, sonarr_data::SonarrData}, @@ -221,4 +230,130 @@ mod tests { assert_eq!(edit_series_modal.monitored, Some(true)); assert_eq!(edit_series_modal.use_season_folders, Some(true)); } + + #[test] + fn test_episode_details_modal_default() { + let episode_details_modal = EpisodeDetailsModal::default(); + + assert!(episode_details_modal.episode_details.is_empty()); + assert!(episode_details_modal.file_details.is_empty()); + assert!(episode_details_modal.audio_details.is_empty()); + assert!(episode_details_modal.video_details.is_empty()); + assert!(episode_details_modal.episode_history.is_empty()); + assert!(episode_details_modal.episode_releases.is_empty()); + + assert_eq!(episode_details_modal.episode_details_tabs.tabs.len(), 4); + + assert_str_eq!( + episode_details_modal.episode_details_tabs.tabs[0].title, + "Details" + ); + assert_eq!( + episode_details_modal.episode_details_tabs.tabs[0].route, + ActiveSonarrBlock::EpisodeDetails.into() + ); + assert_str_eq!( + episode_details_modal.episode_details_tabs.tabs[0].help, + build_context_clue_string(&EPISODE_DETAILS_CONTEXT_CLUES) + ); + assert!(episode_details_modal.episode_details_tabs.tabs[0] + .contextual_help + .is_none()); + + assert_str_eq!( + episode_details_modal.episode_details_tabs.tabs[1].title, + "History" + ); + assert_eq!( + episode_details_modal.episode_details_tabs.tabs[1].route, + ActiveSonarrBlock::EpisodeHistory.into() + ); + assert_str_eq!( + episode_details_modal.episode_details_tabs.tabs[1].help, + build_context_clue_string(&EPISODE_DETAILS_CONTEXT_CLUES) + ); + assert!(episode_details_modal.episode_details_tabs.tabs[1] + .contextual_help + .is_none()); + + assert_str_eq!( + episode_details_modal.episode_details_tabs.tabs[2].title, + "File" + ); + assert_eq!( + episode_details_modal.episode_details_tabs.tabs[2].route, + ActiveSonarrBlock::EpisodeFile.into() + ); + assert_str_eq!( + episode_details_modal.episode_details_tabs.tabs[2].help, + build_context_clue_string(&EPISODE_DETAILS_CONTEXT_CLUES) + ); + assert!(episode_details_modal.episode_details_tabs.tabs[2] + .contextual_help + .is_none()); + + assert_str_eq!( + episode_details_modal.episode_details_tabs.tabs[3].title, + "Manual Search" + ); + assert_eq!( + episode_details_modal.episode_details_tabs.tabs[3].route, + ActiveSonarrBlock::ManualEpisodeSearch.into() + ); + assert_str_eq!( + episode_details_modal.episode_details_tabs.tabs[3].help, + build_context_clue_string(&MANUAL_EPISODE_SEARCH_CONTEXT_CLUES) + ); + assert_eq!( + episode_details_modal.episode_details_tabs.tabs[3].contextual_help, + Some(build_context_clue_string( + &MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES + )) + ); + } + + #[test] + fn test_season_details_modal_default() { + let season_details_modal = SeasonDetailsModal::default(); + + assert!(season_details_modal.episodes.is_empty()); + assert!(season_details_modal.episode_details_modal.is_none()); + assert!(season_details_modal.season_releases.is_empty()); + + assert_eq!(season_details_modal.season_details_tabs.tabs.len(), 2); + + assert_str_eq!( + season_details_modal.season_details_tabs.tabs[0].title, + "Episodes" + ); + assert_eq!( + season_details_modal.season_details_tabs.tabs[0].route, + ActiveSonarrBlock::SeasonDetails.into() + ); + assert!(season_details_modal.season_details_tabs.tabs[0] + .help + .is_empty()); + assert_eq!( + season_details_modal.season_details_tabs.tabs[0].contextual_help, + Some(build_context_clue_string(&SEASON_DETAILS_CONTEXT_CLUES)) + ); + + assert_str_eq!( + season_details_modal.season_details_tabs.tabs[1].title, + "Manual Search" + ); + assert_eq!( + season_details_modal.season_details_tabs.tabs[1].route, + ActiveSonarrBlock::ManualSeasonSearch.into() + ); + assert!(season_details_modal.season_details_tabs.tabs[1] + .help + .is_empty()); + assert_eq!( + season_details_modal.season_details_tabs.tabs[1].contextual_help, + Some(build_context_clue_string( + &MANUAL_SEASON_SEARCH_CONTEXT_CLUES + )) + ); + } } diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 1cf7664..168a84c 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -8,7 +8,9 @@ use crate::{ build_context_clue_string, BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, }, - sonarr::sonarr_context_clues::{HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES}, + sonarr::sonarr_context_clues::{ + HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES, + }, }, models::{ servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem}, @@ -30,6 +32,10 @@ use super::modals::{AddSeriesModal, EditSeriesModal, SeasonDetailsModal}; #[path = "sonarr_data_tests.rs"] mod sonarr_data_tests; +#[cfg(test)] +#[path = "sonarr_test_utils.rs"] +pub mod sonarr_test_utils; + pub struct SonarrData<'a> { pub add_list_exclusion: bool, pub add_searched_series: Option>, @@ -49,6 +55,7 @@ pub struct SonarrData<'a> { pub indexer_test_error: Option, pub language_profiles_map: BiMap, pub logs: StatefulList, + pub log_details: StatefulList, pub main_tabs: TabState, pub prompt_confirm: bool, pub prompt_confirm_action: Option, @@ -60,6 +67,7 @@ pub struct SonarrData<'a> { pub selected_block: BlockSelectionState<'a, ActiveSonarrBlock>, pub series: StatefulTable, pub series_history: Option>, + pub series_info_tabs: TabState, pub start_time: DateTime, pub tags_map: BiMap, pub tasks: StatefulTable, @@ -95,6 +103,7 @@ impl<'a> Default for SonarrData<'a> { indexer_test_all_results: None, language_profiles_map: BiMap::new(), logs: StatefulList::default(), + log_details: StatefulList::default(), prompt_confirm: false, prompt_confirm_action: None, quality_profile_map: BiMap::new(), @@ -154,6 +163,20 @@ impl<'a> Default for SonarrData<'a> { contextual_help: Some(build_context_clue_string(&SYSTEM_CONTEXT_CLUES)), }, ]), + series_info_tabs: TabState::new(vec![ + TabRoute { + title: "Seasons", + route: ActiveSonarrBlock::SeriesDetails.into(), + help: String::new(), + contextual_help: Some(build_context_clue_string(&SERIES_DETAILS_CONTEXT_CLUES)), + }, + TabRoute { + title: "History", + route: ActiveSonarrBlock::SeriesHistory.into(), + help: String::new(), + contextual_help: Some(build_context_clue_string(&HISTORY_CONTEXT_CLUES)), + }, + ]), } } } diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 2d76b91..02e590a 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -10,7 +10,9 @@ mod tests { build_context_clue_string, BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, }, - sonarr::sonarr_context_clues::{HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES}, + sonarr::sonarr_context_clues::{ + HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES, + }, }, models::{ servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}, @@ -76,6 +78,7 @@ mod tests { assert!(sonarr_data.indexer_test_all_results.is_none()); assert!(sonarr_data.language_profiles_map.is_empty()); assert!(sonarr_data.logs.is_empty()); + assert!(sonarr_data.log_details.is_empty()); assert!(!sonarr_data.prompt_confirm); assert!(sonarr_data.prompt_confirm_action.is_none()); assert!(sonarr_data.quality_profile_map.is_empty()); @@ -170,6 +173,30 @@ mod tests { sonarr_data.main_tabs.tabs[6].contextual_help, Some(build_context_clue_string(&SYSTEM_CONTEXT_CLUES)) ); + + assert_eq!(sonarr_data.series_info_tabs.tabs.len(), 2); + + assert_str_eq!(sonarr_data.series_info_tabs.tabs[0].title, "Seasons"); + assert_eq!( + sonarr_data.series_info_tabs.tabs[0].route, + ActiveSonarrBlock::SeriesDetails.into() + ); + assert!(sonarr_data.series_info_tabs.tabs[0].help.is_empty()); + assert_eq!( + sonarr_data.series_info_tabs.tabs[0].contextual_help, + Some(build_context_clue_string(&SERIES_DETAILS_CONTEXT_CLUES)) + ); + + assert_str_eq!(sonarr_data.series_info_tabs.tabs[1].title, "History"); + assert_eq!( + sonarr_data.series_info_tabs.tabs[1].route, + ActiveSonarrBlock::SeriesHistory.into() + ); + assert!(sonarr_data.series_info_tabs.tabs[1].help.is_empty()); + assert_eq!( + sonarr_data.series_info_tabs.tabs[1].contextual_help, + Some(build_context_clue_string(&HISTORY_CONTEXT_CLUES)) + ); } } diff --git a/src/models/servarr_data/sonarr/sonarr_test_utils.rs b/src/models/servarr_data/sonarr/sonarr_test_utils.rs new file mode 100644 index 0000000..4919c67 --- /dev/null +++ b/src/models/servarr_data/sonarr/sonarr_test_utils.rs @@ -0,0 +1,58 @@ +#[cfg(test)] +pub mod utils { + use crate::models::{ + servarr_data::sonarr::{ + modals::{EpisodeDetailsModal, SeasonDetailsModal}, + sonarr_data::SonarrData, + }, + sonarr_models::{AddSeriesSearchResult, Episode, Season, SonarrHistoryItem, SonarrRelease}, + stateful_table::StatefulTable, + HorizontallyScrollableText, ScrollableText, + }; + + pub fn create_test_sonarr_data<'a>() -> SonarrData<'a> { + let mut episode_details_modal = EpisodeDetailsModal { + episode_details: ScrollableText::with_string("test episode details".to_owned()), + ..EpisodeDetailsModal::default() + }; + episode_details_modal + .episode_history + .set_items(vec![SonarrHistoryItem::default()]); + episode_details_modal + .episode_releases + .set_items(vec![SonarrRelease::default()]); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal + .episodes + .set_items(vec![Episode::default()]); + season_details_modal + .season_releases + .set_items(vec![SonarrRelease::default()]); + season_details_modal.episode_details_modal = Some(episode_details_modal); + + let mut seasons = StatefulTable::default(); + seasons.set_items(vec![Season::default()]); + + let mut sonarr_data = SonarrData { + delete_series_files: true, + add_list_exclusion: true, + add_series_search: Some("test search".into()), + edit_root_folder: Some("test path".into()), + seasons, + season_details_modal: Some(season_details_modal), + add_searched_series: Some(StatefulTable::default()), + ..SonarrData::default() + }; + sonarr_data.series_info_tabs.index = 1; + sonarr_data + .add_searched_series + .as_mut() + .unwrap() + .set_items(vec![AddSeriesSearchResult::default()]); + sonarr_data + .log_details + .set_items(vec![HorizontallyScrollableText::default()]); + + sonarr_data + } +} From c3fb5dcd5f63d1e31e82dc3398a20dd577e57047 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 1 Dec 2024 13:48:48 -0700 Subject: [PATCH 07/82] feat(handlers): Sonarr key support for the Series table --- src/handlers/handler_test_utils.rs | 106 +- src/handlers/handlers_tests.rs | 32 +- src/handlers/mod.rs | 24 +- .../blocklist/blocklist_handler_tests.rs | 2 + .../collection_details_handler_tests.rs | 2 + .../collections/collections_handler_tests.rs | 2 + .../downloads/downloads_handler_tests.rs | 2 + .../indexers/indexers_handler_tests.rs | 2 + .../library/library_handler_tests.rs | 2 + .../radarr_handler_test_utils.rs | 44 - .../root_folders_handler_tests.rs | 2 + .../system/system_details_handler_tests.rs | 2 + .../library/library_handler_tests.rs | 1821 +++++++++++++++++ src/handlers/sonarr_handlers/library/mod.rs | 460 +++++ src/handlers/sonarr_handlers/mod.rs | 96 + .../sonarr_handler_test_utils.rs | 157 ++ .../sonarr_handlers/sonarr_handler_tests.rs | 122 ++ src/models/servarr_data/sonarr/sonarr_data.rs | 29 + .../servarr_data/sonarr/sonarr_data_tests.rs | 75 +- 19 files changed, 2900 insertions(+), 82 deletions(-) create mode 100644 src/handlers/sonarr_handlers/library/library_handler_tests.rs create mode 100644 src/handlers/sonarr_handlers/library/mod.rs create mode 100644 src/handlers/sonarr_handlers/mod.rs create mode 100644 src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs create mode 100644 src/handlers/sonarr_handlers/sonarr_handler_tests.rs diff --git a/src/handlers/handler_test_utils.rs b/src/handlers/handler_test_utils.rs index 82a2d97..36e5d5f 100644 --- a/src/handlers/handler_test_utils.rs +++ b/src/handlers/handler_test_utils.rs @@ -99,86 +99,92 @@ mod test_utils { #[macro_export] macro_rules! test_iterable_scroll { - ($func:ident, $handler:ident, $data_ref:ident, $block:expr, $context:expr) => { + ($func:ident, $handler:ident, $servarr_data:ident, $data_ref:ident, $block:expr, $context:expr) => { #[rstest] fn $func(#[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key) { let mut app = App::default(); app .data - .radarr_data + .$servarr_data .$data_ref .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); $handler::with(&key, &mut app, &$block, &$context).handle(); - assert_str_eq!(app.data.radarr_data.$data_ref.current_selection(), "Test 2"); + assert_str_eq!( + app.data.$servarr_data.$data_ref.current_selection(), + "Test 2" + ); $handler::with(&key, &mut app, &$block, &$context).handle(); - assert_str_eq!(app.data.radarr_data.$data_ref.current_selection(), "Test 1"); + assert_str_eq!( + app.data.$servarr_data.$data_ref.current_selection(), + "Test 1" + ); } }; - ($func:ident, $handler:ident, $data_ref:ident, $items:ident, $block:expr, $context:expr, $field:ident) => { + ($func:ident, $handler:ident, $servarr_data:ident, $data_ref:ident, $items:ident, $block:expr, $context:expr, $field:ident) => { #[rstest] fn $func(#[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key) { let mut app = App::default(); app .data - .radarr_data + .$servarr_data .$data_ref .set_items(simple_stateful_iterable_vec!($items)); $handler::with(key, &mut app, $block, $context).handle(); assert_str_eq!( - app.data.radarr_data.$data_ref.current_selection().$field, + app.data.$servarr_data.$data_ref.current_selection().$field, "Test 2" ); $handler::with(key, &mut app, $block, $context).handle(); assert_str_eq!( - app.data.radarr_data.$data_ref.current_selection().$field, + app.data.$servarr_data.$data_ref.current_selection().$field, "Test 1" ); } }; - ($func:ident, $handler:ident, $data_ref:ident, $items:expr, $block:expr, $context:expr, $field:ident) => { + ($func:ident, $handler:ident, $servarr_data:ident, $data_ref:ident, $items:expr, $block:expr, $context:expr, $field:ident) => { #[rstest] fn $func(#[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key) { let mut app = App::default(); - app.data.radarr_data.$data_ref.set_items($items); + app.data.$servarr_data.$data_ref.set_items($items); $handler::with(key, &mut app, $block, $context).handle(); assert_str_eq!( - app.data.radarr_data.$data_ref.current_selection().$field, + app.data.$servarr_data.$data_ref.current_selection().$field, "Test 2" ); $handler::with(key, &mut app, $block, $context).handle(); assert_str_eq!( - app.data.radarr_data.$data_ref.current_selection().$field, + app.data.$servarr_data.$data_ref.current_selection().$field, "Test 1" ); } }; - ($func:ident, $handler:ident, $data_ref:ident, $items:expr, $block:expr, $context:expr, $field:ident, $conversion_fn:ident) => { + ($func:ident, $handler:ident, $servarr_data:ident, $data_ref:ident, $items:expr, $block:expr, $context:expr, $field:ident, $conversion_fn:ident) => { #[rstest] fn $func(#[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key) { let mut app = App::default(); - app.data.radarr_data.$data_ref.set_items($items); + app.data.$servarr_data.$data_ref.set_items($items); $handler::with(key, &mut app, $block, $context).handle(); assert_str_eq!( app .data - .radarr_data + .$servarr_data .$data_ref .current_selection() .$field @@ -191,7 +197,7 @@ mod test_utils { assert_str_eq!( app .data - .radarr_data + .$servarr_data .$data_ref .current_selection() .$field @@ -204,11 +210,11 @@ mod test_utils { #[macro_export] macro_rules! test_iterable_home_and_end { - ($func:ident, $handler:ident, $data_ref:ident, $block:expr, $context:expr) => { + ($func:ident, $handler:ident, $servarr_data:ident, $data_ref:ident, $block:expr, $context:expr) => { #[test] fn $func() { let mut app = App::default(); - app.data.radarr_data.$data_ref.set_items(vec![ + app.data.$servarr_data.$data_ref.set_items(vec![ "Test 1".to_owned(), "Test 2".to_owned(), "Test 3".to_owned(), @@ -216,74 +222,80 @@ mod test_utils { $handler::with(DEFAULT_KEYBINDINGS.end.key, &mut app, $block, $context).handle(); - assert_str_eq!(app.data.radarr_data.$data_ref.current_selection(), "Test 3"); + assert_str_eq!( + app.data.$servarr_data.$data_ref.current_selection(), + "Test 3" + ); $handler::with(DEFAULT_KEYBINDINGS.home.key, &mut app, $block, $context).handle(); - assert_str_eq!(app.data.radarr_data.$data_ref.current_selection(), "Test 1"); + assert_str_eq!( + app.data.$servarr_data.$data_ref.current_selection(), + "Test 1" + ); } }; - ($func:ident, $handler:ident, $data_ref:ident, $items:ident, $block:expr, $context:expr, $field:ident) => { + ($func:ident, $handler:ident, $servarr_data:ident, $data_ref:ident, $items:ident, $block:expr, $context:expr, $field:ident) => { #[test] fn $func() { let mut app = App::default(); app .data - .radarr_data + .$servarr_data .$data_ref .set_items(extended_stateful_iterable_vec!($items)); $handler::with(DEFAULT_KEYBINDINGS.end.key, &mut app, $block, $context).handle(); assert_str_eq!( - app.data.radarr_data.$data_ref.current_selection().$field, + app.data.$servarr_data.$data_ref.current_selection().$field, "Test 3" ); $handler::with(DEFAULT_KEYBINDINGS.home.key, &mut app, $block, $context).handle(); assert_str_eq!( - app.data.radarr_data.$data_ref.current_selection().$field, + app.data.$servarr_data.$data_ref.current_selection().$field, "Test 1" ); } }; - ($func:ident, $handler:ident, $data_ref:ident, $items:expr, $block:expr, $context:expr, $field:ident) => { + ($func:ident, $handler:ident, $servarr_data:ident, $data_ref:ident, $items:expr, $block:expr, $context:expr, $field:ident) => { #[test] fn $func() { let mut app = App::default(); - app.data.radarr_data.$data_ref.set_items($items); + app.data.$servarr_data.$data_ref.set_items($items); $handler::with(DEFAULT_KEYBINDINGS.end.key, &mut app, $block, $context).handle(); assert_str_eq!( - app.data.radarr_data.$data_ref.current_selection().$field, + app.data.$servarr_data.$data_ref.current_selection().$field, "Test 3" ); $handler::with(DEFAULT_KEYBINDINGS.home.key, &mut app, $block, $context).handle(); assert_str_eq!( - app.data.radarr_data.$data_ref.current_selection().$field, + app.data.$servarr_data.$data_ref.current_selection().$field, "Test 1" ); } }; - ($func:ident, $handler:ident, $data_ref:ident, $items:expr, $block:expr, $context:expr, $field:ident, $conversion_fn:ident) => { + ($func:ident, $handler:ident, $servarr_data:ident, $data_ref:ident, $items:expr, $block:expr, $context:expr, $field:ident, $conversion_fn:ident) => { #[test] fn $func() { let mut app = App::default(); - app.data.radarr_data.$data_ref.set_items($items); + app.data.$servarr_data.$data_ref.set_items($items); $handler::with(DEFAULT_KEYBINDINGS.end.key, &mut app, $block, $context).handle(); assert_str_eq!( app .data - .radarr_data + .$servarr_data .$data_ref .current_selection() .$field @@ -296,7 +308,7 @@ mod test_utils { assert_str_eq!( app .data - .radarr_data + .$servarr_data .$data_ref .current_selection() .$field @@ -319,4 +331,34 @@ mod test_utils { assert_eq!(app.get_current_route(), $base.into()); }; } + + #[macro_export] + macro_rules! assert_delete_prompt { + ($handler:ident, $block:expr, $expected_block:expr) => { + let mut app = App::default(); + + $handler::with(DELETE_KEY, &mut app, $block, None).handle(); + + assert_eq!(app.get_current_route(), $expected_block.into()); + }; + + ($handler:ident, $app:expr, $block:expr, $expected_block:expr) => { + $handler::with(DELETE_KEY, &mut $app, $block, None).handle(); + + assert_eq!($app.get_current_route(), $expected_block.into()); + }; + } + + #[macro_export] + macro_rules! assert_refresh_key { + ($handler:ident, $block:expr) => { + let mut app = App::default(); + app.push_navigation_stack($block.into()); + + $handler::with(DEFAULT_KEYBINDINGS.refresh.key, &mut app, $block, None).handle(); + + assert_eq!(app.get_current_route(), $block.into()); + assert!(app.should_refresh); + }; + } } diff --git a/src/handlers/handlers_tests.rs b/src/handlers/handlers_tests.rs index 7b2503e..d204e18 100644 --- a/src/handlers/handlers_tests.rs +++ b/src/handlers/handlers_tests.rs @@ -23,6 +23,19 @@ mod tests { assert!(app.error.text.is_empty()); } + #[rstest] + #[case(ActiveRadarrBlock::Movies.into(), ActiveRadarrBlock::SearchMovie.into())] + #[case(ActiveSonarrBlock::Series.into(), ActiveSonarrBlock::SearchSeries.into())] + fn test_handle_events(#[case] base_block: Route, #[case] top_block: Route) { + let mut app = App::default(); + app.push_navigation_stack(base_block); + app.push_navigation_stack(top_block); + + handle_events(DEFAULT_KEYBINDINGS.esc.key, &mut app); + + assert_eq!(app.get_current_route(), base_block); + } + #[rstest] #[case(0, ActiveSonarrBlock::Series, ActiveSonarrBlock::Series)] #[case(1, ActiveRadarrBlock::Movies, ActiveRadarrBlock::Movies)] @@ -54,8 +67,9 @@ mod tests { } #[rstest] - fn test_handle_prompt_toggle_left_right(#[values(Key::Left, Key::Right)] key: Key) { + fn test_handle_prompt_toggle_left_right_radarr(#[values(Key::Left, Key::Right)] key: Key) { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); assert!(!app.data.radarr_data.prompt_confirm); @@ -67,4 +81,20 @@ mod tests { assert!(!app.data.radarr_data.prompt_confirm); } + + #[rstest] + fn test_handle_prompt_toggle_left_right_sonarr(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + + assert!(!app.data.sonarr_data.prompt_confirm); + + handle_prompt_toggle(&mut app, key); + + assert!(app.data.sonarr_data.prompt_confirm); + + handle_prompt_toggle(&mut app, key); + + assert!(!app.data.sonarr_data.prompt_confirm); + } } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 95a8a33..1f6f88e 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,4 +1,5 @@ use radarr_handlers::RadarrHandler; +use sonarr_handlers::SonarrHandler; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; @@ -6,6 +7,7 @@ use crate::event::Key; use crate::models::{HorizontallyScrollableText, Route}; mod radarr_handlers; +mod sonarr_handlers; #[cfg(test)] #[path = "handlers_tests.rs"] @@ -89,8 +91,16 @@ pub fn handle_events(key: Key, app: &mut App<'_>) { app.reset(); app.server_tabs.previous(); app.pop_and_push_navigation_stack(app.server_tabs.get_active_route()); - } else if let Route::Radarr(active_radarr_block, context) = app.get_current_route() { - RadarrHandler::with(key, app, active_radarr_block, context).handle() + } else { + match app.get_current_route() { + Route::Radarr(active_radarr_block, context) => { + RadarrHandler::with(key, app, active_radarr_block, context).handle() + } + Route::Sonarr(active_sonarr_block, context) => { + SonarrHandler::with(key, app, active_sonarr_block, context).handle() + } + _ => (), + } } } @@ -103,8 +113,14 @@ fn handle_clear_errors(app: &mut App<'_>) { fn handle_prompt_toggle(app: &mut App<'_>, key: Key) { match key { _ if key == DEFAULT_KEYBINDINGS.left.key || key == DEFAULT_KEYBINDINGS.right.key => { - if let Route::Radarr(_, _) = app.get_current_route() { - app.data.radarr_data.prompt_confirm = !app.data.radarr_data.prompt_confirm; + match app.get_current_route() { + Route::Radarr(_, _) => { + app.data.radarr_data.prompt_confirm = !app.data.radarr_data.prompt_confirm + } + Route::Sonarr(_, _) => { + app.data.sonarr_data.prompt_confirm = !app.data.sonarr_data.prompt_confirm + } + _ => (), } } _ => (), diff --git a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs index 812b30f..a48fdfa 100644 --- a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs +++ b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs @@ -28,6 +28,7 @@ mod tests { test_iterable_scroll!( test_blocklist_scroll, BlocklistHandler, + radarr_data, blocklist, simple_stateful_iterable_vec!(BlocklistItem, String, source_title), ActiveRadarrBlock::Blocklist, @@ -136,6 +137,7 @@ mod tests { test_iterable_home_and_end!( test_blocklist_home_and_end, BlocklistHandler, + radarr_data, blocklist, extended_stateful_iterable_vec!(BlocklistItem, String, source_title), ActiveRadarrBlock::Blocklist, diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs b/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs index 32c40b4..b2cbf33 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs @@ -24,6 +24,7 @@ mod tests { test_iterable_scroll!( test_collection_details_scroll, CollectionDetailsHandler, + radarr_data, collection_movies, simple_stateful_iterable_vec!(CollectionMovie, HorizontallyScrollableText), ActiveRadarrBlock::CollectionDetails, @@ -88,6 +89,7 @@ mod tests { test_iterable_home_and_end!( test_collection_details_home_end, CollectionDetailsHandler, + radarr_data, collection_movies, extended_stateful_iterable_vec!(CollectionMovie, HorizontallyScrollableText), ActiveRadarrBlock::CollectionDetails, diff --git a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs index a85b7ab..34a5d84 100644 --- a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs @@ -34,6 +34,7 @@ mod tests { test_iterable_scroll!( test_collections_scroll, CollectionsHandler, + radarr_data, collections, simple_stateful_iterable_vec!(Collection, HorizontallyScrollableText), ActiveRadarrBlock::Collections, @@ -153,6 +154,7 @@ mod tests { test_iterable_home_and_end!( test_collections_home_end, CollectionsHandler, + radarr_data, collections, extended_stateful_iterable_vec!(Collection, HorizontallyScrollableText), ActiveRadarrBlock::Collections, diff --git a/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs index 8dee3af..f47a498 100644 --- a/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs +++ b/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs @@ -22,6 +22,7 @@ mod tests { test_iterable_scroll!( test_downloads_scroll, DownloadsHandler, + radarr_data, downloads, DownloadRecord, ActiveRadarrBlock::Downloads, @@ -69,6 +70,7 @@ mod tests { test_iterable_home_and_end!( test_downloads_home_end, DownloadsHandler, + radarr_data, downloads, DownloadRecord, ActiveRadarrBlock::Downloads, diff --git a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs index aa875f5..ac4c0f3 100644 --- a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs @@ -25,6 +25,7 @@ mod tests { test_iterable_scroll!( test_indexers_scroll, IndexersHandler, + radarr_data, indexers, simple_stateful_iterable_vec!(Indexer, String, protocol), ActiveRadarrBlock::Indexers, @@ -71,6 +72,7 @@ mod tests { test_iterable_home_and_end!( test_indexers_home_end, IndexersHandler, + radarr_data, indexers, extended_stateful_iterable_vec!(Indexer, String, protocol), ActiveRadarrBlock::Indexers, diff --git a/src/handlers/radarr_handlers/library/library_handler_tests.rs b/src/handlers/radarr_handlers/library/library_handler_tests.rs index f79ba5a..49f8738 100644 --- a/src/handlers/radarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/library_handler_tests.rs @@ -30,6 +30,7 @@ mod tests { test_iterable_scroll!( test_movies_scroll, LibraryHandler, + radarr_data, movies, simple_stateful_iterable_vec!(Movie, HorizontallyScrollableText), ActiveRadarrBlock::Movies, @@ -134,6 +135,7 @@ mod tests { test_iterable_home_and_end!( test_movies_home_end, LibraryHandler, + radarr_data, movies, extended_stateful_iterable_vec!(Movie, HorizontallyScrollableText), ActiveRadarrBlock::Movies, diff --git a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs index b14cd28..849202f 100644 --- a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs +++ b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs @@ -228,48 +228,4 @@ mod utils { ); }; } - - #[macro_export] - macro_rules! assert_delete_prompt { - ($block:expr, $expected_block:expr) => { - let mut app = App::default(); - - RadarrHandler::with(DELETE_KEY, &mut app, $block, None).handle(); - - assert_eq!(app.get_current_route(), $expected_block.into()); - }; - - ($handler:ident, $block:expr, $expected_block:expr) => { - let mut app = App::default(); - - $handler::with(DELETE_KEY, &mut app, $block, None).handle(); - - assert_eq!(app.get_current_route(), $expected_block.into()); - }; - - ($app:expr, $block:expr, $expected_block:expr) => { - RadarrHandler::with(DELETE_KEY, &mut $app, $block, None).handle(); - - assert_eq!($app.get_current_route(), $expected_block.into()); - }; - - ($handler:ident, $app:expr, $block:expr, $expected_block:expr) => { - $handler::with(DELETE_KEY, &mut $app, $block, None).handle(); - - assert_eq!($app.get_current_route(), $expected_block.into()); - }; - } - - #[macro_export] - macro_rules! assert_refresh_key { - ($handler:ident, $block:expr) => { - let mut app = App::default(); - app.push_navigation_stack($block.into()); - - $handler::with(DEFAULT_KEYBINDINGS.refresh.key, &mut app, $block, None).handle(); - - assert_eq!(app.get_current_route(), $block.into()); - assert!(app.should_refresh); - }; - } } diff --git a/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs b/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs index 356fc41..670eb9e 100644 --- a/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs +++ b/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs @@ -23,6 +23,7 @@ mod tests { test_iterable_scroll!( test_root_folders_scroll, RootFoldersHandler, + radarr_data, root_folders, simple_stateful_iterable_vec!(RootFolder, String, path), ActiveRadarrBlock::RootFolders, @@ -71,6 +72,7 @@ mod tests { test_iterable_home_and_end!( test_root_folders_home_end, RootFoldersHandler, + radarr_data, root_folders, extended_stateful_iterable_vec!(RootFolder, String, path), ActiveRadarrBlock::RootFolders, diff --git a/src/handlers/radarr_handlers/system/system_details_handler_tests.rs b/src/handlers/radarr_handlers/system/system_details_handler_tests.rs index a6b5bf4..89d1f80 100644 --- a/src/handlers/radarr_handlers/system/system_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/system/system_details_handler_tests.rs @@ -26,6 +26,7 @@ mod tests { test_iterable_scroll!( test_log_details_scroll, SystemDetailsHandler, + radarr_data, log_details, simple_stateful_iterable_vec!(HorizontallyScrollableText, String, text), ActiveRadarrBlock::SystemLogs, @@ -241,6 +242,7 @@ mod tests { test_iterable_home_and_end!( test_log_details_home_end, SystemDetailsHandler, + radarr_data, log_details, extended_stateful_iterable_vec!(HorizontallyScrollableText, String, text), ActiveRadarrBlock::SystemLogs, diff --git a/src/handlers/sonarr_handlers/library/library_handler_tests.rs b/src/handlers/sonarr_handlers/library/library_handler_tests.rs new file mode 100644 index 0000000..ca8bece --- /dev/null +++ b/src/handlers/sonarr_handlers/library/library_handler_tests.rs @@ -0,0 +1,1821 @@ +#[cfg(test)] +mod tests { + use core::sync::atomic::Ordering::SeqCst; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use std::cmp::Ordering; + use strum::IntoEnumIterator; + + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::library::{series_sorting_options, LibraryHandler}; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SERIES_BLOCKS}; + use crate::models::sonarr_models::{Series, SeriesType}; + use crate::models::stateful_table::SortOption; + use crate::models::HorizontallyScrollableText; + + mod test_handle_scroll_up_and_down { + use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; + use pretty_assertions::assert_eq; + + use super::*; + + test_iterable_scroll!( + test_series_scroll, + LibraryHandler, + sonarr_data, + series, + simple_stateful_iterable_vec!(Series, HorizontallyScrollableText), + ActiveSonarrBlock::Series, + None, + title, + to_string + ); + + #[rstest] + fn test_series_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.is_loading = true; + app + .data + .sonarr_data + .series + .set_items(simple_stateful_iterable_vec!( + Series, + HorizontallyScrollableText + )); + + LibraryHandler::with(key, &mut app, ActiveSonarrBlock::Series, None).handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .series + .current_selection() + .title + .to_string(), + "Test 1" + ); + + LibraryHandler::with(key, &mut app, ActiveSonarrBlock::Series, None).handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .series + .current_selection() + .title + .to_string(), + "Test 1" + ); + } + + #[rstest] + fn test_series_sort_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let series_field_vec = sort_options(); + let mut app = App::default(); + app.data.sonarr_data.series.sorting(sort_options()); + + if key == Key::Up { + for i in (0..series_field_vec.len()).rev() { + LibraryHandler::with(key, &mut app, ActiveSonarrBlock::SeriesSortPrompt, None).handle(); + + assert_eq!( + app + .data + .sonarr_data + .series + .sort + .as_ref() + .unwrap() + .current_selection(), + &series_field_vec[i] + ); + } + } else { + for i in 0..series_field_vec.len() { + LibraryHandler::with(key, &mut app, ActiveSonarrBlock::SeriesSortPrompt, None).handle(); + + assert_eq!( + app + .data + .sonarr_data + .series + .sort + .as_ref() + .unwrap() + .current_selection(), + &series_field_vec[(i + 1) % series_field_vec.len()] + ); + } + } + } + } + + mod test_handle_home_end { + use pretty_assertions::assert_eq; + + use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; + + use super::*; + + test_iterable_home_and_end!( + test_series_home_end, + LibraryHandler, + sonarr_data, + series, + extended_stateful_iterable_vec!(Series, HorizontallyScrollableText), + ActiveSonarrBlock::Series, + None, + title, + to_string + ); + + #[test] + fn test_series_home_end_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app + .data + .sonarr_data + .series + .set_items(extended_stateful_iterable_vec!( + Series, + HorizontallyScrollableText + )); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .series + .current_selection() + .title + .to_string(), + "Test 1" + ); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .series + .current_selection() + .title + .to_string(), + "Test 1" + ); + } + + #[test] + fn test_series_search_box_home_end_keys() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.data.sonarr_data.series.search = Some("Test".into()); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SearchSeries, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 4 + ); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SearchSeries, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 0 + ); + } + + #[test] + fn test_series_filter_box_home_end_keys() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.data.sonarr_data.series.filter = Some("Test".into()); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::FilterSeries, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series + .filter + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 4 + ); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::FilterSeries, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series + .filter + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 0 + ); + } + + #[test] + fn test_series_sort_home_end() { + let series_field_vec = sort_options(); + let mut app = App::default(); + app.data.sonarr_data.series.sorting(sort_options()); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SeriesSortPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series + .sort + .as_ref() + .unwrap() + .current_selection(), + &series_field_vec[series_field_vec.len() - 1] + ); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SeriesSortPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series + .sort + .as_ref() + .unwrap() + .current_selection(), + &series_field_vec[0] + ); + } + } + + mod test_handle_delete { + use pretty_assertions::assert_eq; + + use crate::assert_delete_prompt; + use crate::models::servarr_data::sonarr::sonarr_data::DELETE_SERIES_SELECTION_BLOCKS; + + use super::*; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_series_delete() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + assert_delete_prompt!( + LibraryHandler, + app, + ActiveSonarrBlock::Series, + ActiveSonarrBlock::DeleteSeriesPrompt + ); + assert_eq!( + app.data.sonarr_data.selected_block.blocks, + DELETE_SERIES_SELECTION_BLOCKS + ); + } + + #[test] + fn test_series_delete_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + LibraryHandler::with(DELETE_KEY, &mut app, ActiveSonarrBlock::Series, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_series_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(0); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::System.into() + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::System.into()); + } + + #[rstest] + fn test_series_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(0); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::Downloads.into() + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Downloads.into()); + } + + #[rstest] + fn test_left_right_update_all_series_prompt_toggle( + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + + LibraryHandler::with( + key, + &mut app, + ActiveSonarrBlock::UpdateAllSeriesPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + LibraryHandler::with( + key, + &mut app, + ActiveSonarrBlock::UpdateAllSeriesPrompt, + None, + ) + .handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[test] + fn test_series_search_box_left_right_keys() { + let mut app = App::default(); + app.data.sonarr_data.series.search = Some("Test".into()); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::SearchSeries, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 1 + ); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::SearchSeries, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 0 + ); + } + + #[test] + fn test_series_filter_box_left_right_keys() { + let mut app = App::default(); + app.data.sonarr_data.series.filter = Some("Test".into()); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::FilterSeries, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series + .filter + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 1 + ); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::FilterSeries, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series + .filter + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use crate::extended_stateful_iterable_vec; + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_series_details_submit() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::Series, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesDetails.into() + ); + } + + #[test] + fn test_series_details_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::Series, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + } + + #[test] + fn test_search_series_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); + app + .data + .sonarr_data + .series + .set_items(extended_stateful_iterable_vec!( + Series, + HorizontallyScrollableText + )); + app.data.sonarr_data.series.search = Some("Test 2".into()); + + LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchSeries, None).handle(); + + assert_str_eq!( + app.data.sonarr_data.series.current_selection().title.text, + "Test 2" + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + } + + #[test] + fn test_search_series_submit_error_on_no_search_hits() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); + app + .data + .sonarr_data + .series + .set_items(extended_stateful_iterable_vec!( + Series, + HorizontallyScrollableText + )); + app.data.sonarr_data.series.search = Some("Test 5".into()); + + LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchSeries, None).handle(); + + assert_str_eq!( + app.data.sonarr_data.series.current_selection().title.text, + "Test 1" + ); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SearchSeriesError.into() + ); + } + + #[test] + fn test_search_filtered_series_submit() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); + app + .data + .sonarr_data + .series + .set_filtered_items(extended_stateful_iterable_vec!( + Series, + HorizontallyScrollableText + )); + app.data.sonarr_data.series.search = Some("Test 2".into()); + + LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchSeries, None).handle(); + + assert_str_eq!( + app.data.sonarr_data.series.current_selection().title.text, + "Test 2" + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + } + + #[test] + fn test_filter_series_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); + app + .data + .sonarr_data + .series + .set_items(extended_stateful_iterable_vec!( + Series, + HorizontallyScrollableText + )); + app.data.sonarr_data.series.filter = Some("Test".into()); + + LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::FilterSeries, None).handle(); + + assert!(app.data.sonarr_data.series.filtered_items.is_some()); + assert!(!app.should_ignore_quit_key); + assert_eq!( + app + .data + .sonarr_data + .series + .filtered_items + .as_ref() + .unwrap() + .len(), + 3 + ); + assert_str_eq!( + app.data.sonarr_data.series.current_selection().title.text, + "Test 1" + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + } + + #[test] + fn test_filter_series_submit_error_on_no_filter_matches() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); + app + .data + .sonarr_data + .series + .set_items(extended_stateful_iterable_vec!( + Series, + HorizontallyScrollableText + )); + app.data.sonarr_data.series.filter = Some("Test 5".into()); + + LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::FilterSeries, None).handle(); + + assert!(!app.should_ignore_quit_key); + assert!(app.data.sonarr_data.series.filtered_items.is_none()); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::FilterSeriesError.into() + ); + } + + #[test] + fn test_update_all_series_prompt_confirm_submit() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::UpdateAllSeriesPrompt.into()); + + LibraryHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::UpdateAllSeriesPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::UpdateAllSeries) + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + } + + #[test] + fn test_update_all_series_prompt_decline_submit() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::UpdateAllSeriesPrompt.into()); + + LibraryHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::UpdateAllSeriesPrompt, + None, + ) + .handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + } + + #[test] + fn test_series_sort_prompt_submit() { + let mut app = App::default(); + app.data.sonarr_data.series.sort_asc = true; + app.data.sonarr_data.series.sorting(sort_options()); + app.data.sonarr_data.series.set_items(series_vec()); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::SeriesSortPrompt.into()); + + let mut expected_vec = series_vec(); + expected_vec.sort_by(|a, b| a.id.cmp(&b.id)); + expected_vec.reverse(); + + LibraryHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::SeriesSortPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert_eq!(app.data.sonarr_data.series.items, expected_vec); + } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + use ratatui::widgets::TableState; + + use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; + use crate::models::stateful_table::StatefulTable; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_search_series_block_esc( + #[values(ActiveSonarrBlock::SearchSeries, ActiveSonarrBlock::SearchSeriesError)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(active_sonarr_block.into()); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.series.search = Some("Test".into()); + + LibraryHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert!(!app.should_ignore_quit_key); + assert_eq!(app.data.sonarr_data.series.search, None); + } + + #[rstest] + fn test_filter_series_block_esc( + #[values(ActiveSonarrBlock::FilterSeries, ActiveSonarrBlock::FilterSeriesError)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(active_sonarr_block.into()); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.series = StatefulTable { + filter: Some("Test".into()), + filtered_items: Some(Vec::new()), + filtered_state: Some(TableState::default()), + ..StatefulTable::default() + }; + + LibraryHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert!(!app.should_ignore_quit_key); + assert_eq!(app.data.sonarr_data.series.filter, None); + assert_eq!(app.data.sonarr_data.series.filtered_items, None); + assert_eq!(app.data.sonarr_data.series.filtered_state, None); + } + + #[test] + fn test_update_all_series_prompt_blocks_esc() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::UpdateAllSeriesPrompt.into()); + app.data.sonarr_data.prompt_confirm = true; + + LibraryHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::UpdateAllSeriesPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[test] + fn test_series_sort_prompt_block_esc() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::SeriesSortPrompt.into()); + + LibraryHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::SeriesSortPrompt, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.series = StatefulTable { + search: Some("Test".into()), + filter: Some("Test".into()), + filtered_items: Some(Vec::new()), + filtered_state: Some(TableState::default()), + ..StatefulTable::default() + }; + + LibraryHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::Series, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert!(app.error.text.is_empty()); + assert_eq!(app.data.sonarr_data.series.search, None); + assert_eq!(app.data.sonarr_data.series.filter, None); + assert_eq!(app.data.sonarr_data.series.filtered_items, None); + assert_eq!(app.data.sonarr_data.series.filtered_state, None); + } + } + + mod test_handle_key_char { + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use serde_json::Number; + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; + use crate::models::servarr_data::sonarr::sonarr_data::SonarrData; + use crate::models::sonarr_models::SeriesType; + + use crate::network::sonarr_network::SonarrEvent; + use crate::test_edit_series_key; + + use super::*; + + #[test] + fn test_search_series_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SearchSeries.into() + ); + assert!(app.should_ignore_quit_key); + assert_eq!( + app.data.sonarr_data.series.search, + Some(HorizontallyScrollableText::default()) + ); + } + + #[test] + fn test_search_series_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert!(!app.should_ignore_quit_key); + assert_eq!(app.data.sonarr_data.series.search, None); + } + + #[test] + fn test_filter_series_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::FilterSeries.into() + ); + assert!(app.should_ignore_quit_key); + assert!(app.data.sonarr_data.series.filter.is_some()); + } + + #[test] + fn test_filter_series_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert!(!app.should_ignore_quit_key); + assert!(app.data.sonarr_data.series.filter.is_none()); + } + + #[test] + fn test_filter_series_key_resets_previous_filter() { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.data.sonarr_data.series.filter = Some("Test".into()); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::FilterSeries.into() + ); + assert!(app.should_ignore_quit_key); + assert_eq!( + app.data.sonarr_data.series.filter, + Some(HorizontallyScrollableText::default()) + ); + assert!(app.data.sonarr_data.series.filtered_items.is_none()); + assert!(app.data.sonarr_data.series.filtered_state.is_none()); + } + + #[test] + fn test_series_add_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesSearchInput.into() + ); + assert!(app.should_ignore_quit_key); + assert!(app.data.sonarr_data.add_series_search.is_some()); + } + + #[test] + fn test_series_add_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert!(!app.should_ignore_quit_key); + assert!(app.data.sonarr_data.add_series_search.is_none()); + } + + #[test] + fn test_series_edit_key() { + test_edit_series_key!( + LibraryHandler, + ActiveSonarrBlock::Series, + ActiveSonarrBlock::Series + ); + } + + #[test] + fn test_series_edit_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert!(app.data.sonarr_data.edit_series_modal.is_none()); + } + + #[test] + fn test_update_all_series_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::UpdateAllSeriesPrompt.into() + ); + } + + #[test] + fn test_update_all_series_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + } + + #[test] + fn test_refresh_series_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_series_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_search_series_box_backspace_key() { + let mut app = App::default(); + app.data.sonarr_data.series.search = Some("Test".into()); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::SearchSeries, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.series.search.as_ref().unwrap().text, + "Tes" + ); + } + + #[test] + fn test_filter_series_box_backspace_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.data.sonarr_data.series.filter = Some("Test".into()); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::FilterSeries, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.series.filter.as_ref().unwrap().text, + "Tes" + ); + } + + #[test] + fn test_search_series_box_char_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.data.sonarr_data.series.search = Some(HorizontallyScrollableText::default()); + + LibraryHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::SearchSeries, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.series.search.as_ref().unwrap().text, + "h" + ); + } + + #[test] + fn test_filter_series_box_char_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.data.sonarr_data.series.filter = Some(HorizontallyScrollableText::default()); + + LibraryHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::FilterSeries, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.series.filter.as_ref().unwrap().text, + "h" + ); + } + + #[test] + fn test_sort_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.sort.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesSortPrompt.into() + ); + assert_eq!( + app.data.sonarr_data.series.sort.as_ref().unwrap().items, + series_sorting_options() + ); + assert!(!app.data.sonarr_data.series.sort_asc); + } + + #[test] + fn test_sort_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.sort.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert!(app.data.sonarr_data.series.sort.is_none()); + } + + #[test] + fn test_update_all_series_prompt_confirm() { + let mut app = App::default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::UpdateAllSeriesPrompt.into()); + + LibraryHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveSonarrBlock::UpdateAllSeriesPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::UpdateAllSeries) + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + } + } + + // #[rstest] + // fn test_delegates_add_series_blocks_to_add_series_handler( + // #[values( + // ActiveSonarrBlock::AddSeriesSearchInput, + // ActiveSonarrBlock::AddSeriesSearchResults, + // ActiveSonarrBlock::AddSeriesPrompt, + // ActiveSonarrBlock::AddSeriesSelectMonitor, + // ActiveSonarrBlock::AddSeriesSelectSeriesType, + // ActiveSonarrBlock::AddSeriesSelectQualityProfile, + // ActiveSonarrBlock::AddSeriesSelectRootFolder, + // ActiveSonarrBlock::AddSeriesAlreadyInLibrary, + // ActiveSonarrBlock::AddSeriesTagsInput + // )] + // active_sonarr_block: ActiveSonarrBlock, + // ) { + // test_handler_delegation!( + // LibraryHandler, + // ActiveSonarrBlock::Series, + // active_sonarr_block + // ); + // } + + // #[rstest] + // fn test_delegates_series_details_blocks_to_series_details_handler( + // #[values( + // ActiveSonarrBlock::SeriesDetails, + // ActiveSonarrBlock::SeriesHistory, + // ActiveSonarrBlock::FileInfo, + // ActiveSonarrBlock::Cast, + // ActiveSonarrBlock::Crew, + // ActiveSonarrBlock::AutomaticallySearchSeriesPrompt, + // ActiveSonarrBlock::UpdateAndScanPrompt, + // ActiveSonarrBlock::ManualSearch, + // ActiveSonarrBlock::ManualSearchConfirmPrompt + // )] + // active_sonarr_block: ActiveSonarrBlock, + // ) { + // test_handler_delegation!( + // LibraryHandler, + // ActiveSonarrBlock::Series, + // active_sonarr_block + // ); + // } + + // #[rstest] + // fn test_delegates_edit_series_blocks_to_edit_series_handler( + // #[values( + // ActiveSonarrBlock::EditSeriesPrompt, + // ActiveSonarrBlock::EditSeriesPathInput, + // ActiveSonarrBlock::EditSeriesSelectMinimumAvailability, + // ActiveSonarrBlock::EditSeriesSelectQualityProfile, + // ActiveSonarrBlock::EditSeriesTagsInput + // )] + // active_sonarr_block: ActiveSonarrBlock, + // ) { + // test_handler_delegation!( + // LibraryHandler, + // ActiveSonarrBlock::Series, + // active_sonarr_block + // ); + // } + + // #[test] + // fn test_delegates_delete_series_blocks_to_delete_series_handler() { + // test_handler_delegation!( + // LibraryHandler, + // ActiveSonarrBlock::Series, + // ActiveSonarrBlock::DeleteSeriesPrompt + // ); + // } + + #[test] + fn test_series_sorting_options_title() { + let expected_cmp_fn: fn(&Series, &Series) -> Ordering = |a, b| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }; + let mut expected_series_vec = series_vec(); + expected_series_vec.sort_by(expected_cmp_fn); + + let sort_option = series_sorting_options()[0].clone(); + let mut sorted_series_vec = series_vec(); + sorted_series_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_series_vec, expected_series_vec); + assert_str_eq!(sort_option.name, "Title"); + } + + #[test] + fn test_series_sorting_options_year() { + let expected_cmp_fn: fn(&Series, &Series) -> Ordering = |a, b| a.year.cmp(&b.year); + let mut expected_series_vec = series_vec(); + expected_series_vec.sort_by(expected_cmp_fn); + + let sort_option = series_sorting_options()[1].clone(); + let mut sorted_series_vec = series_vec(); + sorted_series_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_series_vec, expected_series_vec); + assert_str_eq!(sort_option.name, "Year"); + } + + #[test] + fn test_series_sorting_options_network() { + let expected_cmp_fn: fn(&Series, &Series) -> Ordering = |a, b| { + a.network + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase() + .cmp(&b.network.as_ref().unwrap_or(&String::new()).to_lowercase()) + }; + let mut expected_series_vec = series_vec(); + expected_series_vec.sort_by(expected_cmp_fn); + + let sort_option = series_sorting_options()[2].clone(); + let mut sorted_series_vec = series_vec(); + sorted_series_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_series_vec, expected_series_vec); + assert_str_eq!(sort_option.name, "Network"); + } + + #[test] + fn test_series_sorting_options_runtime() { + let expected_cmp_fn: fn(&Series, &Series) -> Ordering = |a, b| a.runtime.cmp(&b.runtime); + let mut expected_series_vec = series_vec(); + expected_series_vec.sort_by(expected_cmp_fn); + + let sort_option = series_sorting_options()[3].clone(); + let mut sorted_series_vec = series_vec(); + sorted_series_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_series_vec, expected_series_vec); + assert_str_eq!(sort_option.name, "Runtime"); + } + + #[test] + fn test_series_sorting_options_rating() { + let expected_cmp_fn: fn(&Series, &Series) -> Ordering = |a, b| { + a.certification + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase() + .cmp( + &b.certification + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase(), + ) + }; + let mut expected_series_vec = series_vec(); + expected_series_vec.sort_by(expected_cmp_fn); + + let sort_option = series_sorting_options()[4].clone(); + let mut sorted_series_vec = series_vec(); + sorted_series_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_series_vec, expected_series_vec); + assert_str_eq!(sort_option.name, "Rating"); + } + + #[test] + fn test_series_sorting_options_type() { + let expected_cmp_fn: fn(&Series, &Series) -> Ordering = |a, b| { + a.series_type + .to_string() + .to_lowercase() + .cmp(&b.series_type.to_string().to_lowercase()) + }; + let mut expected_series_vec = series_vec(); + expected_series_vec.sort_by(expected_cmp_fn); + + let sort_option = series_sorting_options()[5].clone(); + let mut sorted_series_vec = series_vec(); + sorted_series_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_series_vec, expected_series_vec); + assert_str_eq!(sort_option.name, "Type"); + } + + #[test] + fn test_series_sorting_options_quality() { + let expected_cmp_fn: fn(&Series, &Series) -> Ordering = + |a, b| a.quality_profile_id.cmp(&b.quality_profile_id); + let mut expected_series_vec = series_vec(); + expected_series_vec.sort_by(expected_cmp_fn); + + let sort_option = series_sorting_options()[6].clone(); + let mut sorted_series_vec = series_vec(); + sorted_series_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_series_vec, expected_series_vec); + assert_str_eq!(sort_option.name, "Quality"); + } + + #[test] + fn test_series_sorting_options_language() { + let expected_cmp_fn: fn(&Series, &Series) -> Ordering = + |a, b| a.language_profile_id.cmp(&b.language_profile_id); + let mut expected_series_vec = series_vec(); + expected_series_vec.sort_by(expected_cmp_fn); + + let sort_option = series_sorting_options()[7].clone(); + let mut sorted_series_vec = series_vec(); + sorted_series_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_series_vec, expected_series_vec); + assert_str_eq!(sort_option.name, "Language"); + } + + #[test] + fn test_series_sorting_options_monitored() { + let expected_cmp_fn: fn(&Series, &Series) -> Ordering = |a, b| a.monitored.cmp(&b.monitored); + let mut expected_series_vec = series_vec(); + expected_series_vec.sort_by(expected_cmp_fn); + + let sort_option = series_sorting_options()[8].clone(); + let mut sorted_series_vec = series_vec(); + sorted_series_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_series_vec, expected_series_vec); + assert_str_eq!(sort_option.name, "Monitored"); + } + + #[test] + fn test_series_sorting_options_tags() { + let expected_cmp_fn: fn(&Series, &Series) -> Ordering = |a, b| { + let a_str = a + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + let b_str = b + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + + a_str.cmp(&b_str) + }; + let mut expected_series_vec = series_vec(); + expected_series_vec.sort_by(expected_cmp_fn); + + let sort_option = series_sorting_options()[9].clone(); + let mut sorted_series_vec = series_vec(); + sorted_series_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_series_vec, expected_series_vec); + assert_str_eq!(sort_option.name, "Tags"); + } + + #[test] + fn test_library_handler_accepts() { + let mut library_handler_blocks = Vec::new(); + library_handler_blocks.extend(SERIES_BLOCKS); + + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if library_handler_blocks.contains(&active_sonarr_block) { + assert!(LibraryHandler::accepts(active_sonarr_block)); + } else { + assert!(!LibraryHandler::accepts(active_sonarr_block)); + } + }); + } + + #[test] + fn test_library_handler_not_ready_when_loading() { + let mut app = App::default(); + app.is_loading = true; + + let handler = LibraryHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_library_handler_not_ready_when_series_is_empty() { + let mut app = App::default(); + app.is_loading = false; + + let handler = LibraryHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_library_handler_ready_when_not_loading_and_series_is_not_empty() { + let mut app = App::default(); + app.is_loading = false; + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + let handler = LibraryHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ); + + assert!(handler.is_ready()); + } + + fn series_vec() -> Vec { + vec![ + Series { + id: 3, + title: "test 1".into(), + network: Some("Network 1".to_owned()), + year: 2024, + monitored: false, + season_folder: false, + runtime: 12.into(), + quality_profile_id: 1, + language_profile_id: 1, + certification: Some("TV-MA".to_owned()), + series_type: SeriesType::Daily, + tags: vec![1.into(), 2.into()], + ..Series::default() + }, + Series { + id: 2, + title: "test 2".into(), + network: Some("Network 2".to_owned()), + year: 1998, + monitored: false, + season_folder: false, + runtime: 60.into(), + quality_profile_id: 2, + language_profile_id: 2, + certification: Some("TV-PG".to_owned()), + series_type: SeriesType::Anime, + tags: vec![1.into(), 3.into()], + ..Series::default() + }, + Series { + id: 1, + title: "test 3".into(), + network: Some("network 3".to_owned()), + year: 1954, + monitored: true, + season_folder: false, + runtime: 120.into(), + quality_profile_id: 3, + language_profile_id: 3, + certification: Some("TV-G".to_owned()), + tags: vec![2.into(), 3.into()], + series_type: SeriesType::Standard, + ..Series::default() + }, + ] + } + + fn sort_options() -> Vec> { + vec![SortOption { + name: "Test 1", + cmp_fn: Some(|a, b| { + b.title + .text + .to_lowercase() + .cmp(&a.title.text.to_lowercase()) + }), + }] + } +} diff --git a/src/handlers/sonarr_handlers/library/mod.rs b/src/handlers/sonarr_handlers/library/mod.rs new file mode 100644 index 0000000..3cde86b --- /dev/null +++ b/src/handlers/sonarr_handlers/library/mod.rs @@ -0,0 +1,460 @@ +use crate::{ + app::App, + event::Key, + handle_text_box_keys, handle_text_box_left_right_keys, + handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}, + models::{ + servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, DELETE_SERIES_SELECTION_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, + SERIES_BLOCKS, + }, + sonarr_models::Series, + stateful_table::SortOption, + BlockSelectionState, HorizontallyScrollableText, Scrollable, + }, + network::sonarr_network::SonarrEvent, +}; + +use super::handle_change_tab_left_right_keys; +use crate::app::key_binding::DEFAULT_KEYBINDINGS; + +#[cfg(test)] +#[path = "library_handler_tests.rs"] +mod library_handler_tests; + +pub(super) struct LibraryHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, 'b> { + fn handle(&mut self) { + match self.active_sonarr_block { + _ => self.handle_key_event(), + } + } + + fn accepts(active_block: ActiveSonarrBlock) -> bool { + SERIES_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + _context: Option, + ) -> LibraryHandler<'a, 'b> { + LibraryHandler { + key, + app, + active_sonarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.sonarr_data.series.is_empty() + } + + fn handle_scroll_up(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Series => self.app.data.sonarr_data.series.scroll_up(), + ActiveSonarrBlock::SeriesSortPrompt => self + .app + .data + .sonarr_data + .series + .sort + .as_mut() + .unwrap() + .scroll_up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Series => self.app.data.sonarr_data.series.scroll_down(), + ActiveSonarrBlock::SeriesSortPrompt => self + .app + .data + .sonarr_data + .series + .sort + .as_mut() + .unwrap() + .scroll_down(), + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Series => self.app.data.sonarr_data.series.scroll_to_top(), + ActiveSonarrBlock::SearchSeries => { + self + .app + .data + .sonarr_data + .series + .search + .as_mut() + .unwrap() + .scroll_home(); + } + ActiveSonarrBlock::FilterSeries => { + self + .app + .data + .sonarr_data + .series + .filter + .as_mut() + .unwrap() + .scroll_home(); + } + ActiveSonarrBlock::SeriesSortPrompt => self + .app + .data + .sonarr_data + .series + .sort + .as_mut() + .unwrap() + .scroll_to_top(), + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Series => self.app.data.sonarr_data.series.scroll_to_bottom(), + ActiveSonarrBlock::SearchSeries => self + .app + .data + .sonarr_data + .series + .search + .as_mut() + .unwrap() + .reset_offset(), + ActiveSonarrBlock::FilterSeries => self + .app + .data + .sonarr_data + .series + .filter + .as_mut() + .unwrap() + .reset_offset(), + ActiveSonarrBlock::SeriesSortPrompt => self + .app + .data + .sonarr_data + .series + .sort + .as_mut() + .unwrap() + .scroll_to_bottom(), + _ => (), + } + } + + fn handle_delete(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::Series { + self + .app + .push_navigation_stack(ActiveSonarrBlock::DeleteSeriesPrompt.into()); + self.app.data.sonarr_data.selected_block = + BlockSelectionState::new(DELETE_SERIES_SELECTION_BLOCKS); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Series => handle_change_tab_left_right_keys(self.app, self.key), + ActiveSonarrBlock::UpdateAllSeriesPrompt => handle_prompt_toggle(self.app, self.key), + ActiveSonarrBlock::SearchSeries => { + handle_text_box_left_right_keys!( + self, + self.key, + self.app.data.sonarr_data.series.search.as_mut().unwrap() + ) + } + ActiveSonarrBlock::FilterSeries => { + handle_text_box_left_right_keys!( + self, + self.key, + self.app.data.sonarr_data.series.filter.as_mut().unwrap() + ) + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Series => self + .app + .push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()), + ActiveSonarrBlock::SearchSeries => { + self.app.pop_navigation_stack(); + self.app.should_ignore_quit_key = false; + + if self.app.data.sonarr_data.series.search.is_some() { + let has_match = self + .app + .data + .sonarr_data + .series + .apply_search(|series| &series.title.text); + + if !has_match { + self + .app + .push_navigation_stack(ActiveSonarrBlock::SearchSeriesError.into()); + } + } + } + ActiveSonarrBlock::FilterSeries => { + self.app.pop_navigation_stack(); + self.app.should_ignore_quit_key = false; + + if self.app.data.sonarr_data.series.filter.is_some() { + let has_matches = self + .app + .data + .sonarr_data + .series + .apply_filter(|series| &series.title.text); + + if !has_matches { + self + .app + .push_navigation_stack(ActiveSonarrBlock::FilterSeriesError.into()); + } + } + } + ActiveSonarrBlock::UpdateAllSeriesPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::UpdateAllSeries); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::SeriesSortPrompt => { + self + .app + .data + .sonarr_data + .series + .items + .sort_by(|a, b| a.id.cmp(&b.id)); + self.app.data.sonarr_data.series.apply_sorting(); + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::FilterSeries | ActiveSonarrBlock::FilterSeriesError => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.series.reset_filter(); + self.app.should_ignore_quit_key = false; + } + ActiveSonarrBlock::SearchSeries | ActiveSonarrBlock::SearchSeriesError => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.series.reset_search(); + self.app.should_ignore_quit_key = false; + } + ActiveSonarrBlock::UpdateAllSeriesPrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.prompt_confirm = false; + } + ActiveSonarrBlock::SeriesSortPrompt => { + self.app.pop_navigation_stack(); + } + _ => { + self.app.data.sonarr_data.series.reset_search(); + self.app.data.sonarr_data.series.reset_filter(); + handle_clear_errors(self.app); + } + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_sonarr_block { + ActiveSonarrBlock::Series => match self.key { + _ if key == DEFAULT_KEYBINDINGS.search.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); + self.app.data.sonarr_data.series.search = Some(HorizontallyScrollableText::default()); + self.app.should_ignore_quit_key = true; + } + _ if key == DEFAULT_KEYBINDINGS.filter.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); + self.app.data.sonarr_data.series.reset_filter(); + self.app.data.sonarr_data.series.filter = Some(HorizontallyScrollableText::default()); + self.app.should_ignore_quit_key = true; + } + _ if key == DEFAULT_KEYBINDINGS.edit.key => { + self.app.push_navigation_stack( + ( + ActiveSonarrBlock::EditSeriesPrompt, + Some(ActiveSonarrBlock::Series), + ) + .into(), + ); + self.app.data.sonarr_data.edit_series_modal = Some((&self.app.data.sonarr_data).into()); + self.app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); + } + _ if key == DEFAULT_KEYBINDINGS.add.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::AddSeriesSearchInput.into()); + self.app.data.sonarr_data.add_series_search = Some(HorizontallyScrollableText::default()); + self.app.should_ignore_quit_key = true; + } + _ if key == DEFAULT_KEYBINDINGS.update.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::UpdateAllSeriesPrompt.into()); + } + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { + self.app.should_refresh = true; + } + _ if key == DEFAULT_KEYBINDINGS.sort.key => { + self + .app + .data + .sonarr_data + .series + .sorting(series_sorting_options()); + self + .app + .push_navigation_stack(ActiveSonarrBlock::SeriesSortPrompt.into()); + } + _ => (), + }, + ActiveSonarrBlock::SearchSeries => { + handle_text_box_keys!( + self, + key, + self.app.data.sonarr_data.series.search.as_mut().unwrap() + ) + } + ActiveSonarrBlock::FilterSeries => { + handle_text_box_keys!( + self, + key, + self.app.data.sonarr_data.series.filter.as_mut().unwrap() + ) + } + ActiveSonarrBlock::UpdateAllSeriesPrompt => { + if key == DEFAULT_KEYBINDINGS.confirm.key { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::UpdateAllSeries); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } +} + +fn series_sorting_options() -> Vec> { + vec![ + SortOption { + name: "Title", + cmp_fn: Some(|a, b| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }), + }, + SortOption { + name: "Year", + cmp_fn: Some(|a, b| a.year.cmp(&b.year)), + }, + SortOption { + name: "Network", + cmp_fn: Some(|a, b| { + a.network + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase() + .cmp(&b.network.as_ref().unwrap_or(&String::new()).to_lowercase()) + }), + }, + SortOption { + name: "Runtime", + cmp_fn: Some(|a, b| a.runtime.cmp(&b.runtime)), + }, + SortOption { + name: "Rating", + cmp_fn: Some(|a, b| { + a.certification + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase() + .cmp( + &b.certification + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase(), + ) + }), + }, + SortOption { + name: "Type", + cmp_fn: Some(|a, b| a.series_type.to_string().cmp(&b.series_type.to_string())), + }, + SortOption { + name: "Quality", + cmp_fn: Some(|a, b| a.quality_profile_id.cmp(&b.quality_profile_id)), + }, + SortOption { + name: "Language", + cmp_fn: Some(|a, b| a.language_profile_id.cmp(&b.language_profile_id)), + }, + SortOption { + name: "Monitored", + cmp_fn: Some(|a, b| a.monitored.cmp(&b.monitored)), + }, + SortOption { + name: "Tags", + cmp_fn: Some(|a, b| { + let a_str = a + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + let b_str = b + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + + a_str.cmp(&b_str) + }), + }, + ] +} diff --git a/src/handlers/sonarr_handlers/mod.rs b/src/handlers/sonarr_handlers/mod.rs new file mode 100644 index 0000000..0dd5014 --- /dev/null +++ b/src/handlers/sonarr_handlers/mod.rs @@ -0,0 +1,96 @@ +use library::LibraryHandler; + +use crate::{ + app::{key_binding::DEFAULT_KEYBINDINGS, App}, + event::Key, + models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, +}; + +use super::KeyEventHandler; + +mod library; + +#[cfg(test)] +#[path = "sonarr_handler_tests.rs"] +mod sonarr_handler_tests; + +#[cfg(test)] +#[path = "sonarr_handler_test_utils.rs"] +mod sonarr_handler_test_utils; + +pub(super) struct SonarrHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SonarrHandler<'a, 'b> { + fn handle(&mut self) { + match self.active_sonarr_block { + _ if LibraryHandler::accepts(self.active_sonarr_block) => { + LibraryHandler::with(self.key, self.app, self.active_sonarr_block, self.context).handle(); + } + _ => self.handle_key_event(), + } + } + + fn accepts(_active_block: ActiveSonarrBlock) -> bool { + true + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + context: Option, + ) -> SonarrHandler<'a, 'b> { + SonarrHandler { + key, + app, + active_sonarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + 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) {} + + fn handle_left_right_action(&mut self) {} + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) {} + + fn handle_char_key_event(&mut self) {} +} + +pub fn handle_change_tab_left_right_keys(app: &mut App<'_>, key: Key) { + let key_ref = key; + match key_ref { + _ if key == DEFAULT_KEYBINDINGS.left.key => { + app.data.sonarr_data.main_tabs.previous(); + app.pop_and_push_navigation_stack(app.data.sonarr_data.main_tabs.get_active_route()); + } + _ if key == DEFAULT_KEYBINDINGS.right.key => { + app.data.sonarr_data.main_tabs.next(); + app.pop_and_push_navigation_stack(app.data.sonarr_data.main_tabs.get_active_route()); + } + _ => (), + } +} diff --git a/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs new file mode 100644 index 0000000..759a2d3 --- /dev/null +++ b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs @@ -0,0 +1,157 @@ +#[cfg(test)] +#[macro_use] +mod utils { + + #[macro_export] + macro_rules! test_edit_series_key { + ($handler:ident, $block:expr, $context:expr) => { + let mut app = App::default(); + let mut sonarr_data = SonarrData { + quality_profile_map: BiMap::from_iter([ + (2222, "HD - 1080p".to_owned()), + (1111, "Any".to_owned()), + ]), + language_profiles_map: BiMap::from_iter([ + (2222, "English".to_owned()), + (1111, "Any".to_owned()), + ]), + tags_map: BiMap::from_iter([(1, "test".to_owned())]), + ..create_test_sonarr_data() + }; + sonarr_data.series.set_items(vec![Series { + path: "/nfs/series/Test".to_owned().into(), + monitored: true, + season_folder: true, + quality_profile_id: 2222, + language_profile_id: 2222, + series_type: SeriesType::Anime, + tags: vec![Number::from(1)], + ..Series::default() + }]); + app.data.sonarr_data = sonarr_data; + + $handler::with(DEFAULT_KEYBINDINGS.edit.key, &mut app, $block, None).handle(); + + assert_eq!( + app.get_current_route(), + (ActiveSonarrBlock::EditSeriesPrompt, Some($context)).into() + ); + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::EditSeriesToggleMonitored + ); + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .series_type_list + .items, + Vec::from_iter(SeriesType::iter()) + ); + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .series_type_list + .current_selection(), + &SeriesType::Anime + ); + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .quality_profile_list + .items, + vec!["Any".to_owned(), "HD - 1080p".to_owned()] + ); + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "HD - 1080p" + ); + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .language_profile_list + .items, + vec!["Any".to_owned(), "English".to_owned()] + ); + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .language_profile_list + .current_selection(), + "English" + ); + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .path + .text, + "/nfs/series/Test" + ); + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .tags + .text, + "test" + ); + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .monitored, + Some(true) + ); + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .use_season_folders, + Some(true) + ); + assert_eq!( + app.data.sonarr_data.selected_block.blocks, + $crate::models::servarr_data::sonarr::sonarr_data::EDIT_SERIES_SELECTION_BLOCKS + ); + }; + } +} diff --git a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs new file mode 100644 index 0000000..4fcc1bd --- /dev/null +++ b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs @@ -0,0 +1,122 @@ +#[cfg(test)] +mod tests { + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; + use crate::handlers::sonarr_handlers::SonarrHandler; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::test_handler_delegation; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + #[case(0, ActiveSonarrBlock::System, ActiveSonarrBlock::Downloads)] + #[case(1, ActiveSonarrBlock::Series, ActiveSonarrBlock::Blocklist)] + #[case(2, ActiveSonarrBlock::Downloads, ActiveSonarrBlock::History)] + #[case(3, ActiveSonarrBlock::Blocklist, ActiveSonarrBlock::RootFolders)] + #[case(4, ActiveSonarrBlock::History, ActiveSonarrBlock::Indexers)] + #[case(5, ActiveSonarrBlock::RootFolders, ActiveSonarrBlock::System)] + #[case(6, ActiveSonarrBlock::Indexers, ActiveSonarrBlock::Series)] + fn test_sonarr_handler_change_tab_left_right_keys( + #[case] index: usize, + #[case] left_block: ActiveSonarrBlock, + #[case] right_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.key); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + left_block.into() + ); + assert_eq!(app.get_current_route(), left_block.into()); + + app.data.sonarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.key); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + right_block.into() + ); + assert_eq!(app.get_current_route(), right_block.into()); + } + + #[rstest] + fn test_delegates_library_blocks_to_library_handler( + #[values( + // ActiveSonarrBlock::AddSeriesAlreadyInLibrary, + // ActiveSonarrBlock::AddSeriesConfirmPrompt, + // ActiveSonarrBlock::AddSeriesEmptySearchResults, + // ActiveSonarrBlock::AddSeriesPrompt, + // ActiveSonarrBlock::AddSeriesSearchInput, + // ActiveSonarrBlock::AddSeriesSearchResults, + // ActiveSonarrBlock::AddSeriesSelectLanguageProfile, + // ActiveSonarrBlock::AddSeriesSelectMonitor, + // ActiveSonarrBlock::AddSeriesSelectQualityProfile, + // ActiveSonarrBlock::AddSeriesSelectRootFolder, + // ActiveSonarrBlock::AddSeriesSelectSeriesType, + // ActiveSonarrBlock::AddSeriesTagsInput, + // ActiveSonarrBlock::AddSeriesToggleUseSeasonFolder, + // ActiveSonarrBlock::AutomaticallySearchEpisodePrompt, + // ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, + // ActiveSonarrBlock::AutomaticallySearchSeriesPrompt, + // ActiveSonarrBlock::DeleteEpisodeFilePrompt, + // ActiveSonarrBlock::DeleteSeriesConfirmPrompt, + // ActiveSonarrBlock::DeleteSeriesPrompt, + // ActiveSonarrBlock::DeleteSeriesToggleAddListExclusion, + // ActiveSonarrBlock::DeleteSeriesToggleDeleteFile, + // ActiveSonarrBlock::EditSeriesPrompt, + // ActiveSonarrBlock::EditSeriesConfirmPrompt, + // ActiveSonarrBlock::EditSeriesPathInput, + // ActiveSonarrBlock::EditSeriesSelectSeriesType, + // ActiveSonarrBlock::EditSeriesSelectQualityProfile, + // ActiveSonarrBlock::EditSeriesSelectLanguageProfile, + // ActiveSonarrBlock::EditSeriesTagsInput, + // ActiveSonarrBlock::EditSeriesToggleMonitored, + // ActiveSonarrBlock::EditSeriesToggleSeasonFolder, + // ActiveSonarrBlock::EpisodeDetails, + // ActiveSonarrBlock::EpisodeFile, + // ActiveSonarrBlock::EpisodeHistory, + // ActiveSonarrBlock::EpisodesSortPrompt, + // ActiveSonarrBlock::FilterEpisodes, + // ActiveSonarrBlock::FilterEpisodesError, + ActiveSonarrBlock::FilterSeries, + ActiveSonarrBlock::FilterSeriesError, + // ActiveSonarrBlock::FilterSeriesHistory, + // ActiveSonarrBlock::FilterSeriesHistoryError, + // ActiveSonarrBlock::ManualEpisodeSearch, + // ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt, + // ActiveSonarrBlock::ManualEpisodeSearchSortPrompt, + // ActiveSonarrBlock::ManualSeasonSearch, + // ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt, + // ActiveSonarrBlock::ManualSeasonSearchSortPrompt, + // ActiveSonarrBlock::SearchEpisodes, + // ActiveSonarrBlock::SearchEpisodesError, + // ActiveSonarrBlock::SearchSeason, + // ActiveSonarrBlock::SearchSeasonError, + ActiveSonarrBlock::SearchSeries, + ActiveSonarrBlock::SearchSeriesError, + // ActiveSonarrBlock::SearchSeriesHistory, + // ActiveSonarrBlock::SearchSeriesHistoryError, + // ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::Series, + // ActiveSonarrBlock::SeriesDetails, + // ActiveSonarrBlock::SeriesHistory, + // ActiveSonarrBlock::SeriesHistorySortPrompt, + ActiveSonarrBlock::SeriesSortPrompt, + ActiveSonarrBlock::UpdateAllSeriesPrompt, + // ActiveSonarrBlock::UpdateAndScanSeriesPrompt + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + SonarrHandler, + ActiveSonarrBlock::Series, + active_sonarr_block + ); + } +} diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 168a84c..6e77c89 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -295,12 +295,41 @@ pub static SERIES_BLOCKS: [ActiveSonarrBlock; 7] = [ ActiveSonarrBlock::UpdateAllSeriesPrompt, ]; +pub static EDIT_SERIES_BLOCKS: [ActiveSonarrBlock; 9] = [ + ActiveSonarrBlock::EditSeriesPrompt, + ActiveSonarrBlock::EditSeriesConfirmPrompt, + ActiveSonarrBlock::EditSeriesPathInput, + ActiveSonarrBlock::EditSeriesSelectSeriesType, + ActiveSonarrBlock::EditSeriesSelectQualityProfile, + ActiveSonarrBlock::EditSeriesSelectLanguageProfile, + ActiveSonarrBlock::EditSeriesTagsInput, + ActiveSonarrBlock::EditSeriesToggleMonitored, + ActiveSonarrBlock::EditSeriesToggleSeasonFolder, +]; + +pub static EDIT_SERIES_SELECTION_BLOCKS: &[&[ActiveSonarrBlock]] = &[ + &[ActiveSonarrBlock::EditSeriesToggleMonitored], + &[ActiveSonarrBlock::EditSeriesToggleSeasonFolder], + &[ActiveSonarrBlock::EditSeriesSelectQualityProfile], + &[ActiveSonarrBlock::EditSeriesSelectLanguageProfile], + &[ActiveSonarrBlock::EditSeriesSelectSeriesType], + &[ActiveSonarrBlock::EditSeriesPathInput], + &[ActiveSonarrBlock::EditSeriesTagsInput], + &[ActiveSonarrBlock::EditSeriesConfirmPrompt], +]; + pub static DOWNLOADS_BLOCKS: [ActiveSonarrBlock; 3] = [ ActiveSonarrBlock::Downloads, ActiveSonarrBlock::DeleteDownloadPrompt, ActiveSonarrBlock::UpdateDownloadsPrompt, ]; +pub const DELETE_SERIES_SELECTION_BLOCKS: &[&[ActiveSonarrBlock]] = &[ + &[ActiveSonarrBlock::DeleteSeriesToggleDeleteFile], + &[ActiveSonarrBlock::DeleteSeriesToggleAddListExclusion], + &[ActiveSonarrBlock::DeleteSeriesConfirmPrompt], +]; + impl From for Route { fn from(active_sonarr_block: ActiveSonarrBlock) -> Route { Route::Sonarr(active_sonarr_block, None) diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 02e590a..aef207e 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -202,7 +202,8 @@ mod tests { mod active_sonarr_block_tests { use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, DOWNLOADS_BLOCKS, SERIES_BLOCKS, + ActiveSonarrBlock, DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_SERIES_BLOCKS, + EDIT_SERIES_SELECTION_BLOCKS, SERIES_BLOCKS, }; #[test] @@ -217,6 +218,59 @@ mod tests { assert!(SERIES_BLOCKS.contains(&ActiveSonarrBlock::UpdateAllSeriesPrompt)); } + #[test] + fn test_edit_movie_blocks_contents() { + assert_eq!(EDIT_SERIES_BLOCKS.len(), 9); + assert!(EDIT_SERIES_BLOCKS.contains(&ActiveSonarrBlock::EditSeriesPrompt)); + assert!(EDIT_SERIES_BLOCKS.contains(&ActiveSonarrBlock::EditSeriesConfirmPrompt)); + assert!(EDIT_SERIES_BLOCKS.contains(&ActiveSonarrBlock::EditSeriesPathInput)); + assert!(EDIT_SERIES_BLOCKS.contains(&ActiveSonarrBlock::EditSeriesSelectSeriesType)); + assert!(EDIT_SERIES_BLOCKS.contains(&ActiveSonarrBlock::EditSeriesSelectQualityProfile)); + assert!(EDIT_SERIES_BLOCKS.contains(&ActiveSonarrBlock::EditSeriesSelectLanguageProfile)); + assert!(EDIT_SERIES_BLOCKS.contains(&ActiveSonarrBlock::EditSeriesTagsInput)); + assert!(EDIT_SERIES_BLOCKS.contains(&ActiveSonarrBlock::EditSeriesToggleMonitored)); + assert!(EDIT_SERIES_BLOCKS.contains(&ActiveSonarrBlock::EditSeriesToggleSeasonFolder)); + } + + #[test] + fn test_edit_series_selection_blocks_ordering() { + let mut edit_series_block_iter = EDIT_SERIES_SELECTION_BLOCKS.iter(); + + assert_eq!( + edit_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::EditSeriesToggleMonitored] + ); + assert_eq!( + edit_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::EditSeriesToggleSeasonFolder] + ); + assert_eq!( + edit_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::EditSeriesSelectQualityProfile] + ); + assert_eq!( + edit_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::EditSeriesSelectLanguageProfile] + ); + assert_eq!( + edit_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::EditSeriesSelectSeriesType] + ); + assert_eq!( + edit_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::EditSeriesPathInput] + ); + assert_eq!( + edit_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::EditSeriesTagsInput] + ); + assert_eq!( + edit_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::EditSeriesConfirmPrompt] + ); + assert_eq!(edit_series_block_iter.next(), None); + } + #[test] fn test_downloads_blocks_contents() { assert_eq!(DOWNLOADS_BLOCKS.len(), 3); @@ -224,5 +278,24 @@ mod tests { assert!(DOWNLOADS_BLOCKS.contains(&ActiveSonarrBlock::DeleteDownloadPrompt)); assert!(DOWNLOADS_BLOCKS.contains(&ActiveSonarrBlock::UpdateDownloadsPrompt)); } + + #[test] + fn test_delete_series_selection_blocks_ordering() { + let mut delete_series_block_iter = DELETE_SERIES_SELECTION_BLOCKS.iter(); + + assert_eq!( + delete_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::DeleteSeriesToggleDeleteFile] + ); + assert_eq!( + delete_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::DeleteSeriesToggleAddListExclusion] + ); + assert_eq!( + delete_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::DeleteSeriesConfirmPrompt] + ); + assert_eq!(delete_series_block_iter.next(), None); + } } } From b75a95a7089f88953e135038ebcdf93567d1e854 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 1 Dec 2024 14:08:06 -0700 Subject: [PATCH 08/82] feat(ui): Support for the Series table --- .../library/library_handler_tests.rs | 19 +++-- src/handlers/sonarr_handlers/library/mod.rs | 9 ++- src/ui/sonarr_ui/library/mod.rs | 74 +++++++++++++++++-- 3 files changed, 86 insertions(+), 16 deletions(-) diff --git a/src/handlers/sonarr_handlers/library/library_handler_tests.rs b/src/handlers/sonarr_handlers/library/library_handler_tests.rs index ca8bece..9d5c93b 100644 --- a/src/handlers/sonarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/library_handler_tests.rs @@ -12,7 +12,7 @@ mod tests { use crate::handlers::sonarr_handlers::library::{series_sorting_options, LibraryHandler}; use crate::handlers::KeyEventHandler; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SERIES_BLOCKS}; - use crate::models::sonarr_models::{Series, SeriesType}; + use crate::models::sonarr_models::{Series, SeriesStatus, SeriesType}; use crate::models::stateful_table::SortOption; use crate::models::HorizontallyScrollableText; @@ -1563,8 +1563,13 @@ mod tests { } #[test] - fn test_series_sorting_options_runtime() { - let expected_cmp_fn: fn(&Series, &Series) -> Ordering = |a, b| a.runtime.cmp(&b.runtime); + fn test_series_sorting_options_status() { + let expected_cmp_fn: fn(&Series, &Series) -> Ordering = |a, b| { + a.status + .to_string() + .to_lowercase() + .cmp(&b.status.to_string().to_lowercase()) + }; let mut expected_series_vec = series_vec(); expected_series_vec.sort_by(expected_cmp_fn); @@ -1573,7 +1578,7 @@ mod tests { sorted_series_vec.sort_by(sort_option.cmp_fn.unwrap()); assert_eq!(sorted_series_vec, expected_series_vec); - assert_str_eq!(sort_option.name, "Runtime"); + assert_str_eq!(sort_option.name, "Status"); } #[test] @@ -1766,7 +1771,7 @@ mod tests { year: 2024, monitored: false, season_folder: false, - runtime: 12.into(), + status: SeriesStatus::Ended, quality_profile_id: 1, language_profile_id: 1, certification: Some("TV-MA".to_owned()), @@ -1781,7 +1786,7 @@ mod tests { year: 1998, monitored: false, season_folder: false, - runtime: 60.into(), + status: SeriesStatus::Continuing, quality_profile_id: 2, language_profile_id: 2, certification: Some("TV-PG".to_owned()), @@ -1796,7 +1801,7 @@ mod tests { year: 1954, monitored: true, season_folder: false, - runtime: 120.into(), + status: SeriesStatus::Upcoming, quality_profile_id: 3, language_profile_id: 3, certification: Some("TV-G".to_owned()), diff --git a/src/handlers/sonarr_handlers/library/mod.rs b/src/handlers/sonarr_handlers/library/mod.rs index 3cde86b..55dc92e 100644 --- a/src/handlers/sonarr_handlers/library/mod.rs +++ b/src/handlers/sonarr_handlers/library/mod.rs @@ -403,8 +403,13 @@ fn series_sorting_options() -> Vec> { }), }, SortOption { - name: "Runtime", - cmp_fn: Some(|a, b| a.runtime.cmp(&b.runtime)), + name: "Status", + cmp_fn: Some(|a, b| { + a.status + .to_string() + .to_lowercase() + .cmp(&b.status.to_string().to_lowercase()) + }), }, SortOption { name: "Rating", diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index 6b77a47..a9661b3 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -4,6 +4,11 @@ use ratatui::{ Frame, }; +use crate::ui::widgets::{ + confirmation_prompt::ConfirmationPrompt, + message::Message, + popup::{Popup, Size}, +}; use crate::{ app::App, models::{ @@ -12,12 +17,12 @@ use crate::{ EnumDisplayStyle, Route, }, ui::{ + draw_input_box_popup, draw_popup_over, styles::ManagarrStyle, utils::{get_width_from_percentage, layout_block_top_border}, widgets::managarr_table::ManagarrTable, DrawUi, }, - utils::convert_runtime, }; #[cfg(test)] @@ -40,6 +45,44 @@ impl DrawUi for LibraryUi { let mut series_ui_matchers = |active_sonarr_block: ActiveSonarrBlock| match active_sonarr_block { ActiveSonarrBlock::Series | ActiveSonarrBlock::SeriesSortPrompt => draw_series(f, app, area), + ActiveSonarrBlock::SearchSeries => draw_popup_over( + f, + app, + area, + draw_series, + draw_series_search_box, + Size::InputBox, + ), + ActiveSonarrBlock::SearchSeriesError => { + let popup = Popup::new(Message::new("Series not found!")).size(Size::Message); + + draw_series(f, app, area); + f.render_widget(popup, f.area()); + } + ActiveSonarrBlock::FilterSeries => draw_popup_over( + f, + app, + area, + draw_series, + draw_filter_series_box, + Size::InputBox, + ), + ActiveSonarrBlock::FilterSeriesError => { + let popup = Popup::new(Message::new("No series found matching the given filter!")) + .size(Size::Message); + + draw_series(f, app, area); + f.render_widget(popup, f.area()); + } + ActiveSonarrBlock::UpdateAllSeriesPrompt => { + let confirmation_prompt = ConfirmationPrompt::new() + .title("Update All Series") + .prompt("Do you want to update info and scan your disks for all of your series?") + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + draw_series(f, app, area); + f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + } _ => (), }; @@ -71,12 +114,11 @@ pub(super) fn draw_series(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let series_table_row_mapping = |series: &Series| { series.title.scroll_left_or_reset( - get_width_from_percentage(area, 27), + get_width_from_percentage(area, 23), *series == current_selection, app.tick_count % app.ticks_until_scroll == 0, ); let monitored = if series.monitored { "🏷" } else { "" }; - let (hours, minutes) = convert_runtime(series.runtime); let certification = series.certification.clone().unwrap_or_default(); let network = series.network.clone().unwrap_or_default(); let quality_profile = quality_profile_map @@ -109,7 +151,7 @@ pub(super) fn draw_series(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(series.title.to_string()), Cell::from(series.year.to_string()), Cell::from(network), - Cell::from(format!("{hours}h {minutes}m")), + Cell::from(series.status.to_display_str()), Cell::from(certification), Cell::from(series.series_type.to_display_str()), Cell::from(quality_profile), @@ -128,7 +170,7 @@ pub(super) fn draw_series(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { "Title", "Year", "Network", - "Runtime", + "Status", "Rating", "Type", "Quality Profile", @@ -137,9 +179,9 @@ pub(super) fn draw_series(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { "Tags", ]) .constraints([ - Constraint::Percentage(27), + Constraint::Percentage(23), Constraint::Percentage(4), - Constraint::Percentage(10), + Constraint::Percentage(14), Constraint::Percentage(6), Constraint::Percentage(6), Constraint::Percentage(6), @@ -177,3 +219,21 @@ fn decorate_series_row_with_style<'a>(series: &Series, row: Row<'a>) -> Row<'a> _ => row.missing(), } } + +fn draw_series_search_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_input_box_popup( + f, + area, + "Search", + app.data.sonarr_data.series.search.as_ref().unwrap(), + ); +} + +fn draw_filter_series_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_input_box_popup( + f, + area, + "Filter", + app.data.sonarr_data.series.filter.as_ref().unwrap(), + ) +} From b1bdc19afb9bb1abde92254fc15c080c767641eb Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 11:30:34 -0700 Subject: [PATCH 09/82] feat(handler): Support for deleting a series in Sonarr --- .../library/delete_series_handler.rs | 118 ++++++ .../library/delete_series_handler_tests.rs | 339 ++++++++++++++++++ .../library/library_handler_tests.rs | 17 +- src/handlers/sonarr_handlers/library/mod.rs | 14 +- src/models/servarr_data/sonarr/sonarr_data.rs | 7 + .../servarr_data/sonarr/sonarr_data_tests.rs | 13 +- 6 files changed, 495 insertions(+), 13 deletions(-) create mode 100644 src/handlers/sonarr_handlers/library/delete_series_handler.rs create mode 100644 src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs diff --git a/src/handlers/sonarr_handlers/library/delete_series_handler.rs b/src/handlers/sonarr_handlers/library/delete_series_handler.rs new file mode 100644 index 0000000..a9fbeff --- /dev/null +++ b/src/handlers/sonarr_handlers/library/delete_series_handler.rs @@ -0,0 +1,118 @@ +use crate::{ + app::{key_binding::DEFAULT_KEYBINDINGS, App}, + event::Key, + handlers::{handle_prompt_toggle, KeyEventHandler}, + models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DELETE_SERIES_BLOCKS}, + network::sonarr_network::SonarrEvent, +}; + +#[cfg(test)] +#[path = "delete_series_handler_tests.rs"] +mod delete_series_handler_tests; + +pub(super) struct DeleteSeriesHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for DeleteSeriesHandler<'a, 'b> { + fn accepts(active_block: ActiveSonarrBlock) -> bool { + DELETE_SERIES_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + _context: Option, + ) -> Self { + DeleteSeriesHandler { + key, + app, + active_sonarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + } + + fn handle_scroll_up(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::DeleteSeriesPrompt { + self.app.data.sonarr_data.selected_block.up(); + } + } + + fn handle_scroll_down(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::DeleteSeriesPrompt { + self.app.data.sonarr_data.selected_block.down(); + } + } + + 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_sonarr_block == ActiveSonarrBlock::DeleteSeriesPrompt { + handle_prompt_toggle(self.app, self.key); + } + } + + fn handle_submit(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::DeleteSeriesPrompt { + match self.app.data.sonarr_data.selected_block.get_active_block() { + ActiveSonarrBlock::DeleteSeriesConfirmPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::DeleteSeries(None)); + self.app.should_refresh = true; + } else { + self.app.data.sonarr_data.reset_delete_series_preferences(); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::DeleteSeriesToggleDeleteFile => { + self.app.data.sonarr_data.delete_series_files = + !self.app.data.sonarr_data.delete_series_files; + } + ActiveSonarrBlock::DeleteSeriesToggleAddListExclusion => { + self.app.data.sonarr_data.add_list_exclusion = + !self.app.data.sonarr_data.add_list_exclusion; + } + _ => (), + } + } + } + + fn handle_esc(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::DeleteSeriesPrompt { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.reset_delete_series_preferences(); + self.app.data.sonarr_data.prompt_confirm = false; + } + } + + fn handle_char_key_event(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::DeleteSeriesPrompt + && self.app.data.sonarr_data.selected_block.get_active_block() + == ActiveSonarrBlock::DeleteSeriesConfirmPrompt + && self.key == DEFAULT_KEYBINDINGS.confirm.key + { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::DeleteSeries(None)); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + } +} diff --git a/src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs b/src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs new file mode 100644 index 0000000..13469da --- /dev/null +++ b/src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs @@ -0,0 +1,339 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::library::delete_series_handler::DeleteSeriesHandler; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DELETE_SERIES_BLOCKS}; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::servarr_data::sonarr::sonarr_data::DELETE_SERIES_SELECTION_BLOCKS; + use crate::models::BlockSelectionState; + + use super::*; + + #[rstest] + fn test_delete_series_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::default(); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(DELETE_SERIES_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.down(); + + DeleteSeriesHandler::with(key, &mut app, ActiveSonarrBlock::DeleteSeriesPrompt, None) + .handle(); + + if key == Key::Up { + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::DeleteSeriesToggleDeleteFile + ); + } else { + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::DeleteSeriesConfirmPrompt + ); + } + } + + #[rstest] + fn test_delete_series_prompt_scroll_no_op_when_not_ready( + #[values(Key::Up, Key::Down)] key: Key, + ) { + let mut app = App::default(); + app.is_loading = true; + app.data.sonarr_data.selected_block = + BlockSelectionState::new(DELETE_SERIES_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.down(); + + DeleteSeriesHandler::with(key, &mut app, ActiveSonarrBlock::DeleteSeriesPrompt, None) + .handle(); + + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::DeleteSeriesToggleAddListExclusion + ); + } + } + + mod test_handle_left_right_action { + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::DeleteSeriesPrompt.into()); + + DeleteSeriesHandler::with(key, &mut app, ActiveSonarrBlock::DeleteSeriesPrompt, None) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + DeleteSeriesHandler::with(key, &mut app, ActiveSonarrBlock::DeleteSeriesPrompt, None) + .handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use crate::models::servarr_data::sonarr::sonarr_data::DELETE_SERIES_SELECTION_BLOCKS; + use crate::models::BlockSelectionState; + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_delete_series_prompt_prompt_decline_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::DeleteSeriesPrompt.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(DELETE_SERIES_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(0, DELETE_SERIES_SELECTION_BLOCKS.len() - 1); + app.data.sonarr_data.delete_series_files = true; + app.data.sonarr_data.add_list_exclusion = true; + + DeleteSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::DeleteSeriesPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + assert!(!app.data.sonarr_data.prompt_confirm); + assert!(!app.data.sonarr_data.delete_series_files); + assert!(!app.data.sonarr_data.add_list_exclusion); + } + + #[test] + fn test_delete_series_confirm_prompt_prompt_confirmation_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::DeleteSeriesPrompt.into()); + app.data.sonarr_data.prompt_confirm = true; + app.data.sonarr_data.delete_series_files = true; + app.data.sonarr_data.add_list_exclusion = true; + app.data.sonarr_data.selected_block = + BlockSelectionState::new(DELETE_SERIES_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(0, DELETE_SERIES_SELECTION_BLOCKS.len() - 1); + + DeleteSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::DeleteSeriesPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::DeleteSeries(None)) + ); + assert!(app.should_refresh); + assert!(app.data.sonarr_data.prompt_confirm); + assert!(app.data.sonarr_data.delete_series_files); + assert!(app.data.sonarr_data.add_list_exclusion); + } + + #[test] + fn test_delete_series_confirm_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::DeleteSeriesPrompt.into()); + app.data.sonarr_data.prompt_confirm = true; + app.data.sonarr_data.delete_series_files = true; + app.data.sonarr_data.add_list_exclusion = true; + + DeleteSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::DeleteSeriesPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::DeleteSeriesPrompt.into() + ); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + assert!(!app.should_refresh); + assert!(app.data.sonarr_data.prompt_confirm); + assert!(app.data.sonarr_data.delete_series_files); + assert!(app.data.sonarr_data.add_list_exclusion); + } + + #[test] + fn test_delete_series_toggle_delete_files_submit() { + let current_route = ActiveSonarrBlock::DeleteSeriesPrompt.into(); + let mut app = App::default(); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(DELETE_SERIES_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveSonarrBlock::DeleteSeriesPrompt.into()); + + DeleteSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::DeleteSeriesPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert_eq!(app.data.sonarr_data.delete_series_files, true); + + DeleteSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::DeleteSeriesPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert_eq!(app.data.sonarr_data.delete_series_files, false); + } + } + + mod test_handle_esc { + use super::*; + use rstest::rstest; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_delete_series_prompt_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::DeleteSeriesPrompt.into()); + app.data.sonarr_data.prompt_confirm = true; + app.data.sonarr_data.delete_series_files = true; + app.data.sonarr_data.add_list_exclusion = true; + + DeleteSeriesHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::DeleteSeriesPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert!(!app.data.sonarr_data.prompt_confirm); + assert!(!app.data.sonarr_data.delete_series_files); + assert!(!app.data.sonarr_data.add_list_exclusion); + } + } + + mod test_handle_key_char { + use crate::{ + models::{ + servarr_data::sonarr::sonarr_data::DELETE_SERIES_SELECTION_BLOCKS, BlockSelectionState, + }, + network::sonarr_network::SonarrEvent, + }; + + use super::*; + + #[test] + fn test_delete_series_confirm_prompt_prompt_confirm() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::DeleteSeriesPrompt.into()); + app.data.sonarr_data.delete_series_files = true; + app.data.sonarr_data.add_list_exclusion = true; + app.data.sonarr_data.selected_block = + BlockSelectionState::new(DELETE_SERIES_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(0, DELETE_SERIES_SELECTION_BLOCKS.len() - 1); + + DeleteSeriesHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveSonarrBlock::DeleteSeriesPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::DeleteSeries(None)) + ); + assert!(app.should_refresh); + assert!(app.data.sonarr_data.prompt_confirm); + assert!(app.data.sonarr_data.delete_series_files); + assert!(app.data.sonarr_data.add_list_exclusion); + } + } + + #[test] + fn test_delete_series_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if DELETE_SERIES_BLOCKS.contains(&active_sonarr_block) { + assert!(DeleteSeriesHandler::accepts(active_sonarr_block)); + } else { + assert!(!DeleteSeriesHandler::accepts(active_sonarr_block)); + } + }); + } + + #[test] + fn test_delete_series_handler_not_ready_when_loading() { + let mut app = App::default(); + app.is_loading = true; + + let handler = DeleteSeriesHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::DeleteSeriesPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_delete_series_handler_ready_when_not_loading() { + let mut app = App::default(); + app.is_loading = false; + + let handler = DeleteSeriesHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::DeleteSeriesPrompt, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/sonarr_handlers/library/library_handler_tests.rs b/src/handlers/sonarr_handlers/library/library_handler_tests.rs index 9d5c93b..752be77 100644 --- a/src/handlers/sonarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/library_handler_tests.rs @@ -15,6 +15,7 @@ mod tests { use crate::models::sonarr_models::{Series, SeriesStatus, SeriesType}; use crate::models::stateful_table::SortOption; use crate::models::HorizontallyScrollableText; + use crate::test_handler_delegation; mod test_handle_scroll_up_and_down { use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; @@ -1500,14 +1501,14 @@ mod tests { // ); // } - // #[test] - // fn test_delegates_delete_series_blocks_to_delete_series_handler() { - // test_handler_delegation!( - // LibraryHandler, - // ActiveSonarrBlock::Series, - // ActiveSonarrBlock::DeleteSeriesPrompt - // ); - // } + #[test] + fn test_delegates_delete_series_blocks_to_delete_series_handler() { + test_handler_delegation!( + LibraryHandler, + ActiveSonarrBlock::Series, + ActiveSonarrBlock::DeleteSeriesPrompt + ); + } #[test] fn test_series_sorting_options_title() { diff --git a/src/handlers/sonarr_handlers/library/mod.rs b/src/handlers/sonarr_handlers/library/mod.rs index 55dc92e..ee61a54 100644 --- a/src/handlers/sonarr_handlers/library/mod.rs +++ b/src/handlers/sonarr_handlers/library/mod.rs @@ -1,3 +1,5 @@ +use delete_series_handler::DeleteSeriesHandler; + use crate::{ app::App, event::Key, @@ -18,6 +20,8 @@ use crate::{ use super::handle_change_tab_left_right_keys; use crate::app::key_binding::DEFAULT_KEYBINDINGS; +mod delete_series_handler; + #[cfg(test)] #[path = "library_handler_tests.rs"] mod library_handler_tests; @@ -26,12 +30,16 @@ pub(super) struct LibraryHandler<'a, 'b> { key: Key, app: &'a mut App<'b>, active_sonarr_block: ActiveSonarrBlock, - _context: Option, + context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, 'b> { fn handle(&mut self) { match self.active_sonarr_block { + _ if DeleteSeriesHandler::accepts(self.active_sonarr_block) => { + DeleteSeriesHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle(); + } _ => self.handle_key_event(), } } @@ -44,13 +52,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' key: Key, app: &'a mut App<'b>, active_block: ActiveSonarrBlock, - _context: Option, + context: Option, ) -> LibraryHandler<'a, 'b> { LibraryHandler { key, app, active_sonarr_block: active_block, - _context, + context, } } diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 6e77c89..f40807a 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -324,6 +324,13 @@ pub static DOWNLOADS_BLOCKS: [ActiveSonarrBlock; 3] = [ ActiveSonarrBlock::UpdateDownloadsPrompt, ]; +pub static DELETE_SERIES_BLOCKS: [ActiveSonarrBlock; 4] = [ + ActiveSonarrBlock::DeleteSeriesPrompt, + ActiveSonarrBlock::DeleteSeriesConfirmPrompt, + ActiveSonarrBlock::DeleteSeriesToggleDeleteFile, + ActiveSonarrBlock::DeleteSeriesToggleAddListExclusion, +]; + pub const DELETE_SERIES_SELECTION_BLOCKS: &[&[ActiveSonarrBlock]] = &[ &[ActiveSonarrBlock::DeleteSeriesToggleDeleteFile], &[ActiveSonarrBlock::DeleteSeriesToggleAddListExclusion], diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index aef207e..bf07cda 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -202,8 +202,8 @@ mod tests { mod active_sonarr_block_tests { use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_SERIES_BLOCKS, - EDIT_SERIES_SELECTION_BLOCKS, SERIES_BLOCKS, + ActiveSonarrBlock, DELETE_SERIES_BLOCKS, DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, + EDIT_SERIES_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, SERIES_BLOCKS, }; #[test] @@ -279,6 +279,15 @@ mod tests { assert!(DOWNLOADS_BLOCKS.contains(&ActiveSonarrBlock::UpdateDownloadsPrompt)); } + #[test] + fn test_delete_series_blocks_contents() { + assert_eq!(DELETE_SERIES_BLOCKS.len(), 4); + assert!(DELETE_SERIES_BLOCKS.contains(&ActiveSonarrBlock::DeleteSeriesPrompt)); + assert!(DELETE_SERIES_BLOCKS.contains(&ActiveSonarrBlock::DeleteSeriesConfirmPrompt)); + assert!(DELETE_SERIES_BLOCKS.contains(&ActiveSonarrBlock::DeleteSeriesToggleDeleteFile)); + assert!(DELETE_SERIES_BLOCKS.contains(&ActiveSonarrBlock::DeleteSeriesToggleAddListExclusion)); + } + #[test] fn test_delete_series_selection_blocks_ordering() { let mut delete_series_block_iter = DELETE_SERIES_SELECTION_BLOCKS.iter(); From 0db57fbff1ed414b95e4b49c40b2b763c365e0e3 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 11:45:13 -0700 Subject: [PATCH 10/82] feat(ui): Delete a series --- .../library/library_handler_tests.rs | 7 ++- src/handlers/sonarr_handlers/library/mod.rs | 4 +- src/models/servarr_data/sonarr/sonarr_data.rs | 2 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 20 +++---- src/ui/sonarr_ui/library/delete_series_ui.rs | 57 +++++++++++++++++++ .../library/delete_series_ui_tests.rs | 19 +++++++ src/ui/sonarr_ui/library/library_ui_tests.rs | 7 ++- src/ui/sonarr_ui/library/mod.rs | 28 +++++---- 8 files changed, 114 insertions(+), 30 deletions(-) create mode 100644 src/ui/sonarr_ui/library/delete_series_ui.rs create mode 100644 src/ui/sonarr_ui/library/delete_series_ui_tests.rs diff --git a/src/handlers/sonarr_handlers/library/library_handler_tests.rs b/src/handlers/sonarr_handlers/library/library_handler_tests.rs index 752be77..35ce42f 100644 --- a/src/handlers/sonarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/library_handler_tests.rs @@ -11,7 +11,9 @@ mod tests { use crate::event::Key; use crate::handlers::sonarr_handlers::library::{series_sorting_options, LibraryHandler}; use crate::handlers::KeyEventHandler; - use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SERIES_BLOCKS}; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, DELETE_SERIES_BLOCKS, LIBRARY_BLOCKS, + }; use crate::models::sonarr_models::{Series, SeriesStatus, SeriesType}; use crate::models::stateful_table::SortOption; use crate::models::HorizontallyScrollableText; @@ -1702,7 +1704,8 @@ mod tests { #[test] fn test_library_handler_accepts() { let mut library_handler_blocks = Vec::new(); - library_handler_blocks.extend(SERIES_BLOCKS); + library_handler_blocks.extend(LIBRARY_BLOCKS); + library_handler_blocks.extend(DELETE_SERIES_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { if library_handler_blocks.contains(&active_sonarr_block) { diff --git a/src/handlers/sonarr_handlers/library/mod.rs b/src/handlers/sonarr_handlers/library/mod.rs index ee61a54..3a1a05e 100644 --- a/src/handlers/sonarr_handlers/library/mod.rs +++ b/src/handlers/sonarr_handlers/library/mod.rs @@ -8,7 +8,7 @@ use crate::{ models::{ servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, DELETE_SERIES_SELECTION_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, - SERIES_BLOCKS, + LIBRARY_BLOCKS, }, sonarr_models::Series, stateful_table::SortOption, @@ -45,7 +45,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' } fn accepts(active_block: ActiveSonarrBlock) -> bool { - SERIES_BLOCKS.contains(&active_block) + DeleteSeriesHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block) } fn with( diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index f40807a..7709042 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -285,7 +285,7 @@ pub enum ActiveSonarrBlock { UpdateDownloadsPrompt, } -pub static SERIES_BLOCKS: [ActiveSonarrBlock; 7] = [ +pub static LIBRARY_BLOCKS: [ActiveSonarrBlock; 7] = [ ActiveSonarrBlock::Series, ActiveSonarrBlock::SeriesSortPrompt, ActiveSonarrBlock::SearchSeries, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index bf07cda..7af3986 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -203,19 +203,19 @@ mod tests { mod active_sonarr_block_tests { use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, DELETE_SERIES_BLOCKS, DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, - EDIT_SERIES_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, SERIES_BLOCKS, + EDIT_SERIES_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, LIBRARY_BLOCKS, }; #[test] - fn test_series_blocks_contents() { - assert_eq!(SERIES_BLOCKS.len(), 7); - assert!(SERIES_BLOCKS.contains(&ActiveSonarrBlock::Series)); - assert!(SERIES_BLOCKS.contains(&ActiveSonarrBlock::SeriesSortPrompt)); - assert!(SERIES_BLOCKS.contains(&ActiveSonarrBlock::SearchSeries)); - assert!(SERIES_BLOCKS.contains(&ActiveSonarrBlock::SearchSeriesError)); - assert!(SERIES_BLOCKS.contains(&ActiveSonarrBlock::FilterSeries)); - assert!(SERIES_BLOCKS.contains(&ActiveSonarrBlock::FilterSeriesError)); - assert!(SERIES_BLOCKS.contains(&ActiveSonarrBlock::UpdateAllSeriesPrompt)); + fn test_library_blocks_contents() { + assert_eq!(LIBRARY_BLOCKS.len(), 7); + assert!(LIBRARY_BLOCKS.contains(&ActiveSonarrBlock::Series)); + assert!(LIBRARY_BLOCKS.contains(&ActiveSonarrBlock::SeriesSortPrompt)); + assert!(LIBRARY_BLOCKS.contains(&ActiveSonarrBlock::SearchSeries)); + assert!(LIBRARY_BLOCKS.contains(&ActiveSonarrBlock::SearchSeriesError)); + assert!(LIBRARY_BLOCKS.contains(&ActiveSonarrBlock::FilterSeries)); + assert!(LIBRARY_BLOCKS.contains(&ActiveSonarrBlock::FilterSeriesError)); + assert!(LIBRARY_BLOCKS.contains(&ActiveSonarrBlock::UpdateAllSeriesPrompt)); } #[test] diff --git a/src/ui/sonarr_ui/library/delete_series_ui.rs b/src/ui/sonarr_ui/library/delete_series_ui.rs new file mode 100644 index 0000000..eb7278e --- /dev/null +++ b/src/ui/sonarr_ui/library/delete_series_ui.rs @@ -0,0 +1,57 @@ +use ratatui::layout::Rect; +use ratatui::Frame; + +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DELETE_SERIES_BLOCKS}; +use crate::models::Route; +use crate::ui::sonarr_ui::library::draw_library; +use crate::ui::widgets::checkbox::Checkbox; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::DrawUi; + +#[cfg(test)] +#[path = "delete_series_ui_tests.rs"] +mod delete_series_ui_tests; + +pub(super) struct DeleteSeriesUi; + +impl DrawUi for DeleteSeriesUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return DELETE_SERIES_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::DeleteSeriesPrompt, _) + ) { + let selected_block = app.data.sonarr_data.selected_block.get_active_block(); + let prompt = format!( + "Do you really want to delete: \n{}?", + app.data.sonarr_data.series.current_selection().title.text + ); + let checkboxes = vec![ + Checkbox::new("Delete Series File") + .checked(app.data.sonarr_data.delete_series_files) + .highlighted(selected_block == ActiveSonarrBlock::DeleteSeriesToggleDeleteFile), + Checkbox::new("Add List Exclusion") + .checked(app.data.sonarr_data.add_list_exclusion) + .highlighted(selected_block == ActiveSonarrBlock::DeleteSeriesToggleAddListExclusion), + ]; + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Series") + .prompt(&prompt) + .checkboxes(checkboxes) + .yes_no_highlighted(selected_block == ActiveSonarrBlock::DeleteSeriesConfirmPrompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + draw_library(f, app, area); + f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + } + } +} diff --git a/src/ui/sonarr_ui/library/delete_series_ui_tests.rs b/src/ui/sonarr_ui/library/delete_series_ui_tests.rs new file mode 100644 index 0000000..17c61b1 --- /dev/null +++ b/src/ui/sonarr_ui/library/delete_series_ui_tests.rs @@ -0,0 +1,19 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DELETE_SERIES_BLOCKS}; + use crate::ui::sonarr_ui::library::delete_series_ui::DeleteSeriesUi; + use crate::ui::DrawUi; + + #[test] + fn test_delete_series_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if DELETE_SERIES_BLOCKS.contains(&active_sonarr_block) { + assert!(DeleteSeriesUi::accepts(active_sonarr_block.into())); + } else { + assert!(!DeleteSeriesUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs index 31c40fc..c94ea65 100644 --- a/src/ui/sonarr_ui/library/library_ui_tests.rs +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -1,8 +1,8 @@ #[cfg(test)] mod tests { - use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DELETE_SERIES_BLOCKS}; use crate::models::{ - servarr_data::sonarr::sonarr_data::SERIES_BLOCKS, sonarr_models::SeriesStatus, + servarr_data::sonarr::sonarr_data::LIBRARY_BLOCKS, sonarr_models::SeriesStatus, }; use crate::ui::sonarr_ui::library::LibraryUi; use crate::ui::styles::ManagarrStyle; @@ -20,7 +20,8 @@ mod tests { #[test] fn test_library_ui_accepts() { let mut library_ui_blocks = Vec::new(); - library_ui_blocks.extend(SERIES_BLOCKS); + library_ui_blocks.extend(LIBRARY_BLOCKS); + library_ui_blocks.extend(DELETE_SERIES_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_radarr_block| { if library_ui_blocks.contains(&active_radarr_block) { diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index a9661b3..e25027f 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -1,3 +1,4 @@ +use delete_series_ui::DeleteSeriesUi; use ratatui::{ layout::{Constraint, Rect}, widgets::{Cell, Row}, @@ -12,7 +13,7 @@ use crate::ui::widgets::{ use crate::{ app::App, models::{ - servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SERIES_BLOCKS}, + servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, LIBRARY_BLOCKS}, sonarr_models::{Series, SeriesStatus}, EnumDisplayStyle, Route, }, @@ -25,6 +26,8 @@ use crate::{ }, }; +mod delete_series_ui; + #[cfg(test)] #[path = "library_ui_tests.rs"] mod library_ui_tests; @@ -34,7 +37,7 @@ pub(super) struct LibraryUi; impl DrawUi for LibraryUi { fn accepts(route: Route) -> bool { if let Route::Sonarr(active_sonarr_block, _) = route { - return SERIES_BLOCKS.contains(&active_sonarr_block); + return DeleteSeriesUi::accepts(route) || LIBRARY_BLOCKS.contains(&active_sonarr_block); } false @@ -44,26 +47,26 @@ impl DrawUi for LibraryUi { let route = app.get_current_route(); let mut series_ui_matchers = |active_sonarr_block: ActiveSonarrBlock| match active_sonarr_block { - ActiveSonarrBlock::Series | ActiveSonarrBlock::SeriesSortPrompt => draw_series(f, app, area), + ActiveSonarrBlock::Series | ActiveSonarrBlock::SeriesSortPrompt => draw_library(f, app, area), ActiveSonarrBlock::SearchSeries => draw_popup_over( f, app, area, - draw_series, - draw_series_search_box, + draw_library, + draw_library_search_box, Size::InputBox, ), ActiveSonarrBlock::SearchSeriesError => { let popup = Popup::new(Message::new("Series not found!")).size(Size::Message); - draw_series(f, app, area); + draw_library(f, app, area); f.render_widget(popup, f.area()); } ActiveSonarrBlock::FilterSeries => draw_popup_over( f, app, area, - draw_series, + draw_library, draw_filter_series_box, Size::InputBox, ), @@ -71,7 +74,7 @@ impl DrawUi for LibraryUi { let popup = Popup::new(Message::new("No series found matching the given filter!")) .size(Size::Message); - draw_series(f, app, area); + draw_library(f, app, area); f.render_widget(popup, f.area()); } ActiveSonarrBlock::UpdateAllSeriesPrompt => { @@ -80,14 +83,15 @@ impl DrawUi for LibraryUi { .prompt("Do you want to update info and scan your disks for all of your series?") .yes_no_value(app.data.sonarr_data.prompt_confirm); - draw_series(f, app, area); + draw_library(f, app, area); f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); } _ => (), }; match route { - Route::Sonarr(active_sonarr_block, _) if SERIES_BLOCKS.contains(&active_sonarr_block) => { + _ if DeleteSeriesUi::accepts(route) => DeleteSeriesUi::draw(f, app, area), + Route::Sonarr(active_sonarr_block, _) if LIBRARY_BLOCKS.contains(&active_sonarr_block) => { series_ui_matchers(active_sonarr_block) } _ => (), @@ -95,7 +99,7 @@ impl DrawUi for LibraryUi { } } -pub(super) fn draw_series(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { +pub(super) fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { let current_selection = if !app.data.sonarr_data.series.items.is_empty() { app.data.sonarr_data.series.current_selection().clone() @@ -220,7 +224,7 @@ fn decorate_series_row_with_style<'a>(series: &Series, row: Row<'a>) -> Row<'a> } } -fn draw_series_search_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { +fn draw_library_search_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { draw_input_box_popup( f, area, From d7f6d12f59529dee08548eeffe2403c67175e27f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 12:43:17 -0700 Subject: [PATCH 11/82] feat(handler): Add series support for Sonarr --- .../library/add_series_handler.rs | 550 +++++ .../library/add_series_handler_tests.rs | 1849 +++++++++++++++++ .../library/library_handler_tests.rs | 47 +- src/handlers/sonarr_handlers/library/mod.rs | 9 +- src/models/servarr_data/sonarr/sonarr_data.rs | 27 + .../servarr_data/sonarr/sonarr_data_tests.rs | 62 +- 6 files changed, 2519 insertions(+), 25 deletions(-) create mode 100644 src/handlers/sonarr_handlers/library/add_series_handler.rs create mode 100644 src/handlers/sonarr_handlers/library/add_series_handler_tests.rs diff --git a/src/handlers/sonarr_handlers/library/add_series_handler.rs b/src/handlers/sonarr_handlers/library/add_series_handler.rs new file mode 100644 index 0000000..03bce76 --- /dev/null +++ b/src/handlers/sonarr_handlers/library/add_series_handler.rs @@ -0,0 +1,550 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, +}; +use crate::models::{BlockSelectionState, Scrollable}; +use crate::network::sonarr_network::SonarrEvent; +use crate::{handle_text_box_keys, handle_text_box_left_right_keys, App, Key}; + +#[cfg(test)] +#[path = "add_series_handler_tests.rs"] +mod add_series_handler_tests; + +pub(super) struct AddSeriesHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for AddSeriesHandler<'a, 'b> { + fn accepts(active_block: ActiveSonarrBlock) -> bool { + ADD_SERIES_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + _context: Option, + ) -> AddSeriesHandler<'a, 'b> { + AddSeriesHandler { + key, + app, + active_sonarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + } + + fn handle_scroll_up(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::AddSeriesSearchResults => self + .app + .data + .sonarr_data + .add_searched_series + .as_mut() + .unwrap() + .scroll_up(), + ActiveSonarrBlock::AddSeriesSelectMonitor => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_up(), + ActiveSonarrBlock::AddSeriesSelectSeriesType => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .series_type_list + .scroll_up(), + ActiveSonarrBlock::AddSeriesSelectQualityProfile => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_up(), + ActiveSonarrBlock::AddSeriesSelectLanguageProfile => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .language_profile_list + .scroll_up(), + ActiveSonarrBlock::AddSeriesSelectRootFolder => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .root_folder_list + .scroll_up(), + ActiveSonarrBlock::AddSeriesPrompt => self.app.data.sonarr_data.selected_block.up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::AddSeriesSearchResults => self + .app + .data + .sonarr_data + .add_searched_series + .as_mut() + .unwrap() + .scroll_down(), + ActiveSonarrBlock::AddSeriesSelectMonitor => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_down(), + ActiveSonarrBlock::AddSeriesSelectSeriesType => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .series_type_list + .scroll_down(), + ActiveSonarrBlock::AddSeriesSelectQualityProfile => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_down(), + ActiveSonarrBlock::AddSeriesSelectLanguageProfile => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .language_profile_list + .scroll_down(), + ActiveSonarrBlock::AddSeriesSelectRootFolder => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .root_folder_list + .scroll_down(), + ActiveSonarrBlock::AddSeriesPrompt => self.app.data.sonarr_data.selected_block.down(), + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::AddSeriesSearchResults => self + .app + .data + .sonarr_data + .add_searched_series + .as_mut() + .unwrap() + .scroll_to_top(), + ActiveSonarrBlock::AddSeriesSelectMonitor => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_top(), + ActiveSonarrBlock::AddSeriesSelectSeriesType => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .series_type_list + .scroll_to_top(), + ActiveSonarrBlock::AddSeriesSelectQualityProfile => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_top(), + ActiveSonarrBlock::AddSeriesSelectLanguageProfile => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .language_profile_list + .scroll_to_top(), + ActiveSonarrBlock::AddSeriesSelectRootFolder => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .root_folder_list + .scroll_to_top(), + ActiveSonarrBlock::AddSeriesSearchInput => self + .app + .data + .sonarr_data + .add_series_search + .as_mut() + .unwrap() + .scroll_home(), + ActiveSonarrBlock::AddSeriesTagsInput => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .tags + .scroll_home(), + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::AddSeriesSearchResults => self + .app + .data + .sonarr_data + .add_searched_series + .as_mut() + .unwrap() + .scroll_to_bottom(), + ActiveSonarrBlock::AddSeriesSelectMonitor => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_bottom(), + ActiveSonarrBlock::AddSeriesSelectSeriesType => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .series_type_list + .scroll_to_bottom(), + ActiveSonarrBlock::AddSeriesSelectQualityProfile => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_bottom(), + ActiveSonarrBlock::AddSeriesSelectLanguageProfile => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .language_profile_list + .scroll_to_bottom(), + ActiveSonarrBlock::AddSeriesSelectRootFolder => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .root_folder_list + .scroll_to_bottom(), + ActiveSonarrBlock::AddSeriesSearchInput => self + .app + .data + .sonarr_data + .add_series_search + .as_mut() + .unwrap() + .reset_offset(), + ActiveSonarrBlock::AddSeriesTagsInput => self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .tags + .reset_offset(), + _ => (), + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::AddSeriesPrompt => handle_prompt_toggle(self.app, self.key), + ActiveSonarrBlock::AddSeriesSearchInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .sonarr_data + .add_series_search + .as_mut() + .unwrap() + ) + } + ActiveSonarrBlock::AddSeriesTagsInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .tags + ) + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + _ if self.active_sonarr_block == ActiveSonarrBlock::AddSeriesSearchInput + && !self + .app + .data + .sonarr_data + .add_series_search + .as_mut() + .unwrap() + .text + .is_empty() => + { + self + .app + .push_navigation_stack(ActiveSonarrBlock::AddSeriesSearchResults.into()); + self.app.should_ignore_quit_key = false; + } + _ if self.active_sonarr_block == ActiveSonarrBlock::AddSeriesSearchResults + && self.app.data.sonarr_data.add_searched_series.is_some() => + { + let tvdb_id = self + .app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .tvdb_id; + + if self + .app + .data + .sonarr_data + .series + .items + .iter() + .any(|series| series.tvdb_id == tvdb_id) + { + self + .app + .push_navigation_stack(ActiveSonarrBlock::AddSeriesAlreadyInLibrary.into()); + } else { + self + .app + .push_navigation_stack(ActiveSonarrBlock::AddSeriesPrompt.into()); + self.app.data.sonarr_data.add_series_modal = Some((&self.app.data.sonarr_data).into()); + self.app.data.sonarr_data.selected_block = + BlockSelectionState::new(ADD_SERIES_SELECTION_BLOCKS); + } + } + ActiveSonarrBlock::AddSeriesPrompt => { + match self.app.data.sonarr_data.selected_block.get_active_block() { + ActiveSonarrBlock::AddSeriesConfirmPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::AddSeries(None)); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::AddSeriesSelectMonitor + | ActiveSonarrBlock::AddSeriesSelectSeriesType + | ActiveSonarrBlock::AddSeriesSelectQualityProfile + | ActiveSonarrBlock::AddSeriesSelectLanguageProfile + | ActiveSonarrBlock::AddSeriesSelectRootFolder => self.app.push_navigation_stack( + self + .app + .data + .sonarr_data + .selected_block + .get_active_block() + .into(), + ), + ActiveSonarrBlock::AddSeriesTagsInput => { + self.app.push_navigation_stack( + self + .app + .data + .sonarr_data + .selected_block + .get_active_block() + .into(), + ); + self.app.should_ignore_quit_key = true; + } + ActiveSonarrBlock::AddSeriesToggleUseSeasonFolder => { + self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .use_season_folder = !self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .use_season_folder; + } + _ => (), + } + } + ActiveSonarrBlock::AddSeriesSelectMonitor + | ActiveSonarrBlock::AddSeriesSelectSeriesType + | ActiveSonarrBlock::AddSeriesSelectQualityProfile + | ActiveSonarrBlock::AddSeriesSelectLanguageProfile + | ActiveSonarrBlock::AddSeriesSelectRootFolder => self.app.pop_navigation_stack(), + ActiveSonarrBlock::AddSeriesTagsInput => { + self.app.pop_navigation_stack(); + self.app.should_ignore_quit_key = false; + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::AddSeriesSearchInput => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.add_series_search = None; + self.app.should_ignore_quit_key = false; + } + ActiveSonarrBlock::AddSeriesSearchResults + | ActiveSonarrBlock::AddSeriesEmptySearchResults => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.add_searched_series = None; + self.app.should_ignore_quit_key = true; + } + ActiveSonarrBlock::AddSeriesPrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.add_series_modal = None; + self.app.data.sonarr_data.prompt_confirm = false; + } + ActiveSonarrBlock::AddSeriesSelectMonitor + | ActiveSonarrBlock::AddSeriesSelectSeriesType + | ActiveSonarrBlock::AddSeriesSelectQualityProfile + | ActiveSonarrBlock::AddSeriesSelectLanguageProfile + | ActiveSonarrBlock::AddSeriesAlreadyInLibrary + | ActiveSonarrBlock::AddSeriesSelectRootFolder => self.app.pop_navigation_stack(), + ActiveSonarrBlock::AddSeriesTagsInput => { + self.app.pop_navigation_stack(); + self.app.should_ignore_quit_key = false; + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_sonarr_block { + ActiveSonarrBlock::AddSeriesSearchInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .sonarr_data + .add_series_search + .as_mut() + .unwrap() + ) + } + ActiveSonarrBlock::AddSeriesTagsInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .tags + ) + } + ActiveSonarrBlock::AddSeriesPrompt => { + if self.app.data.sonarr_data.selected_block.get_active_block() + == ActiveSonarrBlock::AddSeriesConfirmPrompt + && key == DEFAULT_KEYBINDINGS.confirm.key + { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::AddSeries(None)); + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } +} diff --git a/src/handlers/sonarr_handlers/library/add_series_handler_tests.rs b/src/handlers/sonarr_handlers/library/add_series_handler_tests.rs new file mode 100644 index 0000000..63d28b2 --- /dev/null +++ b/src/handlers/sonarr_handlers/library/add_series_handler_tests.rs @@ -0,0 +1,1849 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_str_eq; + use strum::IntoEnumIterator; + + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::library::add_series_handler::AddSeriesHandler; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS}; + use crate::models::servarr_models::RootFolder; + use crate::models::sonarr_models::{AddSeriesSearchResult, SeriesMonitor, SeriesType}; + use crate::models::HorizontallyScrollableText; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::modals::AddSeriesModal; + use crate::models::servarr_data::sonarr::sonarr_data::ADD_SERIES_SELECTION_BLOCKS; + use crate::models::stateful_table::StatefulTable; + use crate::models::BlockSelectionState; + use crate::simple_stateful_iterable_vec; + + use super::*; + + #[rstest] + fn test_add_series_search_results_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + let mut add_searched_series = StatefulTable::default(); + add_searched_series.set_items(simple_stateful_iterable_vec!( + AddSeriesSearchResult, + HorizontallyScrollableText + )); + app.data.sonarr_data.add_searched_series = Some(add_searched_series); + + AddSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::AddSeriesSearchResults, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .title + .to_string(), + "Test 2" + ); + + AddSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::AddSeriesSearchResults, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .title + .to_string(), + "Test 1" + ); + } + + #[rstest] + fn test_add_series_search_results_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.is_loading = true; + let mut add_searched_series = StatefulTable::default(); + add_searched_series.set_items(simple_stateful_iterable_vec!( + AddSeriesSearchResult, + HorizontallyScrollableText + )); + app.data.sonarr_data.add_searched_series = Some(add_searched_series); + + AddSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::AddSeriesSearchResults, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .title + .to_string(), + "Test 1" + ); + + AddSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::AddSeriesSearchResults, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .title + .to_string(), + "Test 1" + ); + } + + #[rstest] + fn test_add_series_select_monitor_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let monitor_vec = Vec::from_iter(SeriesMonitor::iter()); + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal::default()); + app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_vec.clone()); + + if key == Key::Up { + for i in (0..monitor_vec.len()).rev() { + AddSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_vec[i] + ); + } + } else { + for i in 0..monitor_vec.len() { + AddSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_vec[(i + 1) % monitor_vec.len()] + ); + } + } + } + + #[rstest] + fn test_add_series_select_series_type_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let series_type_vec = Vec::from_iter(SeriesType::iter()); + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal::default()); + app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .series_type_list + .set_items(series_type_vec.clone()); + + if key == Key::Up { + for i in (0..series_type_vec.len()).rev() { + AddSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectSeriesType, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .series_type_list + .current_selection(), + &series_type_vec[i] + ); + } + } else { + for i in 0..series_type_vec.len() { + AddSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectSeriesType, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .series_type_list + .current_selection(), + &series_type_vec[(i + 1) % series_type_vec.len()] + ); + } + } + } + + #[rstest] + fn test_add_series_select_quality_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal::default()); + app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); + + AddSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 2" + ); + + AddSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 1" + ); + } + + #[rstest] + fn test_add_series_select_language_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal::default()); + app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .language_profile_list + .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); + + AddSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectLanguageProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .language_profile_list + .current_selection(), + "Test 2" + ); + + AddSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectLanguageProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .language_profile_list + .current_selection(), + "Test 1" + ); + } + + #[rstest] + fn test_add_series_select_root_folder_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal::default()); + app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .root_folder_list + .set_items(simple_stateful_iterable_vec!(RootFolder, String, path)); + + AddSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectRootFolder, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .root_folder_list + .current_selection() + .path, + "Test 2" + ); + + AddSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectRootFolder, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .root_folder_list + .current_selection() + .path, + "Test 1" + ); + } + + #[rstest] + fn test_add_series_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.selected_block = BlockSelectionState::new(ADD_SERIES_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.down(); + + AddSeriesHandler::with(key, &mut app, ActiveSonarrBlock::AddSeriesPrompt, None).handle(); + + if key == Key::Up { + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::AddSeriesSelectRootFolder + ); + } else { + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::AddSeriesSelectQualityProfile + ); + } + } + + #[rstest] + fn test_add_series_prompt_scroll_no_op_when_not_ready(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.is_loading = true; + app.data.sonarr_data.selected_block = BlockSelectionState::new(ADD_SERIES_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.down(); + + AddSeriesHandler::with(key, &mut app, ActiveSonarrBlock::AddSeriesPrompt, None).handle(); + + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::AddSeriesSelectMonitor + ); + } + } + + mod test_handle_home_end { + use std::sync::atomic::Ordering; + + use strum::IntoEnumIterator; + + use crate::extended_stateful_iterable_vec; + use crate::models::servarr_data::sonarr::modals::AddSeriesModal; + use crate::models::stateful_table::StatefulTable; + + use super::*; + + #[test] + fn test_add_series_search_results_home_end() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + let mut add_searched_series = StatefulTable::default(); + add_searched_series.set_items(extended_stateful_iterable_vec!( + AddSeriesSearchResult, + HorizontallyScrollableText + )); + app.data.sonarr_data.add_searched_series = Some(add_searched_series); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::AddSeriesSearchResults, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .title + .to_string(), + "Test 3" + ); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::AddSeriesSearchResults, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .title + .to_string(), + "Test 1" + ); + } + + #[test] + fn test_add_series_search_results_home_end_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.is_loading = true; + let mut add_searched_series = StatefulTable::default(); + add_searched_series.set_items(extended_stateful_iterable_vec!( + AddSeriesSearchResult, + HorizontallyScrollableText + )); + app.data.sonarr_data.add_searched_series = Some(add_searched_series); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::AddSeriesSearchResults, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .title + .to_string(), + "Test 1" + ); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::AddSeriesSearchResults, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .title + .to_string(), + "Test 1" + ); + } + + #[test] + fn test_add_series_select_monitor_home_end() { + let monitor_vec = Vec::from_iter(SeriesMonitor::iter()); + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal::default()); + app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_vec.clone()); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_vec[monitor_vec.len() - 1] + ); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_vec[0] + ); + } + + #[test] + fn test_add_series_select_series_type_home_end() { + let series_type_vec = Vec::from_iter(SeriesType::iter()); + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal::default()); + app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .series_type_list + .set_items(series_type_vec.clone()); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectSeriesType, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .series_type_list + .current_selection(), + &series_type_vec[series_type_vec.len() - 1] + ); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectSeriesType, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .series_type_list + .current_selection(), + &series_type_vec[0] + ); + } + + #[test] + fn test_add_series_select_quality_profile_home_end() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal::default()); + app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec![ + "Test 1".to_owned(), + "Test 2".to_owned(), + "Test 3".to_owned(), + ]); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 3" + ); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 1" + ); + } + + #[test] + fn test_add_series_select_language_profile_home_end() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal::default()); + app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .language_profile_list + .set_items(vec![ + "Test 1".to_owned(), + "Test 2".to_owned(), + "Test 3".to_owned(), + ]); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectLanguageProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .language_profile_list + .current_selection(), + "Test 3" + ); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectLanguageProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .language_profile_list + .current_selection(), + "Test 1" + ); + } + + #[test] + fn test_add_series_select_root_folder_home_end() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal::default()); + app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .root_folder_list + .set_items(extended_stateful_iterable_vec!(RootFolder, String, path)); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectRootFolder, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .root_folder_list + .current_selection() + .path, + "Test 3" + ); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::AddSeriesSelectRootFolder, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .root_folder_list + .current_selection() + .path, + "Test 1" + ); + } + + #[test] + fn test_add_series_search_input_home_end_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_search = Some("Test".into()); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::AddSeriesSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 4 + ); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::AddSeriesSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_add_series_tags_input_home_end_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal { + tags: "Test".into(), + ..AddSeriesModal::default() + }); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::AddSeriesTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 4 + ); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::AddSeriesTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_left_right_action { + use std::sync::atomic::Ordering; + + use crate::models::servarr_data::sonarr::modals::AddSeriesModal; + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + + AddSeriesHandler::with(key, &mut app, ActiveSonarrBlock::AddSeriesPrompt, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + AddSeriesHandler::with(key, &mut app, ActiveSonarrBlock::AddSeriesPrompt, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[test] + fn test_add_series_search_input_left_right_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_search = Some("Test".into()); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::AddSeriesSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 1 + ); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::AddSeriesSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_add_series_tags_input_left_right_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal { + tags: "Test".into(), + ..AddSeriesModal::default() + }); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::AddSeriesTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 1 + ); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::AddSeriesTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + + use crate::models::servarr_data::sonarr::modals::AddSeriesModal; + use crate::models::servarr_data::sonarr::sonarr_data::ADD_SERIES_SELECTION_BLOCKS; + use crate::models::sonarr_models::Series; + use crate::models::stateful_table::StatefulTable; + use crate::models::BlockSelectionState; + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_add_series_search_input_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.should_ignore_quit_key = true; + app.data.sonarr_data.add_series_search = Some("test".into()); + + AddSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AddSeriesSearchInput, + None, + ) + .handle(); + + assert!(!app.should_ignore_quit_key); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesSearchResults.into() + ); + } + + #[test] + fn test_add_series_search_input_submit_noop_on_empty_search() { + let mut app = App::default(); + app.data.sonarr_data.add_series_search = Some(HorizontallyScrollableText::default()); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesSearchInput.into()); + app.should_ignore_quit_key = true; + + AddSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AddSeriesSearchInput, + None, + ) + .handle(); + + assert!(app.should_ignore_quit_key); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesSearchInput.into() + ); + } + + #[test] + fn test_add_series_search_results_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + let mut add_searched_series = StatefulTable::default(); + add_searched_series.set_items(vec![AddSeriesSearchResult::default()]); + app.data.sonarr_data.add_searched_series = Some(add_searched_series); + app.data.sonarr_data.quality_profile_map = + BiMap::from_iter([(1, "B - Test 2".to_owned()), (0, "A - Test 1".to_owned())]); + + AddSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AddSeriesSearchResults, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesPrompt.into() + ); + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::AddSeriesSelectRootFolder + ); + assert!(app.data.sonarr_data.add_series_modal.is_some()); + assert!(!app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .monitor_list + .items + .is_empty()); + assert!(!app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .series_type_list + .items + .is_empty()); + assert!(!app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .quality_profile_list + .items + .is_empty()); + assert_str_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "A - Test 1" + ); + } + + #[test] + fn test_add_series_search_results_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesSearchResults.into()); + let mut add_searched_series = StatefulTable::default(); + add_searched_series.set_items(vec![AddSeriesSearchResult::default()]); + + AddSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AddSeriesSearchResults, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesSearchResults.into() + ); + assert!(app.data.sonarr_data.add_series_modal.is_none()); + } + + #[test] + fn test_add_series_search_results_submit_does_nothing_on_empty_table() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesSearchResults.into()); + AddSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AddSeriesSearchResults, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesSearchResults.into() + ); + } + + #[test] + fn test_add_series_search_results_submit_series_already_in_library() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + let mut add_searched_series = StatefulTable::default(); + add_searched_series.set_items(vec![AddSeriesSearchResult::default()]); + app.data.sonarr_data.add_searched_series = Some(add_searched_series); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + + AddSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AddSeriesSearchResults, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesAlreadyInLibrary.into() + ); + } + + #[test] + fn test_add_series_prompt_prompt_decline_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesPrompt.into()); + app.data.sonarr_data.selected_block = BlockSelectionState::new(ADD_SERIES_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(0, ADD_SERIES_SELECTION_BLOCKS.len() - 1); + + AddSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AddSeriesPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + } + + #[test] + fn test_add_series_confirm_prompt_prompt_confirmation_submit() { + let mut app = App::default(); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesPrompt.into()); + app.data.sonarr_data.prompt_confirm = true; + app.data.sonarr_data.selected_block = BlockSelectionState::new(ADD_SERIES_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(0, ADD_SERIES_SELECTION_BLOCKS.len() - 1); + + AddSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AddSeriesPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::AddSeries(None)) + ); + assert!(app.data.sonarr_data.add_series_modal.is_some()); + } + + #[rstest] + #[case(ActiveSonarrBlock::AddSeriesSelectRootFolder, 0)] + #[case(ActiveSonarrBlock::AddSeriesSelectMonitor, 1)] + #[case(ActiveSonarrBlock::AddSeriesSelectQualityProfile, 2)] + #[case(ActiveSonarrBlock::AddSeriesSelectLanguageProfile, 3)] + #[case(ActiveSonarrBlock::AddSeriesSelectSeriesType, 4)] + #[case(ActiveSonarrBlock::AddSeriesTagsInput, 6)] + fn test_add_series_prompt_selected_block_submit( + #[case] selected_block: ActiveSonarrBlock, + #[case] y_index: usize, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesPrompt.into()); + app.data.sonarr_data.selected_block = BlockSelectionState::new(ADD_SERIES_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.set_index(0, y_index); + + AddSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AddSeriesPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), selected_block.into()); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + + if selected_block == ActiveSonarrBlock::AddSeriesTagsInput { + assert!(app.should_ignore_quit_key); + } + } + + #[rstest] + fn test_add_series_prompt_selecting_preferences_blocks_submit( + #[values( + ActiveSonarrBlock::AddSeriesSelectMonitor, + ActiveSonarrBlock::AddSeriesSelectSeriesType, + ActiveSonarrBlock::AddSeriesSelectQualityProfile, + ActiveSonarrBlock::AddSeriesSelectLanguageProfile, + ActiveSonarrBlock::AddSeriesSelectRootFolder, + ActiveSonarrBlock::AddSeriesTagsInput + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesPrompt.into()); + app.push_navigation_stack(active_sonarr_block.into()); + + AddSeriesHandler::with(SUBMIT_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesPrompt.into() + ); + + if active_sonarr_block == ActiveSonarrBlock::AddSeriesTagsInput { + assert!(!app.should_ignore_quit_key); + } + } + + #[test] + fn test_add_series_toggle_use_season_folder_submit() { + let mut app = App::default(); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal::default()); + app.data.sonarr_data.selected_block = BlockSelectionState::new(ADD_SERIES_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.set_index(0, 5); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesPrompt.into()); + + AddSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AddSeriesPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesPrompt.into() + ); + assert!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .use_season_folder + ); + + AddSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AddSeriesPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesPrompt.into() + ); + assert!( + !app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .use_season_folder + ); + } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::servarr_data::sonarr::modals::AddSeriesModal; + use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; + use crate::models::stateful_table::StatefulTable; + use crate::simple_stateful_iterable_vec; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_add_series_search_input_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.data.sonarr_data = create_test_sonarr_data(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesSearchInput.into()); + + AddSeriesHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::AddSeriesSearchInput, + None, + ) + .handle(); + + assert!(!app.should_ignore_quit_key); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert_eq!(app.data.sonarr_data.add_series_search, None); + } + + #[test] + fn test_add_series_input_esc() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesPrompt.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesTagsInput.into()); + + AddSeriesHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::AddSeriesTagsInput, + None, + ) + .handle(); + + assert!(!app.should_ignore_quit_key); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesPrompt.into() + ); + } + + #[rstest] + fn test_add_series_search_results_esc( + #[values( + ActiveSonarrBlock::AddSeriesSearchResults, + ActiveSonarrBlock::AddSeriesEmptySearchResults + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesSearchInput.into()); + app.push_navigation_stack(active_sonarr_block.into()); + let mut add_searched_series = StatefulTable::default(); + add_searched_series.set_items(simple_stateful_iterable_vec!( + AddSeriesSearchResult, + HorizontallyScrollableText + )); + app.data.sonarr_data.add_searched_series = Some(add_searched_series); + + AddSeriesHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesSearchInput.into() + ); + assert!(app.data.sonarr_data.add_searched_series.is_none()); + assert!(app.should_ignore_quit_key); + } + + #[test] + fn test_add_series_already_in_library_esc() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesSearchResults.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesAlreadyInLibrary.into()); + + AddSeriesHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::AddSeriesAlreadyInLibrary, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesSearchResults.into() + ); + } + + #[test] + fn test_add_series_prompt_esc() { + let mut app = App::default(); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal::default()); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesSearchResults.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesPrompt.into()); + + AddSeriesHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::AddSeriesPrompt, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesSearchResults.into() + ); + assert!(app.data.sonarr_data.add_series_modal.is_none()); + } + + #[test] + fn test_add_series_tags_input_esc() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesPrompt.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesTagsInput.into()); + + AddSeriesHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::AddSeriesTagsInput, + None, + ) + .handle(); + + assert!(!app.should_ignore_quit_key); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesPrompt.into() + ); + } + + #[rstest] + fn test_selecting_preferences_blocks_esc( + #[values( + ActiveSonarrBlock::AddSeriesSelectMonitor, + ActiveSonarrBlock::AddSeriesSelectSeriesType, + ActiveSonarrBlock::AddSeriesSelectQualityProfile, + ActiveSonarrBlock::AddSeriesSelectLanguageProfile, + ActiveSonarrBlock::AddSeriesSelectRootFolder + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesPrompt.into()); + app.push_navigation_stack(active_sonarr_block.into()); + + AddSeriesHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddSeriesPrompt.into() + ); + } + } + + mod test_handle_key_char { + use super::*; + use crate::{ + models::{ + servarr_data::sonarr::{modals::AddSeriesModal, sonarr_data::ADD_SERIES_SELECTION_BLOCKS}, + BlockSelectionState, + }, + network::sonarr_network::SonarrEvent, + }; + + #[test] + fn test_add_series_search_input_backspace() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_search = Some("Test".into()); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::AddSeriesSearchInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_search + .as_ref() + .unwrap() + .text, + "Tes" + ); + } + + #[test] + fn test_add_series_tags_input_backspace() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal { + tags: "Test".into(), + ..AddSeriesModal::default() + }); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::AddSeriesTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .tags + .text, + "Tes" + ); + } + + #[test] + fn test_add_series_search_input_char_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_search = Some(HorizontallyScrollableText::default()); + + AddSeriesHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::AddSeriesSearchInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_search + .as_ref() + .unwrap() + .text, + "h" + ); + } + + #[test] + fn test_add_series_tags_input_char_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal::default()); + + AddSeriesHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::AddSeriesTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .tags + .text, + "h" + ); + } + + #[test] + fn test_add_series_confirm_prompt_prompt_confirmation_confirm() { + let mut app = App::default(); + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddSeriesPrompt.into()); + app.data.sonarr_data.selected_block = BlockSelectionState::new(ADD_SERIES_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(0, ADD_SERIES_SELECTION_BLOCKS.len() - 1); + + AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveSonarrBlock::AddSeriesPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::AddSeries(None)) + ); + assert!(app.data.sonarr_data.add_series_modal.is_some()); + } + } + + #[test] + fn test_add_series_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if ADD_SERIES_BLOCKS.contains(&active_sonarr_block) { + assert!(AddSeriesHandler::accepts(active_sonarr_block)); + } else { + assert!(!AddSeriesHandler::accepts(active_sonarr_block)); + } + }); + } + + #[test] + fn test_add_series_handler_is_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.is_loading = true; + + let handler = AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::AddSeriesPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_add_series_handler_is_ready_when_not_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.is_loading = false; + + let handler = AddSeriesHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::AddSeriesPrompt, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/sonarr_handlers/library/library_handler_tests.rs b/src/handlers/sonarr_handlers/library/library_handler_tests.rs index 35ce42f..207d0cb 100644 --- a/src/handlers/sonarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/library_handler_tests.rs @@ -12,7 +12,7 @@ mod tests { use crate::handlers::sonarr_handlers::library::{series_sorting_options, LibraryHandler}; use crate::handlers::KeyEventHandler; use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, DELETE_SERIES_BLOCKS, LIBRARY_BLOCKS, + ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, LIBRARY_BLOCKS, }; use crate::models::sonarr_models::{Series, SeriesStatus, SeriesType}; use crate::models::stateful_table::SortOption; @@ -1441,27 +1441,29 @@ mod tests { } } - // #[rstest] - // fn test_delegates_add_series_blocks_to_add_series_handler( - // #[values( - // ActiveSonarrBlock::AddSeriesSearchInput, - // ActiveSonarrBlock::AddSeriesSearchResults, - // ActiveSonarrBlock::AddSeriesPrompt, - // ActiveSonarrBlock::AddSeriesSelectMonitor, - // ActiveSonarrBlock::AddSeriesSelectSeriesType, - // ActiveSonarrBlock::AddSeriesSelectQualityProfile, - // ActiveSonarrBlock::AddSeriesSelectRootFolder, - // ActiveSonarrBlock::AddSeriesAlreadyInLibrary, - // ActiveSonarrBlock::AddSeriesTagsInput - // )] - // active_sonarr_block: ActiveSonarrBlock, - // ) { - // test_handler_delegation!( - // LibraryHandler, - // ActiveSonarrBlock::Series, - // active_sonarr_block - // ); - // } + #[rstest] + fn test_delegates_add_series_blocks_to_add_series_handler( + #[values( + ActiveSonarrBlock::AddSeriesAlreadyInLibrary, + ActiveSonarrBlock::AddSeriesEmptySearchResults, + ActiveSonarrBlock::AddSeriesPrompt, + ActiveSonarrBlock::AddSeriesSearchInput, + ActiveSonarrBlock::AddSeriesSearchResults, + ActiveSonarrBlock::AddSeriesSelectLanguageProfile, + ActiveSonarrBlock::AddSeriesSelectMonitor, + ActiveSonarrBlock::AddSeriesSelectQualityProfile, + ActiveSonarrBlock::AddSeriesSelectRootFolder, + ActiveSonarrBlock::AddSeriesSelectSeriesType, + ActiveSonarrBlock::AddSeriesTagsInput + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + LibraryHandler, + ActiveSonarrBlock::Series, + active_sonarr_block + ); + } // #[rstest] // fn test_delegates_series_details_blocks_to_series_details_handler( @@ -1705,6 +1707,7 @@ mod tests { fn test_library_handler_accepts() { let mut library_handler_blocks = Vec::new(); library_handler_blocks.extend(LIBRARY_BLOCKS); + library_handler_blocks.extend(ADD_SERIES_BLOCKS); library_handler_blocks.extend(DELETE_SERIES_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { diff --git a/src/handlers/sonarr_handlers/library/mod.rs b/src/handlers/sonarr_handlers/library/mod.rs index 3a1a05e..fee9089 100644 --- a/src/handlers/sonarr_handlers/library/mod.rs +++ b/src/handlers/sonarr_handlers/library/mod.rs @@ -1,3 +1,4 @@ +use add_series_handler::AddSeriesHandler; use delete_series_handler::DeleteSeriesHandler; use crate::{ @@ -20,6 +21,7 @@ use crate::{ use super::handle_change_tab_left_right_keys; use crate::app::key_binding::DEFAULT_KEYBINDINGS; +mod add_series_handler; mod delete_series_handler; #[cfg(test)] @@ -36,6 +38,9 @@ pub(super) struct LibraryHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, 'b> { fn handle(&mut self) { match self.active_sonarr_block { + _ if AddSeriesHandler::accepts(self.active_sonarr_block) => { + AddSeriesHandler::with(self.key, self.app, self.active_sonarr_block, self.context).handle(); + } _ if DeleteSeriesHandler::accepts(self.active_sonarr_block) => { DeleteSeriesHandler::with(self.key, self.app, self.active_sonarr_block, self.context) .handle(); @@ -45,7 +50,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' } fn accepts(active_block: ActiveSonarrBlock) -> bool { - DeleteSeriesHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block) + AddSeriesHandler::accepts(active_block) + || DeleteSeriesHandler::accepts(active_block) + || LIBRARY_BLOCKS.contains(&active_block) } fn with( diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 7709042..335b54a 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -295,6 +295,33 @@ pub static LIBRARY_BLOCKS: [ActiveSonarrBlock; 7] = [ ActiveSonarrBlock::UpdateAllSeriesPrompt, ]; +pub static ADD_SERIES_BLOCKS: [ActiveSonarrBlock; 13] = [ + ActiveSonarrBlock::AddSeriesAlreadyInLibrary, + ActiveSonarrBlock::AddSeriesConfirmPrompt, + ActiveSonarrBlock::AddSeriesEmptySearchResults, + ActiveSonarrBlock::AddSeriesPrompt, + ActiveSonarrBlock::AddSeriesSearchInput, + ActiveSonarrBlock::AddSeriesSearchResults, + ActiveSonarrBlock::AddSeriesSelectLanguageProfile, + ActiveSonarrBlock::AddSeriesSelectMonitor, + ActiveSonarrBlock::AddSeriesSelectQualityProfile, + ActiveSonarrBlock::AddSeriesSelectRootFolder, + ActiveSonarrBlock::AddSeriesSelectSeriesType, + ActiveSonarrBlock::AddSeriesTagsInput, + ActiveSonarrBlock::AddSeriesToggleUseSeasonFolder, +]; + +pub const ADD_SERIES_SELECTION_BLOCKS: &[&[ActiveSonarrBlock]] = &[ + &[ActiveSonarrBlock::AddSeriesSelectRootFolder], + &[ActiveSonarrBlock::AddSeriesSelectMonitor], + &[ActiveSonarrBlock::AddSeriesSelectQualityProfile], + &[ActiveSonarrBlock::AddSeriesSelectLanguageProfile], + &[ActiveSonarrBlock::AddSeriesSelectSeriesType], + &[ActiveSonarrBlock::AddSeriesToggleUseSeasonFolder], + &[ActiveSonarrBlock::AddSeriesTagsInput], + &[ActiveSonarrBlock::AddSeriesConfirmPrompt], +]; + pub static EDIT_SERIES_BLOCKS: [ActiveSonarrBlock; 9] = [ ActiveSonarrBlock::EditSeriesPrompt, ActiveSonarrBlock::EditSeriesConfirmPrompt, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 7af3986..0f60183 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -202,8 +202,9 @@ mod tests { mod active_sonarr_block_tests { use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, DELETE_SERIES_BLOCKS, DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, - EDIT_SERIES_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, LIBRARY_BLOCKS, + ActiveSonarrBlock, ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, DELETE_SERIES_BLOCKS, + DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_SERIES_BLOCKS, + EDIT_SERIES_SELECTION_BLOCKS, LIBRARY_BLOCKS, }; #[test] @@ -218,6 +219,63 @@ mod tests { assert!(LIBRARY_BLOCKS.contains(&ActiveSonarrBlock::UpdateAllSeriesPrompt)); } + #[test] + fn test_add_series_blocks_contents() { + assert_eq!(ADD_SERIES_BLOCKS.len(), 13); + assert!(ADD_SERIES_BLOCKS.contains(&ActiveSonarrBlock::AddSeriesAlreadyInLibrary)); + assert!(ADD_SERIES_BLOCKS.contains(&ActiveSonarrBlock::AddSeriesConfirmPrompt)); + assert!(ADD_SERIES_BLOCKS.contains(&ActiveSonarrBlock::AddSeriesEmptySearchResults)); + assert!(ADD_SERIES_BLOCKS.contains(&ActiveSonarrBlock::AddSeriesPrompt)); + assert!(ADD_SERIES_BLOCKS.contains(&ActiveSonarrBlock::AddSeriesSearchInput)); + assert!(ADD_SERIES_BLOCKS.contains(&ActiveSonarrBlock::AddSeriesSearchResults)); + assert!(ADD_SERIES_BLOCKS.contains(&ActiveSonarrBlock::AddSeriesSelectLanguageProfile)); + assert!(ADD_SERIES_BLOCKS.contains(&ActiveSonarrBlock::AddSeriesSelectMonitor)); + assert!(ADD_SERIES_BLOCKS.contains(&ActiveSonarrBlock::AddSeriesSelectQualityProfile)); + assert!(ADD_SERIES_BLOCKS.contains(&ActiveSonarrBlock::AddSeriesSelectRootFolder)); + assert!(ADD_SERIES_BLOCKS.contains(&ActiveSonarrBlock::AddSeriesSelectSeriesType)); + assert!(ADD_SERIES_BLOCKS.contains(&ActiveSonarrBlock::AddSeriesTagsInput)); + assert!(ADD_SERIES_BLOCKS.contains(&ActiveSonarrBlock::AddSeriesToggleUseSeasonFolder)); + } + + #[test] + fn test_add_series_selection_blocks_ordering() { + let mut add_series_block_iter = ADD_SERIES_SELECTION_BLOCKS.iter(); + + assert_eq!( + add_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::AddSeriesSelectRootFolder] + ); + assert_eq!( + add_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::AddSeriesSelectMonitor] + ); + assert_eq!( + add_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::AddSeriesSelectQualityProfile] + ); + assert_eq!( + add_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::AddSeriesSelectLanguageProfile] + ); + assert_eq!( + add_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::AddSeriesSelectSeriesType] + ); + assert_eq!( + add_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::AddSeriesToggleUseSeasonFolder] + ); + assert_eq!( + add_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::AddSeriesTagsInput] + ); + assert_eq!( + add_series_block_iter.next().unwrap(), + &[ActiveSonarrBlock::AddSeriesConfirmPrompt] + ); + assert_eq!(add_series_block_iter.next(), None); + } + #[test] fn test_edit_movie_blocks_contents() { assert_eq!(EDIT_SERIES_BLOCKS.len(), 9); From 82e51be0968b1a7515b213763200a574e74d0390 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 13:53:28 -0700 Subject: [PATCH 12/82] feat(ui): Add series support Sonarr --- src/app/context_clues.rs | 5 + src/app/context_clues_tests.rs | 22 +- src/app/radarr/radarr_context_clues.rs | 5 - src/app/radarr/radarr_context_clues_tests.rs | 18 +- src/app/sonarr/mod.rs | 5 + src/app/sonarr/sonarr_context_clues.rs | 5 + src/app/sonarr/sonarr_context_clues_tests.rs | 25 +- src/app/sonarr/sonarr_tests.rs | 17 + .../collections/edit_collection_ui.rs | 3 +- src/ui/radarr_ui/indexers/edit_indexer_ui.rs | 3 +- .../radarr_ui/indexers/indexer_settings_ui.rs | 3 +- src/ui/radarr_ui/library/add_movie_ui.rs | 6 +- src/ui/radarr_ui/library/edit_movie_ui.rs | 3 +- src/ui/sonarr_ui/library/add_series_ui.rs | 486 ++++++++++++++++++ .../sonarr_ui/library/add_series_ui_tests.rs | 19 + src/ui/sonarr_ui/library/library_ui_tests.rs | 5 +- src/ui/sonarr_ui/library/mod.rs | 7 +- src/ui/widgets/confirmation_prompt.rs | 3 +- src/ui/widgets/popup.rs | 2 + src/ui/widgets/popup_tests.rs | 1 + 20 files changed, 599 insertions(+), 44 deletions(-) create mode 100644 src/ui/sonarr_ui/library/add_series_ui.rs create mode 100644 src/ui/sonarr_ui/library/add_series_ui_tests.rs diff --git a/src/app/context_clues.rs b/src/app/context_clues.rs index d1c00ed..32a6463 100644 --- a/src/app/context_clues.rs +++ b/src/app/context_clues.rs @@ -40,6 +40,11 @@ pub static BLOCKLIST_CONTEXT_CLUES: [ContextClue; 5] = [ (DEFAULT_KEYBINDINGS.clear, "clear blocklist"), ]; +pub static CONFIRMATION_PROMPT_CONTEXT_CLUES: [ContextClue; 2] = [ + (DEFAULT_KEYBINDINGS.confirm, "submit"), + (DEFAULT_KEYBINDINGS.esc, "cancel"), +]; + pub static DOWNLOADS_CONTEXT_CLUES: [ContextClue; 3] = [ ( DEFAULT_KEYBINDINGS.refresh, diff --git a/src/app/context_clues_tests.rs b/src/app/context_clues_tests.rs index ac76c8b..0e9fdaa 100644 --- a/src/app/context_clues_tests.rs +++ b/src/app/context_clues_tests.rs @@ -3,9 +3,9 @@ mod test { use pretty_assertions::{assert_eq, assert_str_eq}; use crate::app::context_clues::{ - BARE_POPUP_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, - INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SERVARR_CONTEXT_CLUES, - SYSTEM_CONTEXT_CLUES, + BARE_POPUP_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, + DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, + SERVARR_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, }; use crate::app::{context_clues::build_context_clue_string, key_binding::DEFAULT_KEYBINDINGS}; @@ -106,6 +106,22 @@ mod test { assert_eq!(blocklist_context_clues_iter.next(), None); } + #[test] + fn test_confirmation_prompt_context_clues() { + let mut confirmation_prompt_context_clues_iter = CONFIRMATION_PROMPT_CONTEXT_CLUES.iter(); + + let (key_binding, description) = confirmation_prompt_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.confirm); + assert_str_eq!(*description, "submit"); + + let (key_binding, description) = confirmation_prompt_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); + assert_str_eq!(*description, "cancel"); + assert_eq!(confirmation_prompt_context_clues_iter.next(), None); + } + #[test] fn test_root_folders_context_clues() { let mut root_folders_context_clues_iter = ROOT_FOLDERS_CONTEXT_CLUES.iter(); diff --git a/src/app/radarr/radarr_context_clues.rs b/src/app/radarr/radarr_context_clues.rs index 27a10af..4f92313 100644 --- a/src/app/radarr/radarr_context_clues.rs +++ b/src/app/radarr/radarr_context_clues.rs @@ -66,11 +66,6 @@ pub static ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [ (DEFAULT_KEYBINDINGS.esc, "edit search"), ]; -pub static CONFIRMATION_PROMPT_CONTEXT_CLUES: [ContextClue; 2] = [ - (DEFAULT_KEYBINDINGS.confirm, "submit"), - (DEFAULT_KEYBINDINGS.esc, "cancel"), -]; - pub static SYSTEM_TASKS_CONTEXT_CLUES: [ContextClue; 2] = [ (DEFAULT_KEYBINDINGS.submit, "start task"), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), diff --git a/src/app/radarr/radarr_context_clues_tests.rs b/src/app/radarr/radarr_context_clues_tests.rs index 8aa0173..4cebc74 100644 --- a/src/app/radarr/radarr_context_clues_tests.rs +++ b/src/app/radarr/radarr_context_clues_tests.rs @@ -5,7 +5,7 @@ mod tests { use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::radarr::radarr_context_clues::{ ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES, COLLECTIONS_CONTEXT_CLUES, - COLLECTION_DETAILS_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, + COLLECTION_DETAILS_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES, SYSTEM_TASKS_CONTEXT_CLUES, }; @@ -213,22 +213,6 @@ mod tests { assert_eq!(add_movie_search_results_context_clues_iter.next(), None); } - #[test] - fn test_confirmation_prompt_context_clues() { - let mut confirmation_prompt_context_clues_iter = CONFIRMATION_PROMPT_CONTEXT_CLUES.iter(); - - let (key_binding, description) = confirmation_prompt_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.confirm); - assert_str_eq!(*description, "submit"); - - let (key_binding, description) = confirmation_prompt_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); - assert_str_eq!(*description, "cancel"); - assert_eq!(confirmation_prompt_context_clues_iter.next(), None); - } - #[test] fn test_system_tasks_context_clues() { let mut system_tasks_context_clues_iter = SYSTEM_TASKS_CONTEXT_CLUES.iter(); diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index 0cefe77..9b6a30b 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -108,6 +108,11 @@ impl<'a> App<'a> { .dispatch_network_event(SonarrEvent::GetLogs(None).into()) .await; } + ActiveSonarrBlock::AddSeriesSearchResults => { + self + .dispatch_network_event(SonarrEvent::SearchNewSeries(None).into()) + .await; + } ActiveSonarrBlock::SystemUpdates => { self .dispatch_network_event(SonarrEvent::GetUpdates.into()) diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index 2a7ceab..ee311ec 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -4,6 +4,11 @@ use crate::app::{context_clues::ContextClue, key_binding::DEFAULT_KEYBINDINGS}; #[path = "sonarr_context_clues_tests.rs"] mod sonarr_context_clues_tests; +pub static ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [ + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.esc, "edit search"), +]; + pub static SERIES_CONTEXT_CLUES: [ContextClue; 10] = [ (DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc), (DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc), diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index 6f93de0..d25992c 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -5,13 +5,30 @@ mod tests { use crate::app::{ key_binding::DEFAULT_KEYBINDINGS, sonarr::sonarr_context_clues::{ - EPISODE_DETAILS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, - MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, - MANUAL_SEASON_SEARCH_CONTEXT_CLUES, SEASON_DETAILS_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, - SERIES_DETAILS_CONTEXT_CLUES, + ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES, + HISTORY_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, + MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, + SEASON_DETAILS_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES, }, }; + #[test] + fn test_add_series_search_results_context_clues() { + let mut add_series_search_results_context_clues_iter = + ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES.iter(); + + let (key_binding, description) = add_series_search_results_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); + assert_str_eq!(*description, "details"); + + let (key_binding, description) = add_series_search_results_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); + assert_str_eq!(*description, "edit search"); + assert_eq!(add_series_search_results_context_clues_iter.next(), None); + } + #[test] fn test_series_context_clues() { let mut series_context_clues_iter = SERIES_CONTEXT_CLUES.iter(); diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index 13b743f..a02c217 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -348,6 +348,23 @@ mod tests { assert_eq!(app.tick_count, 0); } + #[tokio::test] + async fn test_dispatch_by_add_movie_search_results_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::AddSeriesSearchResults) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::SearchNewSeries(None).into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + #[tokio::test] async fn test_check_for_sonarr_prompt_action_no_prompt_confirm() { let mut app = App::default(); diff --git a/src/ui/radarr_ui/collections/edit_collection_ui.rs b/src/ui/radarr_ui/collections/edit_collection_ui.rs index 7827427..decb857 100644 --- a/src/ui/radarr_ui/collections/edit_collection_ui.rs +++ b/src/ui/radarr_ui/collections/edit_collection_ui.rs @@ -5,8 +5,7 @@ use ratatui::text::Text; use ratatui::widgets::{ListItem, Paragraph}; use ratatui::Frame; -use crate::app::context_clues::build_context_clue_string; -use crate::app::radarr::radarr_context_clues::CONFIRMATION_PROMPT_CONTEXT_CLUES; +use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; use crate::app::App; use crate::models::servarr_data::radarr::modals::EditCollectionModal; use crate::models::servarr_data::radarr::radarr_data::{ diff --git a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs index 9e681a1..085501c 100644 --- a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs +++ b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs @@ -1,7 +1,6 @@ use std::sync::atomic::Ordering; -use crate::app::context_clues::build_context_clue_string; -use crate::app::radarr::radarr_context_clues::CONFIRMATION_PROMPT_CONTEXT_CLUES; +use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; use crate::app::App; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_INDEXER_BLOCKS}; use crate::models::Route; diff --git a/src/ui/radarr_ui/indexers/indexer_settings_ui.rs b/src/ui/radarr_ui/indexers/indexer_settings_ui.rs index 34affd1..27907b3 100644 --- a/src/ui/radarr_ui/indexers/indexer_settings_ui.rs +++ b/src/ui/radarr_ui/indexers/indexer_settings_ui.rs @@ -5,8 +5,7 @@ use ratatui::text::Text; use ratatui::widgets::Paragraph; use ratatui::Frame; -use crate::app::context_clues::build_context_clue_string; -use crate::app::radarr::radarr_context_clues::CONFIRMATION_PROMPT_CONTEXT_CLUES; +use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; use crate::app::App; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, INDEXER_SETTINGS_BLOCKS, diff --git a/src/ui/radarr_ui/library/add_movie_ui.rs b/src/ui/radarr_ui/library/add_movie_ui.rs index 31009ec..11cd8a4 100644 --- a/src/ui/radarr_ui/library/add_movie_ui.rs +++ b/src/ui/radarr_ui/library/add_movie_ui.rs @@ -5,10 +5,10 @@ use ratatui::text::Text; use ratatui::widgets::{Cell, ListItem, Paragraph, Row}; use ratatui::Frame; -use crate::app::context_clues::{build_context_clue_string, BARE_POPUP_CONTEXT_CLUES}; -use crate::app::radarr::radarr_context_clues::{ - ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, +use crate::app::context_clues::{ + build_context_clue_string, BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, }; +use crate::app::radarr::radarr_context_clues::ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES; use crate::models::radarr_models::AddMovieSearchResult; use crate::models::servarr_data::radarr::modals::AddMovieModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ADD_MOVIE_BLOCKS}; diff --git a/src/ui/radarr_ui/library/edit_movie_ui.rs b/src/ui/radarr_ui/library/edit_movie_ui.rs index b923833..972fffe 100644 --- a/src/ui/radarr_ui/library/edit_movie_ui.rs +++ b/src/ui/radarr_ui/library/edit_movie_ui.rs @@ -6,8 +6,7 @@ use ratatui::text::Text; use ratatui::widgets::{ListItem, Paragraph}; use ratatui::Frame; -use crate::app::context_clues::build_context_clue_string; -use crate::app::radarr::radarr_context_clues::CONFIRMATION_PROMPT_CONTEXT_CLUES; +use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; use crate::app::App; use crate::models::servarr_data::radarr::modals::EditMovieModal; use crate::models::servarr_data::radarr::radarr_data::{ diff --git a/src/ui/sonarr_ui/library/add_series_ui.rs b/src/ui/sonarr_ui/library/add_series_ui.rs new file mode 100644 index 0000000..9469864 --- /dev/null +++ b/src/ui/sonarr_ui/library/add_series_ui.rs @@ -0,0 +1,486 @@ +use std::sync::atomic::Ordering; + +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::text::Text; +use ratatui::widgets::{Cell, ListItem, Paragraph, Row}; +use ratatui::Frame; + +use crate::app::context_clues::{ + build_context_clue_string, BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, +}; +use crate::app::sonarr::sonarr_context_clues::ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES; +use crate::models::servarr_data::sonarr::modals::AddSeriesModal; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS}; +use crate::models::sonarr_models::AddSeriesSearchResult; +use crate::models::{EnumDisplayStyle, Route}; +use crate::ui::sonarr_ui::library::draw_library; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{ + borderless_block, get_width_from_percentage, layout_block, layout_paragraph_borderless, + title_block_centered, +}; +use crate::ui::widgets::button::Button; +use crate::ui::widgets::checkbox::Checkbox; +use crate::ui::widgets::input_box::InputBox; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::widgets::selectable_list::SelectableList; +use crate::ui::{draw_popup_over, DrawUi}; +use crate::{render_selectable_input_box, App}; + +#[cfg(test)] +#[path = "add_series_ui_tests.rs"] +mod add_series_ui_tests; + +pub(super) struct AddSeriesUi; + +impl DrawUi for AddSeriesUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return ADD_SERIES_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let draw_add_series_search_popup = + |f: &mut Frame<'_>, app: &mut App<'_>, area: Rect| match active_sonarr_block { + ActiveSonarrBlock::AddSeriesSearchInput + | ActiveSonarrBlock::AddSeriesSearchResults + | ActiveSonarrBlock::AddSeriesEmptySearchResults => { + draw_add_series_search(f, app, area); + } + ActiveSonarrBlock::AddSeriesPrompt + | ActiveSonarrBlock::AddSeriesSelectMonitor + | ActiveSonarrBlock::AddSeriesSelectSeriesType + | ActiveSonarrBlock::AddSeriesSelectQualityProfile + | ActiveSonarrBlock::AddSeriesSelectLanguageProfile + | ActiveSonarrBlock::AddSeriesSelectRootFolder + | ActiveSonarrBlock::AddSeriesTagsInput => { + draw_popup_over( + f, + app, + area, + draw_add_series_search, + draw_confirmation_popup, + Size::Long, + ); + } + ActiveSonarrBlock::AddSeriesAlreadyInLibrary => { + draw_add_series_search(f, app, area); + f.render_widget( + Popup::new(Message::new("This film is already in your library")).size(Size::Message), + f.area(), + ); + } + _ => (), + }; + + match active_sonarr_block { + _ if ADD_SERIES_BLOCKS.contains(&active_sonarr_block) => draw_popup_over( + f, + app, + area, + draw_library, + draw_add_series_search_popup, + Size::Large, + ), + _ => (), + } + } + } +} + +fn draw_add_series_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let is_loading = app.is_loading || app.data.sonarr_data.add_searched_series.is_none(); + let current_selection = + if let Some(add_searched_series) = app.data.sonarr_data.add_searched_series.as_ref() { + add_searched_series.current_selection().clone() + } else { + AddSeriesSearchResult::default() + }; + + let [search_box_area, results_area, help_area] = Layout::vertical([ + Constraint::Length(3), + Constraint::Fill(0), + Constraint::Length(3), + ]) + .margin(1) + .areas(area); + let block_content = &app + .data + .sonarr_data + .add_series_search + .as_ref() + .unwrap() + .text; + let offset = app + .data + .sonarr_data + .add_series_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst); + let search_results_row_mapping = |series: &AddSeriesSearchResult| { + let rating = series.ratings.clone().unwrap_or_default().value; + let series_rating = if rating == 0.0 { + String::new() + } else { + format!("{rating:.1}") + }; + let in_library = if app + .data + .sonarr_data + .series + .items + .iter() + .any(|mov| mov.tvdb_id == series.tvdb_id) + { + "✔" + } else { + "" + }; + let network = series.network.clone().unwrap_or_default(); + let seasons = if let Some(ref stats) = series.statistics { + format!("{}", stats.season_count) + } else { + String::new() + }; + + series.title.scroll_left_or_reset( + get_width_from_percentage(area, 27), + *series == current_selection, + app.tick_count % app.ticks_until_scroll == 0, + ); + + Row::new(vec![ + Cell::from(in_library), + Cell::from(series.title.to_string()), + Cell::from(series.year.to_string()), + Cell::from(network), + Cell::from(series_rating), + Cell::from(seasons), + Cell::from(series.genres.join(", ")), + ]) + .primary() + }; + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + match active_sonarr_block { + ActiveSonarrBlock::AddSeriesSearchInput => { + let search_box = InputBox::new(block_content) + .offset(offset) + .block(title_block_centered("Add Series")); + let help_text = Text::from(build_context_clue_string(&BARE_POPUP_CONTEXT_CLUES).help()); + let help_paragraph = Paragraph::new(help_text) + .block(borderless_block()) + .centered(); + + search_box.show_cursor(f, search_box_area); + f.render_widget(layout_block(), results_area); + f.render_widget(search_box, search_box_area); + f.render_widget(help_paragraph, help_area); + } + ActiveSonarrBlock::AddSeriesEmptySearchResults => { + let help_text = Text::from(build_context_clue_string(&BARE_POPUP_CONTEXT_CLUES).help()); + let help_paragraph = Paragraph::new(help_text) + .block(borderless_block()) + .centered(); + let error_message = Message::new("No series found matching your query!"); + let error_message_popup = Popup::new(error_message).size(Size::Message); + + f.render_widget(layout_block(), results_area); + f.render_widget(error_message_popup, f.area()); + f.render_widget(help_paragraph, help_area); + } + ActiveSonarrBlock::AddSeriesSearchResults + | ActiveSonarrBlock::AddSeriesPrompt + | ActiveSonarrBlock::AddSeriesSelectMonitor + | ActiveSonarrBlock::AddSeriesSelectSeriesType + | ActiveSonarrBlock::AddSeriesSelectQualityProfile + | ActiveSonarrBlock::AddSeriesSelectLanguageProfile + | ActiveSonarrBlock::AddSeriesSelectRootFolder + | ActiveSonarrBlock::AddSeriesAlreadyInLibrary + | ActiveSonarrBlock::AddSeriesTagsInput => { + let help_text = + Text::from(build_context_clue_string(&ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES).help()); + let help_paragraph = Paragraph::new(help_text) + .block(borderless_block()) + .centered(); + let search_results_table = ManagarrTable::new( + app.data.sonarr_data.add_searched_series.as_mut(), + search_results_row_mapping, + ) + .loading(is_loading) + .block(layout_block()) + .headers([ + "✔", "Title", "Year", "Network", "Seasons", "Rating", "Genres", + ]) + .constraints([ + Constraint::Percentage(2), + Constraint::Percentage(27), + Constraint::Percentage(9), + Constraint::Percentage(13), + Constraint::Percentage(9), + Constraint::Percentage(9), + Constraint::Percentage(28), + ]); + + f.render_widget(search_results_table, results_area); + f.render_widget(help_paragraph, help_area); + } + _ => (), + } + } + + f.render_widget( + InputBox::new(block_content) + .offset(offset) + .block(title_block_centered("Add Series")), + search_box_area, + ); +} + +fn draw_confirmation_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + match active_sonarr_block { + ActiveSonarrBlock::AddSeriesSelectMonitor => { + draw_confirmation_prompt(f, app, area); + draw_add_series_select_monitor_popup(f, app); + } + ActiveSonarrBlock::AddSeriesSelectSeriesType => { + draw_confirmation_prompt(f, app, area); + draw_add_series_select_series_type_popup(f, app); + } + ActiveSonarrBlock::AddSeriesSelectQualityProfile => { + draw_confirmation_prompt(f, app, area); + draw_add_series_select_quality_profile_popup(f, app); + } + ActiveSonarrBlock::AddSeriesSelectLanguageProfile => { + draw_confirmation_prompt(f, app, area); + draw_add_series_select_language_profile_popup(f, app); + } + ActiveSonarrBlock::AddSeriesSelectRootFolder => { + draw_confirmation_prompt(f, app, area); + draw_add_series_select_root_folder_popup(f, app); + } + ActiveSonarrBlock::AddSeriesPrompt | ActiveSonarrBlock::AddSeriesTagsInput => { + draw_confirmation_prompt(f, app, area) + } + _ => (), + } + } +} + +fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let (series_title, series_overview) = ( + &app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .title + .text, + app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .overview + .clone() + .unwrap_or_default(), + ); + let title = format!("Add Series - {series_title}"); + let prompt = series_overview; + let yes_no_value = app.data.sonarr_data.prompt_confirm; + let selected_block = app.data.sonarr_data.selected_block.get_active_block(); + let highlight_yes_no = selected_block == ActiveSonarrBlock::AddSeriesConfirmPrompt; + let AddSeriesModal { + monitor_list, + series_type_list, + quality_profile_list, + language_profile_list, + root_folder_list, + use_season_folder, + tags, + .. + } = app.data.sonarr_data.add_series_modal.as_ref().unwrap(); + + let selected_monitor = monitor_list.current_selection(); + let selected_series_type = series_type_list.current_selection(); + let selected_quality_profile = quality_profile_list.current_selection(); + let selected_language_profile = language_profile_list.current_selection(); + let selected_root_folder = root_folder_list.current_selection(); + + f.render_widget(title_block_centered(&title), area); + + let [paragraph_area, root_folder_area, monitor_area, quality_profile_area, language_profile_area, series_type_area, season_folder_area, tags_area, _, buttons_area, help_area] = + Layout::vertical([ + Constraint::Length(7), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .margin(1) + .areas(area); + + let prompt_paragraph = layout_paragraph_borderless(&prompt); + let help_text = Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help()); + let help_paragraph = Paragraph::new(help_text).centered(); + f.render_widget(prompt_paragraph, paragraph_area); + f.render_widget(help_paragraph, help_area); + + let [add_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(buttons_area); + + let use_season_folder_checkbox = Checkbox::new("Season Folder") + .checked(*use_season_folder) + .highlighted(selected_block == ActiveSonarrBlock::AddSeriesToggleUseSeasonFolder); + let root_folder_drop_down_button = Button::new() + .title(&selected_root_folder.path) + .label("Root Folder") + .icon("▼") + .selected(selected_block == ActiveSonarrBlock::AddSeriesSelectRootFolder); + let monitor_drop_down_button = Button::new() + .title(selected_monitor.to_display_str()) + .label("Monitor") + .icon("▼") + .selected(selected_block == ActiveSonarrBlock::AddSeriesSelectMonitor); + let series_type_drop_down_button = Button::new() + .title(selected_series_type.to_display_str()) + .label("Series Type") + .icon("▼") + .selected(selected_block == ActiveSonarrBlock::AddSeriesSelectSeriesType); + let quality_profile_drop_down_button = Button::new() + .title(selected_quality_profile) + .label("Quality Profile") + .icon("▼") + .selected(selected_block == ActiveSonarrBlock::AddSeriesSelectQualityProfile); + let language_profile_drop_down_button = Button::new() + .title(selected_language_profile) + .label("Language Profile") + .icon("▼") + .selected(selected_block == ActiveSonarrBlock::AddSeriesSelectLanguageProfile); + + f.render_widget(root_folder_drop_down_button, root_folder_area); + f.render_widget(monitor_drop_down_button, monitor_area); + f.render_widget(quality_profile_drop_down_button, quality_profile_area); + f.render_widget(language_profile_drop_down_button, language_profile_area); + f.render_widget(series_type_drop_down_button, series_type_area); + f.render_widget(use_season_folder_checkbox, season_folder_area); + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let tags_input_box = InputBox::new(&tags.text) + .offset(tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(selected_block == ActiveSonarrBlock::AddSeriesTagsInput) + .selected(active_sonarr_block == ActiveSonarrBlock::AddSeriesTagsInput); + render_selectable_input_box!(tags_input_box, f, tags_area); + } + + let add_button = Button::new() + .title("Add") + .selected(yes_no_value && highlight_yes_no); + let cancel_button = Button::new() + .title("Cancel") + .selected(!yes_no_value && highlight_yes_no); + + f.render_widget(add_button, add_area); + f.render_widget(cancel_button, cancel_area); +} + +fn draw_add_series_select_monitor_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let monitor_list = SelectableList::new( + &mut app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .monitor_list, + |monitor| ListItem::new(monitor.to_display_str().to_owned()), + ); + let popup = Popup::new(monitor_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_series_select_series_type_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let series_type_list = SelectableList::new( + &mut app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .series_type_list, + |series_type| ListItem::new(series_type.to_display_str().to_owned()), + ); + let popup = Popup::new(series_type_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_series_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let quality_profile_list = SelectableList::new( + &mut app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .quality_profile_list, + |quality_profile| ListItem::new(quality_profile.clone()), + ); + let popup = Popup::new(quality_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_series_select_language_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let language_profile_list = SelectableList::new( + &mut app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .language_profile_list, + |language_profile| ListItem::new(language_profile.clone()), + ); + let popup = Popup::new(language_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_series_select_root_folder_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let root_folder_list = SelectableList::new( + &mut app + .data + .sonarr_data + .add_series_modal + .as_mut() + .unwrap() + .root_folder_list, + |root_folder| ListItem::new(root_folder.path.to_owned()), + ); + let popup = Popup::new(root_folder_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} diff --git a/src/ui/sonarr_ui/library/add_series_ui_tests.rs b/src/ui/sonarr_ui/library/add_series_ui_tests.rs new file mode 100644 index 0000000..6bd15cd --- /dev/null +++ b/src/ui/sonarr_ui/library/add_series_ui_tests.rs @@ -0,0 +1,19 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS}; + use crate::ui::sonarr_ui::library::add_series_ui::AddSeriesUi; + use crate::ui::DrawUi; + + #[test] + fn test_add_series_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if ADD_SERIES_BLOCKS.contains(&active_sonarr_block) { + assert!(AddSeriesUi::accepts(active_sonarr_block.into())); + } else { + assert!(!AddSeriesUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs index c94ea65..a94e582 100644 --- a/src/ui/sonarr_ui/library/library_ui_tests.rs +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -1,6 +1,8 @@ #[cfg(test)] mod tests { - use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DELETE_SERIES_BLOCKS}; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, + }; use crate::models::{ servarr_data::sonarr::sonarr_data::LIBRARY_BLOCKS, sonarr_models::SeriesStatus, }; @@ -21,6 +23,7 @@ mod tests { fn test_library_ui_accepts() { let mut library_ui_blocks = Vec::new(); library_ui_blocks.extend(LIBRARY_BLOCKS); + library_ui_blocks.extend(ADD_SERIES_BLOCKS); library_ui_blocks.extend(DELETE_SERIES_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_radarr_block| { diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index e25027f..1dc6c6a 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -1,3 +1,4 @@ +use add_series_ui::AddSeriesUi; use delete_series_ui::DeleteSeriesUi; use ratatui::{ layout::{Constraint, Rect}, @@ -26,6 +27,7 @@ use crate::{ }, }; +mod add_series_ui; mod delete_series_ui; #[cfg(test)] @@ -37,7 +39,9 @@ pub(super) struct LibraryUi; impl DrawUi for LibraryUi { fn accepts(route: Route) -> bool { if let Route::Sonarr(active_sonarr_block, _) = route { - return DeleteSeriesUi::accepts(route) || LIBRARY_BLOCKS.contains(&active_sonarr_block); + return AddSeriesUi::accepts(route) + || DeleteSeriesUi::accepts(route) + || LIBRARY_BLOCKS.contains(&active_sonarr_block); } false @@ -90,6 +94,7 @@ impl DrawUi for LibraryUi { }; match route { + _ if AddSeriesUi::accepts(route) => AddSeriesUi::draw(f, app, area), _ if DeleteSeriesUi::accepts(route) => DeleteSeriesUi::draw(f, app, area), Route::Sonarr(active_sonarr_block, _) if LIBRARY_BLOCKS.contains(&active_sonarr_block) => { series_ui_matchers(active_sonarr_block) diff --git a/src/ui/widgets/confirmation_prompt.rs b/src/ui/widgets/confirmation_prompt.rs index 521f638..911bcaa 100644 --- a/src/ui/widgets/confirmation_prompt.rs +++ b/src/ui/widgets/confirmation_prompt.rs @@ -1,5 +1,4 @@ -use crate::app::context_clues::build_context_clue_string; -use crate::app::radarr::radarr_context_clues::CONFIRMATION_PROMPT_CONTEXT_CLUES; +use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{layout_paragraph_borderless, title_block_centered}; use crate::ui::widgets::button::Button; diff --git a/src/ui/widgets/popup.rs b/src/ui/widgets/popup.rs index 0716918..4607cf0 100644 --- a/src/ui/widgets/popup.rs +++ b/src/ui/widgets/popup.rs @@ -21,6 +21,7 @@ pub enum Size { Small, Medium, Large, + Long, } impl Size { @@ -37,6 +38,7 @@ impl Size { Size::Small => (40, 40), Size::Medium => (60, 60), Size::Large => (75, 75), + Size::Long => (65, 80), } } } diff --git a/src/ui/widgets/popup_tests.rs b/src/ui/widgets/popup_tests.rs index 8b68cde..ea18bfe 100644 --- a/src/ui/widgets/popup_tests.rs +++ b/src/ui/widgets/popup_tests.rs @@ -17,6 +17,7 @@ mod tests { assert_eq!(Size::Small.to_percent(), (40, 40)); assert_eq!(Size::Medium.to_percent(), (60, 60)); assert_eq!(Size::Large.to_percent(), (75, 75)); + assert_eq!(Size::Long.to_percent(), (65, 80)); } #[test] From adb1f07fd077a2f40a0ce73c15ba4c4c5b17b1f2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 14:58:51 -0700 Subject: [PATCH 13/82] feat(handler): Edit series support --- .../library/edit_movie_handler_tests.rs | 15 +- .../library/edit_series_handler.rs | 408 +++++ .../library/edit_series_handler_tests.rs | 1322 +++++++++++++++++ .../library/library_handler_tests.rs | 38 +- src/handlers/sonarr_handlers/library/mod.rs | 7 + 5 files changed, 1762 insertions(+), 28 deletions(-) create mode 100644 src/handlers/sonarr_handlers/library/edit_series_handler.rs create mode 100644 src/handlers/sonarr_handlers/library/edit_series_handler_tests.rs diff --git a/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs b/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs index c63c2a7..8835d13 100644 --- a/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs @@ -535,9 +535,7 @@ mod tests { use rstest::rstest; use crate::models::servarr_data::radarr::modals::EditMovieModal; - use crate::models::servarr_data::radarr::radarr_data::{ - EDIT_COLLECTION_SELECTION_BLOCKS, EDIT_MOVIE_SELECTION_BLOCKS, - }; + use crate::models::servarr_data::radarr::radarr_data::EDIT_MOVIE_SELECTION_BLOCKS; use crate::models::{BlockSelectionState, Route}; use crate::network::radarr_network::RadarrEvent; @@ -626,7 +624,7 @@ mod tests { .data .radarr_data .selected_block - .set_index(0, EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); + .set_index(0, EDIT_MOVIE_SELECTION_BLOCKS.len() - 1); EditMovieHandler::with( SUBMIT_KEY, @@ -652,7 +650,7 @@ mod tests { .data .radarr_data .selected_block - .set_index(0, EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); + .set_index(0, EDIT_MOVIE_SELECTION_BLOCKS.len() - 1); EditMovieHandler::with( SUBMIT_KEY, @@ -936,10 +934,7 @@ mod tests { use super::*; use crate::{ models::{ - servarr_data::radarr::{ - modals::EditMovieModal, - radarr_data::{EDIT_COLLECTION_SELECTION_BLOCKS, EDIT_MOVIE_SELECTION_BLOCKS}, - }, + servarr_data::radarr::{modals::EditMovieModal, radarr_data::EDIT_MOVIE_SELECTION_BLOCKS}, BlockSelectionState, }, network::radarr_network::RadarrEvent, @@ -1066,7 +1061,7 @@ mod tests { .data .radarr_data .selected_block - .set_index(0, EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1); + .set_index(0, EDIT_MOVIE_SELECTION_BLOCKS.len() - 1); EditMovieHandler::with( DEFAULT_KEYBINDINGS.confirm.key, diff --git a/src/handlers/sonarr_handlers/library/edit_series_handler.rs b/src/handlers/sonarr_handlers/library/edit_series_handler.rs new file mode 100644 index 0000000..0a13078 --- /dev/null +++ b/src/handlers/sonarr_handlers/library/edit_series_handler.rs @@ -0,0 +1,408 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::App; +use crate::event::Key; +use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_SERIES_BLOCKS}; +use crate::models::Scrollable; +use crate::network::sonarr_network::SonarrEvent; +use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; + +#[cfg(test)] +#[path = "edit_series_handler_tests.rs"] +mod edit_series_handler_tests; + +pub(super) struct EditSeriesHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EditSeriesHandler<'a, 'b> { + fn accepts(active_block: ActiveSonarrBlock) -> bool { + EDIT_SERIES_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + _context: Option, + ) -> EditSeriesHandler<'a, 'b> { + EditSeriesHandler { + key, + app, + active_sonarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && self.app.data.sonarr_data.edit_series_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EditSeriesSelectSeriesType => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .series_type_list + .scroll_up(), + ActiveSonarrBlock::EditSeriesSelectQualityProfile => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_up(), + ActiveSonarrBlock::EditSeriesSelectLanguageProfile => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .language_profile_list + .scroll_up(), + ActiveSonarrBlock::EditSeriesPrompt => self.app.data.sonarr_data.selected_block.up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EditSeriesSelectSeriesType => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .series_type_list + .scroll_down(), + ActiveSonarrBlock::EditSeriesSelectQualityProfile => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_down(), + ActiveSonarrBlock::EditSeriesSelectLanguageProfile => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .language_profile_list + .scroll_down(), + ActiveSonarrBlock::EditSeriesPrompt => self.app.data.sonarr_data.selected_block.down(), + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EditSeriesSelectSeriesType => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .series_type_list + .scroll_to_top(), + ActiveSonarrBlock::EditSeriesSelectQualityProfile => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_top(), + ActiveSonarrBlock::EditSeriesSelectLanguageProfile => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .language_profile_list + .scroll_to_top(), + ActiveSonarrBlock::EditSeriesPathInput => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .path + .scroll_home(), + ActiveSonarrBlock::EditSeriesTagsInput => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .tags + .scroll_home(), + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EditSeriesSelectSeriesType => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .series_type_list + .scroll_to_bottom(), + ActiveSonarrBlock::EditSeriesSelectQualityProfile => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_bottom(), + ActiveSonarrBlock::EditSeriesSelectLanguageProfile => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .language_profile_list + .scroll_to_bottom(), + ActiveSonarrBlock::EditSeriesPathInput => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .path + .reset_offset(), + ActiveSonarrBlock::EditSeriesTagsInput => self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .tags + .reset_offset(), + _ => (), + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EditSeriesPrompt => handle_prompt_toggle(self.app, self.key), + ActiveSonarrBlock::EditSeriesPathInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .path + ) + } + ActiveSonarrBlock::EditSeriesTagsInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .tags + ) + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EditSeriesPrompt => { + match self.app.data.sonarr_data.selected_block.get_active_block() { + ActiveSonarrBlock::EditSeriesConfirmPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::EditSeries(None)); + self.app.should_refresh = true; + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::EditSeriesSelectSeriesType + | ActiveSonarrBlock::EditSeriesSelectQualityProfile + | ActiveSonarrBlock::EditSeriesSelectLanguageProfile => self.app.push_navigation_stack( + self + .app + .data + .sonarr_data + .selected_block + .get_active_block() + .into(), + ), + ActiveSonarrBlock::EditSeriesPathInput | ActiveSonarrBlock::EditSeriesTagsInput => { + self.app.push_navigation_stack( + self + .app + .data + .sonarr_data + .selected_block + .get_active_block() + .into(), + ); + self.app.should_ignore_quit_key = true; + } + ActiveSonarrBlock::EditSeriesToggleMonitored => { + self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .monitored = Some( + !self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .monitored + .unwrap_or_default(), + ) + } + ActiveSonarrBlock::EditSeriesToggleSeasonFolder => { + self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .use_season_folders = Some( + !self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .use_season_folders + .unwrap_or_default(), + ) + } + _ => (), + } + } + ActiveSonarrBlock::EditSeriesSelectSeriesType + | ActiveSonarrBlock::EditSeriesSelectQualityProfile + | ActiveSonarrBlock::EditSeriesSelectLanguageProfile => self.app.pop_navigation_stack(), + ActiveSonarrBlock::EditSeriesPathInput | ActiveSonarrBlock::EditSeriesTagsInput => { + self.app.pop_navigation_stack(); + self.app.should_ignore_quit_key = false; + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EditSeriesTagsInput | ActiveSonarrBlock::EditSeriesPathInput => { + self.app.pop_navigation_stack(); + self.app.should_ignore_quit_key = false; + } + ActiveSonarrBlock::EditSeriesPrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.edit_series_modal = None; + self.app.data.sonarr_data.prompt_confirm = false; + } + ActiveSonarrBlock::EditSeriesSelectSeriesType + | ActiveSonarrBlock::EditSeriesSelectQualityProfile + | ActiveSonarrBlock::EditSeriesSelectLanguageProfile => self.app.pop_navigation_stack(), + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_sonarr_block { + ActiveSonarrBlock::EditSeriesPathInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .path + ) + } + ActiveSonarrBlock::EditSeriesTagsInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .tags + ) + } + ActiveSonarrBlock::EditSeriesPrompt => { + if self.app.data.sonarr_data.selected_block.get_active_block() + == ActiveSonarrBlock::EditSeriesConfirmPrompt + && key == DEFAULT_KEYBINDINGS.confirm.key + { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::EditSeries(None)); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } +} diff --git a/src/handlers/sonarr_handlers/library/edit_series_handler_tests.rs b/src/handlers/sonarr_handlers/library/edit_series_handler_tests.rs new file mode 100644 index 0000000..4a8743a --- /dev/null +++ b/src/handlers/sonarr_handlers/library/edit_series_handler_tests.rs @@ -0,0 +1,1322 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_str_eq; + use strum::IntoEnumIterator; + + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::library::edit_series_handler::EditSeriesHandler; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::modals::EditSeriesModal; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_SERIES_BLOCKS}; + use crate::models::sonarr_models::SeriesType; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::modals::EditSeriesModal; + use crate::models::servarr_data::sonarr::sonarr_data::EDIT_SERIES_SELECTION_BLOCKS; + use crate::models::BlockSelectionState; + + use super::*; + + #[rstest] + fn test_edit_series_select_series_type_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let series_type_vec = Vec::from_iter(SeriesType::iter()); + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .series_type_list + .set_items(series_type_vec.clone()); + + if key == Key::Up { + for i in (0..series_type_vec.len()).rev() { + EditSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::EditSeriesSelectSeriesType, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .series_type_list + .current_selection(), + &series_type_vec[i] + ); + } + } else { + for i in 0..series_type_vec.len() { + EditSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::EditSeriesSelectSeriesType, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .series_type_list + .current_selection(), + &series_type_vec[(i + 1) % series_type_vec.len()] + ); + } + } + } + + #[rstest] + fn test_edit_series_select_quality_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); + + EditSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::EditSeriesSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 2" + ); + + EditSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::EditSeriesSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 1" + ); + } + + #[rstest] + fn test_edit_series_select_language_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .language_profile_list + .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); + + EditSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::EditSeriesSelectLanguageProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .language_profile_list + .current_selection(), + "Test 2" + ); + + EditSeriesHandler::with( + key, + &mut app, + ActiveSonarrBlock::EditSeriesSelectLanguageProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .language_profile_list + .current_selection(), + "Test 1" + ); + } + + #[rstest] + fn test_edit_series_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.down(); + + EditSeriesHandler::with(key, &mut app, ActiveSonarrBlock::EditSeriesPrompt, None).handle(); + + if key == Key::Up { + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::EditSeriesToggleMonitored + ); + } else { + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::EditSeriesSelectQualityProfile + ); + } + } + + #[rstest] + fn test_edit_series_prompt_scroll_no_op_when_not_ready(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.is_loading = true; + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.down(); + + EditSeriesHandler::with(key, &mut app, ActiveSonarrBlock::EditSeriesPrompt, None).handle(); + + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::EditSeriesToggleSeasonFolder + ); + } + } + + mod test_handle_home_end { + use std::sync::atomic::Ordering; + + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::modals::EditSeriesModal; + + use super::*; + + #[test] + fn test_edit_series_select_series_type_home_end() { + let series_type_vec = Vec::from_iter(SeriesType::iter()); + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .series_type_list + .set_items(series_type_vec.clone()); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::EditSeriesSelectSeriesType, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .series_type_list + .current_selection(), + &series_type_vec[series_type_vec.len() - 1] + ); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::EditSeriesSelectSeriesType, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .series_type_list + .current_selection(), + &series_type_vec[0] + ); + } + + #[test] + fn test_edit_series_select_quality_profile_scroll() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec![ + "Test 1".to_owned(), + "Test 2".to_owned(), + "Test 3".to_owned(), + ]); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::EditSeriesSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 3" + ); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::EditSeriesSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 1" + ); + } + + #[test] + fn test_edit_series_select_language_profile_scroll() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .language_profile_list + .set_items(vec![ + "Test 1".to_owned(), + "Test 2".to_owned(), + "Test 3".to_owned(), + ]); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::EditSeriesSelectLanguageProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .language_profile_list + .current_selection(), + "Test 3" + ); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::EditSeriesSelectLanguageProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .language_profile_list + .current_selection(), + "Test 1" + ); + } + + #[test] + fn test_edit_series_path_input_home_end_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal { + path: "Test".into(), + ..EditSeriesModal::default() + }); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::EditSeriesPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::EditSeriesPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_series_tags_input_home_end_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal { + tags: "Test".into(), + ..EditSeriesModal::default() + }); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::EditSeriesTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::EditSeriesTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_left_right_action { + use std::sync::atomic::Ordering; + + use crate::models::servarr_data::sonarr::modals::EditSeriesModal; + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + + EditSeriesHandler::with(key, &mut app, ActiveSonarrBlock::EditSeriesPrompt, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + EditSeriesHandler::with(key, &mut app, ActiveSonarrBlock::EditSeriesPrompt, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[test] + fn test_edit_series_path_input_left_right_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal { + path: "Test".into(), + ..EditSeriesModal::default() + }); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::EditSeriesPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::EditSeriesPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_series_tags_input_left_right_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal { + tags: "Test".into(), + ..EditSeriesModal::default() + }); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::EditSeriesTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::EditSeriesTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::servarr_data::sonarr::modals::EditSeriesModal; + use crate::models::servarr_data::sonarr::sonarr_data::EDIT_SERIES_SELECTION_BLOCKS; + use crate::models::BlockSelectionState; + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_edit_series_path_input_submit() { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal { + path: "Test Path".into(), + ..EditSeriesModal::default() + }); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPathInput.into()); + + EditSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditSeriesPathInput, + None, + ) + .handle(); + + assert!(!app.should_ignore_quit_key); + assert!(!app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .path + .text + .is_empty()); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditSeriesPrompt.into() + ); + } + + #[test] + fn test_edit_series_tags_input_submit() { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal { + tags: "Test Tags".into(), + ..EditSeriesModal::default() + }); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPathInput.into()); + + EditSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditSeriesTagsInput, + None, + ) + .handle(); + + assert!(!app.should_ignore_quit_key); + assert!(!app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .tags + .text + .is_empty()); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditSeriesPrompt.into() + ); + } + + #[test] + fn test_edit_series_prompt_prompt_decline_submit() { + let mut app = App::default(); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(0, EDIT_SERIES_SELECTION_BLOCKS.len() - 1); + + EditSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditSeriesPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + } + + #[test] + fn test_edit_series_confirm_prompt_prompt_confirmation_submit() { + let mut app = App::default(); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + app.data.sonarr_data.prompt_confirm = true; + app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(0, EDIT_SERIES_SELECTION_BLOCKS.len() - 1); + + EditSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditSeriesPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::EditSeries(None)) + ); + assert!(app.data.sonarr_data.edit_series_modal.is_some()); + assert!(app.should_refresh); + } + + #[test] + fn test_edit_series_confirm_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + app.data.sonarr_data.prompt_confirm = true; + + EditSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditSeriesPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditSeriesPrompt.into() + ); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + assert!(!app.should_refresh); + } + + #[test] + fn test_edit_series_toggle_monitored_submit() { + let mut app = App::default(); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + + EditSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditSeriesPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditSeriesPrompt.into() + ); + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .monitored, + Some(true) + ); + + EditSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditSeriesPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditSeriesPrompt.into() + ); + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .monitored, + Some(false) + ); + } + + #[test] + fn test_edit_series_toggle_use_season_folders_submit() { + let mut app = App::default(); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.set_index(0, 1); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + + EditSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditSeriesPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditSeriesPrompt.into() + ); + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .use_season_folders, + Some(true) + ); + + EditSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditSeriesPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditSeriesPrompt.into() + ); + assert_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .use_season_folders, + Some(false) + ); + } + + #[rstest] + #[case(ActiveSonarrBlock::EditSeriesSelectQualityProfile, 2)] + #[case(ActiveSonarrBlock::EditSeriesSelectLanguageProfile, 3)] + #[case(ActiveSonarrBlock::EditSeriesSelectSeriesType, 4)] + #[case(ActiveSonarrBlock::EditSeriesPathInput, 5)] + #[case(ActiveSonarrBlock::EditSeriesTagsInput, 6)] + fn test_edit_series_prompt_selected_block_submit( + #[case] selected_block: ActiveSonarrBlock, + #[case] y_index: usize, + ) { + let mut app = App::default(); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.set_index(0, y_index); + + EditSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditSeriesPrompt, + Some(ActiveSonarrBlock::Series), + ) + .handle(); + + assert_eq!(app.get_current_route(), selected_block.into()); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + + if selected_block == ActiveSonarrBlock::EditSeriesPathInput + || selected_block == ActiveSonarrBlock::EditSeriesTagsInput + { + assert!(app.should_ignore_quit_key); + } + } + + #[rstest] + fn test_edit_series_prompt_selected_block_submit_no_op_when_not_ready( + #[values(1, 2, 3, 4)] y_index: usize, + ) { + let mut app = App::default(); + app.is_loading = true; + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.set_index(0, y_index); + + EditSeriesHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditSeriesPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditSeriesPrompt.into() + ); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + assert!(!app.should_ignore_quit_key); + } + + #[rstest] + fn test_edit_series_prompt_selecting_preferences_blocks_submit( + #[values( + ActiveSonarrBlock::EditSeriesSelectSeriesType, + ActiveSonarrBlock::EditSeriesSelectQualityProfile, + ActiveSonarrBlock::EditSeriesSelectLanguageProfile, + ActiveSonarrBlock::EditSeriesPathInput, + ActiveSonarrBlock::EditSeriesTagsInput + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + app.push_navigation_stack(active_sonarr_block.into()); + + EditSeriesHandler::with(SUBMIT_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditSeriesPrompt.into() + ); + + if active_sonarr_block == ActiveSonarrBlock::EditSeriesPathInput + || active_sonarr_block == ActiveSonarrBlock::EditSeriesTagsInput + { + assert!(!app.should_ignore_quit_key); + } + } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::servarr_data::sonarr::modals::EditSeriesModal; + use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_edit_series_input_esc( + #[values( + ActiveSonarrBlock::EditSeriesTagsInput, + ActiveSonarrBlock::EditSeriesPathInput + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + app.push_navigation_stack(active_sonarr_block.into()); + + EditSeriesHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert!(!app.should_ignore_quit_key); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditSeriesPrompt.into() + ); + } + + #[test] + fn test_edit_series_prompt_esc() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + + EditSeriesHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::EditSeriesPrompt, None) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + + assert!(app.data.sonarr_data.edit_series_modal.is_none()); + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[rstest] + fn test_edit_series_esc( + #[values( + ActiveSonarrBlock::EditSeriesSelectSeriesType, + ActiveSonarrBlock::EditSeriesSelectQualityProfile, + ActiveSonarrBlock::EditSeriesSelectLanguageProfile + )] + active_sonarr_block: ActiveSonarrBlock, + #[values(true, false)] is_ready: bool, + ) { + let mut app = App::default(); + app.is_loading = is_ready; + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(active_sonarr_block.into()); + + EditSeriesHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + } + } + + mod test_handle_key_char { + use super::*; + use crate::{ + models::{ + servarr_data::sonarr::{ + modals::EditSeriesModal, sonarr_data::EDIT_SERIES_SELECTION_BLOCKS, + }, + BlockSelectionState, + }, + network::sonarr_network::SonarrEvent, + }; + + #[test] + fn test_edit_series_path_input_backspace() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal { + path: "Test".into(), + ..EditSeriesModal::default() + }); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::EditSeriesPathInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .path + .text, + "Tes" + ); + } + + #[test] + fn test_edit_series_tags_input_backspace() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal { + tags: "Test".into(), + ..EditSeriesModal::default() + }); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::EditSeriesTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .tags + .text, + "Tes" + ); + } + + #[test] + fn test_edit_series_path_input_char_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + + EditSeriesHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::EditSeriesPathInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .path + .text, + "h" + ); + } + + #[test] + fn test_edit_series_tags_input_char_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + + EditSeriesHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::EditSeriesTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .tags + .text, + "h" + ); + } + + #[test] + fn test_edit_series_confirm_prompt_prompt_confirm() { + let mut app = App::default(); + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(0, EDIT_SERIES_SELECTION_BLOCKS.len() - 1); + + EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveSonarrBlock::EditSeriesPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::EditSeries(None)) + ); + assert!(app.data.sonarr_data.edit_series_modal.is_some()); + assert!(app.should_refresh); + } + } + + #[test] + fn test_edit_series_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if EDIT_SERIES_BLOCKS.contains(&active_sonarr_block) { + assert!(EditSeriesHandler::accepts(active_sonarr_block)); + } else { + assert!(!EditSeriesHandler::accepts(active_sonarr_block)); + } + }); + } + + #[test] + fn test_edit_series_handler_is_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.is_loading = true; + + let handler = EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::EditSeriesPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_series_handler_is_not_ready_when_edit_series_modal_is_none() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.is_loading = false; + + let handler = EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::EditSeriesPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_series_handler_is_ready_when_edit_series_modal_is_some() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.is_loading = false; + app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); + + let handler = EditSeriesHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::EditSeriesPrompt, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/sonarr_handlers/library/library_handler_tests.rs b/src/handlers/sonarr_handlers/library/library_handler_tests.rs index 207d0cb..d3d1fae 100644 --- a/src/handlers/sonarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/library_handler_tests.rs @@ -12,7 +12,7 @@ mod tests { use crate::handlers::sonarr_handlers::library::{series_sorting_options, LibraryHandler}; use crate::handlers::KeyEventHandler; use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, LIBRARY_BLOCKS, + ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, LIBRARY_BLOCKS, }; use crate::models::sonarr_models::{Series, SeriesStatus, SeriesType}; use crate::models::stateful_table::SortOption; @@ -1487,23 +1487,24 @@ mod tests { // ); // } - // #[rstest] - // fn test_delegates_edit_series_blocks_to_edit_series_handler( - // #[values( - // ActiveSonarrBlock::EditSeriesPrompt, - // ActiveSonarrBlock::EditSeriesPathInput, - // ActiveSonarrBlock::EditSeriesSelectMinimumAvailability, - // ActiveSonarrBlock::EditSeriesSelectQualityProfile, - // ActiveSonarrBlock::EditSeriesTagsInput - // )] - // active_sonarr_block: ActiveSonarrBlock, - // ) { - // test_handler_delegation!( - // LibraryHandler, - // ActiveSonarrBlock::Series, - // active_sonarr_block - // ); - // } + #[rstest] + fn test_delegates_edit_series_blocks_to_edit_series_handler( + #[values( + ActiveSonarrBlock::EditSeriesPrompt, + ActiveSonarrBlock::EditSeriesPathInput, + ActiveSonarrBlock::EditSeriesSelectSeriesType, + ActiveSonarrBlock::EditSeriesSelectQualityProfile, + ActiveSonarrBlock::EditSeriesSelectLanguageProfile, + ActiveSonarrBlock::EditSeriesTagsInput + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + LibraryHandler, + ActiveSonarrBlock::Series, + active_sonarr_block + ); + } #[test] fn test_delegates_delete_series_blocks_to_delete_series_handler() { @@ -1709,6 +1710,7 @@ mod tests { library_handler_blocks.extend(LIBRARY_BLOCKS); library_handler_blocks.extend(ADD_SERIES_BLOCKS); library_handler_blocks.extend(DELETE_SERIES_BLOCKS); + library_handler_blocks.extend(EDIT_SERIES_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { if library_handler_blocks.contains(&active_sonarr_block) { diff --git a/src/handlers/sonarr_handlers/library/mod.rs b/src/handlers/sonarr_handlers/library/mod.rs index fee9089..47ff6ea 100644 --- a/src/handlers/sonarr_handlers/library/mod.rs +++ b/src/handlers/sonarr_handlers/library/mod.rs @@ -1,5 +1,7 @@ use add_series_handler::AddSeriesHandler; +mod edit_series_handler; use delete_series_handler::DeleteSeriesHandler; +use edit_series_handler::EditSeriesHandler; use crate::{ app::App, @@ -45,6 +47,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' DeleteSeriesHandler::with(self.key, self.app, self.active_sonarr_block, self.context) .handle(); } + _ if EditSeriesHandler::accepts(self.active_sonarr_block) => { + EditSeriesHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle(); + } _ => self.handle_key_event(), } } @@ -52,6 +58,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' fn accepts(active_block: ActiveSonarrBlock) -> bool { AddSeriesHandler::accepts(active_block) || DeleteSeriesHandler::accepts(active_block) + || EditSeriesHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block) } From 188d781b0dd784c7484ca8331ab6042598a73e88 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 15:31:12 -0700 Subject: [PATCH 14/82] feat(ui): Edit series support --- .../library/edit_series_handler.rs | 26 +- .../library/edit_series_handler_tests.rs | 79 ++++-- src/ui/sonarr_ui/library/add_series_ui.rs | 2 +- src/ui/sonarr_ui/library/edit_series_ui.rs | 256 ++++++++++++++++++ .../sonarr_ui/library/edit_series_ui_tests.rs | 19 ++ src/ui/sonarr_ui/library/library_ui_tests.rs | 3 +- src/ui/sonarr_ui/library/mod.rs | 4 + src/ui/widgets/popup.rs | 2 +- src/ui/widgets/popup_tests.rs | 2 +- 9 files changed, 345 insertions(+), 48 deletions(-) create mode 100644 src/ui/sonarr_ui/library/edit_series_ui.rs create mode 100644 src/ui/sonarr_ui/library/edit_series_ui_tests.rs diff --git a/src/handlers/sonarr_handlers/library/edit_series_handler.rs b/src/handlers/sonarr_handlers/library/edit_series_handler.rs index 0a13078..dbab8c8 100644 --- a/src/handlers/sonarr_handlers/library/edit_series_handler.rs +++ b/src/handlers/sonarr_handlers/library/edit_series_handler.rs @@ -15,7 +15,7 @@ pub(super) struct EditSeriesHandler<'a, 'b> { key: Key, app: &'a mut App<'b>, active_sonarr_block: ActiveSonarrBlock, - _context: Option, + context: Option, } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EditSeriesHandler<'a, 'b> { @@ -27,13 +27,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EditSeriesHandler<'a key: Key, app: &'a mut App<'b>, active_block: ActiveSonarrBlock, - _context: Option, + context: Option, ) -> EditSeriesHandler<'a, 'b> { EditSeriesHandler { key, app, active_sonarr_block: active_block, - _context, + context, } } @@ -267,22 +267,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EditSeriesHandler<'a ActiveSonarrBlock::EditSeriesSelectSeriesType | ActiveSonarrBlock::EditSeriesSelectQualityProfile | ActiveSonarrBlock::EditSeriesSelectLanguageProfile => self.app.push_navigation_stack( - self - .app - .data - .sonarr_data - .selected_block - .get_active_block() + ( + self.app.data.sonarr_data.selected_block.get_active_block(), + self.context, + ) .into(), ), ActiveSonarrBlock::EditSeriesPathInput | ActiveSonarrBlock::EditSeriesTagsInput => { self.app.push_navigation_stack( - self - .app - .data - .sonarr_data - .selected_block - .get_active_block() + ( + self.app.data.sonarr_data.selected_block.get_active_block(), + self.context, + ) .into(), ); self.app.should_ignore_quit_key = true; diff --git a/src/handlers/sonarr_handlers/library/edit_series_handler_tests.rs b/src/handlers/sonarr_handlers/library/edit_series_handler_tests.rs index 4a8743a..6b73c63 100644 --- a/src/handlers/sonarr_handlers/library/edit_series_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/edit_series_handler_tests.rs @@ -663,7 +663,7 @@ mod tests { use crate::models::servarr_data::sonarr::modals::EditSeriesModal; use crate::models::servarr_data::sonarr::sonarr_data::EDIT_SERIES_SELECTION_BLOCKS; - use crate::models::BlockSelectionState; + use crate::models::{BlockSelectionState, Route}; use crate::network::sonarr_network::SonarrEvent; use super::*; @@ -825,24 +825,25 @@ mod tests { #[test] fn test_edit_series_toggle_monitored_submit() { + let current_route = Route::from(( + ActiveSonarrBlock::EditSeriesPrompt, + Some(ActiveSonarrBlock::Series), + )); let mut app = App::default(); app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + app.push_navigation_stack(current_route); EditSeriesHandler::with( SUBMIT_KEY, &mut app, ActiveSonarrBlock::EditSeriesPrompt, - None, + Some(ActiveSonarrBlock::Series), ) .handle(); - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::EditSeriesPrompt.into() - ); + assert_eq!(app.get_current_route(), current_route); assert_eq!( app .data @@ -858,14 +859,11 @@ mod tests { SUBMIT_KEY, &mut app, ActiveSonarrBlock::EditSeriesPrompt, - None, + Some(ActiveSonarrBlock::Series), ) .handle(); - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::EditSeriesPrompt.into() - ); + assert_eq!(app.get_current_route(), current_route); assert_eq!( app .data @@ -880,25 +878,26 @@ mod tests { #[test] fn test_edit_series_toggle_use_season_folders_submit() { + let current_route = Route::from(( + ActiveSonarrBlock::EditSeriesPrompt, + Some(ActiveSonarrBlock::Series), + )); let mut app = App::default(); app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); app.data.sonarr_data.selected_block.set_index(0, 1); app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + app.push_navigation_stack(current_route); EditSeriesHandler::with( SUBMIT_KEY, &mut app, ActiveSonarrBlock::EditSeriesPrompt, - None, + Some(ActiveSonarrBlock::Series), ) .handle(); - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::EditSeriesPrompt.into() - ); + assert_eq!(app.get_current_route(), current_route); assert_eq!( app .data @@ -914,14 +913,11 @@ mod tests { SUBMIT_KEY, &mut app, ActiveSonarrBlock::EditSeriesPrompt, - None, + Some(ActiveSonarrBlock::Series), ) .handle(); - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::EditSeriesPrompt.into() - ); + assert_eq!(app.get_current_route(), current_route); assert_eq!( app .data @@ -947,7 +943,13 @@ mod tests { let mut app = App::default(); app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + app.push_navigation_stack( + ( + ActiveSonarrBlock::EditSeriesPrompt, + Some(ActiveSonarrBlock::Series), + ) + .into(), + ); app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); app.data.sonarr_data.selected_block.set_index(0, y_index); @@ -959,7 +961,10 @@ mod tests { ) .handle(); - assert_eq!(app.get_current_route(), selected_block.into()); + assert_eq!( + app.get_current_route(), + (selected_block, Some(ActiveSonarrBlock::Series)).into() + ); assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); if selected_block == ActiveSonarrBlock::EditSeriesPathInput @@ -977,7 +982,13 @@ mod tests { app.is_loading = true; app.data.sonarr_data.edit_series_modal = Some(EditSeriesModal::default()); app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); + app.push_navigation_stack( + ( + ActiveSonarrBlock::EditSeriesPrompt, + Some(ActiveSonarrBlock::Series), + ) + .into(), + ); app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); app.data.sonarr_data.selected_block.set_index(0, y_index); @@ -985,13 +996,17 @@ mod tests { SUBMIT_KEY, &mut app, ActiveSonarrBlock::EditSeriesPrompt, - None, + Some(ActiveSonarrBlock::Series), ) .handle(); assert_eq!( app.get_current_route(), - ActiveSonarrBlock::EditSeriesPrompt.into() + ( + ActiveSonarrBlock::EditSeriesPrompt, + Some(ActiveSonarrBlock::Series), + ) + .into() ); assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); assert!(!app.should_ignore_quit_key); @@ -1014,7 +1029,13 @@ mod tests { app.push_navigation_stack(ActiveSonarrBlock::EditSeriesPrompt.into()); app.push_navigation_stack(active_sonarr_block.into()); - EditSeriesHandler::with(SUBMIT_KEY, &mut app, active_sonarr_block, None).handle(); + EditSeriesHandler::with( + SUBMIT_KEY, + &mut app, + active_sonarr_block, + Some(ActiveSonarrBlock::Series), + ) + .handle(); assert_eq!( app.get_current_route(), diff --git a/src/ui/sonarr_ui/library/add_series_ui.rs b/src/ui/sonarr_ui/library/add_series_ui.rs index 9469864..0e78792 100644 --- a/src/ui/sonarr_ui/library/add_series_ui.rs +++ b/src/ui/sonarr_ui/library/add_series_ui.rs @@ -324,7 +324,7 @@ fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let [paragraph_area, root_folder_area, monitor_area, quality_profile_area, language_profile_area, series_type_area, season_folder_area, tags_area, _, buttons_area, help_area] = Layout::vertical([ - Constraint::Length(7), + Constraint::Length(6), Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), diff --git a/src/ui/sonarr_ui/library/edit_series_ui.rs b/src/ui/sonarr_ui/library/edit_series_ui.rs new file mode 100644 index 0000000..cdb2eff --- /dev/null +++ b/src/ui/sonarr_ui/library/edit_series_ui.rs @@ -0,0 +1,256 @@ +use std::sync::atomic::Ordering; + +use ratatui::layout::{Constraint, Rect}; +use ratatui::prelude::Layout; +use ratatui::text::Text; +use ratatui::widgets::{ListItem, Paragraph}; +use ratatui::Frame; + +use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; +use crate::app::App; +use crate::models::servarr_data::sonarr::modals::EditSeriesModal; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_SERIES_BLOCKS}; +use crate::models::{EnumDisplayStyle, Route}; +use crate::render_selectable_input_box; +use crate::ui::sonarr_ui::library::draw_library; + +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{layout_paragraph_borderless, title_block_centered}; +use crate::ui::widgets::button::Button; +use crate::ui::widgets::checkbox::Checkbox; +use crate::ui::widgets::input_box::InputBox; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::widgets::selectable_list::SelectableList; +use crate::ui::{draw_popup_over, DrawUi}; + +#[cfg(test)] +#[path = "edit_series_ui_tests.rs"] +mod edit_series_ui_tests; + +pub(super) struct EditSeriesUi; + +impl DrawUi for EditSeriesUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return EDIT_SERIES_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, context_option) = app.get_current_route() { + let draw_edit_series_prompt = + |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| match active_sonarr_block { + ActiveSonarrBlock::EditSeriesSelectSeriesType => { + draw_edit_series_confirmation_prompt(f, app, prompt_area); + draw_edit_series_select_series_type_popup(f, app); + } + ActiveSonarrBlock::EditSeriesSelectQualityProfile => { + draw_edit_series_confirmation_prompt(f, app, prompt_area); + draw_edit_series_select_quality_profile_popup(f, app); + } + ActiveSonarrBlock::EditSeriesSelectLanguageProfile => { + draw_edit_series_confirmation_prompt(f, app, prompt_area); + draw_edit_series_select_language_profile_popup(f, app); + } + ActiveSonarrBlock::EditSeriesPrompt + | ActiveSonarrBlock::EditSeriesToggleMonitored + | ActiveSonarrBlock::EditSeriesToggleSeasonFolder + | ActiveSonarrBlock::EditSeriesPathInput + | ActiveSonarrBlock::EditSeriesTagsInput => { + draw_edit_series_confirmation_prompt(f, app, prompt_area) + } + _ => (), + }; + + if let Some(context) = context_option { + match context { + ActiveSonarrBlock::Series => { + draw_popup_over( + f, + app, + area, + draw_library, + draw_edit_series_prompt, + Size::Long, + ); + } + // _ if SERIES_DETAILS_BLOCKS.contains(&context) => { + // draw_popup_over_ui::(f, app, area, draw_library, Size::Large); + // draw_popup(f, app, draw_edit_series_prompt, Size::Medium); + // } + _ => (), + } + } + } + } +} + +fn draw_edit_series_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let series_title = app + .data + .sonarr_data + .series + .current_selection() + .title + .text + .clone(); + let series_overview = app + .data + .sonarr_data + .series + .current_selection() + .overview + .clone() + .unwrap_or_default(); + let title = format!("Edit - {series_title}"); + let yes_no_value = app.data.sonarr_data.prompt_confirm; + let selected_block = app.data.sonarr_data.selected_block.get_active_block(); + let highlight_yes_no = selected_block == ActiveSonarrBlock::EditSeriesConfirmPrompt; + let EditSeriesModal { + series_type_list, + quality_profile_list, + language_profile_list, + monitored, + use_season_folders, + path, + tags, + } = app.data.sonarr_data.edit_series_modal.as_ref().unwrap(); + let selected_series_type = series_type_list.current_selection(); + let selected_quality_profile = quality_profile_list.current_selection(); + let selected_language_profile = language_profile_list.current_selection(); + + let [paragraph_area, monitored_area, season_folder_area, quality_profile_area, language_profile_area, series_type_area, path_area, tags_area, _, buttons_area, help_area] = + Layout::vertical([ + Constraint::Length(6), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .margin(1) + .areas(area); + let [save_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(buttons_area); + + let help_text = Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help()); + let help_paragraph = Paragraph::new(help_text).centered(); + let prompt_paragraph = layout_paragraph_borderless(&series_overview); + let monitored_checkbox = Checkbox::new("Monitored") + .checked(monitored.unwrap_or_default()) + .highlighted(selected_block == ActiveSonarrBlock::EditSeriesToggleMonitored); + let season_folder_checkbox = Checkbox::new("Season Folder") + .checked(use_season_folders.unwrap_or_default()) + .highlighted(selected_block == ActiveSonarrBlock::EditSeriesToggleSeasonFolder); + let series_type_drop_down_button = Button::new() + .title(selected_series_type.to_display_str()) + .label("Series Type") + .icon("▼") + .selected(selected_block == ActiveSonarrBlock::EditSeriesSelectSeriesType); + let quality_profile_drop_down_button = Button::new() + .title(selected_quality_profile) + .label("Quality Profile") + .icon("▼") + .selected(selected_block == ActiveSonarrBlock::EditSeriesSelectQualityProfile); + let language_profile_drop_down_button = Button::new() + .title(selected_language_profile) + .label("Language Profile") + .icon("▼") + .selected(selected_block == ActiveSonarrBlock::EditSeriesSelectLanguageProfile); + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let path_input_box = InputBox::new(&path.text) + .offset(path.offset.load(Ordering::SeqCst)) + .label("Path") + .highlighted(selected_block == ActiveSonarrBlock::EditSeriesPathInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditSeriesPathInput); + let tags_input_box = InputBox::new(&tags.text) + .offset(tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(selected_block == ActiveSonarrBlock::EditSeriesTagsInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditSeriesTagsInput); + + match active_sonarr_block { + ActiveSonarrBlock::EditSeriesPathInput => path_input_box.show_cursor(f, path_area), + ActiveSonarrBlock::EditSeriesTagsInput => tags_input_box.show_cursor(f, tags_area), + _ => (), + } + + render_selectable_input_box!(path_input_box, f, path_area); + render_selectable_input_box!(tags_input_box, f, tags_area); + } + + let save_button = Button::new() + .title("Save") + .selected(yes_no_value && highlight_yes_no); + let cancel_button = Button::new() + .title("Cancel") + .selected(!yes_no_value && highlight_yes_no); + + f.render_widget(title_block_centered(&title), area); + f.render_widget(prompt_paragraph, paragraph_area); + f.render_widget(monitored_checkbox, monitored_area); + f.render_widget(season_folder_checkbox, season_folder_area); + f.render_widget(quality_profile_drop_down_button, quality_profile_area); + f.render_widget(language_profile_drop_down_button, language_profile_area); + f.render_widget(series_type_drop_down_button, series_type_area); + f.render_widget(save_button, save_area); + f.render_widget(cancel_button, cancel_area); + f.render_widget(help_paragraph, help_area); +} + +fn draw_edit_series_select_series_type_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let series_type_list = SelectableList::new( + &mut app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .series_type_list, + |series_type| ListItem::new(series_type.to_display_str().to_owned()), + ); + let popup = Popup::new(series_type_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_edit_series_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let quality_profile_list = SelectableList::new( + &mut app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .quality_profile_list, + |quality_profile| ListItem::new(quality_profile.clone()), + ); + let popup = Popup::new(quality_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_edit_series_select_language_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let language_profile_list = SelectableList::new( + &mut app + .data + .sonarr_data + .edit_series_modal + .as_mut() + .unwrap() + .language_profile_list, + |language_profile| ListItem::new(language_profile.clone()), + ); + let popup = Popup::new(language_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} diff --git a/src/ui/sonarr_ui/library/edit_series_ui_tests.rs b/src/ui/sonarr_ui/library/edit_series_ui_tests.rs new file mode 100644 index 0000000..d201aec --- /dev/null +++ b/src/ui/sonarr_ui/library/edit_series_ui_tests.rs @@ -0,0 +1,19 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_SERIES_BLOCKS}; + use crate::ui::sonarr_ui::library::edit_series_ui::EditSeriesUi; + use crate::ui::DrawUi; + + #[test] + fn test_edit_movie_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if EDIT_SERIES_BLOCKS.contains(&active_sonarr_block) { + assert!(EditSeriesUi::accepts(active_sonarr_block.into())); + } else { + assert!(!EditSeriesUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs index a94e582..417590a 100644 --- a/src/ui/sonarr_ui/library/library_ui_tests.rs +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, + ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, }; use crate::models::{ servarr_data::sonarr::sonarr_data::LIBRARY_BLOCKS, sonarr_models::SeriesStatus, @@ -25,6 +25,7 @@ mod tests { library_ui_blocks.extend(LIBRARY_BLOCKS); library_ui_blocks.extend(ADD_SERIES_BLOCKS); library_ui_blocks.extend(DELETE_SERIES_BLOCKS); + library_ui_blocks.extend(EDIT_SERIES_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_radarr_block| { if library_ui_blocks.contains(&active_radarr_block) { diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index 1dc6c6a..9a3d731 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -1,5 +1,6 @@ use add_series_ui::AddSeriesUi; use delete_series_ui::DeleteSeriesUi; +use edit_series_ui::EditSeriesUi; use ratatui::{ layout::{Constraint, Rect}, widgets::{Cell, Row}, @@ -29,6 +30,7 @@ use crate::{ mod add_series_ui; mod delete_series_ui; +mod edit_series_ui; #[cfg(test)] #[path = "library_ui_tests.rs"] @@ -41,6 +43,7 @@ impl DrawUi for LibraryUi { if let Route::Sonarr(active_sonarr_block, _) = route { return AddSeriesUi::accepts(route) || DeleteSeriesUi::accepts(route) + || EditSeriesUi::accepts(route) || LIBRARY_BLOCKS.contains(&active_sonarr_block); } @@ -96,6 +99,7 @@ impl DrawUi for LibraryUi { match route { _ if AddSeriesUi::accepts(route) => AddSeriesUi::draw(f, app, area), _ if DeleteSeriesUi::accepts(route) => DeleteSeriesUi::draw(f, app, area), + _ if EditSeriesUi::accepts(route) => EditSeriesUi::draw(f, app, area), Route::Sonarr(active_sonarr_block, _) if LIBRARY_BLOCKS.contains(&active_sonarr_block) => { series_ui_matchers(active_sonarr_block) } diff --git a/src/ui/widgets/popup.rs b/src/ui/widgets/popup.rs index 4607cf0..43b36fb 100644 --- a/src/ui/widgets/popup.rs +++ b/src/ui/widgets/popup.rs @@ -38,7 +38,7 @@ impl Size { Size::Small => (40, 40), Size::Medium => (60, 60), Size::Large => (75, 75), - Size::Long => (65, 80), + Size::Long => (65, 75), } } } diff --git a/src/ui/widgets/popup_tests.rs b/src/ui/widgets/popup_tests.rs index ea18bfe..d643a3f 100644 --- a/src/ui/widgets/popup_tests.rs +++ b/src/ui/widgets/popup_tests.rs @@ -17,7 +17,7 @@ mod tests { assert_eq!(Size::Small.to_percent(), (40, 40)); assert_eq!(Size::Medium.to_percent(), (60, 60)); assert_eq!(Size::Large.to_percent(), (75, 75)); - assert_eq!(Size::Long.to_percent(), (65, 80)); + assert_eq!(Size::Long.to_percent(), (65, 75)); } #[test] From f338dfcb126b2f2787068e1bafce19378ed76296 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 15:40:11 -0700 Subject: [PATCH 15/82] feat(handler): Download tab support --- .../downloads/downloads_handler_tests.rs | 550 ++++++++++++++++++ src/handlers/sonarr_handlers/downloads/mod.rs | 153 +++++ src/handlers/sonarr_handlers/mod.rs | 1 + 3 files changed, 704 insertions(+) create mode 100644 src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs create mode 100644 src/handlers/sonarr_handlers/downloads/mod.rs diff --git a/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs new file mode 100644 index 0000000..6ed8032 --- /dev/null +++ b/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs @@ -0,0 +1,550 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_str_eq; + use strum::IntoEnumIterator; + + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::downloads::DownloadsHandler; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DOWNLOADS_BLOCKS}; + use crate::models::sonarr_models::DownloadRecord; + + mod test_handle_scroll_up_and_down { + use rstest::rstest; + + use crate::models::sonarr_models::DownloadRecord; + use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; + + use super::*; + + test_iterable_scroll!( + test_downloads_scroll, + DownloadsHandler, + sonarr_data, + downloads, + DownloadRecord, + ActiveSonarrBlock::Downloads, + None, + title + ); + + #[rstest] + fn test_downloads_scroll_no_op_when_not_ready( + #[values( + DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key + )] + key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + app.is_loading = true; + app + .data + .sonarr_data + .downloads + .set_items(simple_stateful_iterable_vec!(DownloadRecord)); + + DownloadsHandler::with(key, &mut app, ActiveSonarrBlock::Downloads, None).handle(); + + assert_str_eq!( + app.data.sonarr_data.downloads.current_selection().title, + "Test 1" + ); + + DownloadsHandler::with(key, &mut app, ActiveSonarrBlock::Downloads, None).handle(); + + assert_str_eq!( + app.data.sonarr_data.downloads.current_selection().title, + "Test 1" + ); + } + } + + mod test_handle_home_end { + use crate::models::sonarr_models::DownloadRecord; + use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; + + use super::*; + + test_iterable_home_and_end!( + test_downloads_home_end, + DownloadsHandler, + sonarr_data, + downloads, + DownloadRecord, + ActiveSonarrBlock::Downloads, + None, + title + ); + + #[test] + fn test_downloads_home_end_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + app.is_loading = true; + app + .data + .sonarr_data + .downloads + .set_items(extended_stateful_iterable_vec!(DownloadRecord)); + + DownloadsHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::Downloads, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.downloads.current_selection().title, + "Test 1" + ); + + DownloadsHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::Downloads, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.downloads.current_selection().title, + "Test 1" + ); + } + } + + mod test_handle_delete { + use pretty_assertions::assert_eq; + + use super::*; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_delete_download_prompt() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + app + .data + .sonarr_data + .downloads + .set_items(vec![DownloadRecord::default()]); + + DownloadsHandler::with(DELETE_KEY, &mut app, ActiveSonarrBlock::Downloads, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::DeleteDownloadPrompt.into() + ); + } + + #[test] + fn test_delete_download_prompt_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + app + .data + .sonarr_data + .downloads + .set_items(vec![DownloadRecord::default()]); + + DownloadsHandler::with(DELETE_KEY, &mut app, ActiveSonarrBlock::Downloads, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Downloads.into()); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_downloads_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(1); + + DownloadsHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::Downloads, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::Series.into() + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + } + + #[rstest] + fn test_downloads_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(1); + + DownloadsHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::Downloads, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::Blocklist.into() + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + } + + #[rstest] + fn test_downloads_left_right_prompt_toggle( + #[values( + ActiveSonarrBlock::DeleteDownloadPrompt, + ActiveSonarrBlock::UpdateDownloadsPrompt + )] + active_sonarr_block: ActiveSonarrBlock, + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + + DownloadsHandler::with(key, &mut app, active_sonarr_block, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + DownloadsHandler::with(key, &mut app, active_sonarr_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[rstest] + #[case( + ActiveSonarrBlock::Downloads, + ActiveSonarrBlock::DeleteDownloadPrompt, + SonarrEvent::DeleteDownload(None) + )] + #[case( + ActiveSonarrBlock::Downloads, + ActiveSonarrBlock::UpdateDownloadsPrompt, + SonarrEvent::UpdateDownloads + )] + fn test_downloads_prompt_confirm_submit( + #[case] base_route: ActiveSonarrBlock, + #[case] prompt_block: ActiveSonarrBlock, + #[case] expected_action: SonarrEvent, + ) { + let mut app = App::default(); + app + .data + .sonarr_data + .downloads + .set_items(vec![DownloadRecord::default()]); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + DownloadsHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(expected_action) + ); + assert_eq!(app.get_current_route(), base_route.into()); + } + + #[rstest] + #[case(ActiveSonarrBlock::Downloads, ActiveSonarrBlock::DeleteDownloadPrompt)] + #[case(ActiveSonarrBlock::Downloads, ActiveSonarrBlock::UpdateDownloadsPrompt)] + fn test_downloads_prompt_decline_submit( + #[case] base_route: ActiveSonarrBlock, + #[case] prompt_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app + .data + .sonarr_data + .downloads + .set_items(vec![DownloadRecord::default()]); + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + DownloadsHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + assert_eq!(app.get_current_route(), base_route.into()); + } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + #[case(ActiveSonarrBlock::Downloads, ActiveSonarrBlock::DeleteDownloadPrompt)] + #[case(ActiveSonarrBlock::Downloads, ActiveSonarrBlock::UpdateDownloadsPrompt)] + fn test_downloads_prompt_blocks_esc( + #[case] base_block: ActiveSonarrBlock, + #[case] prompt_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.push_navigation_stack(base_block.into()); + app.push_navigation_stack(prompt_block.into()); + app.data.sonarr_data.prompt_confirm = true; + + DownloadsHandler::with(ESC_KEY, &mut app, prompt_block, None).handle(); + + assert_eq!(app.get_current_route(), base_block.into()); + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + + DownloadsHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::Downloads, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Downloads.into()); + assert!(app.error.text.is_empty()); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + #[test] + fn test_update_downloads_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + app + .data + .sonarr_data + .downloads + .set_items(vec![DownloadRecord::default()]); + + DownloadsHandler::with( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveSonarrBlock::Downloads, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::UpdateDownloadsPrompt.into() + ); + } + + #[test] + fn test_update_downloads_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + app + .data + .sonarr_data + .downloads + .set_items(vec![DownloadRecord::default()]); + + DownloadsHandler::with( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveSonarrBlock::Downloads, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Downloads.into()); + } + + #[test] + fn test_refresh_downloads_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .downloads + .set_items(vec![DownloadRecord::default()]); + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + + DownloadsHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::Downloads, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Downloads.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_downloads_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + app + .data + .sonarr_data + .downloads + .set_items(vec![DownloadRecord::default()]); + + DownloadsHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::Downloads, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Downloads.into()); + assert!(!app.should_refresh); + } + + #[rstest] + #[case( + ActiveSonarrBlock::Downloads, + ActiveSonarrBlock::DeleteDownloadPrompt, + SonarrEvent::DeleteDownload(None) + )] + #[case( + ActiveSonarrBlock::Downloads, + ActiveSonarrBlock::UpdateDownloadsPrompt, + SonarrEvent::UpdateDownloads + )] + fn test_downloads_prompt_confirm_submit( + #[case] base_route: ActiveSonarrBlock, + #[case] prompt_block: ActiveSonarrBlock, + #[case] expected_action: SonarrEvent, + ) { + let mut app = App::default(); + app + .data + .sonarr_data + .downloads + .set_items(vec![DownloadRecord::default()]); + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + DownloadsHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + prompt_block, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(expected_action) + ); + assert_eq!(app.get_current_route(), base_route.into()); + } + } + + #[test] + fn test_downloads_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if DOWNLOADS_BLOCKS.contains(&active_sonarr_block) { + assert!(DownloadsHandler::accepts(active_sonarr_block)); + } else { + assert!(!DownloadsHandler::accepts(active_sonarr_block)); + } + }) + } + + #[test] + fn test_downloads_handler_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + app.is_loading = true; + + let handler = DownloadsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::Downloads, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_downloads_handler_not_ready_when_downloads_is_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + app.is_loading = false; + + let handler = DownloadsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::Downloads, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_downloads_handler_ready_when_not_loading_and_downloads_is_not_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); + app.is_loading = false; + + app + .data + .sonarr_data + .downloads + .set_items(vec![DownloadRecord::default()]); + let handler = DownloadsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::Downloads, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/sonarr_handlers/downloads/mod.rs b/src/handlers/sonarr_handlers/downloads/mod.rs new file mode 100644 index 0000000..1d08deb --- /dev/null +++ b/src/handlers/sonarr_handlers/downloads/mod.rs @@ -0,0 +1,153 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::App; +use crate::event::Key; +use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DOWNLOADS_BLOCKS}; +use crate::models::Scrollable; +use crate::network::sonarr_network::SonarrEvent; + +#[cfg(test)] +#[path = "downloads_handler_tests.rs"] +mod downloads_handler_tests; + +pub(super) struct DownloadsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for DownloadsHandler<'a, 'b> { + fn accepts(active_block: ActiveSonarrBlock) -> bool { + DOWNLOADS_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + _context: Option, + ) -> DownloadsHandler<'a, 'b> { + DownloadsHandler { + key, + app, + active_sonarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.sonarr_data.downloads.is_empty() + } + + fn handle_scroll_up(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::Downloads { + self.app.data.sonarr_data.downloads.scroll_up() + } + } + + fn handle_scroll_down(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::Downloads { + self.app.data.sonarr_data.downloads.scroll_down() + } + } + + fn handle_home(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::Downloads { + self.app.data.sonarr_data.downloads.scroll_to_top() + } + } + + fn handle_end(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::Downloads { + self.app.data.sonarr_data.downloads.scroll_to_bottom() + } + } + + fn handle_delete(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::Downloads { + self + .app + .push_navigation_stack(ActiveSonarrBlock::DeleteDownloadPrompt.into()) + } + } + + fn handle_left_right_action(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Downloads => handle_change_tab_left_right_keys(self.app, self.key), + ActiveSonarrBlock::DeleteDownloadPrompt | ActiveSonarrBlock::UpdateDownloadsPrompt => { + handle_prompt_toggle(self.app, self.key) + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::DeleteDownloadPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::DeleteDownload(None)); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::UpdateDownloadsPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::UpdateDownloads); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::DeleteDownloadPrompt | ActiveSonarrBlock::UpdateDownloadsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.prompt_confirm = false; + } + _ => handle_clear_errors(self.app), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_sonarr_block { + ActiveSonarrBlock::Downloads => match self.key { + _ if key == DEFAULT_KEYBINDINGS.update.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::UpdateDownloadsPrompt.into()); + } + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { + self.app.should_refresh = true; + } + _ => (), + }, + ActiveSonarrBlock::DeleteDownloadPrompt => { + if key == DEFAULT_KEYBINDINGS.confirm.key { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::DeleteDownload(None)); + + self.app.pop_navigation_stack(); + } + } + ActiveSonarrBlock::UpdateDownloadsPrompt => { + if key == DEFAULT_KEYBINDINGS.confirm.key { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::UpdateDownloads); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } +} diff --git a/src/handlers/sonarr_handlers/mod.rs b/src/handlers/sonarr_handlers/mod.rs index 0dd5014..8faa4ce 100644 --- a/src/handlers/sonarr_handlers/mod.rs +++ b/src/handlers/sonarr_handlers/mod.rs @@ -8,6 +8,7 @@ use crate::{ use super::KeyEventHandler; +mod downloads; mod library; #[cfg(test)] From f0d8555a8ad2c5cb2d31fb07c9962c7513d2979e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 15:57:48 -0700 Subject: [PATCH 16/82] feat(ui): Downloads tab support --- src/handlers/sonarr_handlers/mod.rs | 4 + .../sonarr_handlers/sonarr_handler_tests.rs | 60 ++++---- .../sonarr_ui/downloads/downloads_ui_tests.rs | 19 +++ src/ui/sonarr_ui/downloads/mod.rs | 142 ++++++++++++++++++ src/ui/sonarr_ui/mod.rs | 3 + src/utils.rs | 4 + src/utils_tests.rs | 8 +- 7 files changed, 213 insertions(+), 27 deletions(-) create mode 100644 src/ui/sonarr_ui/downloads/downloads_ui_tests.rs create mode 100644 src/ui/sonarr_ui/downloads/mod.rs diff --git a/src/handlers/sonarr_handlers/mod.rs b/src/handlers/sonarr_handlers/mod.rs index 8faa4ce..c3a26f5 100644 --- a/src/handlers/sonarr_handlers/mod.rs +++ b/src/handlers/sonarr_handlers/mod.rs @@ -1,3 +1,4 @@ +use downloads::DownloadsHandler; use library::LibraryHandler; use crate::{ @@ -32,6 +33,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SonarrHandler<'a, 'b _ if LibraryHandler::accepts(self.active_sonarr_block) => { LibraryHandler::with(self.key, self.app, self.active_sonarr_block, self.context).handle(); } + _ if DownloadsHandler::accepts(self.active_sonarr_block) => { + DownloadsHandler::with(self.key, self.app, self.active_sonarr_block, self.context).handle() + } _ => self.handle_key_event(), } } diff --git a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs index 4fcc1bd..e9c21a4 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs @@ -48,36 +48,28 @@ mod tests { #[rstest] fn test_delegates_library_blocks_to_library_handler( #[values( - // ActiveSonarrBlock::AddSeriesAlreadyInLibrary, - // ActiveSonarrBlock::AddSeriesConfirmPrompt, - // ActiveSonarrBlock::AddSeriesEmptySearchResults, - // ActiveSonarrBlock::AddSeriesPrompt, - // ActiveSonarrBlock::AddSeriesSearchInput, - // ActiveSonarrBlock::AddSeriesSearchResults, - // ActiveSonarrBlock::AddSeriesSelectLanguageProfile, - // ActiveSonarrBlock::AddSeriesSelectMonitor, - // ActiveSonarrBlock::AddSeriesSelectQualityProfile, - // ActiveSonarrBlock::AddSeriesSelectRootFolder, - // ActiveSonarrBlock::AddSeriesSelectSeriesType, - // ActiveSonarrBlock::AddSeriesTagsInput, - // ActiveSonarrBlock::AddSeriesToggleUseSeasonFolder, + ActiveSonarrBlock::AddSeriesAlreadyInLibrary, + ActiveSonarrBlock::AddSeriesEmptySearchResults, + ActiveSonarrBlock::AddSeriesPrompt, + ActiveSonarrBlock::AddSeriesSearchInput, + ActiveSonarrBlock::AddSeriesSearchResults, + ActiveSonarrBlock::AddSeriesSelectLanguageProfile, + ActiveSonarrBlock::AddSeriesSelectMonitor, + ActiveSonarrBlock::AddSeriesSelectQualityProfile, + ActiveSonarrBlock::AddSeriesSelectRootFolder, + ActiveSonarrBlock::AddSeriesSelectSeriesType, + ActiveSonarrBlock::AddSeriesTagsInput, // ActiveSonarrBlock::AutomaticallySearchEpisodePrompt, // ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, // ActiveSonarrBlock::AutomaticallySearchSeriesPrompt, // ActiveSonarrBlock::DeleteEpisodeFilePrompt, - // ActiveSonarrBlock::DeleteSeriesConfirmPrompt, - // ActiveSonarrBlock::DeleteSeriesPrompt, - // ActiveSonarrBlock::DeleteSeriesToggleAddListExclusion, - // ActiveSonarrBlock::DeleteSeriesToggleDeleteFile, - // ActiveSonarrBlock::EditSeriesPrompt, - // ActiveSonarrBlock::EditSeriesConfirmPrompt, - // ActiveSonarrBlock::EditSeriesPathInput, - // ActiveSonarrBlock::EditSeriesSelectSeriesType, - // ActiveSonarrBlock::EditSeriesSelectQualityProfile, - // ActiveSonarrBlock::EditSeriesSelectLanguageProfile, - // ActiveSonarrBlock::EditSeriesTagsInput, - // ActiveSonarrBlock::EditSeriesToggleMonitored, - // ActiveSonarrBlock::EditSeriesToggleSeasonFolder, + ActiveSonarrBlock::DeleteSeriesPrompt, + ActiveSonarrBlock::EditSeriesPrompt, + ActiveSonarrBlock::EditSeriesPathInput, + ActiveSonarrBlock::EditSeriesSelectSeriesType, + ActiveSonarrBlock::EditSeriesSelectQualityProfile, + ActiveSonarrBlock::EditSeriesSelectLanguageProfile, + ActiveSonarrBlock::EditSeriesTagsInput, // ActiveSonarrBlock::EpisodeDetails, // ActiveSonarrBlock::EpisodeFile, // ActiveSonarrBlock::EpisodeHistory, @@ -119,4 +111,20 @@ mod tests { active_sonarr_block ); } + + #[rstest] + fn test_delegates_downloads_blocks_to_downloads_handler( + #[values( + ActiveSonarrBlock::Downloads, + ActiveSonarrBlock::DeleteDownloadPrompt, + ActiveSonarrBlock::UpdateDownloadsPrompt + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + SonarrHandler, + ActiveSonarrBlock::Downloads, + active_sonarr_block + ); + } } diff --git a/src/ui/sonarr_ui/downloads/downloads_ui_tests.rs b/src/ui/sonarr_ui/downloads/downloads_ui_tests.rs new file mode 100644 index 0000000..2d040ae --- /dev/null +++ b/src/ui/sonarr_ui/downloads/downloads_ui_tests.rs @@ -0,0 +1,19 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DOWNLOADS_BLOCKS}; + use crate::ui::sonarr_ui::downloads::DownloadsUi; + use crate::ui::DrawUi; + + #[test] + fn test_downloads_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if DOWNLOADS_BLOCKS.contains(&active_sonarr_block) { + assert!(DownloadsUi::accepts(active_sonarr_block.into())); + } else { + assert!(!DownloadsUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/downloads/mod.rs b/src/ui/sonarr_ui/downloads/mod.rs new file mode 100644 index 0000000..4ecdd1f --- /dev/null +++ b/src/ui/sonarr_ui/downloads/mod.rs @@ -0,0 +1,142 @@ +use ratatui::layout::{Constraint, Rect}; +use ratatui::widgets::{Cell, Row}; +use ratatui::Frame; + +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DOWNLOADS_BLOCKS}; +use crate::models::sonarr_models::DownloadRecord; +use crate::models::{HorizontallyScrollableText, Route}; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::DrawUi; +use crate::utils::convert_f64_to_gb; + +#[cfg(test)] +#[path = "downloads_ui_tests.rs"] +mod downloads_ui_tests; + +pub(super) struct DownloadsUi; + +impl DrawUi for DownloadsUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return DOWNLOADS_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + match active_sonarr_block { + ActiveSonarrBlock::Downloads => draw_downloads(f, app, area), + ActiveSonarrBlock::DeleteDownloadPrompt => { + let prompt = format!( + "Do you really want to delete this download: \n{}?", + app.data.sonarr_data.downloads.current_selection().title + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Cancel Download") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + draw_downloads(f, app, area); + f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + } + ActiveSonarrBlock::UpdateDownloadsPrompt => { + let confirmation_prompt = ConfirmationPrompt::new() + .title("Update Downloads") + .prompt("Do you want to update your downloads?") + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + draw_downloads(f, app, area); + f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + } + _ => (), + } + } + } +} + +fn draw_downloads(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = if app.data.sonarr_data.downloads.items.is_empty() { + DownloadRecord::default() + } else { + app.data.sonarr_data.downloads.current_selection().clone() + }; + let downloads_table_footer = app + .data + .sonarr_data + .main_tabs + .get_active_tab_contextual_help(); + + let downloads_row_mapping = |download_record: &DownloadRecord| { + let DownloadRecord { + title, + size, + sizeleft, + download_client, + indexer, + output_path, + .. + } = download_record; + + if output_path.is_some() { + output_path.as_ref().unwrap().scroll_left_or_reset( + get_width_from_percentage(area, 18), + current_selection == *download_record, + app.tick_count % app.ticks_until_scroll == 0, + ); + } + + let percent = if *size == 0.0 { + 0.0 + } else { + 1f64 - (*sizeleft / *size) + }; + let file_size: f64 = convert_f64_to_gb(*size); + + Row::new(vec![ + Cell::from(title.to_owned()), + Cell::from(format!("{:.0}%", percent * 100.0)), + Cell::from(format!("{file_size:.2} GB")), + Cell::from( + output_path + .as_ref() + .unwrap_or(&HorizontallyScrollableText::default()) + .to_string(), + ), + Cell::from(indexer.to_owned()), + Cell::from(download_client.to_owned()), + ]) + .primary() + }; + let downloads_table = ManagarrTable::new( + Some(&mut app.data.sonarr_data.downloads), + downloads_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(downloads_table_footer) + .headers([ + "Title", + "Percent Complete", + "Size", + "Output Path", + "Indexer", + "Download Client", + ]) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(11), + Constraint::Percentage(11), + Constraint::Percentage(18), + Constraint::Percentage(17), + Constraint::Percentage(13), + ]); + + f.render_widget(downloads_table, area); +} diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index 18a8fce..b10f4aa 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -1,6 +1,7 @@ use std::{cmp, iter}; use chrono::{Duration, Utc}; +use downloads::DownloadsUi; use library::LibraryUi; use ratatui::{ layout::{Constraint, Layout, Rect}, @@ -32,6 +33,7 @@ use super::{ DrawUi, }; +mod downloads; mod library; #[cfg(test)] @@ -51,6 +53,7 @@ impl DrawUi for SonarrUi { match route { _ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area), + _ if DownloadsUi::accepts(route) => DownloadsUi::draw(f, app, content_area), _ => (), } } diff --git a/src/utils.rs b/src/utils.rs index b5e98db..741e6e4 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -67,6 +67,10 @@ pub fn convert_to_gb(bytes: i64) -> f64 { bytes as f64 / 1024f64.powi(3) } +pub fn convert_f64_to_gb(bytes: f64) -> f64 { + bytes / 1024f64.powi(3) +} + pub fn convert_runtime(runtime: i64) -> (i64, i64) { let hours = runtime / 60; let minutes = runtime % 60; diff --git a/src/utils_tests.rs b/src/utils_tests.rs index 6c74458..7e9a533 100644 --- a/src/utils_tests.rs +++ b/src/utils_tests.rs @@ -2,7 +2,7 @@ mod tests { use pretty_assertions::assert_eq; - use crate::utils::{convert_runtime, convert_to_gb}; + use crate::utils::{convert_f64_to_gb, convert_runtime, convert_to_gb}; #[test] fn test_convert_to_gb() { @@ -10,6 +10,12 @@ mod tests { assert_eq!(convert_to_gb(2662879723), 2.4799999995157123); } + #[test] + fn test_convert_f64_to_gb() { + assert_eq!(convert_f64_to_gb(2147483648f64), 2f64); + assert_eq!(convert_f64_to_gb(2662879723f64), 2.4799999995157123); + } + #[test] fn test_convert_runtime() { let (hours, minutes) = convert_runtime(154); From 4b7185fbb01184a8bc813058eeafdd90f3476f71 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 16:37:46 -0700 Subject: [PATCH 17/82] feat(handler): Blocklist handler support --- .../blocklist/blocklist_handler_tests.rs | 947 ++++++++++++++++++ src/handlers/sonarr_handlers/blocklist/mod.rs | 279 ++++++ src/handlers/sonarr_handlers/mod.rs | 1 + src/models/servarr_data/sonarr/sonarr_data.rs | 8 + .../servarr_data/sonarr/sonarr_data_tests.rs | 14 +- src/models/sonarr_models.rs | 2 + src/network/sonarr_network.rs | 24 +- src/network/sonarr_network_tests.rs | 13 + 8 files changed, 1284 insertions(+), 4 deletions(-) create mode 100644 src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs create mode 100644 src/handlers/sonarr_handlers/blocklist/mod.rs diff --git a/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs new file mode 100644 index 0000000..6bf173b --- /dev/null +++ b/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs @@ -0,0 +1,947 @@ +#[cfg(test)] +mod tests { + use std::cmp::Ordering; + + use chrono::DateTime; + use pretty_assertions::{assert_eq, assert_str_eq}; + use strum::IntoEnumIterator; + + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::blocklist::{blocklist_sorting_options, BlocklistHandler}; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, BLOCKLIST_BLOCKS}; + use crate::models::servarr_models::{Language, Quality, QualityWrapper}; + use crate::models::sonarr_models::BlocklistItem; + use crate::models::stateful_table::SortOption; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + + use crate::models::sonarr_models::BlocklistItem; + use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; + + use super::*; + + test_iterable_scroll!( + test_blocklist_scroll, + BlocklistHandler, + sonarr_data, + blocklist, + simple_stateful_iterable_vec!(BlocklistItem, String, source_title), + ActiveSonarrBlock::Blocklist, + None, + source_title, + to_string + ); + + #[rstest] + fn test_blocklist_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.is_loading = true; + app + .data + .sonarr_data + .blocklist + .set_items(simple_stateful_iterable_vec!( + BlocklistItem, + String, + source_title + )); + + BlocklistHandler::with(key, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .blocklist + .current_selection() + .source_title + .to_string(), + "Test 1" + ); + + BlocklistHandler::with(key, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .blocklist + .current_selection() + .source_title + .to_string(), + "Test 1" + ); + } + + #[rstest] + fn test_blocklist_sort_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let blocklist_field_vec = sort_options(); + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.sorting(sort_options()); + + if key == Key::Up { + for i in (0..blocklist_field_vec.len()).rev() { + BlocklistHandler::with(key, &mut app, ActiveSonarrBlock::BlocklistSortPrompt, None) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .blocklist + .sort + .as_ref() + .unwrap() + .current_selection(), + &blocklist_field_vec[i] + ); + } + } else { + for i in 0..blocklist_field_vec.len() { + BlocklistHandler::with(key, &mut app, ActiveSonarrBlock::BlocklistSortPrompt, None) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .blocklist + .sort + .as_ref() + .unwrap() + .current_selection(), + &blocklist_field_vec[(i + 1) % blocklist_field_vec.len()] + ); + } + } + } + } + + mod test_handle_home_end { + use pretty_assertions::{assert_eq, assert_str_eq}; + + use crate::models::sonarr_models::BlocklistItem; + use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; + + use super::*; + + test_iterable_home_and_end!( + test_blocklist_home_and_end, + BlocklistHandler, + sonarr_data, + blocklist, + extended_stateful_iterable_vec!(BlocklistItem, String, source_title), + ActiveSonarrBlock::Blocklist, + None, + source_title, + to_string + ); + + #[test] + fn test_blocklist_home_and_end_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.is_loading = true; + app + .data + .sonarr_data + .blocklist + .set_items(extended_stateful_iterable_vec!( + BlocklistItem, + String, + source_title + )); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .blocklist + .current_selection() + .source_title + .to_string(), + "Test 1" + ); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .blocklist + .current_selection() + .source_title + .to_string(), + "Test 1" + ); + } + + #[test] + fn test_blocklist_sort_home_end() { + let blocklist_field_vec = sort_options(); + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.sorting(sort_options()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::BlocklistSortPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .blocklist + .sort + .as_ref() + .unwrap() + .current_selection(), + &blocklist_field_vec[blocklist_field_vec.len() - 1] + ); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::BlocklistSortPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .blocklist + .sort + .as_ref() + .unwrap() + .current_selection(), + &blocklist_field_vec[0] + ); + } + } + + mod test_handle_delete { + use pretty_assertions::assert_eq; + + use super::*; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_delete_blocklist_item_prompt() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::with(DELETE_KEY, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::DeleteBlocklistItemPrompt.into() + ); + } + + #[test] + fn test_delete_blocklist_item_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::with(DELETE_KEY, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_blocklist_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(2); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::Downloads.into() + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Downloads.into()); + } + + #[rstest] + fn test_blocklist_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(2); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::History.into() + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + } + + #[rstest] + fn test_blocklist_left_right_prompt_toggle( + #[values( + ActiveSonarrBlock::DeleteBlocklistItemPrompt, + ActiveSonarrBlock::BlocklistClearAllItemsPrompt + )] + active_sonarr_block: ActiveSonarrBlock, + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + + BlocklistHandler::with(key, &mut app, active_sonarr_block, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + BlocklistHandler::with(key, &mut app, active_sonarr_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_blocklist_submit() { + let mut app = App::default(); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + + BlocklistHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::BlocklistItemDetails.into() + ); + } + + #[test] + fn test_blocklist_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + + BlocklistHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + } + + #[rstest] + #[case( + ActiveSonarrBlock::Blocklist, + ActiveSonarrBlock::DeleteBlocklistItemPrompt, + SonarrEvent::DeleteBlocklistItem(None) + )] + #[case( + ActiveSonarrBlock::Blocklist, + ActiveSonarrBlock::BlocklistClearAllItemsPrompt, + SonarrEvent::ClearBlocklist + )] + fn test_blocklist_prompt_confirm_submit( + #[case] base_route: ActiveSonarrBlock, + #[case] prompt_block: ActiveSonarrBlock, + #[case] expected_action: SonarrEvent, + ) { + let mut app = App::default(); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + BlocklistHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(expected_action) + ); + assert_eq!(app.get_current_route(), base_route.into()); + } + + #[rstest] + fn test_blocklist_prompt_decline_submit( + #[values( + ActiveSonarrBlock::DeleteBlocklistItemPrompt, + ActiveSonarrBlock::BlocklistClearAllItemsPrompt + )] + prompt_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.push_navigation_stack(prompt_block.into()); + + BlocklistHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + } + + #[test] + fn test_blocklist_sort_prompt_submit() { + let mut app = App::default(); + app.data.sonarr_data.blocklist.sort_asc = true; + app.data.sonarr_data.blocklist.sorting(sort_options()); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.push_navigation_stack(ActiveSonarrBlock::BlocklistSortPrompt.into()); + + let mut expected_vec = blocklist_vec(); + expected_vec.sort_by(|a, b| a.id.cmp(&b.id)); + expected_vec.reverse(); + + BlocklistHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::BlocklistSortPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + assert_eq!(app.data.sonarr_data.blocklist.items, expected_vec); + } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::handlers::sonarr_handlers::downloads::DownloadsHandler; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + #[case( + ActiveSonarrBlock::Blocklist, + ActiveSonarrBlock::DeleteBlocklistItemPrompt + )] + #[case( + ActiveSonarrBlock::Blocklist, + ActiveSonarrBlock::BlocklistClearAllItemsPrompt + )] + fn test_blocklist_prompt_blocks_esc( + #[case] base_block: ActiveSonarrBlock, + #[case] prompt_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.push_navigation_stack(base_block.into()); + app.push_navigation_stack(prompt_block.into()); + app.data.sonarr_data.prompt_confirm = true; + + BlocklistHandler::with(ESC_KEY, &mut app, prompt_block, None).handle(); + + assert_eq!(app.get_current_route(), base_block.into()); + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[test] + fn test_esc_blocklist_item_details() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.push_navigation_stack(ActiveSonarrBlock::BlocklistItemDetails.into()); + + BlocklistHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::BlocklistItemDetails, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + } + + #[test] + fn test_blocklist_sort_prompt_block_esc() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.push_navigation_stack(ActiveSonarrBlock::BlocklistSortPrompt.into()); + + BlocklistHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::BlocklistSortPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + + DownloadsHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + assert!(app.error.text.is_empty()); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + #[test] + fn test_refresh_blocklist_key() { + let mut app = App::default(); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_blocklist_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_clear_blocklist_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.clear.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::BlocklistClearAllItemsPrompt.into() + ); + } + + #[test] + fn test_clear_blocklist_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.clear.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + } + + #[test] + fn test_sort_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.sort.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::BlocklistSortPrompt.into() + ); + assert_eq!( + app.data.sonarr_data.blocklist.sort.as_ref().unwrap().items, + blocklist_sorting_options() + ); + assert!(!app.data.sonarr_data.blocklist.sort_asc); + } + + #[test] + fn test_sort_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.sort.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + assert!(app.data.sonarr_data.blocklist.sort.is_none()); + assert!(!app.data.sonarr_data.blocklist.sort_asc); + } + + #[rstest] + #[case( + ActiveSonarrBlock::Blocklist, + ActiveSonarrBlock::DeleteBlocklistItemPrompt, + SonarrEvent::DeleteBlocklistItem(None) + )] + #[case( + ActiveSonarrBlock::Blocklist, + ActiveSonarrBlock::BlocklistClearAllItemsPrompt, + SonarrEvent::ClearBlocklist + )] + fn test_blocklist_prompt_confirm( + #[case] base_route: ActiveSonarrBlock, + #[case] prompt_block: ActiveSonarrBlock, + #[case] expected_action: SonarrEvent, + ) { + let mut app = App::default(); + app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + BlocklistHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + prompt_block, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(expected_action) + ); + assert_eq!(app.get_current_route(), base_route.into()); + } + } + + #[test] + fn test_blocklist_sorting_options_series_title() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| { + a.series_title + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase() + .cmp( + &b.series_title + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase(), + ) + }; + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[0].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Series Title"); + } + + #[test] + fn test_blocklist_sorting_options_source_title() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }; + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[1].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Source Title"); + } + + #[test] + fn test_blocklist_sorting_options_language() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| { + a.language + .name + .to_lowercase() + .cmp(&b.language.name.to_lowercase()) + }; + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[2].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Language"); + } + + #[test] + fn test_blocklist_sorting_options_quality() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| { + a.quality + .quality + .name + .to_lowercase() + .cmp(&b.quality.quality.name.to_lowercase()) + }; + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[3].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Quality"); + } + + #[test] + fn test_blocklist_sorting_options_date() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = + |a, b| a.date.cmp(&b.date); + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[4].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Date"); + } + + #[test] + fn test_blocklist_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if BLOCKLIST_BLOCKS.contains(&active_sonarr_block) { + assert!(BlocklistHandler::accepts(active_sonarr_block)); + } else { + assert!(!BlocklistHandler::accepts(active_sonarr_block)); + } + }) + } + + #[test] + fn test_blocklist_handler_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.is_loading = true; + + let handler = BlocklistHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_blocklist_handler_not_ready_when_blocklist_is_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.is_loading = false; + + let handler = BlocklistHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_blocklist_handler_ready_when_not_loading_and_blocklist_is_not_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); + app.is_loading = false; + app + .data + .sonarr_data + .blocklist + .set_items(vec![BlocklistItem::default()]); + + let handler = BlocklistHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::Blocklist, + None, + ); + + assert!(handler.is_ready()); + } + + fn blocklist_vec() -> Vec { + vec![ + BlocklistItem { + id: 3, + source_title: "test 1".to_owned(), + language: Language { + id: 1, + name: "telgu".to_owned(), + }, + quality: QualityWrapper { + quality: Quality { + name: "HD - 1080p".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-01-10T07:28:45Z").unwrap()), + series_title: Some("test 3".into()), + ..BlocklistItem::default() + }, + BlocklistItem { + id: 2, + source_title: "test 2".to_owned(), + language: Language { + id: 3, + name: "chinese".to_owned(), + }, + quality: QualityWrapper { + quality: Quality { + name: "SD - 720p".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + series_title: Some("test 2".into()), + ..BlocklistItem::default() + }, + BlocklistItem { + id: 1, + source_title: "test 3".to_owned(), + language: Language { + id: 1, + name: "english".to_owned(), + }, + quality: QualityWrapper { + quality: Quality { + name: "HD - 1080p".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()), + series_title: None, + ..BlocklistItem::default() + }, + ] + } + + fn sort_options() -> Vec> { + vec![SortOption { + name: "Test 1", + cmp_fn: Some(|a, b| { + b.source_title + .to_lowercase() + .cmp(&a.source_title.to_lowercase()) + }), + }] + } +} diff --git a/src/handlers/sonarr_handlers/blocklist/mod.rs b/src/handlers/sonarr_handlers/blocklist/mod.rs new file mode 100644 index 0000000..46d92ad --- /dev/null +++ b/src/handlers/sonarr_handlers/blocklist/mod.rs @@ -0,0 +1,279 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::App; +use crate::event::Key; +use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, BLOCKLIST_BLOCKS}; +use crate::models::sonarr_models::BlocklistItem; +use crate::models::stateful_table::SortOption; +use crate::models::Scrollable; +use crate::network::sonarr_network::SonarrEvent; + +#[cfg(test)] +#[path = "blocklist_handler_tests.rs"] +mod blocklist_handler_tests; + +pub(super) struct BlocklistHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for BlocklistHandler<'a, 'b> { + fn accepts(active_block: ActiveSonarrBlock) -> bool { + BLOCKLIST_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + context: Option, + ) -> Self { + BlocklistHandler { + key, + app, + active_sonarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.sonarr_data.blocklist.is_empty() + } + + fn handle_scroll_up(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Blocklist => self.app.data.sonarr_data.blocklist.scroll_up(), + ActiveSonarrBlock::BlocklistSortPrompt => self + .app + .data + .sonarr_data + .blocklist + .sort + .as_mut() + .unwrap() + .scroll_up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Blocklist => self.app.data.sonarr_data.blocklist.scroll_down(), + ActiveSonarrBlock::BlocklistSortPrompt => self + .app + .data + .sonarr_data + .blocklist + .sort + .as_mut() + .unwrap() + .scroll_down(), + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Blocklist => self.app.data.sonarr_data.blocklist.scroll_to_top(), + ActiveSonarrBlock::BlocklistSortPrompt => self + .app + .data + .sonarr_data + .blocklist + .sort + .as_mut() + .unwrap() + .scroll_to_top(), + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Blocklist => self.app.data.sonarr_data.blocklist.scroll_to_bottom(), + ActiveSonarrBlock::BlocklistSortPrompt => self + .app + .data + .sonarr_data + .blocklist + .sort + .as_mut() + .unwrap() + .scroll_to_bottom(), + _ => (), + } + } + + fn handle_delete(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::Blocklist { + self + .app + .push_navigation_stack(ActiveSonarrBlock::DeleteBlocklistItemPrompt.into()); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Blocklist => handle_change_tab_left_right_keys(self.app, self.key), + ActiveSonarrBlock::DeleteBlocklistItemPrompt + | ActiveSonarrBlock::BlocklistClearAllItemsPrompt => handle_prompt_toggle(self.app, self.key), + _ => {} + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::DeleteBlocklistItemPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DeleteBlocklistItem(None)); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::BlocklistClearAllItemsPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::ClearBlocklist); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::BlocklistSortPrompt => { + self + .app + .data + .sonarr_data + .blocklist + .items + .sort_by(|a, b| a.id.cmp(&b.id)); + self.app.data.sonarr_data.blocklist.apply_sorting(); + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::Blocklist => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::BlocklistItemDetails.into()); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::DeleteBlocklistItemPrompt + | ActiveSonarrBlock::BlocklistClearAllItemsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.prompt_confirm = false; + } + ActiveSonarrBlock::BlocklistItemDetails | ActiveSonarrBlock::BlocklistSortPrompt => { + self.app.pop_navigation_stack(); + } + _ => handle_clear_errors(self.app), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_sonarr_block { + ActiveSonarrBlock::Blocklist => match self.key { + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { + self.app.should_refresh = true; + } + _ if key == DEFAULT_KEYBINDINGS.clear.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::BlocklistClearAllItemsPrompt.into()); + } + _ if key == DEFAULT_KEYBINDINGS.sort.key => { + self + .app + .data + .sonarr_data + .blocklist + .sorting(blocklist_sorting_options()); + self + .app + .push_navigation_stack(ActiveSonarrBlock::BlocklistSortPrompt.into()); + } + _ => (), + }, + ActiveSonarrBlock::DeleteBlocklistItemPrompt => { + if key == DEFAULT_KEYBINDINGS.confirm.key { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DeleteBlocklistItem(None)); + + self.app.pop_navigation_stack(); + } + } + ActiveSonarrBlock::BlocklistClearAllItemsPrompt => { + if key == DEFAULT_KEYBINDINGS.confirm.key { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::ClearBlocklist); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } +} + +fn blocklist_sorting_options() -> Vec> { + vec![ + SortOption { + name: "Series Title", + cmp_fn: Some(|a, b| { + a.series_title + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase() + .cmp( + &b.series_title + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase(), + ) + }), + }, + SortOption { + name: "Source Title", + cmp_fn: Some(|a, b| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }), + }, + SortOption { + name: "Language", + cmp_fn: Some(|a, b| { + a.language + .name + .to_lowercase() + .cmp(&b.language.name.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/sonarr_handlers/mod.rs b/src/handlers/sonarr_handlers/mod.rs index c3a26f5..fd0b895 100644 --- a/src/handlers/sonarr_handlers/mod.rs +++ b/src/handlers/sonarr_handlers/mod.rs @@ -9,6 +9,7 @@ use crate::{ use super::KeyEventHandler; +mod blocklist; mod downloads; mod library; diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 335b54a..1d277c5 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -322,6 +322,14 @@ pub const ADD_SERIES_SELECTION_BLOCKS: &[&[ActiveSonarrBlock]] = &[ &[ActiveSonarrBlock::AddSeriesConfirmPrompt], ]; +pub static BLOCKLIST_BLOCKS: [ActiveSonarrBlock; 5] = [ + ActiveSonarrBlock::Blocklist, + ActiveSonarrBlock::BlocklistItemDetails, + ActiveSonarrBlock::DeleteBlocklistItemPrompt, + ActiveSonarrBlock::BlocklistClearAllItemsPrompt, + ActiveSonarrBlock::BlocklistSortPrompt, +]; + pub static EDIT_SERIES_BLOCKS: [ActiveSonarrBlock; 9] = [ ActiveSonarrBlock::EditSeriesPrompt, ActiveSonarrBlock::EditSeriesConfirmPrompt, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 0f60183..e851c9b 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -202,8 +202,8 @@ mod tests { mod active_sonarr_block_tests { use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, DELETE_SERIES_BLOCKS, - DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_SERIES_BLOCKS, + ActiveSonarrBlock, ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, BLOCKLIST_BLOCKS, + DELETE_SERIES_BLOCKS, DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_SERIES_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, LIBRARY_BLOCKS, }; @@ -276,6 +276,16 @@ mod tests { assert_eq!(add_series_block_iter.next(), None); } + #[test] + fn test_blocklist_blocks_contents() { + assert_eq!(BLOCKLIST_BLOCKS.len(), 5); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveSonarrBlock::Blocklist)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveSonarrBlock::BlocklistItemDetails)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveSonarrBlock::DeleteBlocklistItemPrompt)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveSonarrBlock::BlocklistClearAllItemsPrompt)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveSonarrBlock::BlocklistSortPrompt)); + } + #[test] fn test_edit_movie_blocks_contents() { assert_eq!(EDIT_SERIES_BLOCKS.len(), 9); diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index d98d9a7..1e8bcb9 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -78,6 +78,8 @@ pub struct BlocklistItem { pub id: i64, #[serde(deserialize_with = "super::from_i64")] pub series_id: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub series_title: Option, pub episode_ids: Vec, pub source_title: String, pub language: Language, diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index d87fbaa..e55160b 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -19,7 +19,7 @@ use crate::{ LogResponse, QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ - AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, BlocklistResponse, + AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DeleteSeriesParams, DownloadRecord, DownloadsResponse, EditSeriesParams, Episode, IndexerSettings, Series, SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, SonarrRelease, SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, @@ -1303,7 +1303,27 @@ impl<'a, 'b> Network<'a, 'b> { app.get_current_route(), Route::Sonarr(ActiveSonarrBlock::BlocklistSortPrompt, _) ) { - let mut blocklist_vec = blocklist_resp.records; + let mut blocklist_vec: Vec = blocklist_resp + .records + .into_iter() + .map(|item| { + if let Some(series) = app + .data + .sonarr_data + .series + .items + .iter() + .find(|it| it.id == item.series_id) + { + BlocklistItem { + series_title: Some(series.title.text.clone()), + ..item + } + } else { + item + } + }) + .collect(); blocklist_vec.sort_by(|a, b| a.id.cmp(&b.id)); app.data.sonarr_data.blocklist.set_items(blocklist_vec); app.data.sonarr_data.blocklist.apply_sorting_toggle(false); diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index d417562..0493a0f 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -1956,6 +1956,7 @@ mod test { BlocklistItem { id: 123, series_id: 1007, + series_title: Some("Z Series".into()), source_title: "z series".into(), episode_ids: vec![Number::from(42020)], ..blocklist_item() @@ -1978,6 +1979,17 @@ mod test { None, ) .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1007, + title: "Z Series".into(), + ..series() + }]); app_arc.lock().await.data.sonarr_data.blocklist.sort_asc = true; if use_custom_sorting { let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| { @@ -6682,6 +6694,7 @@ mod test { BlocklistItem { id: 1, series_id: 1, + series_title: None, episode_ids: vec![Number::from(1)], source_title: "Test Source Title".to_owned(), language: language(), From 3186fb42e71e4a69b18e59233c05348172a890e1 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 16:39:40 -0700 Subject: [PATCH 18/82] feat(handler): Wired in the blocklist handler to the main handlers --- src/handlers/sonarr_handlers/mod.rs | 4 ++++ .../sonarr_handlers/sonarr_handler_tests.rs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/handlers/sonarr_handlers/mod.rs b/src/handlers/sonarr_handlers/mod.rs index fd0b895..18f36fd 100644 --- a/src/handlers/sonarr_handlers/mod.rs +++ b/src/handlers/sonarr_handlers/mod.rs @@ -1,3 +1,4 @@ +use blocklist::BlocklistHandler; use downloads::DownloadsHandler; use library::LibraryHandler; @@ -37,6 +38,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SonarrHandler<'a, 'b _ if DownloadsHandler::accepts(self.active_sonarr_block) => { DownloadsHandler::with(self.key, self.app, self.active_sonarr_block, self.context).handle() } + _ if BlocklistHandler::accepts(self.active_sonarr_block) => { + BlocklistHandler::with(self.key, self.app, self.active_sonarr_block, self.context).handle() + } _ => self.handle_key_event(), } } diff --git a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs index e9c21a4..99d438c 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs @@ -127,4 +127,22 @@ mod tests { active_sonarr_block ); } + + #[rstest] + fn test_delegates_blocklist_blocks_to_blocklist_handler( + #[values( + ActiveSonarrBlock::Blocklist, + ActiveSonarrBlock::BlocklistItemDetails, + ActiveSonarrBlock::DeleteBlocklistItemPrompt, + ActiveSonarrBlock::BlocklistClearAllItemsPrompt, + ActiveSonarrBlock::BlocklistSortPrompt + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + SonarrHandler, + ActiveSonarrBlock::Blocklist, + active_sonarr_block + ); + } } From 1c6e7986320c55f86d66a96876cb02380ac75438 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 16:54:27 -0700 Subject: [PATCH 19/82] feat(ui): Blocklist UI support --- .../sonarr_ui/blocklist/blocklist_ui_tests.rs | 18 ++ src/ui/sonarr_ui/blocklist/mod.rs | 178 ++++++++++++++++++ src/ui/sonarr_ui/mod.rs | 3 + 3 files changed, 199 insertions(+) create mode 100644 src/ui/sonarr_ui/blocklist/blocklist_ui_tests.rs create mode 100644 src/ui/sonarr_ui/blocklist/mod.rs diff --git a/src/ui/sonarr_ui/blocklist/blocklist_ui_tests.rs b/src/ui/sonarr_ui/blocklist/blocklist_ui_tests.rs new file mode 100644 index 0000000..009adc0 --- /dev/null +++ b/src/ui/sonarr_ui/blocklist/blocklist_ui_tests.rs @@ -0,0 +1,18 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, BLOCKLIST_BLOCKS}; + use crate::ui::sonarr_ui::blocklist::BlocklistUi; + use crate::ui::DrawUi; + use strum::IntoEnumIterator; + + #[test] + fn test_blocklist_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if BLOCKLIST_BLOCKS.contains(&active_sonarr_block) { + assert!(BlocklistUi::accepts(active_sonarr_block.into())); + } else { + assert!(!BlocklistUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/blocklist/mod.rs b/src/ui/sonarr_ui/blocklist/mod.rs new file mode 100644 index 0000000..2cf068e --- /dev/null +++ b/src/ui/sonarr_ui/blocklist/mod.rs @@ -0,0 +1,178 @@ +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, BLOCKLIST_BLOCKS}; +use crate::models::sonarr_models::BlocklistItem; +use crate::models::Route; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::DrawUi; +use ratatui::layout::{Alignment, Constraint, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Cell, Row}; +use ratatui::Frame; + +#[cfg(test)] +#[path = "blocklist_ui_tests.rs"] +mod blocklist_ui_tests; + +pub(super) struct BlocklistUi; + +impl DrawUi for BlocklistUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return BLOCKLIST_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + match active_sonarr_block { + ActiveSonarrBlock::Blocklist | ActiveSonarrBlock::BlocklistSortPrompt => { + draw_blocklist_table(f, app, area) + } + ActiveSonarrBlock::BlocklistItemDetails => { + draw_blocklist_table(f, app, area); + draw_blocklist_item_details_popup(f, app); + } + ActiveSonarrBlock::DeleteBlocklistItemPrompt => { + let prompt = format!( + "Do you want to remove this item from your blocklist: \n{}?", + app + .data + .sonarr_data + .blocklist + .current_selection() + .source_title + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Remove Item from Blocklist") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + draw_blocklist_table(f, app, area); + f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + } + ActiveSonarrBlock::BlocklistClearAllItemsPrompt => { + let confirmation_prompt = ConfirmationPrompt::new() + .title("Clear Blocklist") + .prompt("Do you want to clear your blocklist?") + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + draw_blocklist_table(f, app, area); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::SmallPrompt), + f.area(), + ); + } + _ => (), + } + } + } +} + +fn draw_blocklist_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let current_selection = if app.data.sonarr_data.blocklist.items.is_empty() { + BlocklistItem::default() + } else { + app.data.sonarr_data.blocklist.current_selection().clone() + }; + let blocklist_table_footer = app + .data + .sonarr_data + .main_tabs + .get_active_tab_contextual_help(); + + let blocklist_row_mapping = |blocklist_item: &BlocklistItem| { + let BlocklistItem { + source_title, + series_title, + language, + quality, + date, + .. + } = blocklist_item; + + let title = series_title.as_ref().unwrap_or(&String::new()).to_owned(); + + Row::new(vec![ + Cell::from(title), + Cell::from(source_title.to_owned()), + Cell::from(language.name.to_owned()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let blocklist_table = ManagarrTable::new( + Some(&mut app.data.sonarr_data.blocklist), + blocklist_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(blocklist_table_footer) + .sorting(active_sonarr_block == ActiveSonarrBlock::BlocklistSortPrompt) + .headers([ + "Series Title", + "Source Title", + "Languages", + "Quality", + "Date", + ]) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(40), + Constraint::Percentage(10), + Constraint::Percentage(10), + Constraint::Percentage(15), + ]); + + f.render_widget(blocklist_table, area); + } +} + +fn draw_blocklist_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = if app.data.sonarr_data.blocklist.items.is_empty() { + BlocklistItem::default() + } else { + app.data.sonarr_data.blocklist.current_selection().clone() + }; + let BlocklistItem { + source_title, + protocol, + indexer, + message, + .. + } = current_selection; + let text = Text::from(vec![ + Line::from(vec![ + "Name: ".bold().secondary(), + source_title.to_owned().secondary(), + ]), + Line::from(vec![ + "Protocol: ".bold().secondary(), + protocol.to_owned().secondary(), + ]), + Line::from(vec![ + "Indexer: ".bold().secondary(), + indexer.to_owned().secondary(), + ]), + Line::from(vec![ + "Message: ".bold().secondary(), + message.to_owned().secondary(), + ]), + ]); + + let message = Message::new(text) + .title("Details") + .style(Style::new().secondary()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowMessage), f.area()); +} diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index b10f4aa..7fac977 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -1,5 +1,6 @@ use std::{cmp, iter}; +use blocklist::BlocklistUi; use chrono::{Duration, Utc}; use downloads::DownloadsUi; use library::LibraryUi; @@ -33,6 +34,7 @@ use super::{ DrawUi, }; +mod blocklist; mod downloads; mod library; @@ -54,6 +56,7 @@ impl DrawUi for SonarrUi { match route { _ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area), _ if DownloadsUi::accepts(route) => DownloadsUi::draw(f, app, content_area), + _ if BlocklistUi::accepts(route) => BlocklistUi::draw(f, app, content_area), _ => (), } } From 4f5bad5874c8e732c6c869445d4f8102fa287c6d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 18:03:59 -0700 Subject: [PATCH 20/82] feat(handler): History tab support --- README.md | 2 +- src/app/sonarr/mod.rs | 2 +- src/app/sonarr/sonarr_context_clues.rs | 3 +- src/app/sonarr/sonarr_context_clues_tests.rs | 5 - src/app/sonarr/sonarr_tests.rs | 2 +- .../blocklist/blocklist_handler_tests.rs | 4 +- .../history/history_handler_tests.rs | 1396 +++++++++++++++++ src/handlers/sonarr_handlers/history/mod.rs | 354 +++++ src/handlers/sonarr_handlers/mod.rs | 5 + .../sonarr_handlers/sonarr_handler_tests.rs | 20 + src/models/servarr_data/sonarr/sonarr_data.rs | 14 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 14 +- src/ui/sonarr_ui/blocklist/mod.rs | 7 +- 13 files changed, 1805 insertions(+), 23 deletions(-) create mode 100644 src/handlers/sonarr_handlers/history/history_handler_tests.rs create mode 100644 src/handlers/sonarr_handlers/history/mod.rs diff --git a/README.md b/README.md index 145512e..56e687b 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Key: | 🕒 | ✅ | Search your library | | 🕒 | ✅ | Add series to your library | | 🕒 | ✅ | Delete series, downloads, indexers, root folders, and episode files | -| 🕒 | ✅ | Mark history events as failed | +| 🚫 | ✅ | Mark history events as failed | | 🕒 | ✅ | Trigger automatic searches for series, seasons, or episodes | | 🕒 | ✅ | Trigger refresh and disk scan for series and downloads | | 🕒 | ✅ | Manually search for series, seasons, or episodes | diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index 9b6a30b..563d7b5 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -69,7 +69,7 @@ impl<'a> App<'a> { } ActiveSonarrBlock::History => { self - .dispatch_network_event(SonarrEvent::GetHistory(None).into()) + .dispatch_network_event(SonarrEvent::GetHistory(Some(10000)).into()) .await; } ActiveSonarrBlock::RootFolders => { diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index ee311ec..9aacf69 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -25,9 +25,8 @@ pub static SERIES_CONTEXT_CLUES: [ContextClue; 10] = [ (DEFAULT_KEYBINDINGS.esc, "cancel filter"), ]; -pub static HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [ +pub static HISTORY_CONTEXT_CLUES: [ContextClue; 5] = [ (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), - (DEFAULT_KEYBINDINGS.delete, "mark as failed"), (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), ( diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index d25992c..c72b0b2 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -96,11 +96,6 @@ mod tests { let (key_binding, description) = history_context_clues_iter.next().unwrap(); - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); - assert_str_eq!(*description, "mark as failed"); - - let (key_binding, description) = history_context_clues_iter.next().unwrap(); - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); assert_str_eq!(*description, DEFAULT_KEYBINDINGS.search.desc); diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index a02c217..123212b 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -179,7 +179,7 @@ mod tests { assert!(app.is_loading); assert_eq!( sync_network_rx.recv().await.unwrap(), - SonarrEvent::GetHistory(None).into() + SonarrEvent::GetHistory(Some(10000)).into() ); assert!(!app.data.sonarr_data.prompt_confirm); assert_eq!(app.tick_count, 0); diff --git a/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs index 6bf173b..026f842 100644 --- a/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs +++ b/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs @@ -475,8 +475,6 @@ mod tests { use pretty_assertions::assert_eq; use rstest::rstest; - use crate::handlers::sonarr_handlers::downloads::DownloadsHandler; - use super::*; const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; @@ -547,7 +545,7 @@ mod tests { app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); - DownloadsHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); + BlocklistHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); assert!(app.error.text.is_empty()); diff --git a/src/handlers/sonarr_handlers/history/history_handler_tests.rs b/src/handlers/sonarr_handlers/history/history_handler_tests.rs new file mode 100644 index 0000000..e981ec6 --- /dev/null +++ b/src/handlers/sonarr_handlers/history/history_handler_tests.rs @@ -0,0 +1,1396 @@ +#[cfg(test)] +mod tests { + use core::sync::atomic::Ordering::SeqCst; + use std::cmp::Ordering; + + use chrono::DateTime; + use pretty_assertions::{assert_eq, assert_str_eq}; + use strum::IntoEnumIterator; + + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::history::{history_sorting_options, HistoryHandler}; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS}; + use crate::models::servarr_models::{Language, Quality, QualityWrapper}; + use crate::models::sonarr_models::SonarrHistoryItem; + use crate::models::stateful_table::SortOption; + use crate::models::HorizontallyScrollableText; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + + use crate::models::sonarr_models::SonarrHistoryItem; + use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; + + use super::*; + + test_iterable_scroll!( + test_history_scroll, + HistoryHandler, + sonarr_data, + history, + simple_stateful_iterable_vec!(SonarrHistoryItem, HorizontallyScrollableText, source_title), + ActiveSonarrBlock::History, + None, + source_title, + to_string + ); + + #[rstest] + fn test_history_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.is_loading = true; + app + .data + .sonarr_data + .history + .set_items(simple_stateful_iterable_vec!( + SonarrHistoryItem, + HorizontallyScrollableText, + source_title + )); + + HistoryHandler::with(key, &mut app, ActiveSonarrBlock::History, None).handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .history + .current_selection() + .source_title + .to_string(), + "Test 1" + ); + + HistoryHandler::with(key, &mut app, ActiveSonarrBlock::History, None).handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .history + .current_selection() + .source_title + .to_string(), + "Test 1" + ); + } + + #[rstest] + fn test_history_sort_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let history_field_vec = sort_options(); + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.data.sonarr_data.history.sorting(sort_options()); + + if key == Key::Up { + for i in (0..history_field_vec.len()).rev() { + HistoryHandler::with(key, &mut app, ActiveSonarrBlock::HistorySortPrompt, None).handle(); + + assert_eq!( + app + .data + .sonarr_data + .history + .sort + .as_ref() + .unwrap() + .current_selection(), + &history_field_vec[i] + ); + } + } else { + for i in 0..history_field_vec.len() { + HistoryHandler::with(key, &mut app, ActiveSonarrBlock::HistorySortPrompt, None).handle(); + + assert_eq!( + app + .data + .sonarr_data + .history + .sort + .as_ref() + .unwrap() + .current_selection(), + &history_field_vec[(i + 1) % history_field_vec.len()] + ); + } + } + } + } + + mod test_handle_home_end { + use pretty_assertions::{assert_eq, assert_str_eq}; + + use crate::models::sonarr_models::SonarrHistoryItem; + use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; + + use super::*; + + test_iterable_home_and_end!( + test_history_home_and_end, + HistoryHandler, + sonarr_data, + history, + extended_stateful_iterable_vec!(SonarrHistoryItem, HorizontallyScrollableText, source_title), + ActiveSonarrBlock::History, + None, + source_title, + to_string + ); + + #[test] + fn test_history_home_and_end_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.is_loading = true; + app + .data + .sonarr_data + .history + .set_items(extended_stateful_iterable_vec!( + SonarrHistoryItem, + HorizontallyScrollableText, + source_title + )); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .history + .current_selection() + .source_title + .to_string(), + "Test 1" + ); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .history + .current_selection() + .source_title + .to_string(), + "Test 1" + ); + } + + #[test] + fn test_history_search_box_home_end_keys() { + let mut app = App::default(); + app + .data + .sonarr_data + .history + .set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.history.search = Some("Test".into()); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SearchHistory, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .history + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 4 + ); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SearchHistory, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .history + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 0 + ); + } + + #[test] + fn test_history_filter_box_home_end_keys() { + let mut app = App::default(); + app + .data + .sonarr_data + .history + .set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.history.filter = Some("Test".into()); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::FilterHistory, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .history + .filter + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 4 + ); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::FilterHistory, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .history + .filter + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 0 + ); + } + + #[test] + fn test_history_sort_home_end() { + let history_field_vec = sort_options(); + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.data.sonarr_data.history.sorting(sort_options()); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::HistorySortPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .history + .sort + .as_ref() + .unwrap() + .current_selection(), + &history_field_vec[history_field_vec.len() - 1] + ); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::HistorySortPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .history + .sort + .as_ref() + .unwrap() + .current_selection(), + &history_field_vec[0] + ); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_history_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(3); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::Blocklist.into() + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); + } + + #[rstest] + fn test_history_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(3); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::RootFolders.into() + ); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::RootFolders.into() + ); + } + + #[test] + fn test_history_search_box_left_right_keys() { + let mut app = App::default(); + app.data.sonarr_data.history.search = Some("Test".into()); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::SearchHistory, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .history + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 1 + ); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::SearchHistory, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .history + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 0 + ); + } + + #[test] + fn test_history_filter_box_left_right_keys() { + let mut app = App::default(); + app.data.sonarr_data.history.filter = Some("Test".into()); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::FilterHistory, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .history + .filter + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 1 + ); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::FilterHistory, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .history + .filter + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use crate::extended_stateful_iterable_vec; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_history_submit() { + let mut app = App::default(); + app.data.sonarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + + HistoryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::History, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::HistoryItemDetails.into() + ); + } + + #[test] + fn test_history_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.data.sonarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + + HistoryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::History, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + } + + #[test] + fn test_search_history_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); + app + .data + .sonarr_data + .history + .set_items(extended_stateful_iterable_vec!( + SonarrHistoryItem, + HorizontallyScrollableText, + source_title + )); + app.data.sonarr_data.history.search = Some("Test 2".into()); + + HistoryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchHistory, None).handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .history + .current_selection() + .source_title + .text, + "Test 2" + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + } + + #[test] + fn test_search_history_submit_error_on_no_search_hits() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); + app + .data + .sonarr_data + .history + .set_items(extended_stateful_iterable_vec!( + SonarrHistoryItem, + HorizontallyScrollableText, + source_title + )); + app.data.sonarr_data.history.search = Some("Test 5".into()); + + HistoryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchHistory, None).handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .history + .current_selection() + .source_title + .text, + "Test 1" + ); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SearchHistoryError.into() + ); + } + + #[test] + fn test_search_filtered_history_submit() { + let mut app = App::default(); + app + .data + .sonarr_data + .history + .set_items(vec![SonarrHistoryItem::default()]); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); + app + .data + .sonarr_data + .history + .set_filtered_items(extended_stateful_iterable_vec!( + SonarrHistoryItem, + HorizontallyScrollableText, + source_title + )); + app.data.sonarr_data.history.search = Some("Test 2".into()); + + HistoryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchHistory, None).handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .history + .current_selection() + .source_title + .text, + "Test 2" + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + } + + #[test] + fn test_filter_history_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.push_navigation_stack(ActiveSonarrBlock::FilterHistory.into()); + app + .data + .sonarr_data + .history + .set_items(extended_stateful_iterable_vec!( + SonarrHistoryItem, + HorizontallyScrollableText, + source_title + )); + app.data.sonarr_data.history.filter = Some("Test".into()); + + HistoryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::FilterHistory, None).handle(); + + assert!(app.data.sonarr_data.history.filtered_items.is_some()); + assert!(!app.should_ignore_quit_key); + assert_eq!( + app + .data + .sonarr_data + .history + .filtered_items + .as_ref() + .unwrap() + .len(), + 3 + ); + assert_str_eq!( + app + .data + .sonarr_data + .history + .current_selection() + .source_title + .text, + "Test 1" + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + } + + #[test] + fn test_filter_history_submit_error_on_no_filter_matches() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.push_navigation_stack(ActiveSonarrBlock::FilterHistory.into()); + app + .data + .sonarr_data + .history + .set_items(extended_stateful_iterable_vec!( + SonarrHistoryItem, + HorizontallyScrollableText, + source_title + )); + app.data.sonarr_data.history.filter = Some("Test 5".into()); + + HistoryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::FilterHistory, None).handle(); + + assert!(!app.should_ignore_quit_key); + assert!(app.data.sonarr_data.history.filtered_items.is_none()); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::FilterHistoryError.into() + ); + } + + #[test] + fn test_history_sort_prompt_submit() { + let mut app = App::default(); + app.data.sonarr_data.history.sort_asc = true; + app.data.sonarr_data.history.sorting(sort_options()); + app.data.sonarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.push_navigation_stack(ActiveSonarrBlock::HistorySortPrompt.into()); + + let mut expected_vec = history_vec(); + expected_vec.sort_by(|a, b| a.id.cmp(&b.id)); + expected_vec.reverse(); + + HistoryHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::HistorySortPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + assert_eq!(app.data.sonarr_data.history.items, expected_vec); + } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + use ratatui::widgets::TableState; + use rstest::rstest; + + use crate::models::{ + servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data, + stateful_table::StatefulTable, + }; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_search_history_block_esc( + #[values( + ActiveSonarrBlock::SearchHistory, + ActiveSonarrBlock::SearchHistoryError + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.push_navigation_stack(active_sonarr_block.into()); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.history.search = Some("Test".into()); + + HistoryHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + assert!(!app.should_ignore_quit_key); + assert_eq!(app.data.sonarr_data.history.search, None); + } + + #[rstest] + fn test_filter_history_block_esc( + #[values( + ActiveSonarrBlock::FilterHistory, + ActiveSonarrBlock::FilterHistoryError + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.push_navigation_stack(active_sonarr_block.into()); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.history = StatefulTable { + filter: Some("Test".into()), + filtered_items: Some(Vec::new()), + filtered_state: Some(TableState::default()), + ..StatefulTable::default() + }; + + HistoryHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + assert!(!app.should_ignore_quit_key); + assert_eq!(app.data.sonarr_data.history.filter, None); + assert_eq!(app.data.sonarr_data.history.filtered_items, None); + assert_eq!(app.data.sonarr_data.history.filtered_state, None); + } + + #[test] + fn test_esc_history_item_details() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.push_navigation_stack(ActiveSonarrBlock::HistoryItemDetails.into()); + + HistoryHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::HistoryItemDetails, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + } + + #[test] + fn test_history_sort_prompt_block_esc() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.push_navigation_stack(ActiveSonarrBlock::HistorySortPrompt.into()); + + HistoryHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::HistorySortPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + + HistoryHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::History, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + assert!(app.error.text.is_empty()); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; + + use super::*; + + #[test] + fn test_search_history_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .history + .set_items(vec![SonarrHistoryItem::default()]); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SearchHistory.into() + ); + assert!(app.should_ignore_quit_key); + assert_eq!( + app.data.sonarr_data.history.search, + Some(HorizontallyScrollableText::default()) + ); + } + + #[test] + fn test_search_history_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app + .data + .sonarr_data + .history + .set_items(vec![SonarrHistoryItem::default()]); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + assert!(!app.should_ignore_quit_key); + assert_eq!(app.data.sonarr_data.history.search, None); + } + + #[test] + fn test_filter_history_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .history + .set_items(vec![SonarrHistoryItem::default()]); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::FilterHistory.into() + ); + assert!(app.should_ignore_quit_key); + assert!(app.data.sonarr_data.history.filter.is_some()); + } + + #[test] + fn test_filter_history_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app + .data + .sonarr_data + .history + .set_items(vec![SonarrHistoryItem::default()]); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + assert!(!app.should_ignore_quit_key); + assert!(app.data.sonarr_data.history.filter.is_none()); + } + + #[test] + fn test_filter_history_key_resets_previous_filter() { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .history + .set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.history.filter = Some("Test".into()); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::FilterHistory.into() + ); + assert!(app.should_ignore_quit_key); + assert_eq!( + app.data.sonarr_data.history.filter, + Some(HorizontallyScrollableText::default()) + ); + assert!(app.data.sonarr_data.history.filtered_items.is_none()); + assert!(app.data.sonarr_data.history.filtered_state.is_none()); + } + + #[test] + fn test_refresh_history_key() { + let mut app = App::default(); + app.data.sonarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_history_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.data.sonarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_search_history_box_backspace_key() { + let mut app = App::default(); + app.data.sonarr_data.history.search = Some("Test".into()); + app + .data + .sonarr_data + .history + .set_items(vec![SonarrHistoryItem::default()]); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::SearchHistory, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.history.search.as_ref().unwrap().text, + "Tes" + ); + } + + #[test] + fn test_filter_history_box_backspace_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .history + .set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.history.filter = Some("Test".into()); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::FilterHistory, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.history.filter.as_ref().unwrap().text, + "Tes" + ); + } + + #[test] + fn test_search_history_box_char_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .history + .set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.history.search = Some(HorizontallyScrollableText::default()); + + HistoryHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::SearchHistory, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.history.search.as_ref().unwrap().text, + "h" + ); + } + + #[test] + fn test_filter_history_box_char_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .history + .set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.history.filter = Some(HorizontallyScrollableText::default()); + + HistoryHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::FilterHistory, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.history.filter.as_ref().unwrap().text, + "h" + ); + } + + #[test] + fn test_sort_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.data.sonarr_data.history.set_items(history_vec()); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.sort.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::HistorySortPrompt.into() + ); + assert_eq!( + app.data.sonarr_data.history.sort.as_ref().unwrap().items, + history_sorting_options() + ); + assert!(!app.data.sonarr_data.history.sort_asc); + } + + #[test] + fn test_sort_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.data.sonarr_data.history.set_items(history_vec()); + + HistoryHandler::with( + DEFAULT_KEYBINDINGS.sort.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + assert!(app.data.sonarr_data.history.sort.is_none()); + assert!(!app.data.sonarr_data.history.sort_asc); + } + } + + #[test] + fn test_history_sorting_options_source_title() { + let expected_cmp_fn: fn(&SonarrHistoryItem, &SonarrHistoryItem) -> 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(&SonarrHistoryItem, &SonarrHistoryItem) -> Ordering = |a, b| { + a.event_type + .to_lowercase() + .cmp(&b.event_type.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_language() { + let expected_cmp_fn: fn(&SonarrHistoryItem, &SonarrHistoryItem) -> Ordering = |a, b| { + a.language + .name + .to_lowercase() + .cmp(&b.language.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, "Language"); + } + + #[test] + fn test_history_sorting_options_quality() { + let expected_cmp_fn: fn(&SonarrHistoryItem, &SonarrHistoryItem) -> 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()[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, "Quality"); + } + + #[test] + fn test_history_sorting_options_date() { + let expected_cmp_fn: fn(&SonarrHistoryItem, &SonarrHistoryItem) -> 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()[4].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"); + } + + #[test] + fn test_history_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if HISTORY_BLOCKS.contains(&active_sonarr_block) { + assert!(HistoryHandler::accepts(active_sonarr_block)); + } else { + assert!(!HistoryHandler::accepts(active_sonarr_block)); + } + }) + } + + #[test] + fn test_history_handler_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.is_loading = true; + + let handler = HistoryHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::History, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_history_handler_not_ready_when_history_is_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.is_loading = false; + + let handler = HistoryHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::History, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_history_handler_ready_when_not_loading_and_history_is_not_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.is_loading = false; + app + .data + .sonarr_data + .history + .set_items(vec![SonarrHistoryItem::default()]); + + let handler = HistoryHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::History, + None, + ); + + assert!(handler.is_ready()); + } + + fn history_vec() -> Vec { + vec![ + SonarrHistoryItem { + id: 3, + source_title: "test 1".into(), + event_type: "grabbed".to_owned(), + language: Language { + id: 1, + name: "telgu".to_owned(), + }, + quality: QualityWrapper { + quality: Quality { + name: "HD - 1080p".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-01-10T07:28:45Z").unwrap()), + ..SonarrHistoryItem::default() + }, + SonarrHistoryItem { + id: 2, + source_title: "test 2".into(), + event_type: "downloadFolderImported".to_owned(), + language: Language { + id: 3, + name: "chinese".to_owned(), + }, + quality: QualityWrapper { + quality: Quality { + name: "SD - 720p".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + ..SonarrHistoryItem::default() + }, + SonarrHistoryItem { + id: 1, + source_title: "test 3".into(), + event_type: "episodeFileDeleted".to_owned(), + language: Language { + id: 1, + name: "english".to_owned(), + }, + quality: QualityWrapper { + quality: Quality { + name: "HD - 1080p".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()), + ..SonarrHistoryItem::default() + }, + ] + } + + fn sort_options() -> Vec> { + vec![SortOption { + name: "Test 1", + cmp_fn: Some(|a, b| { + b.source_title + .text + .to_lowercase() + .cmp(&a.source_title.text.to_lowercase()) + }), + }] + } +} diff --git a/src/handlers/sonarr_handlers/history/mod.rs b/src/handlers/sonarr_handlers/history/mod.rs new file mode 100644 index 0000000..7b7069c --- /dev/null +++ b/src/handlers/sonarr_handlers/history/mod.rs @@ -0,0 +1,354 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::App; +use crate::event::Key; +use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::{handle_clear_errors, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS}; +use crate::models::sonarr_models::SonarrHistoryItem; +use crate::models::stateful_table::SortOption; +use crate::models::{HorizontallyScrollableText, Scrollable}; +use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; + +#[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_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for HistoryHandler<'a, 'b> { + fn accepts(active_block: ActiveSonarrBlock) -> bool { + HISTORY_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + context: Option, + ) -> Self { + HistoryHandler { + key, + app, + active_sonarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.sonarr_data.history.is_empty() + } + + fn handle_scroll_up(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::History => self.app.data.sonarr_data.history.scroll_up(), + ActiveSonarrBlock::HistorySortPrompt => self + .app + .data + .sonarr_data + .history + .sort + .as_mut() + .unwrap() + .scroll_up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::History => self.app.data.sonarr_data.history.scroll_down(), + ActiveSonarrBlock::HistorySortPrompt => self + .app + .data + .sonarr_data + .history + .sort + .as_mut() + .unwrap() + .scroll_down(), + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::History => self.app.data.sonarr_data.history.scroll_to_top(), + ActiveSonarrBlock::SearchHistory => { + self + .app + .data + .sonarr_data + .history + .search + .as_mut() + .unwrap() + .scroll_home(); + } + ActiveSonarrBlock::FilterHistory => { + self + .app + .data + .sonarr_data + .history + .filter + .as_mut() + .unwrap() + .scroll_home(); + } + ActiveSonarrBlock::HistorySortPrompt => self + .app + .data + .sonarr_data + .history + .sort + .as_mut() + .unwrap() + .scroll_to_top(), + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::History => self.app.data.sonarr_data.history.scroll_to_bottom(), + ActiveSonarrBlock::SearchHistory => self + .app + .data + .sonarr_data + .history + .search + .as_mut() + .unwrap() + .reset_offset(), + ActiveSonarrBlock::FilterHistory => self + .app + .data + .sonarr_data + .history + .filter + .as_mut() + .unwrap() + .reset_offset(), + ActiveSonarrBlock::HistorySortPrompt => self + .app + .data + .sonarr_data + .history + .sort + .as_mut() + .unwrap() + .scroll_to_bottom(), + _ => (), + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::History => handle_change_tab_left_right_keys(self.app, self.key), + ActiveSonarrBlock::SearchHistory => { + handle_text_box_left_right_keys!( + self, + self.key, + self.app.data.sonarr_data.history.search.as_mut().unwrap() + ) + } + ActiveSonarrBlock::FilterHistory => { + handle_text_box_left_right_keys!( + self, + self.key, + self.app.data.sonarr_data.history.filter.as_mut().unwrap() + ) + } + _ => {} + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SearchHistory => { + self.app.pop_navigation_stack(); + self.app.should_ignore_quit_key = false; + + if self.app.data.sonarr_data.history.search.is_some() { + let has_match = self + .app + .data + .sonarr_data + .history + .apply_search(|history| &history.source_title.text); + + if !has_match { + self + .app + .push_navigation_stack(ActiveSonarrBlock::SearchHistoryError.into()); + } + } + } + ActiveSonarrBlock::FilterHistory => { + self.app.pop_navigation_stack(); + self.app.should_ignore_quit_key = false; + + if self.app.data.sonarr_data.history.filter.is_some() { + let has_matches = self + .app + .data + .sonarr_data + .history + .apply_filter(|history| &history.source_title.text); + + if !has_matches { + self + .app + .push_navigation_stack(ActiveSonarrBlock::FilterHistoryError.into()); + } + } + } + ActiveSonarrBlock::HistorySortPrompt => { + self + .app + .data + .sonarr_data + .history + .items + .sort_by(|a, b| a.id.cmp(&b.id)); + self.app.data.sonarr_data.history.apply_sorting(); + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::History => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::HistoryItemDetails.into()); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::FilterHistory | ActiveSonarrBlock::FilterHistoryError => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.history.reset_filter(); + self.app.should_ignore_quit_key = false; + } + ActiveSonarrBlock::SearchHistory | ActiveSonarrBlock::SearchHistoryError => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.history.reset_search(); + self.app.should_ignore_quit_key = false; + } + ActiveSonarrBlock::HistoryItemDetails | ActiveSonarrBlock::HistorySortPrompt => { + self.app.pop_navigation_stack(); + } + _ => handle_clear_errors(self.app), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_sonarr_block { + ActiveSonarrBlock::History => match self.key { + _ if key == DEFAULT_KEYBINDINGS.search.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); + self.app.data.sonarr_data.history.search = Some(HorizontallyScrollableText::default()); + self.app.should_ignore_quit_key = true; + } + _ if key == DEFAULT_KEYBINDINGS.filter.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::FilterHistory.into()); + self.app.data.sonarr_data.history.reset_filter(); + self.app.data.sonarr_data.history.filter = Some(HorizontallyScrollableText::default()); + self.app.should_ignore_quit_key = true; + } + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { + self.app.should_refresh = true; + } + _ if key == DEFAULT_KEYBINDINGS.sort.key => { + self + .app + .data + .sonarr_data + .history + .sorting(history_sorting_options()); + self + .app + .push_navigation_stack(ActiveSonarrBlock::HistorySortPrompt.into()); + } + _ => (), + }, + ActiveSonarrBlock::SearchHistory => { + handle_text_box_keys!( + self, + key, + self.app.data.sonarr_data.history.search.as_mut().unwrap() + ) + } + ActiveSonarrBlock::FilterHistory => { + handle_text_box_keys!( + self, + key, + self.app.data.sonarr_data.history.filter.as_mut().unwrap() + ) + } + _ => (), + } + } +} + +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_lowercase() + .cmp(&b.event_type.to_lowercase()) + }), + }, + SortOption { + name: "Language", + cmp_fn: Some(|a, b| { + a.language + .name + .to_lowercase() + .cmp(&b.language.name.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/sonarr_handlers/mod.rs b/src/handlers/sonarr_handlers/mod.rs index 18f36fd..83d277b 100644 --- a/src/handlers/sonarr_handlers/mod.rs +++ b/src/handlers/sonarr_handlers/mod.rs @@ -1,5 +1,6 @@ use blocklist::BlocklistHandler; use downloads::DownloadsHandler; +use history::HistoryHandler; use library::LibraryHandler; use crate::{ @@ -12,6 +13,7 @@ use super::KeyEventHandler; mod blocklist; mod downloads; +mod history; mod library; #[cfg(test)] @@ -41,6 +43,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SonarrHandler<'a, 'b _ if BlocklistHandler::accepts(self.active_sonarr_block) => { BlocklistHandler::with(self.key, self.app, self.active_sonarr_block, self.context).handle() } + _ if HistoryHandler::accepts(self.active_sonarr_block) => { + HistoryHandler::with(self.key, self.app, self.active_sonarr_block, self.context).handle() + } _ => self.handle_key_event(), } } diff --git a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs index 99d438c..95de5ce 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs @@ -145,4 +145,24 @@ mod tests { active_sonarr_block ); } + + #[rstest] + fn test_delegates_history_blocks_to_history_handler( + #[values( + ActiveSonarrBlock::History, + ActiveSonarrBlock::HistoryItemDetails, + ActiveSonarrBlock::HistorySortPrompt, + ActiveSonarrBlock::FilterHistory, + ActiveSonarrBlock::FilterHistoryError, + ActiveSonarrBlock::SearchHistory, + ActiveSonarrBlock::SearchHistoryError + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + SonarrHandler, + ActiveSonarrBlock::History, + active_sonarr_block + ); + } } diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 1d277c5..d7f7880 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -238,7 +238,7 @@ pub enum ActiveSonarrBlock { FilterSeriesHistory, FilterSeriesHistoryError, History, - HistoryDetails, + HistoryItemDetails, HistorySortPrompt, Indexers, IndexerSettingsConfirmPrompt, @@ -252,8 +252,6 @@ pub enum ActiveSonarrBlock { ManualSeasonSearch, ManualSeasonSearchConfirmPrompt, ManualSeasonSearchSortPrompt, - MarkHistoryItemAsFailedConfirmPrompt, - MarkHistoryItemAsFailedPrompt, RootFolders, SearchEpisodes, SearchEpisodesError, @@ -372,6 +370,16 @@ pub const DELETE_SERIES_SELECTION_BLOCKS: &[&[ActiveSonarrBlock]] = &[ &[ActiveSonarrBlock::DeleteSeriesConfirmPrompt], ]; +pub static HISTORY_BLOCKS: [ActiveSonarrBlock; 7] = [ + ActiveSonarrBlock::History, + ActiveSonarrBlock::HistoryItemDetails, + ActiveSonarrBlock::HistorySortPrompt, + ActiveSonarrBlock::FilterHistory, + ActiveSonarrBlock::FilterHistoryError, + ActiveSonarrBlock::SearchHistory, + ActiveSonarrBlock::SearchHistoryError, +]; + impl From for Route { fn from(active_sonarr_block: ActiveSonarrBlock) -> Route { Route::Sonarr(active_sonarr_block, None) diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index e851c9b..57ec225 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -204,7 +204,7 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, BLOCKLIST_BLOCKS, DELETE_SERIES_BLOCKS, DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_SERIES_BLOCKS, - EDIT_SERIES_SELECTION_BLOCKS, LIBRARY_BLOCKS, + EDIT_SERIES_SELECTION_BLOCKS, HISTORY_BLOCKS, LIBRARY_BLOCKS, }; #[test] @@ -374,5 +374,17 @@ mod tests { ); assert_eq!(delete_series_block_iter.next(), None); } + + #[test] + fn test_history_blocks_contents() { + assert_eq!(HISTORY_BLOCKS.len(), 7); + assert!(HISTORY_BLOCKS.contains(&ActiveSonarrBlock::History)); + assert!(HISTORY_BLOCKS.contains(&ActiveSonarrBlock::HistoryItemDetails)); + assert!(HISTORY_BLOCKS.contains(&ActiveSonarrBlock::HistorySortPrompt)); + assert!(HISTORY_BLOCKS.contains(&ActiveSonarrBlock::FilterHistory)); + assert!(HISTORY_BLOCKS.contains(&ActiveSonarrBlock::FilterHistoryError)); + assert!(HISTORY_BLOCKS.contains(&ActiveSonarrBlock::SearchHistory)); + assert!(HISTORY_BLOCKS.contains(&ActiveSonarrBlock::SearchHistoryError)); + } } } diff --git a/src/ui/sonarr_ui/blocklist/mod.rs b/src/ui/sonarr_ui/blocklist/mod.rs index 2cf068e..08448c8 100644 --- a/src/ui/sonarr_ui/blocklist/mod.rs +++ b/src/ui/sonarr_ui/blocklist/mod.rs @@ -3,7 +3,7 @@ use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, BLOCKL use crate::models::sonarr_models::BlocklistItem; use crate::models::Route; use crate::ui::styles::ManagarrStyle; -use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; +use crate::ui::utils::layout_block_top_border; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; @@ -78,11 +78,6 @@ impl DrawUi for BlocklistUi { fn draw_blocklist_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { - let current_selection = if app.data.sonarr_data.blocklist.items.is_empty() { - BlocklistItem::default() - } else { - app.data.sonarr_data.blocklist.current_selection().clone() - }; let blocklist_table_footer = app .data .sonarr_data From 4eb974567f0a1afa7d24e80fd9f23fa511c8bd52 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 18:47:50 -0700 Subject: [PATCH 21/82] feat(ui): History tab support --- src/app/sonarr/mod.rs | 2 +- src/app/sonarr/sonarr_tests.rs | 2 +- .../history/history_handler_tests.rs | 11 +- src/handlers/sonarr_handlers/history/mod.rs | 3 +- src/models/sonarr_models.rs | 6 +- src/network/sonarr_network_tests.rs | 4 +- src/ui/sonarr_ui/blocklist/mod.rs | 2 +- src/ui/sonarr_ui/history/history_ui_tests.rs | 18 + src/ui/sonarr_ui/history/mod.rs | 347 ++++++++++++++++++ src/ui/sonarr_ui/mod.rs | 3 + 10 files changed, 386 insertions(+), 12 deletions(-) create mode 100644 src/ui/sonarr_ui/history/history_ui_tests.rs create mode 100644 src/ui/sonarr_ui/history/mod.rs diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index 563d7b5..9b6a30b 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -69,7 +69,7 @@ impl<'a> App<'a> { } ActiveSonarrBlock::History => { self - .dispatch_network_event(SonarrEvent::GetHistory(Some(10000)).into()) + .dispatch_network_event(SonarrEvent::GetHistory(None).into()) .await; } ActiveSonarrBlock::RootFolders => { diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index 123212b..a02c217 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -179,7 +179,7 @@ mod tests { assert!(app.is_loading); assert_eq!( sync_network_rx.recv().await.unwrap(), - SonarrEvent::GetHistory(Some(10000)).into() + SonarrEvent::GetHistory(None).into() ); assert!(!app.data.sonarr_data.prompt_confirm); assert_eq!(app.tick_count, 0); diff --git a/src/handlers/sonarr_handlers/history/history_handler_tests.rs b/src/handlers/sonarr_handlers/history/history_handler_tests.rs index e981ec6..2110070 100644 --- a/src/handlers/sonarr_handlers/history/history_handler_tests.rs +++ b/src/handlers/sonarr_handlers/history/history_handler_tests.rs @@ -14,7 +14,7 @@ mod tests { use crate::handlers::KeyEventHandler; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS}; use crate::models::servarr_models::{Language, Quality, QualityWrapper}; - use crate::models::sonarr_models::SonarrHistoryItem; + use crate::models::sonarr_models::{SonarrHistoryEventType, SonarrHistoryItem}; use crate::models::stateful_table::SortOption; use crate::models::HorizontallyScrollableText; @@ -1197,8 +1197,9 @@ mod tests { fn test_history_sorting_options_event_type() { let expected_cmp_fn: fn(&SonarrHistoryItem, &SonarrHistoryItem) -> Ordering = |a, b| { a.event_type + .to_string() .to_lowercase() - .cmp(&b.event_type.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); @@ -1334,7 +1335,7 @@ mod tests { SonarrHistoryItem { id: 3, source_title: "test 1".into(), - event_type: "grabbed".to_owned(), + event_type: SonarrHistoryEventType::Grabbed, language: Language { id: 1, name: "telgu".to_owned(), @@ -1350,7 +1351,7 @@ mod tests { SonarrHistoryItem { id: 2, source_title: "test 2".into(), - event_type: "downloadFolderImported".to_owned(), + event_type: SonarrHistoryEventType::DownloadFolderImported, language: Language { id: 3, name: "chinese".to_owned(), @@ -1366,7 +1367,7 @@ mod tests { SonarrHistoryItem { id: 1, source_title: "test 3".into(), - event_type: "episodeFileDeleted".to_owned(), + event_type: SonarrHistoryEventType::EpisodeFileDeleted, language: Language { id: 1, name: "english".to_owned(), diff --git a/src/handlers/sonarr_handlers/history/mod.rs b/src/handlers/sonarr_handlers/history/mod.rs index 7b7069c..4d0597b 100644 --- a/src/handlers/sonarr_handlers/history/mod.rs +++ b/src/handlers/sonarr_handlers/history/mod.rs @@ -323,8 +323,9 @@ fn history_sorting_options() -> Vec> { name: "Event Type", cmp_fn: Some(|a, b| { a.event_type + .to_string() .to_lowercase() - .cmp(&b.event_type.to_lowercase()) + .cmp(&b.event_type.to_string().to_lowercase()) }), }, SortOption { diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 1e8bcb9..f3c9e9b 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -465,6 +465,10 @@ pub struct SonarrHistoryData { pub published_date: Option>, pub message: Option, pub reason: Option, + pub source_path: Option, + pub source_relative_path: Option, + pub path: Option, + pub relative_path: Option, } #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] @@ -523,7 +527,7 @@ pub struct SonarrHistoryItem { pub quality: QualityWrapper, pub language: Language, pub date: DateTime, - pub event_type: String, + pub event_type: SonarrHistoryEventType, pub data: SonarrHistoryData, } diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 0493a0f..197f0c8 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -17,7 +17,7 @@ mod test { use crate::models::sonarr_models::{ AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, - EditSeriesParams, IndexerSettings, SeriesMonitor, + EditSeriesParams, IndexerSettings, SeriesMonitor, SonarrHistoryEventType, }; use crate::app::{App, ServarrConfig}; @@ -6780,7 +6780,7 @@ mod test { quality: quality_wrapper(), language: language(), date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), - event_type: "grabbed".into(), + event_type: SonarrHistoryEventType::Grabbed, data: history_data(), } } diff --git a/src/ui/sonarr_ui/blocklist/mod.rs b/src/ui/sonarr_ui/blocklist/mod.rs index 08448c8..74a2d52 100644 --- a/src/ui/sonarr_ui/blocklist/mod.rs +++ b/src/ui/sonarr_ui/blocklist/mod.rs @@ -116,7 +116,7 @@ fn draw_blocklist_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .headers([ "Series Title", "Source Title", - "Languages", + "Language", "Quality", "Date", ]) diff --git a/src/ui/sonarr_ui/history/history_ui_tests.rs b/src/ui/sonarr_ui/history/history_ui_tests.rs new file mode 100644 index 0000000..bae662e --- /dev/null +++ b/src/ui/sonarr_ui/history/history_ui_tests.rs @@ -0,0 +1,18 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS}; + use crate::ui::sonarr_ui::history::HistoryUi; + use crate::ui::DrawUi; + use strum::IntoEnumIterator; + + #[test] + fn test_history_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if HISTORY_BLOCKS.contains(&active_sonarr_block) { + assert!(HistoryUi::accepts(active_sonarr_block.into())); + } else { + assert!(!HistoryUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/history/mod.rs b/src/ui/sonarr_ui/history/mod.rs new file mode 100644 index 0000000..cec5580 --- /dev/null +++ b/src/ui/sonarr_ui/history/mod.rs @@ -0,0 +1,347 @@ +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS}; +use crate::models::sonarr_models::{SonarrHistoryData, SonarrHistoryEventType, SonarrHistoryItem}; +use crate::models::Route; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::{draw_input_box_popup, draw_popup_over, DrawUi}; +use ratatui::layout::{Alignment, Constraint, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Cell, Row}; +use ratatui::Frame; + +#[cfg(test)] +#[path = "history_ui_tests.rs"] +mod history_ui_tests; + +pub(super) struct HistoryUi; + +impl DrawUi for HistoryUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return HISTORY_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + match active_sonarr_block { + ActiveSonarrBlock::History | ActiveSonarrBlock::HistorySortPrompt => { + draw_history_table(f, app, area) + } + ActiveSonarrBlock::SearchHistory => draw_popup_over( + f, + app, + area, + draw_history_table, + draw_history_search_box, + Size::InputBox, + ), + ActiveSonarrBlock::SearchHistoryError => { + let popup = Popup::new(Message::new("History item not found!")).size(Size::Message); + + draw_history_table(f, app, area); + f.render_widget(popup, f.area()); + } + ActiveSonarrBlock::FilterHistory => draw_popup_over( + f, + app, + area, + draw_history_table, + draw_filter_history_box, + Size::InputBox, + ), + ActiveSonarrBlock::FilterHistoryError => { + let popup = Popup::new(Message::new( + "No history items found matching the given filter!", + )) + .size(Size::Message); + + draw_history_table(f, app, area); + f.render_widget(popup, f.area()); + } + ActiveSonarrBlock::HistoryItemDetails => { + draw_history_table(f, app, area); + draw_history_item_details_popup(f, app); + } + _ => (), + } + } + } +} + +fn draw_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = if app.data.sonarr_data.history.items.is_empty() { + SonarrHistoryItem::default() + } else { + app.data.sonarr_data.history.current_selection().clone() + }; + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let history_table_footer = app + .data + .sonarr_data + .main_tabs + .get_active_tab_contextual_help(); + + let history_row_mapping = |history_item: &SonarrHistoryItem| { + let SonarrHistoryItem { + source_title, + language, + quality, + event_type, + date, + .. + } = history_item; + + source_title.scroll_left_or_reset( + get_width_from_percentage(area, 40), + current_selection == *history_item, + app.tick_count % app.ticks_until_scroll == 0, + ); + + Row::new(vec![ + Cell::from(source_title.to_string()), + Cell::from(event_type.to_string()), + Cell::from(language.name.to_owned()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let history_table = + ManagarrTable::new(Some(&mut app.data.sonarr_data.history), history_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(history_table_footer) + .sorting(active_sonarr_block == ActiveSonarrBlock::HistorySortPrompt) + .headers(["Source Title", "Event Type", "Language", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(40), + Constraint::Percentage(15), + Constraint::Percentage(12), + Constraint::Percentage(13), + Constraint::Percentage(20), + ]); + + f.render_widget(history_table, area); + } +} + +fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = if app.data.sonarr_data.history.items.is_empty() { + SonarrHistoryItem::default() + } else { + app.data.sonarr_data.history.current_selection().clone() + }; + + let line_vec = match current_selection.event_type { + SonarrHistoryEventType::Unknown => create_unknown_event_vec(current_selection), + SonarrHistoryEventType::DownloadFolderImported => { + create_download_folder_imported_event_vec(current_selection) + } + SonarrHistoryEventType::DownloadFailed => create_download_failed_event_vec(current_selection), + SonarrHistoryEventType::EpisodeFileDeleted => { + create_episode_file_deleted_event_vec(current_selection) + } + SonarrHistoryEventType::EpisodeFileRenamed => { + create_episode_file_renamed_event_vec(current_selection) + } + _ => create_no_data_event_vec(current_selection), + }; + let text = Text::from(line_vec); + + let message = Message::new(text) + .title("Details") + .style(Style::new().secondary()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowMessage), f.area()); +} + +fn create_unknown_event_vec(history_item: SonarrHistoryItem) -> Vec> { + let SonarrHistoryItem { + source_title, data, .. + } = history_item; + let SonarrHistoryData { + indexer, + release_group, + series_match_type, + nzb_info_url, + download_client_name, + age, + published_date, + .. + } = data; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Indexer: ".bold().secondary(), + indexer.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Release Group: ".bold().secondary(), + release_group.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Series Match Type: ".bold().secondary(), + series_match_type.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "NZB Info URL: ".bold().secondary(), + nzb_info_url.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Download Client Name: ".bold().secondary(), + download_client_name.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Age: ".bold().secondary(), + format!("{} days", age.unwrap_or("0".to_owned())).secondary(), + ]), + Line::from(vec![ + "Published Date: ".bold().secondary(), + published_date.unwrap_or_default().to_string().secondary(), + ]), + ] +} + +fn create_download_folder_imported_event_vec( + history_item: SonarrHistoryItem, +) -> Vec> { + let SonarrHistoryItem { + source_title, data, .. + } = history_item; + let SonarrHistoryData { + dropped_path, + imported_path, + .. + } = data; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Dropped Path: ".bold().secondary(), + dropped_path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Imported Path: ".bold().secondary(), + imported_path.unwrap_or_default().secondary(), + ]), + ] +} + +fn create_download_failed_event_vec(history_item: SonarrHistoryItem) -> Vec> { + let SonarrHistoryItem { + source_title, data, .. + } = history_item; + let SonarrHistoryData { message, .. } = data; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Message: ".bold().secondary(), + message.unwrap_or_default().secondary(), + ]), + ] +} + +fn create_episode_file_deleted_event_vec(history_item: SonarrHistoryItem) -> Vec> { + let SonarrHistoryItem { + source_title, data, .. + } = history_item; + let SonarrHistoryData { reason, .. } = data; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Reason: ".bold().secondary(), + reason.unwrap_or_default().secondary(), + ]), + ] +} + +fn create_episode_file_renamed_event_vec(history_item: SonarrHistoryItem) -> Vec> { + let SonarrHistoryItem { + source_title, data, .. + } = history_item; + let SonarrHistoryData { + source_path, + source_relative_path, + path, + relative_path, + .. + } = data; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Source Path: ".bold().secondary(), + source_path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Source Relative Path: ".bold().secondary(), + source_relative_path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Destination Path: ".bold().secondary(), + path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Destination Relative Path: ".bold().secondary(), + relative_path.unwrap_or_default().secondary(), + ]), + ] +} + +fn create_no_data_event_vec(history_item: SonarrHistoryItem) -> Vec> { + let SonarrHistoryItem { source_title, .. } = history_item; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![String::new().secondary()]), + Line::from(vec!["No additional data available".bold().secondary()]), + ] +} + +fn draw_history_search_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_input_box_popup( + f, + area, + "Search", + app.data.sonarr_data.history.search.as_ref().unwrap(), + ); +} + +fn draw_filter_history_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_input_box_popup( + f, + area, + "Filter", + app.data.sonarr_data.history.filter.as_ref().unwrap(), + ) +} diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index 7fac977..22a5656 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -3,6 +3,7 @@ use std::{cmp, iter}; use blocklist::BlocklistUi; use chrono::{Duration, Utc}; use downloads::DownloadsUi; +use history::HistoryUi; use library::LibraryUi; use ratatui::{ layout::{Constraint, Layout, Rect}, @@ -36,6 +37,7 @@ use super::{ mod blocklist; mod downloads; +mod history; mod library; #[cfg(test)] @@ -57,6 +59,7 @@ impl DrawUi for SonarrUi { _ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area), _ if DownloadsUi::accepts(route) => DownloadsUi::draw(f, app, content_area), _ if BlocklistUi::accepts(route) => BlocklistUi::draw(f, app, content_area), + _ if HistoryUi::accepts(route) => HistoryUi::draw(f, app, content_area), _ => (), } } From bda6f253e09406cf64f3bcbd68aba4071415964d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 3 Dec 2024 16:18:39 -0700 Subject: [PATCH 22/82] feat(handlers): Support for root folder actions --- src/handlers/sonarr_handlers/mod.rs | 6 + .../sonarr_handlers/root_folders/mod.rs | 195 +++++ .../root_folders_handler_tests.rs | 804 ++++++++++++++++++ .../sonarr_handlers/sonarr_handler_tests.rs | 16 + src/models/servarr_data/sonarr/sonarr_data.rs | 6 + .../servarr_data/sonarr/sonarr_data_tests.rs | 10 +- 6 files changed, 1036 insertions(+), 1 deletion(-) create mode 100644 src/handlers/sonarr_handlers/root_folders/mod.rs create mode 100644 src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs diff --git a/src/handlers/sonarr_handlers/mod.rs b/src/handlers/sonarr_handlers/mod.rs index 83d277b..edf2cb8 100644 --- a/src/handlers/sonarr_handlers/mod.rs +++ b/src/handlers/sonarr_handlers/mod.rs @@ -2,6 +2,7 @@ use blocklist::BlocklistHandler; use downloads::DownloadsHandler; use history::HistoryHandler; use library::LibraryHandler; +use root_folders::RootFoldersHandler; use crate::{ app::{key_binding::DEFAULT_KEYBINDINGS, App}, @@ -15,6 +16,7 @@ mod blocklist; mod downloads; mod history; mod library; +mod root_folders; #[cfg(test)] #[path = "sonarr_handler_tests.rs"] @@ -46,6 +48,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SonarrHandler<'a, 'b _ if HistoryHandler::accepts(self.active_sonarr_block) => { HistoryHandler::with(self.key, self.app, self.active_sonarr_block, self.context).handle() } + _ if RootFoldersHandler::accepts(self.active_sonarr_block) => { + RootFoldersHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle() + } _ => self.handle_key_event(), } } diff --git a/src/handlers/sonarr_handlers/root_folders/mod.rs b/src/handlers/sonarr_handlers/root_folders/mod.rs new file mode 100644 index 0000000..cc18830 --- /dev/null +++ b/src/handlers/sonarr_handlers/root_folders/mod.rs @@ -0,0 +1,195 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::App; +use crate::event::Key; +use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ROOT_FOLDERS_BLOCKS}; +use crate::models::{HorizontallyScrollableText, Scrollable}; +use crate::network::sonarr_network::SonarrEvent; +use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; + +#[cfg(test)] +#[path = "root_folders_handler_tests.rs"] +mod root_folders_handler_tests; + +pub(super) struct RootFoldersHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for RootFoldersHandler<'a, 'b> { + fn accepts(active_block: ActiveSonarrBlock) -> bool { + ROOT_FOLDERS_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + _context: Option, + ) -> RootFoldersHandler<'a, 'b> { + RootFoldersHandler { + key, + app, + active_sonarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.sonarr_data.root_folders.is_empty() + } + + fn handle_scroll_up(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::RootFolders { + self.app.data.sonarr_data.root_folders.scroll_up() + } + } + + fn handle_scroll_down(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::RootFolders { + self.app.data.sonarr_data.root_folders.scroll_down() + } + } + + fn handle_home(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::RootFolders => self.app.data.sonarr_data.root_folders.scroll_to_top(), + ActiveSonarrBlock::AddRootFolderPrompt => self + .app + .data + .sonarr_data + .edit_root_folder + .as_mut() + .unwrap() + .scroll_home(), + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::RootFolders => self.app.data.sonarr_data.root_folders.scroll_to_bottom(), + ActiveSonarrBlock::AddRootFolderPrompt => self + .app + .data + .sonarr_data + .edit_root_folder + .as_mut() + .unwrap() + .reset_offset(), + _ => (), + } + } + + fn handle_delete(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::RootFolders { + self + .app + .push_navigation_stack(ActiveSonarrBlock::DeleteRootFolderPrompt.into()) + } + } + + fn handle_left_right_action(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::RootFolders => handle_change_tab_left_right_keys(self.app, self.key), + ActiveSonarrBlock::DeleteRootFolderPrompt => handle_prompt_toggle(self.app, self.key), + ActiveSonarrBlock::AddRootFolderPrompt => { + handle_text_box_left_right_keys!( + self, + self.key, + self.app.data.sonarr_data.edit_root_folder.as_mut().unwrap() + ) + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::DeleteRootFolderPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DeleteRootFolder(None)); + } + + self.app.pop_navigation_stack(); + } + _ if self.active_sonarr_block == ActiveSonarrBlock::AddRootFolderPrompt + && !self + .app + .data + .sonarr_data + .edit_root_folder + .as_ref() + .unwrap() + .text + .is_empty() => + { + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::AddRootFolder(None)); + self.app.data.sonarr_data.prompt_confirm = true; + self.app.should_ignore_quit_key = false; + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::AddRootFolderPrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.edit_root_folder = None; + self.app.data.sonarr_data.prompt_confirm = false; + self.app.should_ignore_quit_key = false; + } + ActiveSonarrBlock::DeleteRootFolderPrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.prompt_confirm = false; + } + _ => handle_clear_errors(self.app), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_sonarr_block { + ActiveSonarrBlock::RootFolders => match self.key { + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { + self.app.should_refresh = true; + } + _ if key == DEFAULT_KEYBINDINGS.add.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::AddRootFolderPrompt.into()); + self.app.data.sonarr_data.edit_root_folder = Some(HorizontallyScrollableText::default()); + self.app.should_ignore_quit_key = true; + } + _ => (), + }, + ActiveSonarrBlock::AddRootFolderPrompt => { + handle_text_box_keys!( + self, + key, + self.app.data.sonarr_data.edit_root_folder.as_mut().unwrap() + ) + } + ActiveSonarrBlock::DeleteRootFolderPrompt => { + if key == DEFAULT_KEYBINDINGS.confirm.key { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DeleteRootFolder(None)); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } +} diff --git a/src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs b/src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs new file mode 100644 index 0000000..8c8947c --- /dev/null +++ b/src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs @@ -0,0 +1,804 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_str_eq; + use strum::IntoEnumIterator; + + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::root_folders::RootFoldersHandler; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ROOT_FOLDERS_BLOCKS}; + use crate::models::servarr_models::RootFolder; + use crate::models::HorizontallyScrollableText; + + mod test_handle_scroll_up_and_down { + use rstest::rstest; + + use crate::models::servarr_models::RootFolder; + use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; + + use super::*; + + test_iterable_scroll!( + test_root_folders_scroll, + RootFoldersHandler, + sonarr_data, + root_folders, + simple_stateful_iterable_vec!(RootFolder, String, path), + ActiveSonarrBlock::RootFolders, + None, + path + ); + + #[rstest] + fn test_root_folders_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.is_loading = true; + app + .data + .sonarr_data + .root_folders + .set_items(simple_stateful_iterable_vec!(RootFolder, String, path)); + + RootFoldersHandler::with(key, &mut app, ActiveSonarrBlock::RootFolders, None).handle(); + + assert_str_eq!( + app.data.sonarr_data.root_folders.current_selection().path, + "Test 1" + ); + + RootFoldersHandler::with(key, &mut app, ActiveSonarrBlock::RootFolders, None).handle(); + + assert_str_eq!( + app.data.sonarr_data.root_folders.current_selection().path, + "Test 1" + ); + } + } + + mod test_handle_home_end { + use std::sync::atomic::Ordering; + + use pretty_assertions::assert_eq; + + use crate::models::servarr_models::RootFolder; + use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; + + use super::*; + + test_iterable_home_and_end!( + test_root_folders_home_end, + RootFoldersHandler, + sonarr_data, + root_folders, + extended_stateful_iterable_vec!(RootFolder, String, path), + ActiveSonarrBlock::RootFolders, + None, + path + ); + + #[test] + fn test_root_folders_home_end_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.is_loading = true; + app + .data + .sonarr_data + .root_folders + .set_items(extended_stateful_iterable_vec!(RootFolder, String, path)); + + RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::RootFolders, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.root_folders.current_selection().path, + "Test 1" + ); + + RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::RootFolders, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.root_folders.current_selection().path, + "Test 1" + ); + } + + #[test] + fn test_add_root_folder_prompt_home_end_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app + .data + .sonarr_data + .root_folders + .set_items(vec![RootFolder::default()]); + app.data.sonarr_data.edit_root_folder = Some("Test".into()); + + RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_root_folder + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 4 + ); + + RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_root_folder + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_delete { + use pretty_assertions::assert_eq; + + use super::*; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_delete_root_folder_prompt() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app + .data + .sonarr_data + .root_folders + .set_items(vec![RootFolder::default()]); + + RootFoldersHandler::with(DELETE_KEY, &mut app, ActiveSonarrBlock::RootFolders, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::DeleteRootFolderPrompt.into() + ); + } + + #[test] + fn test_delete_root_folder_prompt_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app + .data + .sonarr_data + .root_folders + .set_items(vec![RootFolder::default()]); + + RootFoldersHandler::with(DELETE_KEY, &mut app, ActiveSonarrBlock::RootFolders, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::RootFolders.into() + ); + } + } + + mod test_handle_left_right_action { + use std::sync::atomic::Ordering; + + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_root_folders_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(4); + + RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::RootFolders, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::History.into() + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); + } + + #[rstest] + fn test_root_folders_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(4); + + RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::RootFolders, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::Indexers.into() + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + } + + #[rstest] + fn test_left_right_delete_root_folder_prompt_toggle( + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + + RootFoldersHandler::with( + key, + &mut app, + ActiveSonarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + RootFoldersHandler::with( + key, + &mut app, + ActiveSonarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[test] + fn test_add_root_folder_prompt_left_right_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.data.sonarr_data.edit_root_folder = Some("Test".into()); + + RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_root_folder + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 1 + ); + + RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_root_folder + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_add_root_folder_prompt_confirm_submit() { + let mut app = App::default(); + app + .data + .sonarr_data + .root_folders + .set_items(vec![RootFolder::default()]); + app.data.sonarr_data.edit_root_folder = Some("Test".into()); + app.data.sonarr_data.prompt_confirm = true; + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddRootFolderPrompt.into()); + + RootFoldersHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert!(!app.should_ignore_quit_key); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::AddRootFolder(None)) + ); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::RootFolders.into() + ); + } + + #[test] + fn test_add_root_folder_prompt_confirm_submit_noop_on_empty_folder() { + let mut app = App::default(); + app.data.sonarr_data.edit_root_folder = Some(HorizontallyScrollableText::default()); + app.data.sonarr_data.prompt_confirm = false; + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddRootFolderPrompt.into()); + + RootFoldersHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert!(app.should_ignore_quit_key); + assert!(app.data.sonarr_data.prompt_confirm_action.is_none()); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddRootFolderPrompt.into() + ); + } + + #[test] + fn test_delete_root_folder_prompt_confirm_submit() { + let mut app = App::default(); + app + .data + .sonarr_data + .root_folders + .set_items(vec![RootFolder::default()]); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveSonarrBlock::DeleteRootFolderPrompt.into()); + + RootFoldersHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::DeleteRootFolder(None)) + ); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::RootFolders.into() + ); + } + + #[test] + fn test_delete_root_folder_prompt_decline_submit() { + let mut app = App::default(); + app + .data + .sonarr_data + .root_folders + .set_items(vec![RootFolder::default()]); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveSonarrBlock::DeleteRootFolderPrompt.into()); + + RootFoldersHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::RootFolders.into() + ); + } + } + + mod test_handle_esc { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_delete_root_folder_prompt_block_esc() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveSonarrBlock::DeleteRootFolderPrompt.into()); + app.data.sonarr_data.prompt_confirm = true; + + RootFoldersHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::RootFolders.into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[test] + fn test_add_root_folder_prompt_esc() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddRootFolderPrompt.into()); + app.data.sonarr_data.edit_root_folder = Some("/nfs/test".into()); + app.should_ignore_quit_key = true; + + RootFoldersHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::RootFolders.into() + ); + + assert!(app.data.sonarr_data.edit_root_folder.is_none()); + assert!(!app.data.sonarr_data.prompt_confirm); + assert!(!app.should_ignore_quit_key); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + + RootFoldersHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::RootFolders, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::RootFolders.into() + ); + assert!(app.error.text.is_empty()); + } + } + + mod test_handle_key_char { + use pretty_assertions::{assert_eq, assert_str_eq}; + + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + #[test] + fn test_root_folder_add() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app + .data + .sonarr_data + .root_folders + .set_items(vec![RootFolder::default()]); + + RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveSonarrBlock::RootFolders, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AddRootFolderPrompt.into() + ); + assert!(app.should_ignore_quit_key); + assert!(app.data.sonarr_data.edit_root_folder.is_some()); + } + + #[test] + fn test_root_folder_add_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app + .data + .sonarr_data + .root_folders + .set_items(vec![RootFolder::default()]); + + RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveSonarrBlock::RootFolders, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::RootFolders.into() + ); + assert!(!app.should_ignore_quit_key); + assert!(app.data.sonarr_data.edit_root_folder.is_none()); + } + + #[test] + fn test_refresh_root_folders_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .root_folders + .set_items(vec![RootFolder::default()]); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + + RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::RootFolders, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::RootFolders.into() + ); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_root_folders_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app + .data + .sonarr_data + .root_folders + .set_items(vec![RootFolder::default()]); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + + RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::RootFolders, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::RootFolders.into() + ); + assert!(!app.should_refresh); + } + + #[test] + fn test_add_root_folder_prompt_backspace_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app + .data + .sonarr_data + .root_folders + .set_items(vec![RootFolder::default()]); + app.data.sonarr_data.edit_root_folder = Some("/nfs/test".into()); + + RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.edit_root_folder.as_ref().unwrap().text, + "/nfs/tes" + ); + } + + #[test] + fn test_add_root_folder_prompt_char_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app + .data + .sonarr_data + .root_folders + .set_items(vec![RootFolder::default()]); + app.data.sonarr_data.edit_root_folder = Some(HorizontallyScrollableText::default()); + + RootFoldersHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.edit_root_folder.as_ref().unwrap().text, + "h" + ); + } + + #[test] + fn test_delete_root_folder_prompt_confirm() { + let mut app = App::default(); + app + .data + .sonarr_data + .root_folders + .set_items(vec![RootFolder::default()]); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveSonarrBlock::DeleteRootFolderPrompt.into()); + + RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveSonarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::DeleteRootFolder(None)) + ); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::RootFolders.into() + ); + } + } + + #[test] + fn test_root_folders_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if ROOT_FOLDERS_BLOCKS.contains(&active_sonarr_block) { + assert!(RootFoldersHandler::accepts(active_sonarr_block)); + } else { + assert!(!RootFoldersHandler::accepts(active_sonarr_block)); + } + }) + } + + #[test] + fn test_root_folders_handler_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.is_loading = true; + + let handler = RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::RootFolders, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_root_folders_handler_not_ready_when_root_folders_is_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.is_loading = false; + + let handler = RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::RootFolders, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_root_folders_handler_ready_when_not_loading_and_root_folders_is_not_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.is_loading = false; + + app + .data + .sonarr_data + .root_folders + .set_items(vec![RootFolder::default()]); + let handler = RootFoldersHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::RootFolders, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs index 95de5ce..00fd74d 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs @@ -165,4 +165,20 @@ mod tests { active_sonarr_block ); } + + #[rstest] + fn test_delegates_root_folders_blocks_to_root_folders_handler( + #[values( + ActiveSonarrBlock::RootFolders, + ActiveSonarrBlock::AddRootFolderPrompt, + ActiveSonarrBlock::DeleteRootFolderPrompt + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + SonarrHandler, + ActiveSonarrBlock::RootFolders, + active_sonarr_block + ); + } } diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index d7f7880..104e8d8 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -380,6 +380,12 @@ pub static HISTORY_BLOCKS: [ActiveSonarrBlock; 7] = [ ActiveSonarrBlock::SearchHistoryError, ]; +pub static ROOT_FOLDERS_BLOCKS: [ActiveSonarrBlock; 3] = [ + ActiveSonarrBlock::RootFolders, + ActiveSonarrBlock::AddRootFolderPrompt, + ActiveSonarrBlock::DeleteRootFolderPrompt, +]; + impl From for Route { fn from(active_sonarr_block: ActiveSonarrBlock) -> Route { Route::Sonarr(active_sonarr_block, None) diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 57ec225..a5ed5a9 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -204,7 +204,7 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, BLOCKLIST_BLOCKS, DELETE_SERIES_BLOCKS, DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_SERIES_BLOCKS, - EDIT_SERIES_SELECTION_BLOCKS, HISTORY_BLOCKS, LIBRARY_BLOCKS, + EDIT_SERIES_SELECTION_BLOCKS, HISTORY_BLOCKS, LIBRARY_BLOCKS, ROOT_FOLDERS_BLOCKS, }; #[test] @@ -386,5 +386,13 @@ mod tests { assert!(HISTORY_BLOCKS.contains(&ActiveSonarrBlock::SearchHistory)); assert!(HISTORY_BLOCKS.contains(&ActiveSonarrBlock::SearchHistoryError)); } + + #[test] + fn test_root_folders_blocks_contents() { + assert_eq!(ROOT_FOLDERS_BLOCKS.len(), 3); + assert!(ROOT_FOLDERS_BLOCKS.contains(&ActiveSonarrBlock::RootFolders)); + assert!(ROOT_FOLDERS_BLOCKS.contains(&ActiveSonarrBlock::AddRootFolderPrompt)); + assert!(ROOT_FOLDERS_BLOCKS.contains(&ActiveSonarrBlock::DeleteRootFolderPrompt)); + } } } From 8660de530d3b5b5d697d3f28567ada0851ecf799 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 3 Dec 2024 16:24:23 -0700 Subject: [PATCH 23/82] feat(ui): Root folder tab support --- src/ui/sonarr_ui/mod.rs | 3 + src/ui/sonarr_ui/root_folders/mod.rs | 117 ++++++++++++++++++ .../root_folders/root_folders_ui_tests.rs | 19 +++ 3 files changed, 139 insertions(+) create mode 100644 src/ui/sonarr_ui/root_folders/mod.rs create mode 100644 src/ui/sonarr_ui/root_folders/root_folders_ui_tests.rs diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index 22a5656..ae7a097 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -12,6 +12,7 @@ use ratatui::{ widgets::Paragraph, Frame, }; +use root_folders::RootFoldersUi; use crate::{ app::App, @@ -39,6 +40,7 @@ mod blocklist; mod downloads; mod history; mod library; +mod root_folders; #[cfg(test)] #[path = "sonarr_ui_tests.rs"] @@ -60,6 +62,7 @@ impl DrawUi for SonarrUi { _ if DownloadsUi::accepts(route) => DownloadsUi::draw(f, app, content_area), _ if BlocklistUi::accepts(route) => BlocklistUi::draw(f, app, content_area), _ if HistoryUi::accepts(route) => HistoryUi::draw(f, app, content_area), + _ if RootFoldersUi::accepts(route) => RootFoldersUi::draw(f, app, content_area), _ => (), } } diff --git a/src/ui/sonarr_ui/root_folders/mod.rs b/src/ui/sonarr_ui/root_folders/mod.rs new file mode 100644 index 0000000..ee564e3 --- /dev/null +++ b/src/ui/sonarr_ui/root_folders/mod.rs @@ -0,0 +1,117 @@ +use ratatui::layout::{Constraint, Rect}; +use ratatui::widgets::{Cell, Row}; +use ratatui::Frame; + +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ROOT_FOLDERS_BLOCKS}; +use crate::models::servarr_models::RootFolder; +use crate::models::Route; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::layout_block_top_border; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::{draw_input_box_popup, draw_popup_over, DrawUi}; +use crate::utils::convert_to_gb; + +#[cfg(test)] +#[path = "root_folders_ui_tests.rs"] +mod root_folders_ui_tests; + +pub(super) struct RootFoldersUi; + +impl DrawUi for RootFoldersUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return ROOT_FOLDERS_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + match active_sonarr_block { + ActiveSonarrBlock::RootFolders => draw_root_folders(f, app, area), + ActiveSonarrBlock::AddRootFolderPrompt => draw_popup_over( + f, + app, + area, + draw_root_folders, + draw_add_root_folder_prompt_box, + Size::InputBox, + ), + ActiveSonarrBlock::DeleteRootFolderPrompt => { + let prompt = format!( + "Do you really want to delete this root folder: \n{}?", + app.data.sonarr_data.root_folders.current_selection().path + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Root Folder") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + draw_root_folders(f, app, area); + f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + } + _ => (), + } + } + } +} + +fn draw_root_folders(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let help_footer = app + .data + .sonarr_data + .main_tabs + .get_active_tab_contextual_help(); + let root_folders_row_mapping = |root_folders: &RootFolder| { + let RootFolder { + path, + free_space, + unmapped_folders, + .. + } = root_folders; + + let space: f64 = convert_to_gb(*free_space); + + Row::new(vec![ + Cell::from(path.to_owned()), + Cell::from(format!("{space:.2} GB")), + Cell::from( + unmapped_folders + .as_ref() + .unwrap_or(&Vec::new()) + .len() + .to_string(), + ), + ]) + .primary() + }; + + let root_folders_table = ManagarrTable::new( + Some(&mut app.data.sonarr_data.root_folders), + root_folders_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(help_footer) + .headers(["Path", "Free Space", "Unmapped Folders"]) + .constraints([ + Constraint::Ratio(3, 5), + Constraint::Ratio(1, 5), + Constraint::Ratio(1, 5), + ]); + + f.render_widget(root_folders_table, area); +} + +fn draw_add_root_folder_prompt_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_input_box_popup( + f, + area, + "Add Root Folder", + app.data.sonarr_data.edit_root_folder.as_ref().unwrap(), + ); +} diff --git a/src/ui/sonarr_ui/root_folders/root_folders_ui_tests.rs b/src/ui/sonarr_ui/root_folders/root_folders_ui_tests.rs new file mode 100644 index 0000000..1e9e6c0 --- /dev/null +++ b/src/ui/sonarr_ui/root_folders/root_folders_ui_tests.rs @@ -0,0 +1,19 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ROOT_FOLDERS_BLOCKS}; + use crate::ui::sonarr_ui::root_folders::RootFoldersUi; + use crate::ui::DrawUi; + + #[test] + fn test_root_folders_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if ROOT_FOLDERS_BLOCKS.contains(&active_sonarr_block) { + assert!(RootFoldersUi::accepts(active_sonarr_block.into())); + } else { + assert!(!RootFoldersUi::accepts(active_sonarr_block.into())); + } + }); + } +} From 093ef136e75d92e3eff01e849e3e7b00f6db8709 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 3 Dec 2024 17:46:37 -0700 Subject: [PATCH 24/82] feat(handler): Full indexer tab handler support --- .../indexers/edit_indexer_handler.rs | 41 +- .../indexers/edit_indexer_handler_tests.rs | 157 +- .../indexers/indexers_handler_tests.rs | 45 - src/handlers/radarr_handlers/indexers/mod.rs | 5 - .../indexers/edit_indexer_handler.rs | 476 +++++ .../indexers/edit_indexer_handler_tests.rs | 1791 +++++++++++++++++ .../indexers/edit_indexer_settings_handler.rs | 182 ++ .../edit_indexer_settings_handler_tests.rs | 570 ++++++ .../indexers/indexers_handler_tests.rs | 805 ++++++++ src/handlers/sonarr_handlers/indexers/mod.rs | 205 ++ .../indexers/test_all_indexers_handler.rs | 117 ++ .../test_all_indexers_handler_tests.rs | 337 ++++ src/handlers/sonarr_handlers/mod.rs | 5 + .../sonarr_handlers/sonarr_handler_tests.rs | 21 + src/models/servarr_data/modals.rs | 1 + src/models/servarr_data/radarr/radarr_data.rs | 14 +- .../servarr_data/radarr/radarr_data_tests.rs | 15 +- src/models/servarr_data/sonarr/sonarr_data.rs | 97 + .../servarr_data/sonarr/sonarr_data_tests.rs | 166 +- src/network/radarr_network.rs | 3 +- src/network/radarr_network_tests.rs | 9 +- src/network/sonarr_network.rs | 3 +- src/network/sonarr_network_tests.rs | 9 +- 23 files changed, 4995 insertions(+), 79 deletions(-) create mode 100644 src/handlers/sonarr_handlers/indexers/edit_indexer_handler.rs create mode 100644 src/handlers/sonarr_handlers/indexers/edit_indexer_handler_tests.rs create mode 100644 src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler.rs create mode 100644 src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler_tests.rs create mode 100644 src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs create mode 100644 src/handlers/sonarr_handlers/indexers/mod.rs create mode 100644 src/handlers/sonarr_handlers/indexers/test_all_indexers_handler.rs create mode 100644 src/handlers/sonarr_handlers/indexers/test_all_indexers_handler_tests.rs diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs index daf054c..7c0f936 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs @@ -45,14 +45,42 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<' } fn handle_scroll_up(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::EditIndexerPrompt { - self.app.data.radarr_data.selected_block.up(); + match self.active_radarr_block { + ActiveRadarrBlock::EditIndexerPrompt => { + self.app.data.radarr_data.selected_block.up(); + } + ActiveRadarrBlock::EditIndexerPriorityInput => { + self + .app + .data + .radarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .priority += 1; + } + _ => (), } } fn handle_scroll_down(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::EditIndexerPrompt { - self.app.data.radarr_data.selected_block.down(); + match self.active_radarr_block { + ActiveRadarrBlock::EditIndexerPrompt => { + self.app.data.radarr_data.selected_block.down(); + } + ActiveRadarrBlock::EditIndexerPriorityInput => { + let edit_indexer_modal = self + .app + .data + .radarr_data + .edit_indexer_modal + .as_mut() + .unwrap(); + if edit_indexer_modal.priority > 0 { + edit_indexer_modal.priority -= 1; + } + } + _ => (), } } @@ -287,6 +315,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<' self.app.push_navigation_stack(selected_block.into()); self.app.should_ignore_quit_key = true; } + ActiveRadarrBlock::EditIndexerPriorityInput => self + .app + .push_navigation_stack(ActiveRadarrBlock::EditIndexerPriorityInput.into()), ActiveRadarrBlock::EditIndexerToggleEnableRss => { let indexer = self .app @@ -330,6 +361,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<' self.app.pop_navigation_stack(); self.app.should_ignore_quit_key = false; } + ActiveRadarrBlock::EditIndexerPriorityInput => self.app.pop_navigation_stack(), _ => (), } } @@ -345,6 +377,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<' | ActiveRadarrBlock::EditIndexerUrlInput | ActiveRadarrBlock::EditIndexerApiKeyInput | ActiveRadarrBlock::EditIndexerSeedRatioInput + | ActiveRadarrBlock::EditIndexerPriorityInput | ActiveRadarrBlock::EditIndexerTagsInput => { self.app.pop_navigation_stack(); self.app.should_ignore_quit_key = false; diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs index 507de3c..9325c65 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs @@ -20,9 +20,86 @@ mod tests { use super::*; + #[rstest] + fn test_edit_indexer_priority_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); + app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::with( + key, + &mut app, + ActiveRadarrBlock::EditIndexerPriorityInput, + None, + ) + .handle(); + + if key == Key::Up { + assert_eq!( + app + .data + .radarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 1 + ); + } else { + assert_eq!( + app + .data + .radarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 0 + ); + + EditIndexerHandler::with( + Key::Up, + &mut app, + ActiveRadarrBlock::EditIndexerPriorityInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .radarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 1 + ); + + EditIndexerHandler::with( + key, + &mut app, + ActiveRadarrBlock::EditIndexerPriorityInput, + None, + ) + .handle(); + assert_eq!( + app + .data + .radarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 0 + ); + } + } + #[rstest] fn test_edit_indexer_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); app.data.radarr_data.selected_block = BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); @@ -48,6 +125,7 @@ mod tests { #[values(Key::Up, Key::Down)] key: Key, ) { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.is_loading = true; app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); app.data.radarr_data.selected_block = @@ -75,6 +153,7 @@ mod tests { #[test] fn test_edit_indexer_name_input_home_end() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { name: "Test".into(), ..EditIndexerModal::default() @@ -126,6 +205,7 @@ mod tests { #[test] fn test_edit_indexer_url_input_home_end() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { url: "Test".into(), ..EditIndexerModal::default() @@ -177,6 +257,7 @@ mod tests { #[test] fn test_edit_indexer_api_key_input_home_end() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { api_key: "Test".into(), ..EditIndexerModal::default() @@ -228,6 +309,7 @@ mod tests { #[test] fn test_edit_indexer_seed_ratio_input_home_end() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { seed_ratio: "Test".into(), ..EditIndexerModal::default() @@ -279,6 +361,7 @@ mod tests { #[test] fn test_edit_indexer_tags_input_home_end() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { tags: "Test".into(), ..EditIndexerModal::default() @@ -345,6 +428,7 @@ mod tests { #[rstest] fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.selected_block = BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); app.data.radarr_data.selected_block.y = EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1; @@ -386,6 +470,7 @@ mod tests { #[case] right_block: ActiveRadarrBlock, ) { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.selected_block = BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); app.data.radarr_data.selected_block.y = starting_y_index; @@ -426,6 +511,11 @@ mod tests { ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch, ActiveRadarrBlock::EditIndexerTagsInput )] + #[case( + 3, + ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveRadarrBlock::EditIndexerPriorityInput + )] fn test_left_right_block_toggle_nzb( #[values(Key::Left, Key::Right)] key: Key, #[case] starting_y_index: usize, @@ -433,6 +523,7 @@ mod tests { #[case] right_block: ActiveRadarrBlock, ) { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.selected_block = BlockSelectionState::new(EDIT_INDEXER_NZB_SELECTION_BLOCKS); app.data.radarr_data.selected_block.y = starting_y_index; @@ -458,18 +549,19 @@ mod tests { } #[rstest] - fn test_left_right_block_toggle_nzb_empty_row_to_prompt_confirm( + fn test_left_right_block_toggle_torren_empty_row_to_prompt_confirm( #[values(Key::Left, Key::Right)] key: Key, ) { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.selected_block = - BlockSelectionState::new(EDIT_INDEXER_NZB_SELECTION_BLOCKS); - app.data.radarr_data.selected_block.y = 3; + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.y = 4; app.data.radarr_data.prompt_confirm = false; assert_eq!( app.data.radarr_data.selected_block.get_active_block(), - ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch + ActiveRadarrBlock::EditIndexerPriorityInput ); EditIndexerHandler::with(key, &mut app, ActiveRadarrBlock::EditIndexerPrompt, None).handle(); @@ -491,6 +583,7 @@ mod tests { #[test] fn test_edit_indexer_name_input_left_right_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { name: "Test".into(), ..EditIndexerModal::default() @@ -542,6 +635,7 @@ mod tests { #[test] fn test_edit_indexer_url_input_left_right_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { url: "Test".into(), ..EditIndexerModal::default() @@ -593,6 +687,7 @@ mod tests { #[test] fn test_edit_indexer_api_key_input_left_right_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { api_key: "Test".into(), ..EditIndexerModal::default() @@ -644,6 +739,7 @@ mod tests { #[test] fn test_edit_indexer_seed_ratio_input_left_right_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { seed_ratio: "Test".into(), ..EditIndexerModal::default() @@ -695,6 +791,7 @@ mod tests { #[test] fn test_edit_indexer_tags_input_left_right_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { tags: "Test".into(), ..EditIndexerModal::default() @@ -857,6 +954,7 @@ mod tests { #[case] block: ActiveRadarrBlock, ) { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); app.push_navigation_stack(ActiveRadarrBlock::EditIndexerPrompt.into()); app.data.radarr_data.selected_block = @@ -879,9 +977,35 @@ mod tests { assert!(app.should_ignore_quit_key); } + #[test] + fn test_edit_indexer_priority_input_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); + app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.push_navigation_stack(ActiveRadarrBlock::EditIndexerPrompt.into()); + app.data.radarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.radarr_data.selected_block.set_index(0, 4); + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveRadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveRadarrBlock::EditIndexerPriorityInput.into() + ); + assert!(!app.should_ignore_quit_key); + } + #[test] fn test_edit_indexer_toggle_enable_rss_submit() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); app.data.radarr_data.selected_block = BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); @@ -934,6 +1058,7 @@ mod tests { #[test] fn test_edit_indexer_toggle_enable_automatic_search_submit() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); app.data.radarr_data.selected_block = BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); @@ -986,6 +1111,7 @@ mod tests { #[test] fn test_edit_indexer_toggle_enable_interactive_search_submit() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); app.data.radarr_data.selected_block = BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); @@ -1038,6 +1164,7 @@ mod tests { #[test] fn test_edit_indexer_name_input_submit() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.should_ignore_quit_key = true; app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { name: "Test".into(), @@ -1073,6 +1200,7 @@ mod tests { #[test] fn test_edit_indexer_url_input_submit() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.should_ignore_quit_key = true; app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { url: "Test".into(), @@ -1108,6 +1236,7 @@ mod tests { #[test] fn test_edit_indexer_api_key_input_submit() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.should_ignore_quit_key = true; app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { api_key: "Test".into(), @@ -1143,6 +1272,7 @@ mod tests { #[test] fn test_edit_indexer_seed_ratio_input_submit() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.should_ignore_quit_key = true; app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { seed_ratio: "Test".into(), @@ -1178,6 +1308,7 @@ mod tests { #[test] fn test_edit_indexer_tags_input_submit() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.should_ignore_quit_key = true; app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { tags: "Test".into(), @@ -1249,7 +1380,8 @@ mod tests { ActiveRadarrBlock::EditIndexerUrlInput, ActiveRadarrBlock::EditIndexerApiKeyInput, ActiveRadarrBlock::EditIndexerSeedRatioInput, - ActiveRadarrBlock::EditIndexerTagsInput + ActiveRadarrBlock::EditIndexerTagsInput, + ActiveRadarrBlock::EditIndexerPriorityInput )] active_radarr_block: ActiveRadarrBlock, ) { @@ -1283,6 +1415,7 @@ mod tests { #[test] fn test_edit_indexer_name_input_backspace() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { name: "Test".into(), ..EditIndexerModal::default() @@ -1312,6 +1445,7 @@ mod tests { #[test] fn test_edit_indexer_url_input_backspace() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { url: "Test".into(), ..EditIndexerModal::default() @@ -1341,6 +1475,7 @@ mod tests { #[test] fn test_edit_indexer_api_key_input_backspace() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { api_key: "Test".into(), ..EditIndexerModal::default() @@ -1370,6 +1505,7 @@ mod tests { #[test] fn test_edit_indexer_seed_ratio_input_backspace() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { seed_ratio: "Test".into(), ..EditIndexerModal::default() @@ -1399,6 +1535,7 @@ mod tests { #[test] fn test_edit_indexer_tags_input_backspace() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal { tags: "Test".into(), ..EditIndexerModal::default() @@ -1428,6 +1565,7 @@ mod tests { #[test] fn test_edit_indexer_name_input_char_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); EditIndexerHandler::with( @@ -1454,6 +1592,7 @@ mod tests { #[test] fn test_edit_indexer_url_input_char_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); EditIndexerHandler::with( @@ -1480,6 +1619,7 @@ mod tests { #[test] fn test_edit_indexer_api_key_input_char_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); EditIndexerHandler::with( @@ -1506,6 +1646,7 @@ mod tests { #[test] fn test_edit_indexer_seed_ratio_input_char_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); EditIndexerHandler::with( @@ -1532,6 +1673,7 @@ mod tests { #[test] fn test_edit_indexer_tags_input_char_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); EditIndexerHandler::with( @@ -1588,7 +1730,7 @@ mod tests { } #[test] - fn test_indexer_settings_handler_accepts() { + fn test_edit_indexer_handler_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { if EDIT_INDEXER_BLOCKS.contains(&active_radarr_block) { assert!(EditIndexerHandler::accepts(active_radarr_block)); @@ -1601,6 +1743,7 @@ mod tests { #[test] fn test_edit_indexer_handler_is_not_ready_when_loading() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.is_loading = true; let handler = EditIndexerHandler::with( @@ -1616,6 +1759,7 @@ mod tests { #[test] fn test_edit_indexer_handler_is_not_ready_when_edit_indexer_modal_is_none() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.is_loading = false; let handler = EditIndexerHandler::with( @@ -1631,6 +1775,7 @@ mod tests { #[test] fn test_edit_indexer_handler_is_ready_when_edit_indexer_modal_is_some() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.is_loading = false; app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); diff --git a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs index ac4c0f3..5911968 100644 --- a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs @@ -459,51 +459,6 @@ mod tests { use super::*; - #[test] - fn test_indexer_add() { - let mut app = App::default(); - app - .data - .radarr_data - .indexers - .set_items(vec![Indexer::default()]); - - IndexersHandler::with( - DEFAULT_KEYBINDINGS.add.key, - &mut app, - ActiveRadarrBlock::Indexers, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::AddIndexer.into() - ); - } - - #[test] - fn test_indexer_add_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); - app - .data - .radarr_data - .indexers - .set_items(vec![Indexer::default()]); - - IndexersHandler::with( - DEFAULT_KEYBINDINGS.add.key, - &mut app, - ActiveRadarrBlock::Indexers, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); - } - #[test] fn test_refresh_indexers_key() { let mut app = App::default(); diff --git a/src/handlers/radarr_handlers/indexers/mod.rs b/src/handlers/radarr_handlers/indexers/mod.rs index 49c39ce..d080eab 100644 --- a/src/handlers/radarr_handlers/indexers/mod.rs +++ b/src/handlers/radarr_handlers/indexers/mod.rs @@ -169,11 +169,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a, let key = self.key; match self.active_radarr_block { ActiveRadarrBlock::Indexers => match self.key { - _ if key == DEFAULT_KEYBINDINGS.add.key => { - self - .app - .push_navigation_stack(ActiveRadarrBlock::AddIndexer.into()); - } _ if key == DEFAULT_KEYBINDINGS.refresh.key => { self.app.should_refresh = true; } diff --git a/src/handlers/sonarr_handlers/indexers/edit_indexer_handler.rs b/src/handlers/sonarr_handlers/indexers/edit_indexer_handler.rs new file mode 100644 index 0000000..ca41e51 --- /dev/null +++ b/src/handlers/sonarr_handlers/indexers/edit_indexer_handler.rs @@ -0,0 +1,476 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::App; +use crate::event::Key; +use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_INDEXER_BLOCKS}; +use crate::network::sonarr_network::SonarrEvent; +use crate::{handle_prompt_left_right_keys, handle_text_box_keys, handle_text_box_left_right_keys}; + +#[cfg(test)] +#[path = "edit_indexer_handler_tests.rs"] +mod edit_indexer_handler_tests; + +pub(super) struct EditIndexerHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EditIndexerHandler<'a, 'b> { + fn accepts(active_block: ActiveSonarrBlock) -> bool { + EDIT_INDEXER_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + _context: Option, + ) -> EditIndexerHandler<'a, 'b> { + EditIndexerHandler { + key, + app, + active_sonarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && self.app.data.sonarr_data.edit_indexer_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EditIndexerPrompt => { + self.app.data.sonarr_data.selected_block.up(); + } + ActiveSonarrBlock::EditIndexerPriorityInput => { + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .priority += 1; + } + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EditIndexerPrompt => { + self.app.data.sonarr_data.selected_block.down(); + } + ActiveSonarrBlock::EditIndexerPriorityInput => { + let edit_indexer_modal = self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap(); + if edit_indexer_modal.priority > 0 { + edit_indexer_modal.priority -= 1; + } + } + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EditIndexerNameInput => { + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .name + .scroll_home(); + } + ActiveSonarrBlock::EditIndexerUrlInput => { + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .url + .scroll_home(); + } + ActiveSonarrBlock::EditIndexerApiKeyInput => { + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .api_key + .scroll_home(); + } + ActiveSonarrBlock::EditIndexerSeedRatioInput => { + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .seed_ratio + .scroll_home(); + } + ActiveSonarrBlock::EditIndexerTagsInput => { + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .tags + .scroll_home(); + } + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EditIndexerNameInput => { + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .name + .reset_offset(); + } + ActiveSonarrBlock::EditIndexerUrlInput => { + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .url + .reset_offset(); + } + ActiveSonarrBlock::EditIndexerApiKeyInput => { + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .api_key + .reset_offset(); + } + ActiveSonarrBlock::EditIndexerSeedRatioInput => { + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .seed_ratio + .reset_offset(); + } + ActiveSonarrBlock::EditIndexerTagsInput => { + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .tags + .reset_offset(); + } + _ => (), + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EditIndexerPrompt => { + handle_prompt_left_right_keys!( + self, + ActiveSonarrBlock::EditIndexerConfirmPrompt, + sonarr_data + ); + } + ActiveSonarrBlock::EditIndexerNameInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .name + ); + } + ActiveSonarrBlock::EditIndexerUrlInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .url + ); + } + ActiveSonarrBlock::EditIndexerApiKeyInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .api_key + ); + } + ActiveSonarrBlock::EditIndexerSeedRatioInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .seed_ratio + ); + } + ActiveSonarrBlock::EditIndexerTagsInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .tags + ); + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EditIndexerPrompt => { + let selected_block = self.app.data.sonarr_data.selected_block.get_active_block(); + match selected_block { + ActiveSonarrBlock::EditIndexerConfirmPrompt => { + let sonarr_data = &mut self.app.data.sonarr_data; + if sonarr_data.prompt_confirm { + sonarr_data.prompt_confirm_action = Some(SonarrEvent::EditIndexer(None)); + self.app.should_refresh = true; + } else { + sonarr_data.edit_indexer_modal = None; + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::EditIndexerNameInput + | ActiveSonarrBlock::EditIndexerUrlInput + | ActiveSonarrBlock::EditIndexerApiKeyInput + | ActiveSonarrBlock::EditIndexerSeedRatioInput + | ActiveSonarrBlock::EditIndexerTagsInput => { + self.app.push_navigation_stack(selected_block.into()); + self.app.should_ignore_quit_key = true; + } + ActiveSonarrBlock::EditIndexerPriorityInput => self + .app + .push_navigation_stack(ActiveSonarrBlock::EditIndexerPriorityInput.into()), + ActiveSonarrBlock::EditIndexerToggleEnableRss => { + let indexer = self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap(); + indexer.enable_rss = Some(!indexer.enable_rss.unwrap_or_default()); + } + ActiveSonarrBlock::EditIndexerToggleEnableAutomaticSearch => { + let indexer = self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap(); + indexer.enable_automatic_search = + Some(!indexer.enable_automatic_search.unwrap_or_default()); + } + ActiveSonarrBlock::EditIndexerToggleEnableInteractiveSearch => { + let indexer = self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap(); + indexer.enable_interactive_search = + Some(!indexer.enable_interactive_search.unwrap_or_default()); + } + _ => (), + } + } + ActiveSonarrBlock::EditIndexerNameInput + | ActiveSonarrBlock::EditIndexerUrlInput + | ActiveSonarrBlock::EditIndexerApiKeyInput + | ActiveSonarrBlock::EditIndexerSeedRatioInput + | ActiveSonarrBlock::EditIndexerTagsInput => { + self.app.pop_navigation_stack(); + self.app.should_ignore_quit_key = false; + } + ActiveSonarrBlock::EditIndexerPriorityInput => self.app.pop_navigation_stack(), + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EditIndexerPrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.prompt_confirm = false; + self.app.data.sonarr_data.edit_indexer_modal = None; + } + ActiveSonarrBlock::EditIndexerNameInput + | ActiveSonarrBlock::EditIndexerUrlInput + | ActiveSonarrBlock::EditIndexerApiKeyInput + | ActiveSonarrBlock::EditIndexerSeedRatioInput + | ActiveSonarrBlock::EditIndexerPriorityInput + | ActiveSonarrBlock::EditIndexerTagsInput => { + self.app.pop_navigation_stack(); + self.app.should_ignore_quit_key = false; + } + _ => self.app.pop_navigation_stack(), + } + } + + fn handle_char_key_event(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EditIndexerNameInput => { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .name + ); + } + ActiveSonarrBlock::EditIndexerUrlInput => { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .url + ); + } + ActiveSonarrBlock::EditIndexerApiKeyInput => { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .api_key + ); + } + ActiveSonarrBlock::EditIndexerSeedRatioInput => { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .seed_ratio + ); + } + ActiveSonarrBlock::EditIndexerTagsInput => { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .sonarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .tags + ); + } + ActiveSonarrBlock::EditIndexerPrompt => { + if self.app.data.sonarr_data.selected_block.get_active_block() + == ActiveSonarrBlock::EditIndexerConfirmPrompt + && self.key == DEFAULT_KEYBINDINGS.confirm.key + { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::EditIndexer(None)); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } +} diff --git a/src/handlers/sonarr_handlers/indexers/edit_indexer_handler_tests.rs b/src/handlers/sonarr_handlers/indexers/edit_indexer_handler_tests.rs new file mode 100644 index 0000000..1008ec2 --- /dev/null +++ b/src/handlers/sonarr_handlers/indexers/edit_indexer_handler_tests.rs @@ -0,0 +1,1791 @@ +#[cfg(test)] +mod tests { + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::indexers::edit_indexer_handler::EditIndexerHandler; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_INDEXER_BLOCKS}; + use strum::IntoEnumIterator; + + mod test_handle_scroll_up_and_down { + use crate::app::App; + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::sonarr::sonarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS; + use crate::models::BlockSelectionState; + + use super::*; + + #[rstest] + fn test_edit_indexer_priority_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::with( + key, + &mut app, + ActiveSonarrBlock::EditIndexerPriorityInput, + None, + ) + .handle(); + + if key == Key::Up { + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 1 + ); + } else { + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 0 + ); + + EditIndexerHandler::with( + Key::Up, + &mut app, + ActiveSonarrBlock::EditIndexerPriorityInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 1 + ); + + EditIndexerHandler::with( + key, + &mut app, + ActiveSonarrBlock::EditIndexerPriorityInput, + None, + ) + .handle(); + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 0 + ); + } + } + + #[rstest] + fn test_edit_indexer_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.down(); + + EditIndexerHandler::with(key, &mut app, ActiveSonarrBlock::EditIndexerPrompt, None).handle(); + + if key == Key::Up { + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::EditIndexerNameInput + ); + } else { + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::EditIndexerToggleEnableAutomaticSearch + ); + } + } + + #[rstest] + fn test_edit_indexer_prompt_scroll_no_op_when_not_ready( + #[values(Key::Up, Key::Down)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = true; + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.down(); + + EditIndexerHandler::with(key, &mut app, ActiveSonarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::EditIndexerToggleEnableRss + ); + } + } + + mod test_handle_home_end { + use std::sync::atomic::Ordering; + + use crate::app::App; + use crate::models::servarr_data::modals::EditIndexerModal; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_edit_indexer_name_input_home_end() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + name: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_url_input_home_end() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + url: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_api_key_input_home_end() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + api_key: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_seed_ratio_input_home_end() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + seed_ratio: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_tags_input_home_end() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + tags: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_left_right_action { + use std::sync::atomic::Ordering; + + use crate::app::App; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::sonarr::sonarr_data::{ + EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + }; + use crate::models::BlockSelectionState; + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.y = EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1; + + EditIndexerHandler::with(key, &mut app, ActiveSonarrBlock::EditIndexerPrompt, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + EditIndexerHandler::with(key, &mut app, ActiveSonarrBlock::EditIndexerPrompt, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[rstest] + #[case( + 0, + ActiveSonarrBlock::EditIndexerNameInput, + ActiveSonarrBlock::EditIndexerUrlInput + )] + #[case( + 1, + ActiveSonarrBlock::EditIndexerToggleEnableRss, + ActiveSonarrBlock::EditIndexerApiKeyInput + )] + #[case( + 2, + ActiveSonarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveSonarrBlock::EditIndexerSeedRatioInput + )] + #[case( + 3, + ActiveSonarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveSonarrBlock::EditIndexerTagsInput + )] + fn test_left_right_block_toggle_torrents( + #[values(Key::Left, Key::Right)] key: Key, + #[case] starting_y_index: usize, + #[case] left_block: ActiveSonarrBlock, + #[case] right_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.y = starting_y_index; + + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + left_block + ); + + EditIndexerHandler::with(key, &mut app, ActiveSonarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + right_block + ); + + EditIndexerHandler::with(key, &mut app, ActiveSonarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + left_block + ); + } + + #[rstest] + #[case( + 0, + ActiveSonarrBlock::EditIndexerNameInput, + ActiveSonarrBlock::EditIndexerUrlInput + )] + #[case( + 1, + ActiveSonarrBlock::EditIndexerToggleEnableRss, + ActiveSonarrBlock::EditIndexerApiKeyInput + )] + #[case( + 2, + ActiveSonarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveSonarrBlock::EditIndexerTagsInput + )] + #[case( + 3, + ActiveSonarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveSonarrBlock::EditIndexerPriorityInput + )] + fn test_left_right_block_toggle_nzb( + #[values(Key::Left, Key::Right)] key: Key, + #[case] starting_y_index: usize, + #[case] left_block: ActiveSonarrBlock, + #[case] right_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_NZB_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.y = starting_y_index; + + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + left_block + ); + + EditIndexerHandler::with(key, &mut app, ActiveSonarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + right_block + ); + + EditIndexerHandler::with(key, &mut app, ActiveSonarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + left_block + ); + } + + #[rstest] + fn test_left_right_block_toggle_torren_empty_row_to_prompt_confirm( + #[values(Key::Left, Key::Right)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.y = 4; + app.data.sonarr_data.prompt_confirm = false; + + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::EditIndexerPriorityInput + ); + + EditIndexerHandler::with(key, &mut app, ActiveSonarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::EditIndexerConfirmPrompt + ); + + EditIndexerHandler::with(key, &mut app, ActiveSonarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::EditIndexerConfirmPrompt + ); + assert!(app.data.sonarr_data.prompt_confirm); + } + + #[test] + fn test_edit_indexer_name_input_left_right_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + name: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_url_input_left_right_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + url: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_api_key_input_left_right_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + api_key: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_seed_ratio_input_left_right_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + seed_ratio: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_tags_input_left_right_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + tags: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::app::App; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::{ + servarr_data::sonarr::sonarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, BlockSelectionState, + }; + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_edit_indexer_prompt_prompt_decline_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(0, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + assert!(!app.should_refresh); + assert_eq!(app.data.sonarr_data.edit_indexer_modal, None); + } + + #[test] + fn test_edit_indexer_prompt_prompt_confirmation_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(0, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.sonarr_data.prompt_confirm = true; + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert!(app.data.sonarr_data.edit_indexer_modal.is_some()); + assert!(app.should_refresh); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::EditIndexer(None)) + ); + } + + #[test] + fn test_edit_indexer_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.sonarr_data.prompt_confirm = true; + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditIndexerPrompt.into() + ); + assert!(app.data.sonarr_data.edit_indexer_modal.is_some()); + assert!(!app.should_refresh); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + } + + #[rstest] + #[case(0, 0, ActiveSonarrBlock::EditIndexerNameInput)] + #[case(0, 1, ActiveSonarrBlock::EditIndexerUrlInput)] + #[case(1, 1, ActiveSonarrBlock::EditIndexerApiKeyInput)] + #[case(2, 1, ActiveSonarrBlock::EditIndexerSeedRatioInput)] + #[case(3, 1, ActiveSonarrBlock::EditIndexerTagsInput)] + fn test_edit_indexer_prompt_submit_input_fields( + #[case] starting_y: usize, + #[case] starting_x: usize, + #[case] block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(starting_x, starting_y); + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), block.into()); + assert!(app.should_ignore_quit_key); + } + + #[test] + fn test_edit_indexer_priority_input_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.set_index(0, 4); + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditIndexerPriorityInput.into() + ); + assert!(!app.should_ignore_quit_key); + } + + #[test] + fn test_edit_indexer_toggle_enable_rss_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.set_index(0, 1); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditIndexerPrompt.into() + ); + assert!(app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_rss + .unwrap()); + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditIndexerPrompt.into() + ); + assert!(!app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_rss + .unwrap()); + } + + #[test] + fn test_edit_indexer_toggle_enable_automatic_search_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.set_index(0, 2); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditIndexerPrompt.into() + ); + assert!(app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_automatic_search + .unwrap()); + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditIndexerPrompt.into() + ); + assert!(!app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_automatic_search + .unwrap()); + } + + #[test] + fn test_edit_indexer_toggle_enable_interactive_search_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.set_index(0, 3); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditIndexerPrompt.into() + ); + assert!(app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_interactive_search + .unwrap()); + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditIndexerPrompt.into() + ); + assert!(!app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_interactive_search + .unwrap()); + } + + #[test] + fn test_edit_indexer_name_input_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.should_ignore_quit_key = true; + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + name: "Test".into(), + ..EditIndexerModal::default() + }); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerNameInput.into()); + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert!(!app.should_ignore_quit_key); + assert!(!app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .text + .is_empty()); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditIndexerPrompt.into() + ); + } + + #[test] + fn test_edit_indexer_url_input_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.should_ignore_quit_key = true; + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + url: "Test".into(), + ..EditIndexerModal::default() + }); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerUrlInput.into()); + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert!(!app.should_ignore_quit_key); + assert!(!app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .text + .is_empty()); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditIndexerPrompt.into() + ); + } + + #[test] + fn test_edit_indexer_api_key_input_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.should_ignore_quit_key = true; + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + api_key: "Test".into(), + ..EditIndexerModal::default() + }); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerApiKeyInput.into()); + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert!(!app.should_ignore_quit_key); + assert!(!app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .text + .is_empty()); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditIndexerPrompt.into() + ); + } + + #[test] + fn test_edit_indexer_seed_ratio_input_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.should_ignore_quit_key = true; + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + seed_ratio: "Test".into(), + ..EditIndexerModal::default() + }); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerSeedRatioInput.into()); + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert!(!app.should_ignore_quit_key); + assert!(!app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .text + .is_empty()); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditIndexerPrompt.into() + ); + } + + #[test] + fn test_edit_indexer_tags_input_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.should_ignore_quit_key = true; + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + tags: "Test".into(), + ..EditIndexerModal::default() + }); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerTagsInput.into()); + + EditIndexerHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert!(!app.should_ignore_quit_key); + assert!(!app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .text + .is_empty()); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditIndexerPrompt.into() + ); + } + } + + mod test_handle_esc { + use super::*; + use crate::app::App; + use crate::event::Key; + use crate::models::servarr_data::modals::EditIndexerModal; + use pretty_assertions::assert_eq; + use rstest::rstest; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_edit_indexer_prompt_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.data.sonarr_data.edit_indexer_modal, None); + } + + #[rstest] + fn test_edit_indexer_input_fields_esc( + #[values( + ActiveSonarrBlock::EditIndexerNameInput, + ActiveSonarrBlock::EditIndexerUrlInput, + ActiveSonarrBlock::EditIndexerApiKeyInput, + ActiveSonarrBlock::EditIndexerSeedRatioInput, + ActiveSonarrBlock::EditIndexerTagsInput, + ActiveSonarrBlock::EditIndexerPriorityInput + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(active_sonarr_block.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.should_ignore_quit_key = true; + + EditIndexerHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert!(!app.should_ignore_quit_key); + assert_eq!( + app.data.sonarr_data.edit_indexer_modal, + Some(EditIndexerModal::default()) + ); + } + } + + mod test_handle_key_char { + use crate::app::App; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::sonarr::sonarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS; + use crate::models::BlockSelectionState; + use crate::network::sonarr_network::SonarrEvent; + use pretty_assertions::assert_str_eq; + + use super::*; + + #[test] + fn test_edit_indexer_name_input_backspace() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + name: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .text, + "Tes" + ); + } + + #[test] + fn test_edit_indexer_url_input_backspace() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + url: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .text, + "Tes" + ); + } + + #[test] + fn test_edit_indexer_api_key_input_backspace() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + api_key: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .text, + "Tes" + ); + } + + #[test] + fn test_edit_indexer_seed_ratio_input_backspace() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + seed_ratio: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .text, + "Tes" + ); + } + + #[test] + fn test_edit_indexer_tags_input_backspace() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal { + tags: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .text, + "Tes" + ); + } + + #[test] + fn test_edit_indexer_name_input_char_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .text, + "h" + ); + } + + #[test] + fn test_edit_indexer_url_input_char_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .text, + "h" + ); + } + + #[test] + fn test_edit_indexer_api_key_input_char_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .text, + "h" + ); + } + + #[test] + fn test_edit_indexer_seed_ratio_input_char_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .text, + "h" + ); + } + + #[test] + fn test_edit_indexer_tags_input_char_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .text, + "h" + ); + } + + #[test] + fn test_edit_indexer_prompt_prompt_confirmation_confirm() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(0, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1); + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert!(app.data.sonarr_data.edit_indexer_modal.is_some()); + assert!(app.should_refresh); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::EditIndexer(None)) + ); + } + } + + #[test] + fn test_edit_indexer_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if EDIT_INDEXER_BLOCKS.contains(&active_sonarr_block) { + assert!(EditIndexerHandler::accepts(active_sonarr_block)); + } else { + assert!(!EditIndexerHandler::accepts(active_sonarr_block)); + } + }) + } + + #[test] + fn test_edit_indexer_handler_is_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = true; + + let handler = EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_indexer_handler_is_not_ready_when_edit_indexer_modal_is_none() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = false; + + let handler = EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_indexer_handler_is_ready_when_edit_indexer_modal_is_some() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = false; + app.data.sonarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + let handler = EditIndexerHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::EditIndexerPrompt, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler.rs b/src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler.rs new file mode 100644 index 0000000..94afa1d --- /dev/null +++ b/src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler.rs @@ -0,0 +1,182 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::App; +use crate::event::Key; +use crate::handle_prompt_left_right_keys; +use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, INDEXER_SETTINGS_BLOCKS, +}; +use crate::network::sonarr_network::SonarrEvent; + +#[cfg(test)] +#[path = "edit_indexer_settings_handler_tests.rs"] +mod edit_indexer_settings_handler_tests; + +pub(super) struct IndexerSettingsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for IndexerSettingsHandler<'a, 'b> { + fn accepts(active_block: ActiveSonarrBlock) -> bool { + INDEXER_SETTINGS_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + _context: Option, + ) -> IndexerSettingsHandler<'a, 'b> { + IndexerSettingsHandler { + key, + app, + active_sonarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && self.app.data.sonarr_data.indexer_settings.is_some() + } + + fn handle_scroll_up(&mut self) { + let indexer_settings = self.app.data.sonarr_data.indexer_settings.as_mut().unwrap(); + match self.active_sonarr_block { + ActiveSonarrBlock::AllIndexerSettingsPrompt => { + self.app.data.sonarr_data.selected_block.up(); + } + ActiveSonarrBlock::IndexerSettingsMinimumAgeInput => { + indexer_settings.minimum_age += 1; + } + ActiveSonarrBlock::IndexerSettingsRetentionInput => { + indexer_settings.retention += 1; + } + ActiveSonarrBlock::IndexerSettingsMaximumSizeInput => { + indexer_settings.maximum_size += 1; + } + ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput => { + indexer_settings.rss_sync_interval += 1; + } + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + let indexer_settings = self.app.data.sonarr_data.indexer_settings.as_mut().unwrap(); + match self.active_sonarr_block { + ActiveSonarrBlock::AllIndexerSettingsPrompt => { + self.app.data.sonarr_data.selected_block.down() + } + ActiveSonarrBlock::IndexerSettingsMinimumAgeInput => { + if indexer_settings.minimum_age > 0 { + indexer_settings.minimum_age -= 1; + } + } + ActiveSonarrBlock::IndexerSettingsRetentionInput => { + if indexer_settings.retention > 0 { + indexer_settings.retention -= 1; + } + } + ActiveSonarrBlock::IndexerSettingsMaximumSizeInput => { + if indexer_settings.maximum_size > 0 { + indexer_settings.maximum_size -= 1; + } + } + ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput => { + if indexer_settings.rss_sync_interval > 0 { + indexer_settings.rss_sync_interval -= 1; + } + } + _ => (), + } + } + + 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_sonarr_block == ActiveSonarrBlock::AllIndexerSettingsPrompt { + handle_prompt_left_right_keys!( + self, + ActiveSonarrBlock::IndexerSettingsConfirmPrompt, + sonarr_data + ); + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::AllIndexerSettingsPrompt => { + match self.app.data.sonarr_data.selected_block.get_active_block() { + ActiveSonarrBlock::IndexerSettingsConfirmPrompt => { + let sonarr_data = &mut self.app.data.sonarr_data; + if sonarr_data.prompt_confirm { + sonarr_data.prompt_confirm_action = Some(SonarrEvent::EditAllIndexerSettings(None)); + self.app.should_refresh = true; + } else { + sonarr_data.indexer_settings = None; + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::IndexerSettingsMinimumAgeInput + | ActiveSonarrBlock::IndexerSettingsRetentionInput + | ActiveSonarrBlock::IndexerSettingsMaximumSizeInput + | ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput => { + self.app.push_navigation_stack( + ( + self.app.data.sonarr_data.selected_block.get_active_block(), + None, + ) + .into(), + ) + } + + _ => (), + } + } + + ActiveSonarrBlock::IndexerSettingsMinimumAgeInput + | ActiveSonarrBlock::IndexerSettingsRetentionInput + | ActiveSonarrBlock::IndexerSettingsMaximumSizeInput + | ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput => self.app.pop_navigation_stack(), + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::AllIndexerSettingsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.prompt_confirm = false; + self.app.data.sonarr_data.indexer_settings = None; + } + _ => self.app.pop_navigation_stack(), + } + } + + fn handle_char_key_event(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::AllIndexerSettingsPrompt + && self.app.data.sonarr_data.selected_block.get_active_block() + == ActiveSonarrBlock::IndexerSettingsConfirmPrompt + && self.key == DEFAULT_KEYBINDINGS.confirm.key + { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::EditAllIndexerSettings(None)); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + } +} diff --git a/src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler_tests.rs b/src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler_tests.rs new file mode 100644 index 0000000..8301125 --- /dev/null +++ b/src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler_tests.rs @@ -0,0 +1,570 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::indexers::edit_indexer_settings_handler::IndexerSettingsHandler; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, INDEXER_SETTINGS_BLOCKS, + }; + use crate::models::sonarr_models::IndexerSettings; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::servarr_data::sonarr::sonarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS; + use crate::models::sonarr_models::IndexerSettings; + use crate::models::BlockSelectionState; + + use super::*; + + macro_rules! test_i64_counter_scroll_value { + ($block:expr, $key:expr, $data_ref:ident, $negatives:literal) => { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.indexer_settings = Some(IndexerSettings::default()); + + IndexerSettingsHandler::with($key, &mut app, $block, None).handle(); + + if $key == Key::Up { + assert_eq!( + app + .data + .sonarr_data + .indexer_settings + .as_ref() + .unwrap() + .$data_ref, + 1 + ); + } else { + if $negatives { + assert_eq!( + app + .data + .sonarr_data + .indexer_settings + .as_ref() + .unwrap() + .$data_ref, + -1 + ); + } else { + assert_eq!( + app + .data + .sonarr_data + .indexer_settings + .as_ref() + .unwrap() + .$data_ref, + 0 + ); + + IndexerSettingsHandler::with(Key::Up, &mut app, $block, None).handle(); + + assert_eq!( + app + .data + .sonarr_data + .indexer_settings + .as_ref() + .unwrap() + .$data_ref, + 1 + ); + + IndexerSettingsHandler::with($key, &mut app, $block, None).handle(); + assert_eq!( + app + .data + .sonarr_data + .indexer_settings + .as_ref() + .unwrap() + .$data_ref, + 0 + ); + } + } + }; + } + + #[rstest] + fn test_edit_indexer_settings_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.indexer_settings = Some(IndexerSettings::default()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.down(); + + IndexerSettingsHandler::with( + key, + &mut app, + ActiveSonarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + if key == Key::Up { + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::IndexerSettingsMinimumAgeInput + ); + } else { + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::IndexerSettingsMaximumSizeInput + ); + } + } + + #[rstest] + fn test_edit_indexer_settings_prompt_scroll_no_op_when_not_ready( + #[values(Key::Up, Key::Down)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = true; + app.data.sonarr_data.indexer_settings = Some(IndexerSettings::default()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.down(); + + IndexerSettingsHandler::with( + key, + &mut app, + ActiveSonarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.selected_block.get_active_block(), + ActiveSonarrBlock::IndexerSettingsRetentionInput + ); + } + + #[rstest] + fn test_edit_indexer_settings_minimum_age_scroll(#[values(Key::Up, Key::Down)] key: Key) { + test_i64_counter_scroll_value!( + ActiveSonarrBlock::IndexerSettingsMinimumAgeInput, + key, + minimum_age, + false + ); + } + + #[rstest] + fn test_edit_indexer_settings_retention_scroll(#[values(Key::Up, Key::Down)] key: Key) { + test_i64_counter_scroll_value!( + ActiveSonarrBlock::IndexerSettingsRetentionInput, + key, + retention, + false + ); + } + + #[rstest] + fn test_edit_indexer_settings_maximum_size_scroll(#[values(Key::Up, Key::Down)] key: Key) { + test_i64_counter_scroll_value!( + ActiveSonarrBlock::IndexerSettingsMaximumSizeInput, + key, + maximum_size, + false + ); + } + + #[rstest] + fn test_edit_indexer_settings_rss_sync_interval_scroll(#[values(Key::Up, Key::Down)] key: Key) { + test_i64_counter_scroll_value!( + ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput, + key, + rss_sync_interval, + false + ); + } + } + + mod test_handle_left_right_action { + use crate::models::servarr_data::sonarr::sonarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS; + + use crate::models::BlockSelectionState; + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.y = INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1; + + IndexerSettingsHandler::with( + key, + &mut app, + ActiveSonarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + IndexerSettingsHandler::with( + key, + &mut app, + ActiveSonarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::{ + models::{ + servarr_data::sonarr::sonarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS, + sonarr_models::IndexerSettings, BlockSelectionState, + }, + network::sonarr_network::SonarrEvent, + }; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_edit_indexer_settings_prompt_prompt_decline_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::AllIndexerSettingsPrompt.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(0, INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1); + app.data.sonarr_data.indexer_settings = Some(IndexerSettings::default()); + + IndexerSettingsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + assert!(!app.should_refresh); + assert_eq!(app.data.sonarr_data.indexer_settings, None); + } + + #[test] + fn test_edit_indexer_settings_prompt_prompt_confirmation_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::AllIndexerSettingsPrompt.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(0, INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1); + app.data.sonarr_data.indexer_settings = Some(IndexerSettings::default()); + app.data.sonarr_data.prompt_confirm = true; + + IndexerSettingsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::EditAllIndexerSettings(None)) + ); + assert!(app.data.sonarr_data.indexer_settings.is_some()); + assert!(app.should_refresh); + } + + #[test] + fn test_edit_indexer_settings_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::AllIndexerSettingsPrompt.into()); + app.data.sonarr_data.indexer_settings = Some(IndexerSettings::default()); + app.data.sonarr_data.prompt_confirm = true; + + IndexerSettingsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AllIndexerSettingsPrompt.into() + ); + assert!(!app.should_refresh); + } + + #[rstest] + #[case(ActiveSonarrBlock::IndexerSettingsMinimumAgeInput, 0)] + #[case(ActiveSonarrBlock::IndexerSettingsRetentionInput, 1)] + #[case(ActiveSonarrBlock::IndexerSettingsMaximumSizeInput, 2)] + #[case(ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput, 3)] + fn test_edit_indexer_settings_prompt_submit_selected_block( + #[case] selected_block: ActiveSonarrBlock, + #[case] y_index: usize, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.indexer_settings = Some(IndexerSettings::default()); + app.push_navigation_stack(ActiveSonarrBlock::AllIndexerSettingsPrompt.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.set_index(0, y_index); + + IndexerSettingsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), selected_block.into()); + } + + #[rstest] + fn test_edit_indexer_settings_prompt_submit_selected_block_no_op_when_not_ready( + #[values(0, 1, 2, 3, 4)] y_index: usize, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = true; + app.data.sonarr_data.indexer_settings = Some(IndexerSettings::default()); + app.push_navigation_stack(ActiveSonarrBlock::AllIndexerSettingsPrompt.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.sonarr_data.selected_block.set_index(0, y_index); + + IndexerSettingsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AllIndexerSettingsPrompt.into() + ); + } + + #[rstest] + fn test_edit_indexer_settings_selected_block_submit( + #[values( + ActiveSonarrBlock::IndexerSettingsMinimumAgeInput, + ActiveSonarrBlock::IndexerSettingsRetentionInput, + ActiveSonarrBlock::IndexerSettingsMaximumSizeInput, + ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.data.sonarr_data.indexer_settings = Some(IndexerSettings::default()); + app.push_navigation_stack(ActiveSonarrBlock::AllIndexerSettingsPrompt.into()); + app.push_navigation_stack(active_sonarr_block.into()); + + IndexerSettingsHandler::with(SUBMIT_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AllIndexerSettingsPrompt.into() + ); + } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::sonarr_models::IndexerSettings; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_edit_indexer_settings_prompt_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::AllIndexerSettingsPrompt.into()); + app.data.sonarr_data.indexer_settings = Some(IndexerSettings::default()); + + IndexerSettingsHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.data.sonarr_data.indexer_settings, None); + } + + #[rstest] + fn test_edit_indexer_settings_selected_blocks_esc( + #[values( + ActiveSonarrBlock::IndexerSettingsMinimumAgeInput, + ActiveSonarrBlock::IndexerSettingsRetentionInput, + ActiveSonarrBlock::IndexerSettingsMaximumSizeInput, + ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(active_sonarr_block.into()); + app.data.sonarr_data.indexer_settings = Some(IndexerSettings::default()); + + IndexerSettingsHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert_eq!( + app.data.sonarr_data.indexer_settings, + Some(IndexerSettings::default()) + ); + } + } + + mod test_handle_key_char { + use crate::{ + models::{ + servarr_data::sonarr::sonarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS, + sonarr_models::IndexerSettings, BlockSelectionState, + }, + network::sonarr_network::SonarrEvent, + }; + + use super::*; + + #[test] + fn test_edit_indexer_settings_prompt_prompt_confirmation_confirm() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::AllIndexerSettingsPrompt.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app + .data + .sonarr_data + .selected_block + .set_index(0, INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1); + app.data.sonarr_data.indexer_settings = Some(IndexerSettings::default()); + + IndexerSettingsHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveSonarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::EditAllIndexerSettings(None)) + ); + assert!(app.data.sonarr_data.indexer_settings.is_some()); + assert!(app.should_refresh); + } + } + + #[test] + fn test_indexer_settings_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if INDEXER_SETTINGS_BLOCKS.contains(&active_sonarr_block) { + assert!(IndexerSettingsHandler::accepts(active_sonarr_block)); + } else { + assert!(!IndexerSettingsHandler::accepts(active_sonarr_block)); + } + }) + } + + #[test] + fn test_edit_indexer_settings_handler_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = true; + + let handler = IndexerSettingsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::AllIndexerSettingsPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_indexer_settings_handler_not_ready_when_indexer_settings_is_none() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = false; + + let handler = IndexerSettingsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::AllIndexerSettingsPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_indexer_settings_handler_ready_when_not_loading_and_indexer_settings_is_some() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = false; + app.data.sonarr_data.indexer_settings = Some(IndexerSettings::default()); + + let handler = IndexerSettingsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::AllIndexerSettingsPrompt, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs new file mode 100644 index 0000000..0ccf3a8 --- /dev/null +++ b/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs @@ -0,0 +1,805 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::indexers::IndexersHandler; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, EDIT_INDEXER_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_BLOCKS, + }; + use crate::models::servarr_models::Indexer; + use crate::test_handler_delegation; + + mod test_handle_scroll_up_and_down { + use rstest::rstest; + + use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; + + use super::*; + + test_iterable_scroll!( + test_indexers_scroll, + IndexersHandler, + sonarr_data, + indexers, + simple_stateful_iterable_vec!(Indexer, String, protocol), + ActiveSonarrBlock::Indexers, + None, + protocol + ); + + #[rstest] + fn test_indexers_scroll_no_op_when_not_ready( + #[values( + DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key + )] + key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = true; + app + .data + .sonarr_data + .indexers + .set_items(simple_stateful_iterable_vec!(Indexer, String, protocol)); + + IndexersHandler::with(key, &mut app, ActiveSonarrBlock::Indexers, None).handle(); + + assert_str_eq!( + app.data.sonarr_data.indexers.current_selection().protocol, + "Test 1" + ); + + IndexersHandler::with(key, &mut app, ActiveSonarrBlock::Indexers, None).handle(); + + assert_str_eq!( + app.data.sonarr_data.indexers.current_selection().protocol, + "Test 1" + ); + } + } + + mod test_handle_home_end { + use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; + + use super::*; + + test_iterable_home_and_end!( + test_indexers_home_end, + IndexersHandler, + sonarr_data, + indexers, + extended_stateful_iterable_vec!(Indexer, String, protocol), + ActiveSonarrBlock::Indexers, + None, + protocol + ); + + #[test] + fn test_indexers_home_end_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = true; + app + .data + .sonarr_data + .indexers + .set_items(extended_stateful_iterable_vec!(Indexer, String, protocol)); + + IndexersHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::Indexers, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.indexers.current_selection().protocol, + "Test 1" + ); + + IndexersHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::Indexers, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.indexers.current_selection().protocol, + "Test 1" + ); + } + } + + mod test_handle_delete { + use pretty_assertions::assert_eq; + + use super::*; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_delete_indexer_prompt() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::with(DELETE_KEY, &mut app, ActiveSonarrBlock::Indexers, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::DeleteIndexerPrompt.into() + ); + } + + #[test] + fn test_delete_indexer_prompt_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::with(DELETE_KEY, &mut app, ActiveSonarrBlock::Indexers, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_indexers_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(5); + + IndexersHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::RootFolders.into() + ); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::RootFolders.into() + ); + } + + #[rstest] + fn test_indexers_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(5); + + IndexersHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::System.into() + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::System.into()); + } + + #[rstest] + fn test_left_right_delete_indexer_prompt_toggle( + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + + IndexersHandler::with(key, &mut app, ActiveSonarrBlock::DeleteIndexerPrompt, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + IndexersHandler::with(key, &mut app, ActiveSonarrBlock::DeleteIndexerPrompt, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::sonarr::sonarr_data::{ + SonarrData, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + }; + use crate::models::servarr_models::{Indexer, IndexerField}; + use bimap::BiMap; + use pretty_assertions::assert_eq; + use serde_json::{Number, Value}; + + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[rstest] + fn test_edit_indexer_submit(#[values(true, false)] torrent_protocol: bool) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + let protocol = if torrent_protocol { + "torrent".to_owned() + } else { + "usenet".to_owned() + }; + let mut expected_edit_indexer_modal = EditIndexerModal { + name: "Test".into(), + enable_rss: Some(true), + enable_automatic_search: Some(true), + enable_interactive_search: Some(true), + url: "https://test.com".into(), + api_key: "1234".into(), + tags: "usenet, test".into(), + ..EditIndexerModal::default() + }; + let mut sonarr_data = SonarrData { + tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), + ..SonarrData::default() + }; + let mut fields = vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(Value::String("https://test.com".to_owned())), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(Value::String("1234".to_owned())), + }, + ]; + + if torrent_protocol { + fields.push(IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: Some(Value::from(1.2f64)), + }); + expected_edit_indexer_modal.seed_ratio = "1.2".into(); + } + + let indexer = Indexer { + name: Some("Test".to_owned()), + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + protocol, + tags: vec![Number::from(1), Number::from(2)], + fields: Some(fields), + ..Indexer::default() + }; + sonarr_data.indexers.set_items(vec![indexer]); + app.data.sonarr_data = sonarr_data; + + IndexersHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::Indexers, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EditIndexerPrompt.into() + ); + assert_eq!( + app.data.sonarr_data.edit_indexer_modal, + Some((&app.data.sonarr_data).into()) + ); + assert_eq!( + app.data.sonarr_data.edit_indexer_modal, + Some(expected_edit_indexer_modal) + ); + if torrent_protocol { + assert_eq!( + app.data.sonarr_data.selected_block.blocks, + EDIT_INDEXER_TORRENT_SELECTION_BLOCKS + ); + } else { + assert_eq!( + app.data.sonarr_data.selected_block.blocks, + EDIT_INDEXER_NZB_SELECTION_BLOCKS + ); + } + } + + #[test] + fn test_edit_indexer_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::Indexers, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert_eq!(app.data.sonarr_data.edit_indexer_modal, None); + } + + #[test] + fn test_delete_indexer_prompt_confirm_submit() { + let mut app = App::default(); + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::DeleteIndexerPrompt.into()); + + IndexersHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::DeleteIndexerPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::DeleteIndexer(None)) + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + } + + #[test] + fn test_prompt_decline_submit() { + let mut app = App::default(); + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::DeleteIndexerPrompt.into()); + + IndexersHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::DeleteIndexerPrompt, + None, + ) + .handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_delete_indexer_prompt_block_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::DeleteIndexerPrompt.into()); + app.data.sonarr_data.prompt_confirm = true; + + IndexersHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::DeleteIndexerPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[rstest] + fn test_test_indexer_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.data.sonarr_data.indexer_test_error = Some("test result".to_owned()); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::TestIndexer.into()); + + IndexersHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::TestIndexer, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert_eq!(app.data.sonarr_data.indexer_test_error, None); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + + IndexersHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::Indexers, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert!(app.error.text.is_empty()); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use crate::{ + models::servarr_data::sonarr::sonarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS, + network::sonarr_network::SonarrEvent, + }; + + use super::*; + + #[test] + fn test_refresh_indexers_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + + IndexersHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_indexers_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + + IndexersHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_indexer_settings_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::with( + DEFAULT_KEYBINDINGS.settings.key, + &mut app, + ActiveSonarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AllIndexerSettingsPrompt.into() + ); + assert_eq!( + app.data.sonarr_data.selected_block.blocks, + INDEXER_SETTINGS_SELECTION_BLOCKS + ); + } + + #[test] + fn test_indexer_settings_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::with( + DEFAULT_KEYBINDINGS.settings.key, + &mut app, + ActiveSonarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + } + + #[test] + fn test_test_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::with( + DEFAULT_KEYBINDINGS.test.key, + &mut app, + ActiveSonarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::TestIndexer.into() + ); + } + + #[test] + fn test_test_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::with( + DEFAULT_KEYBINDINGS.test.key, + &mut app, + ActiveSonarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + } + + #[test] + fn test_test_all_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::with( + DEFAULT_KEYBINDINGS.test_all.key, + &mut app, + ActiveSonarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::TestAllIndexers.into() + ); + } + + #[test] + fn test_test_all_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::with( + DEFAULT_KEYBINDINGS.test_all.key, + &mut app, + ActiveSonarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + } + + #[test] + fn test_delete_indexer_prompt_confirm() { + let mut app = App::default(); + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::DeleteIndexerPrompt.into()); + + IndexersHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveSonarrBlock::DeleteIndexerPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::DeleteIndexer(None)) + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + } + } + + #[rstest] + fn test_delegates_edit_indexer_blocks_to_edit_indexer_handler( + #[values( + ActiveSonarrBlock::EditIndexerPrompt, + ActiveSonarrBlock::EditIndexerConfirmPrompt, + ActiveSonarrBlock::EditIndexerApiKeyInput, + ActiveSonarrBlock::EditIndexerNameInput, + ActiveSonarrBlock::EditIndexerSeedRatioInput, + ActiveSonarrBlock::EditIndexerToggleEnableRss, + ActiveSonarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveSonarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveSonarrBlock::EditIndexerUrlInput, + ActiveSonarrBlock::EditIndexerTagsInput + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + IndexersHandler, + ActiveSonarrBlock::Indexers, + active_sonarr_block + ); + } + + #[rstest] + fn test_delegates_indexer_settings_blocks_to_indexer_settings_handler( + #[values( + ActiveSonarrBlock::AllIndexerSettingsPrompt, + ActiveSonarrBlock::IndexerSettingsConfirmPrompt, + ActiveSonarrBlock::IndexerSettingsMaximumSizeInput, + ActiveSonarrBlock::IndexerSettingsMinimumAgeInput, + ActiveSonarrBlock::IndexerSettingsRetentionInput, + ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + IndexersHandler, + ActiveSonarrBlock::Indexers, + active_sonarr_block + ); + } + + #[test] + fn test_delegates_test_all_indexers_block_to_test_all_indexers_handler() { + test_handler_delegation!( + IndexersHandler, + ActiveSonarrBlock::Indexers, + ActiveSonarrBlock::TestAllIndexers + ); + } + + #[test] + fn test_indexers_handler_accepts() { + let mut indexers_blocks = Vec::new(); + indexers_blocks.extend(INDEXERS_BLOCKS); + indexers_blocks.extend(INDEXER_SETTINGS_BLOCKS); + indexers_blocks.extend(EDIT_INDEXER_BLOCKS); + indexers_blocks.push(ActiveSonarrBlock::TestAllIndexers); + + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if indexers_blocks.contains(&active_sonarr_block) { + assert!(IndexersHandler::accepts(active_sonarr_block)); + } else { + assert!(!IndexersHandler::accepts(active_sonarr_block)); + } + }) + } + + #[test] + fn test_indexers_handler_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = true; + + let handler = IndexersHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::Indexers, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_indexers_handler_not_ready_when_indexers_is_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = false; + + let handler = IndexersHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::Indexers, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_indexers_handler_ready_when_not_loading_and_indexers_is_not_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = false; + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); + + let handler = IndexersHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::Indexers, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/sonarr_handlers/indexers/mod.rs b/src/handlers/sonarr_handlers/indexers/mod.rs new file mode 100644 index 0000000..1ed5b68 --- /dev/null +++ b/src/handlers/sonarr_handlers/indexers/mod.rs @@ -0,0 +1,205 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::App; +use crate::event::Key; +use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::sonarr_handlers::indexers::edit_indexer_handler::EditIndexerHandler; +use crate::handlers::sonarr_handlers::indexers::edit_indexer_settings_handler::IndexerSettingsHandler; +use crate::handlers::sonarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler; +use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + INDEXERS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, +}; +use crate::models::BlockSelectionState; +use crate::models::Scrollable; +use crate::network::sonarr_network::SonarrEvent; + +mod edit_indexer_handler; +mod edit_indexer_settings_handler; +mod test_all_indexers_handler; + +#[cfg(test)] +#[path = "indexers_handler_tests.rs"] +mod indexers_handler_tests; + +pub(super) struct IndexersHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for IndexersHandler<'a, 'b> { + fn handle(&mut self) { + match self.active_sonarr_block { + _ if EditIndexerHandler::accepts(self.active_sonarr_block) => { + EditIndexerHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle() + } + _ if IndexerSettingsHandler::accepts(self.active_sonarr_block) => { + IndexerSettingsHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle() + } + _ if TestAllIndexersHandler::accepts(self.active_sonarr_block) => { + TestAllIndexersHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle() + } + _ => self.handle_key_event(), + } + } + + fn accepts(active_block: ActiveSonarrBlock) -> bool { + EditIndexerHandler::accepts(active_block) + || IndexerSettingsHandler::accepts(active_block) + || TestAllIndexersHandler::accepts(active_block) + || INDEXERS_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + context: Option, + ) -> IndexersHandler<'a, 'b> { + IndexersHandler { + key, + app, + active_sonarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.sonarr_data.indexers.is_empty() + } + + fn handle_scroll_up(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::Indexers { + self.app.data.sonarr_data.indexers.scroll_up(); + } + } + + fn handle_scroll_down(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::Indexers { + self.app.data.sonarr_data.indexers.scroll_down(); + } + } + + fn handle_home(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::Indexers { + self.app.data.sonarr_data.indexers.scroll_to_top(); + } + } + + fn handle_end(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::Indexers { + self.app.data.sonarr_data.indexers.scroll_to_bottom(); + } + } + + fn handle_delete(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::Indexers { + self + .app + .push_navigation_stack(ActiveSonarrBlock::DeleteIndexerPrompt.into()); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::Indexers => handle_change_tab_left_right_keys(self.app, self.key), + ActiveSonarrBlock::DeleteIndexerPrompt => handle_prompt_toggle(self.app, self.key), + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::DeleteIndexerPrompt => { + let sonarr_data = &mut self.app.data.sonarr_data; + if sonarr_data.prompt_confirm { + sonarr_data.prompt_confirm_action = Some(SonarrEvent::DeleteIndexer(None)); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::Indexers => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::EditIndexerPrompt.into()); + self.app.data.sonarr_data.edit_indexer_modal = Some((&self.app.data.sonarr_data).into()); + let protocol = &self + .app + .data + .sonarr_data + .indexers + .current_selection() + .protocol; + if protocol == "torrent" { + self.app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + } else { + self.app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_NZB_SELECTION_BLOCKS); + } + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::DeleteIndexerPrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.prompt_confirm = false; + } + ActiveSonarrBlock::TestIndexer => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.indexer_test_error = None; + } + _ => handle_clear_errors(self.app), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_sonarr_block { + ActiveSonarrBlock::Indexers => match self.key { + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { + self.app.should_refresh = true; + } + _ if key == DEFAULT_KEYBINDINGS.test.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::TestIndexer.into()); + } + _ if key == DEFAULT_KEYBINDINGS.test_all.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::TestAllIndexers.into()); + } + _ if key == DEFAULT_KEYBINDINGS.settings.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::AllIndexerSettingsPrompt.into()); + self.app.data.sonarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + } + _ => (), + }, + ActiveSonarrBlock::DeleteIndexerPrompt => { + if key == DEFAULT_KEYBINDINGS.confirm.key { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::DeleteIndexer(None)); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } +} diff --git a/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler.rs b/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler.rs new file mode 100644 index 0000000..cd97b9f --- /dev/null +++ b/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler.rs @@ -0,0 +1,117 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::KeyEventHandler; +use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; +use crate::models::Scrollable; + +#[cfg(test)] +#[path = "test_all_indexers_handler_tests.rs"] +mod test_all_indexers_handler_tests; + +pub(super) struct TestAllIndexersHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for TestAllIndexersHandler<'a, 'b> { + fn accepts(active_block: ActiveSonarrBlock) -> bool { + active_block == ActiveSonarrBlock::TestAllIndexers + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + _context: Option, + ) -> TestAllIndexersHandler<'a, 'b> { + TestAllIndexersHandler { + key, + app, + active_sonarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + let table_is_ready = if let Some(table) = &self.app.data.sonarr_data.indexer_test_all_results { + !table.is_empty() + } else { + false + }; + + !self.app.is_loading && table_is_ready + } + + fn handle_scroll_up(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::TestAllIndexers { + self + .app + .data + .sonarr_data + .indexer_test_all_results + .as_mut() + .unwrap() + .scroll_up() + } + } + + fn handle_scroll_down(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::TestAllIndexers { + self + .app + .data + .sonarr_data + .indexer_test_all_results + .as_mut() + .unwrap() + .scroll_down() + } + } + + fn handle_home(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::TestAllIndexers { + self + .app + .data + .sonarr_data + .indexer_test_all_results + .as_mut() + .unwrap() + .scroll_to_top() + } + } + + fn handle_end(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::TestAllIndexers { + self + .app + .data + .sonarr_data + .indexer_test_all_results + .as_mut() + .unwrap() + .scroll_to_bottom() + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) {} + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::TestAllIndexers { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.indexer_test_all_results = None; + } + } + + fn handle_char_key_event(&mut self) {} +} diff --git a/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler_tests.rs b/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler_tests.rs new file mode 100644 index 0000000..7750b4d --- /dev/null +++ b/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler_tests.rs @@ -0,0 +1,337 @@ +#[cfg(test)] +mod tests { + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::modals::IndexerTestResultModalItem; + use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::stateful_table::StatefulTable; + use strum::IntoEnumIterator; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_str_eq; + use rstest::rstest; + + use crate::models::servarr_data::modals::IndexerTestResultModalItem; + use crate::models::stateful_table::StatefulTable; + use crate::simple_stateful_iterable_vec; + + use super::*; + + #[rstest] + fn test_test_all_indexers_results_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + let mut indexer_test_results = StatefulTable::default(); + indexer_test_results.set_items(simple_stateful_iterable_vec!( + IndexerTestResultModalItem, + String, + name + )); + app.data.sonarr_data.indexer_test_all_results = Some(indexer_test_results); + + TestAllIndexersHandler::with(key, &mut app, ActiveSonarrBlock::TestAllIndexers, None) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .current_selection() + .name, + "Test 2" + ); + + TestAllIndexersHandler::with(key, &mut app, ActiveSonarrBlock::TestAllIndexers, None) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .current_selection() + .name, + "Test 1" + ); + } + + #[rstest] + fn test_test_all_indexers_results_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = true; + let mut indexer_test_results = StatefulTable::default(); + indexer_test_results.set_items(simple_stateful_iterable_vec!( + IndexerTestResultModalItem, + String, + name + )); + app.data.sonarr_data.indexer_test_all_results = Some(indexer_test_results); + + TestAllIndexersHandler::with(key, &mut app, ActiveSonarrBlock::TestAllIndexers, None) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .current_selection() + .name, + "Test 1" + ); + + TestAllIndexersHandler::with(key, &mut app, ActiveSonarrBlock::TestAllIndexers, None) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .current_selection() + .name, + "Test 1" + ); + } + } + + mod test_handle_home_end { + use crate::extended_stateful_iterable_vec; + use crate::models::servarr_data::modals::IndexerTestResultModalItem; + use crate::models::stateful_table::StatefulTable; + use pretty_assertions::assert_str_eq; + + use super::*; + + #[test] + fn test_test_all_indexers_results_home_end() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + let mut indexer_test_results = StatefulTable::default(); + indexer_test_results.set_items(extended_stateful_iterable_vec!( + IndexerTestResultModalItem, + String, + name + )); + app.data.sonarr_data.indexer_test_all_results = Some(indexer_test_results); + + TestAllIndexersHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::TestAllIndexers, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .current_selection() + .name, + "Test 3" + ); + + TestAllIndexersHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::TestAllIndexers, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .current_selection() + .name, + "Test 1" + ); + } + + #[test] + fn test_test_all_indexers_results_home_end_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = true; + let mut indexer_test_results = StatefulTable::default(); + indexer_test_results.set_items(extended_stateful_iterable_vec!( + IndexerTestResultModalItem, + String, + name + )); + app.data.sonarr_data.indexer_test_all_results = Some(indexer_test_results); + + TestAllIndexersHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::TestAllIndexers, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .current_selection() + .name, + "Test 1" + ); + + TestAllIndexersHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::TestAllIndexers, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .current_selection() + .name, + "Test 1" + ); + } + } + + mod test_handle_esc { + use super::*; + use crate::models::stateful_table::StatefulTable; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_test_all_indexers_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveSonarrBlock::TestAllIndexers.into()); + app.data.sonarr_data.indexer_test_all_results = Some(StatefulTable::default()); + + TestAllIndexersHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::TestAllIndexers, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + assert!(!app.data.sonarr_data.prompt_confirm); + assert!(app.data.sonarr_data.indexer_test_all_results.is_none()); + } + } + + #[test] + fn test_test_all_indexers_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if active_sonarr_block == ActiveSonarrBlock::TestAllIndexers { + assert!(TestAllIndexersHandler::accepts(active_sonarr_block)); + } else { + assert!(!TestAllIndexersHandler::accepts(active_sonarr_block)); + } + }); + } + + #[test] + fn test_test_all_indexers_handler_is_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = true; + + let handler = TestAllIndexersHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::TestAllIndexers, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_test_all_indexers_handler_is_not_ready_when_results_is_none() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = false; + + let handler = TestAllIndexersHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::TestAllIndexers, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_test_all_indexers_handler_is_not_ready_when_results_is_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = false; + app.data.sonarr_data.indexer_test_all_results = Some(StatefulTable::default()); + + let handler = TestAllIndexersHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::TestAllIndexers, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_test_all_indexers_handler_is_ready_when_results_is_not_empty_and_is_loaded() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); + app.is_loading = false; + let mut indexer_test_results = StatefulTable::default(); + indexer_test_results.set_items(vec![IndexerTestResultModalItem::default()]); + app.data.sonarr_data.indexer_test_all_results = Some(indexer_test_results); + + let handler = TestAllIndexersHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::TestAllIndexers, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/sonarr_handlers/mod.rs b/src/handlers/sonarr_handlers/mod.rs index edf2cb8..eca86a0 100644 --- a/src/handlers/sonarr_handlers/mod.rs +++ b/src/handlers/sonarr_handlers/mod.rs @@ -1,6 +1,7 @@ use blocklist::BlocklistHandler; use downloads::DownloadsHandler; use history::HistoryHandler; +use indexers::IndexersHandler; use library::LibraryHandler; use root_folders::RootFoldersHandler; @@ -15,6 +16,7 @@ use super::KeyEventHandler; mod blocklist; mod downloads; mod history; +mod indexers; mod library; mod root_folders; @@ -52,6 +54,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SonarrHandler<'a, 'b RootFoldersHandler::with(self.key, self.app, self.active_sonarr_block, self.context) .handle() } + _ if IndexersHandler::accepts(self.active_sonarr_block) => { + IndexersHandler::with(self.key, self.app, self.active_sonarr_block, self.context).handle() + } _ => self.handle_key_event(), } } diff --git a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs index 00fd74d..1e1702f 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs @@ -181,4 +181,25 @@ mod tests { active_sonarr_block ); } + + #[rstest] + fn test_delegates_indexers_blocks_to_indexers_handler( + #[values( + ActiveSonarrBlock::DeleteIndexerPrompt, + ActiveSonarrBlock::Indexers, + ActiveSonarrBlock::AllIndexerSettingsPrompt, + ActiveSonarrBlock::IndexerSettingsConfirmPrompt, + ActiveSonarrBlock::IndexerSettingsMaximumSizeInput, + ActiveSonarrBlock::IndexerSettingsMinimumAgeInput, + ActiveSonarrBlock::IndexerSettingsRetentionInput, + ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + SonarrHandler, + ActiveSonarrBlock::Indexers, + active_sonarr_block + ); + } } diff --git a/src/models/servarr_data/modals.rs b/src/models/servarr_data/modals.rs index 0105249..46d329b 100644 --- a/src/models/servarr_data/modals.rs +++ b/src/models/servarr_data/modals.rs @@ -10,6 +10,7 @@ pub struct EditIndexerModal { pub api_key: HorizontallyScrollableText, pub seed_ratio: HorizontallyScrollableText, pub tags: HorizontallyScrollableText, + pub priority: i64, } #[derive(Default, Clone, Eq, PartialEq, Debug)] diff --git a/src/models/servarr_data/radarr/radarr_data.rs b/src/models/servarr_data/radarr/radarr_data.rs index 0d9c621..236b122 100644 --- a/src/models/servarr_data/radarr/radarr_data.rs +++ b/src/models/servarr_data/radarr/radarr_data.rs @@ -209,7 +209,6 @@ impl<'a> Default for RadarrData<'a> { #[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)] pub enum ActiveRadarrBlock { - AddIndexer, AddMovieAlreadyInLibrary, AddMovieSearchInput, AddMovieSearchResults, @@ -256,6 +255,7 @@ pub enum ActiveRadarrBlock { EditIndexerToggleEnableRss, EditIndexerToggleEnableAutomaticSearch, EditIndexerToggleEnableInteractiveSearch, + EditIndexerPriorityInput, EditIndexerUrlInput, EditIndexerTagsInput, EditMoviePrompt, @@ -327,8 +327,7 @@ pub static COLLECTIONS_BLOCKS: [ActiveRadarrBlock; 7] = [ ActiveRadarrBlock::FilterCollectionsError, ActiveRadarrBlock::UpdateAllCollectionsPrompt, ]; -pub static INDEXERS_BLOCKS: [ActiveRadarrBlock; 4] = [ - ActiveRadarrBlock::AddIndexer, +pub static INDEXERS_BLOCKS: [ActiveRadarrBlock; 3] = [ ActiveRadarrBlock::DeleteIndexerPrompt, ActiveRadarrBlock::Indexers, ActiveRadarrBlock::TestIndexer, @@ -431,7 +430,7 @@ pub const DELETE_MOVIE_SELECTION_BLOCKS: &[&[ActiveRadarrBlock]] = &[ &[ActiveRadarrBlock::DeleteMovieToggleAddListExclusion], &[ActiveRadarrBlock::DeleteMovieConfirmPrompt], ]; -pub static EDIT_INDEXER_BLOCKS: [ActiveRadarrBlock; 10] = [ +pub static EDIT_INDEXER_BLOCKS: [ActiveRadarrBlock; 11] = [ ActiveRadarrBlock::EditIndexerPrompt, ActiveRadarrBlock::EditIndexerConfirmPrompt, ActiveRadarrBlock::EditIndexerApiKeyInput, @@ -440,6 +439,7 @@ pub static EDIT_INDEXER_BLOCKS: [ActiveRadarrBlock; 10] = [ ActiveRadarrBlock::EditIndexerToggleEnableRss, ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch, ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveRadarrBlock::EditIndexerPriorityInput, ActiveRadarrBlock::EditIndexerUrlInput, ActiveRadarrBlock::EditIndexerTagsInput, ]; @@ -460,6 +460,10 @@ pub const EDIT_INDEXER_TORRENT_SELECTION_BLOCKS: &[&[ActiveRadarrBlock]] = &[ ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch, ActiveRadarrBlock::EditIndexerTagsInput, ], + &[ + ActiveRadarrBlock::EditIndexerPriorityInput, + ActiveRadarrBlock::EditIndexerConfirmPrompt, + ], &[ ActiveRadarrBlock::EditIndexerConfirmPrompt, ActiveRadarrBlock::EditIndexerConfirmPrompt, @@ -480,7 +484,7 @@ pub const EDIT_INDEXER_NZB_SELECTION_BLOCKS: &[&[ActiveRadarrBlock]] = &[ ], &[ ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch, - ActiveRadarrBlock::EditIndexerConfirmPrompt, + ActiveRadarrBlock::EditIndexerPriorityInput, ], &[ ActiveRadarrBlock::EditIndexerConfirmPrompt, diff --git a/src/models/servarr_data/radarr/radarr_data_tests.rs b/src/models/servarr_data/radarr/radarr_data_tests.rs index 6d4a07d..4222f59 100644 --- a/src/models/servarr_data/radarr/radarr_data_tests.rs +++ b/src/models/servarr_data/radarr/radarr_data_tests.rs @@ -303,8 +303,7 @@ mod tests { #[test] fn test_indexers_blocks_contents() { - assert_eq!(INDEXERS_BLOCKS.len(), 4); - assert!(INDEXERS_BLOCKS.contains(&ActiveRadarrBlock::AddIndexer)); + assert_eq!(INDEXERS_BLOCKS.len(), 3); assert!(INDEXERS_BLOCKS.contains(&ActiveRadarrBlock::DeleteIndexerPrompt)); assert!(INDEXERS_BLOCKS.contains(&ActiveRadarrBlock::Indexers)); assert!(INDEXERS_BLOCKS.contains(&ActiveRadarrBlock::TestIndexer)); @@ -413,7 +412,7 @@ mod tests { #[test] fn test_edit_indexer_blocks_contents() { - assert_eq!(EDIT_INDEXER_BLOCKS.len(), 10); + assert_eq!(EDIT_INDEXER_BLOCKS.len(), 11); assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveRadarrBlock::EditIndexerPrompt)); assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveRadarrBlock::EditIndexerConfirmPrompt)); assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveRadarrBlock::EditIndexerApiKeyInput)); @@ -428,6 +427,7 @@ mod tests { ); assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveRadarrBlock::EditIndexerUrlInput)); assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveRadarrBlock::EditIndexerTagsInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveRadarrBlock::EditIndexerPriorityInput)); } #[test] @@ -607,6 +607,13 @@ mod tests { ActiveRadarrBlock::EditIndexerTagsInput, ] ); + assert_eq!( + edit_indexer_torrent_selection_block_iter.next().unwrap(), + &[ + ActiveRadarrBlock::EditIndexerPriorityInput, + ActiveRadarrBlock::EditIndexerConfirmPrompt, + ] + ); assert_eq!( edit_indexer_torrent_selection_block_iter.next().unwrap(), &[ @@ -646,7 +653,7 @@ mod tests { edit_indexer_nzb_selection_block_iter.next().unwrap(), &[ ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch, - ActiveRadarrBlock::EditIndexerConfirmPrompt, + ActiveRadarrBlock::EditIndexerPriorityInput, ] ); assert_eq!( diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 104e8d8..e736f10 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -216,6 +216,16 @@ pub enum ActiveSonarrBlock { DeleteSeriesToggleDeleteFile, Downloads, EditIndexerPrompt, + EditIndexerConfirmPrompt, + EditIndexerApiKeyInput, + EditIndexerNameInput, + EditIndexerSeedRatioInput, + EditIndexerToggleEnableRss, + EditIndexerToggleEnableAutomaticSearch, + EditIndexerToggleEnableInteractiveSearch, + EditIndexerUrlInput, + EditIndexerPriorityInput, + EditIndexerTagsInput, EditSeriesPrompt, EditSeriesConfirmPrompt, EditSeriesPathInput, @@ -370,6 +380,87 @@ pub const DELETE_SERIES_SELECTION_BLOCKS: &[&[ActiveSonarrBlock]] = &[ &[ActiveSonarrBlock::DeleteSeriesConfirmPrompt], ]; +pub static EDIT_INDEXER_BLOCKS: [ActiveSonarrBlock; 11] = [ + ActiveSonarrBlock::EditIndexerPrompt, + ActiveSonarrBlock::EditIndexerConfirmPrompt, + ActiveSonarrBlock::EditIndexerApiKeyInput, + ActiveSonarrBlock::EditIndexerNameInput, + ActiveSonarrBlock::EditIndexerSeedRatioInput, + ActiveSonarrBlock::EditIndexerToggleEnableRss, + ActiveSonarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveSonarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveSonarrBlock::EditIndexerPriorityInput, + ActiveSonarrBlock::EditIndexerUrlInput, + ActiveSonarrBlock::EditIndexerTagsInput, +]; + +pub const EDIT_INDEXER_TORRENT_SELECTION_BLOCKS: &[&[ActiveSonarrBlock]] = &[ + &[ + ActiveSonarrBlock::EditIndexerNameInput, + ActiveSonarrBlock::EditIndexerUrlInput, + ], + &[ + ActiveSonarrBlock::EditIndexerToggleEnableRss, + ActiveSonarrBlock::EditIndexerApiKeyInput, + ], + &[ + ActiveSonarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveSonarrBlock::EditIndexerSeedRatioInput, + ], + &[ + ActiveSonarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveSonarrBlock::EditIndexerTagsInput, + ], + &[ + ActiveSonarrBlock::EditIndexerPriorityInput, + ActiveSonarrBlock::EditIndexerConfirmPrompt, + ], + &[ + ActiveSonarrBlock::EditIndexerConfirmPrompt, + ActiveSonarrBlock::EditIndexerConfirmPrompt, + ], +]; + +pub const EDIT_INDEXER_NZB_SELECTION_BLOCKS: &[&[ActiveSonarrBlock]] = &[ + &[ + ActiveSonarrBlock::EditIndexerNameInput, + ActiveSonarrBlock::EditIndexerUrlInput, + ], + &[ + ActiveSonarrBlock::EditIndexerToggleEnableRss, + ActiveSonarrBlock::EditIndexerApiKeyInput, + ], + &[ + ActiveSonarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveSonarrBlock::EditIndexerTagsInput, + ], + &[ + ActiveSonarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveSonarrBlock::EditIndexerPriorityInput, + ], + &[ + ActiveSonarrBlock::EditIndexerConfirmPrompt, + ActiveSonarrBlock::EditIndexerConfirmPrompt, + ], +]; + +pub static INDEXER_SETTINGS_BLOCKS: [ActiveSonarrBlock; 6] = [ + ActiveSonarrBlock::AllIndexerSettingsPrompt, + ActiveSonarrBlock::IndexerSettingsConfirmPrompt, + ActiveSonarrBlock::IndexerSettingsMaximumSizeInput, + ActiveSonarrBlock::IndexerSettingsMinimumAgeInput, + ActiveSonarrBlock::IndexerSettingsRetentionInput, + ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput, +]; + +pub const INDEXER_SETTINGS_SELECTION_BLOCKS: &[&[ActiveSonarrBlock]] = &[ + &[ActiveSonarrBlock::IndexerSettingsMinimumAgeInput], + &[ActiveSonarrBlock::IndexerSettingsRetentionInput], + &[ActiveSonarrBlock::IndexerSettingsMaximumSizeInput], + &[ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput], + &[ActiveSonarrBlock::IndexerSettingsConfirmPrompt], +]; + pub static HISTORY_BLOCKS: [ActiveSonarrBlock; 7] = [ ActiveSonarrBlock::History, ActiveSonarrBlock::HistoryItemDetails, @@ -386,6 +477,12 @@ pub static ROOT_FOLDERS_BLOCKS: [ActiveSonarrBlock; 3] = [ ActiveSonarrBlock::DeleteRootFolderPrompt, ]; +pub static INDEXERS_BLOCKS: [ActiveSonarrBlock; 3] = [ + ActiveSonarrBlock::DeleteIndexerPrompt, + ActiveSonarrBlock::Indexers, + ActiveSonarrBlock::TestIndexer, +]; + impl From for Route { fn from(active_sonarr_block: ActiveSonarrBlock) -> Route { Route::Sonarr(active_sonarr_block, None) diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index a5ed5a9..c0aceec 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -203,8 +203,10 @@ mod tests { mod active_sonarr_block_tests { use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, BLOCKLIST_BLOCKS, - DELETE_SERIES_BLOCKS, DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_SERIES_BLOCKS, - EDIT_SERIES_SELECTION_BLOCKS, HISTORY_BLOCKS, LIBRARY_BLOCKS, ROOT_FOLDERS_BLOCKS, + DELETE_SERIES_BLOCKS, DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_INDEXER_BLOCKS, + EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, EDIT_SERIES_BLOCKS, + EDIT_SERIES_SELECTION_BLOCKS, HISTORY_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_BLOCKS, + INDEXER_SETTINGS_SELECTION_BLOCKS, LIBRARY_BLOCKS, ROOT_FOLDERS_BLOCKS, }; #[test] @@ -375,6 +377,158 @@ mod tests { assert_eq!(delete_series_block_iter.next(), None); } + #[test] + fn test_edit_indexer_blocks_contents() { + assert_eq!(EDIT_INDEXER_BLOCKS.len(), 11); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveSonarrBlock::EditIndexerPrompt)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveSonarrBlock::EditIndexerConfirmPrompt)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveSonarrBlock::EditIndexerApiKeyInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveSonarrBlock::EditIndexerNameInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveSonarrBlock::EditIndexerSeedRatioInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveSonarrBlock::EditIndexerToggleEnableRss)); + assert!( + EDIT_INDEXER_BLOCKS.contains(&ActiveSonarrBlock::EditIndexerToggleEnableAutomaticSearch) + ); + assert!( + EDIT_INDEXER_BLOCKS.contains(&ActiveSonarrBlock::EditIndexerToggleEnableInteractiveSearch) + ); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveSonarrBlock::EditIndexerUrlInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveSonarrBlock::EditIndexerTagsInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveSonarrBlock::EditIndexerPriorityInput)); + } + + #[test] + fn test_edit_indexer_nzb_selection_blocks_ordering() { + let mut edit_indexer_nzb_selection_block_iter = EDIT_INDEXER_NZB_SELECTION_BLOCKS.iter(); + + assert_eq!( + edit_indexer_nzb_selection_block_iter.next().unwrap(), + &[ + ActiveSonarrBlock::EditIndexerNameInput, + ActiveSonarrBlock::EditIndexerUrlInput, + ] + ); + assert_eq!( + edit_indexer_nzb_selection_block_iter.next().unwrap(), + &[ + ActiveSonarrBlock::EditIndexerToggleEnableRss, + ActiveSonarrBlock::EditIndexerApiKeyInput, + ] + ); + assert_eq!( + edit_indexer_nzb_selection_block_iter.next().unwrap(), + &[ + ActiveSonarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveSonarrBlock::EditIndexerTagsInput, + ] + ); + assert_eq!( + edit_indexer_nzb_selection_block_iter.next().unwrap(), + &[ + ActiveSonarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveSonarrBlock::EditIndexerPriorityInput, + ] + ); + assert_eq!( + edit_indexer_nzb_selection_block_iter.next().unwrap(), + &[ + ActiveSonarrBlock::EditIndexerConfirmPrompt, + ActiveSonarrBlock::EditIndexerConfirmPrompt, + ] + ); + assert_eq!(edit_indexer_nzb_selection_block_iter.next(), None); + } + + #[test] + fn test_edit_indexer_torrent_selection_blocks_ordering() { + let mut edit_indexer_torrent_selection_block_iter = + EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.iter(); + + assert_eq!( + edit_indexer_torrent_selection_block_iter.next().unwrap(), + &[ + ActiveSonarrBlock::EditIndexerNameInput, + ActiveSonarrBlock::EditIndexerUrlInput, + ] + ); + assert_eq!( + edit_indexer_torrent_selection_block_iter.next().unwrap(), + &[ + ActiveSonarrBlock::EditIndexerToggleEnableRss, + ActiveSonarrBlock::EditIndexerApiKeyInput, + ] + ); + assert_eq!( + edit_indexer_torrent_selection_block_iter.next().unwrap(), + &[ + ActiveSonarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveSonarrBlock::EditIndexerSeedRatioInput, + ] + ); + assert_eq!( + edit_indexer_torrent_selection_block_iter.next().unwrap(), + &[ + ActiveSonarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveSonarrBlock::EditIndexerTagsInput, + ] + ); + assert_eq!( + edit_indexer_torrent_selection_block_iter.next().unwrap(), + &[ + ActiveSonarrBlock::EditIndexerPriorityInput, + ActiveSonarrBlock::EditIndexerConfirmPrompt, + ] + ); + assert_eq!( + edit_indexer_torrent_selection_block_iter.next().unwrap(), + &[ + ActiveSonarrBlock::EditIndexerConfirmPrompt, + ActiveSonarrBlock::EditIndexerConfirmPrompt, + ] + ); + assert_eq!(edit_indexer_torrent_selection_block_iter.next(), None); + } + + #[test] + fn test_indexer_settings_blocks_contents() { + assert_eq!(INDEXER_SETTINGS_BLOCKS.len(), 6); + assert!(INDEXER_SETTINGS_BLOCKS.contains(&ActiveSonarrBlock::AllIndexerSettingsPrompt)); + assert!(INDEXER_SETTINGS_BLOCKS.contains(&ActiveSonarrBlock::IndexerSettingsConfirmPrompt)); + assert!(INDEXER_SETTINGS_BLOCKS.contains(&ActiveSonarrBlock::IndexerSettingsMaximumSizeInput)); + assert!(INDEXER_SETTINGS_BLOCKS.contains(&ActiveSonarrBlock::IndexerSettingsMinimumAgeInput)); + assert!(INDEXER_SETTINGS_BLOCKS.contains(&ActiveSonarrBlock::IndexerSettingsRetentionInput)); + assert!( + INDEXER_SETTINGS_BLOCKS.contains(&ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput) + ); + } + + #[test] + fn test_indexer_settings_selection_blocks_ordering() { + let mut indexer_settings_block_iter = INDEXER_SETTINGS_SELECTION_BLOCKS.iter(); + + assert_eq!( + indexer_settings_block_iter.next().unwrap(), + &[ActiveSonarrBlock::IndexerSettingsMinimumAgeInput,] + ); + assert_eq!( + indexer_settings_block_iter.next().unwrap(), + &[ActiveSonarrBlock::IndexerSettingsRetentionInput,] + ); + assert_eq!( + indexer_settings_block_iter.next().unwrap(), + &[ActiveSonarrBlock::IndexerSettingsMaximumSizeInput,] + ); + assert_eq!( + indexer_settings_block_iter.next().unwrap(), + &[ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput,] + ); + assert_eq!( + indexer_settings_block_iter.next().unwrap(), + &[ActiveSonarrBlock::IndexerSettingsConfirmPrompt,] + ); + assert_eq!(indexer_settings_block_iter.next(), None); + } + #[test] fn test_history_blocks_contents() { assert_eq!(HISTORY_BLOCKS.len(), 7); @@ -394,5 +548,13 @@ mod tests { assert!(ROOT_FOLDERS_BLOCKS.contains(&ActiveSonarrBlock::AddRootFolderPrompt)); assert!(ROOT_FOLDERS_BLOCKS.contains(&ActiveSonarrBlock::DeleteRootFolderPrompt)); } + + #[test] + fn test_indexers_blocks_contents() { + assert_eq!(INDEXERS_BLOCKS.len(), 3); + assert!(INDEXERS_BLOCKS.contains(&ActiveSonarrBlock::DeleteIndexerPrompt)); + assert!(INDEXERS_BLOCKS.contains(&ActiveSonarrBlock::Indexers)); + assert!(INDEXERS_BLOCKS.contains(&ActiveSonarrBlock::TestIndexer)); + } } } diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 7722006..10a86f6 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -1056,6 +1056,7 @@ impl<'a, 'b> Network<'a, 'b> { url, api_key, seed_ratio, + priority, .. } = app.data.radarr_data.edit_indexer_modal.as_ref().unwrap(); @@ -1068,7 +1069,7 @@ impl<'a, 'b> Network<'a, 'b> { api_key.text.clone(), seed_ratio.text.clone(), tag_ids_vec, - priority, + *priority, ) }; diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index b1fb1cf..e3d3dd8 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -4082,7 +4082,7 @@ mod test { "enableAutomaticSearch": false, "enableInteractiveSearch": false, "name": "Test Update", - "priority": 1, + "priority": 0, "fields": [ { "name": "baseUrl", @@ -4134,6 +4134,7 @@ mod test { api_key: "test1234".into(), seed_ratio: "1.3".into(), tags: "usenet, testing".into(), + priority: 0, }; app.data.radarr_data.edit_indexer_modal = Some(edit_indexer_modal); app.data.radarr_data.indexers.set_items(vec![indexer()]); @@ -4179,7 +4180,7 @@ mod test { "enableAutomaticSearch": false, "enableInteractiveSearch": false, "name": "Test Update", - "priority": 1, + "priority": 0, "fields": [ { "name": "baseUrl", @@ -4227,6 +4228,7 @@ mod test { api_key: "test1234".into(), seed_ratio: "1.3".into(), tags: "usenet, testing".into(), + priority: 0, }; app.data.radarr_data.edit_indexer_modal = Some(edit_indexer_modal); let mut indexer = indexer(); @@ -4284,7 +4286,7 @@ mod test { "enableAutomaticSearch": false, "enableInteractiveSearch": false, "name": "Test Update", - "priority": 1, + "priority": 0, "fields": [ { "name": "baseUrl", @@ -4336,6 +4338,7 @@ mod test { api_key: "test1234".into(), seed_ratio: "1.3".into(), tags: "usenet, testing".into(), + priority: 0, }; app.data.radarr_data.edit_indexer_modal = Some(edit_indexer_modal); let mut indexer = indexer(); diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index e55160b..92ec2ba 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -987,6 +987,7 @@ impl<'a, 'b> Network<'a, 'b> { url, api_key, seed_ratio, + priority, .. } = app.data.sonarr_data.edit_indexer_modal.as_ref().unwrap(); @@ -999,7 +1000,7 @@ impl<'a, 'b> Network<'a, 'b> { api_key.text.clone(), seed_ratio.text.clone(), tag_ids_vec, - priority, + *priority, ) }; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 197f0c8..ce27e68 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -1162,7 +1162,7 @@ mod test { "enableAutomaticSearch": false, "enableInteractiveSearch": false, "name": "Test Update", - "priority": 1, + "priority": 0, "fields": [ { "name": "baseUrl", @@ -1218,6 +1218,7 @@ mod test { api_key: "test1234".into(), seed_ratio: "1.3".into(), tags: "usenet, testing".into(), + priority: 0, }; app.data.sonarr_data.edit_indexer_modal = Some(edit_indexer_modal); app.data.sonarr_data.indexers.set_items(vec![indexer()]); @@ -1263,7 +1264,7 @@ mod test { "enableAutomaticSearch": false, "enableInteractiveSearch": false, "name": "Test Update", - "priority": 1, + "priority": 0, "fields": [ { "name": "baseUrl", @@ -1315,6 +1316,7 @@ mod test { api_key: "test1234".into(), seed_ratio: "1.3".into(), tags: "usenet, testing".into(), + priority: 0, }; app.data.sonarr_data.edit_indexer_modal = Some(edit_indexer_modal); let mut indexer = indexer(); @@ -1372,7 +1374,7 @@ mod test { "enableAutomaticSearch": false, "enableInteractiveSearch": false, "name": "Test Update", - "priority": 1, + "priority": 0, "fields": [ { "name": "baseUrl", @@ -1428,6 +1430,7 @@ mod test { api_key: "test1234".into(), seed_ratio: "1.3".into(), tags: "usenet, testing".into(), + priority: 0, }; app.data.sonarr_data.edit_indexer_modal = Some(edit_indexer_modal); let mut indexer = indexer(); From a0b27ec105de0cfb9eb1c24ffe36703b81a2e2d4 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 3 Dec 2024 18:12:23 -0700 Subject: [PATCH 25/82] feat(ui): Support for modifying the indexer priority in Radarr --- src/models/servarr_data/radarr/modals.rs | 2 ++ .../servarr_data/radarr/modals_tests.rs | 4 +++ src/models/servarr_data/sonarr/modals.rs | 2 ++ .../servarr_data/sonarr/modals_tests.rs | 4 +++ src/network/radarr_network.rs | 6 ++-- src/network/sonarr_network.rs | 6 ++-- src/ui/radarr_ui/indexers/edit_indexer_ui.rs | 29 +++++++++++++------ src/ui/widgets/popup.rs | 2 +- src/ui/widgets/popup_tests.rs | 2 +- 9 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/models/servarr_data/radarr/modals.rs b/src/models/servarr_data/radarr/modals.rs index 1b9c0d9..b4f7f73 100644 --- a/src/models/servarr_data/radarr/modals.rs +++ b/src/models/servarr_data/radarr/modals.rs @@ -36,6 +36,7 @@ impl From<&RadarrData<'_>> for EditIndexerModal { enable_interactive_search, tags, fields, + priority, .. } = radarr_data.indexers.current_selection(); let seed_ratio_field_option = fields @@ -53,6 +54,7 @@ impl From<&RadarrData<'_>> for EditIndexerModal { edit_indexer_modal.enable_rss = Some(*enable_rss); edit_indexer_modal.enable_automatic_search = Some(*enable_automatic_search); edit_indexer_modal.enable_interactive_search = Some(*enable_interactive_search); + edit_indexer_modal.priority = *priority; edit_indexer_modal.url = fields .as_ref() .unwrap() diff --git a/src/models/servarr_data/radarr/modals_tests.rs b/src/models/servarr_data/radarr/modals_tests.rs index e19d8a9..91b920b 100644 --- a/src/models/servarr_data/radarr/modals_tests.rs +++ b/src/models/servarr_data/radarr/modals_tests.rs @@ -45,6 +45,7 @@ mod test { enable_interactive_search: true, tags: vec![Number::from(1), Number::from(2)], fields: Some(fields), + priority: 1, ..Indexer::default() }; radarr_data.indexers.set_items(vec![indexer]); @@ -55,6 +56,7 @@ mod test { assert_eq!(edit_indexer_modal.enable_rss, Some(true)); assert_eq!(edit_indexer_modal.enable_automatic_search, Some(true)); assert_eq!(edit_indexer_modal.enable_interactive_search, Some(true)); + assert_eq!(edit_indexer_modal.priority, 1); assert_str_eq!(edit_indexer_modal.url.text, "https://test.com"); assert_str_eq!(edit_indexer_modal.api_key.text, "1234"); @@ -93,6 +95,7 @@ mod test { enable_interactive_search: true, tags: vec![Number::from(1), Number::from(2)], fields: Some(fields), + priority: 1, ..Indexer::default() }; radarr_data.indexers.set_items(vec![indexer]); @@ -103,6 +106,7 @@ mod test { assert_eq!(edit_indexer_modal.enable_rss, Some(true)); assert_eq!(edit_indexer_modal.enable_automatic_search, Some(true)); assert_eq!(edit_indexer_modal.enable_interactive_search, Some(true)); + assert_eq!(edit_indexer_modal.priority, 1); assert_str_eq!(edit_indexer_modal.url.text, "https://test.com"); assert_str_eq!(edit_indexer_modal.api_key.text, "1234"); assert!(edit_indexer_modal.seed_ratio.text.is_empty()); diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 848f199..3d96c05 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -84,6 +84,7 @@ impl From<&SonarrData<'_>> for EditIndexerModal { enable_interactive_search, tags, fields, + priority, .. } = sonarr_data.indexers.current_selection(); let seed_ratio_field_option = fields @@ -101,6 +102,7 @@ impl From<&SonarrData<'_>> for EditIndexerModal { edit_indexer_modal.enable_rss = Some(*enable_rss); edit_indexer_modal.enable_automatic_search = Some(*enable_automatic_search); edit_indexer_modal.enable_interactive_search = Some(*enable_interactive_search); + edit_indexer_modal.priority = *priority; edit_indexer_modal.url = fields .as_ref() .unwrap() diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs index cbaecd8..28cd038 100644 --- a/src/models/servarr_data/sonarr/modals_tests.rs +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -104,6 +104,7 @@ mod tests { enable_interactive_search: true, tags: vec![Number::from(1), Number::from(2)], fields: Some(fields), + priority: 1, ..Indexer::default() }; sonarr_data.indexers.set_items(vec![indexer]); @@ -114,6 +115,7 @@ mod tests { assert_eq!(edit_indexer_modal.enable_rss, Some(true)); assert_eq!(edit_indexer_modal.enable_automatic_search, Some(true)); assert_eq!(edit_indexer_modal.enable_interactive_search, Some(true)); + assert_eq!(edit_indexer_modal.priority, 1); assert_str_eq!(edit_indexer_modal.url.text, "https://test.com"); assert_str_eq!(edit_indexer_modal.api_key.text, "1234"); @@ -152,6 +154,7 @@ mod tests { enable_interactive_search: true, tags: vec![Number::from(1), Number::from(2)], fields: Some(fields), + priority: 1, ..Indexer::default() }; sonarr_data.indexers.set_items(vec![indexer]); @@ -162,6 +165,7 @@ mod tests { assert_eq!(edit_indexer_modal.enable_rss, Some(true)); assert_eq!(edit_indexer_modal.enable_automatic_search, Some(true)); assert_eq!(edit_indexer_modal.enable_interactive_search, Some(true)); + assert_eq!(edit_indexer_modal.priority, 1); assert_str_eq!(edit_indexer_modal.url.text, "https://test.com"); assert_str_eq!(edit_indexer_modal.api_key.text, "1234"); assert!(edit_indexer_modal.seed_ratio.text.is_empty()); diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 10a86f6..66be974 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -927,9 +927,6 @@ impl<'a, 'b> Network<'a, 'b> { info!("Constructing edit indexer body"); let mut detailed_indexer_body: Value = serde_json::from_str(&response).unwrap(); - let priority = detailed_indexer_body["priority"] - .as_i64() - .expect("Unable to deserialize 'priority'"); let ( name, @@ -942,6 +939,9 @@ impl<'a, 'b> Network<'a, 'b> { tags, priority, ) = if let Some(params) = edit_indexer_params { + let priority = detailed_indexer_body["priority"] + .as_i64() + .expect("Unable to deserialize 'priority'"); let seed_ratio_field_option = detailed_indexer_body["fields"] .as_array() .unwrap() diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 92ec2ba..38c4ef9 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -858,9 +858,6 @@ impl<'a, 'b> Network<'a, 'b> { info!("Constructing edit indexer body"); let mut detailed_indexer_body: Value = serde_json::from_str(&response).unwrap(); - let priority = detailed_indexer_body["priority"] - .as_i64() - .expect("Unable to deserialize 'priority'"); let ( name, @@ -873,6 +870,9 @@ impl<'a, 'b> Network<'a, 'b> { tags, priority, ) = if let Some(params) = edit_indexer_params { + let priority = detailed_indexer_body["priority"] + .as_i64() + .expect("Unable to deserialize 'priority'"); let seed_ratio_field_option = detailed_indexer_body["fields"] .as_array() .unwrap() diff --git a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs index 085501c..2aa8f64 100644 --- a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs +++ b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs @@ -59,8 +59,9 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if edit_indexer_modal_option.is_some() { let edit_indexer_modal = edit_indexer_modal_option.as_ref().unwrap(); - let [settings_area, _, buttons_area, help_area] = Layout::vertical([ - Constraint::Length(15), + let [_, settings_area, _, buttons_area, help_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(18), Constraint::Fill(1), Constraint::Length(3), Constraint::Length(1), @@ -71,13 +72,15 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) .margin(1) .areas(settings_area); - let [name_area, rss_area, auto_search_area, interactive_search_area] = Layout::vertical([ - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(3), - ]) - .areas(left_side_area); + let [name_area, rss_area, auto_search_area, interactive_search_area, priority_area] = + Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + ]) + .areas(left_side_area); let [url_area, api_key_area, seed_ratio_area, tags_area] = Layout::vertical([ Constraint::Length(3), Constraint::Length(3), @@ -87,6 +90,7 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .areas(right_side_area); if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { + let priority = edit_indexer_modal.priority.to_string(); let name_input_box = InputBox::new(&edit_indexer_modal.name.text) .offset(edit_indexer_modal.name.offset.load(Ordering::SeqCst)) .label("Name") @@ -107,6 +111,11 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .label("Tags") .highlighted(selected_block == ActiveRadarrBlock::EditIndexerTagsInput) .selected(active_radarr_block == ActiveRadarrBlock::EditIndexerTagsInput); + let priority_input_box = InputBox::new(&priority) + .cursor_after_string(false) + .label("Indexer Priority ▴▾") + .highlighted(selected_block == ActiveRadarrBlock::EditIndexerPriorityInput) + .selected(active_radarr_block == ActiveRadarrBlock::EditIndexerPriorityInput); render_selectable_input_box!(name_input_box, f, name_area); render_selectable_input_box!(url_input_box, f, url_area); @@ -126,8 +135,10 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { render_selectable_input_box!(seed_ratio_input_box, f, seed_ratio_area); render_selectable_input_box!(tags_input_box, f, tags_area); + render_selectable_input_box!(priority_input_box, f, priority_area); } else { render_selectable_input_box!(tags_input_box, f, seed_ratio_area); + render_selectable_input_box!(priority_input_box, f, tags_area); } let rss_checkbox = Checkbox::new("Enable RSS") diff --git a/src/ui/widgets/popup.rs b/src/ui/widgets/popup.rs index 43b36fb..0fcedd2 100644 --- a/src/ui/widgets/popup.rs +++ b/src/ui/widgets/popup.rs @@ -29,7 +29,7 @@ impl Size { match self { Size::SmallPrompt => (20, 20), Size::Prompt => (37, 37), - Size::LargePrompt => (70, 45), + Size::LargePrompt => (70, 50), Size::Message => (25, 8), Size::NarrowMessage => (50, 20), Size::LargeMessage => (25, 25), diff --git a/src/ui/widgets/popup_tests.rs b/src/ui/widgets/popup_tests.rs index d643a3f..1df6e11 100644 --- a/src/ui/widgets/popup_tests.rs +++ b/src/ui/widgets/popup_tests.rs @@ -8,7 +8,7 @@ mod tests { fn test_dimensions_to_percent() { assert_eq!(Size::SmallPrompt.to_percent(), (20, 20)); assert_eq!(Size::Prompt.to_percent(), (37, 37)); - assert_eq!(Size::LargePrompt.to_percent(), (70, 45)); + assert_eq!(Size::LargePrompt.to_percent(), (70, 50)); assert_eq!(Size::Message.to_percent(), (25, 8)); assert_eq!(Size::NarrowMessage.to_percent(), (50, 20)); assert_eq!(Size::LargeMessage.to_percent(), (25, 25)); From 2d2901f6dc8718c9469c6650ba2ca774ca510d5f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 4 Dec 2024 16:39:37 -0700 Subject: [PATCH 26/82] feat(ui): Full Sonarr support for the indexer tab --- src/ui/radarr_ui/blocklist/mod.rs | 5 +- src/ui/radarr_ui/collections/mod.rs | 5 +- src/ui/radarr_ui/downloads/mod.rs | 10 +- src/ui/radarr_ui/indexers/edit_indexer_ui.rs | 2 +- .../radarr_ui/indexers/indexer_settings_ui.rs | 5 +- src/ui/radarr_ui/indexers/mod.rs | 5 +- src/ui/radarr_ui/library/delete_movie_ui.rs | 5 +- src/ui/radarr_ui/library/mod.rs | 5 +- src/ui/radarr_ui/library/movie_details_ui.rs | 15 +- src/ui/radarr_ui/root_folders/mod.rs | 5 +- src/ui/radarr_ui/system/system_details_ui.rs | 5 +- src/ui/sonarr_ui/blocklist/mod.rs | 5 +- src/ui/sonarr_ui/downloads/mod.rs | 10 +- src/ui/sonarr_ui/indexers/edit_indexer_ui.rs | 185 ++++++++++++++++++ .../indexers/edit_indexer_ui_tests.rs | 18 ++ .../sonarr_ui/indexers/indexer_settings_ui.rs | 127 ++++++++++++ .../indexers/indexer_settings_ui_tests.rs | 21 ++ .../sonarr_ui/indexers/indexers_ui_tests.rs | 27 +++ src/ui/sonarr_ui/indexers/mod.rs | 185 ++++++++++++++++++ .../indexers/test_all_indexers_ui.rs | 92 +++++++++ .../indexers/test_all_indexers_ui_tests.rs | 19 ++ src/ui/sonarr_ui/library/delete_series_ui.rs | 5 +- src/ui/sonarr_ui/library/mod.rs | 5 +- src/ui/sonarr_ui/mod.rs | 3 + src/ui/sonarr_ui/root_folders/mod.rs | 5 +- src/ui/widgets/popup.rs | 8 +- src/ui/widgets/popup_tests.rs | 5 +- 27 files changed, 761 insertions(+), 26 deletions(-) create mode 100644 src/ui/sonarr_ui/indexers/edit_indexer_ui.rs create mode 100644 src/ui/sonarr_ui/indexers/edit_indexer_ui_tests.rs create mode 100644 src/ui/sonarr_ui/indexers/indexer_settings_ui.rs create mode 100644 src/ui/sonarr_ui/indexers/indexer_settings_ui_tests.rs create mode 100644 src/ui/sonarr_ui/indexers/indexers_ui_tests.rs create mode 100644 src/ui/sonarr_ui/indexers/mod.rs create mode 100644 src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs create mode 100644 src/ui/sonarr_ui/indexers/test_all_indexers_ui_tests.rs diff --git a/src/ui/radarr_ui/blocklist/mod.rs b/src/ui/radarr_ui/blocklist/mod.rs index ff9f0ae..65bf10f 100644 --- a/src/ui/radarr_ui/blocklist/mod.rs +++ b/src/ui/radarr_ui/blocklist/mod.rs @@ -56,7 +56,10 @@ impl DrawUi for BlocklistUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_blocklist_table(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } ActiveRadarrBlock::BlocklistClearAllItemsPrompt => { let confirmation_prompt = ConfirmationPrompt::new() diff --git a/src/ui/radarr_ui/collections/mod.rs b/src/ui/radarr_ui/collections/mod.rs index e0f1bcd..8b033a3 100644 --- a/src/ui/radarr_ui/collections/mod.rs +++ b/src/ui/radarr_ui/collections/mod.rs @@ -81,7 +81,10 @@ impl DrawUi for CollectionsUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_collections(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), }; diff --git a/src/ui/radarr_ui/downloads/mod.rs b/src/ui/radarr_ui/downloads/mod.rs index 8d61bc6..da8724b 100644 --- a/src/ui/radarr_ui/downloads/mod.rs +++ b/src/ui/radarr_ui/downloads/mod.rs @@ -44,7 +44,10 @@ impl DrawUi for DownloadsUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_downloads(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } ActiveRadarrBlock::UpdateDownloadsPrompt => { let confirmation_prompt = ConfirmationPrompt::new() @@ -53,7 +56,10 @@ impl DrawUi for DownloadsUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_downloads(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), } diff --git a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs index 2aa8f64..d118a32 100644 --- a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs +++ b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs @@ -41,7 +41,7 @@ impl DrawUi for EditIndexerUi { area, draw_indexers, draw_edit_indexer_prompt, - Size::LargePrompt, + Size::WideLargePrompt, ); } } diff --git a/src/ui/radarr_ui/indexers/indexer_settings_ui.rs b/src/ui/radarr_ui/indexers/indexer_settings_ui.rs index 27907b3..642efde 100644 --- a/src/ui/radarr_ui/indexers/indexer_settings_ui.rs +++ b/src/ui/radarr_ui/indexers/indexer_settings_ui.rs @@ -44,7 +44,7 @@ impl DrawUi for IndexerSettingsUi { area, draw_indexers, draw_edit_indexer_settings_prompt, - Size::LargePrompt, + Size::WideLargePrompt, ); } } @@ -61,7 +61,8 @@ fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: if indexer_settings_option.is_some() { let indexer_settings = indexer_settings_option.as_ref().unwrap(); - let [settings_area, _, buttons_area, help_area] = Layout::vertical([ + let [_, settings_area, _, buttons_area, help_area] = Layout::vertical([ + Constraint::Fill(1), Constraint::Length(15), Constraint::Fill(1), Constraint::Length(3), diff --git a/src/ui/radarr_ui/indexers/mod.rs b/src/ui/radarr_ui/indexers/mod.rs index aaa4193..9b1cbbf 100644 --- a/src/ui/radarr_ui/indexers/mod.rs +++ b/src/ui/radarr_ui/indexers/mod.rs @@ -86,7 +86,10 @@ impl DrawUi for IndexersUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_indexers(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), }; diff --git a/src/ui/radarr_ui/library/delete_movie_ui.rs b/src/ui/radarr_ui/library/delete_movie_ui.rs index 4cdc52f..c5d1090 100644 --- a/src/ui/radarr_ui/library/delete_movie_ui.rs +++ b/src/ui/radarr_ui/library/delete_movie_ui.rs @@ -51,7 +51,10 @@ impl DrawUi for DeleteMovieUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_library(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } } } diff --git a/src/ui/radarr_ui/library/mod.rs b/src/ui/radarr_ui/library/mod.rs index 500a4ea..a343b44 100644 --- a/src/ui/radarr_ui/library/mod.rs +++ b/src/ui/radarr_ui/library/mod.rs @@ -84,7 +84,10 @@ impl DrawUi for LibraryUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_library(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), }; diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index 819e43c..2b2bd47 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -61,7 +61,10 @@ impl DrawUi for MovieDetailsUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_movie_info(f, app, content_area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } ActiveRadarrBlock::UpdateAndScanPrompt => { let prompt = format!( @@ -73,7 +76,10 @@ impl DrawUi for MovieDetailsUi { .prompt(&prompt) .yes_no_value(app.data.radarr_data.prompt_confirm); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } ActiveRadarrBlock::ManualSearchConfirmPrompt => { draw_manual_search_confirm_prompt(f, app); @@ -532,7 +538,10 @@ fn draw_manual_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { .prompt(&prompt) .yes_no_value(app.data.radarr_data.prompt_confirm); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } } diff --git a/src/ui/radarr_ui/root_folders/mod.rs b/src/ui/radarr_ui/root_folders/mod.rs index 97da280..04a74d0 100644 --- a/src/ui/radarr_ui/root_folders/mod.rs +++ b/src/ui/radarr_ui/root_folders/mod.rs @@ -52,7 +52,10 @@ impl DrawUi for RootFoldersUi { .yes_no_value(app.data.radarr_data.prompt_confirm); draw_root_folders(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), } diff --git a/src/ui/radarr_ui/system/system_details_ui.rs b/src/ui/radarr_ui/system/system_details_ui.rs index 68ac459..6198c86 100644 --- a/src/ui/radarr_ui/system/system_details_ui.rs +++ b/src/ui/radarr_ui/system/system_details_ui.rs @@ -145,7 +145,10 @@ fn draw_tasks_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .prompt(&prompt) .yes_no_value(app.data.radarr_data.prompt_confirm); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } } diff --git a/src/ui/sonarr_ui/blocklist/mod.rs b/src/ui/sonarr_ui/blocklist/mod.rs index 74a2d52..c382007 100644 --- a/src/ui/sonarr_ui/blocklist/mod.rs +++ b/src/ui/sonarr_ui/blocklist/mod.rs @@ -56,7 +56,10 @@ impl DrawUi for BlocklistUi { .yes_no_value(app.data.sonarr_data.prompt_confirm); draw_blocklist_table(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } ActiveSonarrBlock::BlocklistClearAllItemsPrompt => { let confirmation_prompt = ConfirmationPrompt::new() diff --git a/src/ui/sonarr_ui/downloads/mod.rs b/src/ui/sonarr_ui/downloads/mod.rs index 4ecdd1f..d41c913 100644 --- a/src/ui/sonarr_ui/downloads/mod.rs +++ b/src/ui/sonarr_ui/downloads/mod.rs @@ -44,7 +44,10 @@ impl DrawUi for DownloadsUi { .yes_no_value(app.data.sonarr_data.prompt_confirm); draw_downloads(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } ActiveSonarrBlock::UpdateDownloadsPrompt => { let confirmation_prompt = ConfirmationPrompt::new() @@ -53,7 +56,10 @@ impl DrawUi for DownloadsUi { .yes_no_value(app.data.sonarr_data.prompt_confirm); draw_downloads(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), } diff --git a/src/ui/sonarr_ui/indexers/edit_indexer_ui.rs b/src/ui/sonarr_ui/indexers/edit_indexer_ui.rs new file mode 100644 index 0000000..038a077 --- /dev/null +++ b/src/ui/sonarr_ui/indexers/edit_indexer_ui.rs @@ -0,0 +1,185 @@ +use std::sync::atomic::Ordering; + +use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_INDEXER_BLOCKS}; +use crate::models::Route; +use crate::render_selectable_input_box; +use crate::ui::sonarr_ui::indexers::draw_indexers; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::button::Button; +use crate::ui::widgets::checkbox::Checkbox; +use crate::ui::widgets::input_box::InputBox; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::popup::Size; +use crate::ui::{draw_popup_over, DrawUi}; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::text::Text; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +#[cfg(test)] +#[path = "edit_indexer_ui_tests.rs"] +mod edit_indexer_ui_tests; + +pub(super) struct EditIndexerUi; + +impl DrawUi for EditIndexerUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return EDIT_INDEXER_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_popup_over( + f, + app, + area, + draw_indexers, + draw_edit_indexer_prompt, + Size::WideLargePrompt, + ); + } +} + +fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let block = title_block_centered("Edit Indexer"); + let yes_no_value = app.data.sonarr_data.prompt_confirm; + let selected_block = app.data.sonarr_data.selected_block.get_active_block(); + let highlight_yes_no = selected_block == ActiveSonarrBlock::EditIndexerConfirmPrompt; + let edit_indexer_modal_option = &app.data.sonarr_data.edit_indexer_modal; + let protocol = &app.data.sonarr_data.indexers.current_selection().protocol; + let help_text = Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help()); + let help_paragraph = Paragraph::new(help_text).centered(); + + if edit_indexer_modal_option.is_some() { + let edit_indexer_modal = edit_indexer_modal_option.as_ref().unwrap(); + + let [_, settings_area, _, buttons_area, help_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(18), + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .margin(1) + .areas(area); + let [left_side_area, right_side_area] = + Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) + .margin(1) + .areas(settings_area); + let [name_area, rss_area, auto_search_area, interactive_search_area, priority_area] = + Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + ]) + .areas(left_side_area); + let [url_area, api_key_area, seed_ratio_area, tags_area] = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + ]) + .areas(right_side_area); + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let priority = edit_indexer_modal.priority.to_string(); + let name_input_box = InputBox::new(&edit_indexer_modal.name.text) + .offset(edit_indexer_modal.name.offset.load(Ordering::SeqCst)) + .label("Name") + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerNameInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditIndexerNameInput); + let url_input_box = InputBox::new(&edit_indexer_modal.url.text) + .offset(edit_indexer_modal.url.offset.load(Ordering::SeqCst)) + .label("URL") + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerUrlInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditIndexerUrlInput); + let api_key_input_box = InputBox::new(&edit_indexer_modal.api_key.text) + .offset(edit_indexer_modal.api_key.offset.load(Ordering::SeqCst)) + .label("API Key") + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerApiKeyInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditIndexerApiKeyInput); + let tags_input_box = InputBox::new(&edit_indexer_modal.tags.text) + .offset(edit_indexer_modal.tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerTagsInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditIndexerTagsInput); + let priority_input_box = InputBox::new(&priority) + .cursor_after_string(false) + .label("Indexer Priority ▴▾") + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerPriorityInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditIndexerPriorityInput); + + render_selectable_input_box!(name_input_box, f, name_area); + render_selectable_input_box!(url_input_box, f, url_area); + render_selectable_input_box!(api_key_input_box, f, api_key_area); + + if protocol == "torrent" { + let seed_ratio_input_box = InputBox::new(&edit_indexer_modal.seed_ratio.text) + .offset(edit_indexer_modal.seed_ratio.offset.load(Ordering::SeqCst)) + .label("Seed Ratio") + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerSeedRatioInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditIndexerSeedRatioInput); + let tags_input_box = InputBox::new(&edit_indexer_modal.tags.text) + .offset(edit_indexer_modal.tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerTagsInput) + .selected(active_sonarr_block == ActiveSonarrBlock::EditIndexerTagsInput); + + render_selectable_input_box!(seed_ratio_input_box, f, seed_ratio_area); + render_selectable_input_box!(tags_input_box, f, tags_area); + render_selectable_input_box!(priority_input_box, f, priority_area); + } else { + render_selectable_input_box!(tags_input_box, f, seed_ratio_area); + render_selectable_input_box!(priority_input_box, f, tags_area); + } + + let rss_checkbox = Checkbox::new("Enable RSS") + .checked(edit_indexer_modal.enable_rss.unwrap_or_default()) + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerToggleEnableRss); + let auto_search_checkbox = Checkbox::new("Enable Automatic Search") + .checked( + edit_indexer_modal + .enable_automatic_search + .unwrap_or_default(), + ) + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerToggleEnableAutomaticSearch); + let interactive_search_checkbox = Checkbox::new("Enable Interactive Search") + .checked( + edit_indexer_modal + .enable_interactive_search + .unwrap_or_default(), + ) + .highlighted(selected_block == ActiveSonarrBlock::EditIndexerToggleEnableInteractiveSearch); + + let [save_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(25), Constraint::Percentage(25)]) + .flex(Flex::Center) + .areas(buttons_area); + + let save_button = Button::new() + .title("Save") + .selected(yes_no_value && highlight_yes_no); + let cancel_button = Button::new() + .title("Cancel") + .selected(!yes_no_value && highlight_yes_no); + + f.render_widget(block, area); + f.render_widget(rss_checkbox, rss_area); + f.render_widget(auto_search_checkbox, auto_search_area); + f.render_widget(interactive_search_checkbox, interactive_search_area); + f.render_widget(save_button, save_area); + f.render_widget(cancel_button, cancel_area); + f.render_widget(help_paragraph, help_area); + } + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} diff --git a/src/ui/sonarr_ui/indexers/edit_indexer_ui_tests.rs b/src/ui/sonarr_ui/indexers/edit_indexer_ui_tests.rs new file mode 100644 index 0000000..362bc61 --- /dev/null +++ b/src/ui/sonarr_ui/indexers/edit_indexer_ui_tests.rs @@ -0,0 +1,18 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_INDEXER_BLOCKS}; + use crate::ui::sonarr_ui::indexers::edit_indexer_ui::EditIndexerUi; + use crate::ui::DrawUi; + use strum::IntoEnumIterator; + + #[test] + fn test_edit_indexer_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if EDIT_INDEXER_BLOCKS.contains(&active_sonarr_block) { + assert!(EditIndexerUi::accepts(active_sonarr_block.into())); + } else { + assert!(!EditIndexerUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/indexers/indexer_settings_ui.rs b/src/ui/sonarr_ui/indexers/indexer_settings_ui.rs new file mode 100644 index 0000000..684a15c --- /dev/null +++ b/src/ui/sonarr_ui/indexers/indexer_settings_ui.rs @@ -0,0 +1,127 @@ +use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::text::Text; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, INDEXER_SETTINGS_BLOCKS, +}; +use crate::models::Route; +use crate::render_selectable_input_box; +use crate::ui::sonarr_ui::indexers::draw_indexers; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::button::Button; +use crate::ui::widgets::input_box::InputBox; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::popup::Size; +use crate::ui::{draw_popup_over, DrawUi}; + +#[cfg(test)] +#[path = "indexer_settings_ui_tests.rs"] +mod indexer_settings_ui_tests; + +pub(super) struct IndexerSettingsUi; + +impl DrawUi for IndexerSettingsUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return INDEXER_SETTINGS_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_popup_over( + f, + app, + area, + draw_indexers, + draw_edit_indexer_settings_prompt, + Size::LargePrompt, + ); + } +} + +fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let block = title_block_centered("Configure All Indexer Settings"); + let yes_no_value = app.data.sonarr_data.prompt_confirm; + let selected_block = app.data.sonarr_data.selected_block.get_active_block(); + let highlight_yes_no = selected_block == ActiveSonarrBlock::IndexerSettingsConfirmPrompt; + let indexer_settings_option = &app.data.sonarr_data.indexer_settings; + let help_text = Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help()); + let help_paragraph = Paragraph::new(help_text).centered(); + + if indexer_settings_option.is_some() { + let indexer_settings = indexer_settings_option.as_ref().unwrap(); + + let [_, min_age_area, retention_area, max_size_area, rss_sync_area, _, buttons_area, help_area] = + Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .margin(1) + .areas(area); + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let min_age = indexer_settings.minimum_age.to_string(); + let retention = indexer_settings.retention.to_string(); + let max_size = indexer_settings.maximum_size.to_string(); + let rss_sync_interval = indexer_settings.rss_sync_interval.to_string(); + + let min_age_text_box = InputBox::new(&min_age) + .cursor_after_string(false) + .label("Minimum Age (minutes) ▴▾") + .highlighted(selected_block == ActiveSonarrBlock::IndexerSettingsMinimumAgeInput) + .selected(active_sonarr_block == ActiveSonarrBlock::IndexerSettingsMinimumAgeInput); + let retention_input_box = InputBox::new(&retention) + .cursor_after_string(false) + .label("Retention (days) ▴▾") + .highlighted(selected_block == ActiveSonarrBlock::IndexerSettingsRetentionInput) + .selected(active_sonarr_block == ActiveSonarrBlock::IndexerSettingsRetentionInput); + let max_size_input_box = InputBox::new(&max_size) + .cursor_after_string(false) + .label("Maximum Size (MB) ▴▾") + .highlighted(selected_block == ActiveSonarrBlock::IndexerSettingsMaximumSizeInput) + .selected(active_sonarr_block == ActiveSonarrBlock::IndexerSettingsMaximumSizeInput); + let rss_sync_interval_input_box = InputBox::new(&rss_sync_interval) + .cursor_after_string(false) + .label("RSS Sync Interval (minutes) ▴▾") + .highlighted(selected_block == ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput) + .selected(active_sonarr_block == ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput); + + render_selectable_input_box!(min_age_text_box, f, min_age_area); + render_selectable_input_box!(retention_input_box, f, retention_area); + render_selectable_input_box!(max_size_input_box, f, max_size_area); + render_selectable_input_box!(rss_sync_interval_input_box, f, rss_sync_area); + } + + let [save_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(25), Constraint::Percentage(25)]) + .flex(Flex::Center) + .areas(buttons_area); + + let save_button = Button::new() + .title("Save") + .selected(yes_no_value && highlight_yes_no); + let cancel_button = Button::new() + .title("Cancel") + .selected(!yes_no_value && highlight_yes_no); + + f.render_widget(block, area); + f.render_widget(save_button, save_area); + f.render_widget(cancel_button, cancel_area); + f.render_widget(help_paragraph, help_area); + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} diff --git a/src/ui/sonarr_ui/indexers/indexer_settings_ui_tests.rs b/src/ui/sonarr_ui/indexers/indexer_settings_ui_tests.rs new file mode 100644 index 0000000..f95304f --- /dev/null +++ b/src/ui/sonarr_ui/indexers/indexer_settings_ui_tests.rs @@ -0,0 +1,21 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, INDEXER_SETTINGS_BLOCKS, + }; + use crate::ui::sonarr_ui::indexers::indexer_settings_ui::IndexerSettingsUi; + use crate::ui::DrawUi; + + #[test] + fn test_indexer_settings_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if INDEXER_SETTINGS_BLOCKS.contains(&active_sonarr_block) { + assert!(IndexerSettingsUi::accepts(active_sonarr_block.into())); + } else { + assert!(!IndexerSettingsUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/indexers/indexers_ui_tests.rs b/src/ui/sonarr_ui/indexers/indexers_ui_tests.rs new file mode 100644 index 0000000..84a548d --- /dev/null +++ b/src/ui/sonarr_ui/indexers/indexers_ui_tests.rs @@ -0,0 +1,27 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, EDIT_INDEXER_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_BLOCKS, + }; + use crate::ui::sonarr_ui::indexers::IndexersUi; + use crate::ui::DrawUi; + + #[test] + fn test_indexers_ui_accepts() { + let mut indexers_blocks = Vec::new(); + indexers_blocks.extend(INDEXERS_BLOCKS); + indexers_blocks.extend(INDEXER_SETTINGS_BLOCKS); + indexers_blocks.extend(EDIT_INDEXER_BLOCKS); + indexers_blocks.push(ActiveSonarrBlock::TestAllIndexers); + + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if indexers_blocks.contains(&active_sonarr_block) { + assert!(IndexersUi::accepts(active_sonarr_block.into())); + } else { + assert!(!IndexersUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/indexers/mod.rs b/src/ui/sonarr_ui/indexers/mod.rs new file mode 100644 index 0000000..a560d9a --- /dev/null +++ b/src/ui/sonarr_ui/indexers/mod.rs @@ -0,0 +1,185 @@ +use ratatui::layout::{Constraint, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::Text; +use ratatui::widgets::{Cell, Row}; +use ratatui::Frame; + +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, INDEXERS_BLOCKS}; +use crate::models::servarr_models::Indexer; +use crate::models::Route; +use crate::ui::sonarr_ui::indexers::edit_indexer_ui::EditIndexerUi; +use crate::ui::sonarr_ui::indexers::indexer_settings_ui::IndexerSettingsUi; +use crate::ui::sonarr_ui::indexers::test_all_indexers_ui::TestAllIndexersUi; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{layout_block_top_border, title_block}; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::DrawUi; + +mod edit_indexer_ui; +mod indexer_settings_ui; +mod test_all_indexers_ui; + +#[cfg(test)] +#[path = "indexers_ui_tests.rs"] +mod indexers_ui_tests; + +pub(super) struct IndexersUi; + +impl DrawUi for IndexersUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return EditIndexerUi::accepts(route) + || IndexerSettingsUi::accepts(route) + || TestAllIndexersUi::accepts(route) + || INDEXERS_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let route = app.get_current_route(); + let mut indexers_matchers = |active_sonarr_block| match active_sonarr_block { + ActiveSonarrBlock::Indexers => draw_indexers(f, app, area), + ActiveSonarrBlock::TestIndexer => { + draw_indexers(f, app, area); + if app.is_loading || app.is_routing { + let loading_popup = Popup::new(LoadingBlock::new( + app.is_loading, + title_block("Testing Indexer"), + )) + .size(Size::LargeMessage); + f.render_widget(loading_popup, f.area()); + } else { + let popup = if let Some(result) = app.data.sonarr_data.indexer_test_error.as_ref() { + Popup::new(Message::new(result.clone())).size(Size::LargeMessage) + } else { + let message = Message::new("Indexer test succeeded!") + .title("Success") + .style(Style::new().success().bold()); + Popup::new(message).size(Size::Message) + }; + + f.render_widget(popup, f.area()); + } + } + ActiveSonarrBlock::DeleteIndexerPrompt => { + let prompt = format!( + "Do you really want to delete this indexer: \n{}?", + app + .data + .sonarr_data + .indexers + .current_selection() + .name + .clone() + .unwrap_or_default() + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Indexer") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + draw_indexers(f, app, area); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + _ => (), + }; + + match route { + _ if EditIndexerUi::accepts(route) => EditIndexerUi::draw(f, app, area), + _ if IndexerSettingsUi::accepts(route) => IndexerSettingsUi::draw(f, app, area), + _ if TestAllIndexersUi::accepts(route) => TestAllIndexersUi::draw(f, app, area), + Route::Sonarr(active_sonarr_block, _) if INDEXERS_BLOCKS.contains(&active_sonarr_block) => { + indexers_matchers(active_sonarr_block) + } + _ => (), + } + } +} + +fn draw_indexers(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let indexers_row_mapping = |indexer: &'_ Indexer| { + let Indexer { + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + priority, + tags, + .. + } = indexer; + let bool_to_text = |flag: bool| { + if flag { + return Text::from("Enabled").success(); + } + + Text::from("Disabled").failure() + }; + + let rss = bool_to_text(*enable_rss); + let automatic_search = bool_to_text(*enable_automatic_search); + let interactive_search = bool_to_text(*enable_interactive_search); + let tags: String = tags + .iter() + .map(|tag_id| { + app + .data + .sonarr_data + .tags_map + .get_by_left(&tag_id.as_i64().unwrap()) + .unwrap() + .clone() + }) + .collect::>() + .join(", "); + + Row::new(vec![ + Cell::from(name.clone().unwrap_or_default()), + Cell::from(rss), + Cell::from(automatic_search), + Cell::from(interactive_search), + Cell::from(priority.to_string()), + Cell::from(tags), + ]) + .primary() + }; + let indexers_table_footer = app + .data + .sonarr_data + .main_tabs + .get_active_tab_contextual_help(); + let indexers_table = ManagarrTable::new( + Some(&mut app.data.sonarr_data.indexers), + indexers_row_mapping, + ) + .block(layout_block_top_border()) + .footer(indexers_table_footer) + .loading(app.is_loading) + .headers([ + "Indexer", + "RSS", + "Automatic Search", + "Interactive Search", + "Priority", + "Tags", + ]) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(23), + ]); + + f.render_widget(indexers_table, area); +} diff --git a/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs b/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs new file mode 100644 index 0000000..0962a20 --- /dev/null +++ b/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs @@ -0,0 +1,92 @@ +use crate::app::context_clues::{build_context_clue_string, BARE_POPUP_CONTEXT_CLUES}; +use crate::app::App; +use crate::models::servarr_data::modals::IndexerTestResultModalItem; +use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; +use crate::models::Route; +use crate::ui::sonarr_ui::indexers::draw_indexers; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{borderless_block, get_width_from_percentage, title_block}; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::popup::Size; +use crate::ui::{draw_popup_over, DrawUi}; +use ratatui::layout::{Alignment, Constraint, Rect}; +use ratatui::widgets::{Cell, Row}; +use ratatui::Frame; + +#[cfg(test)] +#[path = "test_all_indexers_ui_tests.rs"] +mod test_all_indexers_ui_tests; + +pub(super) struct TestAllIndexersUi; + +impl DrawUi for TestAllIndexersUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return active_sonarr_block == ActiveSonarrBlock::TestAllIndexers; + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_popup_over( + f, + app, + area, + draw_indexers, + draw_test_all_indexers_test_results, + Size::Large, + ); + } +} + +fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = + if let Some(test_all_results) = app.data.sonarr_data.indexer_test_all_results.as_ref() { + test_all_results.current_selection().clone() + } else { + IndexerTestResultModalItem::default() + }; + f.render_widget(title_block("Test All Indexers"), area); + let help_footer = format!( + "<↑↓> scroll | {}", + build_context_clue_string(&BARE_POPUP_CONTEXT_CLUES) + ); + let test_results_row_mapping = |result: &IndexerTestResultModalItem| { + result.validation_failures.scroll_left_or_reset( + get_width_from_percentage(area, 86), + *result == current_selection, + app.tick_count % app.ticks_until_scroll == 0, + ); + let pass_fail = if result.is_valid { "✔" } else { "❌" }; + let row = Row::new(vec![ + Cell::from(result.name.to_owned()), + Cell::from(pass_fail.to_owned()), + Cell::from(result.validation_failures.to_string()), + ]); + + if result.is_valid { + row.success() + } else { + row.failure() + } + }; + + let indexers_test_results_table = ManagarrTable::new( + app.data.sonarr_data.indexer_test_all_results.as_mut(), + test_results_row_mapping, + ) + .block(borderless_block()) + .loading(app.is_loading) + .footer(Some(help_footer)) + .footer_alignment(Alignment::Center) + .margin(1) + .headers(["Indexer", "Pass/Fail", "Failure Messages"]) + .constraints([ + Constraint::Percentage(20), + Constraint::Percentage(10), + Constraint::Percentage(70), + ]); + + f.render_widget(indexers_test_results_table, area); +} diff --git a/src/ui/sonarr_ui/indexers/test_all_indexers_ui_tests.rs b/src/ui/sonarr_ui/indexers/test_all_indexers_ui_tests.rs new file mode 100644 index 0000000..16f7e2e --- /dev/null +++ b/src/ui/sonarr_ui/indexers/test_all_indexers_ui_tests.rs @@ -0,0 +1,19 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::ui::sonarr_ui::indexers::test_all_indexers_ui::TestAllIndexersUi; + use crate::ui::DrawUi; + + #[test] + fn test_test_all_indexers_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if active_sonarr_block == ActiveSonarrBlock::TestAllIndexers { + assert!(TestAllIndexersUi::accepts(active_sonarr_block.into())); + } else { + assert!(!TestAllIndexersUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/library/delete_series_ui.rs b/src/ui/sonarr_ui/library/delete_series_ui.rs index eb7278e..c30c421 100644 --- a/src/ui/sonarr_ui/library/delete_series_ui.rs +++ b/src/ui/sonarr_ui/library/delete_series_ui.rs @@ -51,7 +51,10 @@ impl DrawUi for DeleteSeriesUi { .yes_no_value(app.data.sonarr_data.prompt_confirm); draw_library(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } } } diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index 9a3d731..4ab9e25 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -91,7 +91,10 @@ impl DrawUi for LibraryUi { .yes_no_value(app.data.sonarr_data.prompt_confirm); draw_library(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), }; diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index ae7a097..4dc47f4 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -4,6 +4,7 @@ use blocklist::BlocklistUi; use chrono::{Duration, Utc}; use downloads::DownloadsUi; use history::HistoryUi; +use indexers::IndexersUi; use library::LibraryUi; use ratatui::{ layout::{Constraint, Layout, Rect}, @@ -39,6 +40,7 @@ use super::{ mod blocklist; mod downloads; mod history; +mod indexers; mod library; mod root_folders; @@ -63,6 +65,7 @@ impl DrawUi for SonarrUi { _ if BlocklistUi::accepts(route) => BlocklistUi::draw(f, app, content_area), _ if HistoryUi::accepts(route) => HistoryUi::draw(f, app, content_area), _ if RootFoldersUi::accepts(route) => RootFoldersUi::draw(f, app, content_area), + _ if IndexersUi::accepts(route) => IndexersUi::draw(f, app, content_area), _ => (), } } diff --git a/src/ui/sonarr_ui/root_folders/mod.rs b/src/ui/sonarr_ui/root_folders/mod.rs index ee564e3..d94b890 100644 --- a/src/ui/sonarr_ui/root_folders/mod.rs +++ b/src/ui/sonarr_ui/root_folders/mod.rs @@ -52,7 +52,10 @@ impl DrawUi for RootFoldersUi { .yes_no_value(app.data.sonarr_data.prompt_confirm); draw_root_folders(f, app, area); - f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); } _ => (), } diff --git a/src/ui/widgets/popup.rs b/src/ui/widgets/popup.rs index 0fcedd2..81c00f1 100644 --- a/src/ui/widgets/popup.rs +++ b/src/ui/widgets/popup.rs @@ -11,8 +11,9 @@ mod popup_tests; pub enum Size { SmallPrompt, - Prompt, + MediumPrompt, LargePrompt, + WideLargePrompt, Message, NarrowMessage, LargeMessage, @@ -28,8 +29,9 @@ impl Size { pub fn to_percent(&self) -> (u16, u16) { match self { Size::SmallPrompt => (20, 20), - Size::Prompt => (37, 37), - Size::LargePrompt => (70, 50), + Size::MediumPrompt => (37, 37), + Size::LargePrompt => (45, 45), + Size::WideLargePrompt => (70, 50), Size::Message => (25, 8), Size::NarrowMessage => (50, 20), Size::LargeMessage => (25, 25), diff --git a/src/ui/widgets/popup_tests.rs b/src/ui/widgets/popup_tests.rs index 1df6e11..2098ed0 100644 --- a/src/ui/widgets/popup_tests.rs +++ b/src/ui/widgets/popup_tests.rs @@ -7,8 +7,9 @@ mod tests { #[test] fn test_dimensions_to_percent() { assert_eq!(Size::SmallPrompt.to_percent(), (20, 20)); - assert_eq!(Size::Prompt.to_percent(), (37, 37)); - assert_eq!(Size::LargePrompt.to_percent(), (70, 50)); + assert_eq!(Size::MediumPrompt.to_percent(), (37, 37)); + assert_eq!(Size::LargePrompt.to_percent(), (45, 45)); + assert_eq!(Size::WideLargePrompt.to_percent(), (70, 50)); assert_eq!(Size::Message.to_percent(), (25, 8)); assert_eq!(Size::NarrowMessage.to_percent(), (50, 20)); assert_eq!(Size::LargeMessage.to_percent(), (25, 25)); From 1b5d70ae2d68fdd0ca037362c051c0afbf4a65d7 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 4 Dec 2024 16:46:06 -0700 Subject: [PATCH 27/82] perf: Improved performance by optimizing API calls to only refresh when the tick prompts a refresh. All UI is now significantly faster --- src/app/radarr/mod.rs | 1 - src/app/radarr/radarr_tests.rs | 16 ---------------- src/app/sonarr/mod.rs | 7 +++---- src/app/sonarr/sonarr_tests.rs | 28 ++++------------------------ 4 files changed, 7 insertions(+), 45 deletions(-) diff --git a/src/app/radarr/mod.rs b/src/app/radarr/mod.rs index 1ecbfda..4010b06 100644 --- a/src/app/radarr/mod.rs +++ b/src/app/radarr/mod.rs @@ -154,7 +154,6 @@ impl<'a> App<'a> { self.cancellation_token.cancel(); } else { self.dispatch_by_radarr_block(&active_radarr_block).await; - self.refresh_radarr_metadata().await; } } diff --git a/src/app/radarr/radarr_tests.rs b/src/app/radarr/radarr_tests.rs index 8742f8f..e56493f 100644 --- a/src/app/radarr/radarr_tests.rs +++ b/src/app/radarr/radarr_tests.rs @@ -563,22 +563,6 @@ mod tests { app.radarr_on_tick(ActiveRadarrBlock::Downloads).await; - assert_eq!( - sync_network_rx.recv().await.unwrap(), - RadarrEvent::GetDownloads.into() - ); - assert_eq!( - sync_network_rx.recv().await.unwrap(), - RadarrEvent::GetQualityProfiles.into() - ); - assert_eq!( - sync_network_rx.recv().await.unwrap(), - RadarrEvent::GetTags.into() - ); - assert_eq!( - sync_network_rx.recv().await.unwrap(), - RadarrEvent::GetRootFolders.into() - ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetDownloads.into() diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index 9b6a30b..71f969d 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -18,9 +18,6 @@ impl<'a> App<'a> { self .dispatch_network_event(SonarrEvent::ListSeries.into()) .await; - self - .dispatch_network_event(SonarrEvent::GetDownloads.into()) - .await; } ActiveSonarrBlock::SeriesDetails => { self.is_loading = true; @@ -63,6 +60,9 @@ impl<'a> App<'a> { .await; } ActiveSonarrBlock::Blocklist => { + self + .dispatch_network_event(SonarrEvent::ListSeries.into()) + .await; self .dispatch_network_event(SonarrEvent::GetBlocklist.into()) .await; @@ -156,7 +156,6 @@ impl<'a> App<'a> { self.cancellation_token.cancel(); } else { self.dispatch_by_sonarr_block(&active_sonarr_block).await; - self.refresh_sonarr_metadata().await; } } diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index a02c217..6821ae9 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -22,6 +22,10 @@ mod tests { .await; assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::ListSeries.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), SonarrEvent::GetBlocklist.into() @@ -232,10 +236,6 @@ mod tests { sync_network_rx.recv().await.unwrap(), SonarrEvent::ListSeries.into() ); - assert_eq!( - sync_network_rx.recv().await.unwrap(), - SonarrEvent::GetDownloads.into() - ); assert!(!app.data.sonarr_data.prompt_confirm); assert_eq!(app.tick_count, 0); } @@ -478,26 +478,6 @@ mod tests { app.sonarr_on_tick(ActiveSonarrBlock::Downloads).await; - assert_eq!( - sync_network_rx.recv().await.unwrap(), - SonarrEvent::GetDownloads.into() - ); - assert_eq!( - sync_network_rx.recv().await.unwrap(), - SonarrEvent::GetQualityProfiles.into() - ); - assert_eq!( - sync_network_rx.recv().await.unwrap(), - SonarrEvent::GetLanguageProfiles.into() - ); - assert_eq!( - sync_network_rx.recv().await.unwrap(), - SonarrEvent::GetTags.into() - ); - assert_eq!( - sync_network_rx.recv().await.unwrap(), - SonarrEvent::GetRootFolders.into() - ); assert_eq!( sync_network_rx.recv().await.unwrap(), SonarrEvent::GetDownloads.into() From 2d251554adaab5aeb3be1ec8aae3f57b59a2419f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 4 Dec 2024 17:04:36 -0700 Subject: [PATCH 28/82] feat(handler): System handler support for Sonarr --- src/handlers/sonarr_handlers/mod.rs | 5 + .../sonarr_handlers/sonarr_handler_tests.rs | 19 + src/handlers/sonarr_handlers/system/mod.rs | 123 ++ .../system/system_details_handler.rs | 180 +++ .../system/system_details_handler_tests.rs | 1020 +++++++++++++++++ .../system/system_handler_tests.rs | 572 +++++++++ src/models/servarr_data/sonarr/sonarr_data.rs | 8 + .../servarr_data/sonarr/sonarr_data_tests.rs | 11 + 8 files changed, 1938 insertions(+) create mode 100644 src/handlers/sonarr_handlers/system/mod.rs create mode 100644 src/handlers/sonarr_handlers/system/system_details_handler.rs create mode 100644 src/handlers/sonarr_handlers/system/system_details_handler_tests.rs create mode 100644 src/handlers/sonarr_handlers/system/system_handler_tests.rs diff --git a/src/handlers/sonarr_handlers/mod.rs b/src/handlers/sonarr_handlers/mod.rs index eca86a0..233a2bf 100644 --- a/src/handlers/sonarr_handlers/mod.rs +++ b/src/handlers/sonarr_handlers/mod.rs @@ -4,6 +4,7 @@ use history::HistoryHandler; use indexers::IndexersHandler; use library::LibraryHandler; use root_folders::RootFoldersHandler; +use system::SystemHandler; use crate::{ app::{key_binding::DEFAULT_KEYBINDINGS, App}, @@ -19,6 +20,7 @@ mod history; mod indexers; mod library; mod root_folders; +mod system; #[cfg(test)] #[path = "sonarr_handler_tests.rs"] @@ -57,6 +59,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SonarrHandler<'a, 'b _ if IndexersHandler::accepts(self.active_sonarr_block) => { IndexersHandler::with(self.key, self.app, self.active_sonarr_block, self.context).handle() } + _ if SystemHandler::accepts(self.active_sonarr_block) => { + SystemHandler::with(self.key, self.app, self.active_sonarr_block, self.context).handle() + } _ => self.handle_key_event(), } } diff --git a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs index 1e1702f..e8dae31 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs @@ -202,4 +202,23 @@ mod tests { active_sonarr_block ); } + + #[rstest] + fn test_delegates_system_blocks_to_system_handler( + #[values( + ActiveSonarrBlock::System, + ActiveSonarrBlock::SystemLogs, + ActiveSonarrBlock::SystemQueuedEvents, + ActiveSonarrBlock::SystemTasks, + ActiveSonarrBlock::SystemTaskStartConfirmPrompt, + ActiveSonarrBlock::SystemUpdates + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + SonarrHandler, + ActiveSonarrBlock::System, + active_sonarr_block + ); + } } diff --git a/src/handlers/sonarr_handlers/system/mod.rs b/src/handlers/sonarr_handlers/system/mod.rs new file mode 100644 index 0000000..83e939a --- /dev/null +++ b/src/handlers/sonarr_handlers/system/mod.rs @@ -0,0 +1,123 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::App; +use crate::event::Key; +use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::sonarr_handlers::system::system_details_handler::SystemDetailsHandler; +use crate::handlers::{handle_clear_errors, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; +use crate::models::Scrollable; + +mod system_details_handler; + +#[cfg(test)] +#[path = "system_handler_tests.rs"] +mod system_handler_tests; + +pub(super) struct SystemHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SystemHandler<'a, 'b> { + fn handle(&mut self) { + match self.active_sonarr_block { + _ if SystemDetailsHandler::accepts(self.active_sonarr_block) => { + SystemDetailsHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle() + } + _ => self.handle_key_event(), + } + } + + fn accepts(active_block: ActiveSonarrBlock) -> bool { + SystemDetailsHandler::accepts(active_block) || active_block == ActiveSonarrBlock::System + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + context: Option, + ) -> SystemHandler<'a, 'b> { + SystemHandler { + key, + app, + active_sonarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + && !self.app.data.sonarr_data.logs.is_empty() + && !self.app.data.sonarr_data.queued_events.is_empty() + && !self.app.data.sonarr_data.tasks.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_sonarr_block == ActiveSonarrBlock::System { + handle_change_tab_left_right_keys(self.app, self.key); + } + } + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) { + handle_clear_errors(self.app) + } + + fn handle_char_key_event(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::System { + let key = self.key; + match self.key { + _ if key == DEFAULT_KEYBINDINGS.refresh.key => { + self.app.should_refresh = true; + } + _ if key == DEFAULT_KEYBINDINGS.events.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::SystemQueuedEvents.into()); + } + _ if key == DEFAULT_KEYBINDINGS.logs.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::SystemLogs.into()); + self + .app + .data + .sonarr_data + .log_details + .set_items(self.app.data.sonarr_data.logs.items.to_vec()); + self.app.data.sonarr_data.log_details.scroll_to_bottom(); + } + _ if key == DEFAULT_KEYBINDINGS.tasks.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::SystemTasks.into()); + } + _ if key == DEFAULT_KEYBINDINGS.update.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::SystemUpdates.into()); + } + _ => (), + } + } + } +} diff --git a/src/handlers/sonarr_handlers/system/system_details_handler.rs b/src/handlers/sonarr_handlers/system/system_details_handler.rs new file mode 100644 index 0000000..2cb2441 --- /dev/null +++ b/src/handlers/sonarr_handlers/system/system_details_handler.rs @@ -0,0 +1,180 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::App; +use crate::event::Key; +use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SYSTEM_DETAILS_BLOCKS}; +use crate::models::stateful_list::StatefulList; +use crate::models::Scrollable; +use crate::network::sonarr_network::SonarrEvent; + +#[cfg(test)] +#[path = "system_details_handler_tests.rs"] +mod system_details_handler_tests; + +pub(super) struct SystemDetailsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SystemDetailsHandler<'a, 'b> { + fn accepts(active_block: ActiveSonarrBlock) -> bool { + SYSTEM_DETAILS_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + context: Option, + ) -> SystemDetailsHandler<'a, 'b> { + SystemDetailsHandler { + key, + app, + active_sonarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + && (!self.app.data.sonarr_data.log_details.is_empty() + || !self.app.data.sonarr_data.updates.is_empty()) + } + + fn handle_scroll_up(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SystemLogs => self.app.data.sonarr_data.log_details.scroll_up(), + ActiveSonarrBlock::SystemTasks => self.app.data.sonarr_data.tasks.scroll_up(), + ActiveSonarrBlock::SystemUpdates => self.app.data.sonarr_data.updates.scroll_up(), + ActiveSonarrBlock::SystemQueuedEvents => self.app.data.sonarr_data.queued_events.scroll_up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SystemLogs => self.app.data.sonarr_data.log_details.scroll_down(), + ActiveSonarrBlock::SystemTasks => self.app.data.sonarr_data.tasks.scroll_down(), + ActiveSonarrBlock::SystemUpdates => self.app.data.sonarr_data.updates.scroll_down(), + ActiveSonarrBlock::SystemQueuedEvents => { + self.app.data.sonarr_data.queued_events.scroll_down() + } + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SystemLogs => self.app.data.sonarr_data.log_details.scroll_to_top(), + ActiveSonarrBlock::SystemTasks => self.app.data.sonarr_data.tasks.scroll_to_top(), + ActiveSonarrBlock::SystemUpdates => self.app.data.sonarr_data.updates.scroll_to_top(), + ActiveSonarrBlock::SystemQueuedEvents => { + self.app.data.sonarr_data.queued_events.scroll_to_top() + } + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SystemLogs => self.app.data.sonarr_data.log_details.scroll_to_bottom(), + ActiveSonarrBlock::SystemTasks => self.app.data.sonarr_data.tasks.scroll_to_bottom(), + ActiveSonarrBlock::SystemUpdates => self.app.data.sonarr_data.updates.scroll_to_bottom(), + ActiveSonarrBlock::SystemQueuedEvents => { + self.app.data.sonarr_data.queued_events.scroll_to_bottom() + } + _ => (), + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + let key = self.key; + + match self.active_sonarr_block { + ActiveSonarrBlock::SystemLogs => match self.key { + _ if key == DEFAULT_KEYBINDINGS.left.key => { + self + .app + .data + .sonarr_data + .log_details + .items + .iter() + .for_each(|log| log.scroll_right()); + } + _ if key == DEFAULT_KEYBINDINGS.right.key => { + self + .app + .data + .sonarr_data + .log_details + .items + .iter() + .for_each(|log| log.scroll_left()); + } + _ => (), + }, + ActiveSonarrBlock::SystemTaskStartConfirmPrompt => handle_prompt_toggle(self.app, self.key), + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SystemTasks => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::SystemTaskStartConfirmPrompt.into()); + } + ActiveSonarrBlock::SystemTaskStartConfirmPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::StartTask(None)); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SystemLogs => { + self.app.data.sonarr_data.log_details = StatefulList::default(); + self.app.pop_navigation_stack() + } + ActiveSonarrBlock::SystemQueuedEvents + | ActiveSonarrBlock::SystemTasks + | ActiveSonarrBlock::SystemUpdates => self.app.pop_navigation_stack(), + ActiveSonarrBlock::SystemTaskStartConfirmPrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.prompt_confirm = false; + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + if SYSTEM_DETAILS_BLOCKS.contains(&self.active_sonarr_block) + && self.key == DEFAULT_KEYBINDINGS.refresh.key + { + self.app.should_refresh = true; + } + + if self.active_sonarr_block == ActiveSonarrBlock::SystemTaskStartConfirmPrompt + && self.key == DEFAULT_KEYBINDINGS.confirm.key + { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::StartTask(None)); + self.app.pop_navigation_stack(); + } + } +} diff --git a/src/handlers/sonarr_handlers/system/system_details_handler_tests.rs b/src/handlers/sonarr_handlers/system/system_details_handler_tests.rs new file mode 100644 index 0000000..a8a908a --- /dev/null +++ b/src/handlers/sonarr_handlers/system/system_details_handler_tests.rs @@ -0,0 +1,1020 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_str_eq; + use strum::IntoEnumIterator; + + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::system::system_details_handler::SystemDetailsHandler; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, SYSTEM_DETAILS_BLOCKS, + }; + use crate::models::servarr_models::QueueEvent; + use crate::models::sonarr_models::SonarrTask; + use crate::models::{HorizontallyScrollableText, ScrollableText}; + + mod test_handle_scroll_up_and_down { + use rstest::rstest; + + use crate::models::{HorizontallyScrollableText, ScrollableText}; + use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; + + use super::*; + + test_iterable_scroll!( + test_log_details_scroll, + SystemDetailsHandler, + sonarr_data, + log_details, + simple_stateful_iterable_vec!(HorizontallyScrollableText, String, text), + ActiveSonarrBlock::SystemLogs, + None, + text + ); + + #[rstest] + fn test_log_details_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = true; + app + .data + .sonarr_data + .log_details + .set_items(simple_stateful_iterable_vec!( + HorizontallyScrollableText, + String, + text + )); + + SystemDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SystemLogs, None).handle(); + + assert_str_eq!( + app.data.sonarr_data.log_details.current_selection().text, + "Test 1" + ); + + SystemDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SystemLogs, None).handle(); + + assert_str_eq!( + app.data.sonarr_data.log_details.current_selection().text, + "Test 1" + ); + } + + #[rstest] + fn test_tasks_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.updates = ScrollableText::with_string("Test 1\nTest 2".to_owned()); + app + .data + .sonarr_data + .tasks + .set_items(simple_stateful_iterable_vec!(SonarrTask, String, name)); + + SystemDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SystemTasks, None).handle(); + + assert_str_eq!( + app.data.sonarr_data.tasks.current_selection().name, + "Test 2" + ); + + SystemDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SystemTasks, None).handle(); + + assert_str_eq!( + app.data.sonarr_data.tasks.current_selection().name, + "Test 1" + ); + } + + #[rstest] + fn test_tasks_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = true; + app.data.sonarr_data.updates = ScrollableText::with_string("Test 1\nTest 2".to_owned()); + app + .data + .sonarr_data + .tasks + .set_items(simple_stateful_iterable_vec!(SonarrTask, String, name)); + + SystemDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SystemTasks, None).handle(); + + assert_str_eq!( + app.data.sonarr_data.tasks.current_selection().name, + "Test 1" + ); + + SystemDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SystemTasks, None).handle(); + + assert_str_eq!( + app.data.sonarr_data.tasks.current_selection().name, + "Test 1" + ); + } + + #[rstest] + fn test_queued_events_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.updates = ScrollableText::with_string("Test 1\nTest 2".to_owned()); + app + .data + .sonarr_data + .queued_events + .set_items(simple_stateful_iterable_vec!(QueueEvent, String, name)); + + SystemDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SystemQueuedEvents, None) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.queued_events.current_selection().name, + "Test 2" + ); + + SystemDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SystemQueuedEvents, None) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.queued_events.current_selection().name, + "Test 1" + ); + } + + #[rstest] + fn test_queued_events_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = true; + app.data.sonarr_data.updates = ScrollableText::with_string("Test 1\nTest 2".to_owned()); + app + .data + .sonarr_data + .queued_events + .set_items(simple_stateful_iterable_vec!(QueueEvent, String, name)); + + SystemDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SystemQueuedEvents, None) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.queued_events.current_selection().name, + "Test 1" + ); + + SystemDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SystemQueuedEvents, None) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.queued_events.current_selection().name, + "Test 1" + ); + } + + #[test] + fn test_system_updates_scroll() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.updates = ScrollableText::with_string("Test 1\nTest 2".to_owned()); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveSonarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.updates.offset, 0); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveSonarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.updates.offset, 1); + } + + #[test] + fn test_system_updates_scroll_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = true; + app.data.sonarr_data.updates = ScrollableText::with_string("Test 1\nTest 2".to_owned()); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveSonarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.updates.offset, 0); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveSonarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.updates.offset, 0); + } + } + + mod test_handle_home_end { + use crate::models::{HorizontallyScrollableText, ScrollableText}; + use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; + + use super::*; + + test_iterable_home_and_end!( + test_log_details_home_end, + SystemDetailsHandler, + sonarr_data, + log_details, + extended_stateful_iterable_vec!(HorizontallyScrollableText, String, text), + ActiveSonarrBlock::SystemLogs, + None, + text + ); + + #[test] + fn test_log_details_home_end_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = true; + app + .data + .sonarr_data + .log_details + .set_items(extended_stateful_iterable_vec!( + HorizontallyScrollableText, + String, + text + )); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SystemLogs, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.log_details.current_selection().text, + "Test 1" + ); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SystemLogs, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.log_details.current_selection().text, + "Test 1" + ); + } + + #[test] + fn test_tasks_home_end() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.updates = + ScrollableText::with_string("Test 1\nTest 2\nTest 3".to_owned()); + app + .data + .sonarr_data + .tasks + .set_items(extended_stateful_iterable_vec!(SonarrTask, String, name)); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SystemTasks, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.tasks.current_selection().name, + "Test 3" + ); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SystemTasks, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.tasks.current_selection().name, + "Test 1" + ); + } + + #[test] + fn test_tasks_home_end_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = true; + app.data.sonarr_data.updates = + ScrollableText::with_string("Test 1\nTest 2\nTest 3".to_owned()); + app + .data + .sonarr_data + .tasks + .set_items(extended_stateful_iterable_vec!(SonarrTask, String, name)); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SystemTasks, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.tasks.current_selection().name, + "Test 1" + ); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SystemTasks, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.tasks.current_selection().name, + "Test 1" + ); + } + + #[test] + fn test_queued_events_home_end() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.updates = + ScrollableText::with_string("Test 1\nTest 2\nTest 3".to_owned()); + app + .data + .sonarr_data + .queued_events + .set_items(extended_stateful_iterable_vec!(QueueEvent, String, name)); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SystemQueuedEvents, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.queued_events.current_selection().name, + "Test 3" + ); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SystemQueuedEvents, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.queued_events.current_selection().name, + "Test 1" + ); + } + + #[test] + fn test_queued_events_home_end_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = true; + app.data.sonarr_data.updates = + ScrollableText::with_string("Test 1\nTest 2\nTest 3".to_owned()); + app + .data + .sonarr_data + .queued_events + .set_items(extended_stateful_iterable_vec!(QueueEvent, String, name)); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SystemQueuedEvents, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.queued_events.current_selection().name, + "Test 1" + ); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SystemQueuedEvents, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.queued_events.current_selection().name, + "Test 1" + ); + } + + #[test] + fn test_system_updates_home_end() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.updates = ScrollableText::with_string("Test 1\nTest 2".to_owned()); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.updates.offset, 1); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.updates.offset, 0); + } + + #[test] + fn test_system_updates_home_end_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = true; + app.data.sonarr_data.updates = ScrollableText::with_string("Test 1\nTest 2".to_owned()); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.updates.offset, 0); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.updates.offset, 0); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[test] + fn test_handle_log_details_left_right() { + let active_sonarr_block = ActiveSonarrBlock::SystemLogs; + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app + .data + .sonarr_data + .log_details + .set_items(vec!["t1".into(), "t22".into()]); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.log_details.items[0].to_string(), "t1"); + assert_eq!(app.data.sonarr_data.log_details.items[1].to_string(), "t22"); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.log_details.items[0].to_string(), "1"); + assert_eq!(app.data.sonarr_data.log_details.items[1].to_string(), "22"); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.log_details.items[0].to_string(), ""); + assert_eq!(app.data.sonarr_data.log_details.items[1].to_string(), "2"); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.log_details.items[0].to_string(), ""); + assert_eq!(app.data.sonarr_data.log_details.items[1].to_string(), ""); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.log_details.items[0].to_string(), ""); + assert_eq!(app.data.sonarr_data.log_details.items[1].to_string(), ""); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.log_details.items[0].to_string(), "1"); + assert_eq!(app.data.sonarr_data.log_details.items[1].to_string(), "2"); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.log_details.items[0].to_string(), "t1"); + assert_eq!(app.data.sonarr_data.log_details.items[1].to_string(), "22"); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.sonarr_data.log_details.items[0].to_string(), "t1"); + assert_eq!(app.data.sonarr_data.log_details.items[1].to_string(), "t22"); + } + + #[rstest] + fn test_left_right_prompt_toggle( + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + + SystemDetailsHandler::with( + key, + &mut app, + ActiveSonarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + SystemDetailsHandler::with( + key, + &mut app, + ActiveSonarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_system_tasks_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.updates = ScrollableText::with_string("Test".to_owned()); + + SystemDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SystemTasks, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SystemTaskStartConfirmPrompt.into() + ); + } + + #[test] + fn test_system_tasks_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SystemTasks.into()); + app.data.sonarr_data.updates = ScrollableText::with_string("Test".to_owned()); + + SystemDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SystemTasks, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SystemTasks.into() + ); + } + + #[test] + fn test_system_tasks_start_task_prompt_confirm_submit() { + let mut app = App::default(); + app.data.sonarr_data.updates = ScrollableText::with_string("Test".to_owned()); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveSonarrBlock::SystemTasks.into()); + app.push_navigation_stack(ActiveSonarrBlock::SystemTaskStartConfirmPrompt.into()); + + SystemDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::StartTask(None)) + ); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SystemTasks.into() + ); + } + + #[test] + fn test_system_tasks_start_task_prompt_decline_submit() { + let mut app = App::default(); + app.data.sonarr_data.updates = ScrollableText::with_string("Test".to_owned()); + app.push_navigation_stack(ActiveSonarrBlock::SystemTasks.into()); + app.push_navigation_stack(ActiveSonarrBlock::SystemTaskStartConfirmPrompt.into()); + + SystemDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SystemTasks.into() + ); + } + } + + mod test_handle_esc { + use crate::models::HorizontallyScrollableText; + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_esc_system_logs(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app + .data + .sonarr_data + .log_details + .set_items(vec![HorizontallyScrollableText::from("test")]); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.push_navigation_stack(ActiveSonarrBlock::SystemLogs.into()); + app + .data + .sonarr_data + .log_details + .set_items(vec![HorizontallyScrollableText::default()]); + + SystemDetailsHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::SystemLogs, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::System.into()); + assert!(app.data.sonarr_data.log_details.items.is_empty()); + } + + #[rstest] + fn test_esc_system_tasks(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.push_navigation_stack(ActiveSonarrBlock::SystemTasks.into()); + app + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask::default()]); + + SystemDetailsHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::SystemTasks, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::System.into()); + } + + #[rstest] + fn test_esc_system_queued_events(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.push_navigation_stack(ActiveSonarrBlock::SystemQueuedEvents.into()); + app + .data + .sonarr_data + .queued_events + .set_items(vec![QueueEvent::default()]); + + SystemDetailsHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::SystemQueuedEvents, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::System.into()); + } + + #[rstest] + fn test_esc_system_updates(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.push_navigation_stack(ActiveSonarrBlock::SystemUpdates.into()); + + SystemDetailsHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::SystemUpdates, None) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::System.into()); + } + + #[test] + fn test_system_tasks_start_task_prompt_esc() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SystemTasks.into()); + app.push_navigation_stack(ActiveSonarrBlock::SystemTaskStartConfirmPrompt.into()); + app.data.sonarr_data.prompt_confirm = true; + + SystemDetailsHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SystemTasks.into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + } + } + + mod test_handle_key_char { + use rstest::rstest; + + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + #[rstest] + fn test_refresh_key( + #[values( + ActiveSonarrBlock::SystemLogs, + ActiveSonarrBlock::SystemTasks, + ActiveSonarrBlock::SystemQueuedEvents, + ActiveSonarrBlock::SystemUpdates + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.updates = ScrollableText::with_string("Test".to_owned()); + app.push_navigation_stack(active_sonarr_block.into()); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + assert!(app.should_refresh); + } + + #[rstest] + fn test_refresh_key_no_op_when_not_ready( + #[values( + ActiveSonarrBlock::SystemLogs, + ActiveSonarrBlock::SystemTasks, + ActiveSonarrBlock::SystemQueuedEvents, + ActiveSonarrBlock::SystemUpdates + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = true; + app.data.sonarr_data.updates = ScrollableText::with_string("Test".to_owned()); + app.push_navigation_stack(active_sonarr_block.into()); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_system_tasks_start_task_prompt_confirm() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.updates = ScrollableText::with_string("Test".to_owned()); + app.push_navigation_stack(ActiveSonarrBlock::SystemTasks.into()); + app.push_navigation_stack(ActiveSonarrBlock::SystemTaskStartConfirmPrompt.into()); + + SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveSonarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::StartTask(None)) + ); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SystemTasks.into() + ); + } + } + + #[test] + fn test_system_details_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if SYSTEM_DETAILS_BLOCKS.contains(&active_sonarr_block) { + assert!(SystemDetailsHandler::accepts(active_sonarr_block)); + } else { + assert!(!SystemDetailsHandler::accepts(active_sonarr_block)); + } + }) + } + + #[test] + fn test_system_details_handler_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = true; + + let handler = SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SystemUpdates, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_system_details_handler_not_ready_when_both_log_details_and_updates_are_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = false; + + let handler = SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SystemUpdates, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_system_details_handler_ready_when_not_loading_and_log_details_is_not_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = false; + app + .data + .sonarr_data + .log_details + .set_items(vec![HorizontallyScrollableText::default()]); + + let handler = SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SystemUpdates, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_system_details_handler_ready_when_not_loading_and_updates_is_not_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = false; + app.data.sonarr_data.updates = ScrollableText::with_string("Test".to_owned()); + + let handler = SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SystemUpdates, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/sonarr_handlers/system/system_handler_tests.rs b/src/handlers/sonarr_handlers/system/system_handler_tests.rs new file mode 100644 index 0000000..3f62287 --- /dev/null +++ b/src/handlers/sonarr_handlers/system/system_handler_tests.rs @@ -0,0 +1,572 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::system::SystemHandler; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, SYSTEM_DETAILS_BLOCKS, + }; + use crate::models::servarr_models::QueueEvent; + use crate::models::sonarr_models::SonarrTask; + use crate::test_handler_delegation; + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + + use super::*; + + #[rstest] + fn test_system_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(6); + + SystemHandler::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveSonarrBlock::System, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::Indexers.into() + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); + } + + #[rstest] + fn test_system_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = is_ready; + app.data.sonarr_data.main_tabs.set_index(6); + + SystemHandler::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveSonarrBlock::System, + None, + ) + .handle(); + + assert_eq!( + app.data.sonarr_data.main_tabs.get_active_route(), + ActiveSonarrBlock::Series.into() + ); + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_default_esc(#[values(true, false)] is_loading: bool) { + let mut app = App::default(); + app.is_loading = is_loading; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + + SystemHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::System, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::System.into()); + assert!(app.error.text.is_empty()); + } + } + + mod test_handle_key_char { + use pretty_assertions::{assert_eq, assert_str_eq}; + + use crate::models::HorizontallyScrollableText; + + use super::*; + + #[test] + fn test_update_system_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.logs.set_items(vec![ + HorizontallyScrollableText::from("test 1"), + HorizontallyScrollableText::from("test 2"), + ]); + app + .data + .sonarr_data + .queued_events + .set_items(vec![QueueEvent::default()]); + app + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask::default()]); + + SystemHandler::with( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveSonarrBlock::System, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SystemUpdates.into() + ); + } + + #[test] + fn test_update_system_key_no_op_if_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.logs.set_items(vec![ + HorizontallyScrollableText::from("test 1"), + HorizontallyScrollableText::from("test 2"), + ]); + app + .data + .sonarr_data + .queued_events + .set_items(vec![QueueEvent::default()]); + app + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask::default()]); + + SystemHandler::with( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveSonarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::System.into()); + } + + #[test] + fn test_queued_events_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.logs.set_items(vec![ + HorizontallyScrollableText::from("test 1"), + HorizontallyScrollableText::from("test 2"), + ]); + app + .data + .sonarr_data + .queued_events + .set_items(vec![QueueEvent::default()]); + app + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask::default()]); + + SystemHandler::with( + DEFAULT_KEYBINDINGS.events.key, + &mut app, + ActiveSonarrBlock::System, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SystemQueuedEvents.into() + ); + } + + #[test] + fn test_queued_events_key_no_op_if_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.logs.set_items(vec![ + HorizontallyScrollableText::from("test 1"), + HorizontallyScrollableText::from("test 2"), + ]); + app + .data + .sonarr_data + .queued_events + .set_items(vec![QueueEvent::default()]); + app + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask::default()]); + + SystemHandler::with( + DEFAULT_KEYBINDINGS.events.key, + &mut app, + ActiveSonarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::System.into()); + } + + #[test] + fn test_refresh_system_key() { + let mut app = App::default(); + app.data.sonarr_data.logs.set_items(vec![ + HorizontallyScrollableText::from("test 1"), + HorizontallyScrollableText::from("test 2"), + ]); + app + .data + .sonarr_data + .queued_events + .set_items(vec![QueueEvent::default()]); + app + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask::default()]); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + + SystemHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::System.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_system_key_no_op_if_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.logs.set_items(vec![ + HorizontallyScrollableText::from("test 1"), + HorizontallyScrollableText::from("test 2"), + ]); + app + .data + .sonarr_data + .queued_events + .set_items(vec![QueueEvent::default()]); + app + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask::default()]); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + + SystemHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::System.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_logs_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.logs.set_items(vec![ + HorizontallyScrollableText::from("test 1"), + HorizontallyScrollableText::from("test 2"), + ]); + app + .data + .sonarr_data + .queued_events + .set_items(vec![QueueEvent::default()]); + app + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask::default()]); + + SystemHandler::with( + DEFAULT_KEYBINDINGS.logs.key, + &mut app, + ActiveSonarrBlock::System, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SystemLogs.into() + ); + assert_eq!( + app.data.sonarr_data.log_details.items, + app.data.sonarr_data.logs.items + ); + assert_str_eq!( + app.data.sonarr_data.log_details.current_selection().text, + "test 2" + ); + } + + #[test] + fn test_logs_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.logs.set_items(vec![ + HorizontallyScrollableText::from("test 1"), + HorizontallyScrollableText::from("test 2"), + ]); + app + .data + .sonarr_data + .queued_events + .set_items(vec![QueueEvent::default()]); + app + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask::default()]); + + SystemHandler::with( + DEFAULT_KEYBINDINGS.logs.key, + &mut app, + ActiveSonarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::System.into()); + assert!(app.data.sonarr_data.log_details.is_empty()); + } + + #[test] + fn test_tasks_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.logs.set_items(vec![ + HorizontallyScrollableText::from("test 1"), + HorizontallyScrollableText::from("test 2"), + ]); + app + .data + .sonarr_data + .queued_events + .set_items(vec![QueueEvent::default()]); + app + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask::default()]); + + SystemHandler::with( + DEFAULT_KEYBINDINGS.tasks.key, + &mut app, + ActiveSonarrBlock::System, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SystemTasks.into() + ); + } + + #[test] + fn test_tasks_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.logs.set_items(vec![ + HorizontallyScrollableText::from("test 1"), + HorizontallyScrollableText::from("test 2"), + ]); + app + .data + .sonarr_data + .queued_events + .set_items(vec![QueueEvent::default()]); + app + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask::default()]); + + SystemHandler::with( + DEFAULT_KEYBINDINGS.tasks.key, + &mut app, + ActiveSonarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::System.into()); + } + } + + #[rstest] + fn test_delegates_system_details_blocks_to_system_details_handler( + #[values( + ActiveSonarrBlock::SystemLogs, + ActiveSonarrBlock::SystemQueuedEvents, + ActiveSonarrBlock::SystemTasks, + ActiveSonarrBlock::SystemTaskStartConfirmPrompt, + ActiveSonarrBlock::SystemUpdates + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + SystemHandler, + ActiveSonarrBlock::System, + active_sonarr_block + ); + } + + #[test] + fn test_system_handler_accepts() { + let mut system_blocks = vec![ActiveSonarrBlock::System]; + system_blocks.extend(SYSTEM_DETAILS_BLOCKS); + + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if system_blocks.contains(&active_sonarr_block) { + assert!(SystemHandler::accepts(active_sonarr_block)); + } else { + assert!(!SystemHandler::accepts(active_sonarr_block)); + } + }) + } + + #[test] + fn test_system_handler_is_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = true; + + let system_handler = SystemHandler::with( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveSonarrBlock::System, + None, + ); + + assert!(!system_handler.is_ready()); + } + + #[test] + fn test_system_handler_is_not_ready_when_logs_is_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = false; + app + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask::default()]); + app + .data + .sonarr_data + .queued_events + .set_items(vec![QueueEvent::default()]); + + let system_handler = SystemHandler::with( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveSonarrBlock::System, + None, + ); + + assert!(!system_handler.is_ready()); + } + + #[test] + fn test_system_handler_is_not_ready_when_tasks_is_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = false; + app.data.sonarr_data.logs.set_items(vec!["test".into()]); + app + .data + .sonarr_data + .queued_events + .set_items(vec![QueueEvent::default()]); + + let system_handler = SystemHandler::with( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveSonarrBlock::System, + None, + ); + + assert!(!system_handler.is_ready()); + } + + #[test] + fn test_system_handler_is_not_ready_when_queued_events_is_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = false; + app.data.sonarr_data.logs.set_items(vec!["test".into()]); + app + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask::default()]); + + let system_handler = SystemHandler::with( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveSonarrBlock::System, + None, + ); + + assert!(!system_handler.is_ready()); + } + + #[test] + fn test_system_handler_is_ready_when_all_required_tables_are_not_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = false; + app.data.sonarr_data.logs.set_items(vec!["test".into()]); + app + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask::default()]); + app + .data + .sonarr_data + .queued_events + .set_items(vec![QueueEvent::default()]); + + let system_handler = SystemHandler::with( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveSonarrBlock::System, + None, + ); + + assert!(system_handler.is_ready()); + } +} diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index e736f10..acec1c2 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -483,6 +483,14 @@ pub static INDEXERS_BLOCKS: [ActiveSonarrBlock; 3] = [ ActiveSonarrBlock::TestIndexer, ]; +pub static SYSTEM_DETAILS_BLOCKS: [ActiveSonarrBlock; 5] = [ + ActiveSonarrBlock::SystemLogs, + ActiveSonarrBlock::SystemQueuedEvents, + ActiveSonarrBlock::SystemTasks, + ActiveSonarrBlock::SystemTaskStartConfirmPrompt, + ActiveSonarrBlock::SystemUpdates, +]; + impl From for Route { fn from(active_sonarr_block: ActiveSonarrBlock) -> Route { Route::Sonarr(active_sonarr_block, None) diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index c0aceec..0e73f33 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -207,6 +207,7 @@ mod tests { EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, EDIT_SERIES_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, HISTORY_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, LIBRARY_BLOCKS, ROOT_FOLDERS_BLOCKS, + SYSTEM_DETAILS_BLOCKS, }; #[test] @@ -556,5 +557,15 @@ mod tests { assert!(INDEXERS_BLOCKS.contains(&ActiveSonarrBlock::Indexers)); assert!(INDEXERS_BLOCKS.contains(&ActiveSonarrBlock::TestIndexer)); } + + #[test] + fn test_system_details_blocks_contents() { + assert_eq!(SYSTEM_DETAILS_BLOCKS.len(), 5); + assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SystemLogs)); + assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SystemQueuedEvents)); + assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SystemTasks)); + assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SystemTaskStartConfirmPrompt)); + assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SystemUpdates)); + } } } From 00cdeee5c6b8925c70410d598b97802fcb62b0f8 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 4 Dec 2024 17:41:30 -0700 Subject: [PATCH 29/82] feat(ui): Full Sonarr system tab support Signed-off-by: Alex Clarke --- src/app/sonarr/sonarr_context_clues.rs | 5 + src/app/sonarr/sonarr_context_clues_tests.rs | 17 ++ src/ui/radarr_ui/mod.rs | 5 - src/ui/radarr_ui/radarr_ui_utils.rs | 46 ---- src/ui/radarr_ui/radarr_ui_utils_tests.rs | 70 ------ src/ui/radarr_ui/system/mod.rs | 5 +- src/ui/radarr_ui/system/system_details_ui.rs | 3 +- src/ui/sonarr_ui/mod.rs | 3 + src/ui/sonarr_ui/system/mod.rs | 232 ++++++++++++++++++ src/ui/sonarr_ui/system/system_details_ui.rs | 180 ++++++++++++++ .../system/system_details_ui_tests.rs | 21 ++ src/ui/sonarr_ui/system/system_ui_tests.rs | 25 ++ src/ui/utils.rs | 40 ++- src/ui/utils_tests.rs | 80 +++++- 14 files changed, 598 insertions(+), 134 deletions(-) delete mode 100644 src/ui/radarr_ui/radarr_ui_utils.rs delete mode 100644 src/ui/radarr_ui/radarr_ui_utils_tests.rs create mode 100644 src/ui/sonarr_ui/system/mod.rs create mode 100644 src/ui/sonarr_ui/system/system_details_ui.rs create mode 100644 src/ui/sonarr_ui/system/system_details_ui_tests.rs create mode 100644 src/ui/sonarr_ui/system/system_ui_tests.rs diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index 9aacf69..f9c64a0 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -90,3 +90,8 @@ pub static EPISODE_DETAILS_CONTEXT_CLUES: [ContextClue; 3] = [ (DEFAULT_KEYBINDINGS.search, "auto search"), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; + +pub static SYSTEM_TASKS_CONTEXT_CLUES: [ContextClue; 2] = [ + (DEFAULT_KEYBINDINGS.submit, "start task"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index c72b0b2..2c8b485 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -9,6 +9,7 @@ mod tests { HISTORY_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, SEASON_DETAILS_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES, + SYSTEM_TASKS_CONTEXT_CLUES, }, }; @@ -266,4 +267,20 @@ mod tests { assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc); assert_eq!(episode_details_context_clues_iter.next(), None); } + + #[test] + fn test_system_tasks_context_clues() { + let mut system_tasks_context_clues_iter = SYSTEM_TASKS_CONTEXT_CLUES.iter(); + + let (key_binding, description) = system_tasks_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); + assert_str_eq!(*description, "start task"); + + let (key_binding, description) = system_tasks_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc); + assert_eq!(system_tasks_context_clues_iter.next(), None); + } } diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index 36d6337..18a9f11 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -34,14 +34,9 @@ mod collections; mod downloads; mod indexers; mod library; -mod radarr_ui_utils; mod root_folders; mod system; -#[cfg(test)] -#[path = "radarr_ui_tests.rs"] -mod radarr_ui_tests; - pub(super) struct RadarrUi; impl DrawUi for RadarrUi { diff --git a/src/ui/radarr_ui/radarr_ui_utils.rs b/src/ui/radarr_ui/radarr_ui_utils.rs deleted file mode 100644 index 9cbadeb..0000000 --- a/src/ui/radarr_ui/radarr_ui_utils.rs +++ /dev/null @@ -1,46 +0,0 @@ -use ratatui::style::{Style, Stylize}; -use ratatui::widgets::ListItem; - -use crate::ui::styles::ManagarrStyle; - -#[cfg(test)] -#[path = "radarr_ui_utils_tests.rs"] -mod radarr_ui_utils_tests; - -pub(super) fn style_log_list_item(list_item: ListItem<'_>, level: String) -> ListItem<'_> { - match level.to_lowercase().as_str() { - "trace" => list_item.gray(), - "debug" => list_item.blue(), - "info" => list_item.style(Style::new().default()), - "warn" => list_item.style(Style::new().secondary()), - "error" => list_item.style(Style::new().failure()), - "fatal" => list_item.style(Style::new().failure().bold()), - _ => list_item.style(Style::new().default()), - } -} - -pub(super) fn convert_to_minutes_hours_days(time: i64) -> String { - if time < 60 { - if time == 0 { - "now".to_owned() - } else if time == 1 { - format!("{time} minute") - } else { - format!("{time} minutes") - } - } else if time / 60 < 24 { - let hours = time / 60; - if hours == 1 { - format!("{hours} hour") - } else { - format!("{hours} hours") - } - } else { - let days = time / (60 * 24); - if days == 1 { - format!("{days} day") - } else { - format!("{days} days") - } - } -} diff --git a/src/ui/radarr_ui/radarr_ui_utils_tests.rs b/src/ui/radarr_ui/radarr_ui_utils_tests.rs deleted file mode 100644 index ddcecf8..0000000 --- a/src/ui/radarr_ui/radarr_ui_utils_tests.rs +++ /dev/null @@ -1,70 +0,0 @@ -#[cfg(test)] -mod tests { - use super::super::*; - use pretty_assertions::assert_str_eq; - use ratatui::prelude::Text; - use ratatui::text::Span; - - #[test] - fn test_determine_log_style_by_level() { - let list_item = ListItem::new(Text::from(Span::raw("test"))); - - assert_eq!( - style_log_list_item(list_item.clone(), "trace".to_string()), - list_item.clone().gray() - ); - assert_eq!( - style_log_list_item(list_item.clone(), "debug".to_string()), - list_item.clone().blue() - ); - assert_eq!( - style_log_list_item(list_item.clone(), "info".to_string()), - list_item.clone().style(Style::new().default()) - ); - assert_eq!( - style_log_list_item(list_item.clone(), "warn".to_string()), - list_item.clone().style(Style::new().secondary()) - ); - assert_eq!( - style_log_list_item(list_item.clone(), "error".to_string()), - list_item.clone().style(Style::new().failure()) - ); - assert_eq!( - style_log_list_item(list_item.clone(), "fatal".to_string()), - list_item.clone().style(Style::new().failure().bold()) - ); - assert_eq!( - style_log_list_item(list_item.clone(), "".to_string()), - list_item.style(Style::new().default()) - ); - } - - #[test] - fn test_determine_log_style_by_level_case_insensitive() { - let list_item = ListItem::new(Text::from(Span::raw("test"))); - - assert_eq!( - style_log_list_item(list_item.clone(), "TrAcE".to_string()), - list_item.gray() - ); - } - - #[test] - fn test_convert_to_minutes_hours_days_minutes() { - assert_str_eq!(convert_to_minutes_hours_days(0), "now"); - assert_str_eq!(convert_to_minutes_hours_days(1), "1 minute"); - assert_str_eq!(convert_to_minutes_hours_days(2), "2 minutes"); - } - - #[test] - fn test_convert_to_minutes_hours_days_hours() { - assert_str_eq!(convert_to_minutes_hours_days(60), "1 hour"); - assert_str_eq!(convert_to_minutes_hours_days(120), "2 hours"); - } - - #[test] - fn test_convert_to_minutes_hours_days_days() { - assert_str_eq!(convert_to_minutes_hours_days(1440), "1 day"); - assert_str_eq!(convert_to_minutes_hours_days(2880), "2 days"); - } -} diff --git a/src/ui/radarr_ui/system/mod.rs b/src/ui/radarr_ui/system/mod.rs index 016f474..db50e4b 100644 --- a/src/ui/radarr_ui/system/mod.rs +++ b/src/ui/radarr_ui/system/mod.rs @@ -15,10 +15,11 @@ use crate::app::App; use crate::models::radarr_models::RadarrTask; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_models::QueueEvent; -use crate::ui::radarr_ui::radarr_ui_utils::{convert_to_minutes_hours_days, style_log_list_item}; use crate::ui::radarr_ui::system::system_details_ui::SystemDetailsUi; use crate::ui::styles::ManagarrStyle; -use crate::ui::utils::layout_block_top_border; +use crate::ui::utils::{ + convert_to_minutes_hours_days, layout_block_top_border, style_log_list_item, +}; use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::selectable_list::SelectableList; diff --git a/src/ui/radarr_ui/system/system_details_ui.rs b/src/ui/radarr_ui/system/system_details_ui.rs index 6198c86..41a5437 100644 --- a/src/ui/radarr_ui/system/system_details_ui.rs +++ b/src/ui/radarr_ui/system/system_details_ui.rs @@ -9,13 +9,12 @@ use crate::app::App; use crate::models::radarr_models::RadarrTask; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS}; use crate::models::Route; -use crate::ui::radarr_ui::radarr_ui_utils::style_log_list_item; use crate::ui::radarr_ui::system::{ draw_queued_events, draw_system_ui_layout, extract_task_props, TASK_TABLE_CONSTRAINTS, TASK_TABLE_HEADERS, }; use crate::ui::styles::ManagarrStyle; -use crate::ui::utils::{borderless_block, title_block}; +use crate::ui::utils::{borderless_block, style_log_list_item, title_block}; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::managarr_table::ManagarrTable; diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index 4dc47f4..c871d17 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -14,6 +14,7 @@ use ratatui::{ Frame, }; use root_folders::RootFoldersUi; +use system::SystemUi; use crate::{ app::App, @@ -43,6 +44,7 @@ mod history; mod indexers; mod library; mod root_folders; +mod system; #[cfg(test)] #[path = "sonarr_ui_tests.rs"] @@ -66,6 +68,7 @@ impl DrawUi for SonarrUi { _ if HistoryUi::accepts(route) => HistoryUi::draw(f, app, content_area), _ if RootFoldersUi::accepts(route) => RootFoldersUi::draw(f, app, content_area), _ if IndexersUi::accepts(route) => IndexersUi::draw(f, app, content_area), + _ if SystemUi::accepts(route) => SystemUi::draw(f, app, content_area), _ => (), } } diff --git a/src/ui/sonarr_ui/system/mod.rs b/src/ui/sonarr_ui/system/mod.rs new file mode 100644 index 0000000..f9e6c88 --- /dev/null +++ b/src/ui/sonarr_ui/system/mod.rs @@ -0,0 +1,232 @@ +use std::ops::Sub; + +use chrono::Utc; +use ratatui::layout::Layout; +use ratatui::style::Style; +use ratatui::text::{Span, Text}; +use ratatui::widgets::{Cell, Paragraph, Row}; +use ratatui::{ + layout::{Constraint, Rect}, + widgets::ListItem, + Frame, +}; + +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; +use crate::models::servarr_models::QueueEvent; +use crate::models::sonarr_models::SonarrTask; +use crate::ui::sonarr_ui::system::system_details_ui::SystemDetailsUi; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{ + convert_to_minutes_hours_days, layout_block_top_border, style_log_list_item, +}; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::selectable_list::SelectableList; +use crate::{ + models::Route, + ui::{utils::title_block, DrawUi}, +}; + +mod system_details_ui; + +#[cfg(test)] +#[path = "system_ui_tests.rs"] +mod system_ui_tests; + +pub(super) const TASK_TABLE_HEADERS: [&str; 4] = + ["Name", "Interval", "Last Execution", "Next Execution"]; + +pub(super) const TASK_TABLE_CONSTRAINTS: [Constraint; 4] = [ + Constraint::Percentage(30), + Constraint::Percentage(23), + Constraint::Percentage(23), + Constraint::Percentage(23), +]; + +pub(super) struct SystemUi; + +impl DrawUi for SystemUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return SystemDetailsUi::accepts(route) || active_sonarr_block == ActiveSonarrBlock::System; + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let route = app.get_current_route(); + + match route { + _ if SystemDetailsUi::accepts(route) => SystemDetailsUi::draw(f, app, area), + _ if matches!(route, Route::Sonarr(ActiveSonarrBlock::System, _)) => { + draw_system_ui_layout(f, app, area) + } + _ => (), + } + } +} + +pub(super) fn draw_system_ui_layout(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let [activities_area, logs_area, help_area] = Layout::vertical([ + Constraint::Ratio(1, 2), + Constraint::Ratio(1, 2), + Constraint::Min(2), + ]) + .areas(area); + + let [tasks_area, events_area] = + Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(activities_area); + + draw_tasks(f, app, tasks_area); + draw_queued_events(f, app, events_area); + draw_logs(f, app, logs_area); + draw_help(f, app, help_area); +} + +fn draw_tasks(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let tasks_row_mapping = |task: &SonarrTask| { + let task_props = extract_task_props(task); + + Row::new(vec![ + Cell::from(task_props.name), + Cell::from(task_props.interval), + Cell::from(task_props.last_execution), + Cell::from(task_props.next_execution), + ]) + .primary() + }; + let tasks_table = ManagarrTable::new(Some(&mut app.data.sonarr_data.tasks), tasks_row_mapping) + .block(title_block("Tasks")) + .loading(app.is_loading) + .highlight_rows(false) + .headers(TASK_TABLE_HEADERS) + .constraints(TASK_TABLE_CONSTRAINTS); + + f.render_widget(tasks_table, area); +} + +pub(super) fn draw_queued_events(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let events_row_mapping = |event: &QueueEvent| { + let queued = convert_to_minutes_hours_days(Utc::now().sub(event.queued).num_minutes()); + let queued_string = if queued != "now" { + format!("{queued} ago") + } else { + queued + }; + let started_string = if event.started.is_some() { + let started = + convert_to_minutes_hours_days(Utc::now().sub(event.started.unwrap()).num_minutes()); + + if started != "now" { + format!("{started} ago") + } else { + started + } + } else { + String::new() + }; + + let duration = if event.duration.is_some() { + &event.duration.as_ref().unwrap()[..8] + } else { + "" + }; + + Row::new(vec![ + Cell::from(event.trigger.clone()), + Cell::from(event.status.clone()), + Cell::from(event.command_name.clone()), + Cell::from(queued_string), + Cell::from(started_string), + Cell::from(duration.to_owned()), + ]) + .primary() + }; + let events_table = ManagarrTable::new( + Some(&mut app.data.sonarr_data.queued_events), + events_row_mapping, + ) + .block(title_block("Queued Events")) + .loading(app.is_loading) + .highlight_rows(false) + .headers(["Trigger", "Status", "Name", "Queued", "Started", "Duration"]) + .constraints([ + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(30), + Constraint::Percentage(16), + Constraint::Percentage(14), + Constraint::Percentage(14), + ]); + + f.render_widget(events_table, area); +} + +fn draw_logs(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let block = title_block("Logs"); + + if app.data.sonarr_data.logs.items.is_empty() { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + return; + } + + let logs_box = SelectableList::new(&mut app.data.sonarr_data.logs, |log| { + let log_line = log.to_string(); + let level = log_line.split('|').collect::>()[1].to_string(); + + style_log_list_item(ListItem::new(Text::from(Span::raw(log_line))), level) + }) + .block(block) + .highlight_style(Style::new().default()); + + f.render_widget(logs_box, area); +} + +fn draw_help(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let help_text = Text::from( + format!( + " {}", + app + .data + .sonarr_data + .main_tabs + .get_active_tab_contextual_help() + .unwrap() + ) + .help(), + ); + let help_paragraph = Paragraph::new(help_text) + .block(layout_block_top_border()) + .left_aligned(); + + f.render_widget(help_paragraph, area); +} + +pub(super) struct TaskProps { + pub(super) name: String, + pub(super) interval: String, + pub(super) last_execution: String, + pub(super) next_execution: String, +} + +pub(super) fn extract_task_props(task: &SonarrTask) -> TaskProps { + let interval = convert_to_minutes_hours_days(task.interval); + let next_execution = + convert_to_minutes_hours_days((task.next_execution - Utc::now()).num_minutes()); + let last_execution = + convert_to_minutes_hours_days((Utc::now() - task.last_execution).num_minutes()); + let last_execution_string = if last_execution != "now" { + format!("{last_execution} ago") + } else { + last_execution + }; + + TaskProps { + name: task.name.clone(), + interval, + last_execution: last_execution_string, + next_execution, + } +} diff --git a/src/ui/sonarr_ui/system/system_details_ui.rs b/src/ui/sonarr_ui/system/system_details_ui.rs new file mode 100644 index 0000000..7aa79a2 --- /dev/null +++ b/src/ui/sonarr_ui/system/system_details_ui.rs @@ -0,0 +1,180 @@ +use ratatui::layout::{Alignment, Rect}; +use ratatui::text::{Span, Text}; +use ratatui::widgets::{Cell, ListItem, Paragraph, Row}; +use ratatui::Frame; + +use crate::app::context_clues::{build_context_clue_string, BARE_POPUP_CONTEXT_CLUES}; +use crate::app::sonarr::sonarr_context_clues::SYSTEM_TASKS_CONTEXT_CLUES; +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SYSTEM_DETAILS_BLOCKS}; +use crate::models::sonarr_models::SonarrTask; +use crate::models::Route; +use crate::ui::sonarr_ui::system::{ + draw_queued_events, draw_system_ui_layout, extract_task_props, TASK_TABLE_CONSTRAINTS, + TASK_TABLE_HEADERS, +}; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{borderless_block, style_log_list_item, title_block}; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::widgets::selectable_list::SelectableList; +use crate::ui::{draw_popup_over, DrawUi}; + +#[cfg(test)] +#[path = "system_details_ui_tests.rs"] +mod system_details_ui_tests; + +pub(super) struct SystemDetailsUi; + +impl DrawUi for SystemDetailsUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return SYSTEM_DETAILS_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + match active_sonarr_block { + ActiveSonarrBlock::SystemLogs => { + draw_system_ui_layout(f, app, area); + draw_logs_popup(f, app); + } + ActiveSonarrBlock::SystemTasks | ActiveSonarrBlock::SystemTaskStartConfirmPrompt => { + draw_popup_over( + f, + app, + area, + draw_system_ui_layout, + draw_tasks_popup, + Size::Large, + ) + } + ActiveSonarrBlock::SystemQueuedEvents => draw_popup_over( + f, + app, + area, + draw_system_ui_layout, + draw_queued_events, + Size::Medium, + ), + ActiveSonarrBlock::SystemUpdates => { + draw_system_ui_layout(f, app, area); + draw_updates_popup(f, app); + } + _ => (), + } + } + } +} + +fn draw_logs_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let block = title_block("Log Details"); + let help_footer = format!( + "<↑↓←→> scroll | {}", + build_context_clue_string(&BARE_POPUP_CONTEXT_CLUES) + ); + + if app.data.sonarr_data.log_details.items.is_empty() { + let loading = LoadingBlock::new(app.is_loading, borderless_block()); + let popup = Popup::new(loading) + .size(Size::Large) + .block(block) + .footer(&help_footer); + + f.render_widget(popup, f.area()); + return; + } + + let logs_list = SelectableList::new(&mut app.data.sonarr_data.log_details, |log| { + let log_line = log.to_string(); + let level = log.text.split('|').collect::>()[1].to_string(); + + style_log_list_item(ListItem::new(Text::from(Span::raw(log_line))), level) + }) + .block(borderless_block()); + let popup = Popup::new(logs_list) + .size(Size::Large) + .block(block) + .footer(&help_footer); + + f.render_widget(popup, f.area()); +} + +fn draw_tasks_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let help_footer = Some(build_context_clue_string(&SYSTEM_TASKS_CONTEXT_CLUES)); + let tasks_row_mapping = |task: &SonarrTask| { + let task_props = extract_task_props(task); + + Row::new(vec![ + Cell::from(task_props.name), + Cell::from(task_props.interval), + Cell::from(task_props.last_execution), + Cell::from(task_props.next_execution), + ]) + .primary() + }; + let tasks_table = ManagarrTable::new(Some(&mut app.data.sonarr_data.tasks), tasks_row_mapping) + .block(borderless_block()) + .loading(app.is_loading) + .margin(1) + .footer(help_footer) + .footer_alignment(Alignment::Center) + .headers(TASK_TABLE_HEADERS) + .constraints(TASK_TABLE_CONSTRAINTS); + + f.render_widget(title_block("Tasks"), area); + f.render_widget(tasks_table, area); + + if matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::SystemTaskStartConfirmPrompt, _) + ) { + let prompt = format!( + "Do you want to manually start this task: {}?", + app.data.sonarr_data.tasks.current_selection().name + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Start Task") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } +} + +fn draw_updates_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let help_footer = format!( + "<↑↓> scroll | {}", + build_context_clue_string(&BARE_POPUP_CONTEXT_CLUES) + ); + let updates = app.data.sonarr_data.updates.get_text(); + let block = title_block("Updates"); + + if !updates.is_empty() { + let updates_paragraph = Paragraph::new(Text::from(updates)) + .block(borderless_block()) + .scroll((app.data.sonarr_data.updates.offset, 0)); + let popup = Popup::new(updates_paragraph) + .size(Size::Large) + .block(block) + .footer(&help_footer); + + f.render_widget(popup, f.area()); + } else { + let loading = LoadingBlock::new(app.is_loading, borderless_block()); + let popup = Popup::new(loading) + .size(Size::Large) + .block(block) + .footer(&help_footer); + + f.render_widget(popup, f.area()); + } +} diff --git a/src/ui/sonarr_ui/system/system_details_ui_tests.rs b/src/ui/sonarr_ui/system/system_details_ui_tests.rs new file mode 100644 index 0000000..80dc239 --- /dev/null +++ b/src/ui/sonarr_ui/system/system_details_ui_tests.rs @@ -0,0 +1,21 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, SYSTEM_DETAILS_BLOCKS, + }; + use crate::ui::sonarr_ui::system::system_details_ui::SystemDetailsUi; + use crate::ui::DrawUi; + + #[test] + fn test_system_details_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if SYSTEM_DETAILS_BLOCKS.contains(&active_sonarr_block) { + assert!(SystemDetailsUi::accepts(active_sonarr_block.into())); + } else { + assert!(!SystemDetailsUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/system/system_ui_tests.rs b/src/ui/sonarr_ui/system/system_ui_tests.rs new file mode 100644 index 0000000..dc43bdc --- /dev/null +++ b/src/ui/sonarr_ui/system/system_ui_tests.rs @@ -0,0 +1,25 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, SYSTEM_DETAILS_BLOCKS, + }; + use crate::ui::sonarr_ui::system::SystemUi; + use crate::ui::DrawUi; + + #[test] + fn test_system_ui_accepts() { + let mut system_ui_blocks = Vec::new(); + system_ui_blocks.push(ActiveSonarrBlock::System); + system_ui_blocks.extend(SYSTEM_DETAILS_BLOCKS); + + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if system_ui_blocks.contains(&active_sonarr_block) { + assert!(SystemUi::accepts(active_sonarr_block.into())); + } else { + assert!(!SystemUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/utils.rs b/src/ui/utils.rs index bf1e24b..3b206db 100644 --- a/src/ui/utils.rs +++ b/src/ui/utils.rs @@ -3,7 +3,7 @@ use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Style, Stylize}; use ratatui::symbols; use ratatui::text::{Line, Span, Text}; -use ratatui::widgets::{Block, BorderType, Borders, LineGauge, Paragraph, Wrap}; +use ratatui::widgets::{Block, BorderType, Borders, LineGauge, ListItem, Paragraph, Wrap}; pub const COLOR_TEAL: Color = Color::Rgb(35, 50, 55); @@ -116,3 +116,41 @@ pub fn line_gauge_with_label(title: &str, ratio: f64) -> LineGauge<'_> { pub fn get_width_from_percentage(area: Rect, percentage: u16) -> usize { (area.width as f64 * (percentage as f64 / 100.0)) as usize } + +pub(super) fn style_log_list_item(list_item: ListItem<'_>, level: String) -> ListItem<'_> { + match level.to_lowercase().as_str() { + "trace" => list_item.gray(), + "debug" => list_item.blue(), + "info" => list_item.style(Style::new().default()), + "warn" => list_item.style(Style::new().secondary()), + "error" => list_item.style(Style::new().failure()), + "fatal" => list_item.style(Style::new().failure().bold()), + _ => list_item.style(Style::new().default()), + } +} + +pub(super) fn convert_to_minutes_hours_days(time: i64) -> String { + if time < 60 { + if time == 0 { + "now".to_owned() + } else if time == 1 { + format!("{time} minute") + } else { + format!("{time} minutes") + } + } else if time / 60 < 24 { + let hours = time / 60; + if hours == 1 { + format!("{hours} hour") + } else { + format!("{hours} hours") + } + } else { + let days = time / (60 * 24); + if days == 1 { + format!("{days} day") + } else { + format!("{days} days") + } + } +} diff --git a/src/ui/utils_tests.rs b/src/ui/utils_tests.rs index 888e6bd..47ab277 100644 --- a/src/ui/utils_tests.rs +++ b/src/ui/utils_tests.rs @@ -1,16 +1,16 @@ #[cfg(test)] mod test { - use pretty_assertions::assert_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use ratatui::layout::{Alignment, Rect}; - use ratatui::style::{Color, Modifier, Style}; - use ratatui::text::Span; - use ratatui::widgets::{Block, BorderType, Borders}; + use ratatui::style::{Color, Modifier, Style, Stylize}; + use ratatui::text::{Span, Text}; + use ratatui::widgets::{Block, BorderType, Borders, ListItem}; use crate::ui::utils::{ - borderless_block, centered_rect, get_width_from_percentage, layout_block, - layout_block_bottom_border, layout_block_top_border, layout_block_top_border_with_title, - layout_block_with_title, logo_block, style_block_highlight, title_block, title_block_centered, - title_style, + borderless_block, centered_rect, convert_to_minutes_hours_days, get_width_from_percentage, + layout_block, layout_block_bottom_border, layout_block_top_border, + layout_block_top_border_with_title, layout_block_with_title, logo_block, style_block_highlight, + style_log_list_item, title_block, title_block_centered, title_style, }; #[test] @@ -174,6 +174,70 @@ mod test { ); } + #[test] + fn test_determine_log_style_by_level() { + use crate::ui::styles::ManagarrStyle; + let list_item = ListItem::new(Text::from(Span::raw("test"))); + + assert_eq!( + style_log_list_item(list_item.clone(), "trace".to_string()), + list_item.clone().gray() + ); + assert_eq!( + style_log_list_item(list_item.clone(), "debug".to_string()), + list_item.clone().blue() + ); + assert_eq!( + style_log_list_item(list_item.clone(), "info".to_string()), + list_item.clone().style(Style::new().default()) + ); + assert_eq!( + style_log_list_item(list_item.clone(), "warn".to_string()), + list_item.clone().style(Style::new().secondary()) + ); + assert_eq!( + style_log_list_item(list_item.clone(), "error".to_string()), + list_item.clone().style(Style::new().failure()) + ); + assert_eq!( + style_log_list_item(list_item.clone(), "fatal".to_string()), + list_item.clone().style(Style::new().failure().bold()) + ); + assert_eq!( + style_log_list_item(list_item.clone(), "".to_string()), + list_item.style(Style::new().default()) + ); + } + + #[test] + fn test_determine_log_style_by_level_case_insensitive() { + let list_item = ListItem::new(Text::from(Span::raw("test"))); + + assert_eq!( + style_log_list_item(list_item.clone(), "TrAcE".to_string()), + list_item.gray() + ); + } + + #[test] + fn test_convert_to_minutes_hours_days_minutes() { + assert_str_eq!(convert_to_minutes_hours_days(0), "now"); + assert_str_eq!(convert_to_minutes_hours_days(1), "1 minute"); + assert_str_eq!(convert_to_minutes_hours_days(2), "2 minutes"); + } + + #[test] + fn test_convert_to_minutes_hours_days_hours() { + assert_str_eq!(convert_to_minutes_hours_days(60), "1 hour"); + assert_str_eq!(convert_to_minutes_hours_days(120), "2 hours"); + } + + #[test] + fn test_convert_to_minutes_hours_days_days() { + assert_str_eq!(convert_to_minutes_hours_days(1440), "1 day"); + assert_str_eq!(convert_to_minutes_hours_days(2880), "2 days"); + } + fn rect() -> Rect { Rect { x: 0, From 678bc77a2319836a35b1626a7c33c25b55357b79 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 5 Dec 2024 11:45:46 -0700 Subject: [PATCH 30/82] fix(ui): Fix the System Details Tasks popup to be navigable in both Sonarr and Radarr --- .../system/system_details_handler.rs | 1 + .../system/system_details_handler_tests.rs | 22 +++++++++++++++++- .../system/system_details_handler.rs | 1 + .../system/system_details_handler_tests.rs | 23 ++++++++++++++++++- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/handlers/radarr_handlers/system/system_details_handler.rs b/src/handlers/radarr_handlers/system/system_details_handler.rs index 38707fb..d6f19dd 100644 --- a/src/handlers/radarr_handlers/system/system_details_handler.rs +++ b/src/handlers/radarr_handlers/system/system_details_handler.rs @@ -44,6 +44,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemDetailsHandler fn is_ready(&self) -> bool { !self.app.is_loading && (!self.app.data.radarr_data.log_details.is_empty() + || !self.app.data.radarr_data.tasks.is_empty() || !self.app.data.radarr_data.updates.is_empty()) } diff --git a/src/handlers/radarr_handlers/system/system_details_handler_tests.rs b/src/handlers/radarr_handlers/system/system_details_handler_tests.rs index 89d1f80..34c97da 100644 --- a/src/handlers/radarr_handlers/system/system_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/system/system_details_handler_tests.rs @@ -944,7 +944,7 @@ mod tests { } #[test] - fn test_system_details_handler_not_ready_when_both_log_details_and_updates_are_empty() { + fn test_system_details_handler_not_ready_when_log_details_and_updates_and_tasks_are_empty() { let mut app = App::default(); app.is_loading = false; @@ -978,6 +978,26 @@ mod tests { assert!(handler.is_ready()); } + #[test] + fn test_system_details_handler_ready_when_not_loading_and_tasks_is_not_empty() { + let mut app = App::default(); + app.is_loading = false; + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); + + let handler = SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveRadarrBlock::SystemTasks, + None, + ); + + assert!(handler.is_ready()); + } + #[test] fn test_system_details_handler_ready_when_not_loading_and_updates_is_not_empty() { let mut app = App::default(); diff --git a/src/handlers/sonarr_handlers/system/system_details_handler.rs b/src/handlers/sonarr_handlers/system/system_details_handler.rs index 2cb2441..9df82b6 100644 --- a/src/handlers/sonarr_handlers/system/system_details_handler.rs +++ b/src/handlers/sonarr_handlers/system/system_details_handler.rs @@ -44,6 +44,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SystemDetailsHandler fn is_ready(&self) -> bool { !self.app.is_loading && (!self.app.data.sonarr_data.log_details.is_empty() + || !self.app.data.sonarr_data.tasks.is_empty() || !self.app.data.sonarr_data.updates.is_empty()) } diff --git a/src/handlers/sonarr_handlers/system/system_details_handler_tests.rs b/src/handlers/sonarr_handlers/system/system_details_handler_tests.rs index a8a908a..94ad27f 100644 --- a/src/handlers/sonarr_handlers/system/system_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/system/system_details_handler_tests.rs @@ -965,7 +965,7 @@ mod tests { } #[test] - fn test_system_details_handler_not_ready_when_both_log_details_and_updates_are_empty() { + fn test_system_details_handler_not_ready_when_log_details_and_updates_and_tasks_are_empty() { let mut app = App::default(); app.push_navigation_stack(ActiveSonarrBlock::System.into()); app.is_loading = false; @@ -1001,6 +1001,27 @@ mod tests { assert!(handler.is_ready()); } + #[test] + fn test_system_details_handler_ready_when_not_loading_and_tasks_is_not_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.is_loading = false; + app + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask::default()]); + + let handler = SystemDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SystemTasks, + None, + ); + + assert!(handler.is_ready()); + } + #[test] fn test_system_details_handler_ready_when_not_loading_and_updates_is_not_empty() { let mut app = App::default(); From 9d0948e124611a22f1641a1ef8e1047e7dbdfbdb Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 5 Dec 2024 12:29:09 -0700 Subject: [PATCH 31/82] refactor(keys): Created a auto search key instead of reusing the existing search key to make things easier --- src/app/key_binding.rs | 5 ++++ src/app/key_binding_tests.rs | 1 + src/app/radarr/radarr_context_clues.rs | 10 +++++-- src/app/radarr/radarr_context_clues_tests.rs | 8 +++--- src/app/sonarr/sonarr_context_clues.rs | 26 +++++++++++-------- src/app/sonarr/sonarr_context_clues_tests.rs | 19 +++++++++----- .../library/movie_details_handler.rs | 2 +- .../library/movie_details_handler_tests.rs | 11 ++++---- 8 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/app/key_binding.rs b/src/app/key_binding.rs index f58a71d..8bda386 100644 --- a/src/app/key_binding.rs +++ b/src/app/key_binding.rs @@ -19,6 +19,7 @@ generate_keybindings! { previous_servarr, clear, search, + auto_search, settings, filter, sort, @@ -82,6 +83,10 @@ pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { key: Key::Char('c'), desc: "clear", }, + auto_search: KeyBinding { + key: Key::Char('S'), + desc: "auto search", + }, search: KeyBinding { key: Key::Char('s'), desc: "search", diff --git a/src/app/key_binding_tests.rs b/src/app/key_binding_tests.rs index 64612ce..2a4ea68 100644 --- a/src/app/key_binding_tests.rs +++ b/src/app/key_binding_tests.rs @@ -16,6 +16,7 @@ mod test { #[case(DEFAULT_KEYBINDINGS.next_servarr, Key::Tab, "next servarr")] #[case(DEFAULT_KEYBINDINGS.previous_servarr, Key::BackTab, "previous servarr")] #[case(DEFAULT_KEYBINDINGS.clear, Key::Char('c'), "clear")] + #[case(DEFAULT_KEYBINDINGS.auto_search, Key::Char('S'), "auto search")] #[case(DEFAULT_KEYBINDINGS.search, Key::Char('s'), "search")] #[case(DEFAULT_KEYBINDINGS.settings, Key::Char('s'), "settings")] #[case(DEFAULT_KEYBINDINGS.filter, Key::Char('f'), "filter")] diff --git a/src/app/radarr/radarr_context_clues.rs b/src/app/radarr/radarr_context_clues.rs index 4f92313..2c37e26 100644 --- a/src/app/radarr/radarr_context_clues.rs +++ b/src/app/radarr/radarr_context_clues.rs @@ -42,7 +42,10 @@ pub static MOVIE_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [ ), (DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc), (DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc), - (DEFAULT_KEYBINDINGS.search, "auto search"), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; @@ -54,7 +57,10 @@ pub static MANUAL_MOVIE_SEARCH_CONTEXT_CLUES: [ContextClue; 6] = [ (DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc), (DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc), (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), - (DEFAULT_KEYBINDINGS.search, "auto search"), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; diff --git a/src/app/radarr/radarr_context_clues_tests.rs b/src/app/radarr/radarr_context_clues_tests.rs index 4cebc74..d8ca11b 100644 --- a/src/app/radarr/radarr_context_clues_tests.rs +++ b/src/app/radarr/radarr_context_clues_tests.rs @@ -133,8 +133,8 @@ mod tests { let (key_binding, description) = movie_details_context_clues_iter.next().unwrap(); - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); - assert_str_eq!(*description, "auto search"); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.auto_search); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.auto_search.desc); let (key_binding, description) = movie_details_context_clues_iter.next().unwrap(); @@ -169,8 +169,8 @@ mod tests { let (key_binding, description) = manual_movie_search_context_clues_iter.next().unwrap(); - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); - assert_str_eq!(*description, "auto search"); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.auto_search); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.auto_search.desc); let (key_binding, description) = manual_movie_search_context_clues_iter.next().unwrap(); diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index f9c64a0..2db8ff7 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -25,6 +25,21 @@ pub static SERIES_CONTEXT_CLUES: [ContextClue; 10] = [ (DEFAULT_KEYBINDINGS.esc, "cancel filter"), ]; +pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 6] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.submit, "season details"), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + (DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + pub static HISTORY_CONTEXT_CLUES: [ContextClue; 5] = [ (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), @@ -36,17 +51,6 @@ pub static HISTORY_CONTEXT_CLUES: [ContextClue; 5] = [ (DEFAULT_KEYBINDINGS.esc, "cancel filter"), ]; -pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [ - ( - DEFAULT_KEYBINDINGS.refresh, - DEFAULT_KEYBINDINGS.refresh.desc, - ), - (DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc), - (DEFAULT_KEYBINDINGS.submit, "details"), - (DEFAULT_KEYBINDINGS.search, "auto search"), - (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), -]; - pub static SEASON_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [ ( DEFAULT_KEYBINDINGS.refresh, diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index 2c8b485..0ad23ef 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -128,18 +128,23 @@ mod tests { let (key_binding, description) = series_details_context_clues_iter.next().unwrap(); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); + assert_str_eq!(*description, "season details"); + + let (key_binding, description) = series_details_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.search.desc); + + let (key_binding, description) = series_details_context_clues_iter.next().unwrap(); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update); assert_str_eq!(*description, DEFAULT_KEYBINDINGS.update.desc); let (key_binding, description) = series_details_context_clues_iter.next().unwrap(); - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); - assert_str_eq!(*description, "details"); - - let (key_binding, description) = series_details_context_clues_iter.next().unwrap(); - - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); - assert_str_eq!(*description, "auto search"); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.auto_search); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.auto_search.desc); let (key_binding, description) = series_details_context_clues_iter.next().unwrap(); diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index fc81c2f..ef9e890 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -432,7 +432,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< | ActiveRadarrBlock::Cast | ActiveRadarrBlock::Crew | ActiveRadarrBlock::ManualSearch => match self.key { - _ if key == DEFAULT_KEYBINDINGS.search.key => { + _ if key == DEFAULT_KEYBINDINGS.auto_search.key => { self .app .push_navigation_stack(ActiveRadarrBlock::AutomaticallySearchMoviePrompt.into()); diff --git a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs index 7f84f11..290ef67 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs @@ -1455,6 +1455,7 @@ mod tests { use strum::IntoEnumIterator; use crate::handlers::radarr_handlers::library::movie_details_handler::releases_sorting_options; + use crate::models::radarr_models::RadarrRelease; use crate::models::radarr_models::{MinimumAvailability, Movie}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils::create_test_radarr_data; @@ -1467,7 +1468,7 @@ mod tests { use super::*; #[rstest] - fn test_search_key( + fn test_auto_search_key( #[values( ActiveRadarrBlock::MovieDetails, ActiveRadarrBlock::MovieHistory, @@ -1478,8 +1479,6 @@ mod tests { )] active_radarr_block: ActiveRadarrBlock, ) { - use crate::models::radarr_models::RadarrRelease; - let mut app = App::default(); let mut modal = MovieDetailsModal { movie_details: ScrollableText::with_string("Test".to_owned()), @@ -1496,7 +1495,7 @@ mod tests { app.data.radarr_data.movie_details_modal = Some(modal); MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.search.key, + DEFAULT_KEYBINDINGS.auto_search.key, &mut app, active_radarr_block, None, @@ -1510,7 +1509,7 @@ mod tests { } #[rstest] - fn test_search_key_no_op_when_not_ready( + fn test_auto_search_key_no_op_when_not_ready( #[values( ActiveRadarrBlock::MovieDetails, ActiveRadarrBlock::MovieHistory, @@ -1530,7 +1529,7 @@ mod tests { }); MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.search.key, + DEFAULT_KEYBINDINGS.auto_search.key, &mut app, active_radarr_block, None, From 5abed23cf29e3947468786cb90a1e299a6239f98 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 5 Dec 2024 19:07:03 -0700 Subject: [PATCH 32/82] refactor(ui): all table search and filter functionality is now available directly through the ManagarrTable widget to make life easier moving forward --- Cargo.toml | 5 +- src/ui/mod.rs | 7 +- src/ui/radarr_ui/collections/mod.rs | 78 ++--- src/ui/radarr_ui/library/mod.rs | 70 ++--- src/ui/sonarr_ui/history/mod.rs | 265 +++-------------- src/ui/sonarr_ui/library/library_ui_tests.rs | 10 +- src/ui/widgets/input_box.rs | 14 +- src/ui/widgets/input_box_popup.rs | 60 ++++ src/ui/widgets/input_box_popup_tests.rs | 34 +++ src/ui/widgets/managarr_table.rs | 123 +++++++- src/ui/widgets/managarr_table_tests.rs | 283 ++++++++++++++++++- src/ui/widgets/mod.rs | 1 + src/ui/widgets/popup.rs | 4 +- src/ui/widgets/popup_tests.rs | 1 + 14 files changed, 609 insertions(+), 346 deletions(-) create mode 100644 src/ui/widgets/input_box_popup.rs create mode 100644 src/ui/widgets/input_box_popup_tests.rs diff --git a/Cargo.toml b/Cargo.toml index 7886fd5..937f273 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,10 @@ strum = { version = "0.26.3", features = ["derive"] } strum_macros = "0.26.4" tokio = { version = "1.36.0", features = ["full"] } tokio-util = "0.7.8" -ratatui = { version = "0.29.0", features = ["all-widgets"] } +ratatui = { version = "0.29.0", features = [ + "all-widgets", + "unstable-widget-ref", +] } urlencoding = "2.1.2" clap = { version = "4.5.20", features = ["derive", "cargo", "env"] } clap_complete = "4.5.33" diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 61bc4a4..a91ae29 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -9,6 +9,7 @@ use ratatui::widgets::Tabs; use ratatui::widgets::Wrap; use ratatui::Frame; use sonarr_ui::SonarrUi; +use utils::layout_block; use crate::app::App; use crate::models::{HorizontallyScrollableText, Route, TabState}; @@ -161,7 +162,11 @@ pub fn draw_popup_over_ui( } fn draw_tabs(f: &mut Frame<'_>, area: Rect, title: &str, tab_state: &TabState) -> Rect { - f.render_widget(title_block(title), area); + if title.is_empty() { + f.render_widget(layout_block(), area); + } else { + f.render_widget(title_block(title), area); + } let [header_area, content_area] = Layout::vertical([Constraint::Length(1), Constraint::Fill(0)]) .margin(1) diff --git a/src/ui/radarr_ui/collections/mod.rs b/src/ui/radarr_ui/collections/mod.rs index 8b033a3..f86b51f 100644 --- a/src/ui/radarr_ui/collections/mod.rs +++ b/src/ui/radarr_ui/collections/mod.rs @@ -14,9 +14,8 @@ use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::managarr_table::ManagarrTable; -use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; -use crate::ui::{draw_input_box_popup, draw_popup_over, DrawUi}; +use crate::ui::DrawUi; mod collection_details_ui; #[cfg(test)] @@ -40,40 +39,12 @@ impl DrawUi for CollectionsUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let route = app.get_current_route(); let mut collections_ui_matcher = |active_radarr_block| match active_radarr_block { - ActiveRadarrBlock::Collections | ActiveRadarrBlock::CollectionsSortPrompt => { - draw_collections(f, app, area) - } - ActiveRadarrBlock::SearchCollection => draw_popup_over( - f, - app, - area, - draw_collections, - draw_collection_search_box, - Size::InputBox, - ), - ActiveRadarrBlock::SearchCollectionError => { - let popup = Popup::new(Message::new("Collection not found!")).size(Size::Message); - - draw_collections(f, app, area); - f.render_widget(popup, f.area()); - } - ActiveRadarrBlock::FilterCollections => draw_popup_over( - f, - app, - area, - draw_collections, - draw_filter_collections_box, - Size::InputBox, - ), - ActiveRadarrBlock::FilterCollectionsError => { - let popup = Popup::new(Message::new( - "No collections found matching the given filter!", - )) - .size(Size::Message); - - draw_collections(f, app, area); - f.render_widget(popup, f.area()); - } + ActiveRadarrBlock::Collections + | ActiveRadarrBlock::CollectionsSortPrompt + | ActiveRadarrBlock::SearchCollection + | ActiveRadarrBlock::SearchCollectionError + | ActiveRadarrBlock::FilterCollections + | ActiveRadarrBlock::FilterCollectionsError => draw_collections(f, app, area), ActiveRadarrBlock::UpdateAllCollectionsPrompt => { let confirmation_prompt = ConfirmationPrompt::new() .title("Update All Collections") @@ -156,6 +127,14 @@ pub(super) fn draw_collections(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) .footer(collections_table_footer) .block(layout_block_top_border()) .sorting(active_radarr_block == ActiveRadarrBlock::CollectionsSortPrompt) + .searching(active_radarr_block == ActiveRadarrBlock::SearchCollection) + .search_produced_empty_results( + active_radarr_block == ActiveRadarrBlock::SearchCollectionError, + ) + .filtering(active_radarr_block == ActiveRadarrBlock::FilterCollections) + .filter_produced_empty_results( + active_radarr_block == ActiveRadarrBlock::FilterCollectionsError, + ) .headers([ "Collection", "Number of Movies", @@ -173,24 +152,15 @@ pub(super) fn draw_collections(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) Constraint::Percentage(15), ]); + if [ + ActiveRadarrBlock::SearchCollection, + ActiveRadarrBlock::FilterCollections, + ] + .contains(&active_radarr_block) + { + collections_table.show_cursor(f, area); + } + f.render_widget(collections_table, area); } } - -fn draw_collection_search_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_input_box_popup( - f, - area, - "Search", - app.data.radarr_data.collections.search.as_ref().unwrap(), - ); -} - -fn draw_filter_collections_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_input_box_popup( - f, - area, - "Filter", - app.data.radarr_data.collections.filter.as_ref().unwrap(), - ) -} diff --git a/src/ui/radarr_ui/library/mod.rs b/src/ui/radarr_ui/library/mod.rs index a343b44..760f31b 100644 --- a/src/ui/radarr_ui/library/mod.rs +++ b/src/ui/radarr_ui/library/mod.rs @@ -14,9 +14,8 @@ use crate::ui::radarr_ui::library::movie_details_ui::MovieDetailsUi; use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::managarr_table::ManagarrTable; -use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; -use crate::ui::{draw_input_box_popup, draw_popup_over, DrawUi}; +use crate::ui::DrawUi; use crate::utils::{convert_runtime, convert_to_gb}; mod add_movie_ui; @@ -47,36 +46,12 @@ impl DrawUi for LibraryUi { let route = app.get_current_route(); let mut library_ui_matchers = |active_radarr_block: ActiveRadarrBlock| match active_radarr_block { - ActiveRadarrBlock::Movies | ActiveRadarrBlock::MoviesSortPrompt => draw_library(f, app, area), - ActiveRadarrBlock::SearchMovie => draw_popup_over( - f, - app, - area, - draw_library, - draw_movie_search_box, - Size::InputBox, - ), - ActiveRadarrBlock::SearchMovieError => { - let popup = Popup::new(Message::new("Movie not found!")).size(Size::Message); - - draw_library(f, app, area); - f.render_widget(popup, f.area()); - } - ActiveRadarrBlock::FilterMovies => draw_popup_over( - f, - app, - area, - draw_library, - draw_filter_movies_box, - Size::InputBox, - ), - ActiveRadarrBlock::FilterMoviesError => { - let popup = Popup::new(Message::new("No movies found matching the given filter!")) - .size(Size::Message); - - draw_library(f, app, area); - f.render_widget(popup, f.area()); - } + ActiveRadarrBlock::Movies + | ActiveRadarrBlock::MoviesSortPrompt + | ActiveRadarrBlock::SearchMovie + | ActiveRadarrBlock::SearchMovieError + | ActiveRadarrBlock::FilterMovies + | ActiveRadarrBlock::FilterMoviesError => draw_library(f, app, area), ActiveRadarrBlock::UpdateAllMoviesPrompt => { let confirmation_prompt = ConfirmationPrompt::new() .title("Update All Movies") @@ -174,6 +149,10 @@ pub(super) fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .loading(app.is_loading) .footer(help_footer) .sorting(active_radarr_block == ActiveRadarrBlock::MoviesSortPrompt) + .searching(active_radarr_block == ActiveRadarrBlock::SearchMovie) + .search_produced_empty_results(active_radarr_block == ActiveRadarrBlock::SearchMovieError) + .filtering(active_radarr_block == ActiveRadarrBlock::FilterMovies) + .filter_produced_empty_results(active_radarr_block == ActiveRadarrBlock::FilterMoviesError) .headers([ "Title", "Year", @@ -199,24 +178,15 @@ pub(super) fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Constraint::Percentage(12), ]); + if [ + ActiveRadarrBlock::SearchMovie, + ActiveRadarrBlock::FilterMovies, + ] + .contains(&active_radarr_block) + { + library_table.show_cursor(f, area); + } + f.render_widget(library_table, area); } } - -fn draw_movie_search_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_input_box_popup( - f, - area, - "Search", - app.data.radarr_data.movies.search.as_ref().unwrap(), - ); -} - -fn draw_filter_movies_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_input_box_popup( - f, - area, - "Filter", - app.data.radarr_data.movies.filter.as_ref().unwrap(), - ) -} diff --git a/src/ui/sonarr_ui/history/mod.rs b/src/ui/sonarr_ui/history/mod.rs index cec5580..70fe8a2 100644 --- a/src/ui/sonarr_ui/history/mod.rs +++ b/src/ui/sonarr_ui/history/mod.rs @@ -1,19 +1,27 @@ use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS}; -use crate::models::sonarr_models::{SonarrHistoryData, SonarrHistoryEventType, SonarrHistoryItem}; +use crate::models::sonarr_models::{SonarrHistoryEventType, SonarrHistoryItem}; use crate::models::Route; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; -use crate::ui::{draw_input_box_popup, draw_popup_over, DrawUi}; +use crate::ui::DrawUi; use ratatui::layout::{Alignment, Constraint, Rect}; -use ratatui::style::{Style, Stylize}; -use ratatui::text::{Line, Text}; +use ratatui::style::Style; +use ratatui::text::Text; use ratatui::widgets::{Cell, Row}; use ratatui::Frame; +use super::sonarr_ui_utils::{ + create_download_failed_history_event_details, + create_download_folder_imported_history_event_details, + create_episode_file_deleted_history_event_details, + create_episode_file_renamed_history_event_details, create_grabbed_history_event_details, + create_no_data_history_event_details, +}; + #[cfg(test)] #[path = "history_ui_tests.rs"] mod history_ui_tests; @@ -32,40 +40,12 @@ impl DrawUi for HistoryUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { match active_sonarr_block { - ActiveSonarrBlock::History | ActiveSonarrBlock::HistorySortPrompt => { - draw_history_table(f, app, area) - } - ActiveSonarrBlock::SearchHistory => draw_popup_over( - f, - app, - area, - draw_history_table, - draw_history_search_box, - Size::InputBox, - ), - ActiveSonarrBlock::SearchHistoryError => { - let popup = Popup::new(Message::new("History item not found!")).size(Size::Message); - - draw_history_table(f, app, area); - f.render_widget(popup, f.area()); - } - ActiveSonarrBlock::FilterHistory => draw_popup_over( - f, - app, - area, - draw_history_table, - draw_filter_history_box, - Size::InputBox, - ), - ActiveSonarrBlock::FilterHistoryError => { - let popup = Popup::new(Message::new( - "No history items found matching the given filter!", - )) - .size(Size::Message); - - draw_history_table(f, app, area); - f.render_widget(popup, f.area()); - } + ActiveSonarrBlock::History + | ActiveSonarrBlock::HistorySortPrompt + | ActiveSonarrBlock::SearchHistory + | ActiveSonarrBlock::SearchHistoryError + | ActiveSonarrBlock::FilterHistory + | ActiveSonarrBlock::FilterHistoryError => draw_history_table(f, app, area), ActiveSonarrBlock::HistoryItemDetails => { draw_history_table(f, app, area); draw_history_item_details_popup(f, app); @@ -120,6 +100,10 @@ fn draw_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .loading(app.is_loading) .footer(history_table_footer) .sorting(active_sonarr_block == ActiveSonarrBlock::HistorySortPrompt) + .searching(active_sonarr_block == ActiveSonarrBlock::SearchHistory) + .search_produced_empty_results(active_sonarr_block == ActiveSonarrBlock::SearchHistoryError) + .filtering(active_sonarr_block == ActiveSonarrBlock::FilterHistory) + .filter_produced_empty_results(active_sonarr_block == ActiveSonarrBlock::FilterHistoryError) .headers(["Source Title", "Event Type", "Language", "Quality", "Date"]) .constraints([ Constraint::Percentage(40), @@ -129,6 +113,15 @@ fn draw_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Constraint::Percentage(20), ]); + if [ + ActiveSonarrBlock::SearchHistory, + ActiveSonarrBlock::FilterHistory, + ] + .contains(&active_sonarr_block) + { + history_table.show_cursor(f, area); + } + f.render_widget(history_table, area); } } @@ -141,18 +134,20 @@ fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { }; let line_vec = match current_selection.event_type { - SonarrHistoryEventType::Unknown => create_unknown_event_vec(current_selection), + SonarrHistoryEventType::Grabbed => create_grabbed_history_event_details(current_selection), SonarrHistoryEventType::DownloadFolderImported => { - create_download_folder_imported_event_vec(current_selection) + create_download_folder_imported_history_event_details(current_selection) + } + SonarrHistoryEventType::DownloadFailed => { + create_download_failed_history_event_details(current_selection) } - SonarrHistoryEventType::DownloadFailed => create_download_failed_event_vec(current_selection), SonarrHistoryEventType::EpisodeFileDeleted => { - create_episode_file_deleted_event_vec(current_selection) + create_episode_file_deleted_history_event_details(current_selection) } SonarrHistoryEventType::EpisodeFileRenamed => { - create_episode_file_renamed_event_vec(current_selection) + create_episode_file_renamed_history_event_details(current_selection) } - _ => create_no_data_event_vec(current_selection), + _ => create_no_data_history_event_details(current_selection), }; let text = Text::from(line_vec); @@ -163,185 +158,3 @@ fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { f.render_widget(Popup::new(message).size(Size::NarrowMessage), f.area()); } - -fn create_unknown_event_vec(history_item: SonarrHistoryItem) -> Vec> { - let SonarrHistoryItem { - source_title, data, .. - } = history_item; - let SonarrHistoryData { - indexer, - release_group, - series_match_type, - nzb_info_url, - download_client_name, - age, - published_date, - .. - } = data; - - vec![ - Line::from(vec![ - "Source Title: ".bold().secondary(), - source_title.text.secondary(), - ]), - Line::from(vec![ - "Indexer: ".bold().secondary(), - indexer.unwrap_or_default().secondary(), - ]), - Line::from(vec![ - "Release Group: ".bold().secondary(), - release_group.unwrap_or_default().secondary(), - ]), - Line::from(vec![ - "Series Match Type: ".bold().secondary(), - series_match_type.unwrap_or_default().secondary(), - ]), - Line::from(vec![ - "NZB Info URL: ".bold().secondary(), - nzb_info_url.unwrap_or_default().secondary(), - ]), - Line::from(vec![ - "Download Client Name: ".bold().secondary(), - download_client_name.unwrap_or_default().secondary(), - ]), - Line::from(vec![ - "Age: ".bold().secondary(), - format!("{} days", age.unwrap_or("0".to_owned())).secondary(), - ]), - Line::from(vec![ - "Published Date: ".bold().secondary(), - published_date.unwrap_or_default().to_string().secondary(), - ]), - ] -} - -fn create_download_folder_imported_event_vec( - history_item: SonarrHistoryItem, -) -> Vec> { - let SonarrHistoryItem { - source_title, data, .. - } = history_item; - let SonarrHistoryData { - dropped_path, - imported_path, - .. - } = data; - - vec![ - Line::from(vec![ - "Source Title: ".bold().secondary(), - source_title.text.secondary(), - ]), - Line::from(vec![ - "Dropped Path: ".bold().secondary(), - dropped_path.unwrap_or_default().secondary(), - ]), - Line::from(vec![ - "Imported Path: ".bold().secondary(), - imported_path.unwrap_or_default().secondary(), - ]), - ] -} - -fn create_download_failed_event_vec(history_item: SonarrHistoryItem) -> Vec> { - let SonarrHistoryItem { - source_title, data, .. - } = history_item; - let SonarrHistoryData { message, .. } = data; - - vec![ - Line::from(vec![ - "Source Title: ".bold().secondary(), - source_title.text.secondary(), - ]), - Line::from(vec![ - "Message: ".bold().secondary(), - message.unwrap_or_default().secondary(), - ]), - ] -} - -fn create_episode_file_deleted_event_vec(history_item: SonarrHistoryItem) -> Vec> { - let SonarrHistoryItem { - source_title, data, .. - } = history_item; - let SonarrHistoryData { reason, .. } = data; - - vec![ - Line::from(vec![ - "Source Title: ".bold().secondary(), - source_title.text.secondary(), - ]), - Line::from(vec![ - "Reason: ".bold().secondary(), - reason.unwrap_or_default().secondary(), - ]), - ] -} - -fn create_episode_file_renamed_event_vec(history_item: SonarrHistoryItem) -> Vec> { - let SonarrHistoryItem { - source_title, data, .. - } = history_item; - let SonarrHistoryData { - source_path, - source_relative_path, - path, - relative_path, - .. - } = data; - - vec![ - Line::from(vec![ - "Source Title: ".bold().secondary(), - source_title.text.secondary(), - ]), - Line::from(vec![ - "Source Path: ".bold().secondary(), - source_path.unwrap_or_default().secondary(), - ]), - Line::from(vec![ - "Source Relative Path: ".bold().secondary(), - source_relative_path.unwrap_or_default().secondary(), - ]), - Line::from(vec![ - "Destination Path: ".bold().secondary(), - path.unwrap_or_default().secondary(), - ]), - Line::from(vec![ - "Destination Relative Path: ".bold().secondary(), - relative_path.unwrap_or_default().secondary(), - ]), - ] -} - -fn create_no_data_event_vec(history_item: SonarrHistoryItem) -> Vec> { - let SonarrHistoryItem { source_title, .. } = history_item; - - vec![ - Line::from(vec![ - "Source Title: ".bold().secondary(), - source_title.text.secondary(), - ]), - Line::from(vec![String::new().secondary()]), - Line::from(vec!["No additional data available".bold().secondary()]), - ] -} - -fn draw_history_search_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_input_box_popup( - f, - area, - "Search", - app.data.sonarr_data.history.search.as_ref().unwrap(), - ); -} - -fn draw_filter_history_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_input_box_popup( - f, - area, - "Filter", - app.data.sonarr_data.history.filter.as_ref().unwrap(), - ) -} diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs index 417590a..7c0c075 100644 --- a/src/ui/sonarr_ui/library/library_ui_tests.rs +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -2,6 +2,7 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, + SERIES_DETAILS_BLOCKS, }; use crate::models::{ servarr_data::sonarr::sonarr_data::LIBRARY_BLOCKS, sonarr_models::SeriesStatus, @@ -26,12 +27,13 @@ mod tests { library_ui_blocks.extend(ADD_SERIES_BLOCKS); library_ui_blocks.extend(DELETE_SERIES_BLOCKS); library_ui_blocks.extend(EDIT_SERIES_BLOCKS); + library_ui_blocks.extend(SERIES_DETAILS_BLOCKS); - ActiveSonarrBlock::iter().for_each(|active_radarr_block| { - if library_ui_blocks.contains(&active_radarr_block) { - assert!(LibraryUi::accepts(active_radarr_block.into())); + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if library_ui_blocks.contains(&active_sonarr_block) { + assert!(LibraryUi::accepts(active_sonarr_block.into())); } else { - assert!(!LibraryUi::accepts(active_radarr_block.into())); + assert!(!LibraryUi::accepts(active_sonarr_block.into())); } }); } diff --git a/src/ui/widgets/input_box.rs b/src/ui/widgets/input_box.rs index fa0f87c..96577b1 100644 --- a/src/ui/widgets/input_box.rs +++ b/src/ui/widgets/input_box.rs @@ -2,7 +2,7 @@ use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::prelude::Text; use ratatui::style::{Style, Styled, Stylize}; -use ratatui::widgets::{Block, Paragraph, Widget}; +use ratatui::widgets::{Block, Paragraph, Widget, WidgetRef}; use ratatui::Frame; use crate::ui::styles::ManagarrStyle; @@ -12,6 +12,8 @@ use crate::ui::utils::{borderless_block, layout_block}; #[path = "input_box_tests.rs"] mod input_box_tests; +#[derive(Default)] +#[cfg_attr(test, derive(Debug, PartialEq))] pub struct InputBox<'a> { content: &'a str, offset: usize, @@ -96,7 +98,7 @@ impl<'a> InputBox<'a> { } } - fn render_input_box(self, area: Rect, buf: &mut Buffer) { + fn render_input_box(&self, area: Rect, buf: &mut Buffer) { let style = if matches!(self.is_highlighted, Some(true)) && matches!(self.is_selected, Some(false)) { Style::new().system_function().bold() @@ -106,7 +108,7 @@ impl<'a> InputBox<'a> { let input_box_paragraph = Paragraph::new(Text::from(self.content)) .style(style) - .block(self.block); + .block(self.block.clone()); if let Some(label) = self.label { let [label_area, text_box_area] = @@ -133,6 +135,12 @@ impl<'a> Widget for InputBox<'a> { } } +impl<'a> WidgetRef for InputBox<'a> { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + self.render_input_box(area, buf); + } +} + impl<'a> Styled for InputBox<'a> { type Item = InputBox<'a>; diff --git a/src/ui/widgets/input_box_popup.rs b/src/ui/widgets/input_box_popup.rs new file mode 100644 index 0000000..8e92a38 --- /dev/null +++ b/src/ui/widgets/input_box_popup.rs @@ -0,0 +1,60 @@ +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{background_block, borderless_block, centered_rect}; +use ratatui::buffer::Buffer; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::widgets::{Block, Clear, Paragraph, Widget, WidgetRef}; + +use super::input_box::InputBox; + +#[cfg(test)] +#[path = "input_box_popup_tests.rs"] +mod input_box_popup_tests; + +pub struct InputBoxPopup<'a> { + input_box: InputBox<'a>, +} + +impl<'a> InputBoxPopup<'a> { + pub fn new(content: &'a str) -> Self { + Self { + input_box: InputBox::new(content), + } + } + + pub fn block(mut self, block: Block<'a>) -> InputBoxPopup<'a> { + self.input_box = self.input_box.block(block); + self + } + + pub fn offset(mut self, offset: usize) -> InputBoxPopup<'a> { + self.input_box = self.input_box.offset(offset); + self + } + + fn render_popup(&self, area: Rect, buf: &mut Buffer) { + let popup_area = Rect { + height: 6, + ..centered_rect(30, 20, area) + }; + Clear.render(popup_area, buf); + background_block().render(popup_area, buf); + + let [text_box_area, help_area] = + Layout::vertical([Constraint::Length(3), Constraint::Length(1)]) + .margin(1) + .areas(popup_area); + self.input_box.render_ref(text_box_area, buf); + + let help = Paragraph::new(" cancel") + .help() + .centered() + .block(borderless_block()); + help.render(help_area, buf); + } +} + +impl<'a> WidgetRef for InputBoxPopup<'a> { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + self.render_popup(area, buf); + } +} diff --git a/src/ui/widgets/input_box_popup_tests.rs b/src/ui/widgets/input_box_popup_tests.rs new file mode 100644 index 0000000..962a3b9 --- /dev/null +++ b/src/ui/widgets/input_box_popup_tests.rs @@ -0,0 +1,34 @@ +#[cfg(test)] +mod tests { + use crate::ui::utils::layout_block; + use crate::ui::widgets::input_box::InputBox; + use crate::ui::widgets::input_box_popup::InputBoxPopup; + use pretty_assertions::assert_eq; + + #[test] + fn test_input_box_popup_new() { + let expected_input_box = InputBox::new("test"); + + let input_box_popup = InputBoxPopup::new("test"); + + assert_eq!(input_box_popup.input_box, expected_input_box); + } + + #[test] + fn test_input_box_popup_block() { + let expected_input_box = InputBox::new("test").block(layout_block().title("title")); + + let input_box_popup = InputBoxPopup::new("test").block(layout_block().title("title")); + + assert_eq!(input_box_popup.input_box, expected_input_box); + } + + #[test] + fn test_input_box_popup_offset() { + let expected_input_box = InputBox::new("test").offset(5); + + let input_box_popup = InputBoxPopup::new("test").offset(5); + + assert_eq!(input_box_popup.input_box, expected_input_box); + } +} diff --git a/src/ui/widgets/managarr_table.rs b/src/ui/widgets/managarr_table.rs index eb9b5c9..b11df86 100644 --- a/src/ui/widgets/managarr_table.rs +++ b/src/ui/widgets/managarr_table.rs @@ -1,15 +1,21 @@ use crate::models::stateful_table::StatefulTable; use crate::ui::styles::ManagarrStyle; -use crate::ui::utils::layout_block_top_border; +use crate::ui::utils::{centered_rect, layout_block_top_border, title_block_centered}; use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::popup::Popup; use crate::ui::widgets::selectable_list::SelectableList; use crate::ui::HIGHLIGHT_SYMBOL; use ratatui::buffer::Buffer; -use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect}; use ratatui::prelude::{Style, Stylize, Text}; -use ratatui::widgets::{Block, ListItem, Paragraph, Row, StatefulWidget, Table, Widget}; +use ratatui::widgets::{Block, ListItem, Paragraph, Row, StatefulWidget, Table, Widget, WidgetRef}; +use ratatui::Frame; use std::fmt::Debug; +use std::sync::atomic::Ordering; + +use super::input_box_popup::InputBoxPopup; +use super::message::Message; +use super::popup::Size; #[cfg(test)] #[path = "managarr_table_tests.rs"] @@ -31,6 +37,14 @@ where is_loading: bool, highlight_rows: bool, is_sorting: bool, + is_searching: bool, + search_produced_empty_results: bool, + is_filtering: bool, + filter_produced_empty_results: bool, + search_box_content_length: usize, + search_box_offset: usize, + filter_box_content_length: usize, + filter_box_offset: usize, } impl<'a, T, F> ManagarrTable<'a, T, F> @@ -39,8 +53,8 @@ where T: Clone + PartialEq + Eq + Debug, { pub fn new(content: Option<&'a mut StatefulTable>, row_mapper: F) -> Self { - Self { - content, + let mut managarr_table = Self { + content: None, table_headers: Vec::new(), constraints: Vec::new(), row_mapper, @@ -51,7 +65,28 @@ where is_loading: false, highlight_rows: true, is_sorting: false, + is_searching: false, + search_produced_empty_results: false, + is_filtering: false, + filter_produced_empty_results: false, + search_box_content_length: 0, + search_box_offset: 0, + filter_box_content_length: 0, + filter_box_offset: 0, + }; + + if let Some(content) = content.as_ref() { + if let Some(search) = content.search.as_ref() { + managarr_table.search_box_content_length = search.text.len(); + managarr_table.search_box_offset = search.offset.load(Ordering::SeqCst); + } else if let Some(filter) = content.filter.as_ref() { + managarr_table.filter_box_content_length = filter.text.len(); + managarr_table.filter_box_offset = filter.offset.load(Ordering::SeqCst); + } } + + managarr_table.content = content; + managarr_table } pub fn headers(mut self, headers: I) -> Self @@ -107,6 +142,26 @@ where self } + pub fn searching(mut self, is_searching: bool) -> Self { + self.is_searching = is_searching; + self + } + + pub fn search_produced_empty_results(mut self, no_search_results: bool) -> Self { + self.search_produced_empty_results = no_search_results; + self + } + + pub fn filtering(mut self, is_filtering: bool) -> Self { + self.is_filtering = is_filtering; + self + } + + pub fn filter_produced_empty_results(mut self, no_filter_results: bool) -> Self { + self.filter_produced_empty_results = no_filter_results; + self + } + fn render_table(self, area: Rect, buf: &mut Buffer) { let table_headers = self.parse_headers(); let table_area = if let Some(ref footer) = self.footer { @@ -160,6 +215,34 @@ where .dimensions(20, 50) .render(table_area, buf); } + + if self.is_searching { + let box_content = &content.search.as_ref().unwrap(); + InputBoxPopup::new(&box_content.text) + .offset(box_content.offset.load(Ordering::SeqCst)) + .block(title_block_centered("Search")) + .render_ref(table_area, buf); + } + + if self.is_filtering { + let box_content = &content.filter.as_ref().unwrap(); + InputBoxPopup::new(&box_content.text) + .offset(box_content.offset.load(Ordering::SeqCst)) + .block(title_block_centered("Filter")) + .render_ref(table_area, buf); + } + + if self.search_produced_empty_results { + Popup::new(Message::new("No items found matching search")) + .size(Size::Message) + .render(table_area, buf); + } + + if self.filter_produced_empty_results { + Popup::new(Message::new("The given filter produced empty results")) + .size(Size::Message) + .render(table_area, buf); + } } else { loading_block.render(table_area, buf); } @@ -189,6 +272,36 @@ where .map(Text::from) .collect() } + + pub fn show_cursor(&self, f: &mut Frame<'_>, area: Rect) { + let mut draw_cursor = |length: usize, offset: usize| { + let table_area = if self.footer.is_some() { + let [content_area, _] = Layout::vertical([Constraint::Fill(0), Constraint::Length(2)]) + .margin(self.margin) + .areas(area); + content_area + } else { + area + }; + let popup_area = Rect { + height: 7, + ..centered_rect(30, 20, table_area) + }; + let [text_box_area, _] = Layout::vertical([Constraint::Length(3), Constraint::Length(1)]) + .margin(1) + .areas(popup_area); + f.set_cursor_position(Position { + x: text_box_area.x + (length - offset) as u16 + 1, + y: text_box_area.y + 1, + }); + }; + + if self.is_searching { + draw_cursor(self.search_box_content_length, self.search_box_offset); + } else if self.is_filtering { + draw_cursor(self.filter_box_content_length, self.filter_box_offset); + } + } } impl<'a, T, F> Widget for ManagarrTable<'a, T, F> diff --git a/src/ui/widgets/managarr_table_tests.rs b/src/ui/widgets/managarr_table_tests.rs index 16c9951..3e43edf 100644 --- a/src/ui/widgets/managarr_table_tests.rs +++ b/src/ui/widgets/managarr_table_tests.rs @@ -2,13 +2,14 @@ mod tests { use crate::models::stateful_list::StatefulList; use crate::models::stateful_table::{SortOption, StatefulTable}; - use crate::models::Scrollable; + use crate::models::{HorizontallyScrollableText, Scrollable}; use crate::ui::utils::layout_block; use crate::ui::widgets::managarr_table::ManagarrTable; use pretty_assertions::assert_eq; use ratatui::layout::{Alignment, Constraint}; use ratatui::text::Text; use ratatui::widgets::{Block, Cell, Row}; + use std::sync::atomic::AtomicUsize; #[test] fn test_managarr_table_new() { @@ -31,6 +32,86 @@ mod tests { assert!(!managarr_table.is_loading); assert!(managarr_table.highlight_rows); assert!(!managarr_table.is_sorting); + assert!(!managarr_table.is_searching); + assert!(!managarr_table.search_produced_empty_results); + assert!(!managarr_table.is_filtering); + assert!(!managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.search_box_content_length, 0); + assert_eq!(managarr_table.search_box_offset, 0); + assert_eq!(managarr_table.filter_box_content_length, 0); + assert_eq!(managarr_table.filter_box_offset, 0); + } + + #[test] + fn test_managarr_table_new_search_box_populated() { + let items = vec!["item1", "item2", "item3"]; + let mut stateful_table = StatefulTable::default(); + stateful_table.set_items(items.clone()); + let horizontally_scrollable_test = HorizontallyScrollableText { + text: "test".to_owned(), + offset: AtomicUsize::new(3), + }; + stateful_table.search = Some(horizontally_scrollable_test); + + let managarr_table = + ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])); + + let row_mapper = managarr_table.row_mapper; + assert_eq!(managarr_table.content.unwrap().items, items); + assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); + assert_eq!(managarr_table.table_headers, Vec::::new()); + assert_eq!(managarr_table.constraints, Vec::new()); + assert_eq!(managarr_table.footer, None); + assert_eq!(managarr_table.footer_alignment, Alignment::Left); + assert_eq!(managarr_table.block, Block::new()); + assert_eq!(managarr_table.margin, 0); + assert!(!managarr_table.is_loading); + assert!(managarr_table.highlight_rows); + assert!(!managarr_table.is_sorting); + assert!(!managarr_table.is_searching); + assert!(!managarr_table.search_produced_empty_results); + assert!(!managarr_table.is_filtering); + assert!(!managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.search_box_content_length, 4); + assert_eq!(managarr_table.search_box_offset, 3); + assert_eq!(managarr_table.filter_box_content_length, 0); + assert_eq!(managarr_table.filter_box_offset, 0); + } + + #[test] + fn test_managarr_table_new_filter_box_populated() { + let items = vec!["item1", "item2", "item3"]; + let mut stateful_table = StatefulTable::default(); + stateful_table.set_items(items.clone()); + let horizontally_scrollable_test = HorizontallyScrollableText { + text: "test".to_owned(), + offset: AtomicUsize::new(3), + }; + stateful_table.filter = Some(horizontally_scrollable_test); + + let managarr_table = + ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])); + + let row_mapper = managarr_table.row_mapper; + assert_eq!(managarr_table.content.unwrap().items, items); + assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); + assert_eq!(managarr_table.table_headers, Vec::::new()); + assert_eq!(managarr_table.constraints, Vec::new()); + assert_eq!(managarr_table.footer, None); + assert_eq!(managarr_table.footer_alignment, Alignment::Left); + assert_eq!(managarr_table.block, Block::new()); + assert_eq!(managarr_table.margin, 0); + assert!(!managarr_table.is_loading); + assert!(managarr_table.highlight_rows); + assert!(!managarr_table.is_sorting); + assert!(!managarr_table.is_searching); + assert!(!managarr_table.search_produced_empty_results); + assert!(!managarr_table.is_filtering); + assert!(!managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.search_box_content_length, 0); + assert_eq!(managarr_table.search_box_offset, 0); + assert_eq!(managarr_table.filter_box_content_length, 4); + assert_eq!(managarr_table.filter_box_offset, 3); } #[test] @@ -56,6 +137,14 @@ mod tests { assert!(!managarr_table.is_loading); assert!(managarr_table.highlight_rows); assert!(!managarr_table.is_sorting); + assert!(!managarr_table.is_searching); + assert!(!managarr_table.search_produced_empty_results); + assert!(!managarr_table.is_filtering); + assert!(!managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.search_box_content_length, 0); + assert_eq!(managarr_table.search_box_offset, 0); + assert_eq!(managarr_table.filter_box_content_length, 0); + assert_eq!(managarr_table.filter_box_offset, 0); } #[test] @@ -81,6 +170,14 @@ mod tests { assert!(!managarr_table.is_loading); assert!(managarr_table.highlight_rows); assert!(!managarr_table.is_sorting); + assert!(!managarr_table.is_searching); + assert!(!managarr_table.search_produced_empty_results); + assert!(!managarr_table.is_filtering); + assert!(!managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.search_box_content_length, 0); + assert_eq!(managarr_table.search_box_offset, 0); + assert_eq!(managarr_table.filter_box_content_length, 0); + assert_eq!(managarr_table.filter_box_offset, 0); } #[test] @@ -106,6 +203,14 @@ mod tests { assert!(!managarr_table.is_loading); assert!(managarr_table.highlight_rows); assert!(!managarr_table.is_sorting); + assert!(!managarr_table.is_searching); + assert!(!managarr_table.search_produced_empty_results); + assert!(!managarr_table.is_filtering); + assert!(!managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.search_box_content_length, 0); + assert_eq!(managarr_table.search_box_offset, 0); + assert_eq!(managarr_table.filter_box_content_length, 0); + assert_eq!(managarr_table.filter_box_offset, 0); } #[test] @@ -130,6 +235,14 @@ mod tests { assert!(!managarr_table.is_loading); assert!(managarr_table.highlight_rows); assert!(!managarr_table.is_sorting); + assert!(!managarr_table.is_searching); + assert!(!managarr_table.search_produced_empty_results); + assert!(!managarr_table.is_filtering); + assert!(!managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.search_box_content_length, 0); + assert_eq!(managarr_table.search_box_offset, 0); + assert_eq!(managarr_table.filter_box_content_length, 0); + assert_eq!(managarr_table.filter_box_offset, 0); } #[test] @@ -154,6 +267,14 @@ mod tests { assert!(!managarr_table.is_loading); assert!(managarr_table.highlight_rows); assert!(!managarr_table.is_sorting); + assert!(!managarr_table.is_searching); + assert!(!managarr_table.search_produced_empty_results); + assert!(!managarr_table.is_filtering); + assert!(!managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.search_box_content_length, 0); + assert_eq!(managarr_table.search_box_offset, 0); + assert_eq!(managarr_table.filter_box_content_length, 0); + assert_eq!(managarr_table.filter_box_offset, 0); } #[test] @@ -177,6 +298,14 @@ mod tests { assert!(!managarr_table.is_loading); assert!(managarr_table.highlight_rows); assert!(!managarr_table.is_sorting); + assert!(!managarr_table.is_searching); + assert!(!managarr_table.search_produced_empty_results); + assert!(!managarr_table.is_filtering); + assert!(!managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.search_box_content_length, 0); + assert_eq!(managarr_table.search_box_offset, 0); + assert_eq!(managarr_table.filter_box_content_length, 0); + assert_eq!(managarr_table.filter_box_offset, 0); } #[test] @@ -201,6 +330,14 @@ mod tests { assert_eq!(managarr_table.margin, 0); assert!(managarr_table.highlight_rows); assert!(!managarr_table.is_sorting); + assert!(!managarr_table.is_searching); + assert!(!managarr_table.search_produced_empty_results); + assert!(!managarr_table.is_filtering); + assert!(!managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.search_box_content_length, 0); + assert_eq!(managarr_table.search_box_offset, 0); + assert_eq!(managarr_table.filter_box_content_length, 0); + assert_eq!(managarr_table.filter_box_offset, 0); } #[test] @@ -225,6 +362,14 @@ mod tests { assert_eq!(managarr_table.margin, 0); assert!(!managarr_table.is_loading); assert!(!managarr_table.is_sorting); + assert!(!managarr_table.is_searching); + assert!(!managarr_table.search_produced_empty_results); + assert!(!managarr_table.is_filtering); + assert!(!managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.search_box_content_length, 0); + assert_eq!(managarr_table.search_box_offset, 0); + assert_eq!(managarr_table.filter_box_content_length, 0); + assert_eq!(managarr_table.filter_box_offset, 0); } #[test] @@ -249,6 +394,142 @@ mod tests { assert_eq!(managarr_table.margin, 0); assert!(!managarr_table.is_loading); assert!(managarr_table.highlight_rows); + assert!(!managarr_table.is_searching); + assert!(!managarr_table.search_produced_empty_results); + assert!(!managarr_table.is_filtering); + assert!(!managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.search_box_content_length, 0); + assert_eq!(managarr_table.search_box_offset, 0); + assert_eq!(managarr_table.filter_box_content_length, 0); + assert_eq!(managarr_table.filter_box_offset, 0); + } + + #[test] + fn test_managarr_table_is_searching() { + let items = vec!["item1", "item2", "item3"]; + let mut stateful_table = StatefulTable::default(); + stateful_table.set_items(items.clone()); + + let managarr_table = + ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])) + .searching(true); + + let row_mapper = managarr_table.row_mapper; + assert!(managarr_table.is_searching); + assert_eq!(managarr_table.content.unwrap().items, items); + assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); + assert_eq!(managarr_table.table_headers, Vec::::new()); + assert_eq!(managarr_table.constraints, Vec::new()); + assert_eq!(managarr_table.footer, None); + assert_eq!(managarr_table.footer_alignment, Alignment::Left); + assert_eq!(managarr_table.block, Block::new()); + assert_eq!(managarr_table.margin, 0); + assert!(!managarr_table.is_loading); + assert!(managarr_table.highlight_rows); + assert!(!managarr_table.is_sorting); + assert!(!managarr_table.search_produced_empty_results); + assert!(!managarr_table.is_filtering); + assert!(!managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.search_box_content_length, 0); + assert_eq!(managarr_table.search_box_offset, 0); + assert_eq!(managarr_table.filter_box_content_length, 0); + assert_eq!(managarr_table.filter_box_offset, 0); + } + + #[test] + fn test_managarr_table_search_produced_empty_results() { + let items = vec!["item1", "item2", "item3"]; + let mut stateful_table = StatefulTable::default(); + stateful_table.set_items(items.clone()); + + let managarr_table = + ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])) + .search_produced_empty_results(true); + + let row_mapper = managarr_table.row_mapper; + assert!(managarr_table.search_produced_empty_results); + assert_eq!(managarr_table.content.unwrap().items, items); + assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); + assert_eq!(managarr_table.table_headers, Vec::::new()); + assert_eq!(managarr_table.constraints, Vec::new()); + assert_eq!(managarr_table.footer, None); + assert_eq!(managarr_table.footer_alignment, Alignment::Left); + assert_eq!(managarr_table.block, Block::new()); + assert_eq!(managarr_table.margin, 0); + assert!(!managarr_table.is_loading); + assert!(managarr_table.highlight_rows); + assert!(!managarr_table.is_sorting); + assert!(!managarr_table.is_searching); + assert!(!managarr_table.is_filtering); + assert!(!managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.search_box_content_length, 0); + assert_eq!(managarr_table.search_box_offset, 0); + assert_eq!(managarr_table.filter_box_content_length, 0); + assert_eq!(managarr_table.filter_box_offset, 0); + } + + #[test] + fn test_managarr_table_is_filtering() { + let items = vec!["item1", "item2", "item3"]; + let mut stateful_table = StatefulTable::default(); + stateful_table.set_items(items.clone()); + + let managarr_table = + ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])) + .filtering(true); + + let row_mapper = managarr_table.row_mapper; + assert!(managarr_table.is_filtering); + assert_eq!(managarr_table.content.unwrap().items, items); + assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); + assert_eq!(managarr_table.table_headers, Vec::::new()); + assert_eq!(managarr_table.constraints, Vec::new()); + assert_eq!(managarr_table.footer, None); + assert_eq!(managarr_table.footer_alignment, Alignment::Left); + assert_eq!(managarr_table.block, Block::new()); + assert_eq!(managarr_table.margin, 0); + assert!(!managarr_table.is_loading); + assert!(managarr_table.highlight_rows); + assert!(!managarr_table.is_sorting); + assert!(!managarr_table.is_searching); + assert!(!managarr_table.search_produced_empty_results); + assert!(!managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.search_box_content_length, 0); + assert_eq!(managarr_table.search_box_offset, 0); + assert_eq!(managarr_table.filter_box_content_length, 0); + assert_eq!(managarr_table.filter_box_offset, 0); + } + + #[test] + fn test_managarr_table_filter_produced_empty_results() { + let items = vec!["item1", "item2", "item3"]; + let mut stateful_table = StatefulTable::default(); + stateful_table.set_items(items.clone()); + + let managarr_table = + ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])) + .filter_produced_empty_results(true); + + let row_mapper = managarr_table.row_mapper; + assert!(managarr_table.filter_produced_empty_results); + assert_eq!(managarr_table.content.unwrap().items, items); + assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); + assert_eq!(managarr_table.table_headers, Vec::::new()); + assert_eq!(managarr_table.constraints, Vec::new()); + assert_eq!(managarr_table.footer, None); + assert_eq!(managarr_table.footer_alignment, Alignment::Left); + assert_eq!(managarr_table.block, Block::new()); + assert_eq!(managarr_table.margin, 0); + assert!(!managarr_table.is_loading); + assert!(managarr_table.highlight_rows); + assert!(!managarr_table.is_sorting); + assert!(!managarr_table.is_searching); + assert!(!managarr_table.search_produced_empty_results); + assert!(!managarr_table.is_filtering); + assert_eq!(managarr_table.search_box_content_length, 0); + assert_eq!(managarr_table.search_box_offset, 0); + assert_eq!(managarr_table.filter_box_content_length, 0); + assert_eq!(managarr_table.filter_box_offset, 0); } #[test] diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs index 8b279a5..1e1c38a 100644 --- a/src/ui/widgets/mod.rs +++ b/src/ui/widgets/mod.rs @@ -7,3 +7,4 @@ pub(super) mod managarr_table; pub(super) mod message; pub(super) mod popup; pub(super) mod selectable_list; +mod input_box_popup; diff --git a/src/ui/widgets/popup.rs b/src/ui/widgets/popup.rs index 81c00f1..02b5083 100644 --- a/src/ui/widgets/popup.rs +++ b/src/ui/widgets/popup.rs @@ -22,6 +22,7 @@ pub enum Size { Small, Medium, Large, + XXLarge, Long, } @@ -40,6 +41,7 @@ impl Size { Size::Small => (40, 40), Size::Medium => (60, 60), Size::Large => (75, 75), + Size::XXLarge => (90, 90), Size::Long => (65, 75), } } @@ -118,6 +120,6 @@ impl<'a, T: Widget> Popup<'a, T> { impl<'a, T: Widget> Widget for Popup<'a, T> { fn render(self, area: Rect, buf: &mut Buffer) { - self.render_popup(area, buf); + self.render_popup(area, buf); } } diff --git a/src/ui/widgets/popup_tests.rs b/src/ui/widgets/popup_tests.rs index 2098ed0..a7bce95 100644 --- a/src/ui/widgets/popup_tests.rs +++ b/src/ui/widgets/popup_tests.rs @@ -18,6 +18,7 @@ mod tests { assert_eq!(Size::Small.to_percent(), (40, 40)); assert_eq!(Size::Medium.to_percent(), (60, 60)); assert_eq!(Size::Large.to_percent(), (75, 75)); + assert_eq!(Size::XXLarge.to_percent(), (90, 90)); assert_eq!(Size::Long.to_percent(), (65, 75)); } From bd1a4f093916c7926f98cab30264178da08ebc4d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 5 Dec 2024 19:07:45 -0700 Subject: [PATCH 33/82] feat(ui): Sonarr Series details UI is now available --- src/app/sonarr/sonarr_context_clues.rs | 3 +- src/app/sonarr/sonarr_context_clues_tests.rs | 5 + src/models/servarr_data/sonarr/sonarr_data.rs | 15 + .../servarr_data/sonarr/sonarr_data_tests.rs | 18 +- src/ui/sonarr_ui/library/mod.rs | 98 ++--- src/ui/sonarr_ui/library/series_details_ui.rs | 395 ++++++++++++++++++ .../library/series_details_ui_tests.rs | 21 + src/ui/sonarr_ui/mod.rs | 1 + src/ui/sonarr_ui/sonarr_ui_utils.rs | 183 ++++++++ src/ui/sonarr_ui/sonarr_ui_utils_tests.rs | 240 +++++++++++ 10 files changed, 929 insertions(+), 50 deletions(-) create mode 100644 src/ui/sonarr_ui/library/series_details_ui.rs create mode 100644 src/ui/sonarr_ui/library/series_details_ui_tests.rs create mode 100644 src/ui/sonarr_ui/sonarr_ui_utils.rs create mode 100644 src/ui/sonarr_ui/sonarr_ui_utils_tests.rs diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index 2db8ff7..37878bf 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -40,7 +40,8 @@ pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 6] = [ (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; -pub static HISTORY_CONTEXT_CLUES: [ContextClue; 5] = [ +pub static HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [ + (DEFAULT_KEYBINDINGS.submit, "details"), (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index 0ad23ef..c5a9fe3 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -92,6 +92,11 @@ mod tests { let (key_binding, description) = history_context_clues_iter.next().unwrap(); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); + assert_str_eq!(*description, "details"); + + let (key_binding, description) = history_context_clues_iter.next().unwrap(); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort); assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc); diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index acec1c2..70fd9ca 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -278,6 +278,7 @@ pub enum ActiveSonarrBlock { Series, SeriesDetails, SeriesHistory, + SeriesHistoryDetails, SeriesHistorySortPrompt, SeriesSortPrompt, System, @@ -303,6 +304,20 @@ pub static LIBRARY_BLOCKS: [ActiveSonarrBlock; 7] = [ ActiveSonarrBlock::UpdateAllSeriesPrompt, ]; +pub static SERIES_DETAILS_BLOCKS: [ActiveSonarrBlock; 11] = [ + ActiveSonarrBlock::SeriesDetails, + ActiveSonarrBlock::SeriesHistory, + ActiveSonarrBlock::SearchSeason, + ActiveSonarrBlock::SearchSeasonError, + ActiveSonarrBlock::UpdateAndScanSeriesPrompt, + ActiveSonarrBlock::SearchSeriesHistory, + ActiveSonarrBlock::SearchSeriesHistoryError, + ActiveSonarrBlock::FilterSeriesHistory, + ActiveSonarrBlock::FilterSeriesHistoryError, + ActiveSonarrBlock::SeriesHistorySortPrompt, + ActiveSonarrBlock::SeriesHistoryDetails, +]; + pub static ADD_SERIES_BLOCKS: [ActiveSonarrBlock; 13] = [ ActiveSonarrBlock::AddSeriesAlreadyInLibrary, ActiveSonarrBlock::AddSeriesConfirmPrompt, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 0e73f33..28f2d34 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -207,7 +207,7 @@ mod tests { EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, EDIT_SERIES_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, HISTORY_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, LIBRARY_BLOCKS, ROOT_FOLDERS_BLOCKS, - SYSTEM_DETAILS_BLOCKS, + SERIES_DETAILS_BLOCKS, SYSTEM_DETAILS_BLOCKS, }; #[test] @@ -567,5 +567,21 @@ mod tests { assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SystemTaskStartConfirmPrompt)); assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SystemUpdates)); } + + #[test] + fn test_series_details_blocks_contents() { + assert_eq!(SERIES_DETAILS_BLOCKS.len(), 11); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesDetails)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesHistory)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeason)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeasonError)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::UpdateAndScanSeriesPrompt)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeriesHistory)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeriesHistoryError)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::FilterSeriesHistory)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::FilterSeriesHistoryError)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesHistorySortPrompt)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesHistoryDetails)); + } } } diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index 4ab9e25..930ecca 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -6,10 +6,10 @@ use ratatui::{ widgets::{Cell, Row}, Frame, }; +use series_details_ui::SeriesDetailsUi; use crate::ui::widgets::{ confirmation_prompt::ConfirmationPrompt, - message::Message, popup::{Popup, Size}, }; use crate::{ @@ -20,7 +20,6 @@ use crate::{ EnumDisplayStyle, Route, }, ui::{ - draw_input_box_popup, draw_popup_over, styles::ManagarrStyle, utils::{get_width_from_percentage, layout_block_top_border}, widgets::managarr_table::ManagarrTable, @@ -31,6 +30,7 @@ use crate::{ mod add_series_ui; mod delete_series_ui; mod edit_series_ui; +mod series_details_ui; #[cfg(test)] #[path = "library_ui_tests.rs"] @@ -44,6 +44,7 @@ impl DrawUi for LibraryUi { return AddSeriesUi::accepts(route) || DeleteSeriesUi::accepts(route) || EditSeriesUi::accepts(route) + || SeriesDetailsUi::accepts(route) || LIBRARY_BLOCKS.contains(&active_sonarr_block); } @@ -54,36 +55,41 @@ impl DrawUi for LibraryUi { let route = app.get_current_route(); let mut series_ui_matchers = |active_sonarr_block: ActiveSonarrBlock| match active_sonarr_block { - ActiveSonarrBlock::Series | ActiveSonarrBlock::SeriesSortPrompt => draw_library(f, app, area), - ActiveSonarrBlock::SearchSeries => draw_popup_over( - f, - app, - area, - draw_library, - draw_library_search_box, - Size::InputBox, - ), - ActiveSonarrBlock::SearchSeriesError => { - let popup = Popup::new(Message::new("Series not found!")).size(Size::Message); + ActiveSonarrBlock::Series + | ActiveSonarrBlock::SeriesSortPrompt + | ActiveSonarrBlock::SearchSeries + | ActiveSonarrBlock::SearchSeriesError + | ActiveSonarrBlock::FilterSeries + | ActiveSonarrBlock::FilterSeriesError => draw_library(f, app, area), + // ActiveSonarrBlock::SearchSeries => draw_popup_over( + // f, + // app, + // area, + // draw_library, + // draw_library_search_box, + // Size::InputBox, + // ), + // ActiveSonarrBlock::SearchSeriesError => { + // let popup = Popup::new(Message::new("Series not found!")).size(Size::Message); - draw_library(f, app, area); - f.render_widget(popup, f.area()); - } - ActiveSonarrBlock::FilterSeries => draw_popup_over( - f, - app, - area, - draw_library, - draw_filter_series_box, - Size::InputBox, - ), - ActiveSonarrBlock::FilterSeriesError => { - let popup = Popup::new(Message::new("No series found matching the given filter!")) - .size(Size::Message); + // draw_library(f, app, area); + // f.render_widget(popup, f.area()); + // } + // ActiveSonarrBlock::FilterSeries => draw_popup_over( + // f, + // app, + // area, + // draw_library, + // draw_filter_series_box, + // Size::InputBox, + // ), + // ActiveSonarrBlock::FilterSeriesError => { + // let popup = Popup::new(Message::new("No series found matching the given filter!")) + // .size(Size::Message); - draw_library(f, app, area); - f.render_widget(popup, f.area()); - } + // draw_library(f, app, area); + // f.render_widget(popup, f.area()); + // } ActiveSonarrBlock::UpdateAllSeriesPrompt => { let confirmation_prompt = ConfirmationPrompt::new() .title("Update All Series") @@ -103,6 +109,7 @@ impl DrawUi for LibraryUi { _ if AddSeriesUi::accepts(route) => AddSeriesUi::draw(f, app, area), _ if DeleteSeriesUi::accepts(route) => DeleteSeriesUi::draw(f, app, area), _ if EditSeriesUi::accepts(route) => EditSeriesUi::draw(f, app, area), + _ if SeriesDetailsUi::accepts(route) => SeriesDetailsUi::draw(f, app, area), Route::Sonarr(active_sonarr_block, _) if LIBRARY_BLOCKS.contains(&active_sonarr_block) => { series_ui_matchers(active_sonarr_block) } @@ -182,6 +189,10 @@ pub(super) fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .loading(app.is_loading) .footer(help_footer) .sorting(active_sonarr_block == ActiveSonarrBlock::SeriesSortPrompt) + .searching(active_sonarr_block == ActiveSonarrBlock::SearchSeries) + .filtering(active_sonarr_block == ActiveSonarrBlock::FilterSeries) + .search_produced_empty_results(active_sonarr_block == ActiveSonarrBlock::SearchSeriesError) + .filter_produced_empty_results(active_sonarr_block == ActiveSonarrBlock::FilterSeriesError) .headers([ "Title", "Year", @@ -207,6 +218,15 @@ pub(super) fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Constraint::Percentage(12), ]); + if [ + ActiveSonarrBlock::SearchSeries, + ActiveSonarrBlock::FilterSeries, + ] + .contains(&active_sonarr_block) + { + series_table.show_cursor(f, area); + } + f.render_widget(series_table, area); } } @@ -235,21 +255,3 @@ fn decorate_series_row_with_style<'a>(series: &Series, row: Row<'a>) -> Row<'a> _ => row.missing(), } } - -fn draw_library_search_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_input_box_popup( - f, - area, - "Search", - app.data.sonarr_data.series.search.as_ref().unwrap(), - ); -} - -fn draw_filter_series_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_input_box_popup( - f, - area, - "Filter", - app.data.sonarr_data.series.filter.as_ref().unwrap(), - ) -} diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs new file mode 100644 index 0000000..d673881 --- /dev/null +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -0,0 +1,395 @@ +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; +use ratatui::Frame; + +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SERIES_DETAILS_BLOCKS}; +use crate::models::sonarr_models::{ + Season, SeasonStatistics, SonarrHistoryEventType, SonarrHistoryItem, +}; +use crate::models::{EnumDisplayStyle, Route}; +use crate::ui::sonarr_ui::sonarr_ui_utils::{ + create_download_failed_history_event_details, + create_download_folder_imported_history_event_details, + create_episode_file_deleted_history_event_details, + create_episode_file_renamed_history_event_details, create_grabbed_history_event_details, + create_no_data_history_event_details, +}; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{ + borderless_block, get_width_from_percentage, layout_block_top_border, title_block, +}; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::{draw_popup_over, draw_tabs, DrawUi}; +use crate::utils::convert_to_gb; + +use super::draw_library; + +#[cfg(test)] +#[path = "series_details_ui_tests.rs"] +mod series_details_ui_tests; + +pub(super) struct SeriesDetailsUi; + +impl DrawUi for SeriesDetailsUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let draw_series_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + f.render_widget( + title_block(&app.data.sonarr_data.series.current_selection().title.text), + popup_area, + ); + let [description_area, detail_area] = + Layout::vertical([Constraint::Percentage(37), Constraint::Fill(0)]) + .margin(1) + .areas(popup_area); + draw_series_description(f, app, description_area); + let content_area = draw_tabs( + f, + detail_area, + "Series Details", + &app.data.sonarr_data.series_info_tabs, + ); + draw_series_details(f, app, content_area); + + match active_sonarr_block { + ActiveSonarrBlock::UpdateAndScanSeriesPrompt => { + let prompt = format!( + "Do you want to trigger an update and disk scan for the series: {}?", + app.data.sonarr_data.series.current_selection().title + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Update and Scan") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveSonarrBlock::SeriesHistoryDetails => { + draw_popup_over( + f, + app, + popup_area, + draw_series_history_table, + draw_history_item_details_popup, + Size::Small, + ); + } + _ => (), + }; + }; + + draw_popup_over( + f, + app, + area, + draw_library, + draw_series_details_popup, + Size::XXLarge, + ); + } + } +} + +pub fn draw_series_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = app.data.sonarr_data.series.current_selection(); + let monitored = if current_selection.monitored { + "Yes" + } else { + "No" + }; + let quality_profile = app + .data + .sonarr_data + .quality_profile_map + .get_by_left(¤t_selection.quality_profile_id) + .unwrap() + .to_owned(); + let language_profile = app + .data + .sonarr_data + .language_profiles_map + .get_by_left(¤t_selection.language_profile_id) + .unwrap() + .to_owned(); + let mut series_description = vec![ + Line::from(vec![ + "Title: ".primary().bold(), + current_selection.title.text.clone().primary().bold(), + ]), + Line::from(vec![ + "Overview: ".primary().bold(), + current_selection + .overview + .clone() + .unwrap_or_default() + .default(), + ]), + Line::from(vec![ + "Network: ".primary().bold(), + current_selection + .network + .clone() + .unwrap_or_default() + .default(), + ]), + Line::from(vec![ + "Status: ".primary().bold(), + current_selection.status.to_display_str().default(), + ]), + Line::from(vec![ + "Genres: ".primary().bold(), + current_selection.genres.join(", ").default(), + ]), + Line::from(vec![ + "Rating: ".primary().bold(), + format!("{}%", (current_selection.ratings.value * 10.0) as i32).default(), + ]), + Line::from(vec![ + "Year: ".primary().bold(), + current_selection.year.to_string().default(), + ]), + Line::from(vec![ + "Runtime: ".primary().bold(), + format!("{} minutes", current_selection.runtime).default(), + ]), + Line::from(vec![ + "Path: ".primary().bold(), + current_selection.path.clone().default(), + ]), + Line::from(vec![ + "Quality Profile: ".primary().bold(), + quality_profile.default(), + ]), + Line::from(vec![ + "Language Profile: ".primary().bold(), + language_profile.default(), + ]), + Line::from(vec!["Monitored: ".primary().bold(), monitored.default()]), + ]; + if let Some(stats) = current_selection.statistics.as_ref() { + let size = convert_to_gb(stats.size_on_disk); + series_description.extend(vec![Line::from(vec![ + "Size on Disk: ".primary().bold(), + format!("{size:.2} GB").default(), + ])]); + } + + let description_paragraph = Paragraph::new(series_description) + .block(borderless_block()) + .wrap(Wrap { trim: false }); + f.render_widget(description_paragraph, area); +} + +pub fn draw_series_details(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = + app.data.sonarr_data.series_info_tabs.get_active_route() + { + match active_sonarr_block { + ActiveSonarrBlock::SeriesDetails => draw_seasons_table(f, app, area), + ActiveSonarrBlock::SeriesHistory => draw_series_history_table(f, app, area), + _ => (), + } + } +} + +fn draw_seasons_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let content = Some(&mut app.data.sonarr_data.seasons); + let help_footer = app + .data + .sonarr_data + .series_info_tabs + .get_active_tab_contextual_help(); + let season_row_mapping = |season: &Season| { + let Season { + season_number, + monitored, + statistics, + } = season; + let SeasonStatistics { + episode_count, + total_episode_count, + size_on_disk, + .. + } = statistics; + let season_monitored = if season.monitored { "🏷" } else { "" }; + let size = convert_to_gb(*size_on_disk); + + let row = Row::new(vec![ + Cell::from(season_monitored.to_owned()), + Cell::from(format!("Season {}", season_number)), + Cell::from(format!("{}/{}", episode_count, total_episode_count)), + Cell::from(format!("{size:.2} GB")), + ]); + if episode_count == total_episode_count { + row.downloaded() + } else if !monitored { + row.unmonitored() + } else { + row.missing() + } + }; + let is_searching = active_sonarr_block == ActiveSonarrBlock::SearchSeason; + let season_table = ManagarrTable::new(content, season_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(help_footer) + .searching(is_searching) + .search_produced_empty_results(active_sonarr_block == ActiveSonarrBlock::SearchSeasonError) + .headers(["Monitored", "Season", "Episode Count", "Size on Disk"]) + .constraints([ + Constraint::Percentage(6), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ]); + + if is_searching { + season_table.show_cursor(f, area); + } + + f.render_widget(season_table, area); + } +} + +fn draw_series_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + match app.data.sonarr_data.series_history.as_ref() { + Some(series_history) if !app.is_loading => { + let current_selection = if series_history.is_empty() { + SonarrHistoryItem::default() + } else { + series_history.current_selection().clone() + }; + let series_history_table_footer = app + .data + .sonarr_data + .series_info_tabs + .get_active_tab_contextual_help(); + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let history_row_mapping = |history_item: &SonarrHistoryItem| { + let SonarrHistoryItem { + source_title, + language, + quality, + event_type, + date, + .. + } = history_item; + + source_title.scroll_left_or_reset( + get_width_from_percentage(area, 40), + current_selection == *history_item, + app.tick_count % app.ticks_until_scroll == 0, + ); + + Row::new(vec![ + Cell::from(source_title.to_string()), + Cell::from(event_type.to_string()), + Cell::from(language.name.to_owned()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let mut series_history_table = app.data.sonarr_data.series_history.as_mut().unwrap(); + let history_table = + ManagarrTable::new(Some(&mut series_history_table), history_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(series_history_table_footer) + .sorting(active_sonarr_block == ActiveSonarrBlock::SeriesHistorySortPrompt) + .searching(active_sonarr_block == ActiveSonarrBlock::SearchSeriesHistory) + .search_produced_empty_results( + active_sonarr_block == ActiveSonarrBlock::SearchSeriesHistoryError, + ) + .filtering(active_sonarr_block == ActiveSonarrBlock::FilterSeriesHistory) + .filter_produced_empty_results( + active_sonarr_block == ActiveSonarrBlock::FilterSeriesHistoryError, + ) + .headers(["Source Title", "Event Type", "Language", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(40), + Constraint::Percentage(15), + Constraint::Percentage(12), + Constraint::Percentage(13), + Constraint::Percentage(20), + ]); + + if [ + ActiveSonarrBlock::SearchSeriesHistory, + ActiveSonarrBlock::FilterSeriesHistory, + ] + .contains(&active_sonarr_block) + { + history_table.show_cursor(f, area); + } + + f.render_widget(history_table, area); + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.radarr_data.movie_details_modal.is_none(), + layout_block_top_border(), + ), + area, + ), + } +} + +fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = + if let Some(series_history_items) = app.data.sonarr_data.series_history.as_ref() { + if series_history_items.is_empty() { + SonarrHistoryItem::default() + } else { + series_history_items.current_selection().clone() + } + } else { + SonarrHistoryItem::default() + }; + + let line_vec = match current_selection.event_type { + SonarrHistoryEventType::Grabbed => create_grabbed_history_event_details(current_selection), + SonarrHistoryEventType::DownloadFolderImported => { + create_download_folder_imported_history_event_details(current_selection) + } + SonarrHistoryEventType::DownloadFailed => { + create_download_failed_history_event_details(current_selection) + } + SonarrHistoryEventType::EpisodeFileDeleted => { + create_episode_file_deleted_history_event_details(current_selection) + } + SonarrHistoryEventType::EpisodeFileRenamed => { + create_episode_file_renamed_history_event_details(current_selection) + } + _ => create_no_data_history_event_details(current_selection), + }; + let text = Text::from(line_vec); + + let message = Message::new(text) + .title("Details") + .style(Style::new().secondary()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowMessage), area); +} diff --git a/src/ui/sonarr_ui/library/series_details_ui_tests.rs b/src/ui/sonarr_ui/library/series_details_ui_tests.rs new file mode 100644 index 0000000..7dd2f8d --- /dev/null +++ b/src/ui/sonarr_ui/library/series_details_ui_tests.rs @@ -0,0 +1,21 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, SERIES_DETAILS_BLOCKS, + }; + use crate::ui::sonarr_ui::library::series_details_ui::SeriesDetailsUi; + use crate::ui::DrawUi; + + #[test] + fn test_series_details_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block) { + assert!(SeriesDetailsUi::accepts(active_sonarr_block.into())); + } else { + assert!(!SeriesDetailsUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index c871d17..845daea 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -44,6 +44,7 @@ mod history; mod indexers; mod library; mod root_folders; +mod sonarr_ui_utils; mod system; #[cfg(test)] diff --git a/src/ui/sonarr_ui/sonarr_ui_utils.rs b/src/ui/sonarr_ui/sonarr_ui_utils.rs new file mode 100644 index 0000000..8fff51a --- /dev/null +++ b/src/ui/sonarr_ui/sonarr_ui_utils.rs @@ -0,0 +1,183 @@ +use ratatui::style::Stylize; +use ratatui::text::Line; + +use crate::models::sonarr_models::{SonarrHistoryData, SonarrHistoryItem}; +use crate::ui::styles::ManagarrStyle; + +#[cfg(test)] +#[path = "sonarr_ui_utils_tests.rs"] +mod sonarr_ui_utils_tests; + +pub(super) fn create_grabbed_history_event_details( + history_item: SonarrHistoryItem, +) -> Vec> { + let SonarrHistoryItem { + source_title, data, .. + } = history_item; + let SonarrHistoryData { + indexer, + release_group, + series_match_type, + nzb_info_url, + download_client_name, + age, + published_date, + .. + } = data; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Indexer: ".bold().secondary(), + indexer.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Release Group: ".bold().secondary(), + release_group.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Series Match Type: ".bold().secondary(), + series_match_type.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "NZB Info URL: ".bold().secondary(), + nzb_info_url.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Download Client Name: ".bold().secondary(), + download_client_name.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Age: ".bold().secondary(), + format!("{} days", age.unwrap_or("0".to_owned())).secondary(), + ]), + Line::from(vec![ + "Published Date: ".bold().secondary(), + published_date.unwrap_or_default().to_string().secondary(), + ]), + ] +} + +pub(super) fn create_download_folder_imported_history_event_details( + history_item: SonarrHistoryItem, +) -> Vec> { + let SonarrHistoryItem { + source_title, data, .. + } = history_item; + let SonarrHistoryData { + dropped_path, + imported_path, + .. + } = data; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Dropped Path: ".bold().secondary(), + dropped_path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Imported Path: ".bold().secondary(), + imported_path.unwrap_or_default().secondary(), + ]), + ] +} + +pub(super) fn create_download_failed_history_event_details( + history_item: SonarrHistoryItem, +) -> Vec> { + let SonarrHistoryItem { + source_title, data, .. + } = history_item; + let SonarrHistoryData { message, .. } = data; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Message: ".bold().secondary(), + message.unwrap_or_default().secondary(), + ]), + ] +} + +pub(super) fn create_episode_file_deleted_history_event_details( + history_item: SonarrHistoryItem, +) -> Vec> { + let SonarrHistoryItem { + source_title, data, .. + } = history_item; + let SonarrHistoryData { reason, .. } = data; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Reason: ".bold().secondary(), + reason.unwrap_or_default().secondary(), + ]), + ] +} + +pub(super) fn create_episode_file_renamed_history_event_details( + history_item: SonarrHistoryItem, +) -> Vec> { + let SonarrHistoryItem { + source_title, data, .. + } = history_item; + let SonarrHistoryData { + source_path, + source_relative_path, + path, + relative_path, + .. + } = data; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Source Path: ".bold().secondary(), + source_path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Source Relative Path: ".bold().secondary(), + source_relative_path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Destination Path: ".bold().secondary(), + path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Destination Relative Path: ".bold().secondary(), + relative_path.unwrap_or_default().secondary(), + ]), + ] +} + +pub(super) fn create_no_data_history_event_details( + history_item: SonarrHistoryItem, +) -> Vec> { + let SonarrHistoryItem { source_title, .. } = history_item; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![String::new().secondary()]), + Line::from(vec!["No additional data available".bold().secondary()]), + ] +} diff --git a/src/ui/sonarr_ui/sonarr_ui_utils_tests.rs b/src/ui/sonarr_ui/sonarr_ui_utils_tests.rs new file mode 100644 index 0000000..a0255c2 --- /dev/null +++ b/src/ui/sonarr_ui/sonarr_ui_utils_tests.rs @@ -0,0 +1,240 @@ +#[cfg(test)] +mod tests { + use chrono::Utc; + use ratatui::{style::Stylize, text::Line}; + + use crate::{ + models::sonarr_models::{SonarrHistoryData, SonarrHistoryItem}, + ui::{ + sonarr_ui::sonarr_ui_utils::{ + create_download_failed_history_event_details, + create_download_folder_imported_history_event_details, + create_episode_file_deleted_history_event_details, + create_episode_file_renamed_history_event_details, create_grabbed_history_event_details, + create_no_data_history_event_details, + }, + styles::ManagarrStyle, + }, + }; + use pretty_assertions::assert_eq; + + #[test] + fn test_create_grabbed_history_event_details() { + let history_item = sonarr_history_item(); + let SonarrHistoryItem { + source_title, data, .. + } = history_item.clone(); + let SonarrHistoryData { + indexer, + release_group, + series_match_type, + nzb_info_url, + download_client_name, + age, + published_date, + .. + } = data; + let expected_vec = vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Indexer: ".bold().secondary(), + indexer.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Release Group: ".bold().secondary(), + release_group.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Series Match Type: ".bold().secondary(), + series_match_type.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "NZB Info URL: ".bold().secondary(), + nzb_info_url.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Download Client Name: ".bold().secondary(), + download_client_name.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Age: ".bold().secondary(), + format!("{} days", age.unwrap_or("0".to_owned())).secondary(), + ]), + Line::from(vec![ + "Published Date: ".bold().secondary(), + published_date.unwrap_or_default().to_string().secondary(), + ]), + ]; + + let history_details_vec = create_grabbed_history_event_details(history_item); + + assert_eq!(expected_vec, history_details_vec); + } + + #[test] + fn test_create_download_folder_imported_history_event_details() { + let history_item = sonarr_history_item(); + let SonarrHistoryItem { + source_title, data, .. + } = history_item.clone(); + let SonarrHistoryData { + dropped_path, + imported_path, + .. + } = data; + let expected_vec = vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Dropped Path: ".bold().secondary(), + dropped_path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Imported Path: ".bold().secondary(), + imported_path.unwrap_or_default().secondary(), + ]), + ]; + + let history_details_vec = create_download_folder_imported_history_event_details(history_item); + + assert_eq!(expected_vec, history_details_vec); + } + + #[test] + fn test_create_download_failed_history_event_details() { + let history_item = sonarr_history_item(); + let SonarrHistoryItem { + source_title, data, .. + } = history_item.clone(); + let SonarrHistoryData { message, .. } = data; + let expected_vec = vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Message: ".bold().secondary(), + message.unwrap_or_default().secondary(), + ]), + ]; + + let history_details_vec = create_download_failed_history_event_details(history_item); + + assert_eq!(expected_vec, history_details_vec); + } + + #[test] + fn test_create_episode_file_deleted_history_event_details() { + let history_item = sonarr_history_item(); + let SonarrHistoryItem { + source_title, data, .. + } = history_item.clone(); + let SonarrHistoryData { reason, .. } = data; + let expected_vec = vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Reason: ".bold().secondary(), + reason.unwrap_or_default().secondary(), + ]), + ]; + + let history_details_vec = create_episode_file_deleted_history_event_details(history_item); + + assert_eq!(expected_vec, history_details_vec); + } + + #[test] + fn test_create_episode_file_renamed_history_event_details() { + let history_item = sonarr_history_item(); + let SonarrHistoryItem { + source_title, data, .. + } = history_item.clone(); + let SonarrHistoryData { + source_path, + source_relative_path, + path, + relative_path, + .. + } = data; + let expected_vec = vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Source Path: ".bold().secondary(), + source_path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Source Relative Path: ".bold().secondary(), + source_relative_path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Destination Path: ".bold().secondary(), + path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Destination Relative Path: ".bold().secondary(), + relative_path.unwrap_or_default().secondary(), + ]), + ]; + + let history_details_vec = create_episode_file_renamed_history_event_details(history_item); + + assert_eq!(expected_vec, history_details_vec); + } + + #[test] + fn test_create_no_data_history_event_details() { + let history_item = sonarr_history_item(); + let SonarrHistoryItem { source_title, .. } = history_item.clone(); + let expected_vec = vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![String::new().secondary()]), + Line::from(vec!["No additional data available".bold().secondary()]), + ]; + + let history_details_vec = create_no_data_history_event_details(history_item); + + assert_eq!(expected_vec, history_details_vec); + } + + fn sonarr_history_item() -> SonarrHistoryItem { + SonarrHistoryItem { + source_title: "test.source.title".into(), + data: sonarr_history_data(), + ..SonarrHistoryItem::default() + } + } + + fn sonarr_history_data() -> SonarrHistoryData { + SonarrHistoryData { + dropped_path: Some("/dropped/test".into()), + imported_path: Some("/imported/test".into()), + indexer: Some("Test Indexer".into()), + release_group: Some("test release group".into()), + series_match_type: Some("test match type".into()), + nzb_info_url: Some("test url".into()), + download_client_name: Some("test download client".into()), + age: Some("1".into()), + published_date: Some(Utc::now()), + message: Some("test message".into()), + reason: Some("test reason".into()), + source_path: Some("/source/path".into()), + source_relative_path: Some("/relative/source/path".into()), + path: Some("/path".into()), + relative_path: Some("/relative/path".into()), + } + } +} From b27c13cf74dec65c36b66372eae7832960d78526 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 5 Dec 2024 19:08:11 -0700 Subject: [PATCH 34/82] fix(handler): Fixed a bug in the history handler that wouldn't reset the filter or search if a user hit 'esc' on the History tab --- .../sonarr_handlers/history/history_handler_tests.rs | 12 ++++++++++++ src/handlers/sonarr_handlers/history/mod.rs | 8 +++++++- src/handlers/sonarr_handlers/sonarr_handler_tests.rs | 1 + 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/handlers/sonarr_handlers/history/history_handler_tests.rs b/src/handlers/sonarr_handlers/history/history_handler_tests.rs index 2110070..2f003ff 100644 --- a/src/handlers/sonarr_handlers/history/history_handler_tests.rs +++ b/src/handlers/sonarr_handlers/history/history_handler_tests.rs @@ -845,11 +845,23 @@ mod tests { app.error = "test error".to_owned().into(); app.push_navigation_stack(ActiveSonarrBlock::History.into()); app.push_navigation_stack(ActiveSonarrBlock::History.into()); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.history = StatefulTable { + search: Some("Test".into()), + filter: Some("Test".into()), + filtered_items: Some(Vec::new()), + filtered_state: Some(TableState::default()), + ..StatefulTable::default() + }; HistoryHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::History, None).handle(); assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); assert!(app.error.text.is_empty()); + assert_eq!(app.data.sonarr_data.history.search, None); + assert_eq!(app.data.sonarr_data.history.filter, None); + assert_eq!(app.data.sonarr_data.history.filtered_items, None); + assert_eq!(app.data.sonarr_data.history.filtered_state, None); } } diff --git a/src/handlers/sonarr_handlers/history/mod.rs b/src/handlers/sonarr_handlers/history/mod.rs index 4d0597b..25e4ce4 100644 --- a/src/handlers/sonarr_handlers/history/mod.rs +++ b/src/handlers/sonarr_handlers/history/mod.rs @@ -1,3 +1,5 @@ +use log::debug; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; @@ -250,7 +252,11 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for HistoryHandler<'a, ' ActiveSonarrBlock::HistoryItemDetails | ActiveSonarrBlock::HistorySortPrompt => { self.app.pop_navigation_stack(); } - _ => handle_clear_errors(self.app), + _ => { + self.app.data.sonarr_data.history.reset_search(); + self.app.data.sonarr_data.history.reset_filter(); + handle_clear_errors(self.app); + } } } diff --git a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs index e8dae31..784ccb4 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs @@ -102,6 +102,7 @@ mod tests { ActiveSonarrBlock::SeriesSortPrompt, ActiveSonarrBlock::UpdateAllSeriesPrompt, // ActiveSonarrBlock::UpdateAndScanSeriesPrompt + // ActiveSonarrBlock::SeriesHistoryDetails, )] active_sonarr_block: ActiveSonarrBlock, ) { From 73d666d1f52476c0c45927cab5a67eb087add918 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 5 Dec 2024 19:11:54 -0700 Subject: [PATCH 35/82] feat(ui): Sonarr support for editing a series from within the series details popup --- src/app/sonarr/sonarr_context_clues.rs | 3 ++- src/app/sonarr/sonarr_context_clues_tests.rs | 5 +++++ src/handlers/sonarr_handlers/history/mod.rs | 2 -- src/ui/sonarr_ui/library/edit_series_ui.rs | 16 ++++++++++------ 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index 37878bf..5dc757c 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -25,11 +25,12 @@ pub static SERIES_CONTEXT_CLUES: [ContextClue; 10] = [ (DEFAULT_KEYBINDINGS.esc, "cancel filter"), ]; -pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 6] = [ +pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 7] = [ ( DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh.desc, ), + (DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc), (DEFAULT_KEYBINDINGS.submit, "season details"), (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), (DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc), diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index c5a9fe3..9f36aa0 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -133,6 +133,11 @@ mod tests { let (key_binding, description) = series_details_context_clues_iter.next().unwrap(); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.edit); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.edit.desc); + + let (key_binding, description) = series_details_context_clues_iter.next().unwrap(); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); assert_str_eq!(*description, "season details"); diff --git a/src/handlers/sonarr_handlers/history/mod.rs b/src/handlers/sonarr_handlers/history/mod.rs index 25e4ce4..b100e29 100644 --- a/src/handlers/sonarr_handlers/history/mod.rs +++ b/src/handlers/sonarr_handlers/history/mod.rs @@ -1,5 +1,3 @@ -use log::debug; - use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; diff --git a/src/ui/sonarr_ui/library/edit_series_ui.rs b/src/ui/sonarr_ui/library/edit_series_ui.rs index cdb2eff..3d25141 100644 --- a/src/ui/sonarr_ui/library/edit_series_ui.rs +++ b/src/ui/sonarr_ui/library/edit_series_ui.rs @@ -9,7 +9,9 @@ use ratatui::Frame; use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; use crate::app::App; use crate::models::servarr_data::sonarr::modals::EditSeriesModal; -use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_SERIES_BLOCKS}; +use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, EDIT_SERIES_BLOCKS, SERIES_DETAILS_BLOCKS, +}; use crate::models::{EnumDisplayStyle, Route}; use crate::render_selectable_input_box; use crate::ui::sonarr_ui::library::draw_library; @@ -21,7 +23,9 @@ use crate::ui::widgets::checkbox::Checkbox; use crate::ui::widgets::input_box::InputBox; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::widgets::selectable_list::SelectableList; -use crate::ui::{draw_popup_over, DrawUi}; +use crate::ui::{draw_popup, draw_popup_over, draw_popup_over_ui, DrawUi}; + +use super::series_details_ui::SeriesDetailsUi; #[cfg(test)] #[path = "edit_series_ui_tests.rs"] @@ -76,10 +80,10 @@ impl DrawUi for EditSeriesUi { Size::Long, ); } - // _ if SERIES_DETAILS_BLOCKS.contains(&context) => { - // draw_popup_over_ui::(f, app, area, draw_library, Size::Large); - // draw_popup(f, app, draw_edit_series_prompt, Size::Medium); - // } + _ if SERIES_DETAILS_BLOCKS.contains(&context) => { + draw_popup_over_ui::(f, app, area, draw_library, Size::Large); + draw_popup(f, app, draw_edit_series_prompt, Size::Medium); + } _ => (), } } From 23b1ca43717f84f3f41e1944a83b0c2f6ffd4918 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 6 Dec 2024 20:30:26 -0700 Subject: [PATCH 36/82] feat(ui): Sonarr support for the series details popup --- Cargo.lock | 20 + Cargo.toml | 2 + src/app/sonarr/mod.rs | 8 +- src/app/sonarr/sonarr_context_clues.rs | 18 + src/app/sonarr/sonarr_context_clues_tests.rs | 53 +- src/app/sonarr/sonarr_tests.rs | 16 +- src/handlers/handler_test_utils.rs | 5 + .../library/movie_details_handler.rs | 2 +- .../library/movie_details_handler_tests.rs | 3 +- src/handlers/sonarr_handlers/history/mod.rs | 2 +- .../library/library_handler_tests.rs | 59 +- src/handlers/sonarr_handlers/library/mod.rs | 7 + .../library/series_details_handler.rs | 610 +++++ .../library/series_details_handler_tests.rs | 1971 +++++++++++++++++ .../sonarr_handler_test_utils.rs | 6 +- src/models/servarr_data/sonarr/sonarr_data.rs | 12 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 26 +- .../servarr_data/sonarr/sonarr_test_utils.rs | 3 + src/models/sonarr_models.rs | 1 + src/network/sonarr_network_tests.rs | 6 +- src/ui/radarr_ui/library/movie_details_ui.rs | 1 - src/ui/sonarr_ui/library/edit_series_ui.rs | 2 +- src/ui/sonarr_ui/library/library_ui_tests.rs | 214 +- src/ui/sonarr_ui/library/mod.rs | 65 +- src/ui/sonarr_ui/library/series_details_ui.rs | 54 +- src/ui/styles.rs | 5 + src/ui/styles_tests.rs | 8 + src/ui/widgets/button.rs | 36 +- src/ui/widgets/button_tests.rs | 41 - src/ui/widgets/checkbox.rs | 20 +- src/ui/widgets/checkbox_tests.rs | 32 - src/ui/widgets/confirmation_prompt.rs | 34 +- src/ui/widgets/confirmation_prompt_tests.rs | 76 - src/ui/widgets/input_box.rs | 42 +- src/ui/widgets/input_box_tests.rs | 98 - src/ui/widgets/managarr_table.rs | 65 +- src/ui/widgets/managarr_table_tests.rs | 353 --- src/ui/widgets/message.rs | 17 +- src/ui/widgets/message_tests.rs | 38 - 39 files changed, 3075 insertions(+), 956 deletions(-) create mode 100644 src/handlers/sonarr_handlers/library/series_details_handler.rs create mode 100644 src/handlers/sonarr_handlers/library/series_details_handler_tests.rs delete mode 100644 src/ui/widgets/button_tests.rs delete mode 100644 src/ui/widgets/checkbox_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 626e5e3..e673269 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -489,12 +489,30 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_setters" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8ef033054e131169b8f0f9a7af8f5533a9436fadf3c500ed547f730f07090d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.89", +] + [[package]] name = "destructure_traitobject" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c877555693c14d2f84191cfd3ad8582790fc52b5e2274b40b59cf5f5cea25c7" +[[package]] +name = "deunicode" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00" + [[package]] name = "diff" version = "0.1.13" @@ -1319,6 +1337,8 @@ dependencies = [ "crossterm", "ctrlc", "derivative", + "derive_setters", + "deunicode", "dirs-next", "human-panic", "indicatif", diff --git a/Cargo.toml b/Cargo.toml index 937f273..0006109 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,8 @@ async-trait = "0.1.83" dirs-next = "2.0.0" managarr-tree-widget = "0.24.0" indicatif = "0.17.9" +derive_setters = "0.1.6" +deunicode = "1.6.0" [dev-dependencies] assert_cmd = "2.0.16" diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index 71f969d..8457f40 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -196,7 +196,13 @@ impl<'a> App<'a> { .current_selection() .clone() .seasons - .unwrap_or_default(); + .unwrap_or_default() + .into_iter() + .map(|mut season| { + season.title = Some(format!("Season {}", season.season_number)); + season + }) + .collect(); self.data.sonarr_data.seasons.set_items(seasons); } } diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index 5dc757c..9f91b92 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -53,6 +53,24 @@ pub static HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [ (DEFAULT_KEYBINDINGS.esc, "cancel filter"), ]; +pub static SERIES_HISTORY_CONTEXT_CLUES: [ContextClue; 9] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc), + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), + (DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc), + (DEFAULT_KEYBINDINGS.esc, "cancel filter/close"), +]; + pub static SEASON_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [ ( DEFAULT_KEYBINDINGS.refresh, diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index 9f36aa0..f5d0c1e 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -9,7 +9,7 @@ mod tests { HISTORY_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, SEASON_DETAILS_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES, - SYSTEM_TASKS_CONTEXT_CLUES, + SERIES_HISTORY_CONTEXT_CLUES, SYSTEM_TASKS_CONTEXT_CLUES, }, }; @@ -86,6 +86,57 @@ mod tests { assert_eq!(series_context_clues_iter.next(), None); } + #[test] + fn test_series_history_context_clues() { + let mut series_history_context_clues_iter = SERIES_HISTORY_CONTEXT_CLUES.iter(); + + let (key_binding, description) = series_history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); + + let (key_binding, description) = series_history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.edit); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.edit.desc); + + let (key_binding, description) = series_history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); + assert_str_eq!(*description, "details"); + + let (key_binding, description) = series_history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc); + + let (key_binding, description) = series_history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.search.desc); + + let (key_binding, description) = series_history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.filter); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.filter.desc); + + let (key_binding, description) = series_history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.auto_search); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.auto_search.desc); + + let (key_binding, description) = series_history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.update.desc); + + let (key_binding, description) = series_history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); + assert_str_eq!(*description, "cancel filter/close"); + assert_eq!(series_history_context_clues_iter.next(), None); + } + #[test] fn test_history_context_clues() { let mut history_context_clues_iter = HISTORY_CONTEXT_CLUES.iter(); diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index 6821ae9..99bc61c 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { mod sonarr_tests { - use pretty_assertions::assert_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use tokio::sync::mpsc; use crate::{ @@ -572,6 +572,13 @@ mod tests { app.populate_seasons_table().await; assert!(!app.data.sonarr_data.seasons.items.is_empty()); + assert_str_eq!( + app.data.sonarr_data.seasons.items[0] + .title + .as_ref() + .unwrap(), + "Season 0" + ); } #[tokio::test] @@ -585,6 +592,13 @@ mod tests { app.populate_seasons_table().await; assert!(!app.data.sonarr_data.seasons.items.is_empty()); + assert_str_eq!( + app.data.sonarr_data.seasons.items[0] + .title + .as_ref() + .unwrap(), + "Season 0" + ); } fn construct_app_unit<'a>() -> (App<'a>, mpsc::Receiver) { diff --git a/src/handlers/handler_test_utils.rs b/src/handlers/handler_test_utils.rs index 36e5d5f..a4b7112 100644 --- a/src/handlers/handler_test_utils.rs +++ b/src/handlers/handler_test_utils.rs @@ -323,6 +323,11 @@ mod test_utils { macro_rules! test_handler_delegation { ($handler:ident, $base:expr, $active_block:expr) => { let mut app = App::default(); + let mut series_history = $crate::models::stateful_table::StatefulTable::default(); + series_history.set_items(vec![ + $crate::models::sonarr_models::SonarrHistoryItem::default(), + ]); + app.data.sonarr_data.series_history = Some(series_history); app.push_navigation_stack($base.into()); app.push_navigation_stack($active_block.into()); diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index ef9e890..71264df 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -457,7 +457,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< _ if key == DEFAULT_KEYBINDINGS.refresh.key => { self .app - .pop_and_push_navigation_stack((self.active_radarr_block).into()); + .pop_and_push_navigation_stack(self.active_radarr_block.into()); } _ if key == DEFAULT_KEYBINDINGS.sort.key => { self diff --git a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs index 290ef67..4a90954 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs @@ -1785,6 +1785,7 @@ mod tests { let mut app = App::default(); app.is_loading = true; app.push_navigation_stack(active_radarr_block.into()); + app.is_routing = false; app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal { movie_details: ScrollableText::with_string("test".to_owned()), ..MovieDetailsModal::default() @@ -1799,7 +1800,7 @@ mod tests { .handle(); assert_eq!(app.get_current_route(), active_radarr_block.into()); - assert!(app.is_routing); + assert!(!app.is_routing); } #[rstest] diff --git a/src/handlers/sonarr_handlers/history/mod.rs b/src/handlers/sonarr_handlers/history/mod.rs index b100e29..575b60c 100644 --- a/src/handlers/sonarr_handlers/history/mod.rs +++ b/src/handlers/sonarr_handlers/history/mod.rs @@ -312,7 +312,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for HistoryHandler<'a, ' } } -fn history_sorting_options() -> Vec> { +pub(in crate::handlers::sonarr_handlers) fn history_sorting_options() -> Vec> { vec![ SortOption { name: "Source Title", diff --git a/src/handlers/sonarr_handlers/library/library_handler_tests.rs b/src/handlers/sonarr_handlers/library/library_handler_tests.rs index d3d1fae..2da1cc3 100644 --- a/src/handlers/sonarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/library_handler_tests.rs @@ -11,9 +11,7 @@ mod tests { use crate::event::Key; use crate::handlers::sonarr_handlers::library::{series_sorting_options, LibraryHandler}; use crate::handlers::KeyEventHandler; - use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, LIBRARY_BLOCKS, - }; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, LIBRARY_BLOCKS, SERIES_DETAILS_BLOCKS}; use crate::models::sonarr_models::{Series, SeriesStatus, SeriesType}; use crate::models::stateful_table::SortOption; use crate::models::HorizontallyScrollableText; @@ -615,6 +613,7 @@ mod tests { #[test] fn test_search_series_submit() { let mut app = App::default(); + app.should_ignore_quit_key = true; app.push_navigation_stack(ActiveSonarrBlock::Series.into()); app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); app @@ -629,6 +628,7 @@ mod tests { LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchSeries, None).handle(); + assert!(!app.should_ignore_quit_key); assert_str_eq!( app.data.sonarr_data.series.current_selection().title.text, "Test 2" @@ -639,6 +639,7 @@ mod tests { #[test] fn test_search_series_submit_error_on_no_search_hits() { let mut app = App::default(); + app.should_ignore_quit_key = true; app.push_navigation_stack(ActiveSonarrBlock::Series.into()); app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); app @@ -653,6 +654,7 @@ mod tests { LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchSeries, None).handle(); + assert!(!app.should_ignore_quit_key); assert_str_eq!( app.data.sonarr_data.series.current_selection().title.text, "Test 1" @@ -666,6 +668,7 @@ mod tests { #[test] fn test_search_filtered_series_submit() { let mut app = App::default(); + app.should_ignore_quit_key = true; app .data .sonarr_data @@ -685,6 +688,7 @@ mod tests { LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchSeries, None).handle(); + assert!(!app.should_ignore_quit_key); assert_str_eq!( app.data.sonarr_data.series.current_selection().title.text, "Test 2" @@ -695,6 +699,7 @@ mod tests { #[test] fn test_filter_series_submit() { let mut app = App::default(); + app.should_ignore_quit_key = true; app.push_navigation_stack(ActiveSonarrBlock::Series.into()); app.push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); app @@ -732,6 +737,7 @@ mod tests { #[test] fn test_filter_series_submit_error_on_no_filter_matches() { let mut app = App::default(); + app.should_ignore_quit_key = true; app.push_navigation_stack(ActiveSonarrBlock::Series.into()); app.push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); app @@ -946,7 +952,6 @@ mod tests { } mod test_handle_key_char { - use bimap::BiMap; use pretty_assertions::{assert_eq, assert_str_eq}; use serde_json::Number; use strum::IntoEnumIterator; @@ -1465,27 +1470,30 @@ mod tests { ); } - // #[rstest] - // fn test_delegates_series_details_blocks_to_series_details_handler( - // #[values( - // ActiveSonarrBlock::SeriesDetails, - // ActiveSonarrBlock::SeriesHistory, - // ActiveSonarrBlock::FileInfo, - // ActiveSonarrBlock::Cast, - // ActiveSonarrBlock::Crew, - // ActiveSonarrBlock::AutomaticallySearchSeriesPrompt, - // ActiveSonarrBlock::UpdateAndScanPrompt, - // ActiveSonarrBlock::ManualSearch, - // ActiveSonarrBlock::ManualSearchConfirmPrompt - // )] - // active_sonarr_block: ActiveSonarrBlock, - // ) { - // test_handler_delegation!( - // LibraryHandler, - // ActiveSonarrBlock::Series, - // active_sonarr_block - // ); - // } + #[rstest] + fn test_delegates_series_details_blocks_to_series_details_handler( + #[values( + ActiveSonarrBlock::SeriesDetails, + ActiveSonarrBlock::SeriesHistory, + ActiveSonarrBlock::SearchSeason, + ActiveSonarrBlock::SearchSeasonError, + ActiveSonarrBlock::UpdateAndScanSeriesPrompt, + ActiveSonarrBlock::AutomaticallySearchSeriesPrompt, + ActiveSonarrBlock::SearchSeriesHistory, + ActiveSonarrBlock::SearchSeriesHistoryError, + ActiveSonarrBlock::FilterSeriesHistory, + ActiveSonarrBlock::FilterSeriesHistoryError, + ActiveSonarrBlock::SeriesHistorySortPrompt, + ActiveSonarrBlock::SeriesHistoryDetails + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + LibraryHandler, + ActiveSonarrBlock::Series, + active_sonarr_block + ); + } #[rstest] fn test_delegates_edit_series_blocks_to_edit_series_handler( @@ -1711,6 +1719,7 @@ mod tests { library_handler_blocks.extend(ADD_SERIES_BLOCKS); library_handler_blocks.extend(DELETE_SERIES_BLOCKS); library_handler_blocks.extend(EDIT_SERIES_BLOCKS); + library_handler_blocks.extend(SERIES_DETAILS_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { if library_handler_blocks.contains(&active_sonarr_block) { diff --git a/src/handlers/sonarr_handlers/library/mod.rs b/src/handlers/sonarr_handlers/library/mod.rs index 47ff6ea..c91ffc1 100644 --- a/src/handlers/sonarr_handlers/library/mod.rs +++ b/src/handlers/sonarr_handlers/library/mod.rs @@ -22,6 +22,7 @@ use crate::{ use super::handle_change_tab_left_right_keys; use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::handlers::sonarr_handlers::library::series_details_handler::SeriesDetailsHandler; mod add_series_handler; mod delete_series_handler; @@ -29,6 +30,7 @@ mod delete_series_handler; #[cfg(test)] #[path = "library_handler_tests.rs"] mod library_handler_tests; +mod series_details_handler; pub(super) struct LibraryHandler<'a, 'b> { key: Key, @@ -51,6 +53,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' EditSeriesHandler::with(self.key, self.app, self.active_sonarr_block, self.context) .handle(); } + _ if SeriesDetailsHandler::accepts(self.active_sonarr_block) => { + SeriesDetailsHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle(); + } _ => self.handle_key_event(), } } @@ -59,6 +65,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' AddSeriesHandler::accepts(active_block) || DeleteSeriesHandler::accepts(active_block) || EditSeriesHandler::accepts(active_block) + || SeriesDetailsHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block) } diff --git a/src/handlers/sonarr_handlers/library/series_details_handler.rs b/src/handlers/sonarr_handlers/library/series_details_handler.rs new file mode 100644 index 0000000..e47fa18 --- /dev/null +++ b/src/handlers/sonarr_handlers/library/series_details_handler.rs @@ -0,0 +1,610 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::App; +use crate::event::Key; +use crate::handle_text_box_keys; +use crate::handlers::sonarr_handlers::history::history_sorting_options; +use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, EDIT_SERIES_SELECTION_BLOCKS, SERIES_DETAILS_BLOCKS, +}; +use crate::models::{BlockSelectionState, HorizontallyScrollableText, Scrollable}; +use crate::network::sonarr_network::SonarrEvent; + +#[cfg(test)] +#[path = "series_details_handler_tests.rs"] +mod series_details_handler_tests; + +pub(super) struct SeriesDetailsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler<'a, 'b> { + fn accepts(active_block: ActiveSonarrBlock) -> bool { + SERIES_DETAILS_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + _context: Option, + ) -> SeriesDetailsHandler<'a, 'b> { + SeriesDetailsHandler { + key, + app, + active_sonarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.active_sonarr_block == ActiveSonarrBlock::SeriesHistory { + !self.app.is_loading && self.app.data.sonarr_data.series_history.is_some() + } else { + !self.app.is_loading + } + } + + fn handle_scroll_up(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SeriesDetails => self.app.data.sonarr_data.seasons.scroll_up(), + ActiveSonarrBlock::SeriesHistory => self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .scroll_up(), + ActiveSonarrBlock::SeriesHistorySortPrompt => self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .sort + .as_mut() + .unwrap() + .scroll_up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SeriesDetails => self.app.data.sonarr_data.seasons.scroll_down(), + ActiveSonarrBlock::SeriesHistory => self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .scroll_down(), + ActiveSonarrBlock::SeriesHistorySortPrompt => self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .sort + .as_mut() + .unwrap() + .scroll_down(), + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SeriesDetails => self.app.data.sonarr_data.seasons.scroll_to_top(), + ActiveSonarrBlock::SearchSeason => self + .app + .data + .sonarr_data + .seasons + .search + .as_mut() + .unwrap() + .scroll_home(), + ActiveSonarrBlock::SearchSeriesHistory => self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .search + .as_mut() + .unwrap() + .scroll_home(), + ActiveSonarrBlock::FilterSeriesHistory => self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .filter + .as_mut() + .unwrap() + .scroll_home(), + ActiveSonarrBlock::SeriesHistory => self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .scroll_to_top(), + ActiveSonarrBlock::SeriesHistorySortPrompt => self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .sort + .as_mut() + .unwrap() + .scroll_to_top(), + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SeriesDetails => self.app.data.sonarr_data.seasons.scroll_to_bottom(), + ActiveSonarrBlock::SearchSeason => self + .app + .data + .sonarr_data + .seasons + .search + .as_mut() + .unwrap() + .reset_offset(), + ActiveSonarrBlock::SearchSeriesHistory => self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .search + .as_mut() + .unwrap() + .reset_offset(), + ActiveSonarrBlock::FilterSeriesHistory => self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .filter + .as_mut() + .unwrap() + .reset_offset(), + ActiveSonarrBlock::SeriesHistory => self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .scroll_to_bottom(), + ActiveSonarrBlock::SeriesHistorySortPrompt => self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .sort + .as_mut() + .unwrap() + .scroll_to_bottom(), + _ => (), + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SeriesDetails | ActiveSonarrBlock::SeriesHistory => match self.key { + _ if self.key == DEFAULT_KEYBINDINGS.left.key => { + self.app.data.sonarr_data.series_info_tabs.previous(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .sonarr_data + .series_info_tabs + .get_active_route(), + ); + } + _ if self.key == DEFAULT_KEYBINDINGS.right.key => { + self.app.data.sonarr_data.series_info_tabs.next(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .sonarr_data + .series_info_tabs + .get_active_route(), + ); + } + _ => (), + }, + ActiveSonarrBlock::UpdateAndScanSeriesPrompt + | ActiveSonarrBlock::AutomaticallySearchSeriesPrompt => { + handle_prompt_toggle(self.app, self.key) + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SeriesDetails if !self.app.data.sonarr_data.seasons.is_empty() => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + } + ActiveSonarrBlock::SeriesHistory + if !self + .app + .data + .sonarr_data + .series_history + .as_ref() + .expect("Series history should be Some") + .is_empty() => + { + self + .app + .push_navigation_stack(ActiveSonarrBlock::SeriesHistoryDetails.into()); + } + ActiveSonarrBlock::AutomaticallySearchSeriesPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::TriggerAutomaticSeriesSearch(None)); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::UpdateAndScanSeriesPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::UpdateAndScanSeries(None)); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::SearchSeason => { + self.app.pop_navigation_stack(); + self.app.should_ignore_quit_key = false; + + if self.app.data.sonarr_data.seasons.search.is_some() { + let has_match = self.app.data.sonarr_data.seasons.apply_search(|season| { + season + .title + .as_ref() + .expect("Season was not populated with title in handlers") + }); + + if !has_match { + self + .app + .push_navigation_stack(ActiveSonarrBlock::SearchSeasonError.into()); + } + } + } + ActiveSonarrBlock::SearchSeriesHistory => { + self.app.pop_navigation_stack(); + self.app.should_ignore_quit_key = false; + + if self + .app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .search + .is_some() + { + let has_match = self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .apply_search(|history_item| &history_item.source_title.text); + + if !has_match { + self + .app + .push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistoryError.into()); + } + } + } + ActiveSonarrBlock::FilterSeriesHistory => { + self.app.pop_navigation_stack(); + self.app.should_ignore_quit_key = false; + + if self + .app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .filter + .is_some() + { + let has_matches = self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .apply_filter(|history_item| &history_item.source_title.text); + + if !has_matches { + self + .app + .push_navigation_stack(ActiveSonarrBlock::FilterSeriesHistoryError.into()); + } + } + } + ActiveSonarrBlock::SeriesHistorySortPrompt => { + self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .items + .sort_by(|a, b| a.id.cmp(&b.id)); + self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .apply_sorting(); + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SearchSeason | ActiveSonarrBlock::SearchSeasonError => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.seasons.reset_search(); + self.app.should_ignore_quit_key = false; + } + ActiveSonarrBlock::SearchSeriesHistory | ActiveSonarrBlock::SearchSeriesHistoryError => { + self.app.pop_navigation_stack(); + self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .reset_search(); + self.app.should_ignore_quit_key = false; + } + ActiveSonarrBlock::FilterSeriesHistory | ActiveSonarrBlock::FilterSeriesHistoryError => { + self.app.pop_navigation_stack(); + self + .app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .reset_filter(); + self.app.should_ignore_quit_key = false; + } + ActiveSonarrBlock::UpdateAndScanSeriesPrompt + | ActiveSonarrBlock::AutomaticallySearchSeriesPrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.prompt_confirm = false; + } + ActiveSonarrBlock::SeriesHistoryDetails | ActiveSonarrBlock::SeriesHistorySortPrompt => { + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::SeriesHistory => { + if self.app.data.sonarr_data.series_history.as_ref().expect("Series history is not populated").filtered_items.is_some() { + self.app.data.sonarr_data.series_history.as_mut().expect("Series history is not populated").reset_filter(); + } else { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.reset_series_info_tabs(); + } + } + ActiveSonarrBlock::SeriesDetails => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.reset_series_info_tabs(); + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_sonarr_block { + ActiveSonarrBlock::SeriesDetails => match self.key { + _ if key == DEFAULT_KEYBINDINGS.refresh.key => self + .app + .pop_and_push_navigation_stack(self.active_sonarr_block.into()), + _ if key == DEFAULT_KEYBINDINGS.search.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::SearchSeason.into()); + self.app.data.sonarr_data.seasons.search = Some(HorizontallyScrollableText::default()); + self.app.should_ignore_quit_key = true; + } + _ if key == DEFAULT_KEYBINDINGS.auto_search.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::AutomaticallySearchSeriesPrompt.into()); + } + _ if key == DEFAULT_KEYBINDINGS.update.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::UpdateAndScanSeriesPrompt.into()); + } + _ if key == DEFAULT_KEYBINDINGS.edit.key => { + self.app.push_navigation_stack( + ( + ActiveSonarrBlock::EditSeriesPrompt, + Some(self.active_sonarr_block), + ) + .into(), + ); + self.app.data.sonarr_data.edit_series_modal = Some((&self.app.data.sonarr_data).into()); + self.app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); + } + _ => (), + }, + ActiveSonarrBlock::SeriesHistory => match self.key { + _ if key == DEFAULT_KEYBINDINGS.refresh.key => self + .app + .pop_and_push_navigation_stack(self.active_sonarr_block.into()), + _ if key == DEFAULT_KEYBINDINGS.auto_search.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::AutomaticallySearchSeriesPrompt.into()); + } + _ if key == DEFAULT_KEYBINDINGS.search.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistory.into()); + self + .app + .data + .sonarr_data + .series_history + .as_mut() + .expect("Series history should be populated") + .search = Some(HorizontallyScrollableText::default()); + self.app.should_ignore_quit_key = true; + } + _ if key == DEFAULT_KEYBINDINGS.filter.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::FilterSeriesHistory.into()); + self + .app + .data + .sonarr_data + .series_history + .as_mut() + .expect("Series history should be populated") + .reset_filter(); + self + .app + .data + .sonarr_data + .series_history + .as_mut() + .expect("Series history should be populated") + .filter = Some(HorizontallyScrollableText::default()); + self.app.should_ignore_quit_key = true; + } + _ if key == DEFAULT_KEYBINDINGS.sort.key => { + self + .app + .data + .sonarr_data + .series_history + .as_mut() + .expect("Series history should be populated") + .sorting(history_sorting_options()); + self + .app + .push_navigation_stack(ActiveSonarrBlock::SeriesHistorySortPrompt.into()); + } + _ if key == DEFAULT_KEYBINDINGS.edit.key => { + self.app.push_navigation_stack( + ( + ActiveSonarrBlock::EditSeriesPrompt, + Some(self.active_sonarr_block), + ) + .into(), + ); + self.app.data.sonarr_data.edit_series_modal = Some((&self.app.data.sonarr_data).into()); + self.app.data.sonarr_data.selected_block = + BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); + } + _ if key == DEFAULT_KEYBINDINGS.update.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::UpdateAndScanSeriesPrompt.into()); + } + _ => (), + }, + ActiveSonarrBlock::SearchSeason => { + handle_text_box_keys!( + self, + key, + self.app.data.sonarr_data.seasons.search.as_mut().unwrap() + ) + } + ActiveSonarrBlock::SearchSeriesHistory => { + handle_text_box_keys!( + self, + key, + self.app.data.sonarr_data.series_history.as_mut().expect("Series history should be populated").search.as_mut().unwrap() + ) + } + ActiveSonarrBlock::FilterSeriesHistory => { + handle_text_box_keys!( + self, + key, + self.app.data.sonarr_data.series_history.as_mut().expect("Series history should be populated").filter.as_mut().unwrap() + ) + } + ActiveSonarrBlock::AutomaticallySearchSeriesPrompt => { + if key == DEFAULT_KEYBINDINGS.confirm.key { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::TriggerAutomaticSeriesSearch(None)); + + self.app.pop_navigation_stack(); + } + } + ActiveSonarrBlock::UpdateAndScanSeriesPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::UpdateAndScanSeries(None)); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } +} diff --git a/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs new file mode 100644 index 0000000..a1861dc --- /dev/null +++ b/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs @@ -0,0 +1,1971 @@ +#[cfg(test)] +mod tests { + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handlers::sonarr_handlers::library::series_details_handler::SeriesDetailsHandler; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, SERIES_DETAILS_BLOCKS, + }; + use crate::models::sonarr_models::Season; + use crate::models::sonarr_models::SonarrHistoryItem; + use crate::models::stateful_table::{SortOption, StatefulTable}; + use core::sync::atomic::Ordering::SeqCst; + use pretty_assertions::assert_str_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + mod test_handle_scroll_up_and_down { + use super::*; + use pretty_assertions::assert_eq; + + #[rstest] + fn test_seasons_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 1, + ..Season::default() + }, + Season { + season_number: 2, + ..Season::default() + }, + ]); + + SeriesDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SeriesDetails, None).handle(); + + assert_eq!( + app + .data + .sonarr_data + .seasons + .current_selection() + .season_number, + 2 + ); + + SeriesDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SeriesDetails, None).handle(); + + assert_eq!( + app + .data + .sonarr_data + .seasons + .current_selection() + .season_number, + 1 + ); + } + + #[rstest] + fn test_series_history_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![ + SonarrHistoryItem { + source_title: "Test 1".into(), + ..SonarrHistoryItem::default() + }, + SonarrHistoryItem { + source_title: "Test 2".into(), + ..SonarrHistoryItem::default() + }, + ]); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SeriesHistory, None).handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .current_selection() + .source_title + .text, + "Test 2" + ); + + SeriesDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SeriesHistory, None).handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .current_selection() + .source_title + .text, + "Test 1" + ); + } + + #[rstest] + fn test_series_history_sort_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let series_history_item_field_vec = sort_options(); + let mut app = App::default(); + app.data.sonarr_data.series_history = Some(StatefulTable::default()); + app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .sorting(sort_options()); + + if key == Key::Up { + for i in (0..series_history_item_field_vec.len()).rev() { + SeriesDetailsHandler::with( + key, + &mut app, + ActiveSonarrBlock::SeriesHistorySortPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort + .as_ref() + .unwrap() + .current_selection(), + &series_history_item_field_vec[i] + ); + } + } else { + for i in 0..series_history_item_field_vec.len() { + SeriesDetailsHandler::with( + key, + &mut app, + ActiveSonarrBlock::SeriesHistorySortPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort + .as_ref() + .unwrap() + .current_selection(), + &series_history_item_field_vec[(i + 1) % series_history_item_field_vec.len()] + ); + } + } + } + } + + mod test_handle_home_end { + use super::*; + use pretty_assertions::{assert_eq, assert_str_eq}; + + #[test] + fn test_seasons_home_and_end() { + let mut app = App::default(); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 1, + ..Season::default() + }, + Season { + season_number: 2, + ..Season::default() + }, + Season { + season_number: 3, + ..Season::default() + }, + ]); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .seasons + .current_selection() + .season_number, + 3 + ); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .seasons + .current_selection() + .season_number, + 1 + ); + } + + #[test] + fn test_series_history_home_and_end() { + let mut app = App::default(); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![ + SonarrHistoryItem { + source_title: "Test 1".into(), + ..SonarrHistoryItem::default() + }, + SonarrHistoryItem { + source_title: "Test 2".into(), + ..SonarrHistoryItem::default() + }, + SonarrHistoryItem { + source_title: "Test 3".into(), + ..SonarrHistoryItem::default() + }, + ]); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SeriesHistory, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .current_selection() + .source_title + .text, + "Test 3" + ); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SeriesHistory, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .current_selection() + .source_title + .text, + "Test 1" + ); + } + + #[test] + fn test_season_search_box_home_end_keys() { + let mut app = App::default(); + app + .data + .sonarr_data + .seasons + .set_items(vec![Season::default()]); + app.data.sonarr_data.seasons.search = Some("Test".into()); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SearchSeason, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .seasons + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 4 + ); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SearchSeason, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .seasons + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 0 + ); + } + + #[test] + fn test_series_history_search_box_home_end_keys() { + let mut app = App::default(); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + series_history.search = Some("Test".into()); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SearchSeriesHistory, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 4 + ); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SearchSeriesHistory, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 0 + ); + } + + #[test] + fn test_series_history_filter_box_home_end_keys() { + let mut app = App::default(); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + series_history.filter = Some("Test".into()); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::FilterSeriesHistory, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .filter + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 4 + ); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::FilterSeriesHistory, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .filter + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 0 + ); + } + + #[test] + fn test_series_history_sort_home_end() { + let series_history_item_field_vec = sort_options(); + let mut app = App::default(); + let mut series_history = StatefulTable::default(); + series_history.sorting(sort_options()); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SeriesHistorySortPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort + .as_ref() + .unwrap() + .current_selection(), + &series_history_item_field_vec[series_history_item_field_vec.len() - 1] + ); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SeriesHistorySortPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort + .as_ref() + .unwrap() + .current_selection(), + &series_history_item_field_vec[0] + ); + } + } + + mod test_handle_left_right_actions { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_left_right_prompt_toggle( + #[values( + ActiveSonarrBlock::AutomaticallySearchSeriesPrompt, + ActiveSonarrBlock::UpdateAndScanSeriesPrompt + )] + active_sonarr_block: ActiveSonarrBlock, + #[values(Key::Left, Key::Right)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + + SeriesDetailsHandler::with(key, &mut app, active_sonarr_block, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + SeriesDetailsHandler::with(key, &mut app, active_sonarr_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[rstest] + #[case(ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory)] + #[case(ActiveSonarrBlock::SeriesHistory, ActiveSonarrBlock::SeriesDetails)] + fn test_series_details_tabs_left_right_action( + #[case] left_block: ActiveSonarrBlock, + #[case] right_block: ActiveSonarrBlock, + #[values(true, false)] is_ready: bool, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.is_loading = is_ready; + app.push_navigation_stack(right_block.into()); + app.data.sonarr_data.series_info_tabs.index = app + .data + .sonarr_data + .series_info_tabs + .tabs + .iter() + .position(|tab_route| tab_route.route == right_block.into()) + .unwrap_or_default(); + + SeriesDetailsHandler::with(DEFAULT_KEYBINDINGS.left.key, &mut app, right_block, None) + .handle(); + + assert_eq!( + app.get_current_route(), + app.data.sonarr_data.series_info_tabs.get_active_route() + ); + assert_eq!(app.get_current_route(), left_block.into()); + + SeriesDetailsHandler::with(DEFAULT_KEYBINDINGS.right.key, &mut app, left_block, None) + .handle(); + + assert_eq!( + app.get_current_route(), + app.data.sonarr_data.series_info_tabs.get_active_route() + ); + assert_eq!(app.get_current_route(), right_block.into()); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use crate::extended_stateful_iterable_vec; + use crate::models::HorizontallyScrollableText; + use crate::network::sonarr_network::SonarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_series_details_submit() { + let mut app = App::default(); + app + .data + .sonarr_data + .seasons + .set_items(extended_stateful_iterable_vec!(Season, Option)); + + SeriesDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeriesDetails, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonDetails.into() + ); + } + + #[test] + fn test_series_details_submit_no_op_on_empty_seasons_table() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + + SeriesDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeriesDetails, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesDetails.into() + ); + } + + #[test] + fn test_series_details_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + + SeriesDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeriesDetails, None) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + } + + #[test] + fn test_series_history_submit() { + let mut app = App::default(); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeriesHistory, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesHistoryDetails.into() + ); + } + + #[test] + fn test_series_history_submit_no_op_when_series_history_is_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + app.data.sonarr_data.series_history = Some(StatefulTable::default()); + + SeriesDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeriesHistory, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesHistory.into() + ); + } + + #[test] + fn test_series_history_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + + SeriesDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeriesHistory, None) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + } + + #[rstest] + #[case( + ActiveSonarrBlock::AutomaticallySearchSeriesPrompt, + SonarrEvent::TriggerAutomaticSeriesSearch(None) + )] + #[case( + ActiveSonarrBlock::UpdateAndScanSeriesPrompt, + SonarrEvent::UpdateAndScanSeries(None) + )] + fn test_series_details_prompt_confirm_submit( + #[case] prompt_block: ActiveSonarrBlock, + #[case] expected_action: SonarrEvent, + ) { + let mut app = App::default(); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + SeriesDetailsHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesDetails.into() + ); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(expected_action) + ); + } + + #[rstest] + fn test_series_details_prompt_decline_submit( + #[values( + ActiveSonarrBlock::AutomaticallySearchSeriesPrompt, + ActiveSonarrBlock::UpdateAndScanSeriesPrompt + )] + prompt_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + SeriesDetailsHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesDetails.into() + ); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + } + + #[test] + fn test_search_seasons_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeason.into()); + app + .data + .sonarr_data + .seasons + .set_items(extended_stateful_iterable_vec!(Season, Option)); + app.data.sonarr_data.seasons.search = Some("Test 2".into()); + + SeriesDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchSeason, None) + .handle(); + + assert_str_eq!( + app + .data + .sonarr_data + .seasons + .current_selection() + .title + .as_ref() + .unwrap(), + "Test 2" + ); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesDetails.into() + ); + } + + #[test] + fn test_search_seasons_submit_error_on_no_search_hits() { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeason.into()); + app + .data + .sonarr_data + .seasons + .set_items(extended_stateful_iterable_vec!(Season, Option)); + app.data.sonarr_data.seasons.search = Some("Test 5".into()); + + SeriesDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchSeason, None) + .handle(); + + assert!(!app.should_ignore_quit_key); + assert_str_eq!( + app + .data + .sonarr_data + .seasons + .current_selection() + .title + .as_ref() + .unwrap(), + "Test 1" + ); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SearchSeasonError.into() + ); + } + + #[test] + fn test_search_series_history_submit() { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistory.into()); + let mut series_history = StatefulTable::default(); + series_history.set_items(extended_stateful_iterable_vec!( + SonarrHistoryItem, + HorizontallyScrollableText, + source_title + )); + series_history.search = Some("Test 2".into()); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::SearchSeriesHistory, + None, + ) + .handle(); + + assert!(!app.should_ignore_quit_key); + assert_str_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .current_selection() + .source_title + .text, + "Test 2" + ); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesHistory.into() + ); + } + + #[test] + fn test_search_series_history_submit_error_on_no_search_hits() { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistory.into()); + let mut series_history = StatefulTable::default(); + series_history.set_items(extended_stateful_iterable_vec!( + SonarrHistoryItem, + HorizontallyScrollableText, + source_title + )); + series_history.search = Some("Test 5".into()); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::SearchSeriesHistory, + None, + ) + .handle(); + + assert!(!app.should_ignore_quit_key); + assert_str_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .current_selection() + .source_title + .text, + "Test 1" + ); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SearchSeriesHistoryError.into() + ); + } + + #[test] + fn test_filter_series_history_submit() { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + app.push_navigation_stack(ActiveSonarrBlock::FilterSeriesHistory.into()); + let mut series_history = StatefulTable::default(); + series_history.set_items(extended_stateful_iterable_vec!( + SonarrHistoryItem, + HorizontallyScrollableText, + source_title + )); + series_history.filter = Some("Test".into()); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::FilterSeriesHistory, + None, + ) + .handle(); + + assert!(app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .filtered_items + .is_some()); + assert!(!app.should_ignore_quit_key); + assert_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .filtered_items + .as_ref() + .unwrap() + .len(), + 3 + ); + assert_str_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .current_selection() + .source_title + .text, + "Test 1" + ); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesHistory.into() + ); + } + + #[test] + fn test_filter_series_history_submit_error_on_no_filter_matches() { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistory.into()); + let mut series_history = StatefulTable::default(); + series_history.set_items(extended_stateful_iterable_vec!( + SonarrHistoryItem, + HorizontallyScrollableText, + source_title + )); + series_history.filter = Some("Test 5".into()); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::FilterSeriesHistory, + None, + ) + .handle(); + + assert!(!app.should_ignore_quit_key); + assert!(app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .filtered_items + .is_none()); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::FilterSeriesHistoryError.into() + ); + } + + #[test] + fn test_series_history_sort_prompt_submit() { + let mut app = App::default(); + let mut series_history = StatefulTable::default(); + let series_history_vec = vec![ + SonarrHistoryItem { + id: 3, + source_title: "Test 1".into(), + ..SonarrHistoryItem::default() + }, + SonarrHistoryItem { + id: 2, + source_title: "Test 2".into(), + ..SonarrHistoryItem::default() + }, + SonarrHistoryItem { + id: 1, + source_title: "Test 3".into(), + ..SonarrHistoryItem::default() + }, + ]; + series_history.set_items(series_history_vec.clone()); + series_history.sorting(sort_options()); + series_history.sort_asc = true; + app.data.sonarr_data.series_history = Some(series_history); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistorySortPrompt.into()); + + let mut expected_vec = series_history_vec; + expected_vec.sort_by(|a, b| a.id.cmp(&b.id)); + expected_vec.reverse(); + + SeriesDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::SeriesHistorySortPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesHistory.into() + ); + assert_eq!( + app.data.sonarr_data.series_history.as_ref().unwrap().items, + expected_vec + ); + } + } + + mod test_handle_esc { + use super::*; + use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; + use crate::models::stateful_table::StatefulTable; + use pretty_assertions::assert_eq; + use ratatui::widgets::TableState; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_search_season_block_esc( + #[values(ActiveSonarrBlock::SearchSeason, ActiveSonarrBlock::SearchSeasonError)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(active_sonarr_block.into()); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.seasons.search = Some("Test".into()); + + SeriesDetailsHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesDetails.into() + ); + assert!(!app.should_ignore_quit_key); + assert_eq!(app.data.sonarr_data.seasons.search, None); + } + + #[rstest] + fn test_search_series_history_block_esc( + #[values( + ActiveSonarrBlock::SearchSeriesHistory, + ActiveSonarrBlock::SearchSeriesHistoryError + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + app.push_navigation_stack(active_sonarr_block.into()); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.series_history.as_mut().unwrap().search = Some("Test".into()); + + SeriesDetailsHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesHistory.into() + ); + assert!(!app.should_ignore_quit_key); + assert_eq!( + app.data.sonarr_data.series_history.as_ref().unwrap().search, + None + ); + } + + #[rstest] + fn test_filter_series_history_block_esc( + #[values( + ActiveSonarrBlock::FilterSeriesHistory, + ActiveSonarrBlock::FilterSeriesHistoryError + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + app.push_navigation_stack(active_sonarr_block.into()); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.series_history = Some(StatefulTable { + filter: Some("Test".into()), + filtered_items: Some(Vec::new()), + filtered_state: Some(TableState::default()), + ..StatefulTable::default() + }); + + SeriesDetailsHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesHistory.into() + ); + assert!(!app.should_ignore_quit_key); + assert_eq!( + app.data.sonarr_data.series_history.as_ref().unwrap().filter, + None + ); + assert_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .filtered_items, + None + ); + assert_eq!( + app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .filtered_state, + None + ); + } + + #[test] + fn test_series_history_sort_prompt_block_esc() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistorySortPrompt.into()); + + SeriesDetailsHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::SeriesHistorySortPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesHistory.into() + ); + } + + #[test] + fn test_series_history_details_block_esc() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistoryDetails.into()); + + SeriesDetailsHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::SeriesHistoryDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesHistory.into() + ); + } + + #[rstest] + fn test_series_details_prompts_esc( + #[values( + ActiveSonarrBlock::AutomaticallySearchSeriesPrompt, + ActiveSonarrBlock::UpdateAndScanSeriesPrompt + )] + prompt_block: ActiveSonarrBlock, + #[values(true, false)] is_ready: bool, + ) { + let mut app = App::default(); + app.is_loading = is_ready; + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + SeriesDetailsHandler::with(ESC_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesDetails.into() + ); + } + + #[test] + fn test_series_history_esc_resets_filter_if_one_is_set_instead_of_closing_the_window() { + let mut app = App::default(); + let series_history = StatefulTable { + filter: Some("Test".into()), + filtered_items: Some(vec![SonarrHistoryItem::default()]), + filtered_state: Some(TableState::default()), + ..StatefulTable::default() + }; + app.data.sonarr_data.series_history = Some(series_history); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + + SeriesDetailsHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::SeriesHistory, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesHistory.into() + ); + assert!(app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .filter + .is_none()); + assert!(app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .filtered_items + .is_none()); + assert!(app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .filtered_state + .is_none()); + } + } + + mod test_handle_key_char { + use super::*; + use crate::handlers::sonarr_handlers::history::history_sorting_options; + use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; + use crate::models::servarr_data::sonarr::sonarr_data::SonarrData; + use crate::models::sonarr_models::{Series, SeriesType}; + use crate::models::HorizontallyScrollableText; + use crate::test_edit_series_key; + use pretty_assertions::{assert_eq, assert_str_eq}; + use ratatui::widgets::TableState; + use serde_json::Number; + use strum::IntoEnumIterator; + use crate::network::sonarr_network::SonarrEvent; + + #[test] + fn test_search_season_key() { + let mut app = App::default(); + app + .data + .sonarr_data + .seasons + .set_items(vec![Season::default()]); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SearchSeason.into() + ); + assert!(app.should_ignore_quit_key); + assert_eq!( + app.data.sonarr_data.seasons.search, + Some(HorizontallyScrollableText::default()) + ); + } + + #[test] + fn test_search_season_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app + .data + .sonarr_data + .seasons + .set_items(vec![Season::default()]); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesDetails.into() + ); + assert!(!app.should_ignore_quit_key); + assert_eq!(app.data.sonarr_data.seasons.search, None); + } + + #[test] + fn test_search_series_history_key() { + let mut app = App::default(); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::SeriesHistory, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SearchSeriesHistory.into() + ); + assert!(app.should_ignore_quit_key); + assert_eq!( + app.data.sonarr_data.series_history.as_ref().unwrap().search, + Some(HorizontallyScrollableText::default()) + ); + } + + #[test] + fn test_search_series_history_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::SeriesHistory, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesHistory.into() + ); + assert!(!app.should_ignore_quit_key); + assert_eq!(app.data.sonarr_data.seasons.search, None); + } + + #[test] + fn test_filter_series_history_key() { + let mut app = App::default(); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::SeriesHistory, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::FilterSeriesHistory.into() + ); + assert!(app.should_ignore_quit_key); + assert_eq!( + app.data.sonarr_data.series_history.as_ref().unwrap().filter, + Some(HorizontallyScrollableText::default()) + ); + } + + #[test] + fn test_filter_series_history_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::SeriesHistory, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesHistory.into() + ); + assert!(!app.should_ignore_quit_key); + assert_eq!(app.data.sonarr_data.seasons.filter, None); + } + + #[test] + fn test_filter_series_history_key_resets_previous_filter() { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + series_history.filter = Some("Test".into()); + series_history.filtered_items = Some(vec![SonarrHistoryItem::default()]); + series_history.filtered_state = Some(TableState::default()); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::SeriesHistory, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::FilterSeriesHistory.into() + ); + assert!(app.should_ignore_quit_key); + assert_eq!( + app.data.sonarr_data.series_history.as_ref().unwrap().filter, + Some(HorizontallyScrollableText::default()) + ); + assert!(app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .filtered_items + .is_none()); + assert!(app + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .filtered_state + .is_none()); + } + + #[rstest] + fn test_series_details_edit_key( + #[values(ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory)] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_edit_series_key!( + SeriesDetailsHandler, + active_sonarr_block, + active_sonarr_block + ); + } + + #[rstest] + fn test_series_edit_key_no_op_when_not_ready( + #[values(ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(active_sonarr_block.into()); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + assert!(app.data.sonarr_data.edit_series_modal.is_none()); + } + + #[rstest] + fn test_auto_search_key( + #[values(ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.series_history = Some(series_history); + app.push_navigation_stack(active_sonarr_block.into()); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AutomaticallySearchSeriesPrompt.into() + ); + } + + #[rstest] + fn test_auto_search_key_no_op_when_not_ready( + #[values(ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(active_sonarr_block.into()); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + active_sonarr_block.into() + ); + } + + #[rstest] + fn test_update_key( + #[values(ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.series_history = Some(series_history); + app.push_navigation_stack(active_sonarr_block.into()); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::UpdateAndScanSeriesPrompt.into() + ); + } + + #[rstest] + fn test_update_key_no_op_when_not_ready( + #[values(ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(active_sonarr_block.into()); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + active_sonarr_block.into() + ); + } + + #[rstest] + fn test_refresh_key( + #[values(ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.series_history = Some(series_history); + app.push_navigation_stack(active_sonarr_block.into()); + app.is_routing = false; + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + active_sonarr_block.into() + ); + assert!(app.is_routing); + } + + #[rstest] + fn test_refresh_key_no_op_when_not_ready( + #[values(ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(active_sonarr_block.into()); + app.is_routing = false; + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + active_sonarr_block.into() + ); + assert!(!app.is_routing); + } + + #[test] + fn test_sort_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.sort.key, + &mut app, + ActiveSonarrBlock::SeriesHistory, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesHistorySortPrompt.into() + ); + assert_eq!( + app.data.sonarr_data.series_history.as_ref().unwrap().sort.as_ref().unwrap().items, + history_sorting_options() + ); + assert!(!app.data.sonarr_data.series_history.as_ref().unwrap().sort_asc); + } + + #[test] + fn test_sort_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + app.is_routing = false; + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.sort.key, + &mut app, + ActiveSonarrBlock::SeriesHistory, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::SeriesHistory.into()); + assert!(app.data.sonarr_data.series_history.as_ref().unwrap().sort.is_none()); + assert!(!app.data.sonarr_data.series_history.as_ref().unwrap().sort_asc); + assert!(!app.is_routing); + } + + #[test] + fn test_search_season_box_backspace_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeason.into()); + app.data.sonarr_data.seasons.search = Some("Test".into()); + app + .data + .sonarr_data + .seasons + .set_items(vec![Season::default()]); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::SearchSeason, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.seasons.search.as_ref().unwrap().text, + "Tes" + ); + } + + #[test] + fn test_search_series_history_box_backspace_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistory.into()); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + series_history.search = Some("Test".into()); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::SearchSeriesHistory, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.series_history.as_ref().unwrap().search.as_ref().unwrap().text, + "Tes" + ); + } + + #[test] + fn test_filter_series_history_box_backspace_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::FilterSeriesHistory.into()); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + series_history.filter = Some("Test".into()); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveSonarrBlock::FilterSeriesHistory, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.series_history.as_ref().unwrap().filter.as_ref().unwrap().text, + "Tes" + ); + } + + #[test] + fn test_search_season_box_char_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeason.into()); + app + .data + .sonarr_data + .seasons + .set_items(vec![Season::default()]); + app.data.sonarr_data.seasons.search = Some(HorizontallyScrollableText::default()); + + SeriesDetailsHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::SearchSeason, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.seasons.search.as_ref().unwrap().text, + "h" + ); + } + + #[test] + fn test_search_series_history_box_char_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistory.into()); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + series_history.search = Some(HorizontallyScrollableText::default()); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::SearchSeriesHistory, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.series_history.as_ref().unwrap().search.as_ref().unwrap().text, + "h" + ); + } + + #[test] + fn test_filter_series_history_box_char_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::FilterSeriesHistory.into()); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + series_history.filter = Some(HorizontallyScrollableText::default()); + app.data.sonarr_data.series_history = Some(series_history); + + SeriesDetailsHandler::with( + Key::Char('h'), + &mut app, + ActiveSonarrBlock::FilterSeriesHistory, + None, + ) + .handle(); + + assert_str_eq!( + app.data.sonarr_data.series_history.as_ref().unwrap().filter.as_ref().unwrap().text, + "h" + ); + } + + #[rstest] + #[case( + ActiveSonarrBlock::AutomaticallySearchSeriesPrompt, + SonarrEvent::TriggerAutomaticSeriesSearch(None) + )] + #[case( + ActiveSonarrBlock::UpdateAndScanSeriesPrompt, + SonarrEvent::UpdateAndScanSeries(None) + )] + fn test_series_details_prompt_confirm_confirm_key( + #[case] prompt_block: ActiveSonarrBlock, + #[case] expected_action: SonarrEvent, + #[values(ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory)] active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(active_sonarr_block.into()); + app.push_navigation_stack(prompt_block.into()); + + SeriesDetailsHandler::with(DEFAULT_KEYBINDINGS.confirm.key, &mut app, prompt_block, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + active_sonarr_block.into() + ); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(expected_action) + ); + } + } + + #[test] + fn test_series_details_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block) { + assert!(SeriesDetailsHandler::accepts(active_sonarr_block)); + } else { + assert!(!SeriesDetailsHandler::accepts(active_sonarr_block)); + } + }); + } + + #[test] + fn test_series_details_handler_is_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.is_loading = true; + + let handler = SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_series_details_handler_is_not_ready_when_not_loading_and_series_history_is_none() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + + let handler = SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeriesHistory, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_series_details_handler_ready_when_not_loading_and_series_history_is_some() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.data.sonarr_data.series_history = Some(StatefulTable::default()); + + let handler = SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeriesHistory, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_series_details_handler_ready_when_not_loading_for_series_details() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + + let handler = SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ); + + assert!(handler.is_ready()); + } + + fn sort_options() -> Vec> { + vec![SortOption { + name: "Test 1", + cmp_fn: Some(|a, b| { + b.source_title + .text + .to_lowercase() + .cmp(&a.source_title.text.to_lowercase()) + }), + }] + } +} diff --git a/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs index 759a2d3..226e2dd 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs @@ -7,15 +7,15 @@ mod utils { ($handler:ident, $block:expr, $context:expr) => { let mut app = App::default(); let mut sonarr_data = SonarrData { - quality_profile_map: BiMap::from_iter([ + quality_profile_map: bimap::BiMap::from_iter([ (2222, "HD - 1080p".to_owned()), (1111, "Any".to_owned()), ]), - language_profiles_map: BiMap::from_iter([ + language_profiles_map: bimap::BiMap::from_iter([ (2222, "English".to_owned()), (1111, "Any".to_owned()), ]), - tags_map: BiMap::from_iter([(1, "test".to_owned())]), + tags_map: bimap::BiMap::from_iter([(1, "test".to_owned())]), ..create_test_sonarr_data() }; sonarr_data.series.set_items(vec![Series { diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 70fd9ca..38ee436 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -10,6 +10,7 @@ use crate::{ }, sonarr::sonarr_context_clues::{ HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES, + SERIES_HISTORY_CONTEXT_CLUES, }, }, models::{ @@ -80,6 +81,12 @@ impl<'a> SonarrData<'a> { self.delete_series_files = false; self.add_list_exclusion = false; } + + pub fn reset_series_info_tabs(&mut self) { + self.series_history = None; + self.seasons = StatefulTable::default(); + self.series_info_tabs.index = 0; + } } impl<'a> Default for SonarrData<'a> { @@ -174,7 +181,7 @@ impl<'a> Default for SonarrData<'a> { title: "History", route: ActiveSonarrBlock::SeriesHistory.into(), help: String::new(), - contextual_help: Some(build_context_clue_string(&HISTORY_CONTEXT_CLUES)), + contextual_help: Some(build_context_clue_string(&SERIES_HISTORY_CONTEXT_CLUES)), }, ]), } @@ -304,12 +311,13 @@ pub static LIBRARY_BLOCKS: [ActiveSonarrBlock; 7] = [ ActiveSonarrBlock::UpdateAllSeriesPrompt, ]; -pub static SERIES_DETAILS_BLOCKS: [ActiveSonarrBlock; 11] = [ +pub static SERIES_DETAILS_BLOCKS: [ActiveSonarrBlock; 12] = [ ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory, ActiveSonarrBlock::SearchSeason, ActiveSonarrBlock::SearchSeasonError, ActiveSonarrBlock::UpdateAndScanSeriesPrompt, + ActiveSonarrBlock::AutomaticallySearchSeriesPrompt, ActiveSonarrBlock::SearchSeriesHistory, ActiveSonarrBlock::SearchSeriesHistoryError, ActiveSonarrBlock::FilterSeriesHistory, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 28f2d34..6e82dc3 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -4,6 +4,9 @@ mod tests { use chrono::{DateTime, Utc}; use pretty_assertions::{assert_eq, assert_str_eq}; + use crate::app::sonarr::sonarr_context_clues::SERIES_HISTORY_CONTEXT_CLUES; + use crate::models::sonarr_models::{Season, SonarrHistoryItem}; + use crate::models::stateful_table::StatefulTable; use crate::{ app::{ context_clues::{ @@ -56,6 +59,24 @@ mod tests { assert!(!sonarr_data.add_list_exclusion); } + #[test] + fn test_reset_series_info_tabs() { + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + let mut sonarr_data = SonarrData { + series_history: Some(series_history), + ..SonarrData::default() + }; + sonarr_data.seasons.set_items(vec![Season::default()]); + sonarr_data.series_info_tabs.index = 1; + + sonarr_data.reset_series_info_tabs(); + + assert!(sonarr_data.series_history.is_none()); + assert!(sonarr_data.seasons.is_empty()); + assert_eq!(sonarr_data.series_info_tabs.index, 0); + } + #[test] fn test_sonarr_data_defaults() { let sonarr_data = SonarrData::default(); @@ -195,7 +216,7 @@ mod tests { assert!(sonarr_data.series_info_tabs.tabs[1].help.is_empty()); assert_eq!( sonarr_data.series_info_tabs.tabs[1].contextual_help, - Some(build_context_clue_string(&HISTORY_CONTEXT_CLUES)) + Some(build_context_clue_string(&SERIES_HISTORY_CONTEXT_CLUES)) ); } } @@ -570,12 +591,13 @@ mod tests { #[test] fn test_series_details_blocks_contents() { - assert_eq!(SERIES_DETAILS_BLOCKS.len(), 11); + assert_eq!(SERIES_DETAILS_BLOCKS.len(), 12); assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesDetails)); assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesHistory)); assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeason)); assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeasonError)); assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::UpdateAndScanSeriesPrompt)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::AutomaticallySearchSeriesPrompt)); assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeriesHistory)); assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeriesHistoryError)); assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::FilterSeriesHistory)); diff --git a/src/models/servarr_data/sonarr/sonarr_test_utils.rs b/src/models/servarr_data/sonarr/sonarr_test_utils.rs index 4919c67..bb7b669 100644 --- a/src/models/servarr_data/sonarr/sonarr_test_utils.rs +++ b/src/models/servarr_data/sonarr/sonarr_test_utils.rs @@ -32,6 +32,8 @@ pub mod utils { let mut seasons = StatefulTable::default(); seasons.set_items(vec![Season::default()]); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); let mut sonarr_data = SonarrData { delete_series_files: true, @@ -39,6 +41,7 @@ pub mod utils { add_series_search: Some("test search".into()), edit_root_folder: Some("test path".into()), seasons, + series_history: Some(series_history), season_details_modal: Some(season_details_modal), add_searched_series: Some(StatefulTable::default()), ..SonarrData::default() diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index f3c9e9b..b133d83 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -239,6 +239,7 @@ impl Eq for Rating {} #[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Season { + pub title: Option, #[serde(deserialize_with = "super::from_i64")] pub season_number: i64, pub monitored: bool, diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index ce27e68..684dbca 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -3,7 +3,7 @@ mod test { use std::sync::Arc; use bimap::BiMap; - use chrono::{DateTime, Utc}; + use chrono::DateTime; use indoc::formatdoc; use mockito::{Matcher, Server}; use pretty_assertions::{assert_eq, assert_str_eq}; @@ -5237,8 +5237,7 @@ mod test { ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - let date_time = DateTime::from(DateTime::parse_from_rfc3339("2023-02-25T20:16:43Z").unwrap()) - as DateTime; + let date_time = DateTime::from(DateTime::parse_from_rfc3339("2023-02-25T20:16:43Z").unwrap()); if let SonarrSerdeable::SystemStatus(status) = network .handle_sonarr_event(SonarrEvent::GetStatus) @@ -6874,6 +6873,7 @@ mod test { fn season() -> Season { Season { + title: None, season_number: 1, monitored: true, statistics: season_statistics(), diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index 2b2bd47..6ef4fc6 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -60,7 +60,6 @@ impl DrawUi for MovieDetailsUi { .prompt(&prompt) .yes_no_value(app.data.radarr_data.prompt_confirm); - draw_movie_info(f, app, content_area); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), diff --git a/src/ui/sonarr_ui/library/edit_series_ui.rs b/src/ui/sonarr_ui/library/edit_series_ui.rs index 3d25141..93a500e 100644 --- a/src/ui/sonarr_ui/library/edit_series_ui.rs +++ b/src/ui/sonarr_ui/library/edit_series_ui.rs @@ -82,7 +82,7 @@ impl DrawUi for EditSeriesUi { } _ if SERIES_DETAILS_BLOCKS.contains(&context) => { draw_popup_over_ui::(f, app, area, draw_library, Size::Large); - draw_popup(f, app, draw_edit_series_prompt, Size::Medium); + draw_popup(f, app, draw_edit_series_prompt, Size::Long); } _ => (), } diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs index 7c0c075..88191ba 100644 --- a/src/ui/sonarr_ui/library/library_ui_tests.rs +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -12,11 +12,11 @@ mod tests { use crate::ui::DrawUi; use pretty_assertions::assert_eq; use ratatui::widgets::{Cell, Row}; - use rstest::rstest; use strum::IntoEnumIterator; + use crate::models::sonarr_models::{Season, SeasonStatistics}; use crate::{ - models::sonarr_models::{Series, SeriesStatistics}, + models::sonarr_models::Series, ui::sonarr_ui::library::decorate_series_row_with_style, }; @@ -38,45 +38,193 @@ mod tests { }); } - #[rstest] - #[case(SeriesStatus::Ended, None, RowStyle::Missing)] - #[case(SeriesStatus::Ended, Some(59.0), RowStyle::Missing)] - #[case(SeriesStatus::Ended, Some(100.0), RowStyle::Downloaded)] - #[case(SeriesStatus::Continuing, None, RowStyle::Missing)] - #[case(SeriesStatus::Continuing, Some(59.0), RowStyle::Missing)] - #[case(SeriesStatus::Continuing, Some(100.0), RowStyle::Unreleased)] - #[case(SeriesStatus::Upcoming, None, RowStyle::Unreleased)] - #[case(SeriesStatus::Deleted, None, RowStyle::Missing)] - fn test_decorate_series_row_with_style( - #[case] series_status: SeriesStatus, - #[case] percent_of_episodes: Option, - #[case] expected_row_style: RowStyle, + #[test] + fn test_decorate_row_with_style_downloaded_when_ended_and_all_monitored_episodes_are_present( ) { - let mut series = Series { - status: series_status, + let seasons = vec![ + Season { + monitored: false, + statistics: SeasonStatistics { + episode_count: 1, + total_episode_count: 3, + ..SeasonStatistics::default() + }, + ..Season::default() + }, + Season { + monitored: true, + statistics: SeasonStatistics { + episode_count: 3, + total_episode_count: 3, + ..SeasonStatistics::default() + }, + ..Season::default() + }, + ]; + let series = Series { + status: SeriesStatus::Ended, + seasons: Some(seasons), ..Series::default() }; - if let Some(percentage) = percent_of_episodes { - series.statistics = Some(SeriesStatistics { - percent_of_episodes: percentage, - ..SeriesStatistics::default() - }); - } - let row = Row::new(vec![Cell::from("test".to_owned())]); let style = decorate_series_row_with_style(&series, row.clone()); - match expected_row_style { - RowStyle::Downloaded => assert_eq!(style, row.downloaded()), - RowStyle::Missing => assert_eq!(style, row.missing()), - RowStyle::Unreleased => assert_eq!(style, row.unreleased()), - } + assert_eq!(style, row.downloaded()); } - enum RowStyle { - Downloaded, - Missing, - Unreleased, + #[test] + fn test_decorate_row_with_style_missing_when_ended_and_episodes_are_missing() { + let seasons = vec![ + Season { + monitored: true, + statistics: SeasonStatistics { + episode_count: 1, + total_episode_count: 3, + ..SeasonStatistics::default() + }, + ..Season::default() + }, + Season { + monitored: true, + statistics: SeasonStatistics { + episode_count: 3, + total_episode_count: 3, + ..SeasonStatistics::default() + }, + ..Season::default() + }, + ]; + let series = Series { + status: SeriesStatus::Ended, + seasons: Some(seasons), + ..Series::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_series_row_with_style(&series, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_row_with_style_indeterminate_when_ended_and_seasons_is_empty() { + let series = Series { + status: SeriesStatus::Ended, + ..Series::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_series_row_with_style(&series, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + + #[test] + fn test_decorate_row_with_style_unreleased_when_continuing_and_all_monitored_episodes_are_present( + ) { + let seasons = vec![ + Season { + monitored: false, + statistics: SeasonStatistics { + episode_count: 1, + total_episode_count: 3, + ..SeasonStatistics::default() + }, + ..Season::default() + }, + Season { + monitored: true, + statistics: SeasonStatistics { + episode_count: 3, + total_episode_count: 3, + ..SeasonStatistics::default() + }, + ..Season::default() + }, + ]; + let series = Series { + status: SeriesStatus::Continuing, + seasons: Some(seasons), + ..Series::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_series_row_with_style(&series, row.clone()); + + assert_eq!(style, row.unreleased()); + } + + #[test] + fn test_decorate_row_with_style_missing_when_continuing_and_episodes_are_missing() { + let seasons = vec![ + Season { + monitored: true, + statistics: SeasonStatistics { + episode_count: 1, + total_episode_count: 3, + ..SeasonStatistics::default() + }, + ..Season::default() + }, + Season { + monitored: true, + statistics: SeasonStatistics { + episode_count: 3, + total_episode_count: 3, + ..SeasonStatistics::default() + }, + ..Season::default() + }, + ]; + let series = Series { + status: SeriesStatus::Continuing, + seasons: Some(seasons), + ..Series::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_series_row_with_style(&series, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_row_with_style_indeterminate_when_continuing_and_seasons_is_empty() { + let series = Series { + status: SeriesStatus::Continuing, + ..Series::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_series_row_with_style(&series, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + + #[test] + fn test_decorate_row_with_style_unreleased_when_upcoming() { + let series = Series { + status: SeriesStatus::Upcoming, + ..Series::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_series_row_with_style(&series, row.clone()); + + assert_eq!(style, row.unreleased()); + } + + #[test] + fn test_decorate_row_with_style_defaults_to_indeterminate() { + let series = Series { + status: SeriesStatus::Deleted, + ..Series::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_series_row_with_style(&series, row.clone()); + + assert_eq!(style, row.indeterminate()); } } diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index 930ecca..e50a994 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -61,35 +61,6 @@ impl DrawUi for LibraryUi { | ActiveSonarrBlock::SearchSeriesError | ActiveSonarrBlock::FilterSeries | ActiveSonarrBlock::FilterSeriesError => draw_library(f, app, area), - // ActiveSonarrBlock::SearchSeries => draw_popup_over( - // f, - // app, - // area, - // draw_library, - // draw_library_search_box, - // Size::InputBox, - // ), - // ActiveSonarrBlock::SearchSeriesError => { - // let popup = Popup::new(Message::new("Series not found!")).size(Size::Message); - - // draw_library(f, app, area); - // f.render_widget(popup, f.area()); - // } - // ActiveSonarrBlock::FilterSeries => draw_popup_over( - // f, - // app, - // area, - // draw_library, - // draw_filter_series_box, - // Size::InputBox, - // ), - // ActiveSonarrBlock::FilterSeriesError => { - // let popup = Popup::new(Message::new("No series found matching the given filter!")) - // .size(Size::Message); - - // draw_library(f, app, area); - // f.render_widget(popup, f.area()); - // } ActiveSonarrBlock::UpdateAllSeriesPrompt => { let confirmation_prompt = ConfirmationPrompt::new() .title("Update All Series") @@ -234,24 +205,36 @@ pub(super) fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { fn decorate_series_row_with_style<'a>(series: &Series, row: Row<'a>) -> Row<'a> { match series.status { SeriesStatus::Ended => { - if let Some(ref stats) = series.statistics { - if stats.percent_of_episodes == 100.0 { - return row.downloaded(); + if let Some(ref seasons) = series.seasons { + return if seasons + .iter() + .filter(|season| season.monitored) + .all(|season| season.statistics.episode_count == season.statistics.total_episode_count) + { + row.downloaded() + } else { + row.missing() } - } - - row.missing() + } + + row.indeterminate() } SeriesStatus::Continuing => { - if let Some(ref stats) = series.statistics { - if stats.percent_of_episodes == 100.0 { - return row.unreleased(); - } + if let Some(ref seasons) = series.seasons { + return if seasons + .iter() + .filter(|season| season.monitored) + .all(|season| season.statistics.episode_count == season.statistics.total_episode_count) + { + row.unreleased() + } else { + row.missing() + }; } - row.missing() + row.indeterminate() } SeriesStatus::Upcoming => row.unreleased(), - _ => row.missing(), + _ => row.indeterminate(), } } diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index d673881..63d6ca5 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -1,8 +1,10 @@ +use deunicode::deunicode; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Style, Stylize}; use ratatui::text::{Line, Text}; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use ratatui::Frame; +use regex::Regex; use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SERIES_DETAILS_BLOCKS}; @@ -67,6 +69,20 @@ impl DrawUi for SeriesDetailsUi { draw_series_details(f, app, content_area); match active_sonarr_block { + ActiveSonarrBlock::AutomaticallySearchSeriesPrompt => { + let prompt = format!( + "Do you want to trigger an automatic search of your indexers for all monitored episode(s) for the series: {}", app.data.sonarr_data.series.current_selection().title + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Automatic Series Search") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } ActiveSonarrBlock::UpdateAndScanSeriesPrompt => { let prompt = format!( "Do you want to trigger an update and disk scan for the series: {}?", @@ -83,14 +99,7 @@ impl DrawUi for SeriesDetailsUi { ); } ActiveSonarrBlock::SeriesHistoryDetails => { - draw_popup_over( - f, - app, - popup_area, - draw_series_history_table, - draw_history_item_details_popup, - Size::Small, - ); + draw_history_item_details_popup(f, app, popup_area); } _ => (), }; @@ -129,19 +138,25 @@ pub fn draw_series_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) .get_by_left(¤t_selection.language_profile_id) .unwrap() .to_owned(); + let overview = Regex::new(r"[\r\n\t]") + .unwrap() + .replace_all( + &deunicode( + current_selection + .overview + .as_ref() + .unwrap_or(&String::new()), + ), + "", + ) + .to_string(); + let mut series_description = vec![ Line::from(vec![ "Title: ".primary().bold(), current_selection.title.text.clone().primary().bold(), ]), - Line::from(vec![ - "Overview: ".primary().bold(), - current_selection - .overview - .clone() - .unwrap_or_default() - .default(), - ]), + Line::from(vec!["Overview: ".primary().bold(), overview.default()]), Line::from(vec![ "Network: ".primary().bold(), current_selection @@ -194,7 +209,7 @@ pub fn draw_series_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) let description_paragraph = Paragraph::new(series_description) .block(borderless_block()) - .wrap(Wrap { trim: false }); + .wrap(Wrap { trim: true }); f.render_widget(description_paragraph, area); } @@ -220,9 +235,10 @@ fn draw_seasons_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .get_active_tab_contextual_help(); let season_row_mapping = |season: &Season| { let Season { - season_number, + title, monitored, statistics, + .. } = season; let SeasonStatistics { episode_count, @@ -235,7 +251,7 @@ fn draw_seasons_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let row = Row::new(vec![ Cell::from(season_monitored.to_owned()), - Cell::from(format!("Season {}", season_number)), + Cell::from(title.clone().unwrap()), Cell::from(format!("{}/{}", episode_count, total_episode_count)), Cell::from(format!("{size:.2} GB")), ]); diff --git a/src/ui/styles.rs b/src/ui/styles.rs index 744f633..6d5e055 100644 --- a/src/ui/styles.rs +++ b/src/ui/styles.rs @@ -14,6 +14,7 @@ where #[allow(clippy::new_ret_no_self)] fn new() -> T; fn awaiting_import(self) -> T; + fn indeterminate(self) -> T; fn default(self) -> T; fn downloaded(self) -> T; fn downloading(self) -> T; @@ -44,6 +45,10 @@ where self.fg(COLOR_ORANGE) } + fn indeterminate(self) -> T { + self.fg(COLOR_ORANGE) + } + fn default(self) -> T { self.white() } diff --git a/src/ui/styles_tests.rs b/src/ui/styles_tests.rs index 55169f7..e32bf2a 100644 --- a/src/ui/styles_tests.rs +++ b/src/ui/styles_tests.rs @@ -18,6 +18,14 @@ mod test { ); } + #[test] + fn test_style_indeterminate() { + assert_eq!( + Style::new().indeterminate(), + Style::new().fg(COLOR_ORANGE) + ); + } + #[test] fn test_style_default() { assert_eq!(Style::new().default(), Style::new().white()); diff --git a/src/ui/widgets/button.rs b/src/ui/widgets/button.rs index 26217b9..2c3524d 100644 --- a/src/ui/widgets/button.rs +++ b/src/ui/widgets/button.rs @@ -1,50 +1,26 @@ use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{layout_block, style_block_highlight}; +use derive_setters::Setters; use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::prelude::{Style, Text, Widget}; use ratatui::style::Styled; use ratatui::widgets::Paragraph; -#[cfg(test)] -#[path = "button_tests.rs"] -mod button_tests; - -#[derive(Default)] +#[derive(Default, Setters)] pub struct Button<'a> { title: &'a str, + #[setters(strip_option)] label: Option<&'a str>, + #[setters(strip_option)] icon: Option<&'a str>, + #[setters(into)] style: Style, + #[setters(rename = "selected")] is_selected: bool, } impl<'a> Button<'a> { - pub fn title(mut self, title: &'a str) -> Button<'a> { - self.title = title; - self - } - - pub fn label(mut self, label: &'a str) -> Button<'a> { - self.label = Some(label); - self - } - - pub fn icon(mut self, icon: &'a str) -> Button<'a> { - self.icon = Some(icon); - self - } - - pub fn style>(mut self, style: S) -> Button<'a> { - self.style = style.into(); - self - } - - pub fn selected(mut self, is_selected: bool) -> Button<'a> { - self.is_selected = is_selected; - self - } - fn render_button_with_icon(self, area: Rect, buf: &mut Buffer) { let [title_area, icon_area] = Layout::horizontal([ Constraint::Length(self.title.len() as u16), diff --git a/src/ui/widgets/button_tests.rs b/src/ui/widgets/button_tests.rs deleted file mode 100644 index 7273a76..0000000 --- a/src/ui/widgets/button_tests.rs +++ /dev/null @@ -1,41 +0,0 @@ -#[cfg(test)] -mod tests { - use crate::ui::widgets::button::Button; - use pretty_assertions::{assert_eq, assert_str_eq}; - use ratatui::style::{Style, Stylize}; - - #[test] - fn test_title() { - let button = Button::default().title("Title"); - - assert_str_eq!(button.title, "Title"); - } - - #[test] - fn test_label() { - let button = Button::default().label("Label"); - - assert_eq!(button.label, Some("Label")); - } - - #[test] - fn test_icon() { - let button = Button::default().icon("Icon"); - - assert_eq!(button.icon, Some("Icon")); - } - - #[test] - fn test_style() { - let button = Button::default().style(Style::new().bold()); - - assert_eq!(button.style, Style::new().bold()); - } - - #[test] - fn test_selected() { - let button = Button::default().selected(true); - - assert!(button.is_selected); - } -} diff --git a/src/ui/widgets/checkbox.rs b/src/ui/widgets/checkbox.rs index 08ee9d2..14dc882 100644 --- a/src/ui/widgets/checkbox.rs +++ b/src/ui/widgets/checkbox.rs @@ -1,3 +1,4 @@ +use derive_setters::Setters; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{borderless_block, layout_block, style_block_highlight}; use ratatui::buffer::Buffer; @@ -6,14 +7,13 @@ use ratatui::prelude::Text; use ratatui::style::Stylize; use ratatui::widgets::{Paragraph, Widget}; -#[cfg(test)] -#[path = "checkbox_tests.rs"] -mod checkbox_tests; - -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Debug, Copy, Clone, Setters)] pub struct Checkbox<'a> { + #[setters(skip)] label: &'a str, + #[setters(rename = "checked")] is_checked: bool, + #[setters(rename = "highlighted")] is_highlighted: bool, } @@ -26,16 +26,6 @@ impl<'a> Checkbox<'a> { } } - pub fn checked(mut self, is_checked: bool) -> Checkbox<'a> { - self.is_checked = is_checked; - self - } - - pub fn highlighted(mut self, is_selected: bool) -> Checkbox<'a> { - self.is_highlighted = is_selected; - self - } - fn render_checkbox(self, area: Rect, buf: &mut Buffer) { let check = if self.is_checked { "✔" } else { "" }; let [label_area, checkbox_area] = diff --git a/src/ui/widgets/checkbox_tests.rs b/src/ui/widgets/checkbox_tests.rs deleted file mode 100644 index 7af1bba..0000000 --- a/src/ui/widgets/checkbox_tests.rs +++ /dev/null @@ -1,32 +0,0 @@ -#[cfg(test)] -mod tests { - use crate::ui::widgets::checkbox::Checkbox; - use pretty_assertions::assert_str_eq; - - #[test] - fn test_checkbox_new() { - let checkbox = Checkbox::new("test"); - - assert_str_eq!(checkbox.label, "test"); - assert!(!checkbox.is_checked); - assert!(!checkbox.is_highlighted); - } - - #[test] - fn test_checkbox_checked() { - let checkbox = Checkbox::new("test").checked(true); - - assert_str_eq!(checkbox.label, "test"); - assert!(checkbox.is_checked); - assert!(!checkbox.is_highlighted); - } - - #[test] - fn test_checkbox_highlighted() { - let checkbox = Checkbox::new("test").highlighted(true); - - assert_str_eq!(checkbox.label, "test"); - assert!(!checkbox.is_checked); - assert!(checkbox.is_highlighted); - } -} diff --git a/src/ui/widgets/confirmation_prompt.rs b/src/ui/widgets/confirmation_prompt.rs index 911bcaa..8679204 100644 --- a/src/ui/widgets/confirmation_prompt.rs +++ b/src/ui/widgets/confirmation_prompt.rs @@ -8,15 +8,19 @@ use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::text::Text; use ratatui::widgets::{Paragraph, Widget}; use std::iter; +use derive_setters::Setters; #[cfg(test)] #[path = "confirmation_prompt_tests.rs"] mod confirmation_prompt_tests; +#[derive(Setters)] pub struct ConfirmationPrompt<'a> { title: &'a str, prompt: &'a str, + #[setters(strip_option)] content: Option>, + #[setters(strip_option)] checkboxes: Option>>, yes_no_value: bool, yes_no_highlighted: bool, @@ -34,36 +38,6 @@ impl<'a> ConfirmationPrompt<'a> { } } - pub fn title(mut self, title: &'a str) -> Self { - self.title = title; - self - } - - pub fn prompt(mut self, prompt: &'a str) -> Self { - self.prompt = prompt; - self - } - - pub fn content(mut self, content: Paragraph<'a>) -> Self { - self.content = Some(content); - self - } - - pub fn checkboxes(mut self, checkboxes: Vec>) -> Self { - self.checkboxes = Some(checkboxes); - self - } - - pub fn yes_no_value(mut self, yes_highlighted: bool) -> Self { - self.yes_no_value = yes_highlighted; - self - } - - pub fn yes_no_highlighted(mut self, yes_highlighted: bool) -> Self { - self.yes_no_highlighted = yes_highlighted; - self - } - fn render_confirmation_prompt_with_checkboxes(self, area: Rect, buf: &mut Buffer) { title_block_centered(self.title).render(area, buf); let help_text = diff --git a/src/ui/widgets/confirmation_prompt_tests.rs b/src/ui/widgets/confirmation_prompt_tests.rs index 6173ef7..61d98d2 100644 --- a/src/ui/widgets/confirmation_prompt_tests.rs +++ b/src/ui/widgets/confirmation_prompt_tests.rs @@ -1,9 +1,7 @@ #[cfg(test)] mod tests { - use crate::ui::widgets::checkbox::Checkbox; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use pretty_assertions::{assert_eq, assert_str_eq}; - use ratatui::widgets::Paragraph; #[test] fn test_confirmation_prompt_new() { @@ -16,78 +14,4 @@ mod tests { assert!(!confirmation_prompt.yes_no_value); assert!(confirmation_prompt.yes_no_highlighted); } - - #[test] - fn test_confirmation_prompt_title() { - let confirmation_prompt = ConfirmationPrompt::new().title("title"); - - assert_str_eq!(confirmation_prompt.title, "title"); - assert_str_eq!(confirmation_prompt.prompt, ""); - assert_eq!(confirmation_prompt.content, None); - assert_eq!(confirmation_prompt.checkboxes, None); - assert!(!confirmation_prompt.yes_no_value); - assert!(confirmation_prompt.yes_no_highlighted); - } - - #[test] - fn test_confirmation_prompt_prompt() { - let confirmation_prompt = ConfirmationPrompt::new().prompt("prompt"); - - assert_str_eq!(confirmation_prompt.prompt, "prompt"); - assert_str_eq!(confirmation_prompt.title, ""); - assert_eq!(confirmation_prompt.content, None); - assert_eq!(confirmation_prompt.checkboxes, None); - assert!(!confirmation_prompt.yes_no_value); - assert!(confirmation_prompt.yes_no_highlighted); - } - - #[test] - fn test_confirmation_prompt_content() { - let content = Paragraph::new("content"); - let confirmation_prompt = ConfirmationPrompt::new().content(content.clone()); - - assert_eq!(confirmation_prompt.content, Some(content)); - assert_str_eq!(confirmation_prompt.title, ""); - assert_str_eq!(confirmation_prompt.prompt, ""); - assert_eq!(confirmation_prompt.checkboxes, None); - assert!(!confirmation_prompt.yes_no_value); - assert!(confirmation_prompt.yes_no_highlighted); - } - - #[test] - fn test_confirmation_prompt_checkboxes() { - let checkboxes = vec![Checkbox::new("test").highlighted(true).checked(false)]; - let confirmation_prompt = ConfirmationPrompt::new().checkboxes(checkboxes.clone()); - - assert_eq!(confirmation_prompt.checkboxes, Some(checkboxes)); - assert_str_eq!(confirmation_prompt.title, ""); - assert_str_eq!(confirmation_prompt.prompt, ""); - assert_eq!(confirmation_prompt.content, None); - assert!(!confirmation_prompt.yes_no_value); - assert!(confirmation_prompt.yes_no_highlighted); - } - - #[test] - fn test_confirmation_prompt_yes_no_value() { - let confirmation_prompt = ConfirmationPrompt::new().yes_no_value(true); - - assert!(confirmation_prompt.yes_no_value); - assert_str_eq!(confirmation_prompt.title, ""); - assert_str_eq!(confirmation_prompt.prompt, ""); - assert_eq!(confirmation_prompt.content, None); - assert_eq!(confirmation_prompt.checkboxes, None); - assert!(confirmation_prompt.yes_no_highlighted); - } - - #[test] - fn test_confirmation_prompt_yes_no_highlighted() { - let confirmation_prompt = ConfirmationPrompt::new().yes_no_highlighted(false); - - assert!(!confirmation_prompt.yes_no_highlighted); - assert_str_eq!(confirmation_prompt.title, ""); - assert_str_eq!(confirmation_prompt.prompt, ""); - assert_eq!(confirmation_prompt.content, None); - assert_eq!(confirmation_prompt.checkboxes, None); - assert!(!confirmation_prompt.yes_no_value); - } } diff --git a/src/ui/widgets/input_box.rs b/src/ui/widgets/input_box.rs index 96577b1..e0ec7e9 100644 --- a/src/ui/widgets/input_box.rs +++ b/src/ui/widgets/input_box.rs @@ -1,3 +1,4 @@ +use derive_setters::Setters; use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::prelude::Text; @@ -12,16 +13,20 @@ use crate::ui::utils::{borderless_block, layout_block}; #[path = "input_box_tests.rs"] mod input_box_tests; -#[derive(Default)] +#[derive(Default, Setters)] #[cfg_attr(test, derive(Debug, PartialEq))] pub struct InputBox<'a> { content: &'a str, offset: usize, + #[setters(into)] style: Style, block: Block<'a>, + #[setters(strip_option)] label: Option<&'a str>, cursor_after_string: bool, + #[setters(rename = "highlighted", strip_option)] is_highlighted: Option, + #[setters(rename = "selected", strip_option)] is_selected: Option, } @@ -39,41 +44,6 @@ impl<'a> InputBox<'a> { } } - pub fn style>(mut self, style: S) -> InputBox<'a> { - self.style = style.into(); - self - } - - pub fn block(mut self, block: Block<'a>) -> InputBox<'a> { - self.block = block; - self - } - - pub fn label(mut self, label: &'a str) -> InputBox<'a> { - self.label = Some(label); - self - } - - pub fn offset(mut self, offset: usize) -> InputBox<'a> { - self.offset = offset; - self - } - - pub fn cursor_after_string(mut self, cursor_after_string: bool) -> InputBox<'a> { - self.cursor_after_string = cursor_after_string; - self - } - - pub fn highlighted(mut self, is_highlighted: bool) -> InputBox<'a> { - self.is_highlighted = Some(is_highlighted); - self - } - - pub fn selected(mut self, is_selected: bool) -> InputBox<'a> { - self.is_selected = Some(is_selected); - self - } - pub fn is_selected(&self) -> bool { self.is_selected.unwrap_or_default() } diff --git a/src/ui/widgets/input_box_tests.rs b/src/ui/widgets/input_box_tests.rs index d4d87b8..a26a05e 100644 --- a/src/ui/widgets/input_box_tests.rs +++ b/src/ui/widgets/input_box_tests.rs @@ -20,104 +20,6 @@ mod tests { assert_eq!(input_box.is_selected, None); } - #[test] - fn test_input_box_style() { - let input_box = InputBox::new("test").style(Style::new().highlight()); - - assert_eq!(input_box.style, Style::new().highlight()); - assert_str_eq!(input_box.content, "test"); - assert_eq!(input_box.offset, 0); - assert_eq!(input_box.block, layout_block()); - assert_eq!(input_box.label, None); - assert!(input_box.cursor_after_string); - assert_eq!(input_box.is_highlighted, None); - assert_eq!(input_box.is_selected, None); - } - - #[test] - fn test_input_box_block() { - let input_box = InputBox::new("test").block(layout_block().title("title")); - - assert_eq!(input_box.block, layout_block().title("title")); - assert_str_eq!(input_box.content, "test"); - assert_eq!(input_box.offset, 0); - assert_eq!(input_box.style, Style::new().default()); - assert_eq!(input_box.label, None); - assert!(input_box.cursor_after_string); - assert_eq!(input_box.is_highlighted, None); - assert_eq!(input_box.is_selected, None); - } - - #[test] - fn test_input_box_label() { - let input_box = InputBox::new("test").label("label"); - - assert_str_eq!(input_box.label.unwrap(), "label"); - assert_str_eq!(input_box.content, "test"); - assert_eq!(input_box.offset, 0); - assert_eq!(input_box.style, Style::new().default()); - assert_eq!(input_box.block, layout_block()); - assert!(input_box.cursor_after_string); - assert_eq!(input_box.is_highlighted, None); - assert_eq!(input_box.is_selected, None); - } - - #[test] - fn test_input_box_offset() { - let input_box = InputBox::new("test").offset(1); - - assert_eq!(input_box.offset, 1); - assert_str_eq!(input_box.content, "test"); - assert_eq!(input_box.style, Style::new().default()); - assert_eq!(input_box.block, layout_block()); - assert_eq!(input_box.label, None); - assert!(input_box.cursor_after_string); - assert_eq!(input_box.is_highlighted, None); - assert_eq!(input_box.is_selected, None); - } - - #[test] - fn test_input_box_cursor_after_string() { - let input_box = InputBox::new("test").cursor_after_string(false); - - assert!(!input_box.cursor_after_string); - assert_str_eq!(input_box.content, "test"); - assert_eq!(input_box.offset, 0); - assert_eq!(input_box.style, Style::new().default()); - assert_eq!(input_box.block, layout_block()); - assert_eq!(input_box.label, None); - assert_eq!(input_box.is_highlighted, None); - assert_eq!(input_box.is_selected, None); - } - - #[test] - fn test_input_box_highlighted() { - let input_box = InputBox::new("test").highlighted(true); - - assert_eq!(input_box.is_highlighted, Some(true)); - assert_str_eq!(input_box.content, "test"); - assert_eq!(input_box.offset, 0); - assert_eq!(input_box.style, Style::new().default()); - assert_eq!(input_box.block, layout_block()); - assert_eq!(input_box.label, None); - assert!(input_box.cursor_after_string); - assert_eq!(input_box.is_selected, None); - } - - #[test] - fn test_input_box_selected() { - let input_box = InputBox::new("test").selected(true); - - assert_eq!(input_box.is_selected, Some(true)); - assert_str_eq!(input_box.content, "test"); - assert_eq!(input_box.offset, 0); - assert_eq!(input_box.style, Style::new().default()); - assert_eq!(input_box.block, layout_block()); - assert_eq!(input_box.label, None); - assert!(input_box.cursor_after_string); - assert_eq!(input_box.is_highlighted, None); - } - #[test] fn test_input_box_is_selected() { let input_box = InputBox::new("test").selected(true); diff --git a/src/ui/widgets/managarr_table.rs b/src/ui/widgets/managarr_table.rs index b11df86..e8f94d2 100644 --- a/src/ui/widgets/managarr_table.rs +++ b/src/ui/widgets/managarr_table.rs @@ -12,7 +12,7 @@ use ratatui::widgets::{Block, ListItem, Paragraph, Row, StatefulWidget, Table, W use ratatui::Frame; use std::fmt::Debug; use std::sync::atomic::Ordering; - +use derive_setters::Setters; use super::input_box_popup::InputBoxPopup; use super::message::Message; use super::popup::Size; @@ -21,24 +21,32 @@ use super::popup::Size; #[path = "managarr_table_tests.rs"] mod managarr_table_tests; +#[derive(Setters)] pub struct ManagarrTable<'a, T, F> where F: Fn(&T) -> Row<'a>, T: Clone + PartialEq + Eq + Debug, { + #[setters(strip_option)] content: Option<&'a mut StatefulTable>, + #[setters(skip)] table_headers: Vec, + #[setters(skip)] constraints: Vec, row_mapper: F, footer: Option, footer_alignment: Alignment, block: Block<'a>, margin: u16, + #[setters(rename = "loading")] is_loading: bool, highlight_rows: bool, + #[setters(rename = "sorting")] is_sorting: bool, + #[setters(rename = "searching")] is_searching: bool, search_produced_empty_results: bool, + #[setters(rename = "filtering")] is_filtering: bool, filter_produced_empty_results: bool, search_box_content_length: usize, @@ -107,61 +115,6 @@ where self } - pub fn footer(mut self, footer: Option) -> Self { - self.footer = footer; - self - } - - pub fn footer_alignment(mut self, alignment: Alignment) -> Self { - self.footer_alignment = alignment; - self - } - - pub fn block(mut self, block: Block<'a>) -> Self { - self.block = block; - self - } - - pub fn margin(mut self, margin: u16) -> Self { - self.margin = margin; - self - } - - pub fn loading(mut self, is_loading: bool) -> Self { - self.is_loading = is_loading; - self - } - - pub fn highlight_rows(mut self, highlight_rows: bool) -> Self { - self.highlight_rows = highlight_rows; - self - } - - pub fn sorting(mut self, is_sorting: bool) -> Self { - self.is_sorting = is_sorting; - self - } - - pub fn searching(mut self, is_searching: bool) -> Self { - self.is_searching = is_searching; - self - } - - pub fn search_produced_empty_results(mut self, no_search_results: bool) -> Self { - self.search_produced_empty_results = no_search_results; - self - } - - pub fn filtering(mut self, is_filtering: bool) -> Self { - self.is_filtering = is_filtering; - self - } - - pub fn filter_produced_empty_results(mut self, no_filter_results: bool) -> Self { - self.filter_produced_empty_results = no_filter_results; - self - } - fn render_table(self, area: Rect, buf: &mut Buffer) { let table_headers = self.parse_headers(); let table_area = if let Some(ref footer) = self.footer { diff --git a/src/ui/widgets/managarr_table_tests.rs b/src/ui/widgets/managarr_table_tests.rs index 3e43edf..831cd57 100644 --- a/src/ui/widgets/managarr_table_tests.rs +++ b/src/ui/widgets/managarr_table_tests.rs @@ -3,7 +3,6 @@ mod tests { use crate::models::stateful_list::StatefulList; use crate::models::stateful_table::{SortOption, StatefulTable}; use crate::models::{HorizontallyScrollableText, Scrollable}; - use crate::ui::utils::layout_block; use crate::ui::widgets::managarr_table::ManagarrTable; use pretty_assertions::assert_eq; use ratatui::layout::{Alignment, Constraint}; @@ -180,358 +179,6 @@ mod tests { assert_eq!(managarr_table.filter_box_offset, 0); } - #[test] - fn test_managarr_table_footer() { - let items = vec!["item1", "item2", "item3"]; - let mut stateful_table = StatefulTable::default(); - stateful_table.set_items(items.clone()); - let footer = "footer".to_owned(); - - let managarr_table = - ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])) - .footer(Some(footer.clone())); - - let row_mapper = managarr_table.row_mapper; - assert_eq!(managarr_table.footer, Some(footer)); - assert_eq!(managarr_table.content.unwrap().items, items); - assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); - assert_eq!(managarr_table.table_headers, Vec::::new()); - assert_eq!(managarr_table.constraints, Vec::new()); - assert_eq!(managarr_table.footer_alignment, Alignment::Left); - assert_eq!(managarr_table.block, Block::new()); - assert_eq!(managarr_table.margin, 0); - assert!(!managarr_table.is_loading); - assert!(managarr_table.highlight_rows); - assert!(!managarr_table.is_sorting); - assert!(!managarr_table.is_searching); - assert!(!managarr_table.search_produced_empty_results); - assert!(!managarr_table.is_filtering); - assert!(!managarr_table.filter_produced_empty_results); - assert_eq!(managarr_table.search_box_content_length, 0); - assert_eq!(managarr_table.search_box_offset, 0); - assert_eq!(managarr_table.filter_box_content_length, 0); - assert_eq!(managarr_table.filter_box_offset, 0); - } - - #[test] - fn test_managarr_table_footer_alignment() { - let items = vec!["item1", "item2", "item3"]; - let mut stateful_table = StatefulTable::default(); - stateful_table.set_items(items.clone()); - - let managarr_table = - ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])) - .footer_alignment(Alignment::Center); - - let row_mapper = managarr_table.row_mapper; - assert_eq!(managarr_table.footer_alignment, Alignment::Center); - assert_eq!(managarr_table.content.unwrap().items, items); - assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); - assert_eq!(managarr_table.table_headers, Vec::::new()); - assert_eq!(managarr_table.constraints, Vec::new()); - assert_eq!(managarr_table.footer, None); - assert_eq!(managarr_table.block, Block::new()); - assert_eq!(managarr_table.margin, 0); - assert!(!managarr_table.is_loading); - assert!(managarr_table.highlight_rows); - assert!(!managarr_table.is_sorting); - assert!(!managarr_table.is_searching); - assert!(!managarr_table.search_produced_empty_results); - assert!(!managarr_table.is_filtering); - assert!(!managarr_table.filter_produced_empty_results); - assert_eq!(managarr_table.search_box_content_length, 0); - assert_eq!(managarr_table.search_box_offset, 0); - assert_eq!(managarr_table.filter_box_content_length, 0); - assert_eq!(managarr_table.filter_box_offset, 0); - } - - #[test] - fn test_managarr_table_block() { - let items = vec!["item1", "item2", "item3"]; - let mut stateful_table = StatefulTable::default(); - stateful_table.set_items(items.clone()); - - let managarr_table = - ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])) - .block(layout_block()); - - let row_mapper = managarr_table.row_mapper; - assert_eq!(managarr_table.block, layout_block()); - assert_eq!(managarr_table.content.unwrap().items, items); - assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); - assert_eq!(managarr_table.table_headers, Vec::::new()); - assert_eq!(managarr_table.constraints, Vec::new()); - assert_eq!(managarr_table.footer, None); - assert_eq!(managarr_table.footer_alignment, Alignment::Left); - assert_eq!(managarr_table.margin, 0); - assert!(!managarr_table.is_loading); - assert!(managarr_table.highlight_rows); - assert!(!managarr_table.is_sorting); - assert!(!managarr_table.is_searching); - assert!(!managarr_table.search_produced_empty_results); - assert!(!managarr_table.is_filtering); - assert!(!managarr_table.filter_produced_empty_results); - assert_eq!(managarr_table.search_box_content_length, 0); - assert_eq!(managarr_table.search_box_offset, 0); - assert_eq!(managarr_table.filter_box_content_length, 0); - assert_eq!(managarr_table.filter_box_offset, 0); - } - - #[test] - fn test_managarr_table_margin() { - let items = vec!["item1", "item2", "item3"]; - let mut stateful_table = StatefulTable::default(); - stateful_table.set_items(items.clone()); - - let managarr_table = - ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])).margin(1); - - let row_mapper = managarr_table.row_mapper; - assert_eq!(managarr_table.margin, 1); - assert_eq!(managarr_table.content.unwrap().items, items); - assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); - assert_eq!(managarr_table.table_headers, Vec::::new()); - assert_eq!(managarr_table.constraints, Vec::new()); - assert_eq!(managarr_table.footer, None); - assert_eq!(managarr_table.footer_alignment, Alignment::Left); - assert_eq!(managarr_table.block, Block::new()); - assert!(!managarr_table.is_loading); - assert!(managarr_table.highlight_rows); - assert!(!managarr_table.is_sorting); - assert!(!managarr_table.is_searching); - assert!(!managarr_table.search_produced_empty_results); - assert!(!managarr_table.is_filtering); - assert!(!managarr_table.filter_produced_empty_results); - assert_eq!(managarr_table.search_box_content_length, 0); - assert_eq!(managarr_table.search_box_offset, 0); - assert_eq!(managarr_table.filter_box_content_length, 0); - assert_eq!(managarr_table.filter_box_offset, 0); - } - - #[test] - fn test_managarr_table_loading() { - let items = vec!["item1", "item2", "item3"]; - let mut stateful_table = StatefulTable::default(); - stateful_table.set_items(items.clone()); - - let managarr_table = - ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])) - .loading(true); - - let row_mapper = managarr_table.row_mapper; - assert!(managarr_table.is_loading); - assert_eq!(managarr_table.content.unwrap().items, items); - assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); - assert_eq!(managarr_table.table_headers, Vec::::new()); - assert_eq!(managarr_table.constraints, Vec::new()); - assert_eq!(managarr_table.footer, None); - assert_eq!(managarr_table.footer_alignment, Alignment::Left); - assert_eq!(managarr_table.block, Block::new()); - assert_eq!(managarr_table.margin, 0); - assert!(managarr_table.highlight_rows); - assert!(!managarr_table.is_sorting); - assert!(!managarr_table.is_searching); - assert!(!managarr_table.search_produced_empty_results); - assert!(!managarr_table.is_filtering); - assert!(!managarr_table.filter_produced_empty_results); - assert_eq!(managarr_table.search_box_content_length, 0); - assert_eq!(managarr_table.search_box_offset, 0); - assert_eq!(managarr_table.filter_box_content_length, 0); - assert_eq!(managarr_table.filter_box_offset, 0); - } - - #[test] - fn test_managarr_table_highlight_rows() { - let items = vec!["item1", "item2", "item3"]; - let mut stateful_table = StatefulTable::default(); - stateful_table.set_items(items.clone()); - - let managarr_table = - ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])) - .highlight_rows(false); - - let row_mapper = managarr_table.row_mapper; - assert!(!managarr_table.highlight_rows); - assert_eq!(managarr_table.content.unwrap().items, items); - assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); - assert_eq!(managarr_table.table_headers, Vec::::new()); - assert_eq!(managarr_table.constraints, Vec::new()); - assert_eq!(managarr_table.footer, None); - assert_eq!(managarr_table.footer_alignment, Alignment::Left); - assert_eq!(managarr_table.block, Block::new()); - assert_eq!(managarr_table.margin, 0); - assert!(!managarr_table.is_loading); - assert!(!managarr_table.is_sorting); - assert!(!managarr_table.is_searching); - assert!(!managarr_table.search_produced_empty_results); - assert!(!managarr_table.is_filtering); - assert!(!managarr_table.filter_produced_empty_results); - assert_eq!(managarr_table.search_box_content_length, 0); - assert_eq!(managarr_table.search_box_offset, 0); - assert_eq!(managarr_table.filter_box_content_length, 0); - assert_eq!(managarr_table.filter_box_offset, 0); - } - - #[test] - fn test_managarr_table_sorting() { - let items = vec!["item1", "item2", "item3"]; - let mut stateful_table = StatefulTable::default(); - stateful_table.set_items(items.clone()); - - let managarr_table = - ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])) - .sorting(true); - - let row_mapper = managarr_table.row_mapper; - assert!(managarr_table.is_sorting); - assert_eq!(managarr_table.content.unwrap().items, items); - assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); - assert_eq!(managarr_table.table_headers, Vec::::new()); - assert_eq!(managarr_table.constraints, Vec::new()); - assert_eq!(managarr_table.footer, None); - assert_eq!(managarr_table.footer_alignment, Alignment::Left); - assert_eq!(managarr_table.block, Block::new()); - assert_eq!(managarr_table.margin, 0); - assert!(!managarr_table.is_loading); - assert!(managarr_table.highlight_rows); - assert!(!managarr_table.is_searching); - assert!(!managarr_table.search_produced_empty_results); - assert!(!managarr_table.is_filtering); - assert!(!managarr_table.filter_produced_empty_results); - assert_eq!(managarr_table.search_box_content_length, 0); - assert_eq!(managarr_table.search_box_offset, 0); - assert_eq!(managarr_table.filter_box_content_length, 0); - assert_eq!(managarr_table.filter_box_offset, 0); - } - - #[test] - fn test_managarr_table_is_searching() { - let items = vec!["item1", "item2", "item3"]; - let mut stateful_table = StatefulTable::default(); - stateful_table.set_items(items.clone()); - - let managarr_table = - ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])) - .searching(true); - - let row_mapper = managarr_table.row_mapper; - assert!(managarr_table.is_searching); - assert_eq!(managarr_table.content.unwrap().items, items); - assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); - assert_eq!(managarr_table.table_headers, Vec::::new()); - assert_eq!(managarr_table.constraints, Vec::new()); - assert_eq!(managarr_table.footer, None); - assert_eq!(managarr_table.footer_alignment, Alignment::Left); - assert_eq!(managarr_table.block, Block::new()); - assert_eq!(managarr_table.margin, 0); - assert!(!managarr_table.is_loading); - assert!(managarr_table.highlight_rows); - assert!(!managarr_table.is_sorting); - assert!(!managarr_table.search_produced_empty_results); - assert!(!managarr_table.is_filtering); - assert!(!managarr_table.filter_produced_empty_results); - assert_eq!(managarr_table.search_box_content_length, 0); - assert_eq!(managarr_table.search_box_offset, 0); - assert_eq!(managarr_table.filter_box_content_length, 0); - assert_eq!(managarr_table.filter_box_offset, 0); - } - - #[test] - fn test_managarr_table_search_produced_empty_results() { - let items = vec!["item1", "item2", "item3"]; - let mut stateful_table = StatefulTable::default(); - stateful_table.set_items(items.clone()); - - let managarr_table = - ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])) - .search_produced_empty_results(true); - - let row_mapper = managarr_table.row_mapper; - assert!(managarr_table.search_produced_empty_results); - assert_eq!(managarr_table.content.unwrap().items, items); - assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); - assert_eq!(managarr_table.table_headers, Vec::::new()); - assert_eq!(managarr_table.constraints, Vec::new()); - assert_eq!(managarr_table.footer, None); - assert_eq!(managarr_table.footer_alignment, Alignment::Left); - assert_eq!(managarr_table.block, Block::new()); - assert_eq!(managarr_table.margin, 0); - assert!(!managarr_table.is_loading); - assert!(managarr_table.highlight_rows); - assert!(!managarr_table.is_sorting); - assert!(!managarr_table.is_searching); - assert!(!managarr_table.is_filtering); - assert!(!managarr_table.filter_produced_empty_results); - assert_eq!(managarr_table.search_box_content_length, 0); - assert_eq!(managarr_table.search_box_offset, 0); - assert_eq!(managarr_table.filter_box_content_length, 0); - assert_eq!(managarr_table.filter_box_offset, 0); - } - - #[test] - fn test_managarr_table_is_filtering() { - let items = vec!["item1", "item2", "item3"]; - let mut stateful_table = StatefulTable::default(); - stateful_table.set_items(items.clone()); - - let managarr_table = - ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])) - .filtering(true); - - let row_mapper = managarr_table.row_mapper; - assert!(managarr_table.is_filtering); - assert_eq!(managarr_table.content.unwrap().items, items); - assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); - assert_eq!(managarr_table.table_headers, Vec::::new()); - assert_eq!(managarr_table.constraints, Vec::new()); - assert_eq!(managarr_table.footer, None); - assert_eq!(managarr_table.footer_alignment, Alignment::Left); - assert_eq!(managarr_table.block, Block::new()); - assert_eq!(managarr_table.margin, 0); - assert!(!managarr_table.is_loading); - assert!(managarr_table.highlight_rows); - assert!(!managarr_table.is_sorting); - assert!(!managarr_table.is_searching); - assert!(!managarr_table.search_produced_empty_results); - assert!(!managarr_table.filter_produced_empty_results); - assert_eq!(managarr_table.search_box_content_length, 0); - assert_eq!(managarr_table.search_box_offset, 0); - assert_eq!(managarr_table.filter_box_content_length, 0); - assert_eq!(managarr_table.filter_box_offset, 0); - } - - #[test] - fn test_managarr_table_filter_produced_empty_results() { - let items = vec!["item1", "item2", "item3"]; - let mut stateful_table = StatefulTable::default(); - stateful_table.set_items(items.clone()); - - let managarr_table = - ManagarrTable::new(Some(&mut stateful_table), |&s| Row::new(vec![Cell::new(s)])) - .filter_produced_empty_results(true); - - let row_mapper = managarr_table.row_mapper; - assert!(managarr_table.filter_produced_empty_results); - assert_eq!(managarr_table.content.unwrap().items, items); - assert_eq!(row_mapper(&"item1"), Row::new(vec![Cell::new("item1")])); - assert_eq!(managarr_table.table_headers, Vec::::new()); - assert_eq!(managarr_table.constraints, Vec::new()); - assert_eq!(managarr_table.footer, None); - assert_eq!(managarr_table.footer_alignment, Alignment::Left); - assert_eq!(managarr_table.block, Block::new()); - assert_eq!(managarr_table.margin, 0); - assert!(!managarr_table.is_loading); - assert!(managarr_table.highlight_rows); - assert!(!managarr_table.is_sorting); - assert!(!managarr_table.is_searching); - assert!(!managarr_table.search_produced_empty_results); - assert!(!managarr_table.is_filtering); - assert_eq!(managarr_table.search_box_content_length, 0); - assert_eq!(managarr_table.search_box_offset, 0); - assert_eq!(managarr_table.filter_box_content_length, 0); - assert_eq!(managarr_table.filter_box_offset, 0); - } - #[test] fn test_managarr_table_parse_headers() { let items = vec!["item1", "item2", "item3"]; diff --git a/src/ui/widgets/message.rs b/src/ui/widgets/message.rs index 7543430..3cfc8f6 100644 --- a/src/ui/widgets/message.rs +++ b/src/ui/widgets/message.rs @@ -1,5 +1,6 @@ use crate::ui::styles::ManagarrStyle; use crate::ui::utils::title_block_centered; +use derive_setters::Setters; use ratatui::buffer::Buffer; use ratatui::layout::{Alignment, Rect}; use ratatui::style::{Style, Stylize}; @@ -10,6 +11,7 @@ use ratatui::widgets::{Paragraph, Widget, Wrap}; #[path = "message_tests.rs"] mod message_tests; +#[derive(Setters)] pub struct Message<'a> { text: Text<'a>, title: &'a str, @@ -30,21 +32,6 @@ impl<'a> Message<'a> { } } - pub fn title(mut self, title: &'a str) -> Self { - self.title = title; - self - } - - pub fn style(mut self, style: Style) -> Self { - self.style = style; - self - } - - pub fn alignment(mut self, alignment: Alignment) -> Self { - self.alignment = alignment; - self - } - fn render_message(self, area: Rect, buf: &mut Buffer) { Paragraph::new(self.text) .style(self.style) diff --git a/src/ui/widgets/message_tests.rs b/src/ui/widgets/message_tests.rs index cf72796..f15db96 100644 --- a/src/ui/widgets/message_tests.rs +++ b/src/ui/widgets/message_tests.rs @@ -18,42 +18,4 @@ mod tests { assert_eq!(message.style, Style::new().failure().bold()); assert_eq!(message.alignment, Alignment::Center); } - - #[test] - fn test_message_title() { - let test_message = "This is a message"; - let title = "Success"; - - let message = Message::new(test_message).title(title); - - assert_str_eq!(message.title, title); - assert_eq!(message.text, Text::from(test_message)); - assert_eq!(message.style, Style::new().failure().bold()); - assert_eq!(message.alignment, Alignment::Center); - } - - #[test] - fn test_message_style() { - let test_message = "This is a message"; - let style = Style::new().success().bold(); - - let message = Message::new(test_message).style(style); - - assert_eq!(message.style, style); - assert_eq!(message.text, Text::from(test_message)); - assert_str_eq!(message.title, "Error"); - assert_eq!(message.alignment, Alignment::Center); - } - - #[test] - fn test_message_alignment() { - let test_message = "This is a message"; - - let message = Message::new(test_message).alignment(Alignment::Left); - - assert_eq!(message.alignment, Alignment::Left); - assert_eq!(message.text, Text::from(test_message)); - assert_str_eq!(message.title, "Error"); - assert_eq!(message.style, Style::new().failure().bold()); - } } From 47b609369b43af139932aafa3a88e506aa226d41 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sat, 7 Dec 2024 19:20:13 -0700 Subject: [PATCH 37/82] refactor(handler): Created a macro to handle all table key events to reduce code duplication and make future implementations faster; Only refactored the Sonarr library to use it thus far --- Cargo.lock | 1 + Cargo.toml | 1 + src/handlers/handler_test_utils.rs | 11 + src/handlers/handlers_tests.rs | 12 + src/handlers/mod.rs | 1 + .../library/library_handler_tests.rs | 61 ++- src/handlers/sonarr_handlers/library/mod.rs | 276 ++---------- .../library/series_details_handler.rs | 42 +- src/handlers/table_handler.rs | 401 ++++++++++++++++++ 9 files changed, 547 insertions(+), 259 deletions(-) create mode 100644 src/handlers/table_handler.rs diff --git a/Cargo.lock b/Cargo.lock index e673269..b013f5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1349,6 +1349,7 @@ dependencies = [ "managarr-tree-widget", "mockall", "mockito", + "paste", "pretty_assertions", "ratatui", "regex", diff --git a/Cargo.toml b/Cargo.toml index 0006109..82e13d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ managarr-tree-widget = "0.24.0" indicatif = "0.17.9" derive_setters = "0.1.6" deunicode = "1.6.0" +paste = "1.0.15" [dev-dependencies] assert_cmd = "2.0.16" diff --git a/src/handlers/handler_test_utils.rs b/src/handlers/handler_test_utils.rs index a4b7112..e7f6eb2 100644 --- a/src/handlers/handler_test_utils.rs +++ b/src/handlers/handler_test_utils.rs @@ -103,6 +103,7 @@ mod test_utils { #[rstest] fn $func(#[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key) { let mut app = App::default(); + app.push_navigation_stack($block.into()); app .data .$servarr_data @@ -129,6 +130,7 @@ mod test_utils { #[rstest] fn $func(#[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key) { let mut app = App::default(); + app.push_navigation_stack($block.into()); app .data .$servarr_data @@ -155,6 +157,7 @@ mod test_utils { #[rstest] fn $func(#[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key) { let mut app = App::default(); + app.push_navigation_stack($block.into()); app.data.$servarr_data.$data_ref.set_items($items); $handler::with(key, &mut app, $block, $context).handle(); @@ -177,6 +180,7 @@ mod test_utils { #[rstest] fn $func(#[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key) { let mut app = App::default(); + app.push_navigation_stack($block.into()); app.data.$servarr_data.$data_ref.set_items($items); $handler::with(key, &mut app, $block, $context).handle(); @@ -214,6 +218,7 @@ mod test_utils { #[test] fn $func() { let mut app = App::default(); + app.push_navigation_stack($block.into()); app.data.$servarr_data.$data_ref.set_items(vec![ "Test 1".to_owned(), "Test 2".to_owned(), @@ -240,6 +245,7 @@ mod test_utils { #[test] fn $func() { let mut app = App::default(); + app.push_navigation_stack($block.into()); app .data .$servarr_data @@ -266,6 +272,7 @@ mod test_utils { #[test] fn $func() { let mut app = App::default(); + app.push_navigation_stack($block.into()); app.data.$servarr_data.$data_ref.set_items($items); $handler::with(DEFAULT_KEYBINDINGS.end.key, &mut app, $block, $context).handle(); @@ -288,6 +295,7 @@ mod test_utils { #[test] fn $func() { let mut app = App::default(); + app.push_navigation_stack($block.into()); app.data.$servarr_data.$data_ref.set_items($items); $handler::with(DEFAULT_KEYBINDINGS.end.key, &mut app, $block, $context).handle(); @@ -328,6 +336,9 @@ mod test_utils { $crate::models::sonarr_models::SonarrHistoryItem::default(), ]); app.data.sonarr_data.series_history = Some(series_history); + app.data.sonarr_data.series.set_items(vec![ + $crate::models::sonarr_models::Series::default(), + ]); app.push_navigation_stack($base.into()); app.push_navigation_stack($active_block.into()); diff --git a/src/handlers/handlers_tests.rs b/src/handlers/handlers_tests.rs index d204e18..0b0fa44 100644 --- a/src/handlers/handlers_tests.rs +++ b/src/handlers/handlers_tests.rs @@ -1,5 +1,7 @@ #[cfg(test)] mod tests { + use crate::models::radarr_models::Movie; + use crate::models::sonarr_models::Series; use pretty_assertions::assert_eq; use rstest::rstest; @@ -30,6 +32,16 @@ mod tests { let mut app = App::default(); app.push_navigation_stack(base_block); app.push_navigation_stack(top_block); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); handle_events(DEFAULT_KEYBINDINGS.esc.key, &mut app); diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 1f6f88e..e3d2ddc 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -16,6 +16,7 @@ mod handlers_tests; #[cfg(test)] #[path = "handler_test_utils.rs"] pub mod handler_test_utils; +mod table_handler; pub trait KeyEventHandler<'a, 'b, T: Into + Copy> { fn handle_key_event(&mut self) { diff --git a/src/handlers/sonarr_handlers/library/library_handler_tests.rs b/src/handlers/sonarr_handlers/library/library_handler_tests.rs index 2da1cc3..2e193c2 100644 --- a/src/handlers/sonarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/library_handler_tests.rs @@ -195,6 +195,7 @@ mod tests { #[test] fn test_series_search_box_home_end_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); app .data .sonarr_data @@ -248,6 +249,7 @@ mod tests { #[test] fn test_series_filter_box_home_end_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); app .data .sonarr_data @@ -473,6 +475,8 @@ mod tests { #[test] fn test_series_search_box_left_right_keys() { let mut app = App::default(); + app.data.sonarr_data.series.set_items(vec![Series::default()]); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); app.data.sonarr_data.series.search = Some("Test".into()); LibraryHandler::with( @@ -521,6 +525,8 @@ mod tests { #[test] fn test_series_filter_box_left_right_keys() { let mut app = App::default(); + app.data.sonarr_data.series.set_items(vec![Series::default()]); + app.push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); app.data.sonarr_data.series.filter = Some("Test".into()); LibraryHandler::with( @@ -860,6 +866,7 @@ mod tests { app.push_navigation_stack(active_sonarr_block.into()); app.data.sonarr_data = create_test_sonarr_data(); app.data.sonarr_data.series.search = Some("Test".into()); + app.data.sonarr_data.series.set_items(vec![Series::default()]); LibraryHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); @@ -868,15 +875,10 @@ mod tests { assert_eq!(app.data.sonarr_data.series.search, None); } - #[rstest] - fn test_filter_series_block_esc( - #[values(ActiveSonarrBlock::FilterSeries, ActiveSonarrBlock::FilterSeriesError)] - active_sonarr_block: ActiveSonarrBlock, - ) { + #[test] + fn test_series_block_esc_resets_filter_if_already_set() { let mut app = App::default(); - app.should_ignore_quit_key = true; app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.push_navigation_stack(active_sonarr_block.into()); app.data.sonarr_data = create_test_sonarr_data(); app.data.sonarr_data.series = StatefulTable { filter: Some("Test".into()), @@ -884,8 +886,32 @@ mod tests { filtered_state: Some(TableState::default()), ..StatefulTable::default() }; + app.data.sonarr_data.series.set_items(vec![Series::default()]); - LibraryHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + LibraryHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::Series, None).handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); + assert_eq!(app.data.sonarr_data.series.filter, None); + assert_eq!(app.data.sonarr_data.series.filtered_items, None); + assert_eq!(app.data.sonarr_data.series.filtered_state, None); + } + + #[test] + fn test_filter_series_error_block_esc() { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::FilterSeriesError.into()); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.series = StatefulTable { + filter: Some("Test".into()), + filtered_items: Some(Vec::new()), + filtered_state: Some(TableState::default()), + ..StatefulTable::default() + }; + app.data.sonarr_data.series.set_items(vec![Series::default()]); + + LibraryHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::FilterSeriesError, None).handle(); assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); assert!(!app.should_ignore_quit_key); @@ -916,6 +942,7 @@ mod tests { #[test] fn test_series_sort_prompt_block_esc() { let mut app = App::default(); + app.data.sonarr_data.series.set_items(vec![Series::default()]); app.push_navigation_stack(ActiveSonarrBlock::Series.into()); app.push_navigation_stack(ActiveSonarrBlock::SeriesSortPrompt.into()); @@ -932,22 +959,11 @@ mod tests { app.push_navigation_stack(ActiveSonarrBlock::Series.into()); app.push_navigation_stack(ActiveSonarrBlock::Series.into()); app.data.sonarr_data = create_test_sonarr_data(); - app.data.sonarr_data.series = StatefulTable { - search: Some("Test".into()), - filter: Some("Test".into()), - filtered_items: Some(Vec::new()), - filtered_state: Some(TableState::default()), - ..StatefulTable::default() - }; LibraryHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::Series, None).handle(); assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); assert!(app.error.text.is_empty()); - assert_eq!(app.data.sonarr_data.series.search, None); - assert_eq!(app.data.sonarr_data.series.filter, None); - assert_eq!(app.data.sonarr_data.series.filtered_items, None); - assert_eq!(app.data.sonarr_data.series.filtered_state, None); } } @@ -968,6 +984,7 @@ mod tests { #[test] fn test_search_series_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); app .data .sonarr_data @@ -1020,6 +1037,7 @@ mod tests { #[test] fn test_filter_series_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); app .data .sonarr_data @@ -1274,6 +1292,7 @@ mod tests { #[test] fn test_search_series_box_backspace_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); app.data.sonarr_data.series.search = Some("Test".into()); app .data @@ -1298,6 +1317,7 @@ mod tests { #[test] fn test_filter_series_box_backspace_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); app .data .sonarr_data @@ -1322,6 +1342,7 @@ mod tests { #[test] fn test_search_series_box_char_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); app .data .sonarr_data @@ -1346,6 +1367,7 @@ mod tests { #[test] fn test_filter_series_box_char_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); app .data .sonarr_data @@ -1370,6 +1392,7 @@ mod tests { #[test] fn test_sort_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); app .data .sonarr_data diff --git a/src/handlers/sonarr_handlers/library/mod.rs b/src/handlers/sonarr_handlers/library/mod.rs index c91ffc1..a5ff91e 100644 --- a/src/handlers/sonarr_handlers/library/mod.rs +++ b/src/handlers/sonarr_handlers/library/mod.rs @@ -6,7 +6,7 @@ use edit_series_handler::EditSeriesHandler; use crate::{ app::App, event::Key, - handle_text_box_keys, handle_text_box_left_right_keys, + handle_table_events, handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}, models::{ servarr_data::sonarr::sonarr_data::{ @@ -23,6 +23,7 @@ use crate::{ use super::handle_change_tab_left_right_keys; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::handlers::sonarr_handlers::library::series_details_handler::SeriesDetailsHandler; +use crate::handlers::table_handler::TableHandlingProps; mod add_series_handler; mod delete_series_handler; @@ -39,25 +40,43 @@ pub(super) struct LibraryHandler<'a, 'b> { context: Option, } +impl<'a, 'b> LibraryHandler<'a, 'b> { + handle_table_events!(self, series, self.app.data.sonarr_data.series, Series); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, 'b> { fn handle(&mut self) { - match self.active_sonarr_block { - _ if AddSeriesHandler::accepts(self.active_sonarr_block) => { - AddSeriesHandler::with(self.key, self.app, self.active_sonarr_block, self.context).handle(); + let series_table_handling_props = TableHandlingProps::new(ActiveSonarrBlock::Series.into()) + .sorting_block(ActiveSonarrBlock::SeriesSortPrompt.into()) + .sort_by_fn(|a: &Series, b: &Series| a.id.cmp(&b.id)) + .sort_options(series_sorting_options()) + .searching_block(ActiveSonarrBlock::SearchSeries.into()) + .search_error_block(ActiveSonarrBlock::SearchSeriesError.into()) + .search_field_fn(|series| &series.title.text) + .filtering_block(ActiveSonarrBlock::FilterSeries.into()) + .filter_error_block(ActiveSonarrBlock::FilterSeriesError.into()) + .filter_field_fn(|series| &series.title.text); + + if !self.handle_series_table_events(series_table_handling_props) { + match self.active_sonarr_block { + _ if AddSeriesHandler::accepts(self.active_sonarr_block) => { + AddSeriesHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle(); + } + _ if DeleteSeriesHandler::accepts(self.active_sonarr_block) => { + DeleteSeriesHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle(); + } + _ if EditSeriesHandler::accepts(self.active_sonarr_block) => { + EditSeriesHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle(); + } + _ if SeriesDetailsHandler::accepts(self.active_sonarr_block) => { + SeriesDetailsHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle(); + } + _ => self.handle_key_event(), } - _ if DeleteSeriesHandler::accepts(self.active_sonarr_block) => { - DeleteSeriesHandler::with(self.key, self.app, self.active_sonarr_block, self.context) - .handle(); - } - _ if EditSeriesHandler::accepts(self.active_sonarr_block) => { - EditSeriesHandler::with(self.key, self.app, self.active_sonarr_block, self.context) - .handle(); - } - _ if SeriesDetailsHandler::accepts(self.active_sonarr_block) => { - SeriesDetailsHandler::with(self.key, self.app, self.active_sonarr_block, self.context) - .handle(); - } - _ => self.handle_key_event(), } } @@ -91,109 +110,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' !self.app.is_loading && !self.app.data.sonarr_data.series.is_empty() } - fn handle_scroll_up(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::Series => self.app.data.sonarr_data.series.scroll_up(), - ActiveSonarrBlock::SeriesSortPrompt => self - .app - .data - .sonarr_data - .series - .sort - .as_mut() - .unwrap() - .scroll_up(), - _ => (), - } - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::Series => self.app.data.sonarr_data.series.scroll_down(), - ActiveSonarrBlock::SeriesSortPrompt => self - .app - .data - .sonarr_data - .series - .sort - .as_mut() - .unwrap() - .scroll_down(), - _ => (), - } - } + fn handle_scroll_down(&mut self) {} - fn handle_home(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::Series => self.app.data.sonarr_data.series.scroll_to_top(), - ActiveSonarrBlock::SearchSeries => { - self - .app - .data - .sonarr_data - .series - .search - .as_mut() - .unwrap() - .scroll_home(); - } - ActiveSonarrBlock::FilterSeries => { - self - .app - .data - .sonarr_data - .series - .filter - .as_mut() - .unwrap() - .scroll_home(); - } - ActiveSonarrBlock::SeriesSortPrompt => self - .app - .data - .sonarr_data - .series - .sort - .as_mut() - .unwrap() - .scroll_to_top(), - _ => (), - } - } + fn handle_home(&mut self) {} - fn handle_end(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::Series => self.app.data.sonarr_data.series.scroll_to_bottom(), - ActiveSonarrBlock::SearchSeries => self - .app - .data - .sonarr_data - .series - .search - .as_mut() - .unwrap() - .reset_offset(), - ActiveSonarrBlock::FilterSeries => self - .app - .data - .sonarr_data - .series - .filter - .as_mut() - .unwrap() - .reset_offset(), - ActiveSonarrBlock::SeriesSortPrompt => self - .app - .data - .sonarr_data - .series - .sort - .as_mut() - .unwrap() - .scroll_to_bottom(), - _ => (), - } - } + fn handle_end(&mut self) {} fn handle_delete(&mut self) { if self.active_sonarr_block == ActiveSonarrBlock::Series { @@ -209,20 +132,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' match self.active_sonarr_block { ActiveSonarrBlock::Series => handle_change_tab_left_right_keys(self.app, self.key), ActiveSonarrBlock::UpdateAllSeriesPrompt => handle_prompt_toggle(self.app, self.key), - ActiveSonarrBlock::SearchSeries => { - handle_text_box_left_right_keys!( - self, - self.key, - self.app.data.sonarr_data.series.search.as_mut().unwrap() - ) - } - ActiveSonarrBlock::FilterSeries => { - handle_text_box_left_right_keys!( - self, - self.key, - self.app.data.sonarr_data.series.filter.as_mut().unwrap() - ) - } _ => (), } } @@ -232,44 +141,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' ActiveSonarrBlock::Series => self .app .push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()), - ActiveSonarrBlock::SearchSeries => { - self.app.pop_navigation_stack(); - self.app.should_ignore_quit_key = false; - - if self.app.data.sonarr_data.series.search.is_some() { - let has_match = self - .app - .data - .sonarr_data - .series - .apply_search(|series| &series.title.text); - - if !has_match { - self - .app - .push_navigation_stack(ActiveSonarrBlock::SearchSeriesError.into()); - } - } - } - ActiveSonarrBlock::FilterSeries => { - self.app.pop_navigation_stack(); - self.app.should_ignore_quit_key = false; - - if self.app.data.sonarr_data.series.filter.is_some() { - let has_matches = self - .app - .data - .sonarr_data - .series - .apply_filter(|series| &series.title.text); - - if !has_matches { - self - .app - .push_navigation_stack(ActiveSonarrBlock::FilterSeriesError.into()); - } - } - } ActiveSonarrBlock::UpdateAllSeriesPrompt => { if self.app.data.sonarr_data.prompt_confirm { self.app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::UpdateAllSeries); @@ -277,44 +148,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' self.app.pop_navigation_stack(); } - ActiveSonarrBlock::SeriesSortPrompt => { - self - .app - .data - .sonarr_data - .series - .items - .sort_by(|a, b| a.id.cmp(&b.id)); - self.app.data.sonarr_data.series.apply_sorting(); - - self.app.pop_navigation_stack(); - } _ => (), } } fn handle_esc(&mut self) { match self.active_sonarr_block { - ActiveSonarrBlock::FilterSeries | ActiveSonarrBlock::FilterSeriesError => { - self.app.pop_navigation_stack(); - self.app.data.sonarr_data.series.reset_filter(); - self.app.should_ignore_quit_key = false; - } - ActiveSonarrBlock::SearchSeries | ActiveSonarrBlock::SearchSeriesError => { - self.app.pop_navigation_stack(); - self.app.data.sonarr_data.series.reset_search(); - self.app.should_ignore_quit_key = false; - } ActiveSonarrBlock::UpdateAllSeriesPrompt => { self.app.pop_navigation_stack(); self.app.data.sonarr_data.prompt_confirm = false; } - ActiveSonarrBlock::SeriesSortPrompt => { - self.app.pop_navigation_stack(); - } _ => { - self.app.data.sonarr_data.series.reset_search(); - self.app.data.sonarr_data.series.reset_filter(); handle_clear_errors(self.app); } } @@ -324,21 +168,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' let key = self.key; match self.active_sonarr_block { ActiveSonarrBlock::Series => match self.key { - _ if key == DEFAULT_KEYBINDINGS.search.key => { - self - .app - .push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); - self.app.data.sonarr_data.series.search = Some(HorizontallyScrollableText::default()); - self.app.should_ignore_quit_key = true; - } - _ if key == DEFAULT_KEYBINDINGS.filter.key => { - self - .app - .push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); - self.app.data.sonarr_data.series.reset_filter(); - self.app.data.sonarr_data.series.filter = Some(HorizontallyScrollableText::default()); - self.app.should_ignore_quit_key = true; - } _ if key == DEFAULT_KEYBINDINGS.edit.key => { self.app.push_navigation_stack( ( @@ -366,33 +195,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' _ if key == DEFAULT_KEYBINDINGS.refresh.key => { self.app.should_refresh = true; } - _ if key == DEFAULT_KEYBINDINGS.sort.key => { - self - .app - .data - .sonarr_data - .series - .sorting(series_sorting_options()); - self - .app - .push_navigation_stack(ActiveSonarrBlock::SeriesSortPrompt.into()); - } _ => (), }, - ActiveSonarrBlock::SearchSeries => { - handle_text_box_keys!( - self, - key, - self.app.data.sonarr_data.series.search.as_mut().unwrap() - ) - } - ActiveSonarrBlock::FilterSeries => { - handle_text_box_keys!( - self, - key, - self.app.data.sonarr_data.series.filter.as_mut().unwrap() - ) - } ActiveSonarrBlock::UpdateAllSeriesPrompt => { if key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.sonarr_data.prompt_confirm = true; diff --git a/src/handlers/sonarr_handlers/library/series_details_handler.rs b/src/handlers/sonarr_handlers/library/series_details_handler.rs index e47fa18..950ee72 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler.rs @@ -436,8 +436,24 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler self.app.pop_navigation_stack(); } ActiveSonarrBlock::SeriesHistory => { - if self.app.data.sonarr_data.series_history.as_ref().expect("Series history is not populated").filtered_items.is_some() { - self.app.data.sonarr_data.series_history.as_mut().expect("Series history is not populated").reset_filter(); + if self + .app + .data + .sonarr_data + .series_history + .as_ref() + .expect("Series history is not populated") + .filtered_items + .is_some() + { + self + .app + .data + .sonarr_data + .series_history + .as_mut() + .expect("Series history is not populated") + .reset_filter(); } else { self.app.pop_navigation_stack(); self.app.data.sonarr_data.reset_series_info_tabs(); @@ -577,14 +593,32 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler handle_text_box_keys!( self, key, - self.app.data.sonarr_data.series_history.as_mut().expect("Series history should be populated").search.as_mut().unwrap() + self + .app + .data + .sonarr_data + .series_history + .as_mut() + .expect("Series history should be populated") + .search + .as_mut() + .unwrap() ) } ActiveSonarrBlock::FilterSeriesHistory => { handle_text_box_keys!( self, key, - self.app.data.sonarr_data.series_history.as_mut().expect("Series history should be populated").filter.as_mut().unwrap() + self + .app + .data + .sonarr_data + .series_history + .as_mut() + .expect("Series history should be populated") + .filter + .as_mut() + .unwrap() ) } ActiveSonarrBlock::AutomaticallySearchSeriesPrompt => { diff --git a/src/handlers/table_handler.rs b/src/handlers/table_handler.rs new file mode 100644 index 0000000..b05759f --- /dev/null +++ b/src/handlers/table_handler.rs @@ -0,0 +1,401 @@ +use crate::models::stateful_table::SortOption; +use crate::models::Route; +use derive_setters::Setters; +use std::cmp::Ordering; +use std::fmt::Debug; + +#[derive(Setters)] +pub struct TableHandlingProps +where + T: Clone + PartialEq + Eq + Debug + Default, +{ + #[setters(strip_option)] + pub sorting_block: Option, + #[setters(strip_option)] + pub sort_options: Option>>, + #[setters(strip_option)] + pub sort_by_fn: Option Ordering>, + #[setters(strip_option)] + pub searching_block: Option, + #[setters(strip_option)] + pub search_error_block: Option, + #[setters(strip_option)] + pub search_field_fn: Option &str>, + #[setters(strip_option)] + pub filtering_block: Option, + #[setters(strip_option)] + pub filter_error_block: Option, + #[setters(strip_option)] + pub filter_field_fn: Option &str>, + #[setters(skip)] + pub table_block: Route, +} + +#[macro_export] +macro_rules! handle_table_events { + ($self:expr, $name:ty, $table:expr, $row:ident) => { + paste::paste! { + fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + if $self.is_ready() { + match $self.key { + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.up.key => $self.[](props), + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.down.key => $self.[](props), + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.home.key => $self.[](props), + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.end.key => $self.[](props), + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.left.key + || $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.right.key => + { + $self.[](props) + } + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.submit.key => $self.[](props), + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.esc.key => $self.[](props), + _ if props.searching_block.is_some() + && $self.app.get_current_route() == *props.searching_block.as_ref().unwrap() => + { + $self.[]() + } + _ if props.filtering_block.is_some() + && $self.app.get_current_route() == *props.filtering_block.as_ref().unwrap() => + { + $self.[]() + } + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.filter.key => $self.[](props), + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.search.key => $self.[](props), + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.sort.key => $self.[](props), + _ => false, + } + } else { + false + } + } + + fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + match $self.app.get_current_route() { + _ if props.table_block == $self.app.get_current_route() => { + $table.scroll_up(); + true + } + _ if props.sorting_block.is_some() + && $self.app.get_current_route() == *props.sorting_block.as_ref().unwrap() => + { + $table.sort.as_mut().unwrap().scroll_up(); + true + } + _ => false, + } + } + + fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + match $self.app.get_current_route() { + _ if props.table_block == $self.app.get_current_route() => { + $table.scroll_down(); + true + } + _ if props.sorting_block.is_some() + && $self.app.get_current_route() == *props.sorting_block.as_ref().unwrap() => + { + $table + .sort + .as_mut() + .unwrap() + .scroll_down(); + true + } + _ => false, + } + } + + fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + match $self.app.get_current_route() { + _ if props.table_block == $self.app.get_current_route() => { + $table.scroll_to_top(); + true + } + _ if props.sorting_block.is_some() + && $self.app.get_current_route() == *props.sorting_block.as_ref().unwrap() => + { + $table + .sort + .as_mut() + .unwrap() + .scroll_to_top(); + true + } + _ if props.searching_block.is_some() + && $self.app.get_current_route() == *props.searching_block.as_ref().unwrap() => + { + $table + .search + .as_mut() + .unwrap() + .scroll_home(); + true + } + _ if props.filtering_block.is_some() + && $self.app.get_current_route() == *props.filtering_block.as_ref().unwrap() => + { + $table + .filter + .as_mut() + .unwrap() + .scroll_home(); + true + } + _ => false, + } + } + + fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + match $self.app.get_current_route() { + _ if props.table_block == $self.app.get_current_route() => { + $table.scroll_to_bottom(); + true + } + _ if props.sorting_block.is_some() + && $self.app.get_current_route() == *props.sorting_block.as_ref().unwrap() => + { + $table + .sort + .as_mut() + .unwrap() + .scroll_to_bottom(); + true + } + _ if props.searching_block.is_some() + && $self.app.get_current_route() == *props.searching_block.as_ref().unwrap() => + { + $table + .search + .as_mut() + .unwrap() + .reset_offset(); + true + } + _ if props.filtering_block.is_some() + && $self.app.get_current_route() == *props.filtering_block.as_ref().unwrap() => + { + $table + .filter + .as_mut() + .unwrap() + .reset_offset(); + true + } + _ => false, + } + } + + fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + match $self.app.get_current_route() { + _ if props.searching_block.is_some() + && $self.app.get_current_route() == *props.searching_block.as_ref().unwrap() => + { + $crate::handle_text_box_left_right_keys!( + $self, + $self.key, + $table.search.as_mut().unwrap() + ); + true + } + _ if props.filtering_block.is_some() + && $self.app.get_current_route() == *props.filtering_block.as_ref().unwrap() => + { + $crate::handle_text_box_left_right_keys!( + $self, + $self.key, + $table.filter.as_mut().unwrap() + ); + true + } + _ => false, + } + } + + fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + match $self.app.get_current_route() { + _ if props.sorting_block.is_some() + && $self.app.get_current_route() == *props.sorting_block.as_ref().unwrap() => + { + let sort_by_fn = props.sort_by_fn.expect("Sort by function is required"); + + $table.items.sort_by(sort_by_fn); + $table.apply_sorting(); + $self.app.pop_navigation_stack(); + + true + } + _ if props.searching_block.is_some() + && $self.app.get_current_route() == *props.searching_block.as_ref().unwrap() => + { + $self.app.pop_navigation_stack(); + $self.app.should_ignore_quit_key = false; + + if $table.search.is_some() { + let search_field_fn = props + .search_field_fn + .expect("Search field function is required"); + let has_match = $table.apply_search(search_field_fn); + + if !has_match { + $self.app.push_navigation_stack( + props + .search_error_block + .expect("Search error block is undefined"), + ); + } + } + + true + } + _ if props.filtering_block.is_some() + && $self.app.get_current_route() == *props.filtering_block.as_ref().unwrap() => + { + $self.app.pop_navigation_stack(); + $self.app.should_ignore_quit_key = false; + + if $table.filter.is_some() { + let filter_field_fn = props + .filter_field_fn + .expect("Search field function is required"); + let has_match = $table.apply_filter(filter_field_fn); + + if !has_match { + $self.app.push_navigation_stack( + props + .filter_error_block + .expect("Search error block is undefined"), + ); + } + } + + true + } + _ => false, + } + } + + fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + match $self.app.get_current_route() { + _ if props.sorting_block.is_some() + && $self.app.get_current_route() == *props.sorting_block.as_ref().unwrap() => + { + $self.app.pop_navigation_stack(); + true + } + _ if (props.searching_block.is_some() + && $self.app.get_current_route() == *props.searching_block.as_ref().unwrap()) + || (props.search_error_block.is_some() + && $self.app.get_current_route() == *props.search_error_block.as_ref().unwrap()) => + { + $self.app.pop_navigation_stack(); + $table.reset_search(); + $self.app.should_ignore_quit_key = false; + true + } + _ if (props.filtering_block.is_some() + && $self.app.get_current_route() == *props.filtering_block.as_ref().unwrap()) + || (props.filter_error_block.is_some() + && $self.app.get_current_route() == *props.filter_error_block.as_ref().unwrap()) => + { + $self.app.pop_navigation_stack(); + $table.reset_filter(); + $self.app.should_ignore_quit_key = false; + true + } + _ if props.table_block == $self.app.get_current_route() + && $table.filtered_items.is_some() => + { + $table.reset_filter(); + true + } + _ => false, + } + } + + fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + if matches!($self.app.get_current_route(), _ if props.table_block == $self.app.get_current_route()) { + $self + .app + .push_navigation_stack(props.filtering_block.expect("Filtering block is undefined").into()); + $table.reset_filter(); + $table.filter = Some(HorizontallyScrollableText::default()); + $self.app.should_ignore_quit_key = true; + + true + } else { + false + } + } + + fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + if matches!($self.app.get_current_route(), _ if props.table_block == $self.app.get_current_route()) { + $self + .app + .push_navigation_stack(props.searching_block.expect("Searching block is undefined")); + $table.search = Some(HorizontallyScrollableText::default()); + $self.app.should_ignore_quit_key = true; + + true + } else { + false + } + } + + fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + if matches!($self.app.get_current_route(), _ if props.table_block == $self.app.get_current_route()) { + $table.sorting( + props + .sort_options + .as_ref() + .expect("Sort options are undefined") + .clone(), + ); + $self + .app + .push_navigation_stack(props.sorting_block.expect("Sorting block is undefined")); + true + } else { + false + } + } + + fn [](&mut $self) -> bool { + $crate::handle_text_box_keys!( + $self, + $self.key, + $table.search.as_mut().unwrap() + ); + true + } + + fn [](&mut $self) -> bool { + $crate::handle_text_box_keys!( + $self, + $self.key, + $table.filter.as_mut().unwrap() + ); + true + } + } + }; +} + +impl TableHandlingProps +where + T: Clone + PartialEq + Eq + Debug + Default, +{ + pub fn new(table_block: Route) -> Self { + TableHandlingProps { + sorting_block: None, + sort_options: None, + sort_by_fn: None, + searching_block: None, + search_error_block: None, + search_field_fn: None, + filtering_block: None, + filter_error_block: None, + filter_field_fn: None, + table_block, + } + } +} From accdf99503d64992eb65ceee982393eb7d966432 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sat, 7 Dec 2024 19:36:43 -0700 Subject: [PATCH 38/82] fix(ui): Fixed a bug that requires a minimum height for all popups so all error messages and other simple popups appear --- src/ui/widgets/popup.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/ui/widgets/popup.rs b/src/ui/widgets/popup.rs index 02b5083..40dca22 100644 --- a/src/ui/widgets/popup.rs +++ b/src/ui/widgets/popup.rs @@ -90,7 +90,16 @@ impl<'a, T: Widget> Popup<'a, T> { } fn render_popup(self, area: Rect, buf: &mut Buffer) { - let popup_area = centered_rect(self.percent_x, self.percent_y, area); + let mut popup_area = centered_rect(self.percent_x, self.percent_y, area); + let height = if popup_area.height < 3 { + 3 + } else { + popup_area.height + }; + popup_area = Rect { + height, + ..popup_area + }; Clear.render(popup_area, buf); background_block().render(popup_area, buf); @@ -120,6 +129,6 @@ impl<'a, T: Widget> Popup<'a, T> { impl<'a, T: Widget> Widget for Popup<'a, T> { fn render(self, area: Rect, buf: &mut Buffer) { - self.render_popup(area, buf); + self.render_popup(area, buf); } } From c58e8b1a00c6c30b85fc3cbf58e728dd1565290f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 12:29:59 -0700 Subject: [PATCH 39/82] refactor(series_details_handler): Use the new handle_table_events macro --- .../library/series_details_handler.rs | 442 +++--------------- .../library/series_details_handler_tests.rs | 15 +- 2 files changed, 68 insertions(+), 389 deletions(-) diff --git a/src/handlers/sonarr_handlers/library/series_details_handler.rs b/src/handlers/sonarr_handlers/library/series_details_handler.rs index 950ee72..9e95788 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler.rs @@ -1,12 +1,14 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; -use crate::handle_text_box_keys; +use crate::handle_table_events; use crate::handlers::sonarr_handlers::history::history_sorting_options; +use crate::handlers::table_handler::TableHandlingProps; use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EDIT_SERIES_SELECTION_BLOCKS, SERIES_DETAILS_BLOCKS, }; +use crate::models::sonarr_models::{Season, SonarrHistoryItem}; use crate::models::{BlockSelectionState, HorizontallyScrollableText, Scrollable}; use crate::network::sonarr_network::SonarrEvent; @@ -21,7 +23,53 @@ pub(super) struct SeriesDetailsHandler<'a, 'b> { _context: Option, } +impl<'a, 'b> SeriesDetailsHandler<'a, 'b> { + handle_table_events!(self, season, self.app.data.sonarr_data.seasons, Season); + handle_table_events!( + self, + series_history, + self + .app + .data + .sonarr_data + .series_history + .as_mut() + .expect("Series history is undefined"), + SonarrHistoryItem + ); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler<'a, 'b> { + fn handle(&mut self) { + let season_table_handling_props = + TableHandlingProps::new(ActiveSonarrBlock::SeriesDetails.into()) + .searching_block(ActiveSonarrBlock::SearchSeason.into()) + .search_error_block(ActiveSonarrBlock::SearchSeasonError.into()) + .search_field_fn(|season: &Season| { + season + .title + .as_ref() + .expect("Season was not populated with title in handlers") + }); + let series_history_table_handling_props = + TableHandlingProps::new(ActiveSonarrBlock::SeriesHistory.into()) + .sorting_block(ActiveSonarrBlock::SeriesHistorySortPrompt.into()) + .sort_options(history_sorting_options()) + .sort_by_fn(|a: &SonarrHistoryItem, b: &SonarrHistoryItem| a.id.cmp(&b.id)) + .searching_block(ActiveSonarrBlock::SearchSeriesHistory.into()) + .search_error_block(ActiveSonarrBlock::SearchSeriesHistoryError.into()) + .search_field_fn(|history_item: &SonarrHistoryItem| &history_item.source_title.text) + .filtering_block(ActiveSonarrBlock::FilterSeriesHistory.into()) + .filter_error_block(ActiveSonarrBlock::FilterSeriesHistoryError.into()) + .filter_field_fn(|history_item: &SonarrHistoryItem| &history_item.source_title.text); + + if !self.handle_season_table_events(season_table_handling_props) + && !self.handle_series_history_table_events(series_history_table_handling_props) + { + self.handle_key_event(); + } + } + fn accepts(active_block: ActiveSonarrBlock) -> bool { SERIES_DETAILS_BLOCKS.contains(&active_block) } @@ -52,171 +100,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler } } - fn handle_scroll_up(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::SeriesDetails => self.app.data.sonarr_data.seasons.scroll_up(), - ActiveSonarrBlock::SeriesHistory => self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .scroll_up(), - ActiveSonarrBlock::SeriesHistorySortPrompt => self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .sort - .as_mut() - .unwrap() - .scroll_up(), - _ => (), - } - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::SeriesDetails => self.app.data.sonarr_data.seasons.scroll_down(), - ActiveSonarrBlock::SeriesHistory => self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .scroll_down(), - ActiveSonarrBlock::SeriesHistorySortPrompt => self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .sort - .as_mut() - .unwrap() - .scroll_down(), - _ => (), - } - } + fn handle_scroll_down(&mut self) {} - fn handle_home(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::SeriesDetails => self.app.data.sonarr_data.seasons.scroll_to_top(), - ActiveSonarrBlock::SearchSeason => self - .app - .data - .sonarr_data - .seasons - .search - .as_mut() - .unwrap() - .scroll_home(), - ActiveSonarrBlock::SearchSeriesHistory => self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .search - .as_mut() - .unwrap() - .scroll_home(), - ActiveSonarrBlock::FilterSeriesHistory => self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .filter - .as_mut() - .unwrap() - .scroll_home(), - ActiveSonarrBlock::SeriesHistory => self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .scroll_to_top(), - ActiveSonarrBlock::SeriesHistorySortPrompt => self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .sort - .as_mut() - .unwrap() - .scroll_to_top(), - _ => (), - } - } + fn handle_home(&mut self) {} - fn handle_end(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::SeriesDetails => self.app.data.sonarr_data.seasons.scroll_to_bottom(), - ActiveSonarrBlock::SearchSeason => self - .app - .data - .sonarr_data - .seasons - .search - .as_mut() - .unwrap() - .reset_offset(), - ActiveSonarrBlock::SearchSeriesHistory => self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .search - .as_mut() - .unwrap() - .reset_offset(), - ActiveSonarrBlock::FilterSeriesHistory => self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .filter - .as_mut() - .unwrap() - .reset_offset(), - ActiveSonarrBlock::SeriesHistory => self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .scroll_to_bottom(), - ActiveSonarrBlock::SeriesHistorySortPrompt => self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .sort - .as_mut() - .unwrap() - .scroll_to_bottom(), - _ => (), - } - } + fn handle_end(&mut self) {} fn handle_delete(&mut self) {} @@ -292,147 +182,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler self.app.pop_navigation_stack(); } - ActiveSonarrBlock::SearchSeason => { - self.app.pop_navigation_stack(); - self.app.should_ignore_quit_key = false; - - if self.app.data.sonarr_data.seasons.search.is_some() { - let has_match = self.app.data.sonarr_data.seasons.apply_search(|season| { - season - .title - .as_ref() - .expect("Season was not populated with title in handlers") - }); - - if !has_match { - self - .app - .push_navigation_stack(ActiveSonarrBlock::SearchSeasonError.into()); - } - } - } - ActiveSonarrBlock::SearchSeriesHistory => { - self.app.pop_navigation_stack(); - self.app.should_ignore_quit_key = false; - - if self - .app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .search - .is_some() - { - let has_match = self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .apply_search(|history_item| &history_item.source_title.text); - - if !has_match { - self - .app - .push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistoryError.into()); - } - } - } - ActiveSonarrBlock::FilterSeriesHistory => { - self.app.pop_navigation_stack(); - self.app.should_ignore_quit_key = false; - - if self - .app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .filter - .is_some() - { - let has_matches = self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .apply_filter(|history_item| &history_item.source_title.text); - - if !has_matches { - self - .app - .push_navigation_stack(ActiveSonarrBlock::FilterSeriesHistoryError.into()); - } - } - } - ActiveSonarrBlock::SeriesHistorySortPrompt => { - self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .items - .sort_by(|a, b| a.id.cmp(&b.id)); - self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .apply_sorting(); - - self.app.pop_navigation_stack(); - } _ => (), } } fn handle_esc(&mut self) { match self.active_sonarr_block { - ActiveSonarrBlock::SearchSeason | ActiveSonarrBlock::SearchSeasonError => { - self.app.pop_navigation_stack(); - self.app.data.sonarr_data.seasons.reset_search(); - self.app.should_ignore_quit_key = false; - } - ActiveSonarrBlock::SearchSeriesHistory | ActiveSonarrBlock::SearchSeriesHistoryError => { - self.app.pop_navigation_stack(); - self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .reset_search(); - self.app.should_ignore_quit_key = false; - } - ActiveSonarrBlock::FilterSeriesHistory | ActiveSonarrBlock::FilterSeriesHistoryError => { - self.app.pop_navigation_stack(); - self - .app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .reset_filter(); - self.app.should_ignore_quit_key = false; - } ActiveSonarrBlock::UpdateAndScanSeriesPrompt | ActiveSonarrBlock::AutomaticallySearchSeriesPrompt => { self.app.pop_navigation_stack(); self.app.data.sonarr_data.prompt_confirm = false; } - ActiveSonarrBlock::SeriesHistoryDetails | ActiveSonarrBlock::SeriesHistorySortPrompt => { + ActiveSonarrBlock::SeriesHistoryDetails => { self.app.pop_navigation_stack(); } ActiveSonarrBlock::SeriesHistory => { @@ -474,13 +235,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler _ if key == DEFAULT_KEYBINDINGS.refresh.key => self .app .pop_and_push_navigation_stack(self.active_sonarr_block.into()), - _ if key == DEFAULT_KEYBINDINGS.search.key => { - self - .app - .push_navigation_stack(ActiveSonarrBlock::SearchSeason.into()); - self.app.data.sonarr_data.seasons.search = Some(HorizontallyScrollableText::default()); - self.app.should_ignore_quit_key = true; - } _ if key == DEFAULT_KEYBINDINGS.auto_search.key => { self .app @@ -514,55 +268,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler .app .push_navigation_stack(ActiveSonarrBlock::AutomaticallySearchSeriesPrompt.into()); } - _ if key == DEFAULT_KEYBINDINGS.search.key => { - self - .app - .push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistory.into()); - self - .app - .data - .sonarr_data - .series_history - .as_mut() - .expect("Series history should be populated") - .search = Some(HorizontallyScrollableText::default()); - self.app.should_ignore_quit_key = true; - } - _ if key == DEFAULT_KEYBINDINGS.filter.key => { - self - .app - .push_navigation_stack(ActiveSonarrBlock::FilterSeriesHistory.into()); - self - .app - .data - .sonarr_data - .series_history - .as_mut() - .expect("Series history should be populated") - .reset_filter(); - self - .app - .data - .sonarr_data - .series_history - .as_mut() - .expect("Series history should be populated") - .filter = Some(HorizontallyScrollableText::default()); - self.app.should_ignore_quit_key = true; - } - _ if key == DEFAULT_KEYBINDINGS.sort.key => { - self - .app - .data - .sonarr_data - .series_history - .as_mut() - .expect("Series history should be populated") - .sorting(history_sorting_options()); - self - .app - .push_navigation_stack(ActiveSonarrBlock::SeriesHistorySortPrompt.into()); - } _ if key == DEFAULT_KEYBINDINGS.edit.key => { self.app.push_navigation_stack( ( @@ -582,45 +287,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler } _ => (), }, - ActiveSonarrBlock::SearchSeason => { - handle_text_box_keys!( - self, - key, - self.app.data.sonarr_data.seasons.search.as_mut().unwrap() - ) - } - ActiveSonarrBlock::SearchSeriesHistory => { - handle_text_box_keys!( - self, - key, - self - .app - .data - .sonarr_data - .series_history - .as_mut() - .expect("Series history should be populated") - .search - .as_mut() - .unwrap() - ) - } - ActiveSonarrBlock::FilterSeriesHistory => { - handle_text_box_keys!( - self, - key, - self - .app - .data - .sonarr_data - .series_history - .as_mut() - .expect("Series history should be populated") - .filter - .as_mut() - .unwrap() - ) - } ActiveSonarrBlock::AutomaticallySearchSeriesPrompt => { if key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.sonarr_data.prompt_confirm = true; diff --git a/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs index a1861dc..a5d9a43 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs @@ -25,6 +25,7 @@ mod tests { #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, ) { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); app.data.sonarr_data.seasons.set_items(vec![ Season { season_number: 1, @@ -66,6 +67,7 @@ mod tests { #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, ) { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); let mut series_history = StatefulTable::default(); series_history.set_items(vec![ SonarrHistoryItem { @@ -184,6 +186,7 @@ mod tests { #[test] fn test_seasons_home_and_end() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); app.data.sonarr_data.seasons.set_items(vec![ Season { season_number: 1, @@ -239,6 +242,7 @@ mod tests { #[test] fn test_series_history_home_and_end() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); let mut series_history = StatefulTable::default(); series_history.set_items(vec![ SonarrHistoryItem { @@ -307,6 +311,8 @@ mod tests { .sonarr_data .seasons .set_items(vec![Season::default()]); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeason.into()); app.data.sonarr_data.seasons.search = Some("Test".into()); SeriesDetailsHandler::with( @@ -355,6 +361,8 @@ mod tests { #[test] fn test_series_history_search_box_home_end_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + app.push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistory.into()); let mut series_history = StatefulTable::default(); series_history.set_items(vec![SonarrHistoryItem::default()]); series_history.search = Some("Test".into()); @@ -410,6 +418,8 @@ mod tests { #[test] fn test_series_history_filter_box_home_end_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + app.push_navigation_stack(ActiveSonarrBlock::FilterSeriesHistory.into()); let mut series_history = StatefulTable::default(); series_history.set_items(vec![SonarrHistoryItem::default()]); series_history.filter = Some("Test".into()); @@ -952,7 +962,7 @@ mod tests { let mut app = App::default(); app.should_ignore_quit_key = true; app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistory.into()); + app.push_navigation_stack(ActiveSonarrBlock::FilterSeriesHistory.into()); let mut series_history = StatefulTable::default(); series_history.set_items(extended_stateful_iterable_vec!( SonarrHistoryItem, @@ -1276,6 +1286,7 @@ mod tests { #[test] fn test_search_season_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); app .data .sonarr_data @@ -1331,6 +1342,7 @@ mod tests { #[test] fn test_search_series_history_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); let mut series_history = StatefulTable::default(); series_history.set_items(vec![SonarrHistoryItem::default()]); app.data.sonarr_data.series_history = Some(series_history); @@ -1382,6 +1394,7 @@ mod tests { #[test] fn test_filter_series_history_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); let mut series_history = StatefulTable::default(); series_history.set_items(vec![SonarrHistoryItem::default()]); app.data.sonarr_data.series_history = Some(series_history); From 35bc6cf31ced4ac3ebdd89ea7e9e52917c40fa16 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 12:35:12 -0700 Subject: [PATCH 40/82] refactor(downloads_handler): Use the new handle_table_events macro --- src/handlers/sonarr_handlers/downloads/mod.rs | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/handlers/sonarr_handlers/downloads/mod.rs b/src/handlers/sonarr_handlers/downloads/mod.rs index 1d08deb..c4990c6 100644 --- a/src/handlers/sonarr_handlers/downloads/mod.rs +++ b/src/handlers/sonarr_handlers/downloads/mod.rs @@ -1,10 +1,14 @@ +use crate::models::HorizontallyScrollableText; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; +use crate::handle_table_events; use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; +use crate::handlers::table_handler::TableHandlingProps; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DOWNLOADS_BLOCKS}; use crate::models::Scrollable; +use crate::models::sonarr_models::DownloadRecord; use crate::network::sonarr_network::SonarrEvent; #[cfg(test)] @@ -18,7 +22,19 @@ pub(super) struct DownloadsHandler<'a, 'b> { _context: Option, } +impl<'a, 'b> DownloadsHandler<'a, 'b> { + handle_table_events!(self, downloads, self.app.data.sonarr_data.downloads, DownloadRecord); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for DownloadsHandler<'a, 'b> { + fn handle(&mut self) { + let download_table_handling_props = TableHandlingProps::new(ActiveSonarrBlock::Downloads.into()); + + if !self.handle_downloads_table_events(download_table_handling_props) { + self.handle_key_event(); + } + } + fn accepts(active_block: ActiveSonarrBlock) -> bool { DOWNLOADS_BLOCKS.contains(&active_block) } @@ -46,27 +62,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for DownloadsHandler<'a, } fn handle_scroll_up(&mut self) { - if self.active_sonarr_block == ActiveSonarrBlock::Downloads { - self.app.data.sonarr_data.downloads.scroll_up() - } } fn handle_scroll_down(&mut self) { - if self.active_sonarr_block == ActiveSonarrBlock::Downloads { - self.app.data.sonarr_data.downloads.scroll_down() - } } fn handle_home(&mut self) { - if self.active_sonarr_block == ActiveSonarrBlock::Downloads { - self.app.data.sonarr_data.downloads.scroll_to_top() - } } fn handle_end(&mut self) { - if self.active_sonarr_block == ActiveSonarrBlock::Downloads { - self.app.data.sonarr_data.downloads.scroll_to_bottom() - } } fn handle_delete(&mut self) { From b4de97dfe2e18baec6cb07db4fc3dfc4335b9b14 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 12:39:07 -0700 Subject: [PATCH 41/82] refactor(blocklist_handler): Use the new handle_table_events macro --- src/handlers/sonarr_handlers/blocklist/mod.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/handlers/sonarr_handlers/blocklist/mod.rs b/src/handlers/sonarr_handlers/blocklist/mod.rs index 46d92ad..0cffdd7 100644 --- a/src/handlers/sonarr_handlers/blocklist/mod.rs +++ b/src/handlers/sonarr_handlers/blocklist/mod.rs @@ -1,12 +1,14 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; +use crate::handle_table_events; use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; +use crate::handlers::table_handler::TableHandlingProps; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, BLOCKLIST_BLOCKS}; use crate::models::sonarr_models::BlocklistItem; use crate::models::stateful_table::SortOption; -use crate::models::Scrollable; +use crate::models::{Scrollable, HorizontallyScrollableText}; use crate::network::sonarr_network::SonarrEvent; #[cfg(test)] @@ -20,7 +22,22 @@ pub(super) struct BlocklistHandler<'a, 'b> { _context: Option, } +impl<'a, 'b> BlocklistHandler<'a, 'b> { + handle_table_events!(self, blocklist, self.app.data.sonarr_data.blocklist, BlocklistItem); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for BlocklistHandler<'a, 'b> { + fn handle(&mut self) { + let blocklist_table_handling_props = TableHandlingProps::new(ActiveSonarrBlock::Blocklist.into()) + .sorting_block(ActiveSonarrBlock::BlocklistSortPrompt.into()) + .sort_by_fn(|a: &BlocklistItem, b: &BlocklistItem| a.id.cmp(&b.id)) + .sort_options(blocklist_sorting_options()); + + if !self.handle_blocklist_table_events(blocklist_table_handling_props) { + self.handle_key_event(); + } + } + fn accepts(active_block: ActiveSonarrBlock) -> bool { BLOCKLIST_BLOCKS.contains(&active_block) } From 0205f13e53f3b874d358861c30532f7082aa4120 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 13:08:43 -0700 Subject: [PATCH 42/82] refactor(history_handler): Use the new handle_table_event macro --- src/handlers/handler_test_utils.rs | 9 + .../history/history_handler_tests.rs | 28 +- src/handlers/sonarr_handlers/history/mod.rs | 279 +++--------------- .../servarr_data/sonarr/sonarr_test_utils.rs | 7 + 4 files changed, 77 insertions(+), 246 deletions(-) diff --git a/src/handlers/handler_test_utils.rs b/src/handlers/handler_test_utils.rs index e7f6eb2..49e1038 100644 --- a/src/handlers/handler_test_utils.rs +++ b/src/handlers/handler_test_utils.rs @@ -331,6 +331,15 @@ mod test_utils { macro_rules! test_handler_delegation { ($handler:ident, $base:expr, $active_block:expr) => { let mut app = App::default(); + app.data.sonarr_data.history.set_items(vec![$crate::models::sonarr_models::SonarrHistoryItem::default()]); + app.data.sonarr_data.root_folders.set_items(vec![$crate::models::servarr_models::RootFolder::default()]); + app.data.sonarr_data.indexers.set_items(vec![$crate::models::servarr_models::Indexer::default()]); + app.data.sonarr_data.blocklist.set_items(vec![$crate::models::sonarr_models::BlocklistItem::default()]); + app.data.radarr_data.movies.set_items(vec![$crate::models::radarr_models::Movie::default()]); + app.data.radarr_data.collections.set_items(vec![$crate::models::radarr_models::Collection::default()]); + app.data.radarr_data.collection_movies.set_items(vec![$crate::models::radarr_models::Movie::default()]); + app.data.radarr_data.indexers.set_items(vec![$crate::models::servarr_models::Indexer::default()]); + app.data.radarr_data.root_folders.set_items(vec![$crate::models::servarr_models::RootFolder::default()]); let mut series_history = $crate::models::stateful_table::StatefulTable::default(); series_history.set_items(vec![ $crate::models::sonarr_models::SonarrHistoryItem::default(), diff --git a/src/handlers/sonarr_handlers/history/history_handler_tests.rs b/src/handlers/sonarr_handlers/history/history_handler_tests.rs index 2f003ff..b037f37 100644 --- a/src/handlers/sonarr_handlers/history/history_handler_tests.rs +++ b/src/handlers/sonarr_handlers/history/history_handler_tests.rs @@ -205,6 +205,7 @@ mod tests { #[test] fn test_history_search_box_home_end_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); app .data .sonarr_data @@ -258,6 +259,7 @@ mod tests { #[test] fn test_history_filter_box_home_end_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::FilterHistory.into()); app .data .sonarr_data @@ -413,6 +415,8 @@ mod tests { #[test] fn test_history_search_box_left_right_keys() { let mut app = App::default(); + app.data.sonarr_data.history.set_items(vec![SonarrHistoryItem::default()]); + app.push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); app.data.sonarr_data.history.search = Some("Test".into()); HistoryHandler::with( @@ -461,6 +465,8 @@ mod tests { #[test] fn test_history_filter_box_left_right_keys() { let mut app = App::default(); + app.data.sonarr_data.history.set_items(vec![SonarrHistoryItem::default()]); + app.push_navigation_stack(ActiveSonarrBlock::FilterHistory.into()); app.data.sonarr_data.history.filter = Some("Test".into()); HistoryHandler::with( @@ -766,6 +772,7 @@ mod tests { app.push_navigation_stack(ActiveSonarrBlock::History.into()); app.push_navigation_stack(active_sonarr_block.into()); app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.history.set_items(vec![SonarrHistoryItem::default()]); app.data.sonarr_data.history.search = Some("Test".into()); HistoryHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); @@ -794,6 +801,7 @@ mod tests { filtered_state: Some(TableState::default()), ..StatefulTable::default() }; + app.data.sonarr_data.history.set_items(vec![SonarrHistoryItem::default()]); HistoryHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); @@ -807,6 +815,7 @@ mod tests { #[test] fn test_esc_history_item_details() { let mut app = App::default(); + app.data.sonarr_data.history.set_items(vec![SonarrHistoryItem::default()]); app.push_navigation_stack(ActiveSonarrBlock::History.into()); app.push_navigation_stack(ActiveSonarrBlock::HistoryItemDetails.into()); @@ -824,6 +833,7 @@ mod tests { #[test] fn test_history_sort_prompt_block_esc() { let mut app = App::default(); + app.data.sonarr_data.history.set_items(vec![SonarrHistoryItem::default()]); app.push_navigation_stack(ActiveSonarrBlock::History.into()); app.push_navigation_stack(ActiveSonarrBlock::HistorySortPrompt.into()); @@ -846,22 +856,12 @@ mod tests { app.push_navigation_stack(ActiveSonarrBlock::History.into()); app.push_navigation_stack(ActiveSonarrBlock::History.into()); app.data.sonarr_data = create_test_sonarr_data(); - app.data.sonarr_data.history = StatefulTable { - search: Some("Test".into()), - filter: Some("Test".into()), - filtered_items: Some(Vec::new()), - filtered_state: Some(TableState::default()), - ..StatefulTable::default() - }; + app.data.sonarr_data.history.set_items(vec![SonarrHistoryItem::default()]); HistoryHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::History, None).handle(); assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); assert!(app.error.text.is_empty()); - assert_eq!(app.data.sonarr_data.history.search, None); - assert_eq!(app.data.sonarr_data.history.filter, None); - assert_eq!(app.data.sonarr_data.history.filtered_items, None); - assert_eq!(app.data.sonarr_data.history.filtered_state, None); } } @@ -875,6 +875,7 @@ mod tests { #[test] fn test_search_history_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); app .data .sonarr_data @@ -927,6 +928,7 @@ mod tests { #[test] fn test_filter_history_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); app .data .sonarr_data @@ -1047,6 +1049,7 @@ mod tests { #[test] fn test_search_history_box_backspace_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); app.data.sonarr_data.history.search = Some("Test".into()); app .data @@ -1071,6 +1074,7 @@ mod tests { #[test] fn test_filter_history_box_backspace_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::FilterHistory.into()); app .data .sonarr_data @@ -1095,6 +1099,7 @@ mod tests { #[test] fn test_search_history_box_char_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); app .data .sonarr_data @@ -1119,6 +1124,7 @@ mod tests { #[test] fn test_filter_history_box_char_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::FilterHistory.into()); app .data .sonarr_data diff --git a/src/handlers/sonarr_handlers/history/mod.rs b/src/handlers/sonarr_handlers/history/mod.rs index 575b60c..51c8dc6 100644 --- a/src/handlers/sonarr_handlers/history/mod.rs +++ b/src/handlers/sonarr_handlers/history/mod.rs @@ -2,12 +2,13 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::TableHandlingProps; use crate::handlers::{handle_clear_errors, KeyEventHandler}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS}; use crate::models::sonarr_models::SonarrHistoryItem; use crate::models::stateful_table::SortOption; use crate::models::{HorizontallyScrollableText, Scrollable}; -use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; +use crate::handle_table_events; #[cfg(test)] #[path = "history_handler_tests.rs"] @@ -20,7 +21,33 @@ pub(super) struct HistoryHandler<'a, 'b> { _context: Option, } +impl<'a, 'b> HistoryHandler<'a, 'b> { + handle_table_events!( + self, + history, + self.app.data.sonarr_data.history, + SonarrHistoryItem + ); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for HistoryHandler<'a, 'b> { + fn handle(&mut self) { + let history_table_handling_props = TableHandlingProps::new(ActiveSonarrBlock::History.into()) + .sorting_block(ActiveSonarrBlock::HistorySortPrompt.into()) + .sort_by_fn(|a: &SonarrHistoryItem, b: &SonarrHistoryItem| a.id.cmp(&b.id)) + .sort_options(history_sorting_options()) + .searching_block(ActiveSonarrBlock::SearchHistory.into()) + .search_error_block(ActiveSonarrBlock::SearchHistoryError.into()) + .search_field_fn(|history| &history.source_title.text) + .filtering_block(ActiveSonarrBlock::FilterHistory.into()) + .filter_error_block(ActiveSonarrBlock::FilterHistoryError.into()) + .filter_field_fn(|history| &history.source_title.text); + + if !self.handle_history_table_events(history_table_handling_props) { + self.handle_key_event(); + } + } + fn accepts(active_block: ActiveSonarrBlock) -> bool { HISTORY_BLOCKS.contains(&active_block) } @@ -47,272 +74,54 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for HistoryHandler<'a, ' !self.app.is_loading && !self.app.data.sonarr_data.history.is_empty() } - fn handle_scroll_up(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::History => self.app.data.sonarr_data.history.scroll_up(), - ActiveSonarrBlock::HistorySortPrompt => self - .app - .data - .sonarr_data - .history - .sort - .as_mut() - .unwrap() - .scroll_up(), - _ => (), - } - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::History => self.app.data.sonarr_data.history.scroll_down(), - ActiveSonarrBlock::HistorySortPrompt => self - .app - .data - .sonarr_data - .history - .sort - .as_mut() - .unwrap() - .scroll_down(), - _ => (), - } - } + fn handle_scroll_down(&mut self) {} - fn handle_home(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::History => self.app.data.sonarr_data.history.scroll_to_top(), - ActiveSonarrBlock::SearchHistory => { - self - .app - .data - .sonarr_data - .history - .search - .as_mut() - .unwrap() - .scroll_home(); - } - ActiveSonarrBlock::FilterHistory => { - self - .app - .data - .sonarr_data - .history - .filter - .as_mut() - .unwrap() - .scroll_home(); - } - ActiveSonarrBlock::HistorySortPrompt => self - .app - .data - .sonarr_data - .history - .sort - .as_mut() - .unwrap() - .scroll_to_top(), - _ => (), - } - } + fn handle_home(&mut self) {} - fn handle_end(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::History => self.app.data.sonarr_data.history.scroll_to_bottom(), - ActiveSonarrBlock::SearchHistory => self - .app - .data - .sonarr_data - .history - .search - .as_mut() - .unwrap() - .reset_offset(), - ActiveSonarrBlock::FilterHistory => self - .app - .data - .sonarr_data - .history - .filter - .as_mut() - .unwrap() - .reset_offset(), - ActiveSonarrBlock::HistorySortPrompt => self - .app - .data - .sonarr_data - .history - .sort - .as_mut() - .unwrap() - .scroll_to_bottom(), - _ => (), - } - } + fn handle_end(&mut self) {} fn handle_delete(&mut self) {} fn handle_left_right_action(&mut self) { match self.active_sonarr_block { ActiveSonarrBlock::History => handle_change_tab_left_right_keys(self.app, self.key), - ActiveSonarrBlock::SearchHistory => { - handle_text_box_left_right_keys!( - self, - self.key, - self.app.data.sonarr_data.history.search.as_mut().unwrap() - ) - } - ActiveSonarrBlock::FilterHistory => { - handle_text_box_left_right_keys!( - self, - self.key, - self.app.data.sonarr_data.history.filter.as_mut().unwrap() - ) - } _ => {} } } fn handle_submit(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::SearchHistory => { - self.app.pop_navigation_stack(); - self.app.should_ignore_quit_key = false; - - if self.app.data.sonarr_data.history.search.is_some() { - let has_match = self - .app - .data - .sonarr_data - .history - .apply_search(|history| &history.source_title.text); - - if !has_match { - self - .app - .push_navigation_stack(ActiveSonarrBlock::SearchHistoryError.into()); - } - } - } - ActiveSonarrBlock::FilterHistory => { - self.app.pop_navigation_stack(); - self.app.should_ignore_quit_key = false; - - if self.app.data.sonarr_data.history.filter.is_some() { - let has_matches = self - .app - .data - .sonarr_data - .history - .apply_filter(|history| &history.source_title.text); - - if !has_matches { - self - .app - .push_navigation_stack(ActiveSonarrBlock::FilterHistoryError.into()); - } - } - } - ActiveSonarrBlock::HistorySortPrompt => { - self - .app - .data - .sonarr_data - .history - .items - .sort_by(|a, b| a.id.cmp(&b.id)); - self.app.data.sonarr_data.history.apply_sorting(); - - self.app.pop_navigation_stack(); - } - ActiveSonarrBlock::History => { - self - .app - .push_navigation_stack(ActiveSonarrBlock::HistoryItemDetails.into()); - } - _ => (), + if self.active_sonarr_block == ActiveSonarrBlock::History { + self + .app + .push_navigation_stack(ActiveSonarrBlock::HistoryItemDetails.into()); } } fn handle_esc(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::FilterHistory | ActiveSonarrBlock::FilterHistoryError => { - self.app.pop_navigation_stack(); - self.app.data.sonarr_data.history.reset_filter(); - self.app.should_ignore_quit_key = false; - } - ActiveSonarrBlock::SearchHistory | ActiveSonarrBlock::SearchHistoryError => { - self.app.pop_navigation_stack(); - self.app.data.sonarr_data.history.reset_search(); - self.app.should_ignore_quit_key = false; - } - ActiveSonarrBlock::HistoryItemDetails | ActiveSonarrBlock::HistorySortPrompt => { - self.app.pop_navigation_stack(); - } - _ => { - self.app.data.sonarr_data.history.reset_search(); - self.app.data.sonarr_data.history.reset_filter(); - handle_clear_errors(self.app); - } + if self.active_sonarr_block == ActiveSonarrBlock::HistoryItemDetails { + self.app.pop_navigation_stack(); + } else { + handle_clear_errors(self.app); } } fn handle_char_key_event(&mut self) { let key = self.key; - match self.active_sonarr_block { - ActiveSonarrBlock::History => match self.key { - _ if key == DEFAULT_KEYBINDINGS.search.key => { - self - .app - .push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); - self.app.data.sonarr_data.history.search = Some(HorizontallyScrollableText::default()); - self.app.should_ignore_quit_key = true; - } - _ if key == DEFAULT_KEYBINDINGS.filter.key => { - self - .app - .push_navigation_stack(ActiveSonarrBlock::FilterHistory.into()); - self.app.data.sonarr_data.history.reset_filter(); - self.app.data.sonarr_data.history.filter = Some(HorizontallyScrollableText::default()); - self.app.should_ignore_quit_key = true; - } + if self.active_sonarr_block == ActiveSonarrBlock::History { + match self.key { _ if key == DEFAULT_KEYBINDINGS.refresh.key => { self.app.should_refresh = true; } - _ if key == DEFAULT_KEYBINDINGS.sort.key => { - self - .app - .data - .sonarr_data - .history - .sorting(history_sorting_options()); - self - .app - .push_navigation_stack(ActiveSonarrBlock::HistorySortPrompt.into()); - } _ => (), - }, - ActiveSonarrBlock::SearchHistory => { - handle_text_box_keys!( - self, - key, - self.app.data.sonarr_data.history.search.as_mut().unwrap() - ) } - ActiveSonarrBlock::FilterHistory => { - handle_text_box_keys!( - self, - key, - self.app.data.sonarr_data.history.filter.as_mut().unwrap() - ) - } - _ => (), } } } -pub(in crate::handlers::sonarr_handlers) fn history_sorting_options() -> Vec> { +pub(in crate::handlers::sonarr_handlers) fn history_sorting_options( +) -> Vec> { vec![ SortOption { name: "Source Title", diff --git a/src/models/servarr_data/sonarr/sonarr_test_utils.rs b/src/models/servarr_data/sonarr/sonarr_test_utils.rs index bb7b669..2ba4dea 100644 --- a/src/models/servarr_data/sonarr/sonarr_test_utils.rs +++ b/src/models/servarr_data/sonarr/sonarr_test_utils.rs @@ -9,6 +9,8 @@ pub mod utils { stateful_table::StatefulTable, HorizontallyScrollableText, ScrollableText, }; + use crate::models::servarr_models::{Indexer, RootFolder}; + use crate::models::sonarr_models::{BlocklistItem, Series}; pub fn create_test_sonarr_data<'a>() -> SonarrData<'a> { let mut episode_details_modal = EpisodeDetailsModal { @@ -46,6 +48,11 @@ pub mod utils { add_searched_series: Some(StatefulTable::default()), ..SonarrData::default() }; + sonarr_data.series.set_items(vec![Series::default()]); + sonarr_data.history.set_items(vec![SonarrHistoryItem::default()]); + sonarr_data.blocklist.set_items(vec![BlocklistItem::default()]); + sonarr_data.root_folders.set_items(vec![RootFolder::default()]); + sonarr_data.indexers.set_items(vec![Indexer::default()]); sonarr_data.series_info_tabs.index = 1; sonarr_data .add_searched_series From de95f13febc5bdb40e536c73f63b6f9b78b2d2af Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 13:10:17 -0700 Subject: [PATCH 43/82] fix(handler_tests): Fixed all delegation tests to have initial conditions set properly --- src/handlers/handler_test_utils.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/handlers/handler_test_utils.rs b/src/handlers/handler_test_utils.rs index 49e1038..85afc44 100644 --- a/src/handlers/handler_test_utils.rs +++ b/src/handlers/handler_test_utils.rs @@ -337,7 +337,7 @@ mod test_utils { app.data.sonarr_data.blocklist.set_items(vec![$crate::models::sonarr_models::BlocklistItem::default()]); app.data.radarr_data.movies.set_items(vec![$crate::models::radarr_models::Movie::default()]); app.data.radarr_data.collections.set_items(vec![$crate::models::radarr_models::Collection::default()]); - app.data.radarr_data.collection_movies.set_items(vec![$crate::models::radarr_models::Movie::default()]); + app.data.radarr_data.collection_movies.set_items(vec![$crate::models::radarr_models::CollectionMovie::default()]); app.data.radarr_data.indexers.set_items(vec![$crate::models::servarr_models::Indexer::default()]); app.data.radarr_data.root_folders.set_items(vec![$crate::models::servarr_models::RootFolder::default()]); let mut series_history = $crate::models::stateful_table::StatefulTable::default(); From b06051877879b7ee70b7b3a6eb2b77b723128e42 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 13:15:59 -0700 Subject: [PATCH 44/82] refactor(root_folder_handler): Use the new handle_table_events macro --- .../sonarr_handlers/root_folders/mod.rs | 50 +++++++++++-------- .../root_folders_handler_tests.rs | 1 + 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/handlers/sonarr_handlers/root_folders/mod.rs b/src/handlers/sonarr_handlers/root_folders/mod.rs index cc18830..3cb8950 100644 --- a/src/handlers/sonarr_handlers/root_folders/mod.rs +++ b/src/handlers/sonarr_handlers/root_folders/mod.rs @@ -2,11 +2,13 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::TableHandlingProps; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ROOT_FOLDERS_BLOCKS}; +use crate::models::servarr_models::RootFolder; use crate::models::{HorizontallyScrollableText, Scrollable}; use crate::network::sonarr_network::SonarrEvent; -use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; +use crate::{handle_table_events, handle_text_box_keys, handle_text_box_left_right_keys}; #[cfg(test)] #[path = "root_folders_handler_tests.rs"] @@ -19,7 +21,25 @@ pub(super) struct RootFoldersHandler<'a, 'b> { _context: Option, } +impl<'a, 'b> RootFoldersHandler<'a, 'b> { + handle_table_events!( + self, + root_folders, + self.app.data.sonarr_data.root_folders, + RootFolder + ); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for RootFoldersHandler<'a, 'b> { + fn handle(&mut self) { + let root_folders_table_handling_props = + TableHandlingProps::new(ActiveSonarrBlock::RootFolders.into()); + + if !self.handle_root_folders_table_events(root_folders_table_handling_props) { + self.handle_key_event(); + } + } + fn accepts(active_block: ActiveSonarrBlock) -> bool { ROOT_FOLDERS_BLOCKS.contains(&active_block) } @@ -46,45 +66,33 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for RootFoldersHandler<' !self.app.is_loading && !self.app.data.sonarr_data.root_folders.is_empty() } - fn handle_scroll_up(&mut self) { - if self.active_sonarr_block == ActiveSonarrBlock::RootFolders { - self.app.data.sonarr_data.root_folders.scroll_up() - } - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - if self.active_sonarr_block == ActiveSonarrBlock::RootFolders { - self.app.data.sonarr_data.root_folders.scroll_down() - } - } + fn handle_scroll_down(&mut self) {} fn handle_home(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::RootFolders => self.app.data.sonarr_data.root_folders.scroll_to_top(), - ActiveSonarrBlock::AddRootFolderPrompt => self + if self.active_sonarr_block == ActiveSonarrBlock::AddRootFolderPrompt { + self .app .data .sonarr_data .edit_root_folder .as_mut() .unwrap() - .scroll_home(), - _ => (), + .scroll_home() } } fn handle_end(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::RootFolders => self.app.data.sonarr_data.root_folders.scroll_to_bottom(), - ActiveSonarrBlock::AddRootFolderPrompt => self + if self.active_sonarr_block == ActiveSonarrBlock::AddRootFolderPrompt { + self .app .data .sonarr_data .edit_root_folder .as_mut() .unwrap() - .reset_offset(), - _ => (), + .reset_offset() } } diff --git a/src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs b/src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs index 8c8947c..b83da4e 100644 --- a/src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs +++ b/src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs @@ -123,6 +123,7 @@ mod tests { fn test_add_root_folder_prompt_home_end_keys() { let mut app = App::default(); app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveSonarrBlock::AddRootFolderPrompt.into()); app .data .sonarr_data From dd23e84ccf2bb74b1ae56e49245f57c8c625069b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 13:24:18 -0700 Subject: [PATCH 45/82] refactor(indexers_handler): Use the new handle_table_events macro --- src/app/key_binding.rs | 2 +- src/app/key_binding_tests.rs | 2 +- src/handlers/handler_test_utils.rs | 5 +++ .../indexers/indexers_handler_tests.rs | 1 + src/handlers/sonarr_handlers/indexers/mod.rs | 38 ++++++++++++------- 5 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/app/key_binding.rs b/src/app/key_binding.rs index 8bda386..0726c14 100644 --- a/src/app/key_binding.rs +++ b/src/app/key_binding.rs @@ -92,7 +92,7 @@ pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { desc: "search", }, settings: KeyBinding { - key: Key::Char('s'), + key: Key::Char('S'), desc: "settings", }, filter: KeyBinding { diff --git a/src/app/key_binding_tests.rs b/src/app/key_binding_tests.rs index 2a4ea68..7b3cbb9 100644 --- a/src/app/key_binding_tests.rs +++ b/src/app/key_binding_tests.rs @@ -18,7 +18,7 @@ mod test { #[case(DEFAULT_KEYBINDINGS.clear, Key::Char('c'), "clear")] #[case(DEFAULT_KEYBINDINGS.auto_search, Key::Char('S'), "auto search")] #[case(DEFAULT_KEYBINDINGS.search, Key::Char('s'), "search")] - #[case(DEFAULT_KEYBINDINGS.settings, Key::Char('s'), "settings")] + #[case(DEFAULT_KEYBINDINGS.settings, Key::Char('S'), "settings")] #[case(DEFAULT_KEYBINDINGS.filter, Key::Char('f'), "filter")] #[case(DEFAULT_KEYBINDINGS.sort, Key::Char('o'), "sort")] #[case(DEFAULT_KEYBINDINGS.edit, Key::Char('e'), "edit")] diff --git a/src/handlers/handler_test_utils.rs b/src/handlers/handler_test_utils.rs index 85afc44..72f14dc 100644 --- a/src/handlers/handler_test_utils.rs +++ b/src/handlers/handler_test_utils.rs @@ -340,6 +340,11 @@ mod test_utils { app.data.radarr_data.collection_movies.set_items(vec![$crate::models::radarr_models::CollectionMovie::default()]); app.data.radarr_data.indexers.set_items(vec![$crate::models::servarr_models::Indexer::default()]); app.data.radarr_data.root_folders.set_items(vec![$crate::models::servarr_models::RootFolder::default()]); + let mut movie_details_modal = $crate::models::servarr_data::radarr::modals::MovieDetailsModal::default(); + movie_details_modal + .movie_history + .set_items(vec![$crate::models::radarr_models::MovieHistoryItem::default()]); + app.data.radarr_data.movie_details_modal = Some(movie_details_modal); let mut series_history = $crate::models::stateful_table::StatefulTable::default(); series_history.set_items(vec![ $crate::models::sonarr_models::SonarrHistoryItem::default(), diff --git a/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs index 0ccf3a8..c21199f 100644 --- a/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs @@ -515,6 +515,7 @@ mod tests { #[test] fn test_indexer_settings_key() { let mut app = App::default(); + app.data.sonarr_data.indexers.set_items(vec![Indexer::default()]); app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); app .data diff --git a/src/handlers/sonarr_handlers/indexers/mod.rs b/src/handlers/sonarr_handlers/indexers/mod.rs index 1ed5b68..5a91876 100644 --- a/src/handlers/sonarr_handlers/indexers/mod.rs +++ b/src/handlers/sonarr_handlers/indexers/mod.rs @@ -1,17 +1,21 @@ +use crate::models::HorizontallyScrollableText; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; +use crate::handle_table_events; use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::sonarr_handlers::indexers::edit_indexer_handler::EditIndexerHandler; use crate::handlers::sonarr_handlers::indexers::edit_indexer_settings_handler::IndexerSettingsHandler; use crate::handlers::sonarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; +use crate::handlers::table_handler::TableHandlingProps; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, }; use crate::models::BlockSelectionState; use crate::models::Scrollable; +use crate::models::servarr_models::Indexer; use crate::network::sonarr_network::SonarrEvent; mod edit_indexer_handler; @@ -29,22 +33,30 @@ pub(super) struct IndexersHandler<'a, 'b> { context: Option, } +impl<'a, 'b> IndexersHandler<'a, 'b> { + handle_table_events!(self, indexers, self.app.data.sonarr_data.indexers, Indexer); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for IndexersHandler<'a, 'b> { fn handle(&mut self) { - match self.active_sonarr_block { - _ if EditIndexerHandler::accepts(self.active_sonarr_block) => { - EditIndexerHandler::with(self.key, self.app, self.active_sonarr_block, self.context) - .handle() + let indexers_table_handling_props = TableHandlingProps::new(ActiveSonarrBlock::Indexers.into()); + + if !self.handle_indexers_table_events(indexers_table_handling_props) { + match self.active_sonarr_block { + _ if EditIndexerHandler::accepts(self.active_sonarr_block) => { + EditIndexerHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle() + } + _ if IndexerSettingsHandler::accepts(self.active_sonarr_block) => { + IndexerSettingsHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle() + } + _ if TestAllIndexersHandler::accepts(self.active_sonarr_block) => { + TestAllIndexersHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle() + } + _ => self.handle_key_event(), } - _ if IndexerSettingsHandler::accepts(self.active_sonarr_block) => { - IndexerSettingsHandler::with(self.key, self.app, self.active_sonarr_block, self.context) - .handle() - } - _ if TestAllIndexersHandler::accepts(self.active_sonarr_block) => { - TestAllIndexersHandler::with(self.key, self.app, self.active_sonarr_block, self.context) - .handle() - } - _ => self.handle_key_event(), } } From 5850f7a6213f4cf16b2ce7310e8def6cb40ad38f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 13:26:59 -0700 Subject: [PATCH 46/82] refactor(indexers_handler): Use the new handle_table_events macro --- src/handlers/sonarr_handlers/indexers/mod.rs | 34 ++++++-------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/src/handlers/sonarr_handlers/indexers/mod.rs b/src/handlers/sonarr_handlers/indexers/mod.rs index 5a91876..74f4c45 100644 --- a/src/handlers/sonarr_handlers/indexers/mod.rs +++ b/src/handlers/sonarr_handlers/indexers/mod.rs @@ -1,4 +1,3 @@ -use crate::models::HorizontallyScrollableText; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; @@ -7,15 +6,16 @@ use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::sonarr_handlers::indexers::edit_indexer_handler::EditIndexerHandler; use crate::handlers::sonarr_handlers::indexers::edit_indexer_settings_handler::IndexerSettingsHandler; use crate::handlers::sonarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler; -use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, }; -use crate::models::BlockSelectionState; -use crate::models::Scrollable; use crate::models::servarr_models::Indexer; +use crate::models::BlockSelectionState; +use crate::models::HorizontallyScrollableText; +use crate::models::Scrollable; use crate::network::sonarr_network::SonarrEvent; mod edit_indexer_handler; @@ -40,7 +40,7 @@ impl<'a, 'b> IndexersHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for IndexersHandler<'a, 'b> { fn handle(&mut self) { let indexers_table_handling_props = TableHandlingProps::new(ActiveSonarrBlock::Indexers.into()); - + if !self.handle_indexers_table_events(indexers_table_handling_props) { match self.active_sonarr_block { _ if EditIndexerHandler::accepts(self.active_sonarr_block) => { @@ -89,29 +89,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for IndexersHandler<'a, !self.app.is_loading && !self.app.data.sonarr_data.indexers.is_empty() } - fn handle_scroll_up(&mut self) { - if self.active_sonarr_block == ActiveSonarrBlock::Indexers { - self.app.data.sonarr_data.indexers.scroll_up(); - } - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - if self.active_sonarr_block == ActiveSonarrBlock::Indexers { - self.app.data.sonarr_data.indexers.scroll_down(); - } - } + fn handle_scroll_down(&mut self) {} - fn handle_home(&mut self) { - if self.active_sonarr_block == ActiveSonarrBlock::Indexers { - self.app.data.sonarr_data.indexers.scroll_to_top(); - } - } + fn handle_home(&mut self) {} - fn handle_end(&mut self) { - if self.active_sonarr_block == ActiveSonarrBlock::Indexers { - self.app.data.sonarr_data.indexers.scroll_to_bottom(); - } - } + fn handle_end(&mut self) {} fn handle_delete(&mut self) { if self.active_sonarr_block == ActiveSonarrBlock::Indexers { From f1d934b0a64988b2b1ba5802f6de60692c95df0a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 13:43:01 -0700 Subject: [PATCH 47/82] refactor(library_handler): Radarr use the new handle_table_events macro --- .../library/library_handler_tests.rs | 16 +- src/handlers/radarr_handlers/library/mod.rs | 275 +++--------------- .../servarr_data/radarr/radarr_test_utils.rs | 12 +- 3 files changed, 61 insertions(+), 242 deletions(-) diff --git a/src/handlers/radarr_handlers/library/library_handler_tests.rs b/src/handlers/radarr_handlers/library/library_handler_tests.rs index 49f8738..e513af1 100644 --- a/src/handlers/radarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/library_handler_tests.rs @@ -199,6 +199,7 @@ mod tests { #[test] fn test_movie_search_box_home_end_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); app .data .radarr_data @@ -252,6 +253,7 @@ mod tests { #[test] fn test_movie_filter_box_home_end_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); app .data .radarr_data @@ -479,6 +481,8 @@ mod tests { #[test] fn test_movie_search_box_left_right_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); + app.data.radarr_data.movies.set_items(vec![Movie::default()]); app.data.radarr_data.movies.search = Some("Test".into()); LibraryHandler::with( @@ -527,6 +531,8 @@ mod tests { #[test] fn test_movie_filter_box_left_right_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); + app.data.radarr_data.movies.set_items(vec![Movie::default()]); app.data.radarr_data.movies.filter = Some("Test".into()); LibraryHandler::with( @@ -882,6 +888,7 @@ mod tests { filtered_state: Some(TableState::default()), ..StatefulTable::default() }; + app.data.radarr_data.movies.set_items(vec![Movie::default()]); LibraryHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); @@ -914,6 +921,7 @@ mod tests { #[test] fn test_movies_sort_prompt_block_esc() { let mut app = App::default(); + app.data.radarr_data.movies.set_items(movies_vec()); app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(ActiveRadarrBlock::MoviesSortPrompt.into()); @@ -942,10 +950,6 @@ mod tests { assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); assert!(app.error.text.is_empty()); - assert_eq!(app.data.radarr_data.movies.search, None); - assert_eq!(app.data.radarr_data.movies.filter, None); - assert_eq!(app.data.radarr_data.movies.filtered_items, None); - assert_eq!(app.data.radarr_data.movies.filtered_state, None); } } @@ -1275,6 +1279,7 @@ mod tests { #[test] fn test_search_movies_box_backspace_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); app.data.radarr_data.movies.search = Some("Test".into()); app .data @@ -1299,6 +1304,7 @@ mod tests { #[test] fn test_filter_movies_box_backspace_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); app .data .radarr_data @@ -1323,6 +1329,7 @@ mod tests { #[test] fn test_search_movies_box_char_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); app .data .radarr_data @@ -1347,6 +1354,7 @@ mod tests { #[test] fn test_filter_movies_box_char_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); app .data .radarr_data diff --git a/src/handlers/radarr_handlers/library/mod.rs b/src/handlers/radarr_handlers/library/mod.rs index f8ae0b9..b77c47c 100644 --- a/src/handlers/radarr_handlers/library/mod.rs +++ b/src/handlers/radarr_handlers/library/mod.rs @@ -8,6 +8,8 @@ use crate::handlers::radarr_handlers::library::edit_movie_handler::EditMovieHand use crate::handlers::radarr_handlers::library::movie_details_handler::MovieDetailsHandler; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; +use crate::handle_table_events; +use crate::handlers::table_handler::TableHandlingProps; use crate::models::radarr_models::Movie; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, DELETE_MOVIE_SELECTION_BLOCKS, EDIT_MOVIE_SELECTION_BLOCKS, LIBRARY_BLOCKS, @@ -15,7 +17,6 @@ use crate::models::servarr_data::radarr::radarr_data::{ use crate::models::stateful_table::SortOption; use crate::models::{BlockSelectionState, HorizontallyScrollableText, Scrollable}; use crate::network::radarr_network::RadarrEvent; -use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; mod add_movie_handler; mod delete_movie_handler; @@ -33,24 +34,43 @@ pub(super) struct LibraryHandler<'a, 'b> { context: Option, } +impl<'a, 'b> LibraryHandler<'a, 'b> { + handle_table_events!(self, movies, self.app.data.radarr_data.movies, Movie); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, 'b> { fn handle(&mut self) { - match self.active_radarr_block { - _ if AddMovieHandler::accepts(self.active_radarr_block) => { - AddMovieHandler::with(self.key, self.app, self.active_radarr_block, self.context).handle(); + let movie_table_handling_props = TableHandlingProps::new(ActiveRadarrBlock::Movies.into()) + .sorting_block(ActiveRadarrBlock::MoviesSortPrompt.into()) + .sort_by_fn(|a: &Movie, b: &Movie| a.id.cmp(&b.id)) + .sort_options(movies_sorting_options()) + .searching_block(ActiveRadarrBlock::SearchMovie.into()) + .search_error_block(ActiveRadarrBlock::SearchMovieError.into()) + .search_field_fn(|movie| &movie.title.text) + .filtering_block(ActiveRadarrBlock::FilterMovies.into()) + .filter_error_block(ActiveRadarrBlock::FilterMoviesError.into()) + .filter_field_fn(|movie| &movie.title.text); + + if !self.handle_movies_table_events(movie_table_handling_props) { + match self.active_radarr_block { + _ if AddMovieHandler::accepts(self.active_radarr_block) => { + AddMovieHandler::with(self.key, self.app, self.active_radarr_block, self.context) + .handle(); + } + _ if DeleteMovieHandler::accepts(self.active_radarr_block) => { + DeleteMovieHandler::with(self.key, self.app, self.active_radarr_block, self.context) + .handle(); + } + _ if EditMovieHandler::accepts(self.active_radarr_block) => { + EditMovieHandler::with(self.key, self.app, self.active_radarr_block, self.context) + .handle(); + } + _ if MovieDetailsHandler::accepts(self.active_radarr_block) => { + MovieDetailsHandler::with(self.key, self.app, self.active_radarr_block, self.context) + .handle(); + } + _ => self.handle_key_event(), } - _ if DeleteMovieHandler::accepts(self.active_radarr_block) => { - DeleteMovieHandler::with(self.key, self.app, self.active_radarr_block, self.context) - .handle(); - } - _ if EditMovieHandler::accepts(self.active_radarr_block) => { - EditMovieHandler::with(self.key, self.app, self.active_radarr_block, self.context).handle(); - } - _ if MovieDetailsHandler::accepts(self.active_radarr_block) => { - MovieDetailsHandler::with(self.key, self.app, self.active_radarr_block, self.context) - .handle(); - } - _ => self.handle_key_event(), } } @@ -84,109 +104,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' !self.app.is_loading && !self.app.data.radarr_data.movies.is_empty() } - fn handle_scroll_up(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::Movies => self.app.data.radarr_data.movies.scroll_up(), - ActiveRadarrBlock::MoviesSortPrompt => self - .app - .data - .radarr_data - .movies - .sort - .as_mut() - .unwrap() - .scroll_up(), - _ => (), - } - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::Movies => self.app.data.radarr_data.movies.scroll_down(), - ActiveRadarrBlock::MoviesSortPrompt => self - .app - .data - .radarr_data - .movies - .sort - .as_mut() - .unwrap() - .scroll_down(), - _ => (), - } - } + fn handle_scroll_down(&mut self) {} - fn handle_home(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::Movies => self.app.data.radarr_data.movies.scroll_to_top(), - ActiveRadarrBlock::SearchMovie => { - self - .app - .data - .radarr_data - .movies - .search - .as_mut() - .unwrap() - .scroll_home(); - } - ActiveRadarrBlock::FilterMovies => { - self - .app - .data - .radarr_data - .movies - .filter - .as_mut() - .unwrap() - .scroll_home(); - } - ActiveRadarrBlock::MoviesSortPrompt => self - .app - .data - .radarr_data - .movies - .sort - .as_mut() - .unwrap() - .scroll_to_top(), - _ => (), - } - } + fn handle_home(&mut self) {} - fn handle_end(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::Movies => self.app.data.radarr_data.movies.scroll_to_bottom(), - ActiveRadarrBlock::SearchMovie => self - .app - .data - .radarr_data - .movies - .search - .as_mut() - .unwrap() - .reset_offset(), - ActiveRadarrBlock::FilterMovies => self - .app - .data - .radarr_data - .movies - .filter - .as_mut() - .unwrap() - .reset_offset(), - ActiveRadarrBlock::MoviesSortPrompt => self - .app - .data - .radarr_data - .movies - .sort - .as_mut() - .unwrap() - .scroll_to_bottom(), - _ => (), - } - } + fn handle_end(&mut self) {} fn handle_delete(&mut self) { if self.active_radarr_block == ActiveRadarrBlock::Movies { @@ -202,20 +126,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' match self.active_radarr_block { ActiveRadarrBlock::Movies => handle_change_tab_left_right_keys(self.app, self.key), ActiveRadarrBlock::UpdateAllMoviesPrompt => handle_prompt_toggle(self.app, self.key), - ActiveRadarrBlock::SearchMovie => { - handle_text_box_left_right_keys!( - self, - self.key, - self.app.data.radarr_data.movies.search.as_mut().unwrap() - ) - } - ActiveRadarrBlock::FilterMovies => { - handle_text_box_left_right_keys!( - self, - self.key, - self.app.data.radarr_data.movies.filter.as_mut().unwrap() - ) - } _ => (), } } @@ -225,44 +135,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' ActiveRadarrBlock::Movies => self .app .push_navigation_stack(ActiveRadarrBlock::MovieDetails.into()), - ActiveRadarrBlock::SearchMovie => { - self.app.pop_navigation_stack(); - self.app.should_ignore_quit_key = false; - - if self.app.data.radarr_data.movies.search.is_some() { - let has_match = self - .app - .data - .radarr_data - .movies - .apply_search(|movie| &movie.title.text); - - if !has_match { - self - .app - .push_navigation_stack(ActiveRadarrBlock::SearchMovieError.into()); - } - } - } - ActiveRadarrBlock::FilterMovies => { - self.app.pop_navigation_stack(); - self.app.should_ignore_quit_key = false; - - if self.app.data.radarr_data.movies.filter.is_some() { - let has_matches = self - .app - .data - .radarr_data - .movies - .apply_filter(|movie| &movie.title.text); - - if !has_matches { - self - .app - .push_navigation_stack(ActiveRadarrBlock::FilterMoviesError.into()); - } - } - } ActiveRadarrBlock::UpdateAllMoviesPrompt => { if self.app.data.radarr_data.prompt_confirm { self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateAllMovies); @@ -270,44 +142,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' self.app.pop_navigation_stack(); } - ActiveRadarrBlock::MoviesSortPrompt => { - self - .app - .data - .radarr_data - .movies - .items - .sort_by(|a, b| a.id.cmp(&b.id)); - self.app.data.radarr_data.movies.apply_sorting(); - - self.app.pop_navigation_stack(); - } _ => (), } } fn handle_esc(&mut self) { match self.active_radarr_block { - ActiveRadarrBlock::FilterMovies | ActiveRadarrBlock::FilterMoviesError => { - self.app.pop_navigation_stack(); - self.app.data.radarr_data.movies.reset_filter(); - self.app.should_ignore_quit_key = false; - } - ActiveRadarrBlock::SearchMovie | ActiveRadarrBlock::SearchMovieError => { - self.app.pop_navigation_stack(); - self.app.data.radarr_data.movies.reset_search(); - self.app.should_ignore_quit_key = false; - } ActiveRadarrBlock::UpdateAllMoviesPrompt => { self.app.pop_navigation_stack(); self.app.data.radarr_data.prompt_confirm = false; } - ActiveRadarrBlock::MoviesSortPrompt => { - self.app.pop_navigation_stack(); - } _ => { - self.app.data.radarr_data.movies.reset_search(); - self.app.data.radarr_data.movies.reset_filter(); handle_clear_errors(self.app); } } @@ -317,21 +162,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' let key = self.key; match self.active_radarr_block { ActiveRadarrBlock::Movies => match self.key { - _ if key == DEFAULT_KEYBINDINGS.search.key => { - self - .app - .push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); - self.app.data.radarr_data.movies.search = Some(HorizontallyScrollableText::default()); - self.app.should_ignore_quit_key = true; - } - _ if key == DEFAULT_KEYBINDINGS.filter.key => { - self - .app - .push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); - self.app.data.radarr_data.movies.reset_filter(); - self.app.data.radarr_data.movies.filter = Some(HorizontallyScrollableText::default()); - self.app.should_ignore_quit_key = true; - } _ if key == DEFAULT_KEYBINDINGS.edit.key => { self.app.push_navigation_stack( ( @@ -359,33 +189,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' _ if key == DEFAULT_KEYBINDINGS.refresh.key => { self.app.should_refresh = true; } - _ if key == DEFAULT_KEYBINDINGS.sort.key => { - self - .app - .data - .radarr_data - .movies - .sorting(movies_sorting_options()); - self - .app - .push_navigation_stack(ActiveRadarrBlock::MoviesSortPrompt.into()); - } _ => (), }, - ActiveRadarrBlock::SearchMovie => { - handle_text_box_keys!( - self, - key, - self.app.data.radarr_data.movies.search.as_mut().unwrap() - ) - } - ActiveRadarrBlock::FilterMovies => { - handle_text_box_keys!( - self, - key, - self.app.data.radarr_data.movies.filter.as_mut().unwrap() - ) - } ActiveRadarrBlock::UpdateAllMoviesPrompt => { if key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; diff --git a/src/models/servarr_data/radarr/radarr_test_utils.rs b/src/models/servarr_data/radarr/radarr_test_utils.rs index a3c5469..aae4098 100644 --- a/src/models/servarr_data/radarr/radarr_test_utils.rs +++ b/src/models/servarr_data/radarr/radarr_test_utils.rs @@ -1,12 +1,11 @@ #[cfg(test)] pub mod utils { - use crate::models::radarr_models::{ - AddMovieSearchResult, CollectionMovie, Credit, MovieHistoryItem, RadarrRelease, - }; + use crate::models::radarr_models::{AddMovieSearchResult, BlocklistItem, Collection, CollectionMovie, Credit, DownloadRecord, Movie, MovieHistoryItem, RadarrRelease}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::RadarrData; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, ScrollableText}; + use crate::models::servarr_models::{Indexer, RootFolder}; pub fn create_test_radarr_data<'a>() -> RadarrData<'a> { let mut movie_details_modal = MovieDetailsModal { @@ -35,6 +34,13 @@ pub mod utils { add_searched_movies: Some(StatefulTable::default()), ..RadarrData::default() }; + radarr_data.movies.set_items(vec![Movie::default()]); + radarr_data.collection_movies.set_items(vec![CollectionMovie::default()]); + radarr_data.collections.set_items(vec![Collection::default()]); + radarr_data.downloads.set_items(vec![DownloadRecord::default()]); + radarr_data.blocklist.set_items(vec![BlocklistItem::default()]); + radarr_data.root_folders.set_items(vec![RootFolder::default()]); + radarr_data.indexers.set_items(vec![Indexer::default()]); radarr_data.movie_info_tabs.index = 1; radarr_data .add_searched_movies From d6863dc1fd681289a4e1fc80da0ed871770bde3d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 14:04:34 -0700 Subject: [PATCH 48/82] refactor(movie_details_handler): Use the new handle_table_events macro --- src/handlers/handler_test_utils.rs | 3 + .../library/movie_details_handler.rs | 277 ++++-------------- .../library/movie_details_handler_tests.rs | 23 +- src/handlers/table_handler.rs | 5 +- 4 files changed, 84 insertions(+), 224 deletions(-) diff --git a/src/handlers/handler_test_utils.rs b/src/handlers/handler_test_utils.rs index 72f14dc..b4b537f 100644 --- a/src/handlers/handler_test_utils.rs +++ b/src/handlers/handler_test_utils.rs @@ -344,6 +344,9 @@ mod test_utils { movie_details_modal .movie_history .set_items(vec![$crate::models::radarr_models::MovieHistoryItem::default()]); + movie_details_modal.movie_cast.set_items(vec![$crate::models::radarr_models::Credit::default()]); + movie_details_modal.movie_crew.set_items(vec![$crate::models::radarr_models::Credit::default()]); + movie_details_modal.movie_releases.set_items(vec![$crate::models::radarr_models::RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); let mut series_history = $crate::models::stateful_table::StatefulTable::default(); series_history.set_items(vec![ diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index 71264df..cdbf7e6 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -1,10 +1,13 @@ +use crate::models::HorizontallyScrollableText; use serde_json::Number; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; +use crate::handle_table_events; +use crate::handlers::table_handler::TableHandlingProps; use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; -use crate::models::radarr_models::RadarrRelease; +use crate::models::radarr_models::{Credit, MovieHistoryItem, RadarrRelease}; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_MOVIE_SELECTION_BLOCKS, MOVIE_DETAILS_BLOCKS, }; @@ -24,7 +27,58 @@ pub(super) struct MovieDetailsHandler<'a, 'b> { _context: Option, } +impl<'a, 'b> MovieDetailsHandler<'a, 'b> { + handle_table_events!( + self, + movie_releases, + self + .app + .data + .radarr_data + .movie_details_modal + .as_mut() + .unwrap() + .movie_releases, + RadarrRelease + ); + handle_table_events!( + self, + movie_history, + self + .app + .data + .radarr_data + .movie_details_modal + .as_mut() + .unwrap() + .movie_history, + MovieHistoryItem + ); + handle_table_events!(self, movie_cast, self.app.data.radarr_data.movie_details_modal.as_mut().unwrap().movie_cast, Credit); + handle_table_events!(self, movie_crew, self.app.data.radarr_data.movie_details_modal.as_mut().unwrap().movie_crew, Credit); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler<'a, 'b> { + fn handle(&mut self) { + let movie_history_table_handling_props = + TableHandlingProps::new(ActiveRadarrBlock::MovieHistory.into()); + let movie_releases_table_handling_props = + TableHandlingProps::new(ActiveRadarrBlock::ManualSearch.into()) + .sorting_block(ActiveRadarrBlock::ManualSearchSortPrompt.into()) + .sort_options(releases_sorting_options()); + let movie_cast_table_handling_props = + TableHandlingProps::new(ActiveRadarrBlock::Cast.into()); + let movie_crew_table_handling_props = TableHandlingProps::new(ActiveRadarrBlock::Crew.into()); + + if !self.handle_movie_history_table_events(movie_history_table_handling_props) + && !self.handle_movie_releases_table_events(movie_releases_table_handling_props) + && !self.handle_movie_cast_table_events(movie_cast_table_handling_props) + && !self.handle_movie_crew_table_events(movie_crew_table_handling_props) + { + self.handle_key_event(); + } + } + fn accepts(active_block: ActiveRadarrBlock) -> bool { MOVIE_DETAILS_BLOCKS.contains(&active_block) } @@ -83,54 +137,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< .unwrap() .movie_details .scroll_up(), - ActiveRadarrBlock::MovieHistory => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_history - .scroll_up(), - ActiveRadarrBlock::Cast => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_cast - .scroll_up(), - ActiveRadarrBlock::Crew => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_crew - .scroll_up(), - ActiveRadarrBlock::ManualSearch => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_releases - .scroll_up(), - ActiveRadarrBlock::ManualSearchSortPrompt => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_releases - .sort - .as_mut() - .unwrap() - .scroll_up(), _ => (), } } @@ -146,54 +152,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< .unwrap() .movie_details .scroll_down(), - ActiveRadarrBlock::MovieHistory => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_history - .scroll_down(), - ActiveRadarrBlock::Cast => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_cast - .scroll_down(), - ActiveRadarrBlock::Crew => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_crew - .scroll_down(), - ActiveRadarrBlock::ManualSearch => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_releases - .scroll_down(), - ActiveRadarrBlock::ManualSearchSortPrompt => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_releases - .sort - .as_mut() - .unwrap() - .scroll_down(), _ => (), } } @@ -209,54 +167,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< .unwrap() .movie_details .scroll_to_top(), - ActiveRadarrBlock::MovieHistory => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_history - .scroll_to_top(), - ActiveRadarrBlock::Cast => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_cast - .scroll_to_top(), - ActiveRadarrBlock::Crew => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_crew - .scroll_to_top(), - ActiveRadarrBlock::ManualSearch => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_releases - .scroll_to_top(), - ActiveRadarrBlock::ManualSearchSortPrompt => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_releases - .sort - .as_mut() - .unwrap() - .scroll_to_top(), _ => (), } } @@ -272,54 +182,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< .unwrap() .movie_details .scroll_to_bottom(), - ActiveRadarrBlock::MovieHistory => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_history - .scroll_to_bottom(), - ActiveRadarrBlock::Cast => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_cast - .scroll_to_bottom(), - ActiveRadarrBlock::Crew => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_crew - .scroll_to_bottom(), - ActiveRadarrBlock::ManualSearch => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_releases - .scroll_to_bottom(), - ActiveRadarrBlock::ManualSearchSortPrompt => self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_releases - .sort - .as_mut() - .unwrap() - .scroll_to_bottom(), _ => (), } } @@ -385,18 +247,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< self.app.pop_navigation_stack(); } - ActiveRadarrBlock::ManualSearchSortPrompt => { - self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_releases - .apply_sorting(); - self.app.pop_navigation_stack(); - } _ => (), } } @@ -414,8 +264,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< } ActiveRadarrBlock::AutomaticallySearchMoviePrompt | ActiveRadarrBlock::UpdateAndScanPrompt - | ActiveRadarrBlock::ManualSearchConfirmPrompt - | ActiveRadarrBlock::ManualSearchSortPrompt => { + | ActiveRadarrBlock::ManualSearchConfirmPrompt => { self.app.pop_navigation_stack(); self.app.data.radarr_data.prompt_confirm = false; } @@ -459,20 +308,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< .app .pop_and_push_navigation_stack(self.active_radarr_block.into()); } - _ if key == DEFAULT_KEYBINDINGS.sort.key => { - self - .app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_releases - .sorting(releases_sorting_options()); - self - .app - .push_navigation_stack(ActiveRadarrBlock::ManualSearchSortPrompt.into()); - } _ => (), }, ActiveRadarrBlock::AutomaticallySearchMoviePrompt => { diff --git a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs index 4a90954..319ced6 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs @@ -135,6 +135,7 @@ mod tests { #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, ) { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::MovieHistory.into()); let mut movie_details_modal = MovieDetailsModal::default(); movie_details_modal .movie_history @@ -232,6 +233,7 @@ mod tests { #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, ) { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Cast.into()); let mut movie_details_modal = MovieDetailsModal::default(); movie_details_modal .movie_cast @@ -317,6 +319,7 @@ mod tests { #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, ) { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Crew.into()); let mut movie_details_modal = MovieDetailsModal::default(); movie_details_modal .movie_crew @@ -402,6 +405,7 @@ mod tests { #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, ) { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into()); let mut movie_details_modal = MovieDetailsModal::default(); movie_details_modal .movie_releases @@ -666,6 +670,7 @@ mod tests { #[test] fn test_movie_history_home_end() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::MovieHistory.into()); let mut movie_details_modal = MovieDetailsModal::default(); movie_details_modal .movie_history @@ -783,6 +788,7 @@ mod tests { #[test] fn test_cast_home_end() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Cast.into()); let mut movie_details_modal = MovieDetailsModal::default(); movie_details_modal .movie_cast @@ -888,6 +894,7 @@ mod tests { #[test] fn test_crew_home_end() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Crew.into()); let mut movie_details_modal = MovieDetailsModal::default(); movie_details_modal .movie_crew @@ -993,6 +1000,7 @@ mod tests { #[test] fn test_manual_search_home_end() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into()); let mut movie_details_modal = MovieDetailsModal::default(); movie_details_modal .movie_releases @@ -1430,12 +1438,12 @@ mod tests { ActiveRadarrBlock::AutomaticallySearchMoviePrompt, ActiveRadarrBlock::UpdateAndScanPrompt, ActiveRadarrBlock::ManualSearchConfirmPrompt, - ActiveRadarrBlock::ManualSearchSortPrompt )] prompt_block: ActiveRadarrBlock, #[values(true, false)] is_ready: bool, ) { let mut app = App::default(); + app.data.radarr_data = create_test_radarr_data(); app.is_loading = is_ready; app.data.radarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); @@ -1446,6 +1454,18 @@ mod tests { assert!(!app.data.radarr_data.prompt_confirm); assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } + + #[rstest] + fn test_manual_search_sort_prompt_esc() { + let mut app = App::default(); + app.data.radarr_data = create_test_radarr_data(); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app.push_navigation_stack(ActiveRadarrBlock::ManualSearchSortPrompt.into()); + + MovieDetailsHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::ManualSearchSortPrompt, None).handle(); + + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); + } } mod test_handle_key_char { @@ -1542,6 +1562,7 @@ mod tests { #[test] fn test_sort_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into()); let mut modal = MovieDetailsModal::default(); modal.movie_releases.set_items(release_vec()); app.data.radarr_data.movie_details_modal = Some(modal); diff --git a/src/handlers/table_handler.rs b/src/handlers/table_handler.rs index b05759f..78d1e70 100644 --- a/src/handlers/table_handler.rs +++ b/src/handlers/table_handler.rs @@ -216,9 +216,10 @@ macro_rules! handle_table_events { _ if props.sorting_block.is_some() && $self.app.get_current_route() == *props.sorting_block.as_ref().unwrap() => { - let sort_by_fn = props.sort_by_fn.expect("Sort by function is required"); + if let Some(sort_by_fn) = props.sort_by_fn { + $table.items.sort_by(sort_by_fn); + } - $table.items.sort_by(sort_by_fn); $table.apply_sorting(); $self.app.pop_navigation_stack(); From 87a652d91116f0c418b50aabf6f802991ad9cf14 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 14:14:24 -0700 Subject: [PATCH 49/82] refactor(collections_handler): Use the new handle_table_events macro --- .../collections/collections_handler_tests.rs | 26 +- .../radarr_handlers/collections/mod.rs | 275 ++---------------- 2 files changed, 43 insertions(+), 258 deletions(-) diff --git a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs index 34a5d84..6a4ff25 100644 --- a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs @@ -218,6 +218,7 @@ mod tests { #[test] fn test_collection_search_box_home_end_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::SearchCollection.into()); app .data .radarr_data @@ -271,6 +272,7 @@ mod tests { #[test] fn test_collection_filter_box_home_end_keys() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::FilterCollections.into()); app .data .radarr_data @@ -447,6 +449,8 @@ mod tests { #[test] fn test_collection_search_box_left_right_keys() { let mut app = App::default(); + app.data.radarr_data.collections.set_items(vec![Collection::default()]); + app.push_navigation_stack(ActiveRadarrBlock::SearchCollection.into()); app.data.radarr_data.collections.search = Some("Test".into()); CollectionsHandler::with( @@ -495,6 +499,8 @@ mod tests { #[test] fn test_collection_filter_box_left_right_keys() { let mut app = App::default(); + app.data.radarr_data.collections.set_items(vec![Collection::default()]); + app.push_navigation_stack(ActiveRadarrBlock::FilterCollections.into()); app.data.radarr_data.collections.filter = Some("Test".into()); CollectionsHandler::with( @@ -932,6 +938,7 @@ mod tests { filtered_state: Some(TableState::default()), ..StatefulTable::default() }; + app.data.radarr_data.collections.set_items(vec![Collection::default()]); CollectionsHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); @@ -970,6 +977,7 @@ mod tests { #[test] fn test_collections_sort_prompt_block_esc() { let mut app = App::default(); + app.data.radarr_data.collections.set_items(vec![Collection::default()]); app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); app.push_navigation_stack(ActiveRadarrBlock::CollectionsSortPrompt.into()); @@ -995,13 +1003,6 @@ mod tests { app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); app.data.radarr_data = create_test_radarr_data(); - app.data.radarr_data.collections = StatefulTable { - search: Some("Test".into()), - filter: Some("Test".into()), - filtered_items: Some(Vec::new()), - filtered_state: Some(TableState::default()), - ..StatefulTable::default() - }; CollectionsHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::Collections, None).handle(); @@ -1010,10 +1011,6 @@ mod tests { ActiveRadarrBlock::Collections.into() ); assert!(app.error.text.is_empty()); - assert_eq!(app.data.radarr_data.collections.search, None); - assert_eq!(app.data.radarr_data.collections.filter, None); - assert_eq!(app.data.radarr_data.collections.filtered_items, None); - assert_eq!(app.data.radarr_data.collections.filtered_state, None); } } @@ -1035,6 +1032,7 @@ mod tests { #[test] fn test_search_collections_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); app .data .radarr_data @@ -1090,6 +1088,7 @@ mod tests { #[test] fn test_filter_collections_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); app .data .radarr_data @@ -1315,6 +1314,7 @@ mod tests { #[test] fn test_search_collections_box_backspace_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::SearchCollection.into()); app .data .radarr_data @@ -1346,6 +1346,7 @@ mod tests { #[test] fn test_filter_collections_box_backspace_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::FilterCollections.into()); app .data .radarr_data @@ -1377,6 +1378,7 @@ mod tests { #[test] fn test_search_collections_box_char_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::SearchCollection.into()); app .data .radarr_data @@ -1408,6 +1410,7 @@ mod tests { #[test] fn test_filter_collections_box_char_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::FilterCollections.into()); app .data .radarr_data @@ -1439,6 +1442,7 @@ mod tests { #[test] fn test_sort_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); app .data .radarr_data diff --git a/src/handlers/radarr_handlers/collections/mod.rs b/src/handlers/radarr_handlers/collections/mod.rs index de1a987..5e7c587 100644 --- a/src/handlers/radarr_handlers/collections/mod.rs +++ b/src/handlers/radarr_handlers/collections/mod.rs @@ -4,6 +4,7 @@ use crate::event::Key; use crate::handlers::radarr_handlers::collections::collection_details_handler::CollectionDetailsHandler; use crate::handlers::radarr_handlers::collections::edit_collection_handler::EditCollectionHandler; use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::TableHandlingProps; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::radarr_models::Collection; use crate::models::servarr_data::radarr::radarr_data::{ @@ -12,7 +13,7 @@ use crate::models::servarr_data::radarr::radarr_data::{ use crate::models::stateful_table::SortOption; use crate::models::{BlockSelectionState, HorizontallyScrollableText, Scrollable}; use crate::network::radarr_network::RadarrEvent; -use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; +use crate::handle_table_events; mod collection_details_handler; mod edit_collection_handler; @@ -28,18 +29,35 @@ pub(super) struct CollectionsHandler<'a, 'b> { context: Option, } +impl<'a, 'b> CollectionsHandler<'a, 'b> { + handle_table_events!(self, collections, self.app.data.radarr_data.collections, Collection); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<'a, 'b> { fn handle(&mut self) { - match self.active_radarr_block { - _ if CollectionDetailsHandler::accepts(self.active_radarr_block) => { - CollectionDetailsHandler::with(self.key, self.app, self.active_radarr_block, self.context) - .handle(); + let collections_table_handling_props = TableHandlingProps::new(ActiveRadarrBlock::Collections.into()) + .sorting_block(ActiveRadarrBlock::CollectionsSortPrompt.into()) + .sort_by_fn(|a: &Collection, b: &Collection| a.id.cmp(&b.id)) + .sort_options(collections_sorting_options()) + .searching_block(ActiveRadarrBlock::SearchCollection.into()) + .search_error_block(ActiveRadarrBlock::SearchCollectionError.into()) + .search_field_fn(|collection| &collection.title.text) + .filtering_block(ActiveRadarrBlock::FilterCollections.into()) + .filter_error_block(ActiveRadarrBlock::FilterCollectionsError.into()) + .filter_field_fn(|collection| &collection.title.text); + + if !self.handle_collections_table_events(collections_table_handling_props) { + match self.active_radarr_block { + _ if CollectionDetailsHandler::accepts(self.active_radarr_block) => { + CollectionDetailsHandler::with(self.key, self.app, self.active_radarr_block, self.context) + .handle(); + } + _ if EditCollectionHandler::accepts(self.active_radarr_block) => { + EditCollectionHandler::with(self.key, self.app, self.active_radarr_block, self.context) + .handle(); + } + _ => self.handle_key_event(), } - _ if EditCollectionHandler::accepts(self.active_radarr_block) => { - EditCollectionHandler::with(self.key, self.app, self.active_radarr_block, self.context) - .handle(); - } - _ => self.handle_key_event(), } } @@ -72,103 +90,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' } fn handle_scroll_up(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::Collections => self.app.data.radarr_data.collections.scroll_up(), - ActiveRadarrBlock::CollectionsSortPrompt => self - .app - .data - .radarr_data - .collections - .sort - .as_mut() - .unwrap() - .scroll_up(), - _ => (), - } } fn handle_scroll_down(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::Collections => self.app.data.radarr_data.collections.scroll_down(), - ActiveRadarrBlock::CollectionsSortPrompt => self - .app - .data - .radarr_data - .collections - .sort - .as_mut() - .unwrap() - .scroll_down(), - _ => (), - } } fn handle_home(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::Collections => self.app.data.radarr_data.collections.scroll_to_top(), - ActiveRadarrBlock::SearchCollection => self - .app - .data - .radarr_data - .collections - .search - .as_mut() - .unwrap() - .scroll_home(), - ActiveRadarrBlock::FilterCollections => self - .app - .data - .radarr_data - .collections - .filter - .as_mut() - .unwrap() - .scroll_home(), - ActiveRadarrBlock::CollectionsSortPrompt => self - .app - .data - .radarr_data - .collections - .sort - .as_mut() - .unwrap() - .scroll_to_top(), - _ => (), - } } fn handle_end(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::Collections => self.app.data.radarr_data.collections.scroll_to_bottom(), - ActiveRadarrBlock::SearchCollection => self - .app - .data - .radarr_data - .collections - .search - .as_mut() - .unwrap() - .reset_offset(), - ActiveRadarrBlock::FilterCollections => self - .app - .data - .radarr_data - .collections - .filter - .as_mut() - .unwrap() - .reset_offset(), - ActiveRadarrBlock::CollectionsSortPrompt => self - .app - .data - .radarr_data - .collections - .sort - .as_mut() - .unwrap() - .scroll_to_bottom(), - _ => (), - } } fn handle_delete(&mut self) {} @@ -177,34 +107,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' match self.active_radarr_block { ActiveRadarrBlock::Collections => handle_change_tab_left_right_keys(self.app, self.key), ActiveRadarrBlock::UpdateAllCollectionsPrompt => handle_prompt_toggle(self.app, self.key), - ActiveRadarrBlock::SearchCollection => { - handle_text_box_left_right_keys!( - self, - self.key, - self - .app - .data - .radarr_data - .collections - .search - .as_mut() - .unwrap() - ) - } - ActiveRadarrBlock::FilterCollections => { - handle_text_box_left_right_keys!( - self, - self.key, - self - .app - .data - .radarr_data - .collections - .filter - .as_mut() - .unwrap() - ) - } _ => (), } } @@ -214,44 +116,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' ActiveRadarrBlock::Collections => self .app .push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into()), - ActiveRadarrBlock::SearchCollection => { - self.app.pop_navigation_stack(); - self.app.should_ignore_quit_key = false; - - if self.app.data.radarr_data.collections.search.is_some() { - let has_match = self - .app - .data - .radarr_data - .collections - .apply_search(|collection| &collection.title.text); - - if !has_match { - self - .app - .push_navigation_stack(ActiveRadarrBlock::SearchCollectionError.into()); - } - } - } - ActiveRadarrBlock::FilterCollections => { - self.app.pop_navigation_stack(); - self.app.should_ignore_quit_key = false; - - if self.app.data.radarr_data.collections.filter.is_some() { - let has_matches = self - .app - .data - .radarr_data - .collections - .apply_filter(|collection| &collection.title.text); - - if !has_matches { - self - .app - .push_navigation_stack(ActiveRadarrBlock::FilterCollectionsError.into()); - } - } - } ActiveRadarrBlock::UpdateAllCollectionsPrompt => { if self.app.data.radarr_data.prompt_confirm { self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateCollections); @@ -259,44 +123,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' self.app.pop_navigation_stack(); } - ActiveRadarrBlock::CollectionsSortPrompt => { - self - .app - .data - .radarr_data - .collections - .items - .sort_by(|a, b| a.id.cmp(&b.id)); - self.app.data.radarr_data.collections.apply_sorting(); - - self.app.pop_navigation_stack(); - } _ => (), } } fn handle_esc(&mut self) { match self.active_radarr_block { - ActiveRadarrBlock::FilterCollections | ActiveRadarrBlock::FilterCollectionsError => { - self.app.pop_navigation_stack(); - self.app.data.radarr_data.collections.reset_filter(); - self.app.should_ignore_quit_key = false; - } - ActiveRadarrBlock::SearchCollection | ActiveRadarrBlock::SearchCollectionError => { - self.app.pop_navigation_stack(); - self.app.data.radarr_data.collections.reset_search(); - self.app.should_ignore_quit_key = false; - } ActiveRadarrBlock::UpdateAllCollectionsPrompt => { self.app.pop_navigation_stack(); self.app.data.radarr_data.prompt_confirm = false; } - ActiveRadarrBlock::CollectionsSortPrompt => { - self.app.pop_navigation_stack(); - } _ => { - self.app.data.radarr_data.collections.reset_search(); - self.app.data.radarr_data.collections.reset_filter(); handle_clear_errors(self.app); } } @@ -306,23 +143,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' let key = self.key; match self.active_radarr_block { ActiveRadarrBlock::Collections => match self.key { - _ if key == DEFAULT_KEYBINDINGS.search.key => { - self - .app - .push_navigation_stack(ActiveRadarrBlock::SearchCollection.into()); - self.app.data.radarr_data.collections.search = - Some(HorizontallyScrollableText::default()); - self.app.should_ignore_quit_key = true; - } - _ if key == DEFAULT_KEYBINDINGS.filter.key => { - self - .app - .push_navigation_stack(ActiveRadarrBlock::FilterCollections.into()); - self.app.data.radarr_data.collections.reset_filter(); - self.app.data.radarr_data.collections.filter = - Some(HorizontallyScrollableText::default()); - self.app.should_ignore_quit_key = true; - } _ if key == DEFAULT_KEYBINDINGS.edit.key => { self.app.push_navigation_stack( ( @@ -344,47 +164,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' _ if key == DEFAULT_KEYBINDINGS.refresh.key => { self.app.should_refresh = true; } - _ if key == DEFAULT_KEYBINDINGS.sort.key => { - self - .app - .data - .radarr_data - .collections - .sorting(collections_sorting_options()); - self - .app - .push_navigation_stack(ActiveRadarrBlock::CollectionsSortPrompt.into()); - } _ => (), }, - ActiveRadarrBlock::SearchCollection => { - handle_text_box_keys!( - self, - key, - self - .app - .data - .radarr_data - .collections - .search - .as_mut() - .unwrap() - ) - } - ActiveRadarrBlock::FilterCollections => { - handle_text_box_keys!( - self, - key, - self - .app - .data - .radarr_data - .collections - .filter - .as_mut() - .unwrap() - ) - } ActiveRadarrBlock::UpdateAllCollectionsPrompt => { if key == DEFAULT_KEYBINDINGS.confirm.key { self.app.data.radarr_data.prompt_confirm = true; From 048877bbb680adcbda92f9e0b4df538846737b9d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 14:22:59 -0700 Subject: [PATCH 50/82] refactor(collection_details_handler): use the new handle_table_events macro --- .../collections/collection_details_handler.rs | 21 +++++++ .../radarr_handlers/collections/mod.rs | 55 +++++++++-------- .../library/movie_details_handler.rs | 60 ++++++++++++------- src/handlers/sonarr_handlers/blocklist/mod.rs | 24 +++++--- src/handlers/sonarr_handlers/downloads/mod.rs | 31 +++++----- src/handlers/sonarr_handlers/history/mod.rs | 9 ++- src/handlers/sonarr_handlers/indexers/mod.rs | 1 - .../library/series_details_handler.rs | 2 +- src/handlers/table_handler.rs | 4 +- 9 files changed, 129 insertions(+), 78 deletions(-) diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler.rs b/src/handlers/radarr_handlers/collections/collection_details_handler.rs index 8d09807..1e51b08 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler.rs @@ -1,7 +1,10 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; +use crate::handle_table_events; +use crate::handlers::table_handler::TableHandlingProps; use crate::handlers::KeyEventHandler; +use crate::models::radarr_models::CollectionMovie; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, ADD_MOVIE_SELECTION_BLOCKS, COLLECTION_DETAILS_BLOCKS, EDIT_COLLECTION_SELECTION_BLOCKS, @@ -20,7 +23,25 @@ pub(super) struct CollectionDetailsHandler<'a, 'b> { _context: Option, } +impl<'a, 'b> CollectionDetailsHandler<'a, 'b> { + handle_table_events!( + self, + collection_movies, + self.app.data.radarr_data.collection_movies, + CollectionMovie + ); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHandler<'a, 'b> { + fn handle(&mut self) { + let collection_movies_table_handling_props = + TableHandlingProps::new(ActiveRadarrBlock::CollectionDetails.into()); + + if !self.handle_collection_movies_table_events(collection_movies_table_handling_props) { + self.handle_key_event(); + } + } + fn accepts(active_block: ActiveRadarrBlock) -> bool { COLLECTION_DETAILS_BLOCKS.contains(&active_block) } diff --git a/src/handlers/radarr_handlers/collections/mod.rs b/src/handlers/radarr_handlers/collections/mod.rs index 5e7c587..17f315e 100644 --- a/src/handlers/radarr_handlers/collections/mod.rs +++ b/src/handlers/radarr_handlers/collections/mod.rs @@ -1,6 +1,7 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; +use crate::handle_table_events; use crate::handlers::radarr_handlers::collections::collection_details_handler::CollectionDetailsHandler; use crate::handlers::radarr_handlers::collections::edit_collection_handler::EditCollectionHandler; use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; @@ -11,9 +12,8 @@ use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, COLLECTIONS_BLOCKS, EDIT_COLLECTION_SELECTION_BLOCKS, }; use crate::models::stateful_table::SortOption; -use crate::models::{BlockSelectionState, HorizontallyScrollableText, Scrollable}; +use crate::models::{BlockSelectionState, Scrollable}; use crate::network::radarr_network::RadarrEvent; -use crate::handle_table_events; mod collection_details_handler; mod edit_collection_handler; @@ -30,27 +30,38 @@ pub(super) struct CollectionsHandler<'a, 'b> { } impl<'a, 'b> CollectionsHandler<'a, 'b> { - handle_table_events!(self, collections, self.app.data.radarr_data.collections, Collection); + handle_table_events!( + self, + collections, + self.app.data.radarr_data.collections, + Collection + ); } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<'a, 'b> { fn handle(&mut self) { - let collections_table_handling_props = TableHandlingProps::new(ActiveRadarrBlock::Collections.into()) - .sorting_block(ActiveRadarrBlock::CollectionsSortPrompt.into()) - .sort_by_fn(|a: &Collection, b: &Collection| a.id.cmp(&b.id)) - .sort_options(collections_sorting_options()) - .searching_block(ActiveRadarrBlock::SearchCollection.into()) - .search_error_block(ActiveRadarrBlock::SearchCollectionError.into()) - .search_field_fn(|collection| &collection.title.text) - .filtering_block(ActiveRadarrBlock::FilterCollections.into()) - .filter_error_block(ActiveRadarrBlock::FilterCollectionsError.into()) - .filter_field_fn(|collection| &collection.title.text); - + let collections_table_handling_props = + TableHandlingProps::new(ActiveRadarrBlock::Collections.into()) + .sorting_block(ActiveRadarrBlock::CollectionsSortPrompt.into()) + .sort_by_fn(|a: &Collection, b: &Collection| a.id.cmp(&b.id)) + .sort_options(collections_sorting_options()) + .searching_block(ActiveRadarrBlock::SearchCollection.into()) + .search_error_block(ActiveRadarrBlock::SearchCollectionError.into()) + .search_field_fn(|collection| &collection.title.text) + .filtering_block(ActiveRadarrBlock::FilterCollections.into()) + .filter_error_block(ActiveRadarrBlock::FilterCollectionsError.into()) + .filter_field_fn(|collection| &collection.title.text); + if !self.handle_collections_table_events(collections_table_handling_props) { match self.active_radarr_block { _ if CollectionDetailsHandler::accepts(self.active_radarr_block) => { - CollectionDetailsHandler::with(self.key, self.app, self.active_radarr_block, self.context) - .handle(); + CollectionDetailsHandler::with( + self.key, + self.app, + self.active_radarr_block, + self.context, + ) + .handle(); } _ if EditCollectionHandler::accepts(self.active_radarr_block) => { EditCollectionHandler::with(self.key, self.app, self.active_radarr_block, self.context) @@ -89,17 +100,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' !self.app.is_loading && !self.app.data.radarr_data.collections.is_empty() } - fn handle_scroll_up(&mut self) { - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - } + fn handle_scroll_down(&mut self) {} - fn handle_home(&mut self) { - } + fn handle_home(&mut self) {} - fn handle_end(&mut self) { - } + fn handle_end(&mut self) {} fn handle_delete(&mut self) {} diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index cdbf7e6..8e096a4 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -1,4 +1,3 @@ -use crate::models::HorizontallyScrollableText; use serde_json::Number; use crate::app::key_binding::DEFAULT_KEYBINDINGS; @@ -54,8 +53,32 @@ impl<'a, 'b> MovieDetailsHandler<'a, 'b> { .movie_history, MovieHistoryItem ); - handle_table_events!(self, movie_cast, self.app.data.radarr_data.movie_details_modal.as_mut().unwrap().movie_cast, Credit); - handle_table_events!(self, movie_crew, self.app.data.radarr_data.movie_details_modal.as_mut().unwrap().movie_crew, Credit); + handle_table_events!( + self, + movie_cast, + self + .app + .data + .radarr_data + .movie_details_modal + .as_mut() + .unwrap() + .movie_cast, + Credit + ); + handle_table_events!( + self, + movie_crew, + self + .app + .data + .radarr_data + .movie_details_modal + .as_mut() + .unwrap() + .movie_crew, + Credit + ); } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler<'a, 'b> { @@ -66,8 +89,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< TableHandlingProps::new(ActiveRadarrBlock::ManualSearch.into()) .sorting_block(ActiveRadarrBlock::ManualSearchSortPrompt.into()) .sort_options(releases_sorting_options()); - let movie_cast_table_handling_props = - TableHandlingProps::new(ActiveRadarrBlock::Cast.into()); + let movie_cast_table_handling_props = TableHandlingProps::new(ActiveRadarrBlock::Cast.into()); let movie_crew_table_handling_props = TableHandlingProps::new(ActiveRadarrBlock::Crew.into()); if !self.handle_movie_history_table_events(movie_history_table_handling_props) @@ -127,8 +149,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< } fn handle_scroll_up(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::MovieDetails => self + if self.active_radarr_block == ActiveRadarrBlock::MovieDetails { + self .app .data .radarr_data @@ -136,14 +158,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< .as_mut() .unwrap() .movie_details - .scroll_up(), - _ => (), + .scroll_up() } } fn handle_scroll_down(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::MovieDetails => self + if self.active_radarr_block == ActiveRadarrBlock::MovieDetails { + self .app .data .radarr_data @@ -151,14 +172,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< .as_mut() .unwrap() .movie_details - .scroll_down(), - _ => (), + .scroll_down() } } fn handle_home(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::MovieDetails => self + if self.active_radarr_block == ActiveRadarrBlock::MovieDetails { + self .app .data .radarr_data @@ -166,14 +186,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< .as_mut() .unwrap() .movie_details - .scroll_to_top(), - _ => (), + .scroll_to_top() } } fn handle_end(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::MovieDetails => self + if let ActiveRadarrBlock::MovieDetails = self.active_radarr_block { + self .app .data .radarr_data @@ -181,8 +200,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< .as_mut() .unwrap() .movie_details - .scroll_to_bottom(), - _ => (), + .scroll_to_bottom() } } diff --git a/src/handlers/sonarr_handlers/blocklist/mod.rs b/src/handlers/sonarr_handlers/blocklist/mod.rs index 0cffdd7..c227d4e 100644 --- a/src/handlers/sonarr_handlers/blocklist/mod.rs +++ b/src/handlers/sonarr_handlers/blocklist/mod.rs @@ -3,12 +3,12 @@ use crate::app::App; use crate::event::Key; use crate::handle_table_events; use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; -use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, BLOCKLIST_BLOCKS}; use crate::models::sonarr_models::BlocklistItem; use crate::models::stateful_table::SortOption; -use crate::models::{Scrollable, HorizontallyScrollableText}; +use crate::models::Scrollable; use crate::network::sonarr_network::SonarrEvent; #[cfg(test)] @@ -23,21 +23,27 @@ pub(super) struct BlocklistHandler<'a, 'b> { } impl<'a, 'b> BlocklistHandler<'a, 'b> { - handle_table_events!(self, blocklist, self.app.data.sonarr_data.blocklist, BlocklistItem); + handle_table_events!( + self, + blocklist, + self.app.data.sonarr_data.blocklist, + BlocklistItem + ); } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for BlocklistHandler<'a, 'b> { fn handle(&mut self) { - let blocklist_table_handling_props = TableHandlingProps::new(ActiveSonarrBlock::Blocklist.into()) - .sorting_block(ActiveSonarrBlock::BlocklistSortPrompt.into()) - .sort_by_fn(|a: &BlocklistItem, b: &BlocklistItem| a.id.cmp(&b.id)) - .sort_options(blocklist_sorting_options()); - + let blocklist_table_handling_props = + TableHandlingProps::new(ActiveSonarrBlock::Blocklist.into()) + .sorting_block(ActiveSonarrBlock::BlocklistSortPrompt.into()) + .sort_by_fn(|a: &BlocklistItem, b: &BlocklistItem| a.id.cmp(&b.id)) + .sort_options(blocklist_sorting_options()); + if !self.handle_blocklist_table_events(blocklist_table_handling_props) { self.handle_key_event(); } } - + fn accepts(active_block: ActiveSonarrBlock) -> bool { BLOCKLIST_BLOCKS.contains(&active_block) } diff --git a/src/handlers/sonarr_handlers/downloads/mod.rs b/src/handlers/sonarr_handlers/downloads/mod.rs index c4990c6..2482d76 100644 --- a/src/handlers/sonarr_handlers/downloads/mod.rs +++ b/src/handlers/sonarr_handlers/downloads/mod.rs @@ -1,14 +1,13 @@ -use crate::models::HorizontallyScrollableText; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; use crate::handle_table_events; use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; -use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DOWNLOADS_BLOCKS}; -use crate::models::Scrollable; use crate::models::sonarr_models::DownloadRecord; +use crate::models::Scrollable; use crate::network::sonarr_network::SonarrEvent; #[cfg(test)] @@ -23,18 +22,24 @@ pub(super) struct DownloadsHandler<'a, 'b> { } impl<'a, 'b> DownloadsHandler<'a, 'b> { - handle_table_events!(self, downloads, self.app.data.sonarr_data.downloads, DownloadRecord); + handle_table_events!( + self, + downloads, + self.app.data.sonarr_data.downloads, + DownloadRecord + ); } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for DownloadsHandler<'a, 'b> { fn handle(&mut self) { - let download_table_handling_props = TableHandlingProps::new(ActiveSonarrBlock::Downloads.into()); - + let download_table_handling_props = + TableHandlingProps::new(ActiveSonarrBlock::Downloads.into()); + if !self.handle_downloads_table_events(download_table_handling_props) { self.handle_key_event(); } } - + fn accepts(active_block: ActiveSonarrBlock) -> bool { DOWNLOADS_BLOCKS.contains(&active_block) } @@ -61,17 +66,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for DownloadsHandler<'a, !self.app.is_loading && !self.app.data.sonarr_data.downloads.is_empty() } - fn handle_scroll_up(&mut self) { - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - } + fn handle_scroll_down(&mut self) {} - fn handle_home(&mut self) { - } + fn handle_home(&mut self) {} - fn handle_end(&mut self) { - } + fn handle_end(&mut self) {} fn handle_delete(&mut self) { if self.active_sonarr_block == ActiveSonarrBlock::Downloads { diff --git a/src/handlers/sonarr_handlers/history/mod.rs b/src/handlers/sonarr_handlers/history/mod.rs index 51c8dc6..c374f23 100644 --- a/src/handlers/sonarr_handlers/history/mod.rs +++ b/src/handlers/sonarr_handlers/history/mod.rs @@ -1,14 +1,14 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; +use crate::handle_table_events; use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::table_handler::TableHandlingProps; use crate::handlers::{handle_clear_errors, KeyEventHandler}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS}; use crate::models::sonarr_models::SonarrHistoryItem; use crate::models::stateful_table::SortOption; -use crate::models::{HorizontallyScrollableText, Scrollable}; -use crate::handle_table_events; +use crate::models::Scrollable; #[cfg(test)] #[path = "history_handler_tests.rs"] @@ -85,9 +85,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for HistoryHandler<'a, ' fn handle_delete(&mut self) {} fn handle_left_right_action(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::History => handle_change_tab_left_right_keys(self.app, self.key), - _ => {} + if self.active_sonarr_block == ActiveSonarrBlock::History { + handle_change_tab_left_right_keys(self.app, self.key) } } diff --git a/src/handlers/sonarr_handlers/indexers/mod.rs b/src/handlers/sonarr_handlers/indexers/mod.rs index 74f4c45..6a5329b 100644 --- a/src/handlers/sonarr_handlers/indexers/mod.rs +++ b/src/handlers/sonarr_handlers/indexers/mod.rs @@ -14,7 +14,6 @@ use crate::models::servarr_data::sonarr::sonarr_data::{ }; use crate::models::servarr_models::Indexer; use crate::models::BlockSelectionState; -use crate::models::HorizontallyScrollableText; use crate::models::Scrollable; use crate::network::sonarr_network::SonarrEvent; diff --git a/src/handlers/sonarr_handlers/library/series_details_handler.rs b/src/handlers/sonarr_handlers/library/series_details_handler.rs index 9e95788..7365481 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler.rs @@ -9,7 +9,7 @@ use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EDIT_SERIES_SELECTION_BLOCKS, SERIES_DETAILS_BLOCKS, }; use crate::models::sonarr_models::{Season, SonarrHistoryItem}; -use crate::models::{BlockSelectionState, HorizontallyScrollableText, Scrollable}; +use crate::models::{BlockSelectionState, Scrollable}; use crate::network::sonarr_network::SonarrEvent; #[cfg(test)] diff --git a/src/handlers/table_handler.rs b/src/handlers/table_handler.rs index 78d1e70..bd20cff 100644 --- a/src/handlers/table_handler.rs +++ b/src/handlers/table_handler.rs @@ -319,7 +319,7 @@ macro_rules! handle_table_events { .app .push_navigation_stack(props.filtering_block.expect("Filtering block is undefined").into()); $table.reset_filter(); - $table.filter = Some(HorizontallyScrollableText::default()); + $table.filter = Some($crate::models::HorizontallyScrollableText::default()); $self.app.should_ignore_quit_key = true; true @@ -333,7 +333,7 @@ macro_rules! handle_table_events { $self .app .push_navigation_stack(props.searching_block.expect("Searching block is undefined")); - $table.search = Some(HorizontallyScrollableText::default()); + $table.search = Some($crate::models::HorizontallyScrollableText::default()); $self.app.should_ignore_quit_key = true; true From 27f12716d9af2fdde497b8d76e9199fb3e79b386 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 14:28:12 -0700 Subject: [PATCH 51/82] refactor(downloads_handler): Use the new handle_table_events macro --- .../collections/collection_details_handler.rs | 29 ++---------- src/handlers/radarr_handlers/downloads/mod.rs | 45 ++++++++++--------- 2 files changed, 29 insertions(+), 45 deletions(-) diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler.rs b/src/handlers/radarr_handlers/collections/collection_details_handler.rs index 1e51b08..a058d7e 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler.rs @@ -68,34 +68,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHan !self.app.is_loading && !self.app.data.radarr_data.collection_movies.is_empty() } - fn handle_scroll_up(&mut self) { - if ActiveRadarrBlock::CollectionDetails == self.active_radarr_block { - self.app.data.radarr_data.collection_movies.scroll_up() - } - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - if ActiveRadarrBlock::CollectionDetails == self.active_radarr_block { - self.app.data.radarr_data.collection_movies.scroll_down() - } - } + fn handle_scroll_down(&mut self) {} - fn handle_home(&mut self) { - if ActiveRadarrBlock::CollectionDetails == self.active_radarr_block { - self.app.data.radarr_data.collection_movies.scroll_to_top(); - } - } + fn handle_home(&mut self) {} - fn handle_end(&mut self) { - if ActiveRadarrBlock::CollectionDetails == self.active_radarr_block { - self - .app - .data - .radarr_data - .collection_movies - .scroll_to_bottom(); - } - } + fn handle_end(&mut self) {} fn handle_delete(&mut self) {} diff --git a/src/handlers/radarr_handlers/downloads/mod.rs b/src/handlers/radarr_handlers/downloads/mod.rs index b194efe..fa0756b 100644 --- a/src/handlers/radarr_handlers/downloads/mod.rs +++ b/src/handlers/radarr_handlers/downloads/mod.rs @@ -1,8 +1,11 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; +use crate::handle_table_events; use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::TableHandlingProps; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; +use crate::models::radarr_models::DownloadRecord; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, DOWNLOADS_BLOCKS}; use crate::models::Scrollable; use crate::network::radarr_network::RadarrEvent; @@ -18,7 +21,25 @@ pub(super) struct DownloadsHandler<'a, 'b> { _context: Option, } +impl<'a, 'b> DownloadsHandler<'a, 'b> { + handle_table_events!( + self, + downloads, + self.app.data.radarr_data.downloads, + DownloadRecord + ); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DownloadsHandler<'a, 'b> { + fn handle(&mut self) { + let downloads_table_handling_props = + TableHandlingProps::new(ActiveRadarrBlock::Downloads.into()); + + if !self.handle_downloads_table_events(downloads_table_handling_props) { + self.handle_key_event(); + } + } + fn accepts(active_block: ActiveRadarrBlock) -> bool { DOWNLOADS_BLOCKS.contains(&active_block) } @@ -45,29 +66,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DownloadsHandler<'a, !self.app.is_loading && !self.app.data.radarr_data.downloads.is_empty() } - fn handle_scroll_up(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::Downloads { - self.app.data.radarr_data.downloads.scroll_up() - } - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::Downloads { - self.app.data.radarr_data.downloads.scroll_down() - } - } + fn handle_scroll_down(&mut self) {} - fn handle_home(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::Downloads { - self.app.data.radarr_data.downloads.scroll_to_top() - } - } + fn handle_home(&mut self) {} - fn handle_end(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::Downloads { - self.app.data.radarr_data.downloads.scroll_to_bottom() - } - } + fn handle_end(&mut self) {} fn handle_delete(&mut self) { if self.active_radarr_block == ActiveRadarrBlock::Downloads { From 23d149093fded7302de84c49389767482accd135 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 14:34:47 -0700 Subject: [PATCH 52/82] refactor(blocklist_handler): Use the new handle_table_events macro --- src/handlers/handler_test_utils.rs | 1 + .../blocklist/blocklist_handler_tests.rs | 2 + src/handlers/radarr_handlers/blocklist/mod.rs | 112 +++++------------- 3 files changed, 31 insertions(+), 84 deletions(-) diff --git a/src/handlers/handler_test_utils.rs b/src/handlers/handler_test_utils.rs index b4b537f..122c35e 100644 --- a/src/handlers/handler_test_utils.rs +++ b/src/handlers/handler_test_utils.rs @@ -340,6 +340,7 @@ mod test_utils { app.data.radarr_data.collection_movies.set_items(vec![$crate::models::radarr_models::CollectionMovie::default()]); app.data.radarr_data.indexers.set_items(vec![$crate::models::servarr_models::Indexer::default()]); app.data.radarr_data.root_folders.set_items(vec![$crate::models::servarr_models::RootFolder::default()]); + app.data.radarr_data.blocklist.set_items(vec![$crate::models::radarr_models::BlocklistItem::default()]); let mut movie_details_modal = $crate::models::servarr_data::radarr::modals::MovieDetailsModal::default(); movie_details_modal .movie_history diff --git a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs index a48fdfa..bfb9bce 100644 --- a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs +++ b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs @@ -520,6 +520,7 @@ mod tests { #[test] fn test_blocklist_sort_prompt_block_esc() { let mut app = App::default(); + app.data.radarr_data.blocklist.set_items(vec![BlocklistItem::default()]); app.push_navigation_stack(ActiveRadarrBlock::Blocklist.into()); app.push_navigation_stack(ActiveRadarrBlock::BlocklistSortPrompt.into()); @@ -634,6 +635,7 @@ mod tests { #[test] fn test_sort_key() { let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Blocklist.into()); app.data.radarr_data.blocklist.set_items(blocklist_vec()); BlocklistHandler::with( diff --git a/src/handlers/radarr_handlers/blocklist/mod.rs b/src/handlers/radarr_handlers/blocklist/mod.rs index 940e2b2..372412b 100644 --- a/src/handlers/radarr_handlers/blocklist/mod.rs +++ b/src/handlers/radarr_handlers/blocklist/mod.rs @@ -1,7 +1,9 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; +use crate::handle_table_events; use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::TableHandlingProps; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::radarr_models::BlocklistItem; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, BLOCKLIST_BLOCKS}; @@ -20,7 +22,28 @@ pub(super) struct BlocklistHandler<'a, 'b> { _context: Option, } +impl<'a, 'b> BlocklistHandler<'a, 'b> { + handle_table_events!( + self, + blocklist, + self.app.data.radarr_data.blocklist, + BlocklistItem + ); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a, 'b> { + fn handle(&mut self) { + let blocklist_table_handling_props = + TableHandlingProps::new(ActiveRadarrBlock::Blocklist.into()) + .sorting_block(ActiveRadarrBlock::BlocklistSortPrompt.into()) + .sort_by_fn(|a: &BlocklistItem, b: &BlocklistItem| a.id.cmp(&b.id)) + .sort_options(blocklist_sorting_options()); + + if !self.handle_blocklist_table_events(blocklist_table_handling_props) { + self.handle_key_event(); + } + } + fn accepts(active_block: ActiveRadarrBlock) -> bool { BLOCKLIST_BLOCKS.contains(&active_block) } @@ -47,69 +70,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a, !self.app.is_loading && !self.app.data.radarr_data.blocklist.is_empty() } - fn handle_scroll_up(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::Blocklist => self.app.data.radarr_data.blocklist.scroll_up(), - ActiveRadarrBlock::BlocklistSortPrompt => self - .app - .data - .radarr_data - .blocklist - .sort - .as_mut() - .unwrap() - .scroll_up(), - _ => (), - } - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::Blocklist => self.app.data.radarr_data.blocklist.scroll_down(), - ActiveRadarrBlock::BlocklistSortPrompt => self - .app - .data - .radarr_data - .blocklist - .sort - .as_mut() - .unwrap() - .scroll_down(), - _ => (), - } - } + fn handle_scroll_down(&mut self) {} - fn handle_home(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::Blocklist => self.app.data.radarr_data.blocklist.scroll_to_top(), - ActiveRadarrBlock::BlocklistSortPrompt => self - .app - .data - .radarr_data - .blocklist - .sort - .as_mut() - .unwrap() - .scroll_to_top(), - _ => (), - } - } + fn handle_home(&mut self) {} - fn handle_end(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::Blocklist => self.app.data.radarr_data.blocklist.scroll_to_bottom(), - ActiveRadarrBlock::BlocklistSortPrompt => self - .app - .data - .radarr_data - .blocklist - .sort - .as_mut() - .unwrap() - .scroll_to_bottom(), - _ => (), - } - } + fn handle_end(&mut self) {} fn handle_delete(&mut self) { if self.active_radarr_block == ActiveRadarrBlock::Blocklist { @@ -145,18 +112,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a, self.app.pop_navigation_stack(); } - ActiveRadarrBlock::BlocklistSortPrompt => { - self - .app - .data - .radarr_data - .blocklist - .items - .sort_by(|a, b| a.id.cmp(&b.id)); - self.app.data.radarr_data.blocklist.apply_sorting(); - - self.app.pop_navigation_stack(); - } ActiveRadarrBlock::Blocklist => { self .app @@ -173,7 +128,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a, self.app.pop_navigation_stack(); self.app.data.radarr_data.prompt_confirm = false; } - ActiveRadarrBlock::BlocklistItemDetails | ActiveRadarrBlock::BlocklistSortPrompt => { + ActiveRadarrBlock::BlocklistItemDetails => { self.app.pop_navigation_stack(); } _ => handle_clear_errors(self.app), @@ -192,17 +147,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a, .app .push_navigation_stack(ActiveRadarrBlock::BlocklistClearAllItemsPrompt.into()); } - _ if key == DEFAULT_KEYBINDINGS.sort.key => { - self - .app - .data - .radarr_data - .blocklist - .sorting(blocklist_sorting_options()); - self - .app - .push_navigation_stack(ActiveRadarrBlock::BlocklistSortPrompt.into()); - } _ => (), }, ActiveRadarrBlock::DeleteBlocklistItemPrompt => { From 03d7aed25892a5d61d0d81312eb5d1e1488fb10b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 14:38:26 -0700 Subject: [PATCH 53/82] refactor(root_folders_handler): Use the new handle_table_events macro --- .../radarr_handlers/root_folders/mod.rs | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/src/handlers/radarr_handlers/root_folders/mod.rs b/src/handlers/radarr_handlers/root_folders/mod.rs index 52cc66b..841b762 100644 --- a/src/handlers/radarr_handlers/root_folders/mod.rs +++ b/src/handlers/radarr_handlers/root_folders/mod.rs @@ -2,11 +2,13 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::TableHandlingProps; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ROOT_FOLDERS_BLOCKS}; +use crate::models::servarr_models::RootFolder; use crate::models::{HorizontallyScrollableText, Scrollable}; use crate::network::radarr_network::RadarrEvent; -use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; +use crate::{handle_table_events, handle_text_box_keys, handle_text_box_left_right_keys}; #[cfg(test)] #[path = "root_folders_handler_tests.rs"] @@ -19,7 +21,25 @@ pub(super) struct RootFoldersHandler<'a, 'b> { _context: Option, } +impl<'a, 'b> RootFoldersHandler<'a, 'b> { + handle_table_events!( + self, + root_folders, + self.app.data.radarr_data.root_folders, + RootFolder + ); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RootFoldersHandler<'a, 'b> { + fn handle(&mut self) { + let root_folder_table_handling_props = + TableHandlingProps::new(ActiveRadarrBlock::RootFolders.into()); + + if !self.handle_root_folders_table_events(root_folder_table_handling_props) { + self.handle_key_event(); + } + } + fn accepts(active_block: ActiveRadarrBlock) -> bool { ROOT_FOLDERS_BLOCKS.contains(&active_block) } @@ -46,45 +66,33 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RootFoldersHandler<' !self.app.is_loading && !self.app.data.radarr_data.root_folders.is_empty() } - fn handle_scroll_up(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::RootFolders { - self.app.data.radarr_data.root_folders.scroll_up() - } - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::RootFolders { - self.app.data.radarr_data.root_folders.scroll_down() - } - } + fn handle_scroll_down(&mut self) {} fn handle_home(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::RootFolders => self.app.data.radarr_data.root_folders.scroll_to_top(), - ActiveRadarrBlock::AddRootFolderPrompt => self + if self.active_radarr_block == ActiveRadarrBlock::AddRootFolderPrompt { + self .app .data .radarr_data .edit_root_folder .as_mut() .unwrap() - .scroll_home(), - _ => (), + .scroll_home() } } fn handle_end(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::RootFolders => self.app.data.radarr_data.root_folders.scroll_to_bottom(), - ActiveRadarrBlock::AddRootFolderPrompt => self + if self.active_radarr_block == ActiveRadarrBlock::AddRootFolderPrompt { + self .app .data .radarr_data .edit_root_folder .as_mut() .unwrap() - .reset_offset(), - _ => (), + .reset_offset() } } From 1b8b19fde5485d0820d589919249c5e0114ed98f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 8 Dec 2024 14:42:18 -0700 Subject: [PATCH 54/82] refactor(indexers_handler): Use the new handle_table_events macro --- src/handlers/radarr_handlers/indexers/mod.rs | 61 +++++++++----------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/src/handlers/radarr_handlers/indexers/mod.rs b/src/handlers/radarr_handlers/indexers/mod.rs index d080eab..5fe6502 100644 --- a/src/handlers/radarr_handlers/indexers/mod.rs +++ b/src/handlers/radarr_handlers/indexers/mod.rs @@ -1,15 +1,18 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; +use crate::handle_table_events; use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::radarr_handlers::indexers::edit_indexer_handler::EditIndexerHandler; use crate::handlers::radarr_handlers::indexers::edit_indexer_settings_handler::IndexerSettingsHandler; use crate::handlers::radarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler; +use crate::handlers::table_handler::TableHandlingProps; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, }; +use crate::models::servarr_models::Indexer; use crate::models::BlockSelectionState; use crate::models::Scrollable; use crate::network::radarr_network::RadarrEvent; @@ -29,22 +32,30 @@ pub(super) struct IndexersHandler<'a, 'b> { context: Option, } +impl<'a, 'b> IndexersHandler<'a, 'b> { + handle_table_events!(self, indexers, self.app.data.radarr_data.indexers, Indexer); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a, 'b> { fn handle(&mut self) { - match self.active_radarr_block { - _ if EditIndexerHandler::accepts(self.active_radarr_block) => { - EditIndexerHandler::with(self.key, self.app, self.active_radarr_block, self.context) - .handle() + let indexer_table_handling_props = TableHandlingProps::new(ActiveRadarrBlock::Indexers.into()); + + if !self.handle_indexers_table_events(indexer_table_handling_props) { + match self.active_radarr_block { + _ if EditIndexerHandler::accepts(self.active_radarr_block) => { + EditIndexerHandler::with(self.key, self.app, self.active_radarr_block, self.context) + .handle() + } + _ if IndexerSettingsHandler::accepts(self.active_radarr_block) => { + IndexerSettingsHandler::with(self.key, self.app, self.active_radarr_block, self.context) + .handle() + } + _ if TestAllIndexersHandler::accepts(self.active_radarr_block) => { + TestAllIndexersHandler::with(self.key, self.app, self.active_radarr_block, self.context) + .handle() + } + _ => self.handle_key_event(), } - _ if IndexerSettingsHandler::accepts(self.active_radarr_block) => { - IndexerSettingsHandler::with(self.key, self.app, self.active_radarr_block, self.context) - .handle() - } - _ if TestAllIndexersHandler::accepts(self.active_radarr_block) => { - TestAllIndexersHandler::with(self.key, self.app, self.active_radarr_block, self.context) - .handle() - } - _ => self.handle_key_event(), } } @@ -77,29 +88,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a, !self.app.is_loading && !self.app.data.radarr_data.indexers.is_empty() } - fn handle_scroll_up(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::Indexers { - self.app.data.radarr_data.indexers.scroll_up(); - } - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::Indexers { - self.app.data.radarr_data.indexers.scroll_down(); - } - } + fn handle_scroll_down(&mut self) {} - fn handle_home(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::Indexers { - self.app.data.radarr_data.indexers.scroll_to_top(); - } - } + fn handle_home(&mut self) {} - fn handle_end(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::Indexers { - self.app.data.radarr_data.indexers.scroll_to_bottom(); - } - } + fn handle_end(&mut self) {} fn handle_delete(&mut self) { if self.active_radarr_block == ActiveRadarrBlock::Indexers { From 5b65e872251674313953440f15353081c42e5e4a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 9 Dec 2024 14:15:47 -0700 Subject: [PATCH 55/82] feat(network): Sonarr support for fetching season history --- src/models/servarr_data/sonarr/modals.rs | 2 + .../servarr_data/sonarr/modals_tests.rs | 1 + src/network/sonarr_network.rs | 57 ++- src/network/sonarr_network_tests.rs | 355 +++++++++++++++++- 4 files changed, 413 insertions(+), 2 deletions(-) diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 3d96c05..92c7645 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -312,6 +312,7 @@ impl Default for EpisodeDetailsModal { pub struct SeasonDetailsModal { pub episodes: StatefulTable, pub episode_details_modal: Option, + pub season_history: StatefulTable, pub season_releases: StatefulTable, pub season_details_tabs: TabState, } @@ -322,6 +323,7 @@ impl Default for SeasonDetailsModal { episodes: StatefulTable::default(), episode_details_modal: None, season_releases: StatefulTable::default(), + season_history: StatefulTable::default(), season_details_tabs: TabState::new(vec![ TabRoute { title: "Episodes", diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs index 28cd038..4d98505 100644 --- a/src/models/servarr_data/sonarr/modals_tests.rs +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -323,6 +323,7 @@ mod tests { assert!(season_details_modal.episodes.is_empty()); assert!(season_details_modal.episode_details_modal.is_none()); assert!(season_details_modal.season_releases.is_empty()); + assert!(season_details_modal.season_history.is_empty()); assert_eq!(season_details_modal.season_details_tabs.tabs.len(), 2); diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 38c4ef9..7cc85dc 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -70,6 +70,7 @@ pub enum SonarrEvent { GetQueuedEvents, GetRootFolders, GetEpisodeReleases(Option), + GetSeasonHistory(Option<(i64, i64)>), GetSeasonReleases(Option<(i64, i64)>), GetSecurityConfig, GetSeriesDetails(Option), @@ -128,7 +129,7 @@ impl NetworkResource for SonarrEvent { | SonarrEvent::DeleteRootFolder(_) | SonarrEvent::AddRootFolder(_) => "/rootfolder", SonarrEvent::GetSeasonReleases(_) | SonarrEvent::GetEpisodeReleases(_) => "/release", - SonarrEvent::GetSeriesHistory(_) => "/history/series", + SonarrEvent::GetSeriesHistory(_) | SonarrEvent::GetSeasonHistory(_) => "/history/series", SonarrEvent::GetStatus => "/system/status", SonarrEvent::GetTasks => "/system/task", SonarrEvent::GetUpdates => "/update", @@ -266,6 +267,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_episode_releases(params) .await .map(SonarrSerdeable::from), + SonarrEvent::GetSeasonHistory(params) => self + .get_sonarr_season_history(params) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetSeasonReleases(params) => self .get_season_releases(params) .await @@ -1887,6 +1892,56 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_season_history( + &mut self, + series_season_id_tuple: Option<(i64, i64)>, + ) -> Result { + let event = SonarrEvent::GetSeasonHistory(None); + let (series_id, season_number) = + if let Some((series_id, season_number)) = series_season_id_tuple { + (Some(series_id), Some(season_number)) + } else { + (None, None) + }; + + let (series_id, series_id_param) = self.extract_series_id(series_id).await; + let (season_number, season_number_param) = self.extract_season_number(season_number).await; + + info!("Fetching history for series with ID: {series_id} and season number: {season_number}"); + + let params = format!("{series_id_param}&{season_number_param}",); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), SonarrHistoryWrapper>(request_props, |history_response, mut app| { + if app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + let mut history_vec = history_response.records; + history_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_history + .set_items(history_vec); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_history + .apply_sorting_toggle(false); + }) + .await + } + async fn get_sonarr_security_config(&mut self) -> Result { info!("Fetching Sonarr security config"); let event = SonarrEvent::GetSecurityConfig; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 684dbca..be959fb 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -224,6 +224,17 @@ mod test { assert_str_eq!(event.resource(), "/history"); } + #[rstest] + fn test_resource_series_history( + #[values( + SonarrEvent::GetSeriesHistory(None), + SonarrEvent::GetSeasonHistory(None) + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/history/series"); + } + #[rstest] fn test_resource_queue( #[values(SonarrEvent::GetDownloads, SonarrEvent::DeleteDownload(None))] event: SonarrEvent, @@ -261,7 +272,6 @@ mod test { #[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] #[case(SonarrEvent::GetDiskSpace, "/diskspace")] - #[case(SonarrEvent::GetSeriesHistory(None), "/history/series")] #[case(SonarrEvent::GetLanguageProfiles, "/languageprofile")] #[case(SonarrEvent::GetLogs(Some(500)), "/log")] #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] @@ -4156,6 +4166,349 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_sonarr_season_history_event() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetSeasonHistory(None), + None, + Some("seriesId=1&seasonNumber=1"), + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_history + .sort_asc = true; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetSeasonHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .items, + expected_history_items + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .sort_asc + ); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_season_history_event_uses_provided_series_id_and_season_number() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetSeasonHistory(Some((2, 2))), + None, + Some("seriesId=2&seasonNumber=2"), + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_history + .sort_asc = true; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetSeasonHistory(Some((2, 2)))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .items, + expected_history_items + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .sort_asc + ); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_season_history_event_empty_season_details_modal() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetSeasonHistory(None), + None, + Some("seriesId=1&seasonNumber=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetSeasonHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .items, + expected_history_items + ); + assert!( + !app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .sort_asc + ); + assert_eq!(history, response); + } + } + #[tokio::test] async fn test_handle_get_season_releases_event() { let release_json = json!([ From 6427a80bd12ce7c95aa16eefc5e8499a89c111ee Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 9 Dec 2024 14:30:07 -0700 Subject: [PATCH 56/82] feat(cli): Sonarr support for fetching season history events --- src/cli/sonarr/list_command_handler.rs | 29 +++++++ src/cli/sonarr/list_command_handler_tests.rs | 82 ++++++++++++++++++++ src/network/sonarr_network.rs | 6 +- src/network/sonarr_network_tests.rs | 24 +++--- 4 files changed, 126 insertions(+), 15 deletions(-) diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index cf92a36..7bc46ff 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -67,6 +67,23 @@ pub enum SonarrListCommand { QueuedEvents, #[command(about = "List all root folders in Sonarr")] RootFolders, + #[command( + about = "Fetch all history events for the given season corresponding to the series with the given ID." + )] + SeasonHistory { + #[arg( + long, + help = "The Sonarr ID of the series whose history you wish to fetch and list", + required = true + )] + series_id: i64, + #[arg( + long, + help = "The season number to fetch history events for", + required = true + )] + season_number: i64, + }, #[command(about = "List all series in your Sonarr library")] Series, #[command(about = "Fetch all history events for the series with the given ID")] @@ -207,6 +224,18 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH .await?; serde_json::to_string_pretty(&resp)? } + SonarrListCommand::SeasonHistory { + series_id, + season_number, + } => { + let resp = self + .network + .handle_network_event( + SonarrEvent::GetSeasonHistory(Some((series_id, season_number))).into(), + ) + .await?; + serde_json::to_string_pretty(&resp)? + } SonarrListCommand::Series => { let resp = self .network diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index 7e71599..54cc3b0 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -149,6 +149,58 @@ mod tests { } } + #[test] + fn test_season_history_requires_series_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "list", + "season-history", + "--season-number", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_season_history_requires_season_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "list", + "season-history", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_season_history_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "list", + "season-history", + "--series-id", + "1", + "--season-number", + "1", + ]); + + assert!(result.is_ok()); + } + #[test] fn test_list_series_history_requires_series_id() { let result = @@ -368,5 +420,35 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_list_season_history_command() { + let expected_series_id = 1; + let expected_season_number = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetSeasonHistory(Some((expected_series_id, expected_season_number))).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let list_season_history_command = SonarrListCommand::SeasonHistory { + series_id: 1, + season_number: 1, + }; + + let result = + SonarrListCommandHandler::with(&app_arc, list_season_history_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 7cc85dc..a782071 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -1895,7 +1895,7 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_sonarr_season_history( &mut self, series_season_id_tuple: Option<(i64, i64)>, - ) -> Result { + ) -> Result> { let event = SonarrEvent::GetSeasonHistory(None); let (series_id, season_number) = if let Some((series_id, season_number)) = series_season_id_tuple { @@ -1915,12 +1915,12 @@ impl<'a, 'b> Network<'a, 'b> { .await; self - .handle_request::<(), SonarrHistoryWrapper>(request_props, |history_response, mut app| { + .handle_request::<(), Vec>(request_props, |history_items, mut app| { if app.data.sonarr_data.season_details_modal.is_none() { app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); } - let mut history_vec = history_response.records; + let mut history_vec = history_items; history_vec.sort_by(|a, b| a.id.cmp(&b.id)); app .data diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index be959fb..dd1e27b 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -4168,7 +4168,7 @@ mod test { #[tokio::test] async fn test_handle_get_sonarr_season_history_event() { - let history_json = json!({"records": [{ + let history_json = json!([{ "id": 123, "sourceTitle": "z episode", "episodeId": 1007, @@ -4193,8 +4193,8 @@ mod test { "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" } - }]}); - let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); let expected_history_items = vec![ SonarrHistoryItem { id: 123, @@ -4247,7 +4247,7 @@ mod test { .sort_asc = true; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + if let SonarrSerdeable::SonarrHistoryItems(history) = network .handle_sonarr_event(SonarrEvent::GetSeasonHistory(None)) .await .unwrap() @@ -4284,7 +4284,7 @@ mod test { #[tokio::test] async fn test_handle_get_sonarr_season_history_event_uses_provided_series_id_and_season_number() { - let history_json = json!({"records": [{ + let history_json = json!([{ "id": 123, "sourceTitle": "z episode", "episodeId": 1007, @@ -4309,8 +4309,8 @@ mod test { "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" } - }]}); - let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); let expected_history_items = vec![ SonarrHistoryItem { id: 123, @@ -4363,7 +4363,7 @@ mod test { .sort_asc = true; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + if let SonarrSerdeable::SonarrHistoryItems(history) = network .handle_sonarr_event(SonarrEvent::GetSeasonHistory(Some((2, 2)))) .await .unwrap() @@ -4400,7 +4400,7 @@ mod test { #[tokio::test] async fn test_handle_get_sonarr_season_history_event_empty_season_details_modal() { - let history_json = json!({"records": [{ + let history_json = json!([{ "id": 123, "sourceTitle": "z episode", "episodeId": 1007, @@ -4425,8 +4425,8 @@ mod test { "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" } - }]}); - let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); let expected_history_items = vec![ SonarrHistoryItem { id: 123, @@ -4467,7 +4467,7 @@ mod test { .set_items(vec![season()]); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + if let SonarrSerdeable::SonarrHistoryItems(history) = network .handle_sonarr_event(SonarrEvent::GetSeasonHistory(None)) .await .unwrap() From f3b7f155b7a32c18c6deb66ea9bdebfc1b19ecac Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 9 Dec 2024 15:15:09 -0700 Subject: [PATCH 57/82] feat(app): Model and modal support for the season and episode details popups --- src/app/sonarr/mod.rs | 5 + src/app/sonarr/sonarr_context_clues.rs | 65 +++++++++++-- src/app/sonarr/sonarr_context_clues_tests.rs | 94 +++++++++++++------ src/app/sonarr/sonarr_tests.rs | 17 ++++ src/models/servarr_data/sonarr/modals.rs | 25 +++-- .../servarr_data/sonarr/modals_tests.rs | 55 +++++++---- src/models/servarr_data/sonarr/sonarr_data.rs | 24 +++++ .../servarr_data/sonarr/sonarr_data_tests.rs | 21 ++++- 8 files changed, 240 insertions(+), 66 deletions(-) diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index 8457f40..7002b8e 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -34,6 +34,11 @@ impl<'a> App<'a> { .dispatch_network_event(SonarrEvent::GetEpisodes(None).into()) .await; } + ActiveSonarrBlock::SeasonHistory => { + self + .dispatch_network_event(SonarrEvent::GetSeasonHistory(None).into()) + .await; + } ActiveSonarrBlock::ManualSeasonSearch => { self .dispatch_network_event(SonarrEvent::GetSeasonReleases(None).into()) diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index 9f91b92..90caa32 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -71,25 +71,49 @@ pub static SERIES_HISTORY_CONTEXT_CLUES: [ContextClue; 9] = [ (DEFAULT_KEYBINDINGS.esc, "cancel filter/close"), ]; -pub static SEASON_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [ +pub static SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES: [ContextClue; 2] = [ + (DEFAULT_KEYBINDINGS.submit, "episode details"), + (DEFAULT_KEYBINDINGS.delete, "delete episode"), +]; + +pub static SEASON_DETAILS_CONTEXT_CLUES: [ContextClue; 4] = [ ( DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh.desc, ), - (DEFAULT_KEYBINDINGS.submit, "details"), - (DEFAULT_KEYBINDINGS.search, "auto search"), - (DEFAULT_KEYBINDINGS.delete, "delete episode"), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; -pub static MANUAL_SEASON_SEARCH_CONTEXT_CLUES: [ContextClue; 5] = [ +pub static SEASON_HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [ ( DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh.desc, ), - (DEFAULT_KEYBINDINGS.search, "auto search"), (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), - (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), + (DEFAULT_KEYBINDINGS.esc, "cancel filter/close"), +]; + +pub static MANUAL_SEASON_SEARCH_CONTEXT_CLUES: [ContextClue; 4] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; @@ -98,12 +122,15 @@ pub static MANUAL_EPISODE_SEARCH_CONTEXT_CLUES: [ContextClue; 4] = [ DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh.desc, ), - (DEFAULT_KEYBINDINGS.search, "auto search"), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; -pub static MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES: [ContextClue; 1] = +pub static DETAILS_CONTEXTUAL_CONTEXT_CLUES: [ContextClue; 1] = [(DEFAULT_KEYBINDINGS.submit, "details")]; pub static EPISODE_DETAILS_CONTEXT_CLUES: [ContextClue; 3] = [ @@ -111,10 +138,28 @@ pub static EPISODE_DETAILS_CONTEXT_CLUES: [ContextClue; 3] = [ DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh.desc, ), - (DEFAULT_KEYBINDINGS.search, "auto search"), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; +pub static EPISODE_HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), + (DEFAULT_KEYBINDINGS.esc, "cancel filter/close"), +]; + pub static SYSTEM_TASKS_CONTEXT_CLUES: [ContextClue; 2] = [ (DEFAULT_KEYBINDINGS.submit, "start task"), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index f5d0c1e..73499d9 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -5,11 +5,11 @@ mod tests { use crate::app::{ key_binding::DEFAULT_KEYBINDINGS, sonarr::sonarr_context_clues::{ - ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES, - HISTORY_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, - MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, - SEASON_DETAILS_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES, - SERIES_HISTORY_CONTEXT_CLUES, SYSTEM_TASKS_CONTEXT_CLUES, + ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES, DETAILS_CONTEXTUAL_CONTEXT_CLUES, + EPISODE_DETAILS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, + MANUAL_SEASON_SEARCH_CONTEXT_CLUES, SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES, + SEASON_DETAILS_CONTEXT_CLUES, SEASON_HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, + SERIES_DETAILS_CONTEXT_CLUES, SERIES_HISTORY_CONTEXT_CLUES, SYSTEM_TASKS_CONTEXT_CLUES, }, }; @@ -225,18 +225,13 @@ mod tests { let (key_binding, description) = season_details_context_clues_iter.next().unwrap(); - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); - assert_str_eq!(*description, "details"); - - let (key_binding, description) = season_details_context_clues_iter.next().unwrap(); - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); - assert_str_eq!(*description, "auto search"); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.search.desc); let (key_binding, description) = season_details_context_clues_iter.next().unwrap(); - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); - assert_str_eq!(*description, "delete episode"); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.auto_search); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.auto_search.desc); let (key_binding, description) = season_details_context_clues_iter.next().unwrap(); @@ -245,6 +240,57 @@ mod tests { assert_eq!(season_details_context_clues_iter.next(), None); } + #[test] + fn test_season_details_contextual_context_clues() { + let mut season_details_contextual_context_clues_iter = + SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES.iter(); + let (key_binding, description) = season_details_contextual_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); + assert_str_eq!(*description, "episode details"); + + let (key_binding, description) = season_details_contextual_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); + assert_str_eq!(*description, "delete episode"); + assert_eq!(season_details_contextual_context_clues_iter.next(), None); + } + + #[test] + fn test_season_history_context_clues() { + let mut season_history_context_clues_iter = SEASON_HISTORY_CONTEXT_CLUES.iter(); + let (key_binding, description) = season_history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); + + let (key_binding, description) = season_history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc); + + let (key_binding, description) = season_history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.search.desc); + + let (key_binding, description) = season_history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.filter); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.filter.desc); + + let (key_binding, description) = season_history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.auto_search); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.auto_search.desc); + + let (key_binding, description) = season_history_context_clues_iter.next().unwrap(); + + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); + assert_str_eq!(*description, "cancel filter/close"); + assert_eq!(season_history_context_clues_iter.next(), None); + } + #[test] fn test_manual_season_search_context_clues() { let mut manual_season_search_context_clues_iter = MANUAL_SEASON_SEARCH_CONTEXT_CLUES.iter(); @@ -256,8 +302,8 @@ mod tests { let (key_binding, description) = manual_season_search_context_clues_iter.next().unwrap(); - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); - assert_str_eq!(*description, "auto search"); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.auto_search); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.auto_search.desc); let (key_binding, description) = manual_season_search_context_clues_iter.next().unwrap(); @@ -266,11 +312,6 @@ mod tests { let (key_binding, description) = manual_season_search_context_clues_iter.next().unwrap(); - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); - assert_str_eq!(*description, "details"); - - let (key_binding, description) = manual_season_search_context_clues_iter.next().unwrap(); - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc); assert_eq!(manual_season_search_context_clues_iter.next(), None); @@ -287,8 +328,8 @@ mod tests { let (key_binding, description) = manual_episode_search_context_clues_iter.next().unwrap(); - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); - assert_str_eq!(*description, "auto search"); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.auto_search); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.auto_search.desc); let (key_binding, description) = manual_episode_search_context_clues_iter.next().unwrap(); @@ -303,9 +344,8 @@ mod tests { } #[test] - fn test_manual_episode_search_contextual_context_clues() { - let mut manual_search_contextual_context_clues_iter = - MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES.iter(); + fn details_contextual_context_clues() { + let mut manual_search_contextual_context_clues_iter = DETAILS_CONTEXTUAL_CONTEXT_CLUES.iter(); let (key_binding, description) = manual_search_contextual_context_clues_iter.next().unwrap(); assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); @@ -324,8 +364,8 @@ mod tests { let (key_binding, description) = episode_details_context_clues_iter.next().unwrap(); - assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); - assert_str_eq!(*description, "auto search"); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.auto_search); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.auto_search.desc); let (key_binding, description) = episode_details_context_clues_iter.next().unwrap(); diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index 99bc61c..91dfe63 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -87,6 +87,23 @@ mod tests { assert_eq!(app.tick_count, 0); } + #[tokio::test] + async fn test_dispatch_by_season_history_block() { + let (mut app, mut sync_network_rx) = construct_app_unit(); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::SeasonHistory) + .await; + + assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetSeasonHistory(None).into() + ); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + #[tokio::test] async fn test_dispatch_by_manual_season_search_block() { let (mut app, mut sync_network_rx) = construct_app_unit(); diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 92c7645..ad08bc7 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -4,9 +4,10 @@ use crate::{ app::{ context_clues::build_context_clue_string, sonarr::sonarr_context_clues::{ - EPISODE_DETAILS_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, - MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, - SEASON_DETAILS_CONTEXT_CLUES, + DETAILS_CONTEXTUAL_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES, + EPISODE_HISTORY_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, + MANUAL_SEASON_SEARCH_CONTEXT_CLUES, SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES, + SEASON_DETAILS_CONTEXT_CLUES, SEASON_HISTORY_CONTEXT_CLUES, }, }, models::{ @@ -287,8 +288,8 @@ impl Default for EpisodeDetailsModal { TabRoute { title: "History", route: ActiveSonarrBlock::EpisodeHistory.into(), - help: build_context_clue_string(&EPISODE_DETAILS_CONTEXT_CLUES), - contextual_help: None, + help: build_context_clue_string(&EPISODE_HISTORY_CONTEXT_CLUES), + contextual_help: Some(build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES)), }, TabRoute { title: "File", @@ -300,9 +301,7 @@ impl Default for EpisodeDetailsModal { title: "Manual Search", route: ActiveSonarrBlock::ManualEpisodeSearch.into(), help: build_context_clue_string(&MANUAL_EPISODE_SEARCH_CONTEXT_CLUES), - contextual_help: Some(build_context_clue_string( - &MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, - )), + contextual_help: Some(build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES)), }, ]), } @@ -328,13 +327,19 @@ impl Default for SeasonDetailsModal { TabRoute { title: "Episodes", route: ActiveSonarrBlock::SeasonDetails.into(), - help: String::new(), + help: build_context_clue_string(&SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES), contextual_help: Some(build_context_clue_string(&SEASON_DETAILS_CONTEXT_CLUES)), }, + TabRoute { + title: "History", + route: ActiveSonarrBlock::SeasonHistory.into(), + help: build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES), + contextual_help: Some(build_context_clue_string(&SEASON_HISTORY_CONTEXT_CLUES)), + }, TabRoute { title: "Manual Search", route: ActiveSonarrBlock::ManualSeasonSearch.into(), - help: String::new(), + help: build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES), contextual_help: Some(build_context_clue_string( &MANUAL_SEASON_SEARCH_CONTEXT_CLUES, )), diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs index 4d98505..e35ba5b 100644 --- a/src/models/servarr_data/sonarr/modals_tests.rs +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -7,9 +7,10 @@ mod tests { use crate::app::context_clues::build_context_clue_string; use crate::app::sonarr::sonarr_context_clues::{ - EPISODE_DETAILS_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, + DETAILS_CONTEXTUAL_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES, EPISODE_HISTORY_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, - SEASON_DETAILS_CONTEXT_CLUES, + SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES, SEASON_DETAILS_CONTEXT_CLUES, + SEASON_HISTORY_CONTEXT_CLUES, }; use crate::models::servarr_data::sonarr::modals::{ EditSeriesModal, EpisodeDetailsModal, SeasonDetailsModal, @@ -274,11 +275,12 @@ mod tests { ); assert_str_eq!( episode_details_modal.episode_details_tabs.tabs[1].help, - build_context_clue_string(&EPISODE_DETAILS_CONTEXT_CLUES) + build_context_clue_string(&EPISODE_HISTORY_CONTEXT_CLUES) + ); + assert_eq!( + episode_details_modal.episode_details_tabs.tabs[1].contextual_help, + Some(build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES)) ); - assert!(episode_details_modal.episode_details_tabs.tabs[1] - .contextual_help - .is_none()); assert_str_eq!( episode_details_modal.episode_details_tabs.tabs[2].title, @@ -310,9 +312,7 @@ mod tests { ); assert_eq!( episode_details_modal.episode_details_tabs.tabs[3].contextual_help, - Some(build_context_clue_string( - &MANUAL_EPISODE_SEARCH_CONTEXTUAL_CONTEXT_CLUES - )) + Some(build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES)) ); } @@ -325,7 +325,7 @@ mod tests { assert!(season_details_modal.season_releases.is_empty()); assert!(season_details_modal.season_history.is_empty()); - assert_eq!(season_details_modal.season_details_tabs.tabs.len(), 2); + assert_eq!(season_details_modal.season_details_tabs.tabs.len(), 3); assert_str_eq!( season_details_modal.season_details_tabs.tabs[0].title, @@ -335,9 +335,10 @@ mod tests { season_details_modal.season_details_tabs.tabs[0].route, ActiveSonarrBlock::SeasonDetails.into() ); - assert!(season_details_modal.season_details_tabs.tabs[0] - .help - .is_empty()); + assert_str_eq!( + season_details_modal.season_details_tabs.tabs[0].help, + build_context_clue_string(&SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES) + ); assert_eq!( season_details_modal.season_details_tabs.tabs[0].contextual_help, Some(build_context_clue_string(&SEASON_DETAILS_CONTEXT_CLUES)) @@ -345,17 +346,35 @@ mod tests { assert_str_eq!( season_details_modal.season_details_tabs.tabs[1].title, - "Manual Search" + "History" ); assert_eq!( season_details_modal.season_details_tabs.tabs[1].route, - ActiveSonarrBlock::ManualSeasonSearch.into() + ActiveSonarrBlock::SeasonHistory.into() + ); + assert_str_eq!( + season_details_modal.season_details_tabs.tabs[1].help, + build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES) ); - assert!(season_details_modal.season_details_tabs.tabs[1] - .help - .is_empty()); assert_eq!( season_details_modal.season_details_tabs.tabs[1].contextual_help, + Some(build_context_clue_string(&SEASON_HISTORY_CONTEXT_CLUES)) + ); + + assert_str_eq!( + season_details_modal.season_details_tabs.tabs[2].title, + "Manual Search" + ); + assert_eq!( + season_details_modal.season_details_tabs.tabs[2].route, + ActiveSonarrBlock::ManualSeasonSearch.into() + ); + assert_str_eq!( + season_details_modal.season_details_tabs.tabs[2].help, + build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES) + ); + assert_eq!( + season_details_modal.season_details_tabs.tabs[2].contextual_help, Some(build_context_clue_string( &MANUAL_SEASON_SEARCH_CONTEXT_CLUES )) diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 38ee436..1b5bcd2 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -254,6 +254,8 @@ pub enum ActiveSonarrBlock { FilterSeriesError, FilterSeriesHistory, FilterSeriesHistoryError, + FilterSeasonHistory, + FilterSeasonHistoryError, History, HistoryItemDetails, HistorySortPrompt, @@ -280,7 +282,12 @@ pub enum ActiveSonarrBlock { SearchSeriesError, SearchSeriesHistory, SearchSeriesHistoryError, + SearchSeasonHistory, + SearchSeasonHistoryError, SeasonDetails, + SeasonHistory, + SeasonHistoryDetails, + SeasonHistorySortPrompt, #[default] Series, SeriesDetails, @@ -326,6 +333,23 @@ pub static SERIES_DETAILS_BLOCKS: [ActiveSonarrBlock; 12] = [ ActiveSonarrBlock::SeriesHistoryDetails, ]; +pub static SEASON_DETAILS_BLOCKS: [ActiveSonarrBlock; 14] = [ + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::SearchSeason, + ActiveSonarrBlock::SearchSeasonError, + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, + ActiveSonarrBlock::SearchSeasonHistory, + ActiveSonarrBlock::SearchSeasonHistoryError, + ActiveSonarrBlock::FilterSeasonHistory, + ActiveSonarrBlock::FilterSeasonHistoryError, + ActiveSonarrBlock::SeasonHistorySortPrompt, + ActiveSonarrBlock::SeasonHistoryDetails, + ActiveSonarrBlock::ManualSeasonSearch, + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt, + ActiveSonarrBlock::ManualSeasonSearchSortPrompt, +]; + pub static ADD_SERIES_BLOCKS: [ActiveSonarrBlock; 13] = [ ActiveSonarrBlock::AddSeriesAlreadyInLibrary, ActiveSonarrBlock::AddSeriesConfirmPrompt, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 6e82dc3..5f65129 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -228,7 +228,7 @@ mod tests { EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, EDIT_SERIES_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, HISTORY_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, LIBRARY_BLOCKS, ROOT_FOLDERS_BLOCKS, - SERIES_DETAILS_BLOCKS, SYSTEM_DETAILS_BLOCKS, + SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, SYSTEM_DETAILS_BLOCKS, }; #[test] @@ -605,5 +605,24 @@ mod tests { assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesHistorySortPrompt)); assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesHistoryDetails)); } + + #[test] + fn test_season_details_blocks_contents() { + assert_eq!(SEASON_DETAILS_BLOCKS.len(), 14); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeasonDetails)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeasonHistory)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeason)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeasonError)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::AutomaticallySearchSeasonPrompt)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeasonHistory)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeasonHistoryError)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::FilterSeasonHistory)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::FilterSeasonHistoryError)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeasonHistorySortPrompt)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeasonHistoryDetails)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualSeasonSearch)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualSeasonSearchSortPrompt)); + } } } From 75c4fcbb9e0b6a64354b876c5864f7880ebb06e7 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 10 Dec 2024 16:22:02 -0700 Subject: [PATCH 58/82] feat(network): Support for fetching all episode files for a given series --- src/models/servarr_data/sonarr/modals.rs | 4 +- .../servarr_data/sonarr/modals_tests.rs | 1 + src/models/sonarr_models.rs | 5 + src/models/sonarr_models_tests.rs | 17 +- src/network/sonarr_network.rs | 46 ++++- src/network/sonarr_network_tests.rs | 171 +++++++++++++++++- src/ui/sonarr_ui/library/season_details_ui.rs | 0 .../library/season_details_ui_tests.rs | 0 8 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 src/ui/sonarr_ui/library/season_details_ui.rs create mode 100644 src/ui/sonarr_ui/library/season_details_ui_tests.rs diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index ad08bc7..7c90581 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -19,7 +19,7 @@ use crate::{ HorizontallyScrollableText, ScrollableText, TabRoute, TabState, }, }; - +use crate::models::sonarr_models::EpisodeFile; use super::sonarr_data::{ActiveSonarrBlock, SonarrData}; #[cfg(test)] @@ -310,6 +310,7 @@ impl Default for EpisodeDetailsModal { pub struct SeasonDetailsModal { pub episodes: StatefulTable, + pub episode_files: StatefulTable, pub episode_details_modal: Option, pub season_history: StatefulTable, pub season_releases: StatefulTable, @@ -321,6 +322,7 @@ impl Default for SeasonDetailsModal { SeasonDetailsModal { episodes: StatefulTable::default(), episode_details_modal: None, + episode_files: StatefulTable::default(), season_releases: StatefulTable::default(), season_history: StatefulTable::default(), season_details_tabs: TabState::new(vec![ diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs index e35ba5b..e06b06e 100644 --- a/src/models/servarr_data/sonarr/modals_tests.rs +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -322,6 +322,7 @@ mod tests { assert!(season_details_modal.episodes.is_empty()); assert!(season_details_modal.episode_details_modal.is_none()); + assert!(season_details_modal.episode_files.is_empty()); assert!(season_details_modal.season_releases.is_empty()); assert!(season_details_modal.season_history.is_empty()); diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index b133d83..7c23e6d 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -176,11 +176,14 @@ impl Display for Episode { #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct EpisodeFile { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, pub relative_path: String, pub path: String, #[serde(deserialize_with = "super::from_i64")] pub size: i64, pub language: Language, + pub quality: QualityWrapper, pub date_added: DateTime, pub media_info: Option, } @@ -626,6 +629,7 @@ pub enum SonarrSerdeable { DiskSpaces(Vec), Episode(Episode), Episodes(Vec), + EpisodeFiles(Vec), HostConfig(HostConfig), IndexerSettings(IndexerSettings), Indexers(Vec), @@ -669,6 +673,7 @@ serde_enum_from!( DiskSpaces(Vec), Episode(Episode), Episodes(Vec), + EpisodeFiles(Vec), HostConfig(HostConfig), IndexerSettings(IndexerSettings), Indexers(Vec), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 87ac76d..2ba98eb 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -11,7 +11,7 @@ mod tests { }, sonarr_models::{ AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, - Episode, IndexerSettings, Series, SeriesMonitor, SeriesStatus, SeriesType, + Episode, EpisodeFile, IndexerSettings, Series, SeriesMonitor, SeriesStatus, SeriesType, SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, }, @@ -236,6 +236,21 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::Episodes(episodes)); } + #[test] + fn test_sonarr_serdeable_from_episode_files() { + let episode_files = vec![EpisodeFile { + id: 1, + ..EpisodeFile::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = episode_files.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::EpisodeFiles(episode_files) + ); + } + #[test] fn test_sonarr_serdeable_from_host_config() { let host_config = HostConfig { diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index a782071..48e75b1 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -21,9 +21,9 @@ use crate::{ sonarr_models::{ AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DeleteSeriesParams, DownloadRecord, DownloadsResponse, EditSeriesParams, Episode, - IndexerSettings, Series, SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, - SonarrRelease, SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, - SystemStatus, + EpisodeFile, IndexerSettings, Series, SonarrCommandBody, SonarrHistoryItem, + SonarrHistoryWrapper, SonarrRelease, SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, + SonarrTaskName, SystemStatus, }, stateful_table::StatefulTable, HorizontallyScrollableText, Route, Scrollable, ScrollableText, @@ -62,6 +62,7 @@ pub enum SonarrEvent { GetIndexers, GetEpisodeDetails(Option), GetEpisodes(Option), + GetEpisodeFiles(Option), GetEpisodeHistory(Option), GetLanguageProfiles, GetLogs(Option), @@ -104,7 +105,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetAllIndexerSettings | SonarrEvent::EditAllIndexerSettings(_) => { "/config/indexer" } - SonarrEvent::DeleteEpisodeFile(_) => "/episodefile", + SonarrEvent::GetEpisodeFiles(_) | SonarrEvent::DeleteEpisodeFile(_) => "/episodefile", SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", SonarrEvent::GetDownloads | SonarrEvent::DeleteDownload(_) => "/queue", SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", @@ -225,6 +226,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_episodes(series_id) .await .map(SonarrSerdeable::from), + SonarrEvent::GetEpisodeFiles(series_id) => self + .get_episode_files(series_id) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetEpisodeDetails(episode_id) => self .get_episode_details(episode_id) .await @@ -1420,6 +1425,39 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_episode_files(&mut self, series_id: Option) -> Result> { + let event = SonarrEvent::GetEpisodeFiles(series_id); + let (id, series_id_param) = self.extract_series_id(series_id).await; + info!("Fetching episodes files for Sonarr series with ID: {id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(series_id_param), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |episode_file_vec, mut app| { + if app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_files + .set_items(episode_file_vec); + }) + .await + } + async fn get_sonarr_episode_history( &mut self, episode_id: Option, diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index dd1e27b..42db1df 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -108,6 +108,7 @@ mod test { "airDateUtc": "2024-02-10T07:28:45Z", "overview": "Okay so this one time at band camp...", "episodeFile": { + "id": 1, "relativePath": "/season 1/episode 1.mkv", "path": "/nfs/tv/series/season 1/episode 1.mkv", "size": 3543348019, @@ -265,10 +266,20 @@ mod test { assert_str_eq!(event.resource(), "/release"); } + #[rstest] + fn test_resource_episode_file( + #[values( + SonarrEvent::GetEpisodeFiles(None), + SonarrEvent::DeleteEpisodeFile(None) + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/episodefile"); + } + #[rstest] #[case(SonarrEvent::ClearBlocklist, "/blocklist/bulk")] #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] - #[case(SonarrEvent::DeleteEpisodeFile(None), "/episodefile")] #[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] #[case(SonarrEvent::GetDiskSpace, "/diskspace")] @@ -2589,6 +2600,162 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_episode_files_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([episode_file()])), + None, + SonarrEvent::GetEpisodeFiles(None), + None, + Some("seriesId=1"), + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::EpisodeFiles(episode_files) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeFiles(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_files + .items, + vec![episode_file()] + ); + assert_eq!(episode_files, vec![episode_file()]); + } + } + + #[tokio::test] + async fn test_handle_get_episode_files_event_empty_season_details_modal() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([episode_file()])), + None, + SonarrEvent::GetEpisodeFiles(None), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::EpisodeFiles(episode_files) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeFiles(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_files + .items, + vec![episode_file()] + ); + assert_eq!(episode_files, vec![episode_file()]); + } + } + + #[tokio::test] + async fn test_handle_get_episode_files_event_uses_provided_series_id() { + let episode_file = EpisodeFile { + id: 2, + ..episode_file() + }; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([episode_file.clone()])), + None, + SonarrEvent::GetEpisodeFiles(Some(2)), + None, + Some("seriesId=2"), + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::EpisodeFiles(episode_files) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeFiles(Some(2))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_files + .items, + vec![episode_file.clone()] + ); + assert_eq!(episode_files, vec![episode_file]); + } + } + #[tokio::test] async fn test_handle_get_sonarr_host_config_event() { let host_config_response = json!({ @@ -7104,9 +7271,11 @@ mod test { fn episode_file() -> EpisodeFile { EpisodeFile { + id: 1, relative_path: "/season 1/episode 1.mkv".to_owned(), path: "/nfs/tv/series/season 1/episode 1.mkv".to_owned(), size: 3543348019, + quality: quality_wrapper(), language: language(), date_added: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), media_info: Some(media_info()), diff --git a/src/ui/sonarr_ui/library/season_details_ui.rs b/src/ui/sonarr_ui/library/season_details_ui.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/sonarr_ui/library/season_details_ui_tests.rs b/src/ui/sonarr_ui/library/season_details_ui_tests.rs new file mode 100644 index 0000000..e69de29 From cbad40245f92ad013c4e398d98a71bfff33e34c2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 10 Dec 2024 16:23:30 -0700 Subject: [PATCH 59/82] feat(app): Dispatch support for Season Details to fetch both the current downloads as well as the episode files to match qualities to them --- src/app/sonarr/mod.rs | 6 ++++++ src/app/sonarr/sonarr_tests.rs | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index 7002b8e..74dcd1e 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -33,6 +33,12 @@ impl<'a> App<'a> { self .dispatch_network_event(SonarrEvent::GetEpisodes(None).into()) .await; + self + .dispatch_network_event(SonarrEvent::GetEpisodeFiles(None).into()) + .await; + self + .dispatch_network_event(SonarrEvent::GetDownloads.into()) + .await; } ActiveSonarrBlock::SeasonHistory => { self diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index 91dfe63..7e688e6 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -83,6 +83,14 @@ mod tests { sync_network_rx.recv().await.unwrap(), SonarrEvent::GetEpisodes(None).into() ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetEpisodeFiles(None).into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDownloads.into() + ); assert!(!app.data.sonarr_data.prompt_confirm); assert_eq!(app.tick_count, 0); } From 7bf331110231ce3877af5ff82b61e4ef4d76e252 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 10 Dec 2024 16:32:35 -0700 Subject: [PATCH 60/82] feat(cli): Sonarr support for fetching a list of all episode files for a given series ID --- src/cli/sonarr/list_command_handler.rs | 16 ++++++ src/cli/sonarr/list_command_handler_tests.rs | 59 ++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index 7bc46ff..1603308 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -33,6 +33,15 @@ pub enum SonarrListCommand { )] series_id: i64, }, + #[command(about = "List the episode files for the series with the given ID")] + EpisodeFiles { + #[arg( + long, + help = "The Sonarr ID of the series whose episode files you wish to fetch", + required = true + )] + series_id: i64, + }, #[command(about = "Fetch all history events for the episode with the given ID")] EpisodeHistory { #[arg( @@ -158,6 +167,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH .await?; serde_json::to_string_pretty(&resp)? } + SonarrListCommand::EpisodeFiles { series_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetEpisodeFiles(Some(series_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } SonarrListCommand::EpisodeHistory { episode_id } => { let resp = self .network diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index 54cc3b0..c333193 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -57,6 +57,18 @@ mod tests { ); } + #[test] + fn test_list_episode_files_requires_series_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "episode-files"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + #[test] fn test_list_episode_history_requires_series_id() { let result = @@ -149,6 +161,27 @@ mod tests { } } + #[test] + fn test_list_episode_files_success() { + let expected_args = SonarrListCommand::EpisodeFiles { series_id: 1 }; + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "list", + "episode-files", + "--series-id", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::List(episode_files_command))) = + result.unwrap().command + { + assert_eq!(episode_files_command, expected_args); + } + } + #[test] fn test_season_history_requires_series_id() { let result = Cli::command().try_get_matches_from([ @@ -315,6 +348,32 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_handle_list_episode_files_command() { + let expected_series_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetEpisodeFiles(Some(expected_series_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let list_episode_files_command = SonarrListCommand::EpisodeFiles { series_id: 1 }; + + let result = + SonarrListCommandHandler::with(&app_arc, list_episode_files_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_handle_list_history_command() { let expected_events = 1000; From e9a30382a343cb4f888f4e45eff35508beaabf1f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 10 Dec 2024 18:23:09 -0700 Subject: [PATCH 61/82] feat(ui): Sonarr support for viewing season details --- src/models/sonarr_models.rs | 53 +- src/models/sonarr_models_tests.rs | 42 +- src/network/sonarr_network.rs | 6 +- src/network/sonarr_network_tests.rs | 11 +- src/ui/radarr_ui/library/movie_details_ui.rs | 14 +- .../library/movie_details_ui_tests.rs | 36 +- src/ui/sonarr_ui/library/library_ui_tests.rs | 9 +- src/ui/sonarr_ui/library/mod.rs | 6 +- src/ui/sonarr_ui/library/season_details_ui.rs | 535 ++++++++++++++++++ .../library/season_details_ui_tests.rs | 21 + src/ui/sonarr_ui/library/series_details_ui.rs | 35 +- .../library/series_details_ui_tests.rs | 9 +- src/ui/utils.rs | 10 + src/ui/utils_tests.rs | 42 +- 14 files changed, 737 insertions(+), 92 deletions(-) diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 7c23e6d..791b002 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -107,7 +107,7 @@ pub struct DeleteSeriesParams { #[serde(rename_all = "camelCase")] pub struct DownloadRecord { pub title: String, - pub status: String, + pub status: DownloadStatus, #[serde(deserialize_with = "super::from_i64")] pub id: i64, #[serde(deserialize_with = "super::from_i64")] @@ -124,6 +124,57 @@ pub struct DownloadRecord { impl Eq for DownloadRecord {} +#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, EnumIter)] +#[serde(rename_all = "camelCase")] +pub enum DownloadStatus { + #[default] + Unknown, + Queued, + Paused, + Downloading, + Completed, + Failed, + Warning, + Delay, + DownloadClientUnavailable, + Fallback, +} + +impl Display for DownloadStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let download_status = match self { + DownloadStatus::Unknown => "unknown", + DownloadStatus::Queued => "queued", + DownloadStatus::Paused => "paused", + DownloadStatus::Downloading => "downloading", + DownloadStatus::Completed => "completed", + DownloadStatus::Failed => "failed", + DownloadStatus::Warning => "warning", + DownloadStatus::Delay => "delay", + DownloadStatus::DownloadClientUnavailable => "downloadClientUnavailable", + DownloadStatus::Fallback => "fallback", + }; + write!(f, "{download_status}") + } +} + +impl<'a> EnumDisplayStyle<'a> for DownloadStatus { + fn to_display_str(self) -> &'a str { + match self { + DownloadStatus::Unknown => "Unknown", + DownloadStatus::Queued => "Queued", + DownloadStatus::Paused => "Paused", + DownloadStatus::Downloading => "Downloading", + DownloadStatus::Completed => "Completed", + DownloadStatus::Failed => "Failed", + DownloadStatus::Warning => "Warning", + DownloadStatus::Delay => "Delay", + DownloadStatus::DownloadClientUnavailable => "Download Client Unavailable", + DownloadStatus::Fallback => "Fallback", + } + } +} + #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DownloadsResponse { diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 2ba98eb..ff55030 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -10,10 +10,10 @@ mod tests { RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ - AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, - Episode, EpisodeFile, IndexerSettings, Series, SeriesMonitor, SeriesStatus, SeriesType, - SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, SonarrSerdeable, SonarrTask, - SonarrTaskName, SystemStatus, + AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadStatus, + DownloadsResponse, Episode, EpisodeFile, IndexerSettings, Series, SeriesMonitor, + SeriesStatus, SeriesType, SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, + SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, }, EnumDisplayStyle, Serdeable, }; @@ -119,6 +119,40 @@ mod tests { assert_str_eq!(SeriesType::Anime.to_display_str(), "Anime"); } + #[test] + fn test_download_status_display() { + assert_str_eq!(DownloadStatus::Unknown.to_string(), "unknown"); + assert_str_eq!(DownloadStatus::Queued.to_string(), "queued"); + assert_str_eq!(DownloadStatus::Paused.to_string(), "paused"); + assert_str_eq!(DownloadStatus::Downloading.to_string(), "downloading"); + assert_str_eq!(DownloadStatus::Completed.to_string(), "completed"); + assert_str_eq!(DownloadStatus::Failed.to_string(), "failed"); + assert_str_eq!(DownloadStatus::Warning.to_string(), "warning"); + assert_str_eq!(DownloadStatus::Delay.to_string(), "delay"); + assert_str_eq!( + DownloadStatus::DownloadClientUnavailable.to_string(), + "downloadClientUnavailable" + ); + assert_str_eq!(DownloadStatus::Fallback.to_string(), "fallback"); + } + + #[test] + fn test_download_status_to_display_str() { + assert_str_eq!(DownloadStatus::Unknown.to_display_str(), "Unknown"); + assert_str_eq!(DownloadStatus::Queued.to_display_str(), "Queued"); + assert_str_eq!(DownloadStatus::Paused.to_display_str(), "Paused"); + assert_str_eq!(DownloadStatus::Downloading.to_display_str(), "Downloading"); + assert_str_eq!(DownloadStatus::Completed.to_display_str(), "Completed"); + assert_str_eq!(DownloadStatus::Failed.to_display_str(), "Failed"); + assert_str_eq!(DownloadStatus::Warning.to_display_str(), "Warning"); + assert_str_eq!(DownloadStatus::Delay.to_display_str(), "Delay"); + assert_str_eq!( + DownloadStatus::DownloadClientUnavailable.to_display_str(), + "Download Client Unavailable" + ); + assert_str_eq!(DownloadStatus::Fallback.to_display_str(), "Fallback"); + } + #[test] fn test_sonarr_history_event_type_display() { assert_str_eq!(SonarrHistoryEventType::Unknown.to_string(), "unknown",); diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 48e75b1..2c8ad65 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -31,7 +31,7 @@ use crate::{ network::RequestMethod, utils::convert_to_gb, }; - +use crate::models::sonarr_models::DownloadStatus; use super::{Network, NetworkEvent, NetworkResource}; #[cfg(test)] #[path = "sonarr_network_tests.rs"] @@ -2642,11 +2642,11 @@ fn get_episode_status(has_file: bool, downloads_vec: &[DownloadRecord], episode_ .iter() .find(|&download| download.episode_id == episode_id) { - if download.status == "downloading" { + if download.status == DownloadStatus::Downloading { return "Downloading".to_owned(); } - if download.status == "completed" { + if download.status == DownloadStatus::Completed { return "Awaiting Import".to_owned(); } } diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 42db1df..b87ebda 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -15,10 +15,7 @@ mod test { use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; - use crate::models::sonarr_models::{ - AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, - EditSeriesParams, IndexerSettings, SeriesMonitor, SonarrHistoryEventType, - }; + use crate::models::sonarr_models::{AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, DownloadStatus, EditSeriesParams, IndexerSettings, SeriesMonitor, SonarrHistoryEventType}; use crate::app::{App, ServarrConfig}; use crate::models::radarr_models::IndexerTestResult; @@ -7167,7 +7164,7 @@ mod test { false, &[DownloadRecord { episode_id: 1, - status: "downloading".to_owned(), + status: DownloadStatus::Downloading, ..DownloadRecord::default() }], 1 @@ -7183,7 +7180,7 @@ mod test { false, &[DownloadRecord { episode_id: 1, - status: "completed".to_owned(), + status: DownloadStatus::Completed, ..DownloadRecord::default() }], 1 @@ -7231,7 +7228,7 @@ mod test { fn download_record() -> DownloadRecord { DownloadRecord { title: "Test Download Title".to_owned(), - status: "downloading".to_owned(), + status: DownloadStatus::Downloading, id: 1, episode_id: 1, size: 3543348019f64, diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index 6ef4fc6..7cfe147 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -13,9 +13,7 @@ use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_ use crate::models::Route; use crate::ui::radarr_ui::library::draw_library; use crate::ui::styles::ManagarrStyle; -use crate::ui::utils::{ - borderless_block, get_width_from_percentage, layout_block_bottom_border, layout_block_top_border, -}; +use crate::ui::utils::{borderless_block, decorate_peer_style, get_width_from_percentage, layout_block_bottom_border, layout_block_top_border}; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::managarr_table::ManagarrTable; @@ -555,13 +553,3 @@ fn style_from_download_status(download_status: &str, is_monitored: bool, status: _ => Style::new().downloaded(), } } - -fn decorate_peer_style(seeders: u64, leechers: u64, text: Text<'_>) -> Text<'_> { - if seeders == 0 { - text.failure() - } else if seeders < leechers { - text.warning() - } else { - text.success() - } -} diff --git a/src/ui/radarr_ui/library/movie_details_ui_tests.rs b/src/ui/radarr_ui/library/movie_details_ui_tests.rs index 485594b..18aa2bc 100644 --- a/src/ui/radarr_ui/library/movie_details_ui_tests.rs +++ b/src/ui/radarr_ui/library/movie_details_ui_tests.rs @@ -2,13 +2,11 @@ mod tests { use pretty_assertions::assert_eq; use ratatui::style::Style; - use ratatui::text::Text; use rstest::rstest; use strum::IntoEnumIterator; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; - use crate::ui::radarr_ui::library::movie_details_ui::{ - decorate_peer_style, style_from_download_status, MovieDetailsUi, + use crate::ui::radarr_ui::library::movie_details_ui::{style_from_download_status, MovieDetailsUi, }; use crate::ui::styles::ManagarrStyle; use crate::ui::DrawUi; @@ -43,36 +41,4 @@ mod tests { expected_style ); } - - #[rstest] - #[case(0, 0, PeerStyle::Failure)] - #[case(1, 2, PeerStyle::Warning)] - #[case(4, 2, PeerStyle::Success)] - fn test_decorate_peer_style( - #[case] seeders: u64, - #[case] leechers: u64, - #[case] expected_style: PeerStyle, - ) { - let text = Text::from("test"); - match expected_style { - PeerStyle::Failure => assert_eq!( - decorate_peer_style(seeders, leechers, text.clone()), - text.failure() - ), - PeerStyle::Warning => assert_eq!( - decorate_peer_style(seeders, leechers, text.clone()), - text.warning() - ), - PeerStyle::Success => assert_eq!( - decorate_peer_style(seeders, leechers, text.clone()), - text.success() - ), - } - } - - enum PeerStyle { - Failure, - Warning, - Success, - } } diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs index 88191ba..874b38f 100644 --- a/src/ui/sonarr_ui/library/library_ui_tests.rs +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -2,7 +2,7 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, - SERIES_DETAILS_BLOCKS, + SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, }; use crate::models::{ servarr_data::sonarr::sonarr_data::LIBRARY_BLOCKS, sonarr_models::SeriesStatus, @@ -16,8 +16,7 @@ mod tests { use crate::models::sonarr_models::{Season, SeasonStatistics}; use crate::{ - models::sonarr_models::Series, - ui::sonarr_ui::library::decorate_series_row_with_style, + models::sonarr_models::Series, ui::sonarr_ui::library::decorate_series_row_with_style, }; #[test] @@ -28,6 +27,7 @@ mod tests { library_ui_blocks.extend(DELETE_SERIES_BLOCKS); library_ui_blocks.extend(EDIT_SERIES_BLOCKS); library_ui_blocks.extend(SERIES_DETAILS_BLOCKS); + library_ui_blocks.extend(SEASON_DETAILS_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { if library_ui_blocks.contains(&active_sonarr_block) { @@ -39,8 +39,7 @@ mod tests { } #[test] - fn test_decorate_row_with_style_downloaded_when_ended_and_all_monitored_episodes_are_present( - ) { + fn test_decorate_row_with_style_downloaded_when_ended_and_all_monitored_episodes_are_present() { let seasons = vec![ Season { monitored: false, diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index e50a994..3cfe084 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -35,6 +35,7 @@ mod series_details_ui; #[cfg(test)] #[path = "library_ui_tests.rs"] mod library_ui_tests; +mod season_details_ui; pub(super) struct LibraryUi; @@ -80,7 +81,10 @@ impl DrawUi for LibraryUi { _ if AddSeriesUi::accepts(route) => AddSeriesUi::draw(f, app, area), _ if DeleteSeriesUi::accepts(route) => DeleteSeriesUi::draw(f, app, area), _ if EditSeriesUi::accepts(route) => EditSeriesUi::draw(f, app, area), - _ if SeriesDetailsUi::accepts(route) => SeriesDetailsUi::draw(f, app, area), + _ if SeriesDetailsUi::accepts(route) => { + draw_library(f, app, area); + SeriesDetailsUi::draw(f, app, area) + }, Route::Sonarr(active_sonarr_block, _) if LIBRARY_BLOCKS.contains(&active_sonarr_block) => { series_ui_matchers(active_sonarr_block) } diff --git a/src/ui/sonarr_ui/library/season_details_ui.rs b/src/ui/sonarr_ui/library/season_details_ui.rs index e69de29..8b5765a 100644 --- a/src/ui/sonarr_ui/library/season_details_ui.rs +++ b/src/ui/sonarr_ui/library/season_details_ui.rs @@ -0,0 +1,535 @@ +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SEASON_DETAILS_BLOCKS}; +use crate::models::sonarr_models::{ + DownloadRecord, DownloadStatus, Episode, SonarrHistoryItem, SonarrRelease, +}; +use crate::models::Route; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{ + borderless_block, decorate_peer_style, get_width_from_percentage, layout_block_top_border, +}; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::{draw_popup, draw_tabs, DrawUi}; +use crate::utils::convert_to_gb; +use chrono::Utc; +use ratatui::layout::{Constraint, Rect}; +use ratatui::prelude::{Line, Stylize, Text}; +use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; +use ratatui::Frame; + +#[cfg(test)] +#[path = "season_details_ui_tests.rs"] +mod season_details_ui_tests; + +pub(super) struct SeasonDetailsUi; + +impl DrawUi for SeasonDetailsUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return SEASON_DETAILS_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if app.data.sonarr_data.season_details_modal.is_some() { + if let Route::Sonarr(active_sonarr_block, _) = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_details_tabs + .get_active_route() + { + let draw_season_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + let content_area = draw_tabs( + f, + popup_area, + "Season Details", + &app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_details_tabs, + ); + draw_season_details(f, app, content_area); + + match active_sonarr_block { + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt => { + let prompt = format!( + "Do you want to trigger an automatic search of your indexers for season packs for the season: Season {}", + app.data.sonarr_data.seasons.current_selection().season_number + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Automatic Season Search") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveSonarrBlock::DeleteEpisodeFilePrompt => { + let prompt = format!( + "Do you really want to delete this episode: \n{}?", + app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .current_selection() + .title + .as_ref() + .unwrap_or(&String::new()) + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Episode") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt => { + draw_manual_season_search_confirm_prompt(f, app); + } + _ => (), + } + }; + + draw_popup(f, app, draw_season_details_popup, Size::Large); + } + } + } +} + +pub fn draw_season_details(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Some(season_details_modal) = app.data.sonarr_data.season_details_modal.as_ref() { + if let Route::Sonarr(active_sonarr_block, _) = + season_details_modal.season_details_tabs.get_active_route() + { + match active_sonarr_block { + ActiveSonarrBlock::SeasonDetails => draw_episodes_table(f, app, area), + ActiveSonarrBlock::SeasonHistory => draw_episode_history_table(f, app, area), + ActiveSonarrBlock::ManualSeasonSearch => draw_season_releases(f, app, area), + _ => (), + } + } + } +} + +fn draw_episodes_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let help_footer = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .expect("Season details modal is unpopulated") + .season_details_tabs + .get_active_tab_contextual_help(); + let episode_files = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .expect("Season details modal is unpopulated") + .episode_files + .items + .clone(); + let content = Some( + &mut app + .data + .sonarr_data + .season_details_modal + .as_mut() + .expect("Season details modal is unpopulated") + .episodes, + ); + let downloads_vec = &app.data.sonarr_data.downloads.items; + + let episode_row_mapping = |episode: &Episode| { + let Episode { + episode_number, + title, + air_date_utc, + episode_file_id, + .. + } = episode; + let episode_file = episode_files + .iter() + .find(|episode_file| episode_file.id == *episode_file_id); + let (quality_profile, size_on_disk) = if let Some(episode_file) = episode_file { + ( + episode_file.quality.quality.name.to_owned(), + episode_file.size, + ) + } else { + (String::new(), 0) + }; + + let episode_monitored = if episode.monitored { "🏷" } else { "" }; + let size = convert_to_gb(size_on_disk); + let air_date = if let Some(air_date) = air_date_utc.as_ref() { + air_date.to_string() + } else { + String::new() + }; + + decorate_with_row_style( + downloads_vec, + episode, + Row::new(vec![ + Cell::from(episode_monitored.to_owned()), + Cell::from(episode_number.to_string()), + Cell::from(title.clone().unwrap_or_default()), + Cell::from(air_date), + Cell::from(format!("{size:.2} GB")), + Cell::from(quality_profile), + ]), + ) + }; + let is_searching = active_sonarr_block == ActiveSonarrBlock::SearchSeason; + let season_table = ManagarrTable::new(content, episode_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(help_footer) + .searching(active_sonarr_block == ActiveSonarrBlock::SearchSeason) + .search_produced_empty_results(active_sonarr_block == ActiveSonarrBlock::SearchSeasonError) + .headers([ + "🏷", + "#", + "Title", + "Air Date", + "Size on Disk", + "Quality Profile", + ]) + .constraints([ + Constraint::Percentage(4), + Constraint::Percentage(4), + Constraint::Percentage(50), + Constraint::Percentage(19), + Constraint::Percentage(10), + Constraint::Percentage(12), + ]); + + if is_searching { + season_table.show_cursor(f, area); + } + + f.render_widget(season_table, area); + } +} + +fn draw_episode_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + match app.data.sonarr_data.season_details_modal.as_ref() { + Some(season_details_modal) if !app.is_loading => { + let current_selection = if season_details_modal.season_history.is_empty() { + SonarrHistoryItem::default() + } else { + season_details_modal + .season_history + .current_selection() + .clone() + }; + let season_history_table_footer = season_details_modal + .season_details_tabs + .get_active_tab_contextual_help(); + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let history_row_mapping = |history_item: &SonarrHistoryItem| { + let SonarrHistoryItem { + source_title, + language, + quality, + event_type, + date, + .. + } = history_item; + + source_title.scroll_left_or_reset( + get_width_from_percentage(area, 40), + current_selection == *history_item, + app.tick_count % app.ticks_until_scroll == 0, + ); + + Row::new(vec![ + Cell::from(source_title.to_string()), + Cell::from(event_type.to_string()), + Cell::from(language.name.to_owned()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let mut season_history_table = &mut app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_history; + let history_table = + ManagarrTable::new(Some(&mut season_history_table), history_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(season_history_table_footer) + .sorting(active_sonarr_block == ActiveSonarrBlock::SeasonHistorySortPrompt) + .searching(active_sonarr_block == ActiveSonarrBlock::SearchSeasonHistory) + .search_produced_empty_results( + active_sonarr_block == ActiveSonarrBlock::SearchSeasonHistoryError, + ) + .filtering(active_sonarr_block == ActiveSonarrBlock::FilterSeasonHistory) + .filter_produced_empty_results( + active_sonarr_block == ActiveSonarrBlock::FilterSeasonHistoryError, + ) + .headers(["Source Title", "Event Type", "Language", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(40), + Constraint::Percentage(15), + Constraint::Percentage(12), + Constraint::Percentage(13), + Constraint::Percentage(20), + ]); + + if [ + ActiveSonarrBlock::SearchSeriesHistory, + ActiveSonarrBlock::FilterSeriesHistory, + ] + .contains(&active_sonarr_block) + { + history_table.show_cursor(f, area); + } + + f.render_widget(history_table, area); + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.sonarr_data.season_details_modal.is_none(), + layout_block_top_border(), + ), + area, + ), + } +} + +fn draw_season_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + match app.data.sonarr_data.season_details_modal.as_ref() { + Some(season_details_modal) if !app.is_loading => { + let current_selection = if season_details_modal.season_releases.is_empty() { + SonarrRelease::default() + } else { + season_details_modal + .season_releases + .current_selection() + .clone() + }; + let season_release_table_footer = season_details_modal + .season_details_tabs + .get_active_tab_contextual_help(); + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let season_release_row_mapping = |release: &SonarrRelease| { + let SonarrRelease { + protocol, + age, + title, + indexer, + size, + rejected, + seeders, + leechers, + languages, + quality, + .. + } = release; + + let age = format!("{age} days"); + title.scroll_left_or_reset( + get_width_from_percentage(area, 30), + current_selection == *release + && active_sonarr_block != ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt, + app.tick_count % app.ticks_until_scroll == 0, + ); + let size = convert_to_gb(*size); + let rejected_str = if *rejected { "⛔" } else { "" }; + let peers = if seeders.is_none() || leechers.is_none() { + Text::from("") + } else { + let seeders = seeders.clone().unwrap().as_u64().unwrap(); + let leechers = leechers.clone().unwrap().as_u64().unwrap(); + + decorate_peer_style( + seeders, + leechers, + Text::from(format!("{seeders} / {leechers}")), + ) + }; + + let language = if languages.is_some() { + languages.clone().unwrap()[0].name.clone() + } else { + String::new() + }; + let quality = quality.quality.name.clone(); + + Row::new(vec![ + Cell::from(protocol.clone()), + Cell::from(age), + Cell::from(rejected_str), + Cell::from(title.to_string()), + Cell::from(indexer.clone()), + Cell::from(format!("{size:.1} GB")), + Cell::from(peers), + Cell::from(language), + Cell::from(quality), + ]) + .primary() + }; + let mut season_release_table = &mut app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_releases; + let release_table = + ManagarrTable::new(Some(&mut season_release_table), season_release_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(season_release_table_footer) + .sorting(active_sonarr_block == ActiveSonarrBlock::ManualSeasonSearchSortPrompt) + .headers(["Source Title", "Event Type", "Language", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(40), + Constraint::Percentage(15), + Constraint::Percentage(12), + Constraint::Percentage(13), + Constraint::Percentage(20), + ]); + + f.render_widget(release_table, area); + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.sonarr_data.season_details_modal.is_none(), + layout_block_top_border(), + ), + area, + ), + } +} + +fn draw_manual_season_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_releases + .current_selection(); + let title = if current_selection.rejected { + "Download Rejected Release" + } else { + "Download Release" + }; + let prompt = if current_selection.rejected { + format!( + "Do you really want to download the rejected release: {}?", + ¤t_selection.title.text + ) + } else { + format!( + "Do you want to download the release: {}?", + ¤t_selection.title.text + ) + }; + + if current_selection.rejected { + let mut lines_vec = vec![Line::from("Rejection reasons: ".primary().bold())]; + let mut rejections_spans = current_selection + .rejections + .clone() + .unwrap_or_default() + .iter() + .map(|item| Line::from(format!("• {item}").primary().bold())) + .collect::>>(); + lines_vec.append(&mut rejections_spans); + + let content_paragraph = Paragraph::new(lines_vec) + .block(borderless_block()) + .wrap(Wrap { trim: false }) + .left_aligned(); + let confirmation_prompt = ConfirmationPrompt::new() + .title(title) + .prompt(&prompt) + .content(content_paragraph) + .yes_no_value(app.data.radarr_data.prompt_confirm); + + f.render_widget(Popup::new(confirmation_prompt).size(Size::Small), f.area()); + } else { + let confirmation_prompt = ConfirmationPrompt::new() + .title(title) + .prompt(&prompt) + .yes_no_value(app.data.radarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } +} + +fn decorate_with_row_style<'a>( + downloads_vec: &[DownloadRecord], + episode: &Episode, + row: Row<'a>, +) -> Row<'a> { + if !episode.has_file { + if let Some(download) = downloads_vec + .iter() + .find(|&download| download.episode_id == episode.id) + { + if download.status == DownloadStatus::Downloading { + return row.downloading(); + } + + if download.status == DownloadStatus::Completed { + return row.awaiting_import(); + } + } + + if !episode.monitored { + return row.unmonitored_missing(); + } + + if let Some(air_date) = episode.air_date_utc.as_ref() { + if air_date > &Utc::now() { + return row.unreleased(); + } + } + + return row.missing(); + } + + if !episode.monitored { + row.unmonitored() + } else { + row.downloaded() + } +} diff --git a/src/ui/sonarr_ui/library/season_details_ui_tests.rs b/src/ui/sonarr_ui/library/season_details_ui_tests.rs index e69de29..64264fc 100644 --- a/src/ui/sonarr_ui/library/season_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/season_details_ui_tests.rs @@ -0,0 +1,21 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, SEASON_DETAILS_BLOCKS, + }; + use crate::ui::sonarr_ui::library::season_details_ui::SeasonDetailsUi; + use crate::ui::DrawUi; + + #[test] + fn test_season_details_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if SEASON_DETAILS_BLOCKS.contains(&active_sonarr_block) { + assert!(SeasonDetailsUi::accepts(active_sonarr_block.into())); + } else { + assert!(!SeasonDetailsUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index 63d6ca5..40dc905 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -28,7 +28,8 @@ use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; -use crate::ui::{draw_popup_over, draw_tabs, DrawUi}; +use crate::ui::{draw_popup, draw_popup_over, draw_tabs, DrawUi}; +use crate::ui::sonarr_ui::library::season_details_ui::SeasonDetailsUi; use crate::utils::convert_to_gb; use super::draw_library; @@ -42,14 +43,15 @@ pub(super) struct SeriesDetailsUi; impl DrawUi for SeriesDetailsUi { fn accepts(route: Route) -> bool { if let Route::Sonarr(active_sonarr_block, _) = route { - return SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block); + return SeasonDetailsUi::accepts(route) || SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block); } false } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let route = app.get_current_route(); + if let Route::Sonarr(active_sonarr_block, _) = route { let draw_series_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { f.render_widget( title_block(&app.data.sonarr_data.series.current_selection().title.text), @@ -105,14 +107,23 @@ impl DrawUi for SeriesDetailsUi { }; }; - draw_popup_over( - f, - app, - area, - draw_library, - draw_series_details_popup, - Size::XXLarge, - ); + match route { + _ if SeasonDetailsUi::accepts(route) => { + draw_popup(f, app, draw_series_details_popup, Size::XXLarge); + SeasonDetailsUi::draw(f, app, area); + }, + Route::Sonarr(active_sonarr_block, _) if SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block) => { + draw_popup_over( + f, + app, + area, + draw_library, + draw_series_details_popup, + Size::XXLarge, + ); + } + _ => (), + } } } } @@ -364,7 +375,7 @@ fn draw_series_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } _ => f.render_widget( LoadingBlock::new( - app.is_loading || app.data.radarr_data.movie_details_modal.is_none(), + app.is_loading || app.data.sonarr_data.seasons.is_empty(), layout_block_top_border(), ), area, diff --git a/src/ui/sonarr_ui/library/series_details_ui_tests.rs b/src/ui/sonarr_ui/library/series_details_ui_tests.rs index 7dd2f8d..0dc52da 100644 --- a/src/ui/sonarr_ui/library/series_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/series_details_ui_tests.rs @@ -2,16 +2,17 @@ mod tests { use strum::IntoEnumIterator; - use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, SERIES_DETAILS_BLOCKS, - }; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS}; use crate::ui::sonarr_ui::library::series_details_ui::SeriesDetailsUi; use crate::ui::DrawUi; #[test] fn test_series_details_ui_accepts() { + let mut blocks = SERIES_DETAILS_BLOCKS.clone().to_vec(); + blocks.extend(SEASON_DETAILS_BLOCKS); + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { - if SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block) { + if blocks.contains(&active_sonarr_block) { assert!(SeriesDetailsUi::accepts(active_sonarr_block.into())); } else { assert!(!SeriesDetailsUi::accepts(active_sonarr_block.into())); diff --git a/src/ui/utils.rs b/src/ui/utils.rs index 3b206db..3a7aba9 100644 --- a/src/ui/utils.rs +++ b/src/ui/utils.rs @@ -154,3 +154,13 @@ pub(super) fn convert_to_minutes_hours_days(time: i64) -> String { } } } + +pub(super) fn decorate_peer_style(seeders: u64, leechers: u64, text: Text<'_>) -> Text<'_> { + if seeders == 0 { + text.failure() + } else if seeders < leechers { + text.warning() + } else { + text.success() + } +} diff --git a/src/ui/utils_tests.rs b/src/ui/utils_tests.rs index 47ab277..f0b2e17 100644 --- a/src/ui/utils_tests.rs +++ b/src/ui/utils_tests.rs @@ -5,13 +5,8 @@ mod test { use ratatui::style::{Color, Modifier, Style, Stylize}; use ratatui::text::{Span, Text}; use ratatui::widgets::{Block, BorderType, Borders, ListItem}; - - use crate::ui::utils::{ - borderless_block, centered_rect, convert_to_minutes_hours_days, get_width_from_percentage, - layout_block, layout_block_bottom_border, layout_block_top_border, - layout_block_top_border_with_title, layout_block_with_title, logo_block, style_block_highlight, - style_log_list_item, title_block, title_block_centered, title_style, - }; + use rstest::rstest; + use crate::ui::utils::{borderless_block, centered_rect, convert_to_minutes_hours_days, decorate_peer_style, get_width_from_percentage, layout_block, layout_block_bottom_border, layout_block_top_border, layout_block_top_border_with_title, layout_block_with_title, logo_block, style_block_highlight, style_log_list_item, title_block, title_block_centered, title_style}; #[test] fn test_layout_block() { @@ -238,6 +233,39 @@ mod test { assert_str_eq!(convert_to_minutes_hours_days(2880), "2 days"); } + #[rstest] + #[case(0, 0, PeerStyle::Failure)] + #[case(1, 2, PeerStyle::Warning)] + #[case(4, 2, PeerStyle::Success)] + fn test_decorate_peer_style( + #[case] seeders: u64, + #[case] leechers: u64, + #[case] expected_style: PeerStyle, + ) { + use crate::ui::styles::ManagarrStyle; + let text = Text::from("test"); + match expected_style { + PeerStyle::Failure => assert_eq!( + decorate_peer_style(seeders, leechers, text.clone()), + text.failure() + ), + PeerStyle::Warning => assert_eq!( + decorate_peer_style(seeders, leechers, text.clone()), + text.warning() + ), + PeerStyle::Success => assert_eq!( + decorate_peer_style(seeders, leechers, text.clone()), + text.success() + ), + } + } + + enum PeerStyle { + Failure, + Warning, + Success, + } + fn rect() -> Rect { Rect { x: 0, From c09950d0af93cb63c02c9dabb038f0cf01628400 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 11 Dec 2024 15:08:52 -0700 Subject: [PATCH 62/82] refactor(ui): Simplified the popup delegation so all future UI is easier to implement --- src/app/app_tests.rs | 8 ++ src/app/radarr/mod.rs | 21 ++++ src/app/radarr/radarr_context_clues.rs | 3 +- src/app/radarr/radarr_context_clues_tests.rs | 5 + src/app/radarr/radarr_tests.rs | 38 ++++++- src/app/sonarr/mod.rs | 12 ++ src/app/sonarr/sonarr_tests.rs | 16 +++ .../collection_details_handler_tests.rs | 2 +- .../collections/collections_handler_tests.rs | 30 +++-- .../radarr_handlers/collections/mod.rs | 10 +- .../indexers/indexers_handler_tests.rs | 4 +- src/handlers/radarr_handlers/indexers/mod.rs | 2 +- .../radarr_handler_test_utils.rs | 2 +- .../indexers/indexers_handler_tests.rs | 4 +- src/handlers/sonarr_handlers/indexers/mod.rs | 2 +- src/handlers/table_handler.rs | 9 +- src/models/servarr_data/radarr/radarr_data.rs | 4 +- .../servarr_data/radarr/radarr_data_tests.rs | 2 +- src/models/servarr_data/sonarr/sonarr_data.rs | 4 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 2 +- src/network/radarr_network.rs | 10 +- src/network/radarr_network_tests.rs | 6 +- src/network/sonarr_network.rs | 4 +- src/network/sonarr_network_tests.rs | 6 +- src/ui/mod.rs | 33 ------ src/ui/radarr_ui/blocklist/mod.rs | 8 +- .../collections/collection_details_ui.rs | 41 ++----- .../collection_details_ui_tests.rs | 2 + .../collections/edit_collection_ui.rs | 67 +++++------- .../collections/edit_collection_ui_tests.rs | 4 +- src/ui/radarr_ui/collections/mod.rs | 34 ++---- src/ui/radarr_ui/downloads/mod.rs | 5 +- src/ui/radarr_ui/indexers/edit_indexer_ui.rs | 9 +- .../radarr_ui/indexers/indexer_settings_ui.rs | 9 +- src/ui/radarr_ui/indexers/mod.rs | 103 +++++++++--------- .../indexers/test_all_indexers_ui.rs | 21 ++-- src/ui/radarr_ui/library/add_movie_ui.rs | 68 ++---------- src/ui/radarr_ui/library/delete_movie_ui.rs | 4 +- src/ui/radarr_ui/library/edit_movie_ui.rs | 54 +++------ src/ui/radarr_ui/library/mod.rs | 33 ++---- src/ui/radarr_ui/library/movie_details_ui.rs | 9 +- src/ui/radarr_ui/root_folders/mod.rs | 10 +- src/ui/radarr_ui/system/mod.rs | 13 +-- src/ui/radarr_ui/system/system_details_ui.rs | 16 +-- src/ui/sonarr_ui/blocklist/mod.rs | 8 +- src/ui/sonarr_ui/downloads/mod.rs | 5 +- src/ui/sonarr_ui/history/mod.rs | 16 +-- src/ui/sonarr_ui/indexers/edit_indexer_ui.rs | 9 +- .../sonarr_ui/indexers/indexer_settings_ui.rs | 9 +- src/ui/sonarr_ui/indexers/mod.rs | 101 +++++++++-------- .../indexers/test_all_indexers_ui.rs | 12 +- src/ui/sonarr_ui/library/add_series_ui.rs | 63 ++++------- src/ui/sonarr_ui/library/delete_series_ui.rs | 6 +- src/ui/sonarr_ui/library/edit_series_ui.rs | 67 ++++-------- src/ui/sonarr_ui/library/mod.rs | 34 ++---- src/ui/sonarr_ui/library/series_details_ui.rs | 28 ++--- src/ui/sonarr_ui/root_folders/mod.rs | 10 +- src/ui/sonarr_ui/system/mod.rs | 13 +-- src/ui/sonarr_ui/system/system_details_ui.rs | 18 +-- 59 files changed, 488 insertions(+), 660 deletions(-) diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index 1e92f3b..a3902f2 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -223,6 +223,14 @@ mod tests { sync_network_rx.recv().await.unwrap(), RadarrEvent::GetStatus.into() ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetTags.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetMovies.into() diff --git a/src/app/radarr/mod.rs b/src/app/radarr/mod.rs index 4010b06..173b2c4 100644 --- a/src/app/radarr/mod.rs +++ b/src/app/radarr/mod.rs @@ -17,11 +17,23 @@ impl<'a> App<'a> { .await; } ActiveRadarrBlock::Collections => { + self + .dispatch_network_event(RadarrEvent::GetQualityProfiles.into()) + .await; self .dispatch_network_event(RadarrEvent::GetCollections.into()) .await; + self + .dispatch_network_event(RadarrEvent::GetMovies.into()) + .await; } ActiveRadarrBlock::CollectionDetails => { + self + .dispatch_network_event(RadarrEvent::GetQualityProfiles.into()) + .await; + self + .dispatch_network_event(RadarrEvent::GetTags.into()) + .await; self.is_loading = true; self.populate_movie_collection_table().await; self.is_loading = false; @@ -37,6 +49,12 @@ impl<'a> App<'a> { .await; } ActiveRadarrBlock::Movies => { + self + .dispatch_network_event(RadarrEvent::GetQualityProfiles.into()) + .await; + self + .dispatch_network_event(RadarrEvent::GetTags.into()) + .await; self .dispatch_network_event(RadarrEvent::GetMovies.into()) .await; @@ -45,6 +63,9 @@ impl<'a> App<'a> { .await; } ActiveRadarrBlock::Indexers => { + self + .dispatch_network_event(RadarrEvent::GetTags.into()) + .await; self .dispatch_network_event(RadarrEvent::GetIndexers.into()) .await; diff --git a/src/app/radarr/radarr_context_clues.rs b/src/app/radarr/radarr_context_clues.rs index 2c37e26..d35222b 100644 --- a/src/app/radarr/radarr_context_clues.rs +++ b/src/app/radarr/radarr_context_clues.rs @@ -77,7 +77,8 @@ pub static SYSTEM_TASKS_CONTEXT_CLUES: [ContextClue; 2] = [ (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; -pub static COLLECTION_DETAILS_CONTEXT_CLUES: [ContextClue; 2] = [ +pub static COLLECTION_DETAILS_CONTEXT_CLUES: [ContextClue; 3] = [ (DEFAULT_KEYBINDINGS.submit, "show overview/add movie"), + (DEFAULT_KEYBINDINGS.edit, "edit collection"), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; diff --git a/src/app/radarr/radarr_context_clues_tests.rs b/src/app/radarr/radarr_context_clues_tests.rs index d8ca11b..91c2528 100644 --- a/src/app/radarr/radarr_context_clues_tests.rs +++ b/src/app/radarr/radarr_context_clues_tests.rs @@ -240,6 +240,11 @@ mod tests { let (key_binding, description) = collection_details_context_clues_iter.next().unwrap(); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.edit); + assert_str_eq!(*description, "edit collection"); + + let (key_binding, description) = collection_details_context_clues_iter.next().unwrap(); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc); assert_eq!(collection_details_context_clues_iter.next(), None); diff --git a/src/app/radarr/radarr_tests.rs b/src/app/radarr/radarr_tests.rs index e56493f..45648d7 100644 --- a/src/app/radarr/radarr_tests.rs +++ b/src/app/radarr/radarr_tests.rs @@ -38,17 +38,25 @@ mod tests { .await; assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetQualityProfiles.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetCollections.into() ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetMovies.into() + ); assert!(!app.data.radarr_data.prompt_confirm); assert_eq!(app.tick_count, 0); } #[tokio::test] async fn test_dispatch_by_collection_details_block() { - let (mut app, _) = construct_app_unit(); + let (mut app, mut sync_network_rx) = construct_app_unit(); app.data.radarr_data.collections.set_items(vec![Collection { movies: Some(vec![CollectionMovie::default()]), @@ -60,6 +68,14 @@ mod tests { .await; assert!(!app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetTags.into() + ); assert!(!app.data.radarr_data.collection_movies.items.is_empty()); assert_eq!(app.tick_count, 0); assert!(!app.data.radarr_data.prompt_confirm); @@ -80,6 +96,14 @@ mod tests { .await; assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetTags.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::AddMovie(None).into() @@ -132,6 +156,14 @@ mod tests { .await; assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetTags.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetMovies.into() @@ -153,6 +185,10 @@ mod tests { .await; assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetTags.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetIndexers.into() diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index 74dcd1e..6e16140 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -15,6 +15,15 @@ impl<'a> App<'a> { pub(super) async fn dispatch_by_sonarr_block(&mut self, active_sonarr_block: &ActiveSonarrBlock) { match active_sonarr_block { ActiveSonarrBlock::Series => { + self + .dispatch_network_event(SonarrEvent::GetQualityProfiles.into()) + .await; + self + .dispatch_network_event(SonarrEvent::GetLanguageProfiles.into()) + .await; + self + .dispatch_network_event(SonarrEvent::GetTags.into()) + .await; self .dispatch_network_event(SonarrEvent::ListSeries.into()) .await; @@ -89,6 +98,9 @@ impl<'a> App<'a> { .await; } ActiveSonarrBlock::Indexers => { + self + .dispatch_network_event(SonarrEvent::GetTags.into()) + .await; self .dispatch_network_event(SonarrEvent::GetIndexers.into()) .await; diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index 7e688e6..79b0ccc 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -257,6 +257,18 @@ mod tests { .await; assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetLanguageProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetTags.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), SonarrEvent::ListSeries.into() @@ -274,6 +286,10 @@ mod tests { .await; assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetTags.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), SonarrEvent::GetIndexers.into() diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs b/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs index b2cbf33..294fffc 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs @@ -369,7 +369,7 @@ mod tests { test_edit_collection_key!( CollectionDetailsHandler, ActiveRadarrBlock::CollectionDetails, - ActiveRadarrBlock::CollectionDetails + Some(ActiveRadarrBlock::CollectionDetails) ); } diff --git a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs index 6a4ff25..a62a6aa 100644 --- a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs @@ -449,7 +449,11 @@ mod tests { #[test] fn test_collection_search_box_left_right_keys() { let mut app = App::default(); - app.data.radarr_data.collections.set_items(vec![Collection::default()]); + app + .data + .radarr_data + .collections + .set_items(vec![Collection::default()]); app.push_navigation_stack(ActiveRadarrBlock::SearchCollection.into()); app.data.radarr_data.collections.search = Some("Test".into()); @@ -499,7 +503,11 @@ mod tests { #[test] fn test_collection_filter_box_left_right_keys() { let mut app = App::default(); - app.data.radarr_data.collections.set_items(vec![Collection::default()]); + app + .data + .radarr_data + .collections + .set_items(vec![Collection::default()]); app.push_navigation_stack(ActiveRadarrBlock::FilterCollections.into()); app.data.radarr_data.collections.filter = Some("Test".into()); @@ -938,7 +946,11 @@ mod tests { filtered_state: Some(TableState::default()), ..StatefulTable::default() }; - app.data.radarr_data.collections.set_items(vec![Collection::default()]); + app + .data + .radarr_data + .collections + .set_items(vec![Collection::default()]); CollectionsHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); @@ -977,7 +989,11 @@ mod tests { #[test] fn test_collections_sort_prompt_block_esc() { let mut app = App::default(); - app.data.radarr_data.collections.set_items(vec![Collection::default()]); + app + .data + .radarr_data + .collections + .set_items(vec![Collection::default()]); app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); app.push_navigation_stack(ActiveRadarrBlock::CollectionsSortPrompt.into()); @@ -1174,11 +1190,7 @@ mod tests { #[test] fn test_collection_edit_key() { - test_edit_collection_key!( - CollectionsHandler, - ActiveRadarrBlock::Collections, - ActiveRadarrBlock::Collections - ); + test_edit_collection_key!(CollectionsHandler, ActiveRadarrBlock::Collections, None); } #[test] diff --git a/src/handlers/radarr_handlers/collections/mod.rs b/src/handlers/radarr_handlers/collections/mod.rs index 17f315e..f0ee1ef 100644 --- a/src/handlers/radarr_handlers/collections/mod.rs +++ b/src/handlers/radarr_handlers/collections/mod.rs @@ -151,13 +151,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' match self.active_radarr_block { ActiveRadarrBlock::Collections => match self.key { _ if key == DEFAULT_KEYBINDINGS.edit.key => { - self.app.push_navigation_stack( - ( - ActiveRadarrBlock::EditCollectionPrompt, - Some(ActiveRadarrBlock::Collections), - ) - .into(), - ); + self + .app + .push_navigation_stack(ActiveRadarrBlock::EditCollectionPrompt.into()); self.app.data.radarr_data.edit_collection_modal = Some((&self.app.data.radarr_data).into()); self.app.data.radarr_data.selected_block = diff --git a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs index 5911968..b0e9a43 100644 --- a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs @@ -424,14 +424,14 @@ mod tests { fn test_test_indexer_esc(#[values(true, false)] is_ready: bool) { let mut app = App::default(); app.is_loading = is_ready; - app.data.radarr_data.indexer_test_error = Some("test result".to_owned()); + app.data.radarr_data.indexer_test_errors = Some("test result".to_owned()); app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.push_navigation_stack(ActiveRadarrBlock::TestIndexer.into()); IndexersHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::TestIndexer, None).handle(); assert_eq!(app.get_current_route(), ActiveRadarrBlock::Indexers.into()); - assert_eq!(app.data.radarr_data.indexer_test_error, None); + assert_eq!(app.data.radarr_data.indexer_test_errors, None); } #[rstest] diff --git a/src/handlers/radarr_handlers/indexers/mod.rs b/src/handlers/radarr_handlers/indexers/mod.rs index 5fe6502..e239fc5 100644 --- a/src/handlers/radarr_handlers/indexers/mod.rs +++ b/src/handlers/radarr_handlers/indexers/mod.rs @@ -154,7 +154,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a, } ActiveRadarrBlock::TestIndexer => { self.app.pop_navigation_stack(); - self.app.data.radarr_data.indexer_test_error = None; + self.app.data.radarr_data.indexer_test_errors = None; } _ => handle_clear_errors(self.app), } diff --git a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs index 849202f..b56730e 100644 --- a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs +++ b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs @@ -141,7 +141,7 @@ mod utils { assert_eq!( app.get_current_route(), - (ActiveRadarrBlock::EditCollectionPrompt, Some($context)).into() + (ActiveRadarrBlock::EditCollectionPrompt, $context).into() ); assert_eq!( app.data.radarr_data.selected_block.get_active_block(), diff --git a/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs index c21199f..826927e 100644 --- a/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs @@ -432,14 +432,14 @@ mod tests { fn test_test_indexer_esc(#[values(true, false)] is_ready: bool) { let mut app = App::default(); app.is_loading = is_ready; - app.data.sonarr_data.indexer_test_error = Some("test result".to_owned()); + app.data.sonarr_data.indexer_test_errors = Some("test result".to_owned()); app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); app.push_navigation_stack(ActiveSonarrBlock::TestIndexer.into()); IndexersHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::TestIndexer, None).handle(); assert_eq!(app.get_current_route(), ActiveSonarrBlock::Indexers.into()); - assert_eq!(app.data.sonarr_data.indexer_test_error, None); + assert_eq!(app.data.sonarr_data.indexer_test_errors, None); } #[rstest] diff --git a/src/handlers/sonarr_handlers/indexers/mod.rs b/src/handlers/sonarr_handlers/indexers/mod.rs index 6a5329b..78baf98 100644 --- a/src/handlers/sonarr_handlers/indexers/mod.rs +++ b/src/handlers/sonarr_handlers/indexers/mod.rs @@ -154,7 +154,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for IndexersHandler<'a, } ActiveSonarrBlock::TestIndexer => { self.app.pop_navigation_stack(); - self.app.data.sonarr_data.indexer_test_error = None; + self.app.data.sonarr_data.indexer_test_errors = None; } _ => handle_clear_errors(self.app), } diff --git a/src/handlers/table_handler.rs b/src/handlers/table_handler.rs index bd20cff..5a144ee 100644 --- a/src/handlers/table_handler.rs +++ b/src/handlers/table_handler.rs @@ -59,9 +59,12 @@ macro_rules! handle_table_events { { $self.[]() } - _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.filter.key => $self.[](props), - _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.search.key => $self.[](props), - _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.sort.key => $self.[](props), + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.filter.key + && props.filtering_block.is_some() => $self.[](props), + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.search.key + && props.searching_block.is_some() => $self.[](props), + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.sort.key + && props.sorting_block.is_some() => $self.[](props), _ => false, } } else { diff --git a/src/models/servarr_data/radarr/radarr_data.rs b/src/models/servarr_data/radarr/radarr_data.rs index 236b122..d5ae110 100644 --- a/src/models/servarr_data/radarr/radarr_data.rs +++ b/src/models/servarr_data/radarr/radarr_data.rs @@ -62,7 +62,7 @@ pub struct RadarrData<'a> { pub edit_indexer_modal: Option, pub edit_root_folder: Option, pub indexer_settings: Option, - pub indexer_test_error: Option, + pub indexer_test_errors: Option, pub indexer_test_all_results: Option>, pub movie_details_modal: Option, pub prompt_confirm: bool, @@ -112,7 +112,7 @@ impl<'a> Default for RadarrData<'a> { edit_indexer_modal: None, edit_root_folder: None, indexer_settings: None, - indexer_test_error: None, + indexer_test_errors: None, indexer_test_all_results: None, movie_details_modal: None, prompt_confirm: false, diff --git a/src/models/servarr_data/radarr/radarr_data_tests.rs b/src/models/servarr_data/radarr/radarr_data_tests.rs index 4222f59..04590c9 100644 --- a/src/models/servarr_data/radarr/radarr_data_tests.rs +++ b/src/models/servarr_data/radarr/radarr_data_tests.rs @@ -92,7 +92,7 @@ mod tests { assert!(radarr_data.edit_root_folder.is_none()); assert!(radarr_data.edit_indexer_modal.is_none()); assert!(radarr_data.indexer_settings.is_none()); - assert!(radarr_data.indexer_test_error.is_none()); + assert!(radarr_data.indexer_test_errors.is_none()); assert!(radarr_data.indexer_test_all_results.is_none()); assert!(radarr_data.movie_details_modal.is_none()); assert!(radarr_data.prompt_confirm_action.is_none()); diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 1b5bcd2..21a2a8a 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -53,7 +53,7 @@ pub struct SonarrData<'a> { pub indexers: StatefulTable, pub indexer_settings: Option, pub indexer_test_all_results: Option>, - pub indexer_test_error: Option, + pub indexer_test_errors: Option, pub language_profiles_map: BiMap, pub logs: StatefulList, pub log_details: StatefulList, @@ -106,7 +106,7 @@ impl<'a> Default for SonarrData<'a> { history: StatefulTable::default(), indexers: StatefulTable::default(), indexer_settings: None, - indexer_test_error: None, + indexer_test_errors: None, indexer_test_all_results: None, language_profiles_map: BiMap::new(), logs: StatefulList::default(), diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 5f65129..3e69b23 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -95,7 +95,7 @@ mod tests { assert!(sonarr_data.history.is_empty()); assert!(sonarr_data.indexers.is_empty()); assert!(sonarr_data.indexer_settings.is_none()); - assert!(sonarr_data.indexer_test_error.is_none()); + assert!(sonarr_data.indexer_test_errors.is_none()); assert!(sonarr_data.indexer_test_all_results.is_none()); assert!(sonarr_data.language_profiles_map.is_empty()); assert!(sonarr_data.logs.is_empty()); diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 66be974..bd074b6 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -2102,12 +2102,16 @@ impl<'a, 'b> Network<'a, 'b> { self .handle_request::(request_props, |test_results, mut app| { if test_results.as_object().is_none() { - app.data.radarr_data.indexer_test_error = Some( - test_results.as_array().unwrap()[0] + app.data.radarr_data.indexer_test_errors = Some( + test_results + .as_array() + .unwrap()[0] .get("errorMessage") .unwrap() - .to_string(), + .to_string() ); + } else { + app.data.radarr_data.indexer_test_errors = Some(String::new()); }; }) .await diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index e3d3dd8..a8cf90f 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -931,7 +931,7 @@ mod test { async_details_server.assert_async().await; async_test_server.assert_async().await; assert_eq!( - app_arc.lock().await.data.radarr_data.indexer_test_error, + app_arc.lock().await.data.radarr_data.indexer_test_errors, Some("\"test failure\"".to_owned()) ); assert_eq!(value, response_json) @@ -1000,8 +1000,8 @@ mod test { async_details_server.assert_async().await; async_test_server.assert_async().await; assert_eq!( - app_arc.lock().await.data.radarr_data.indexer_test_error, - None + app_arc.lock().await.data.radarr_data.indexer_test_errors, + Some(String::new()) ); assert_eq!(value, json!({})); } diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 2c8ad65..614b53c 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -2368,12 +2368,14 @@ impl<'a, 'b> Network<'a, 'b> { self .handle_request::(request_props, |test_results, mut app| { if test_results.as_object().is_none() { - app.data.sonarr_data.indexer_test_error = Some( + app.data.sonarr_data.indexer_test_errors = Some( test_results.as_array().unwrap()[0] .get("errorMessage") .unwrap() .to_string(), ); + } else { + app.data.sonarr_data.indexer_test_errors = Some(String::new()); }; }) .await diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index b87ebda..40f14e7 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -6302,7 +6302,7 @@ mod test { async_details_server.assert_async().await; async_test_server.assert_async().await; assert_eq!( - app_arc.lock().await.data.sonarr_data.indexer_test_error, + app_arc.lock().await.data.sonarr_data.indexer_test_errors, Some("\"test failure\"".to_owned()) ); assert_eq!(value, response_json) @@ -6371,8 +6371,8 @@ mod test { async_details_server.assert_async().await; async_test_server.assert_async().await; assert_eq!( - app_arc.lock().await.data.sonarr_data.indexer_test_error, - None + app_arc.lock().await.data.sonarr_data.indexer_test_errors, + Some(String::new()) ); assert_eq!(value, json!({})); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a91ae29..0459c86 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -128,39 +128,6 @@ pub fn draw_popup( popup_fn(f, app, popup_area); } -fn draw_popup_ui(f: &mut Frame<'_>, app: &mut App<'_>, size: Size) { - let (percent_x, percent_y) = size.to_percent(); - let popup_area = centered_rect(percent_x, percent_y, f.area()); - f.render_widget(Clear, popup_area); - f.render_widget(background_block(), popup_area); - T::draw(f, app, popup_area); -} - -pub fn draw_popup_over( - f: &mut Frame<'_>, - app: &mut App<'_>, - area: Rect, - background_fn: impl Fn(&mut Frame<'_>, &mut App<'_>, Rect), - popup_fn: impl Fn(&mut Frame<'_>, &mut App<'_>, Rect), - size: Size, -) { - background_fn(f, app, area); - - draw_popup(f, app, popup_fn, size); -} - -pub fn draw_popup_over_ui( - f: &mut Frame<'_>, - app: &mut App<'_>, - area: Rect, - background_fn: impl Fn(&mut Frame<'_>, &mut App<'_>, Rect), - size: Size, -) { - background_fn(f, app, area); - - draw_popup_ui::(f, app, size); -} - fn draw_tabs(f: &mut Frame<'_>, area: Rect, title: &str, tab_state: &TabState) -> Rect { if title.is_empty() { f.render_widget(layout_block(), area); diff --git a/src/ui/radarr_ui/blocklist/mod.rs b/src/ui/radarr_ui/blocklist/mod.rs index 65bf10f..28165d8 100644 --- a/src/ui/radarr_ui/blocklist/mod.rs +++ b/src/ui/radarr_ui/blocklist/mod.rs @@ -32,12 +32,10 @@ impl DrawUi for BlocklistUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { + draw_blocklist_table(f, app, area); + match active_radarr_block { - ActiveRadarrBlock::Blocklist | ActiveRadarrBlock::BlocklistSortPrompt => { - draw_blocklist_table(f, app, area) - } ActiveRadarrBlock::BlocklistItemDetails => { - draw_blocklist_table(f, app, area); draw_blocklist_item_details_popup(f, app); } ActiveRadarrBlock::DeleteBlocklistItemPrompt => { @@ -55,7 +53,6 @@ impl DrawUi for BlocklistUi { .prompt(&prompt) .yes_no_value(app.data.radarr_data.prompt_confirm); - draw_blocklist_table(f, app, area); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), @@ -67,7 +64,6 @@ impl DrawUi for BlocklistUi { .prompt("Do you want to clear your blocklist?") .yes_no_value(app.data.radarr_data.prompt_confirm); - draw_blocklist_table(f, app, area); f.render_widget( Popup::new(confirmation_prompt).size(Size::SmallPrompt), f.area(), diff --git a/src/ui/radarr_ui/collections/collection_details_ui.rs b/src/ui/radarr_ui/collections/collection_details_ui.rs index a19e6fd..5f5f3e1 100644 --- a/src/ui/radarr_ui/collections/collection_details_ui.rs +++ b/src/ui/radarr_ui/collections/collection_details_ui.rs @@ -12,7 +12,6 @@ use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, }; use crate::models::{EnumDisplayStyle, Route}; -use crate::ui::radarr_ui::collections::draw_collections; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{ borderless_block, get_width_from_percentage, layout_block_top_border_with_title, title_block, @@ -20,7 +19,7 @@ use crate::ui::utils::{ }; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::Size; -use crate::ui::{draw_popup_over, DrawUi}; +use crate::ui::{draw_popup, DrawUi}; use crate::utils::convert_runtime; #[cfg(test)] @@ -31,41 +30,25 @@ pub(super) struct CollectionDetailsUi; impl DrawUi for CollectionDetailsUi { fn accepts(route: Route) -> bool { - if let Route::Radarr(active_radarr_block, _) = route { + if let Route::Radarr(active_radarr_block, context_option) = route { + if let Some(context) = context_option { + return COLLECTION_DETAILS_BLOCKS.contains(&active_radarr_block) + && context == ActiveRadarrBlock::CollectionDetails; + } + return COLLECTION_DETAILS_BLOCKS.contains(&active_radarr_block); } false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { if let Route::Radarr(active_radarr_block, context_option) = app.get_current_route() { - let draw_collection_details_popup = - |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| match context_option - .unwrap_or(active_radarr_block) - { - ActiveRadarrBlock::ViewMovieOverview => { - draw_popup_over( - f, - app, - popup_area, - draw_collection_details, - draw_movie_overview, - Size::Small, - ); - } - ActiveRadarrBlock::CollectionDetails => draw_collection_details(f, app, popup_area), - _ => (), - }; + draw_popup(f, app, draw_collection_details, Size::Large); - draw_popup_over( - f, - app, - area, - draw_collections, - draw_collection_details_popup, - Size::Large, - ); + if context_option.unwrap_or(active_radarr_block) == ActiveRadarrBlock::ViewMovieOverview { + draw_popup(f, app, draw_movie_overview, Size::Small); + } } } } diff --git a/src/ui/radarr_ui/collections/collection_details_ui_tests.rs b/src/ui/radarr_ui/collections/collection_details_ui_tests.rs index 440fdf8..30f721b 100644 --- a/src/ui/radarr_ui/collections/collection_details_ui_tests.rs +++ b/src/ui/radarr_ui/collections/collection_details_ui_tests.rs @@ -17,5 +17,7 @@ mod tests { assert!(!CollectionDetailsUi::accepts(active_radarr_block.into())); } }); + + assert!(CollectionDetailsUi::accepts((ActiveRadarrBlock::CollectionDetails, Some(ActiveRadarrBlock::CollectionDetails)).into())); } } diff --git a/src/ui/radarr_ui/collections/edit_collection_ui.rs b/src/ui/radarr_ui/collections/edit_collection_ui.rs index decb857..e2e8197 100644 --- a/src/ui/radarr_ui/collections/edit_collection_ui.rs +++ b/src/ui/radarr_ui/collections/edit_collection_ui.rs @@ -1,9 +1,8 @@ -use std::sync::atomic::Ordering; - use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::text::Text; use ratatui::widgets::{ListItem, Paragraph}; use ratatui::Frame; +use std::sync::atomic::Ordering; use crate::app::context_clues::{build_context_clue_string, CONFIRMATION_PROMPT_CONTEXT_CLUES}; use crate::app::App; @@ -14,7 +13,6 @@ use crate::models::servarr_data::radarr::radarr_data::{ use crate::models::{EnumDisplayStyle, Route}; use crate::render_selectable_input_box; use crate::ui::radarr_ui::collections::collection_details_ui::CollectionDetailsUi; -use crate::ui::radarr_ui::collections::draw_collections; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{layout_paragraph_borderless, title_block_centered}; use crate::ui::widgets::button::Button; @@ -22,7 +20,7 @@ use crate::ui::widgets::checkbox::Checkbox; use crate::ui::widgets::input_box::InputBox; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::widgets::selectable_list::SelectableList; -use crate::ui::{draw_popup, draw_popup_over, draw_popup_over_ui, DrawUi}; +use crate::ui::{draw_popup, DrawUi}; #[cfg(test)] #[path = "edit_collection_ui_tests.rs"] @@ -32,51 +30,42 @@ pub(super) struct EditCollectionUi; impl DrawUi for EditCollectionUi { fn accepts(route: Route) -> bool { - if let Route::Radarr(active_radarr_block, _) = route { + if let Route::Radarr(active_radarr_block, context_option) = route { + if let Some(context) = context_option { + return EDIT_COLLECTION_BLOCKS.contains(&active_radarr_block) + && context == ActiveRadarrBlock::CollectionDetails; + } + return EDIT_COLLECTION_BLOCKS.contains(&active_radarr_block); } false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { if let Route::Radarr(active_radarr_block, context_option) = app.get_current_route() { - let draw_edit_collection_prompt = - |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| match active_radarr_block { - ActiveRadarrBlock::EditCollectionSelectMinimumAvailability => { - draw_edit_collection_confirmation_prompt(f, app, prompt_area); - draw_edit_collection_select_minimum_availability_popup(f, app); - } - ActiveRadarrBlock::EditCollectionSelectQualityProfile => { - draw_edit_collection_confirmation_prompt(f, app, prompt_area); - draw_edit_collection_select_quality_profile_popup(f, app); - } - ActiveRadarrBlock::EditCollectionPrompt - | ActiveRadarrBlock::EditCollectionToggleMonitored - | ActiveRadarrBlock::EditCollectionRootFolderPathInput - | ActiveRadarrBlock::EditCollectionToggleSearchOnAdd => { - draw_edit_collection_confirmation_prompt(f, app, prompt_area) - } - _ => (), - }; - if let Some(context) = context_option { - match context { - ActiveRadarrBlock::Collections => draw_popup_over( - f, - app, - area, - draw_collections, - draw_edit_collection_prompt, - Size::Medium, - ), - _ if COLLECTION_DETAILS_BLOCKS.contains(&context) => { - draw_popup_over_ui::(f, app, area, draw_collections, Size::Large); - draw_popup(f, app, draw_edit_collection_prompt, Size::Medium); - } - _ => (), + if COLLECTION_DETAILS_BLOCKS.contains(&context) { + draw_popup(f, app, CollectionDetailsUi::draw, Size::Large); } } + + draw_popup( + f, + app, + draw_edit_collection_confirmation_prompt, + Size::Medium, + ); + + match active_radarr_block { + ActiveRadarrBlock::EditCollectionSelectMinimumAvailability => { + draw_edit_collection_select_minimum_availability_popup(f, app); + } + ActiveRadarrBlock::EditCollectionSelectQualityProfile => { + draw_edit_collection_select_quality_profile_popup(f, app); + } + _ => (), + }; } } } diff --git a/src/ui/radarr_ui/collections/edit_collection_ui_tests.rs b/src/ui/radarr_ui/collections/edit_collection_ui_tests.rs index be9fbc4..842737d 100644 --- a/src/ui/radarr_ui/collections/edit_collection_ui_tests.rs +++ b/src/ui/radarr_ui/collections/edit_collection_ui_tests.rs @@ -16,6 +16,8 @@ mod tests { } else { assert!(!EditCollectionUi::accepts(active_radarr_block.into())); } - }) + }); + + assert!(EditCollectionUi::accepts((ActiveRadarrBlock::EditCollectionPrompt, Some(ActiveRadarrBlock::CollectionDetails)).into())); } } diff --git a/src/ui/radarr_ui/collections/mod.rs b/src/ui/radarr_ui/collections/mod.rs index f86b51f..37269f6 100644 --- a/src/ui/radarr_ui/collections/mod.rs +++ b/src/ui/radarr_ui/collections/mod.rs @@ -2,8 +2,6 @@ use ratatui::layout::{Constraint, Rect}; use ratatui::widgets::{Cell, Row}; use ratatui::Frame; -pub(super) use collection_details_ui::draw_collection_details; - use crate::app::App; use crate::models::radarr_models::Collection; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, COLLECTIONS_BLOCKS}; @@ -38,37 +36,23 @@ impl DrawUi for CollectionsUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let route = app.get_current_route(); - let mut collections_ui_matcher = |active_radarr_block| match active_radarr_block { - ActiveRadarrBlock::Collections - | ActiveRadarrBlock::CollectionsSortPrompt - | ActiveRadarrBlock::SearchCollection - | ActiveRadarrBlock::SearchCollectionError - | ActiveRadarrBlock::FilterCollections - | ActiveRadarrBlock::FilterCollectionsError => draw_collections(f, app, area), - ActiveRadarrBlock::UpdateAllCollectionsPrompt => { + draw_collections(f, app, area); + + match route { + _ if CollectionDetailsUi::accepts(route) => CollectionDetailsUi::draw(f, app, area), + _ if EditCollectionUi::accepts(route) => EditCollectionUi::draw(f, app, area), + Route::Radarr(ActiveRadarrBlock::UpdateAllCollectionsPrompt, _) => { let confirmation_prompt = ConfirmationPrompt::new() .title("Update All Collections") .prompt("Do you want to update all of your collections?") .yes_no_value(app.data.radarr_data.prompt_confirm); - draw_collections(f, app, area); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), ); } _ => (), - }; - - match route { - _ if CollectionDetailsUi::accepts(route) => CollectionDetailsUi::draw(f, app, area), - _ if EditCollectionUi::accepts(route) => EditCollectionUi::draw(f, app, area), - Route::Radarr(active_radarr_block, _) - if COLLECTIONS_BLOCKS.contains(&active_radarr_block) => - { - collections_ui_matcher(active_radarr_block) - } - _ => (), } } } @@ -123,7 +107,11 @@ pub(super) fn draw_collections(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) .primary() }; let collections_table = ManagarrTable::new(content, collection_row_mapping) - .loading(app.is_loading) + .loading( + app.is_loading + || app.data.radarr_data.movies.is_empty() + || app.data.radarr_data.quality_profile_map.is_empty(), + ) .footer(collections_table_footer) .block(layout_block_top_border()) .sorting(active_radarr_block == ActiveRadarrBlock::CollectionsSortPrompt) diff --git a/src/ui/radarr_ui/downloads/mod.rs b/src/ui/radarr_ui/downloads/mod.rs index da8724b..48ab433 100644 --- a/src/ui/radarr_ui/downloads/mod.rs +++ b/src/ui/radarr_ui/downloads/mod.rs @@ -31,8 +31,9 @@ impl DrawUi for DownloadsUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { + draw_downloads(f, app, area); + match active_radarr_block { - ActiveRadarrBlock::Downloads => draw_downloads(f, app, area), ActiveRadarrBlock::DeleteDownloadPrompt => { let prompt = format!( "Do you really want to delete this download: \n{}?", @@ -43,7 +44,6 @@ impl DrawUi for DownloadsUi { .prompt(&prompt) .yes_no_value(app.data.radarr_data.prompt_confirm); - draw_downloads(f, app, area); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), @@ -55,7 +55,6 @@ impl DrawUi for DownloadsUi { .prompt("Do you want to update your downloads?") .yes_no_value(app.data.radarr_data.prompt_confirm); - draw_downloads(f, app, area); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), diff --git a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs index d118a32..dc50d5e 100644 --- a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs +++ b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs @@ -5,7 +5,6 @@ use crate::app::App; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_INDEXER_BLOCKS}; use crate::models::Route; use crate::render_selectable_input_box; -use crate::ui::radarr_ui::indexers::draw_indexers; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::title_block_centered; use crate::ui::widgets::button::Button; @@ -13,7 +12,7 @@ use crate::ui::widgets::checkbox::Checkbox; use crate::ui::widgets::input_box::InputBox; use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::popup::Size; -use crate::ui::{draw_popup_over, DrawUi}; +use crate::ui::{draw_popup, DrawUi}; use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::text::Text; use ratatui::widgets::Paragraph; @@ -34,12 +33,10 @@ impl DrawUi for EditIndexerUi { false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_popup_over( + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + draw_popup( f, app, - area, - draw_indexers, draw_edit_indexer_prompt, Size::WideLargePrompt, ); diff --git a/src/ui/radarr_ui/indexers/indexer_settings_ui.rs b/src/ui/radarr_ui/indexers/indexer_settings_ui.rs index 642efde..3dd1972 100644 --- a/src/ui/radarr_ui/indexers/indexer_settings_ui.rs +++ b/src/ui/radarr_ui/indexers/indexer_settings_ui.rs @@ -12,7 +12,6 @@ use crate::models::servarr_data::radarr::radarr_data::{ }; use crate::models::Route; use crate::render_selectable_input_box; -use crate::ui::radarr_ui::indexers::draw_indexers; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::title_block_centered; use crate::ui::widgets::button::Button; @@ -20,7 +19,7 @@ use crate::ui::widgets::checkbox::Checkbox; use crate::ui::widgets::input_box::InputBox; use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::popup::Size; -use crate::ui::{draw_popup_over, DrawUi}; +use crate::ui::{draw_popup, DrawUi}; #[cfg(test)] #[path = "indexer_settings_ui_tests.rs"] @@ -37,12 +36,10 @@ impl DrawUi for IndexerSettingsUi { false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_popup_over( + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + draw_popup( f, app, - area, - draw_indexers, draw_edit_indexer_settings_prompt, Size::WideLargePrompt, ); diff --git a/src/ui/radarr_ui/indexers/mod.rs b/src/ui/radarr_ui/indexers/mod.rs index 9b1cbbf..63aaa81 100644 --- a/src/ui/radarr_ui/indexers/mod.rs +++ b/src/ui/radarr_ui/indexers/mod.rs @@ -44,63 +44,62 @@ impl DrawUi for IndexersUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let route = app.get_current_route(); - let mut indexers_matchers = |active_radarr_block| match active_radarr_block { - ActiveRadarrBlock::Indexers => draw_indexers(f, app, area), - ActiveRadarrBlock::TestIndexer => { - draw_indexers(f, app, area); - if app.is_loading { - let loading_popup = Popup::new(LoadingBlock::new( - app.is_loading, - title_block("Testing Indexer"), - )) - .size(Size::LargeMessage); - f.render_widget(loading_popup, f.area()); - } else { - let popup = if let Some(result) = app.data.radarr_data.indexer_test_error.as_ref() { - Popup::new(Message::new(result.clone())).size(Size::LargeMessage) - } else { - let message = Message::new("Indexer test succeeded!") - .title("Success") - .style(Style::new().success().bold()); - Popup::new(message).size(Size::Message) - }; - - f.render_widget(popup, f.area()); - } - } - ActiveRadarrBlock::DeleteIndexerPrompt => { - let prompt = format!( - "Do you really want to delete this indexer: \n{}?", - app - .data - .radarr_data - .indexers - .current_selection() - .name - .clone() - .unwrap_or_default() - ); - let confirmation_prompt = ConfirmationPrompt::new() - .title("Delete Indexer") - .prompt(&prompt) - .yes_no_value(app.data.radarr_data.prompt_confirm); - - draw_indexers(f, app, area); - f.render_widget( - Popup::new(confirmation_prompt).size(Size::MediumPrompt), - f.area(), - ); - } - _ => (), - }; + draw_indexers(f, app, area); match route { _ if EditIndexerUi::accepts(route) => EditIndexerUi::draw(f, app, area), _ if IndexerSettingsUi::accepts(route) => IndexerSettingsUi::draw(f, app, area), _ if TestAllIndexersUi::accepts(route) => TestAllIndexersUi::draw(f, app, area), - Route::Radarr(active_radarr_block, _) if INDEXERS_BLOCKS.contains(&active_radarr_block) => { - indexers_matchers(active_radarr_block) - } + Route::Radarr(active_radarr_block, _) => match active_radarr_block { + ActiveRadarrBlock::TestIndexer => { + if app.is_loading || app.data.radarr_data.indexer_test_errors.is_none() { + let loading_popup = Popup::new(LoadingBlock::new( + app.is_loading || app.data.radarr_data.indexer_test_errors.is_none(), + title_block("Testing Indexer"), + )) + .size(Size::LargeMessage); + f.render_widget(loading_popup, f.area()); + } else { + let popup = { + let result = app.data.radarr_data.indexer_test_errors.as_ref().expect("Test result is unpopulated"); + + if !result.is_empty() { + Popup::new(Message::new(result.clone())).size(Size::LargeMessage) + } else { + let message = Message::new("Indexer test succeeded!") + .title("Success") + .style(Style::new().success().bold()); + Popup::new(message).size(Size::Message) + } + }; + + f.render_widget(popup, f.area()); + } + } + ActiveRadarrBlock::DeleteIndexerPrompt => { + let prompt = format!( + "Do you really want to delete this indexer: \n{}?", + app + .data + .radarr_data + .indexers + .current_selection() + .name + .clone() + .unwrap_or_default() + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Indexer") + .prompt(&prompt) + .yes_no_value(app.data.radarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + _ => (), + }, _ => (), } } diff --git a/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs b/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs index 1b36a45..f7adc0d 100644 --- a/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs +++ b/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs @@ -3,12 +3,11 @@ use crate::app::App; use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::Route; -use crate::ui::radarr_ui::indexers::draw_indexers; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{borderless_block, get_width_from_percentage, title_block}; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::Size; -use crate::ui::{draw_popup_over, DrawUi}; +use crate::ui::{draw_popup, DrawUi}; use ratatui::layout::{Alignment, Constraint, Rect}; use ratatui::widgets::{Cell, Row}; use ratatui::Frame; @@ -28,26 +27,22 @@ impl DrawUi for TestAllIndexersUi { false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_popup_over( - f, - app, - area, - draw_indexers, - draw_test_all_indexers_test_results, - Size::Large, - ); + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + draw_popup(f, app, draw_test_all_indexers_test_results, Size::Large); } } fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let is_loading = app.is_loading || app.data.radarr_data.indexer_test_all_results.is_none(); + let block = title_block("Test All Indexers"); + let current_selection = if let Some(test_all_results) = app.data.radarr_data.indexer_test_all_results.as_ref() { test_all_results.current_selection().clone() } else { IndexerTestResultModalItem::default() }; - f.render_widget(title_block("Test All Indexers"), area); + f.render_widget(block, area); let help_footer = format!( "<↑↓> scroll | {}", build_context_clue_string(&BARE_POPUP_CONTEXT_CLUES) @@ -77,7 +72,7 @@ fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, are test_results_row_mapping, ) .block(borderless_block()) - .loading(app.is_loading) + .loading(is_loading) .footer(Some(help_footer)) .footer_alignment(Alignment::Center) .margin(1) diff --git a/src/ui/radarr_ui/library/add_movie_ui.rs b/src/ui/radarr_ui/library/add_movie_ui.rs index 11cd8a4..1070b16 100644 --- a/src/ui/radarr_ui/library/add_movie_ui.rs +++ b/src/ui/radarr_ui/library/add_movie_ui.rs @@ -13,8 +13,6 @@ use crate::models::radarr_models::AddMovieSearchResult; use crate::models::servarr_data::radarr::modals::AddMovieModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ADD_MOVIE_BLOCKS}; use crate::models::{EnumDisplayStyle, Route}; -use crate::ui::radarr_ui::collections::{draw_collection_details, draw_collections}; -use crate::ui::radarr_ui::library::draw_library; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{ borderless_block, get_width_from_percentage, layout_block, layout_paragraph_borderless, @@ -26,9 +24,10 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::widgets::selectable_list::SelectableList; -use crate::ui::{draw_popup_over, DrawUi}; +use crate::ui::{draw_popup, DrawUi}; use crate::utils::convert_runtime; use crate::{render_selectable_input_box, App}; +use crate::ui::radarr_ui::collections::CollectionsUi; #[cfg(test)] #[path = "add_movie_ui_tests.rs"] @@ -47,72 +46,34 @@ impl DrawUi for AddMovieUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Radarr(active_radarr_block, context_option) = app.get_current_route() { - let draw_add_movie_search_popup = - |f: &mut Frame<'_>, app: &mut App<'_>, area: Rect| match active_radarr_block { - ActiveRadarrBlock::AddMovieSearchInput - | ActiveRadarrBlock::AddMovieSearchResults - | ActiveRadarrBlock::AddMovieEmptySearchResults => { - draw_add_movie_search(f, app, area); - } + if context_option.is_some() { + CollectionsUi::draw(f, app, area); + draw_popup(f, app, draw_confirmation_popup, Size::Medium); + } else { + draw_popup(f, app, draw_add_movie_search, Size::Large); + + match active_radarr_block { ActiveRadarrBlock::AddMoviePrompt | ActiveRadarrBlock::AddMovieSelectMonitor | ActiveRadarrBlock::AddMovieSelectMinimumAvailability | ActiveRadarrBlock::AddMovieSelectQualityProfile | ActiveRadarrBlock::AddMovieSelectRootFolder | ActiveRadarrBlock::AddMovieTagsInput => { - if context_option.is_some() { - draw_popup_over( + draw_popup( f, app, - area, - draw_collection_details, draw_confirmation_popup, Size::Medium, ); - } else { - draw_popup_over( - f, - app, - area, - draw_add_movie_search, - draw_confirmation_popup, - Size::Medium, - ); - } } ActiveRadarrBlock::AddMovieAlreadyInLibrary => { - draw_add_movie_search(f, app, area); f.render_widget( Popup::new(Message::new("This film is already in your library")).size(Size::Message), f.area(), ); } _ => (), - }; - - match active_radarr_block { - _ if ADD_MOVIE_BLOCKS.contains(&active_radarr_block) => { - if context_option.is_some() { - draw_popup_over( - f, - app, - area, - draw_collections, - draw_add_movie_search_popup, - Size::Large, - ) - } else { - draw_popup_over( - f, - app, - area, - draw_library, - draw_add_movie_search_popup, - Size::Large, - ) - } } - _ => (), } } } @@ -285,26 +246,21 @@ fn draw_add_movie_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { fn draw_confirmation_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { + draw_confirmation_prompt(f, app, area); + match active_radarr_block { ActiveRadarrBlock::AddMovieSelectMonitor => { - draw_confirmation_prompt(f, app, area); draw_add_movie_select_monitor_popup(f, app); } ActiveRadarrBlock::AddMovieSelectMinimumAvailability => { - draw_confirmation_prompt(f, app, area); draw_add_movie_select_minimum_availability_popup(f, app); } ActiveRadarrBlock::AddMovieSelectQualityProfile => { - draw_confirmation_prompt(f, app, area); draw_add_movie_select_quality_profile_popup(f, app); } ActiveRadarrBlock::AddMovieSelectRootFolder => { - draw_confirmation_prompt(f, app, area); draw_add_movie_select_root_folder_popup(f, app); } - ActiveRadarrBlock::AddMoviePrompt | ActiveRadarrBlock::AddMovieTagsInput => { - draw_confirmation_prompt(f, app, area) - } _ => (), } } diff --git a/src/ui/radarr_ui/library/delete_movie_ui.rs b/src/ui/radarr_ui/library/delete_movie_ui.rs index c5d1090..9a338da 100644 --- a/src/ui/radarr_ui/library/delete_movie_ui.rs +++ b/src/ui/radarr_ui/library/delete_movie_ui.rs @@ -4,7 +4,6 @@ use ratatui::Frame; use crate::app::App; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, DELETE_MOVIE_BLOCKS}; use crate::models::Route; -use crate::ui::radarr_ui::library::draw_library; use crate::ui::widgets::checkbox::Checkbox; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::popup::{Popup, Size}; @@ -25,7 +24,7 @@ impl DrawUi for DeleteMovieUi { false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { if matches!( app.get_current_route(), Route::Radarr(ActiveRadarrBlock::DeleteMoviePrompt, _) @@ -50,7 +49,6 @@ impl DrawUi for DeleteMovieUi { .yes_no_highlighted(selected_block == ActiveRadarrBlock::DeleteMovieConfirmPrompt) .yes_no_value(app.data.radarr_data.prompt_confirm); - draw_library(f, app, area); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), diff --git a/src/ui/radarr_ui/library/edit_movie_ui.rs b/src/ui/radarr_ui/library/edit_movie_ui.rs index 972fffe..f9458e8 100644 --- a/src/ui/radarr_ui/library/edit_movie_ui.rs +++ b/src/ui/radarr_ui/library/edit_movie_ui.rs @@ -14,7 +14,6 @@ use crate::models::servarr_data::radarr::radarr_data::{ }; use crate::models::{EnumDisplayStyle, Route}; use crate::render_selectable_input_box; -use crate::ui::radarr_ui::library::draw_library; use crate::ui::radarr_ui::library::movie_details_ui::MovieDetailsUi; use crate::ui::styles::ManagarrStyle; @@ -24,7 +23,7 @@ use crate::ui::widgets::checkbox::Checkbox; use crate::ui::widgets::input_box::InputBox; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::widgets::selectable_list::SelectableList; -use crate::ui::{draw_popup, draw_popup_over, draw_popup_over_ui, DrawUi}; +use crate::ui::{draw_popup, DrawUi}; #[cfg(test)] #[path = "edit_movie_ui_tests.rs"] @@ -41,46 +40,25 @@ impl DrawUi for EditMovieUi { false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { if let Route::Radarr(active_radarr_block, context_option) = app.get_current_route() { - let draw_edit_movie_prompt = - |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| match active_radarr_block { - ActiveRadarrBlock::EditMovieSelectMinimumAvailability => { - draw_edit_movie_confirmation_prompt(f, app, prompt_area); - draw_edit_movie_select_minimum_availability_popup(f, app); - } - ActiveRadarrBlock::EditMovieSelectQualityProfile => { - draw_edit_movie_confirmation_prompt(f, app, prompt_area); - draw_edit_movie_select_quality_profile_popup(f, app); - } - ActiveRadarrBlock::EditMoviePrompt - | ActiveRadarrBlock::EditMovieToggleMonitored - | ActiveRadarrBlock::EditMoviePathInput - | ActiveRadarrBlock::EditMovieTagsInput => { - draw_edit_movie_confirmation_prompt(f, app, prompt_area) - } - _ => (), - }; - if let Some(context) = context_option { - match context { - ActiveRadarrBlock::Movies => { - draw_popup_over( - f, - app, - area, - draw_library, - draw_edit_movie_prompt, - Size::Medium, - ); - } - _ if MOVIE_DETAILS_BLOCKS.contains(&context) => { - draw_popup_over_ui::(f, app, area, draw_library, Size::Large); - draw_popup(f, app, draw_edit_movie_prompt, Size::Medium); - } - _ => (), + if MOVIE_DETAILS_BLOCKS.contains(&context) { + draw_popup(f, app, MovieDetailsUi::draw, Size::Large); } } + + draw_popup(f, app, draw_edit_movie_confirmation_prompt, Size::Medium); + + match active_radarr_block { + ActiveRadarrBlock::EditMovieSelectMinimumAvailability => { + draw_edit_movie_select_minimum_availability_popup(f, app); + } + ActiveRadarrBlock::EditMovieSelectQualityProfile => { + draw_edit_movie_select_quality_profile_popup(f, app); + } + _ => (), + } } } } diff --git a/src/ui/radarr_ui/library/mod.rs b/src/ui/radarr_ui/library/mod.rs index 760f31b..3ed8db2 100644 --- a/src/ui/radarr_ui/library/mod.rs +++ b/src/ui/radarr_ui/library/mod.rs @@ -44,43 +44,32 @@ impl DrawUi for LibraryUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let route = app.get_current_route(); - let mut library_ui_matchers = |active_radarr_block: ActiveRadarrBlock| match active_radarr_block - { - ActiveRadarrBlock::Movies - | ActiveRadarrBlock::MoviesSortPrompt - | ActiveRadarrBlock::SearchMovie - | ActiveRadarrBlock::SearchMovieError - | ActiveRadarrBlock::FilterMovies - | ActiveRadarrBlock::FilterMoviesError => draw_library(f, app, area), - ActiveRadarrBlock::UpdateAllMoviesPrompt => { + if !matches!(route, Route::Radarr(_, Some(_))) { + draw_library(f, app, area); + } + + match route { + _ if MovieDetailsUi::accepts(route) => MovieDetailsUi::draw(f, app, area), + _ if AddMovieUi::accepts(route) => AddMovieUi::draw(f, app, area), + _ if EditMovieUi::accepts(route) => EditMovieUi::draw(f, app, area), + _ if DeleteMovieUi::accepts(route) => DeleteMovieUi::draw(f, app, area), + Route::Radarr(ActiveRadarrBlock::UpdateAllMoviesPrompt, _) => { let confirmation_prompt = ConfirmationPrompt::new() .title("Update All Movies") .prompt("Do you want to update info and scan your disks for all of your movies?") .yes_no_value(app.data.radarr_data.prompt_confirm); - draw_library(f, app, area); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), ); } _ => (), - }; - - match route { - _ if MovieDetailsUi::accepts(route) => MovieDetailsUi::draw(f, app, area), - _ if AddMovieUi::accepts(route) => AddMovieUi::draw(f, app, area), - _ if EditMovieUi::accepts(route) => EditMovieUi::draw(f, app, area), - _ if DeleteMovieUi::accepts(route) => DeleteMovieUi::draw(f, app, area), - Route::Radarr(active_radarr_block, _) if LIBRARY_BLOCKS.contains(&active_radarr_block) => { - library_ui_matchers(active_radarr_block) - } - _ => (), } } } -pub(super) fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { +fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { let current_selection = if !app.data.radarr_data.movies.items.is_empty() { app.data.radarr_data.movies.current_selection().clone() diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index 7cfe147..74eff7e 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -11,14 +11,13 @@ use crate::models::radarr_models::{Credit, MovieHistoryItem, RadarrRelease}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; use crate::models::Route; -use crate::ui::radarr_ui::library::draw_library; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{borderless_block, decorate_peer_style, get_width_from_percentage, layout_block_bottom_border, layout_block_top_border}; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::{Popup, Size}; -use crate::ui::{draw_popup_over, draw_tabs, DrawUi}; +use crate::ui::{draw_popup, draw_tabs, DrawUi}; use crate::utils::convert_to_gb; #[cfg(test)] @@ -36,7 +35,7 @@ impl DrawUi for MovieDetailsUi { false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { if let Route::Radarr(active_radarr_block, context_option) = app.get_current_route() { let draw_movie_info_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { let content_area = draw_tabs( @@ -85,11 +84,9 @@ impl DrawUi for MovieDetailsUi { } }; - draw_popup_over( + draw_popup( f, app, - area, - draw_library, draw_movie_info_popup, Size::Large, ); diff --git a/src/ui/radarr_ui/root_folders/mod.rs b/src/ui/radarr_ui/root_folders/mod.rs index 04a74d0..683570b 100644 --- a/src/ui/radarr_ui/root_folders/mod.rs +++ b/src/ui/radarr_ui/root_folders/mod.rs @@ -11,7 +11,7 @@ use crate::ui::utils::layout_block_top_border; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::{Popup, Size}; -use crate::ui::{draw_input_box_popup, draw_popup_over, DrawUi}; +use crate::ui::{draw_input_box_popup, draw_popup, DrawUi}; use crate::utils::convert_to_gb; #[cfg(test)] @@ -31,13 +31,12 @@ impl DrawUi for RootFoldersUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { + draw_root_folders(f, app, area); + match active_radarr_block { - ActiveRadarrBlock::RootFolders => draw_root_folders(f, app, area), - ActiveRadarrBlock::AddRootFolderPrompt => draw_popup_over( + ActiveRadarrBlock::AddRootFolderPrompt => draw_popup( f, app, - area, - draw_root_folders, draw_add_root_folder_prompt_box, Size::InputBox, ), @@ -51,7 +50,6 @@ impl DrawUi for RootFoldersUi { .prompt(&prompt) .yes_no_value(app.data.radarr_data.prompt_confirm); - draw_root_folders(f, app, area); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), diff --git a/src/ui/radarr_ui/system/mod.rs b/src/ui/radarr_ui/system/mod.rs index db50e4b..3fbef36 100644 --- a/src/ui/radarr_ui/system/mod.rs +++ b/src/ui/radarr_ui/system/mod.rs @@ -63,18 +63,15 @@ impl DrawUi for SystemUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let route = app.get_current_route(); - - match route { - _ if SystemDetailsUi::accepts(route) => SystemDetailsUi::draw(f, app, area), - _ if matches!(route, Route::Radarr(ActiveRadarrBlock::System, _)) => { - draw_system_ui_layout(f, app, area) - } - _ => (), + draw_system_ui_layout(f, app, area); + + if SystemDetailsUi::accepts(route) { + SystemDetailsUi::draw(f, app, area); } } } -pub(super) fn draw_system_ui_layout(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { +fn draw_system_ui_layout(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let [activities_area, logs_area, help_area] = Layout::vertical([ Constraint::Ratio(1, 2), Constraint::Ratio(1, 2), diff --git a/src/ui/radarr_ui/system/system_details_ui.rs b/src/ui/radarr_ui/system/system_details_ui.rs index 41a5437..7eba677 100644 --- a/src/ui/radarr_ui/system/system_details_ui.rs +++ b/src/ui/radarr_ui/system/system_details_ui.rs @@ -10,7 +10,7 @@ use crate::models::radarr_models::RadarrTask; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS}; use crate::models::Route; use crate::ui::radarr_ui::system::{ - draw_queued_events, draw_system_ui_layout, extract_task_props, TASK_TABLE_CONSTRAINTS, + draw_queued_events, extract_task_props, TASK_TABLE_CONSTRAINTS, TASK_TABLE_HEADERS, }; use crate::ui::styles::ManagarrStyle; @@ -20,7 +20,7 @@ use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::widgets::selectable_list::SelectableList; -use crate::ui::{draw_popup_over, DrawUi}; +use crate::ui::{draw_popup, DrawUi}; #[cfg(test)] #[path = "system_details_ui_tests.rs"] @@ -37,33 +37,27 @@ impl DrawUi for SystemDetailsUi { false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { match active_radarr_block { ActiveRadarrBlock::SystemLogs => { - draw_system_ui_layout(f, app, area); draw_logs_popup(f, app); } ActiveRadarrBlock::SystemTasks | ActiveRadarrBlock::SystemTaskStartConfirmPrompt => { - draw_popup_over( + draw_popup( f, app, - area, - draw_system_ui_layout, draw_tasks_popup, Size::Large, ) } - ActiveRadarrBlock::SystemQueuedEvents => draw_popup_over( + ActiveRadarrBlock::SystemQueuedEvents => draw_popup( f, app, - area, - draw_system_ui_layout, draw_queued_events, Size::Medium, ), ActiveRadarrBlock::SystemUpdates => { - draw_system_ui_layout(f, app, area); draw_updates_popup(f, app); } _ => (), diff --git a/src/ui/sonarr_ui/blocklist/mod.rs b/src/ui/sonarr_ui/blocklist/mod.rs index c382007..07f2efb 100644 --- a/src/ui/sonarr_ui/blocklist/mod.rs +++ b/src/ui/sonarr_ui/blocklist/mod.rs @@ -32,12 +32,10 @@ impl DrawUi for BlocklistUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + draw_blocklist_table(f, app, area); + match active_sonarr_block { - ActiveSonarrBlock::Blocklist | ActiveSonarrBlock::BlocklistSortPrompt => { - draw_blocklist_table(f, app, area) - } ActiveSonarrBlock::BlocklistItemDetails => { - draw_blocklist_table(f, app, area); draw_blocklist_item_details_popup(f, app); } ActiveSonarrBlock::DeleteBlocklistItemPrompt => { @@ -55,7 +53,6 @@ impl DrawUi for BlocklistUi { .prompt(&prompt) .yes_no_value(app.data.sonarr_data.prompt_confirm); - draw_blocklist_table(f, app, area); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), @@ -67,7 +64,6 @@ impl DrawUi for BlocklistUi { .prompt("Do you want to clear your blocklist?") .yes_no_value(app.data.sonarr_data.prompt_confirm); - draw_blocklist_table(f, app, area); f.render_widget( Popup::new(confirmation_prompt).size(Size::SmallPrompt), f.area(), diff --git a/src/ui/sonarr_ui/downloads/mod.rs b/src/ui/sonarr_ui/downloads/mod.rs index d41c913..3ef77a3 100644 --- a/src/ui/sonarr_ui/downloads/mod.rs +++ b/src/ui/sonarr_ui/downloads/mod.rs @@ -31,8 +31,9 @@ impl DrawUi for DownloadsUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + draw_downloads(f, app, area); + match active_sonarr_block { - ActiveSonarrBlock::Downloads => draw_downloads(f, app, area), ActiveSonarrBlock::DeleteDownloadPrompt => { let prompt = format!( "Do you really want to delete this download: \n{}?", @@ -43,7 +44,6 @@ impl DrawUi for DownloadsUi { .prompt(&prompt) .yes_no_value(app.data.sonarr_data.prompt_confirm); - draw_downloads(f, app, area); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), @@ -55,7 +55,6 @@ impl DrawUi for DownloadsUi { .prompt("Do you want to update your downloads?") .yes_no_value(app.data.sonarr_data.prompt_confirm); - draw_downloads(f, app, area); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), diff --git a/src/ui/sonarr_ui/history/mod.rs b/src/ui/sonarr_ui/history/mod.rs index 70fe8a2..0ac32d1 100644 --- a/src/ui/sonarr_ui/history/mod.rs +++ b/src/ui/sonarr_ui/history/mod.rs @@ -39,18 +39,10 @@ impl DrawUi for HistoryUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { - match active_sonarr_block { - ActiveSonarrBlock::History - | ActiveSonarrBlock::HistorySortPrompt - | ActiveSonarrBlock::SearchHistory - | ActiveSonarrBlock::SearchHistoryError - | ActiveSonarrBlock::FilterHistory - | ActiveSonarrBlock::FilterHistoryError => draw_history_table(f, app, area), - ActiveSonarrBlock::HistoryItemDetails => { - draw_history_table(f, app, area); - draw_history_item_details_popup(f, app); - } - _ => (), + draw_history_table(f, app, area); + + if active_sonarr_block == ActiveSonarrBlock::HistoryItemDetails { + draw_history_item_details_popup(f, app); } } } diff --git a/src/ui/sonarr_ui/indexers/edit_indexer_ui.rs b/src/ui/sonarr_ui/indexers/edit_indexer_ui.rs index 038a077..0bf48f1 100644 --- a/src/ui/sonarr_ui/indexers/edit_indexer_ui.rs +++ b/src/ui/sonarr_ui/indexers/edit_indexer_ui.rs @@ -5,7 +5,6 @@ use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_INDEXER_BLOCKS}; use crate::models::Route; use crate::render_selectable_input_box; -use crate::ui::sonarr_ui::indexers::draw_indexers; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::title_block_centered; use crate::ui::widgets::button::Button; @@ -13,7 +12,7 @@ use crate::ui::widgets::checkbox::Checkbox; use crate::ui::widgets::input_box::InputBox; use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::popup::Size; -use crate::ui::{draw_popup_over, DrawUi}; +use crate::ui::{draw_popup, DrawUi}; use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::text::Text; use ratatui::widgets::Paragraph; @@ -34,12 +33,10 @@ impl DrawUi for EditIndexerUi { false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_popup_over( + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + draw_popup( f, app, - area, - draw_indexers, draw_edit_indexer_prompt, Size::WideLargePrompt, ); diff --git a/src/ui/sonarr_ui/indexers/indexer_settings_ui.rs b/src/ui/sonarr_ui/indexers/indexer_settings_ui.rs index 684a15c..9f66133 100644 --- a/src/ui/sonarr_ui/indexers/indexer_settings_ui.rs +++ b/src/ui/sonarr_ui/indexers/indexer_settings_ui.rs @@ -10,14 +10,13 @@ use crate::models::servarr_data::sonarr::sonarr_data::{ }; use crate::models::Route; use crate::render_selectable_input_box; -use crate::ui::sonarr_ui::indexers::draw_indexers; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::title_block_centered; use crate::ui::widgets::button::Button; use crate::ui::widgets::input_box::InputBox; use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::popup::Size; -use crate::ui::{draw_popup_over, DrawUi}; +use crate::ui::{draw_popup, DrawUi}; #[cfg(test)] #[path = "indexer_settings_ui_tests.rs"] @@ -34,12 +33,10 @@ impl DrawUi for IndexerSettingsUi { false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_popup_over( + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + draw_popup( f, app, - area, - draw_indexers, draw_edit_indexer_settings_prompt, Size::LargePrompt, ); diff --git a/src/ui/sonarr_ui/indexers/mod.rs b/src/ui/sonarr_ui/indexers/mod.rs index a560d9a..f5df36e 100644 --- a/src/ui/sonarr_ui/indexers/mod.rs +++ b/src/ui/sonarr_ui/indexers/mod.rs @@ -44,62 +44,61 @@ impl DrawUi for IndexersUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let route = app.get_current_route(); - let mut indexers_matchers = |active_sonarr_block| match active_sonarr_block { - ActiveSonarrBlock::Indexers => draw_indexers(f, app, area), - ActiveSonarrBlock::TestIndexer => { - draw_indexers(f, app, area); - if app.is_loading || app.is_routing { - let loading_popup = Popup::new(LoadingBlock::new( - app.is_loading, - title_block("Testing Indexer"), - )) - .size(Size::LargeMessage); - f.render_widget(loading_popup, f.area()); - } else { - let popup = if let Some(result) = app.data.sonarr_data.indexer_test_error.as_ref() { - Popup::new(Message::new(result.clone())).size(Size::LargeMessage) - } else { - let message = Message::new("Indexer test succeeded!") - .title("Success") - .style(Style::new().success().bold()); - Popup::new(message).size(Size::Message) - }; - - f.render_widget(popup, f.area()); - } - } - ActiveSonarrBlock::DeleteIndexerPrompt => { - let prompt = format!( - "Do you really want to delete this indexer: \n{}?", - app - .data - .sonarr_data - .indexers - .current_selection() - .name - .clone() - .unwrap_or_default() - ); - let confirmation_prompt = ConfirmationPrompt::new() - .title("Delete Indexer") - .prompt(&prompt) - .yes_no_value(app.data.sonarr_data.prompt_confirm); - - draw_indexers(f, app, area); - f.render_widget( - Popup::new(confirmation_prompt).size(Size::MediumPrompt), - f.area(), - ); - } - _ => (), - }; + draw_indexers(f, app, area); match route { _ if EditIndexerUi::accepts(route) => EditIndexerUi::draw(f, app, area), _ if IndexerSettingsUi::accepts(route) => IndexerSettingsUi::draw(f, app, area), _ if TestAllIndexersUi::accepts(route) => TestAllIndexersUi::draw(f, app, area), - Route::Sonarr(active_sonarr_block, _) if INDEXERS_BLOCKS.contains(&active_sonarr_block) => { - indexers_matchers(active_sonarr_block) + Route::Sonarr(active_sonarr_block, _) => match active_sonarr_block { + ActiveSonarrBlock::TestIndexer => { + if app.is_loading || app.data.sonarr_data.indexer_test_errors.is_none() { + let loading_popup = Popup::new(LoadingBlock::new( + app.is_loading || app.data.sonarr_data.indexer_test_errors.is_none(), + title_block("Testing Indexer"), + )) + .size(Size::LargeMessage); + f.render_widget(loading_popup, f.area()); + } else { + let popup = { + let result = app.data.sonarr_data.indexer_test_errors.as_ref().expect("Test result is unpopulated"); + + if !result.is_empty() { + Popup::new(Message::new(result.clone())).size(Size::LargeMessage) + } else { + let message = Message::new("Indexer test succeeded!") + .title("Success") + .style(Style::new().success().bold()); + Popup::new(message).size(Size::Message) + } + }; + + f.render_widget(popup, f.area()); + } + } + ActiveSonarrBlock::DeleteIndexerPrompt => { + let prompt = format!( + "Do you really want to delete this indexer: \n{}?", + app + .data + .sonarr_data + .indexers + .current_selection() + .name + .clone() + .unwrap_or_default() + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Indexer") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + _ => (), } _ => (), } diff --git a/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs b/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs index 0962a20..22befdd 100644 --- a/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs +++ b/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs @@ -3,12 +3,11 @@ use crate::app::App; use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::Route; -use crate::ui::sonarr_ui::indexers::draw_indexers; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{borderless_block, get_width_from_percentage, title_block}; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::Size; -use crate::ui::{draw_popup_over, DrawUi}; +use crate::ui::{draw_popup, DrawUi}; use ratatui::layout::{Alignment, Constraint, Rect}; use ratatui::widgets::{Cell, Row}; use ratatui::Frame; @@ -28,12 +27,10 @@ impl DrawUi for TestAllIndexersUi { false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_popup_over( + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + draw_popup( f, app, - area, - draw_indexers, draw_test_all_indexers_test_results, Size::Large, ); @@ -41,6 +38,7 @@ impl DrawUi for TestAllIndexersUi { } fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let is_loading = app.is_loading || app.data.sonarr_data.indexer_test_all_results.is_none(); let current_selection = if let Some(test_all_results) = app.data.sonarr_data.indexer_test_all_results.as_ref() { test_all_results.current_selection().clone() @@ -77,7 +75,7 @@ fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, are test_results_row_mapping, ) .block(borderless_block()) - .loading(app.is_loading) + .loading(is_loading) .footer(Some(help_footer)) .footer_alignment(Alignment::Center) .margin(1) diff --git a/src/ui/sonarr_ui/library/add_series_ui.rs b/src/ui/sonarr_ui/library/add_series_ui.rs index 0e78792..42b1266 100644 --- a/src/ui/sonarr_ui/library/add_series_ui.rs +++ b/src/ui/sonarr_ui/library/add_series_ui.rs @@ -13,7 +13,6 @@ use crate::models::servarr_data::sonarr::modals::AddSeriesModal; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS}; use crate::models::sonarr_models::AddSeriesSearchResult; use crate::models::{EnumDisplayStyle, Route}; -use crate::ui::sonarr_ui::library::draw_library; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{ borderless_block, get_width_from_percentage, layout_block, layout_paragraph_borderless, @@ -26,7 +25,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::widgets::selectable_list::SelectableList; -use crate::ui::{draw_popup_over, DrawUi}; +use crate::ui::{draw_popup, DrawUi}; use crate::{render_selectable_input_box, App}; #[cfg(test)] @@ -44,50 +43,26 @@ impl DrawUi for AddSeriesUi { false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { - let draw_add_series_search_popup = - |f: &mut Frame<'_>, app: &mut App<'_>, area: Rect| match active_sonarr_block { - ActiveSonarrBlock::AddSeriesSearchInput - | ActiveSonarrBlock::AddSeriesSearchResults - | ActiveSonarrBlock::AddSeriesEmptySearchResults => { - draw_add_series_search(f, app, area); - } - ActiveSonarrBlock::AddSeriesPrompt - | ActiveSonarrBlock::AddSeriesSelectMonitor - | ActiveSonarrBlock::AddSeriesSelectSeriesType - | ActiveSonarrBlock::AddSeriesSelectQualityProfile - | ActiveSonarrBlock::AddSeriesSelectLanguageProfile - | ActiveSonarrBlock::AddSeriesSelectRootFolder - | ActiveSonarrBlock::AddSeriesTagsInput => { - draw_popup_over( - f, - app, - area, - draw_add_series_search, - draw_confirmation_popup, - Size::Long, - ); - } - ActiveSonarrBlock::AddSeriesAlreadyInLibrary => { - draw_add_series_search(f, app, area); - f.render_widget( - Popup::new(Message::new("This film is already in your library")).size(Size::Message), - f.area(), - ); - } - _ => (), - }; - + draw_popup(f, app, draw_add_series_search, Size::Large); + match active_sonarr_block { - _ if ADD_SERIES_BLOCKS.contains(&active_sonarr_block) => draw_popup_over( - f, - app, - area, - draw_library, - draw_add_series_search_popup, - Size::Large, - ), + ActiveSonarrBlock::AddSeriesPrompt + | ActiveSonarrBlock::AddSeriesSelectMonitor + | ActiveSonarrBlock::AddSeriesSelectSeriesType + | ActiveSonarrBlock::AddSeriesSelectQualityProfile + | ActiveSonarrBlock::AddSeriesSelectLanguageProfile + | ActiveSonarrBlock::AddSeriesSelectRootFolder + | ActiveSonarrBlock::AddSeriesTagsInput => { + draw_popup(f, app, draw_confirmation_popup, Size::Long); + } + ActiveSonarrBlock::AddSeriesAlreadyInLibrary => { + f.render_widget( + Popup::new(Message::new("This series is already in your library")).size(Size::Message), + f.area(), + ); + } _ => (), } } diff --git a/src/ui/sonarr_ui/library/delete_series_ui.rs b/src/ui/sonarr_ui/library/delete_series_ui.rs index c30c421..13e8ebb 100644 --- a/src/ui/sonarr_ui/library/delete_series_ui.rs +++ b/src/ui/sonarr_ui/library/delete_series_ui.rs @@ -4,7 +4,6 @@ use ratatui::Frame; use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DELETE_SERIES_BLOCKS}; use crate::models::Route; -use crate::ui::sonarr_ui::library::draw_library; use crate::ui::widgets::checkbox::Checkbox; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::popup::{Popup, Size}; @@ -25,14 +24,14 @@ impl DrawUi for DeleteSeriesUi { false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { if matches!( app.get_current_route(), Route::Sonarr(ActiveSonarrBlock::DeleteSeriesPrompt, _) ) { let selected_block = app.data.sonarr_data.selected_block.get_active_block(); let prompt = format!( - "Do you really want to delete: \n{}?", + "Do you really want to delete the series: \n{}?", app.data.sonarr_data.series.current_selection().title.text ); let checkboxes = vec![ @@ -50,7 +49,6 @@ impl DrawUi for DeleteSeriesUi { .yes_no_highlighted(selected_block == ActiveSonarrBlock::DeleteSeriesConfirmPrompt) .yes_no_value(app.data.sonarr_data.prompt_confirm); - draw_library(f, app, area); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), diff --git a/src/ui/sonarr_ui/library/edit_series_ui.rs b/src/ui/sonarr_ui/library/edit_series_ui.rs index 93a500e..394df83 100644 --- a/src/ui/sonarr_ui/library/edit_series_ui.rs +++ b/src/ui/sonarr_ui/library/edit_series_ui.rs @@ -14,7 +14,6 @@ use crate::models::servarr_data::sonarr::sonarr_data::{ }; use crate::models::{EnumDisplayStyle, Route}; use crate::render_selectable_input_box; -use crate::ui::sonarr_ui::library::draw_library; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{layout_paragraph_borderless, title_block_centered}; @@ -23,7 +22,7 @@ use crate::ui::widgets::checkbox::Checkbox; use crate::ui::widgets::input_box::InputBox; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::widgets::selectable_list::SelectableList; -use crate::ui::{draw_popup, draw_popup_over, draw_popup_over_ui, DrawUi}; +use crate::ui::{draw_popup, DrawUi}; use super::series_details_ui::SeriesDetailsUi; @@ -42,51 +41,33 @@ impl DrawUi for EditSeriesUi { false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { if let Route::Sonarr(active_sonarr_block, context_option) = app.get_current_route() { - let draw_edit_series_prompt = - |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| match active_sonarr_block { - ActiveSonarrBlock::EditSeriesSelectSeriesType => { - draw_edit_series_confirmation_prompt(f, app, prompt_area); - draw_edit_series_select_series_type_popup(f, app); - } - ActiveSonarrBlock::EditSeriesSelectQualityProfile => { - draw_edit_series_confirmation_prompt(f, app, prompt_area); - draw_edit_series_select_quality_profile_popup(f, app); - } - ActiveSonarrBlock::EditSeriesSelectLanguageProfile => { - draw_edit_series_confirmation_prompt(f, app, prompt_area); - draw_edit_series_select_language_profile_popup(f, app); - } - ActiveSonarrBlock::EditSeriesPrompt - | ActiveSonarrBlock::EditSeriesToggleMonitored - | ActiveSonarrBlock::EditSeriesToggleSeasonFolder - | ActiveSonarrBlock::EditSeriesPathInput - | ActiveSonarrBlock::EditSeriesTagsInput => { - draw_edit_series_confirmation_prompt(f, app, prompt_area) - } - _ => (), - }; - if let Some(context) = context_option { - match context { - ActiveSonarrBlock::Series => { - draw_popup_over( - f, - app, - area, - draw_library, - draw_edit_series_prompt, - Size::Long, - ); - } - _ if SERIES_DETAILS_BLOCKS.contains(&context) => { - draw_popup_over_ui::(f, app, area, draw_library, Size::Large); - draw_popup(f, app, draw_edit_series_prompt, Size::Long); - } - _ => (), + if SERIES_DETAILS_BLOCKS.contains(&context) { + draw_popup(f, app, SeriesDetailsUi::draw, Size::Large); } } + + let draw_edit_series_prompt = + |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| { + draw_edit_series_confirmation_prompt(f, app, prompt_area); + + match active_sonarr_block { + ActiveSonarrBlock::EditSeriesSelectSeriesType => { + draw_edit_series_select_series_type_popup(f, app); + } + ActiveSonarrBlock::EditSeriesSelectQualityProfile => { + draw_edit_series_select_quality_profile_popup(f, app); + } + ActiveSonarrBlock::EditSeriesSelectLanguageProfile => { + draw_edit_series_select_language_profile_popup(f, app); + } + _ => (), + } + }; + + draw_popup(f, app, draw_edit_series_prompt, Size::Long); } } } diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index 3cfe084..7fb879d 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -54,46 +54,30 @@ impl DrawUi for LibraryUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let route = app.get_current_route(); - let mut series_ui_matchers = |active_sonarr_block: ActiveSonarrBlock| match active_sonarr_block - { - ActiveSonarrBlock::Series - | ActiveSonarrBlock::SeriesSortPrompt - | ActiveSonarrBlock::SearchSeries - | ActiveSonarrBlock::SearchSeriesError - | ActiveSonarrBlock::FilterSeries - | ActiveSonarrBlock::FilterSeriesError => draw_library(f, app, area), - ActiveSonarrBlock::UpdateAllSeriesPrompt => { + draw_library(f, app, area); + + match route { + _ if AddSeriesUi::accepts(route) => AddSeriesUi::draw(f, app, area), + _ if DeleteSeriesUi::accepts(route) => DeleteSeriesUi::draw(f, app, area), + _ if EditSeriesUi::accepts(route) => EditSeriesUi::draw(f, app, area), + _ if SeriesDetailsUi::accepts(route) => SeriesDetailsUi::draw(f, app, area), + Route::Sonarr(ActiveSonarrBlock::UpdateAllSeriesPrompt, _) => { let confirmation_prompt = ConfirmationPrompt::new() .title("Update All Series") .prompt("Do you want to update info and scan your disks for all of your series?") .yes_no_value(app.data.sonarr_data.prompt_confirm); - draw_library(f, app, area); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), ); } _ => (), - }; - - match route { - _ if AddSeriesUi::accepts(route) => AddSeriesUi::draw(f, app, area), - _ if DeleteSeriesUi::accepts(route) => DeleteSeriesUi::draw(f, app, area), - _ if EditSeriesUi::accepts(route) => EditSeriesUi::draw(f, app, area), - _ if SeriesDetailsUi::accepts(route) => { - draw_library(f, app, area); - SeriesDetailsUi::draw(f, app, area) - }, - Route::Sonarr(active_sonarr_block, _) if LIBRARY_BLOCKS.contains(&active_sonarr_block) => { - series_ui_matchers(active_sonarr_block) - } - _ => (), } } } -pub(super) fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { +fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { let current_selection = if !app.data.sonarr_data.series.items.is_empty() { app.data.sonarr_data.series.current_selection().clone() diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index 40dc905..3e763f4 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -12,6 +12,7 @@ use crate::models::sonarr_models::{ Season, SeasonStatistics, SonarrHistoryEventType, SonarrHistoryItem, }; use crate::models::{EnumDisplayStyle, Route}; +use crate::ui::sonarr_ui::library::season_details_ui::SeasonDetailsUi; use crate::ui::sonarr_ui::sonarr_ui_utils::{ create_download_failed_history_event_details, create_download_folder_imported_history_event_details, @@ -28,12 +29,9 @@ use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; -use crate::ui::{draw_popup, draw_popup_over, draw_tabs, DrawUi}; -use crate::ui::sonarr_ui::library::season_details_ui::SeasonDetailsUi; +use crate::ui::{draw_popup, draw_tabs, DrawUi}; use crate::utils::convert_to_gb; -use super::draw_library; - #[cfg(test)] #[path = "series_details_ui_tests.rs"] mod series_details_ui_tests; @@ -107,28 +105,16 @@ impl DrawUi for SeriesDetailsUi { }; }; - match route { - _ if SeasonDetailsUi::accepts(route) => { - draw_popup(f, app, draw_series_details_popup, Size::XXLarge); - SeasonDetailsUi::draw(f, app, area); - }, - Route::Sonarr(active_sonarr_block, _) if SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block) => { - draw_popup_over( - f, - app, - area, - draw_library, - draw_series_details_popup, - Size::XXLarge, - ); - } - _ => (), + draw_popup(f, app, draw_series_details_popup, Size::XXLarge); + + if SeasonDetailsUi::accepts(route) { + SeasonDetailsUi::draw(f, app, area); } } } } -pub fn draw_series_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { +fn draw_series_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let current_selection = app.data.sonarr_data.series.current_selection(); let monitored = if current_selection.monitored { "Yes" diff --git a/src/ui/sonarr_ui/root_folders/mod.rs b/src/ui/sonarr_ui/root_folders/mod.rs index d94b890..15a37a3 100644 --- a/src/ui/sonarr_ui/root_folders/mod.rs +++ b/src/ui/sonarr_ui/root_folders/mod.rs @@ -11,7 +11,7 @@ use crate::ui::utils::layout_block_top_border; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::{Popup, Size}; -use crate::ui::{draw_input_box_popup, draw_popup_over, DrawUi}; +use crate::ui::{draw_input_box_popup, draw_popup, DrawUi}; use crate::utils::convert_to_gb; #[cfg(test)] @@ -31,13 +31,12 @@ impl DrawUi for RootFoldersUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + draw_root_folders(f, app, area); + match active_sonarr_block { - ActiveSonarrBlock::RootFolders => draw_root_folders(f, app, area), - ActiveSonarrBlock::AddRootFolderPrompt => draw_popup_over( + ActiveSonarrBlock::AddRootFolderPrompt => draw_popup( f, app, - area, - draw_root_folders, draw_add_root_folder_prompt_box, Size::InputBox, ), @@ -51,7 +50,6 @@ impl DrawUi for RootFoldersUi { .prompt(&prompt) .yes_no_value(app.data.sonarr_data.prompt_confirm); - draw_root_folders(f, app, area); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), diff --git a/src/ui/sonarr_ui/system/mod.rs b/src/ui/sonarr_ui/system/mod.rs index f9e6c88..2a5f9fb 100644 --- a/src/ui/sonarr_ui/system/mod.rs +++ b/src/ui/sonarr_ui/system/mod.rs @@ -57,18 +57,15 @@ impl DrawUi for SystemUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let route = app.get_current_route(); - - match route { - _ if SystemDetailsUi::accepts(route) => SystemDetailsUi::draw(f, app, area), - _ if matches!(route, Route::Sonarr(ActiveSonarrBlock::System, _)) => { - draw_system_ui_layout(f, app, area) - } - _ => (), + draw_system_ui_layout(f, app, area); + + if SystemDetailsUi::accepts(route) { + SystemDetailsUi::draw(f, app, area); } } } -pub(super) fn draw_system_ui_layout(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { +fn draw_system_ui_layout(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let [activities_area, logs_area, help_area] = Layout::vertical([ Constraint::Ratio(1, 2), Constraint::Ratio(1, 2), diff --git a/src/ui/sonarr_ui/system/system_details_ui.rs b/src/ui/sonarr_ui/system/system_details_ui.rs index 7aa79a2..f2eb60e 100644 --- a/src/ui/sonarr_ui/system/system_details_ui.rs +++ b/src/ui/sonarr_ui/system/system_details_ui.rs @@ -10,7 +10,7 @@ use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SYSTEM use crate::models::sonarr_models::SonarrTask; use crate::models::Route; use crate::ui::sonarr_ui::system::{ - draw_queued_events, draw_system_ui_layout, extract_task_props, TASK_TABLE_CONSTRAINTS, + draw_queued_events, extract_task_props, TASK_TABLE_CONSTRAINTS, TASK_TABLE_HEADERS, }; use crate::ui::styles::ManagarrStyle; @@ -20,7 +20,7 @@ use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::widgets::selectable_list::SelectableList; -use crate::ui::{draw_popup_over, DrawUi}; +use crate::ui::{draw_popup, DrawUi}; #[cfg(test)] #[path = "system_details_ui_tests.rs"] @@ -37,33 +37,27 @@ impl DrawUi for SystemDetailsUi { false } - fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { match active_sonarr_block { ActiveSonarrBlock::SystemLogs => { - draw_system_ui_layout(f, app, area); draw_logs_popup(f, app); } ActiveSonarrBlock::SystemTasks | ActiveSonarrBlock::SystemTaskStartConfirmPrompt => { - draw_popup_over( + draw_popup( f, app, - area, - draw_system_ui_layout, draw_tasks_popup, Size::Large, ) } - ActiveSonarrBlock::SystemQueuedEvents => draw_popup_over( + ActiveSonarrBlock::SystemQueuedEvents => draw_popup( f, app, - area, - draw_system_ui_layout, draw_queued_events, Size::Medium, ), ActiveSonarrBlock::SystemUpdates => { - draw_system_ui_layout(f, app, area); draw_updates_popup(f, app); } _ => (), @@ -158,7 +152,7 @@ fn draw_updates_popup(f: &mut Frame<'_>, app: &mut App<'_>) { let updates = app.data.sonarr_data.updates.get_text(); let block = title_block("Updates"); - if !updates.is_empty() { + if !updates.is_empty() && !app.is_loading { let updates_paragraph = Paragraph::new(Text::from(updates)) .block(borderless_block()) .scroll((app.data.sonarr_data.updates.offset, 0)); From ed2211586e3abf3dfac9550d3f114abf6569086a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 11 Dec 2024 17:03:52 -0700 Subject: [PATCH 63/82] refactor(handlers): Refactored the handlers to all use the handle_table_events macro when appropriate and created tests for the macro so tests don't have to be duplicated across each handler --- src/handlers/handler_test_utils.rs | 42 +- src/handlers/mod.rs | 8 +- .../blocklist/blocklist_handler_tests.rs | 331 ---- src/handlers/radarr_handlers/blocklist/mod.rs | 9 +- .../collections/collection_details_handler.rs | 10 +- .../collection_details_handler_tests.rs | 138 -- .../collections/collections_handler_tests.rs | 1128 +------------ .../radarr_handlers/collections/mod.rs | 10 +- .../downloads/downloads_handler_tests.rs | 106 -- src/handlers/radarr_handlers/downloads/mod.rs | 9 +- .../indexers/indexers_handler_tests.rs | 104 -- src/handlers/radarr_handlers/indexers/mod.rs | 8 +- .../indexers/test_all_indexers_handler.rs | 82 +- .../test_all_indexers_handler_tests.rs | 215 --- .../library/add_movie_handler.rs | 60 +- .../library/add_movie_handler_tests.rs | 220 --- .../library/library_handler_tests.rs | 958 ----------- src/handlers/radarr_handlers/library/mod.rs | 8 +- .../library/movie_details_handler.rs | 22 +- .../library/movie_details_handler_tests.rs | 1067 +------------ .../radarr_handlers/root_folders/mod.rs | 10 +- .../root_folders_handler_tests.rs | 103 +- .../system/system_handler_tests.rs | 1 - .../blocklist/blocklist_handler_tests.rs | 334 ---- src/handlers/sonarr_handlers/blocklist/mod.rs | 96 +- .../downloads/downloads_handler_tests.rs | 108 -- src/handlers/sonarr_handlers/downloads/mod.rs | 9 +- .../history/history_handler_tests.rs | 1039 +----------- src/handlers/sonarr_handlers/history/mod.rs | 7 +- .../indexers/indexers_handler_tests.rs | 112 +- src/handlers/sonarr_handlers/indexers/mod.rs | 8 +- .../indexers/test_all_indexers_handler.rs | 82 +- .../test_all_indexers_handler_tests.rs | 219 --- .../library/add_series_handler.rs | 60 +- .../library/add_series_handler_tests.rs | 224 --- .../library/library_handler_tests.rs | 995 +----------- src/handlers/sonarr_handlers/library/mod.rs | 8 +- .../library/series_details_handler.rs | 16 +- .../library/series_details_handler_tests.rs | 1403 +---------------- .../sonarr_handlers/root_folders/mod.rs | 10 +- .../root_folders_handler_tests.rs | 105 +- .../system/system_handler_tests.rs | 1 - src/handlers/table_handler.rs | 174 +- src/handlers/table_handler_tests.rs | 1221 ++++++++++++++ 44 files changed, 1592 insertions(+), 9288 deletions(-) create mode 100644 src/handlers/table_handler_tests.rs diff --git a/src/handlers/handler_test_utils.rs b/src/handlers/handler_test_utils.rs index 122c35e..1a49e94 100644 --- a/src/handlers/handler_test_utils.rs +++ b/src/handlers/handler_test_utils.rs @@ -112,14 +112,14 @@ mod test_utils { $handler::with(&key, &mut app, &$block, &$context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app.data.$servarr_data.$data_ref.current_selection(), "Test 2" ); $handler::with(&key, &mut app, &$block, &$context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app.data.$servarr_data.$data_ref.current_selection(), "Test 1" ); @@ -139,14 +139,14 @@ mod test_utils { $handler::with(key, &mut app, $block, $context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app.data.$servarr_data.$data_ref.current_selection().$field, "Test 2" ); $handler::with(key, &mut app, $block, $context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app.data.$servarr_data.$data_ref.current_selection().$field, "Test 1" ); @@ -162,14 +162,14 @@ mod test_utils { $handler::with(key, &mut app, $block, $context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app.data.$servarr_data.$data_ref.current_selection().$field, "Test 2" ); $handler::with(key, &mut app, $block, $context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app.data.$servarr_data.$data_ref.current_selection().$field, "Test 1" ); @@ -185,7 +185,7 @@ mod test_utils { $handler::with(key, &mut app, $block, $context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app .data .$servarr_data @@ -198,7 +198,7 @@ mod test_utils { $handler::with(key, &mut app, $block, $context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app .data .$servarr_data @@ -227,14 +227,14 @@ mod test_utils { $handler::with(DEFAULT_KEYBINDINGS.end.key, &mut app, $block, $context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app.data.$servarr_data.$data_ref.current_selection(), "Test 3" ); $handler::with(DEFAULT_KEYBINDINGS.home.key, &mut app, $block, $context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app.data.$servarr_data.$data_ref.current_selection(), "Test 1" ); @@ -254,14 +254,14 @@ mod test_utils { $handler::with(DEFAULT_KEYBINDINGS.end.key, &mut app, $block, $context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app.data.$servarr_data.$data_ref.current_selection().$field, "Test 3" ); $handler::with(DEFAULT_KEYBINDINGS.home.key, &mut app, $block, $context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app.data.$servarr_data.$data_ref.current_selection().$field, "Test 1" ); @@ -277,14 +277,14 @@ mod test_utils { $handler::with(DEFAULT_KEYBINDINGS.end.key, &mut app, $block, $context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app.data.$servarr_data.$data_ref.current_selection().$field, "Test 3" ); $handler::with(DEFAULT_KEYBINDINGS.home.key, &mut app, $block, $context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app.data.$servarr_data.$data_ref.current_selection().$field, "Test 1" ); @@ -300,7 +300,7 @@ mod test_utils { $handler::with(DEFAULT_KEYBINDINGS.end.key, &mut app, $block, $context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app .data .$servarr_data @@ -313,7 +313,7 @@ mod test_utils { $handler::with(DEFAULT_KEYBINDINGS.home.key, &mut app, $block, $context).handle(); - assert_str_eq!( + pretty_assertions::assert_str_eq!( app .data .$servarr_data @@ -335,12 +335,14 @@ mod test_utils { app.data.sonarr_data.root_folders.set_items(vec![$crate::models::servarr_models::RootFolder::default()]); app.data.sonarr_data.indexers.set_items(vec![$crate::models::servarr_models::Indexer::default()]); app.data.sonarr_data.blocklist.set_items(vec![$crate::models::sonarr_models::BlocklistItem::default()]); + app.data.sonarr_data.add_searched_series = Some($crate::models::stateful_table::StatefulTable::default()); app.data.radarr_data.movies.set_items(vec![$crate::models::radarr_models::Movie::default()]); app.data.radarr_data.collections.set_items(vec![$crate::models::radarr_models::Collection::default()]); app.data.radarr_data.collection_movies.set_items(vec![$crate::models::radarr_models::CollectionMovie::default()]); app.data.radarr_data.indexers.set_items(vec![$crate::models::servarr_models::Indexer::default()]); app.data.radarr_data.root_folders.set_items(vec![$crate::models::servarr_models::RootFolder::default()]); app.data.radarr_data.blocklist.set_items(vec![$crate::models::radarr_models::BlocklistItem::default()]); + app.data.radarr_data.add_searched_movies = Some($crate::models::stateful_table::StatefulTable::default()); let mut movie_details_modal = $crate::models::servarr_data::radarr::modals::MovieDetailsModal::default(); movie_details_modal .movie_history @@ -362,7 +364,7 @@ mod test_utils { $handler::with(DEFAULT_KEYBINDINGS.esc.key, &mut app, $active_block, None).handle(); - assert_eq!(app.get_current_route(), $base.into()); + pretty_assertions::assert_eq!(app.get_current_route(), $base.into()); }; } @@ -373,13 +375,13 @@ mod test_utils { $handler::with(DELETE_KEY, &mut app, $block, None).handle(); - assert_eq!(app.get_current_route(), $expected_block.into()); + pretty_assertions::assert_eq!(app.get_current_route(), $expected_block.into()); }; ($handler:ident, $app:expr, $block:expr, $expected_block:expr) => { $handler::with(DELETE_KEY, &mut $app, $block, None).handle(); - assert_eq!($app.get_current_route(), $expected_block.into()); + pretty_assertions::assert_eq!($app.get_current_route(), $expected_block.into()); }; } @@ -391,7 +393,7 @@ mod test_utils { $handler::with(DEFAULT_KEYBINDINGS.refresh.key, &mut app, $block, None).handle(); - assert_eq!(app.get_current_route(), $block.into()); + pretty_assertions::assert_eq!(app.get_current_route(), $block.into()); assert!(app.should_refresh); }; } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index e3d2ddc..ed2a906 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -132,10 +132,10 @@ fn handle_prompt_toggle(app: &mut App<'_>, key: Key) { macro_rules! handle_text_box_left_right_keys { ($self:expr, $key:expr, $input:expr) => { match $self.key { - _ if $key == DEFAULT_KEYBINDINGS.left.key => { + _ if $key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.left.key => { $input.scroll_left(); } - _ if $key == DEFAULT_KEYBINDINGS.right.key => { + _ if $key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.right.key => { $input.scroll_right(); } _ => (), @@ -147,7 +147,7 @@ macro_rules! handle_text_box_left_right_keys { macro_rules! handle_text_box_keys { ($self:expr, $key:expr, $input:expr) => { match $self.key { - _ if $key == DEFAULT_KEYBINDINGS.backspace.key => { + _ if $key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.backspace.key => { $input.pop(); } Key::Char(character) => { @@ -163,7 +163,7 @@ macro_rules! handle_prompt_left_right_keys { ($self:expr, $confirm_prompt:expr, $data:ident) => { if $self.app.data.$data.selected_block.get_active_block() == $confirm_prompt { handle_prompt_toggle($self.app, $self.key); - } else if $self.key == DEFAULT_KEYBINDINGS.left.key { + } else if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.left.key { $self.app.data.$data.selected_block.left(); } else { $self.app.data.$data.selected_block.right(); diff --git a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs index bfb9bce..2a44f7f 100644 --- a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs +++ b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs @@ -14,238 +14,6 @@ mod tests { use crate::models::radarr_models::{BlocklistItem, BlocklistItemMovie}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, BLOCKLIST_BLOCKS}; use crate::models::servarr_models::{Language, Quality, QualityWrapper}; - use crate::models::stateful_table::SortOption; - - mod test_handle_scroll_up_and_down { - use pretty_assertions::{assert_eq, assert_str_eq}; - use rstest::rstest; - - use crate::models::radarr_models::BlocklistItem; - use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; - - use super::*; - - test_iterable_scroll!( - test_blocklist_scroll, - BlocklistHandler, - radarr_data, - blocklist, - simple_stateful_iterable_vec!(BlocklistItem, String, source_title), - ActiveRadarrBlock::Blocklist, - None, - source_title, - to_string - ); - - #[rstest] - fn test_blocklist_scroll_no_op_when_not_ready( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.is_loading = true; - app - .data - .radarr_data - .blocklist - .set_items(simple_stateful_iterable_vec!( - BlocklistItem, - String, - source_title - )); - - BlocklistHandler::with(key, &mut app, ActiveRadarrBlock::Blocklist, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .blocklist - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - - BlocklistHandler::with(key, &mut app, ActiveRadarrBlock::Blocklist, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .blocklist - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - } - - #[rstest] - fn test_blocklist_sort_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let blocklist_field_vec = sort_options(); - let mut app = App::default(); - app.data.radarr_data.blocklist.sorting(sort_options()); - - if key == Key::Up { - for i in (0..blocklist_field_vec.len()).rev() { - BlocklistHandler::with(key, &mut app, ActiveRadarrBlock::BlocklistSortPrompt, None) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .blocklist - .sort - .as_ref() - .unwrap() - .current_selection(), - &blocklist_field_vec[i] - ); - } - } else { - for i in 0..blocklist_field_vec.len() { - BlocklistHandler::with(key, &mut app, ActiveRadarrBlock::BlocklistSortPrompt, None) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .blocklist - .sort - .as_ref() - .unwrap() - .current_selection(), - &blocklist_field_vec[(i + 1) % blocklist_field_vec.len()] - ); - } - } - } - } - - mod test_handle_home_end { - use pretty_assertions::{assert_eq, assert_str_eq}; - - use crate::models::radarr_models::BlocklistItem; - use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; - - use super::*; - - test_iterable_home_and_end!( - test_blocklist_home_and_end, - BlocklistHandler, - radarr_data, - blocklist, - extended_stateful_iterable_vec!(BlocklistItem, String, source_title), - ActiveRadarrBlock::Blocklist, - None, - source_title, - to_string - ); - - #[test] - fn test_blocklist_home_and_end_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app - .data - .radarr_data - .blocklist - .set_items(extended_stateful_iterable_vec!( - BlocklistItem, - String, - source_title - )); - - BlocklistHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::Blocklist, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .blocklist - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - - BlocklistHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::Blocklist, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .blocklist - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - } - - #[test] - fn test_blocklist_sort_home_end() { - let blocklist_field_vec = sort_options(); - let mut app = App::default(); - app.data.radarr_data.blocklist.sorting(sort_options()); - - BlocklistHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::BlocklistSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .blocklist - .sort - .as_ref() - .unwrap() - .current_selection(), - &blocklist_field_vec[blocklist_field_vec.len() - 1] - ); - - BlocklistHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::BlocklistSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .blocklist - .sort - .as_ref() - .unwrap() - .current_selection(), - &blocklist_field_vec[0] - ); - } - } mod test_handle_delete { use pretty_assertions::assert_eq; @@ -439,31 +207,6 @@ mod tests { assert_eq!(app.data.radarr_data.prompt_confirm_action, None); assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); } - - #[test] - fn test_blocklist_sort_prompt_submit() { - let mut app = App::default(); - app.data.radarr_data.blocklist.sort_asc = true; - app.data.radarr_data.blocklist.sorting(sort_options()); - app.data.radarr_data.blocklist.set_items(blocklist_vec()); - app.push_navigation_stack(ActiveRadarrBlock::Blocklist.into()); - app.push_navigation_stack(ActiveRadarrBlock::BlocklistSortPrompt.into()); - - let mut expected_vec = blocklist_vec(); - expected_vec.sort_by(|a, b| a.id.cmp(&b.id)); - expected_vec.reverse(); - - BlocklistHandler::with( - SUBMIT_KEY, - &mut app, - ActiveRadarrBlock::BlocklistSortPrompt, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); - assert_eq!(app.data.radarr_data.blocklist.items, expected_vec); - } } mod test_handle_esc { @@ -517,24 +260,6 @@ mod tests { assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); } - #[test] - fn test_blocklist_sort_prompt_block_esc() { - let mut app = App::default(); - app.data.radarr_data.blocklist.set_items(vec![BlocklistItem::default()]); - app.push_navigation_stack(ActiveRadarrBlock::Blocklist.into()); - app.push_navigation_stack(ActiveRadarrBlock::BlocklistSortPrompt.into()); - - BlocklistHandler::with( - ESC_KEY, - &mut app, - ActiveRadarrBlock::BlocklistSortPrompt, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); - } - #[rstest] fn test_default_esc(#[values(true, false)] is_ready: bool) { let mut app = App::default(); @@ -632,51 +357,6 @@ mod tests { assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); } - #[test] - fn test_sort_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Blocklist.into()); - app.data.radarr_data.blocklist.set_items(blocklist_vec()); - - BlocklistHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveRadarrBlock::Blocklist, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::BlocklistSortPrompt.into() - ); - assert_eq!( - app.data.radarr_data.blocklist.sort.as_ref().unwrap().items, - blocklist_sorting_options() - ); - assert!(!app.data.radarr_data.blocklist.sort_asc); - } - - #[test] - fn test_sort_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveRadarrBlock::Blocklist.into()); - app.data.radarr_data.blocklist.set_items(blocklist_vec()); - - BlocklistHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveRadarrBlock::Blocklist, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveRadarrBlock::Blocklist.into()); - assert!(app.data.radarr_data.blocklist.sort.is_none()); - assert!(!app.data.radarr_data.blocklist.sort_asc); - } - #[rstest] #[case( ActiveRadarrBlock::Blocklist, @@ -987,15 +667,4 @@ mod tests { }, ] } - - fn sort_options() -> Vec> { - vec![SortOption { - name: "Test 1", - cmp_fn: Some(|a, b| { - b.source_title - .to_lowercase() - .cmp(&a.source_title.to_lowercase()) - }), - }] - } } diff --git a/src/handlers/radarr_handlers/blocklist/mod.rs b/src/handlers/radarr_handlers/blocklist/mod.rs index 372412b..2f23683 100644 --- a/src/handlers/radarr_handlers/blocklist/mod.rs +++ b/src/handlers/radarr_handlers/blocklist/mod.rs @@ -3,12 +3,11 @@ use crate::app::App; use crate::event::Key; use crate::handle_table_events; use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; -use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::radarr_models::BlocklistItem; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, BLOCKLIST_BLOCKS}; use crate::models::stateful_table::SortOption; -use crate::models::Scrollable; use crate::network::radarr_network::RadarrEvent; #[cfg(test)] @@ -33,13 +32,13 @@ impl<'a, 'b> BlocklistHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a, 'b> { fn handle(&mut self) { - let blocklist_table_handling_props = - TableHandlingProps::new(ActiveRadarrBlock::Blocklist.into()) + let blocklist_table_handling_config = + TableHandlingConfig::new(ActiveRadarrBlock::Blocklist.into()) .sorting_block(ActiveRadarrBlock::BlocklistSortPrompt.into()) .sort_by_fn(|a: &BlocklistItem, b: &BlocklistItem| a.id.cmp(&b.id)) .sort_options(blocklist_sorting_options()); - if !self.handle_blocklist_table_events(blocklist_table_handling_props) { + if !self.handle_blocklist_table_events(blocklist_table_handling_config) { self.handle_key_event(); } } diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler.rs b/src/handlers/radarr_handlers/collections/collection_details_handler.rs index a058d7e..1d13768 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler.rs @@ -2,7 +2,7 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; use crate::handle_table_events; -use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::KeyEventHandler; use crate::models::radarr_models::CollectionMovie; use crate::models::servarr_data::radarr::radarr_data::{ @@ -10,7 +10,7 @@ use crate::models::servarr_data::radarr::radarr_data::{ EDIT_COLLECTION_SELECTION_BLOCKS, }; use crate::models::stateful_table::StatefulTable; -use crate::models::{BlockSelectionState, Scrollable}; +use crate::models::BlockSelectionState; #[cfg(test)] #[path = "collection_details_handler_tests.rs"] @@ -34,10 +34,10 @@ impl<'a, 'b> CollectionDetailsHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHandler<'a, 'b> { fn handle(&mut self) { - let collection_movies_table_handling_props = - TableHandlingProps::new(ActiveRadarrBlock::CollectionDetails.into()); + let collection_movies_table_handling_config = + TableHandlingConfig::new(ActiveRadarrBlock::CollectionDetails.into()); - if !self.handle_collection_movies_table_events(collection_movies_table_handling_props) { + if !self.handle_collection_movies_table_events(collection_movies_table_handling_config) { self.handle_key_event(); } } diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs b/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs index 294fffc..c23a644 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs @@ -12,144 +12,6 @@ mod tests { use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, }; - use crate::models::HorizontallyScrollableText; - - mod test_handle_scroll_up_and_down { - use rstest::rstest; - - use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; - - use super::*; - - test_iterable_scroll!( - test_collection_details_scroll, - CollectionDetailsHandler, - radarr_data, - collection_movies, - simple_stateful_iterable_vec!(CollectionMovie, HorizontallyScrollableText), - ActiveRadarrBlock::CollectionDetails, - None, - title, - to_string - ); - - #[rstest] - fn test_collection_details_scroll_no_op_when_not_ready( - #[values( - DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key - )] - key: Key, - ) { - let mut app = App::default(); - app.is_loading = true; - app - .data - .radarr_data - .collection_movies - .set_items(simple_stateful_iterable_vec!( - CollectionMovie, - HorizontallyScrollableText - )); - - CollectionDetailsHandler::with(key, &mut app, ActiveRadarrBlock::CollectionDetails, None) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .collection_movies - .current_selection() - .title - .to_string(), - "Test 1" - ); - - CollectionDetailsHandler::with(key, &mut app, ActiveRadarrBlock::CollectionDetails, None) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .collection_movies - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - } - - mod test_handle_home_end { - use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; - - use super::*; - - test_iterable_home_and_end!( - test_collection_details_home_end, - CollectionDetailsHandler, - radarr_data, - collection_movies, - extended_stateful_iterable_vec!(CollectionMovie, HorizontallyScrollableText), - ActiveRadarrBlock::CollectionDetails, - None, - title, - to_string - ); - - #[test] - fn test_collection_details_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app - .data - .radarr_data - .collection_movies - .set_items(extended_stateful_iterable_vec!( - CollectionMovie, - HorizontallyScrollableText - )); - - CollectionDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::CollectionDetails, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .collection_movies - .current_selection() - .title - .to_string(), - "Test 1" - ); - - CollectionDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::CollectionDetails, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .collection_movies - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - } mod test_handle_submit { use bimap::BiMap; diff --git a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs index a62a6aa..a5d72ae 100644 --- a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod tests { - use core::sync::atomic::Ordering::SeqCst; use std::cmp::Ordering; use std::iter; @@ -19,357 +18,7 @@ mod tests { use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, COLLECTIONS_BLOCKS, COLLECTION_DETAILS_BLOCKS, EDIT_COLLECTION_BLOCKS, }; - use crate::models::stateful_table::SortOption; - use crate::models::HorizontallyScrollableText; - use crate::{extended_stateful_iterable_vec, test_handler_delegation}; - - mod test_handle_scroll_up_and_down { - use pretty_assertions::assert_eq; - use rstest::rstest; - - use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; - - use super::*; - - test_iterable_scroll!( - test_collections_scroll, - CollectionsHandler, - radarr_data, - collections, - simple_stateful_iterable_vec!(Collection, HorizontallyScrollableText), - ActiveRadarrBlock::Collections, - None, - title, - to_string - ); - - #[rstest] - fn test_collections_scroll_no_op_when_not_ready( - #[values( - DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key - )] - key: Key, - ) { - let mut app = App::default(); - app.is_loading = true; - app - .data - .radarr_data - .collections - .set_items(simple_stateful_iterable_vec!( - Collection, - HorizontallyScrollableText - )); - - CollectionsHandler::with(key, &mut app, ActiveRadarrBlock::Collections, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .collections - .current_selection() - .title - .to_string(), - "Test 1" - ); - - CollectionsHandler::with(key, &mut app, ActiveRadarrBlock::Collections, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .collections - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - - #[rstest] - fn test_collections_sort_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let collection_field_vec = sort_options(); - let mut app = App::default(); - app.data.radarr_data.collections.sorting(sort_options()); - - if key == Key::Up { - for i in (0..collection_field_vec.len()).rev() { - CollectionsHandler::with( - key, - &mut app, - ActiveRadarrBlock::CollectionsSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .collections - .sort - .as_ref() - .unwrap() - .current_selection(), - &collection_field_vec[i] - ); - } - } else { - for i in 0..collection_field_vec.len() { - CollectionsHandler::with( - key, - &mut app, - ActiveRadarrBlock::CollectionsSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .collections - .sort - .as_ref() - .unwrap() - .current_selection(), - &collection_field_vec[(i + 1) % collection_field_vec.len()] - ); - } - } - } - } - - mod test_handle_home_end { - use pretty_assertions::assert_eq; - - use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; - - use super::*; - - test_iterable_home_and_end!( - test_collections_home_end, - CollectionsHandler, - radarr_data, - collections, - extended_stateful_iterable_vec!(Collection, HorizontallyScrollableText), - ActiveRadarrBlock::Collections, - None, - title, - to_string - ); - - #[test] - fn test_collections_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app - .data - .radarr_data - .collections - .set_items(extended_stateful_iterable_vec!( - Collection, - HorizontallyScrollableText - )); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::Collections, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .collections - .current_selection() - .title - .to_string(), - "Test 1" - ); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::Collections, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .collections - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - - #[test] - fn test_collection_search_box_home_end_keys() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::SearchCollection.into()); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - app.data.radarr_data.collections.search = Some("Test".into()); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::SearchCollection, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .collections - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 4 - ); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::SearchCollection, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .collections - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } - - #[test] - fn test_collection_filter_box_home_end_keys() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::FilterCollections.into()); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - app.data.radarr_data.collections.filter = Some("Test".into()); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::FilterCollections, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .collections - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 4 - ); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::FilterCollections, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .collections - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } - - #[test] - fn test_collections_sort_home_end() { - let collection_field_vec = sort_options(); - let mut app = App::default(); - app.data.radarr_data.collections.sorting(sort_options()); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::CollectionsSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .collections - .sort - .as_ref() - .unwrap() - .current_selection(), - &collection_field_vec[collection_field_vec.len() - 1] - ); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::CollectionsSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .collections - .sort - .as_ref() - .unwrap() - .current_selection(), - &collection_field_vec[0] - ); - } - } + use crate::test_handler_delegation; mod test_handle_left_right_action { use pretty_assertions::assert_eq; @@ -445,114 +94,6 @@ mod tests { assert!(!app.data.radarr_data.prompt_confirm); } - - #[test] - fn test_collection_search_box_left_right_keys() { - let mut app = App::default(); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - app.push_navigation_stack(ActiveRadarrBlock::SearchCollection.into()); - app.data.radarr_data.collections.search = Some("Test".into()); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.left.key, - &mut app, - ActiveRadarrBlock::SearchCollection, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .collections - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 1 - ); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.right.key, - &mut app, - ActiveRadarrBlock::SearchCollection, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .collections - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } - - #[test] - fn test_collection_filter_box_left_right_keys() { - let mut app = App::default(); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - app.push_navigation_stack(ActiveRadarrBlock::FilterCollections.into()); - app.data.radarr_data.collections.filter = Some("Test".into()); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.left.key, - &mut app, - ActiveRadarrBlock::FilterCollections, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .collections - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 1 - ); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.right.key, - &mut app, - ActiveRadarrBlock::FilterCollections, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .collections - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } } mod test_handle_submit { @@ -600,206 +141,6 @@ mod tests { ); } - #[test] - fn test_search_collections_submit() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app.push_navigation_stack(ActiveRadarrBlock::SearchCollection.into()); - app - .data - .radarr_data - .collections - .set_items(extended_stateful_iterable_vec!( - Collection, - HorizontallyScrollableText - )); - app.data.radarr_data.collections.search = Some("Test 2".into()); - - CollectionsHandler::with( - SUBMIT_KEY, - &mut app, - ActiveRadarrBlock::SearchCollection, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .collections - .current_selection() - .title - .text, - "Test 2" - ); - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::Collections.into() - ); - } - - #[test] - fn test_search_collections_submit_error_on_no_search_hits() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app.push_navigation_stack(ActiveRadarrBlock::SearchCollection.into()); - app - .data - .radarr_data - .collections - .set_items(extended_stateful_iterable_vec!( - Collection, - HorizontallyScrollableText - )); - app.data.radarr_data.collections.search = Some("Test 5".into()); - - CollectionsHandler::with( - SUBMIT_KEY, - &mut app, - ActiveRadarrBlock::SearchCollection, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .collections - .current_selection() - .title - .text, - "Test 1" - ); - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::SearchCollectionError.into() - ); - } - - #[test] - fn test_search_filtered_collections_submit() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app.push_navigation_stack(ActiveRadarrBlock::SearchCollection.into()); - app - .data - .radarr_data - .collections - .set_items(extended_stateful_iterable_vec!( - Collection, - HorizontallyScrollableText - )); - app.data.radarr_data.collections.search = Some("Test 2".into()); - - CollectionsHandler::with( - SUBMIT_KEY, - &mut app, - ActiveRadarrBlock::SearchCollection, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .collections - .current_selection() - .title - .text, - "Test 2" - ); - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::Collections.into() - ); - } - - #[test] - fn test_filter_collections_submit() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app.push_navigation_stack(ActiveRadarrBlock::FilterCollections.into()); - app - .data - .radarr_data - .collections - .set_items(extended_stateful_iterable_vec!( - Collection, - HorizontallyScrollableText - )); - app.data.radarr_data.collections.filter = Some("Test".into()); - - CollectionsHandler::with( - SUBMIT_KEY, - &mut app, - ActiveRadarrBlock::FilterCollections, - None, - ) - .handle(); - - assert!(!app.should_ignore_quit_key); - assert!(app.data.radarr_data.collections.filtered_items.is_some()); - assert_eq!( - app - .data - .radarr_data - .collections - .filtered_items - .as_ref() - .unwrap() - .len(), - 3 - ); - assert_str_eq!( - app - .data - .radarr_data - .collections - .current_selection() - .title - .text, - "Test 1" - ); - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::Collections.into() - ); - } - - #[test] - fn test_filter_collections_submit_error_on_no_filter_matches() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app.push_navigation_stack(ActiveRadarrBlock::FilterCollections.into()); - app - .data - .radarr_data - .collections - .set_items(extended_stateful_iterable_vec!( - Collection, - HorizontallyScrollableText - )); - app.data.radarr_data.collections.filter = Some("Test 5".into()); - - CollectionsHandler::with( - SUBMIT_KEY, - &mut app, - ActiveRadarrBlock::FilterCollections, - None, - ) - .handle(); - - assert!(!app.should_ignore_quit_key); - assert!(app.data.radarr_data.collections.filtered_items.is_none()); - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::FilterCollectionsError.into() - ); - } - #[test] fn test_update_all_collections_prompt_confirm_submit() { let mut app = App::default(); @@ -857,113 +198,17 @@ mod tests { ActiveRadarrBlock::Collections.into() ); } - - #[test] - fn test_collections_sort_prompt_submit() { - let mut app = App::default(); - app.data.radarr_data.collections.sort_asc = true; - app.data.radarr_data.collections.sorting(sort_options()); - app - .data - .radarr_data - .collections - .set_items(collections_vec()); - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app.push_navigation_stack(ActiveRadarrBlock::CollectionsSortPrompt.into()); - - let mut expected_vec = collections_vec(); - expected_vec.sort_by(|a, b| a.id.cmp(&b.id)); - expected_vec.reverse(); - - CollectionsHandler::with( - SUBMIT_KEY, - &mut app, - ActiveRadarrBlock::CollectionsSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::Collections.into() - ); - assert_eq!(app.data.radarr_data.collections.items, expected_vec); - } } mod test_handle_esc { use pretty_assertions::assert_eq; - use ratatui::widgets::TableState; use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils::create_test_radarr_data; - use crate::models::stateful_table::StatefulTable; use super::*; const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; - #[rstest] - fn test_search_collection_block_esc( - #[values( - ActiveRadarrBlock::SearchCollection, - ActiveRadarrBlock::SearchCollectionError - )] - active_radarr_block: ActiveRadarrBlock, - ) { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app.push_navigation_stack(active_radarr_block.into()); - app.data.radarr_data = create_test_radarr_data(); - app.data.radarr_data.collections.search = Some("Test".into()); - - CollectionsHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::Collections.into() - ); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.radarr_data.collections.search, None); - } - - #[rstest] - fn test_filter_collections_block_esc( - #[values( - ActiveRadarrBlock::FilterCollections, - ActiveRadarrBlock::FilterCollectionsError - )] - active_radarr_block: ActiveRadarrBlock, - ) { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app.push_navigation_stack(active_radarr_block.into()); - app.data.radarr_data = create_test_radarr_data(); - app.data.radarr_data.collections = StatefulTable { - filter: Some("Test".into()), - filtered_items: Some(Vec::new()), - filtered_state: Some(TableState::default()), - ..StatefulTable::default() - }; - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - - CollectionsHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::Collections.into() - ); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.radarr_data.collections.filter, None); - assert_eq!(app.data.radarr_data.collections.filtered_items, None); - assert_eq!(app.data.radarr_data.collections.filtered_state, None); - } - #[test] fn test_update_all_collections_prompt_block_esc() { let mut app = App::default(); @@ -986,31 +231,6 @@ mod tests { assert!(!app.data.radarr_data.prompt_confirm); } - #[test] - fn test_collections_sort_prompt_block_esc() { - let mut app = App::default(); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app.push_navigation_stack(ActiveRadarrBlock::CollectionsSortPrompt.into()); - - CollectionsHandler::with( - ESC_KEY, - &mut app, - ActiveRadarrBlock::CollectionsSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::Collections.into() - ); - } - #[rstest] fn test_default_esc(#[values(true, false)] is_ready: bool) { let mut app = App::default(); @@ -1045,149 +265,6 @@ mod tests { use super::*; - #[test] - fn test_search_collections_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.search.key, - &mut app, - ActiveRadarrBlock::Collections, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::SearchCollection.into() - ); - assert!(app.should_ignore_quit_key); - assert_eq!( - app.data.radarr_data.collections.search, - Some(HorizontallyScrollableText::default()) - ); - } - - #[test] - fn test_search_collections_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.search.key, - &mut app, - ActiveRadarrBlock::Collections, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::Collections.into() - ); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.radarr_data.collections.search, None); - } - - #[test] - fn test_filter_collections_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.filter.key, - &mut app, - ActiveRadarrBlock::Collections, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::FilterCollections.into() - ); - assert!(app.should_ignore_quit_key); - assert!(app.data.radarr_data.collections.filter.is_some()); - } - - #[test] - fn test_filter_collections_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.filter.key, - &mut app, - ActiveRadarrBlock::Collections, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::Collections.into() - ); - assert!(!app.should_ignore_quit_key); - assert!(app.data.radarr_data.collections.filter.is_none()); - } - - #[test] - fn test_filter_collections_key_resets_previous_filter() { - let mut app = App::default(); - app.data.radarr_data = create_test_radarr_data(); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app.data.radarr_data.collections.filter = Some("Test".into()); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.filter.key, - &mut app, - ActiveRadarrBlock::Collections, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::FilterCollections.into() - ); - assert!(app.should_ignore_quit_key); - assert_eq!( - app.data.radarr_data.collections.filter, - Some(HorizontallyScrollableText::default()) - ); - assert!(app.data.radarr_data.collections.filtered_items.is_none()); - assert!(app.data.radarr_data.collections.filtered_state.is_none()); - } - #[test] fn test_collection_edit_key() { test_edit_collection_key!(CollectionsHandler, ActiveRadarrBlock::Collections, None); @@ -1323,197 +400,6 @@ mod tests { assert!(!app.should_refresh); } - #[test] - fn test_search_collections_box_backspace_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::SearchCollection.into()); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - app.data.radarr_data.collections.search = Some("Test".into()); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.backspace.key, - &mut app, - ActiveRadarrBlock::SearchCollection, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .collections - .search - .as_ref() - .unwrap() - .text, - "Tes" - ); - } - - #[test] - fn test_filter_collections_box_backspace_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::FilterCollections.into()); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - app.data.radarr_data.collections.filter = Some("Test".into()); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.backspace.key, - &mut app, - ActiveRadarrBlock::FilterCollections, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .collections - .filter - .as_ref() - .unwrap() - .text, - "Tes" - ); - } - - #[test] - fn test_search_collections_box_char_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::SearchCollection.into()); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - app.data.radarr_data.collections.search = Some(HorizontallyScrollableText::default()); - - CollectionsHandler::with( - Key::Char('h'), - &mut app, - ActiveRadarrBlock::SearchCollection, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .collections - .search - .as_ref() - .unwrap() - .text, - "h" - ); - } - - #[test] - fn test_filter_collections_box_char_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::FilterCollections.into()); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - app.data.radarr_data.collections.filter = Some(HorizontallyScrollableText::default()); - - CollectionsHandler::with( - Key::Char('h'), - &mut app, - ActiveRadarrBlock::FilterCollections, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .collections - .filter - .as_ref() - .unwrap() - .text, - "h" - ); - } - - #[test] - fn test_sort_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveRadarrBlock::Collections, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::CollectionsSortPrompt.into() - ); - assert_eq!( - app - .data - .radarr_data - .collections - .sort - .as_ref() - .unwrap() - .items, - collections_sorting_options() - ); - assert!(!app.data.radarr_data.collections.sort_asc); - } - - #[test] - fn test_sort_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); - app - .data - .radarr_data - .collections - .set_items(vec![Collection::default()]); - - CollectionsHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveRadarrBlock::Collections, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::Collections.into() - ); - assert!(app.data.radarr_data.collections.sort.is_none()); - assert!(!app.data.radarr_data.collections.sort_asc); - } - #[test] fn test_update_all_collections_prompt_confirm_confirm() { let mut app = App::default(); @@ -1787,16 +673,4 @@ mod tests { }, ] } - - fn sort_options() -> Vec> { - vec![SortOption { - name: "Test 1", - cmp_fn: Some(|a, b| { - b.title - .text - .to_lowercase() - .cmp(&a.title.text.to_lowercase()) - }), - }] - } } diff --git a/src/handlers/radarr_handlers/collections/mod.rs b/src/handlers/radarr_handlers/collections/mod.rs index f0ee1ef..265a03d 100644 --- a/src/handlers/radarr_handlers/collections/mod.rs +++ b/src/handlers/radarr_handlers/collections/mod.rs @@ -5,14 +5,14 @@ use crate::handle_table_events; use crate::handlers::radarr_handlers::collections::collection_details_handler::CollectionDetailsHandler; use crate::handlers::radarr_handlers::collections::edit_collection_handler::EditCollectionHandler; use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; -use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::radarr_models::Collection; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, COLLECTIONS_BLOCKS, EDIT_COLLECTION_SELECTION_BLOCKS, }; use crate::models::stateful_table::SortOption; -use crate::models::{BlockSelectionState, Scrollable}; +use crate::models::BlockSelectionState; use crate::network::radarr_network::RadarrEvent; mod collection_details_handler; @@ -40,8 +40,8 @@ impl<'a, 'b> CollectionsHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<'a, 'b> { fn handle(&mut self) { - let collections_table_handling_props = - TableHandlingProps::new(ActiveRadarrBlock::Collections.into()) + let collections_table_handling_config = + TableHandlingConfig::new(ActiveRadarrBlock::Collections.into()) .sorting_block(ActiveRadarrBlock::CollectionsSortPrompt.into()) .sort_by_fn(|a: &Collection, b: &Collection| a.id.cmp(&b.id)) .sort_options(collections_sorting_options()) @@ -52,7 +52,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' .filter_error_block(ActiveRadarrBlock::FilterCollectionsError.into()) .filter_field_fn(|collection| &collection.title.text); - if !self.handle_collections_table_events(collections_table_handling_props) { + if !self.handle_collections_table_events(collections_table_handling_config) { match self.active_radarr_block { _ if CollectionDetailsHandler::accepts(self.active_radarr_block) => { CollectionDetailsHandler::with( diff --git a/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs index f47a498..e5e75bc 100644 --- a/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs +++ b/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod tests { - use pretty_assertions::assert_str_eq; use strum::IntoEnumIterator; use crate::app::key_binding::DEFAULT_KEYBINDINGS; @@ -11,111 +10,6 @@ mod tests { use crate::models::radarr_models::DownloadRecord; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, DOWNLOADS_BLOCKS}; - mod test_handle_scroll_up_and_down { - use rstest::rstest; - - use crate::models::radarr_models::DownloadRecord; - use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; - - use super::*; - - test_iterable_scroll!( - test_downloads_scroll, - DownloadsHandler, - radarr_data, - downloads, - DownloadRecord, - ActiveRadarrBlock::Downloads, - None, - title - ); - - #[rstest] - fn test_downloads_scroll_no_op_when_not_ready( - #[values( - DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key - )] - key: Key, - ) { - let mut app = App::default(); - app.is_loading = true; - app - .data - .radarr_data - .downloads - .set_items(simple_stateful_iterable_vec!(DownloadRecord)); - - DownloadsHandler::with(key, &mut app, ActiveRadarrBlock::Downloads, None).handle(); - - assert_str_eq!( - app.data.radarr_data.downloads.current_selection().title, - "Test 1" - ); - - DownloadsHandler::with(key, &mut app, ActiveRadarrBlock::Downloads, None).handle(); - - assert_str_eq!( - app.data.radarr_data.downloads.current_selection().title, - "Test 1" - ); - } - } - - mod test_handle_home_end { - use crate::models::radarr_models::DownloadRecord; - use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; - - use super::*; - - test_iterable_home_and_end!( - test_downloads_home_end, - DownloadsHandler, - radarr_data, - downloads, - DownloadRecord, - ActiveRadarrBlock::Downloads, - None, - title - ); - - #[test] - fn test_downloads_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app - .data - .radarr_data - .downloads - .set_items(extended_stateful_iterable_vec!(DownloadRecord)); - - DownloadsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::Downloads, - None, - ) - .handle(); - - assert_str_eq!( - app.data.radarr_data.downloads.current_selection().title, - "Test 1" - ); - - DownloadsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::Downloads, - None, - ) - .handle(); - - assert_str_eq!( - app.data.radarr_data.downloads.current_selection().title, - "Test 1" - ); - } - } - mod test_handle_delete { use pretty_assertions::assert_eq; diff --git a/src/handlers/radarr_handlers/downloads/mod.rs b/src/handlers/radarr_handlers/downloads/mod.rs index fa0756b..a09966f 100644 --- a/src/handlers/radarr_handlers/downloads/mod.rs +++ b/src/handlers/radarr_handlers/downloads/mod.rs @@ -3,11 +3,10 @@ use crate::app::App; use crate::event::Key; use crate::handle_table_events; use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; -use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::radarr_models::DownloadRecord; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, DOWNLOADS_BLOCKS}; -use crate::models::Scrollable; use crate::network::radarr_network::RadarrEvent; #[cfg(test)] @@ -32,10 +31,10 @@ impl<'a, 'b> DownloadsHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DownloadsHandler<'a, 'b> { fn handle(&mut self) { - let downloads_table_handling_props = - TableHandlingProps::new(ActiveRadarrBlock::Downloads.into()); + let downloads_table_handling_config = + TableHandlingConfig::new(ActiveRadarrBlock::Downloads.into()); - if !self.handle_downloads_table_events(downloads_table_handling_props) { + if !self.handle_downloads_table_events(downloads_table_handling_config) { self.handle_key_event(); } } diff --git a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs index b0e9a43..5f070a7 100644 --- a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod tests { - use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use strum::IntoEnumIterator; @@ -15,109 +14,6 @@ mod tests { use crate::models::servarr_models::Indexer; use crate::test_handler_delegation; - mod test_handle_scroll_up_and_down { - use rstest::rstest; - - use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; - - use super::*; - - test_iterable_scroll!( - test_indexers_scroll, - IndexersHandler, - radarr_data, - indexers, - simple_stateful_iterable_vec!(Indexer, String, protocol), - ActiveRadarrBlock::Indexers, - None, - protocol - ); - - #[rstest] - fn test_indexers_scroll_no_op_when_not_ready( - #[values( - DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key - )] - key: Key, - ) { - let mut app = App::default(); - app.is_loading = true; - app - .data - .radarr_data - .indexers - .set_items(simple_stateful_iterable_vec!(Indexer, String, protocol)); - - IndexersHandler::with(key, &mut app, ActiveRadarrBlock::Indexers, None).handle(); - - assert_str_eq!( - app.data.radarr_data.indexers.current_selection().protocol, - "Test 1" - ); - - IndexersHandler::with(key, &mut app, ActiveRadarrBlock::Indexers, None).handle(); - - assert_str_eq!( - app.data.radarr_data.indexers.current_selection().protocol, - "Test 1" - ); - } - } - - mod test_handle_home_end { - use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; - - use super::*; - - test_iterable_home_and_end!( - test_indexers_home_end, - IndexersHandler, - radarr_data, - indexers, - extended_stateful_iterable_vec!(Indexer, String, protocol), - ActiveRadarrBlock::Indexers, - None, - protocol - ); - - #[test] - fn test_indexers_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app - .data - .radarr_data - .indexers - .set_items(extended_stateful_iterable_vec!(Indexer, String, protocol)); - - IndexersHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::Indexers, - None, - ) - .handle(); - - assert_str_eq!( - app.data.radarr_data.indexers.current_selection().protocol, - "Test 1" - ); - - IndexersHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::Indexers, - None, - ) - .handle(); - - assert_str_eq!( - app.data.radarr_data.indexers.current_selection().protocol, - "Test 1" - ); - } - } - mod test_handle_delete { use pretty_assertions::assert_eq; diff --git a/src/handlers/radarr_handlers/indexers/mod.rs b/src/handlers/radarr_handlers/indexers/mod.rs index e239fc5..282e80a 100644 --- a/src/handlers/radarr_handlers/indexers/mod.rs +++ b/src/handlers/radarr_handlers/indexers/mod.rs @@ -6,7 +6,7 @@ use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::radarr_handlers::indexers::edit_indexer_handler::EditIndexerHandler; use crate::handlers::radarr_handlers::indexers::edit_indexer_settings_handler::IndexerSettingsHandler; use crate::handlers::radarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler; -use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, @@ -14,7 +14,6 @@ use crate::models::servarr_data::radarr::radarr_data::{ }; use crate::models::servarr_models::Indexer; use crate::models::BlockSelectionState; -use crate::models::Scrollable; use crate::network::radarr_network::RadarrEvent; mod edit_indexer_handler; @@ -38,9 +37,10 @@ impl<'a, 'b> IndexersHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a, 'b> { fn handle(&mut self) { - let indexer_table_handling_props = TableHandlingProps::new(ActiveRadarrBlock::Indexers.into()); + let indexer_table_handling_config = + TableHandlingConfig::new(ActiveRadarrBlock::Indexers.into()); - if !self.handle_indexers_table_events(indexer_table_handling_props) { + if !self.handle_indexers_table_events(indexer_table_handling_config) { match self.active_radarr_block { _ if EditIndexerHandler::accepts(self.active_radarr_block) => { EditIndexerHandler::with(self.key, self.app, self.active_radarr_block, self.context) diff --git a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler.rs b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler.rs index 50cac84..1b4297e 100644 --- a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler.rs +++ b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler.rs @@ -1,8 +1,10 @@ use crate::app::App; use crate::event::Key; +use crate::handle_table_events; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::KeyEventHandler; +use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; -use crate::models::Scrollable; #[cfg(test)] #[path = "test_all_indexers_handler_tests.rs"] @@ -15,7 +17,33 @@ pub(super) struct TestAllIndexersHandler<'a, 'b> { _context: Option, } +impl<'a, 'b> TestAllIndexersHandler<'a, 'b> { + handle_table_events!( + self, + indexer_test_all_results, + self + .app + .data + .radarr_data + .indexer_test_all_results + .as_mut() + .unwrap(), + IndexerTestResultModalItem + ); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for TestAllIndexersHandler<'a, 'b> { + fn handle(&mut self) { + let test_all_indexers_test_results_table_handler_config = + TableHandlingConfig::new(ActiveRadarrBlock::TestAllIndexers.into()); + + if !self.handle_indexer_test_all_results_table_events( + test_all_indexers_test_results_table_handler_config, + ) { + self.handle_key_event(); + } + } + fn accepts(active_block: ActiveRadarrBlock) -> bool { active_block == ActiveRadarrBlock::TestAllIndexers } @@ -48,57 +76,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for TestAllIndexersHandl !self.app.is_loading && table_is_ready } - fn handle_scroll_up(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::TestAllIndexers { - self - .app - .data - .radarr_data - .indexer_test_all_results - .as_mut() - .unwrap() - .scroll_up() - } - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::TestAllIndexers { - self - .app - .data - .radarr_data - .indexer_test_all_results - .as_mut() - .unwrap() - .scroll_down() - } - } + fn handle_scroll_down(&mut self) {} - fn handle_home(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::TestAllIndexers { - self - .app - .data - .radarr_data - .indexer_test_all_results - .as_mut() - .unwrap() - .scroll_to_top() - } - } + fn handle_home(&mut self) {} - fn handle_end(&mut self) { - if self.active_radarr_block == ActiveRadarrBlock::TestAllIndexers { - self - .app - .data - .radarr_data - .indexer_test_all_results - .as_mut() - .unwrap() - .scroll_to_bottom() - } - } + fn handle_end(&mut self) {} fn handle_delete(&mut self) {} diff --git a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs index 7e1a4f3..31a93d7 100644 --- a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs @@ -2,7 +2,6 @@ mod tests { use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; - use crate::event::Key; use crate::handlers::radarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler; use crate::handlers::KeyEventHandler; use crate::models::servarr_data::modals::IndexerTestResultModalItem; @@ -10,220 +9,6 @@ mod tests { use crate::models::stateful_table::StatefulTable; use strum::IntoEnumIterator; - mod test_handle_scroll_up_and_down { - use pretty_assertions::assert_str_eq; - use rstest::rstest; - - use crate::models::servarr_data::modals::IndexerTestResultModalItem; - use crate::models::stateful_table::StatefulTable; - use crate::simple_stateful_iterable_vec; - - use super::*; - - #[rstest] - fn test_test_all_indexers_results_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - let mut indexer_test_results = StatefulTable::default(); - indexer_test_results.set_items(simple_stateful_iterable_vec!( - IndexerTestResultModalItem, - String, - name - )); - app.data.radarr_data.indexer_test_all_results = Some(indexer_test_results); - - TestAllIndexersHandler::with(key, &mut app, ActiveRadarrBlock::TestAllIndexers, None) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 2" - ); - - TestAllIndexersHandler::with(key, &mut app, ActiveRadarrBlock::TestAllIndexers, None) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 1" - ); - } - - #[rstest] - fn test_test_all_indexers_results_scroll_no_op_when_not_ready( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.is_loading = true; - let mut indexer_test_results = StatefulTable::default(); - indexer_test_results.set_items(simple_stateful_iterable_vec!( - IndexerTestResultModalItem, - String, - name - )); - app.data.radarr_data.indexer_test_all_results = Some(indexer_test_results); - - TestAllIndexersHandler::with(key, &mut app, ActiveRadarrBlock::TestAllIndexers, None) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 1" - ); - - TestAllIndexersHandler::with(key, &mut app, ActiveRadarrBlock::TestAllIndexers, None) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 1" - ); - } - } - - mod test_handle_home_end { - use crate::extended_stateful_iterable_vec; - use crate::models::servarr_data::modals::IndexerTestResultModalItem; - use crate::models::stateful_table::StatefulTable; - use pretty_assertions::assert_str_eq; - - use super::*; - - #[test] - fn test_test_all_indexers_results_home_end() { - let mut app = App::default(); - let mut indexer_test_results = StatefulTable::default(); - indexer_test_results.set_items(extended_stateful_iterable_vec!( - IndexerTestResultModalItem, - String, - name - )); - app.data.radarr_data.indexer_test_all_results = Some(indexer_test_results); - - TestAllIndexersHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::TestAllIndexers, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 3" - ); - - TestAllIndexersHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::TestAllIndexers, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 1" - ); - } - - #[test] - fn test_test_all_indexers_results_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - let mut indexer_test_results = StatefulTable::default(); - indexer_test_results.set_items(extended_stateful_iterable_vec!( - IndexerTestResultModalItem, - String, - name - )); - app.data.radarr_data.indexer_test_all_results = Some(indexer_test_results); - - TestAllIndexersHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::TestAllIndexers, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 1" - ); - - TestAllIndexersHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::TestAllIndexers, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 1" - ); - } - } - mod test_handle_esc { use super::*; use crate::models::stateful_table::StatefulTable; diff --git a/src/handlers/radarr_handlers/library/add_movie_handler.rs b/src/handlers/radarr_handlers/library/add_movie_handler.rs index ed555f6..c6c11d5 100644 --- a/src/handlers/radarr_handlers/library/add_movie_handler.rs +++ b/src/handlers/radarr_handlers/library/add_movie_handler.rs @@ -1,11 +1,13 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; +use crate::models::radarr_models::AddMovieSearchResult; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, ADD_MOVIE_BLOCKS, ADD_MOVIE_SELECTION_BLOCKS, }; use crate::models::{BlockSelectionState, Scrollable}; use crate::network::radarr_network::RadarrEvent; -use crate::{handle_text_box_keys, handle_text_box_left_right_keys, App, Key}; +use crate::{handle_table_events, handle_text_box_keys, handle_text_box_left_right_keys, App, Key}; #[cfg(test)] #[path = "add_movie_handler_tests.rs"] @@ -18,7 +20,31 @@ pub(super) struct AddMovieHandler<'a, 'b> { context: Option, } +impl<'a, 'b> AddMovieHandler<'a, 'b> { + handle_table_events!( + self, + add_movie_search_results, + self + .app + .data + .radarr_data + .add_searched_movies + .as_mut() + .unwrap(), + AddMovieSearchResult + ); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a, 'b> { + fn handle(&mut self) { + let add_movie_table_handling_config = + TableHandlingConfig::new(ActiveRadarrBlock::AddMovieSearchResults.into()); + + if !self.handle_add_movie_search_results_table_events(add_movie_table_handling_config) { + self.handle_key_event(); + } + } + fn accepts(active_block: ActiveRadarrBlock) -> bool { ADD_MOVIE_BLOCKS.contains(&active_block) } @@ -47,14 +73,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a, fn handle_scroll_up(&mut self) { match self.active_radarr_block { - ActiveRadarrBlock::AddMovieSearchResults => self - .app - .data - .radarr_data - .add_searched_movies - .as_mut() - .unwrap() - .scroll_up(), ActiveRadarrBlock::AddMovieSelectMonitor => self .app .data @@ -98,14 +116,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a, fn handle_scroll_down(&mut self) { match self.active_radarr_block { - ActiveRadarrBlock::AddMovieSearchResults => self - .app - .data - .radarr_data - .add_searched_movies - .as_mut() - .unwrap() - .scroll_down(), ActiveRadarrBlock::AddMovieSelectMonitor => self .app .data @@ -149,14 +159,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a, fn handle_home(&mut self) { match self.active_radarr_block { - ActiveRadarrBlock::AddMovieSearchResults => self - .app - .data - .radarr_data - .add_searched_movies - .as_mut() - .unwrap() - .scroll_to_top(), ActiveRadarrBlock::AddMovieSelectMonitor => self .app .data @@ -216,14 +218,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a, fn handle_end(&mut self) { match self.active_radarr_block { - ActiveRadarrBlock::AddMovieSearchResults => self - .app - .data - .radarr_data - .add_searched_movies - .as_mut() - .unwrap() - .scroll_to_bottom(), ActiveRadarrBlock::AddMovieSelectMonitor => self .app .data diff --git a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs index 18b4036..ebd98b5 100644 --- a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs @@ -20,123 +20,11 @@ mod tests { use crate::models::servarr_data::radarr::modals::AddMovieModal; use crate::models::servarr_data::radarr::radarr_data::ADD_MOVIE_SELECTION_BLOCKS; - use crate::models::stateful_table::StatefulTable; use crate::models::BlockSelectionState; use crate::simple_stateful_iterable_vec; use super::*; - #[rstest] - fn test_add_movie_search_results_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - let mut add_searched_movies = StatefulTable::default(); - add_searched_movies.set_items(simple_stateful_iterable_vec!( - AddMovieSearchResult, - HorizontallyScrollableText - )); - app.data.radarr_data.add_searched_movies = Some(add_searched_movies); - - AddMovieHandler::with( - key, - &mut app, - ActiveRadarrBlock::AddMovieSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .add_searched_movies - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 2" - ); - - AddMovieHandler::with( - key, - &mut app, - ActiveRadarrBlock::AddMovieSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .add_searched_movies - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - - #[rstest] - fn test_add_movie_search_results_scroll_no_op_when_not_ready( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.is_loading = true; - let mut add_searched_movies = StatefulTable::default(); - add_searched_movies.set_items(simple_stateful_iterable_vec!( - AddMovieSearchResult, - HorizontallyScrollableText - )); - app.data.radarr_data.add_searched_movies = Some(add_searched_movies); - - AddMovieHandler::with( - key, - &mut app, - ActiveRadarrBlock::AddMovieSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .add_searched_movies - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 1" - ); - - AddMovieHandler::with( - key, - &mut app, - ActiveRadarrBlock::AddMovieSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .add_searched_movies - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - #[rstest] fn test_add_movie_select_monitor_scroll( #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, @@ -421,117 +309,9 @@ mod tests { use crate::extended_stateful_iterable_vec; use crate::models::servarr_data::radarr::modals::AddMovieModal; - use crate::models::stateful_table::StatefulTable; use super::*; - #[test] - fn test_add_movie_search_results_home_end() { - let mut app = App::default(); - let mut add_searched_movies = StatefulTable::default(); - add_searched_movies.set_items(extended_stateful_iterable_vec!( - AddMovieSearchResult, - HorizontallyScrollableText - )); - app.data.radarr_data.add_searched_movies = Some(add_searched_movies); - - AddMovieHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::AddMovieSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .add_searched_movies - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 3" - ); - - AddMovieHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::AddMovieSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .add_searched_movies - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - - #[test] - fn test_add_movie_search_results_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - let mut add_searched_movies = StatefulTable::default(); - add_searched_movies.set_items(extended_stateful_iterable_vec!( - AddMovieSearchResult, - HorizontallyScrollableText - )); - app.data.radarr_data.add_searched_movies = Some(add_searched_movies); - - AddMovieHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::AddMovieSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .add_searched_movies - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 1" - ); - - AddMovieHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::AddMovieSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .add_searched_movies - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - #[test] fn test_add_movie_select_monitor_home_end() { let monitor_vec = Vec::from_iter(MovieMonitor::iter()); diff --git a/src/handlers/radarr_handlers/library/library_handler_tests.rs b/src/handlers/radarr_handlers/library/library_handler_tests.rs index e513af1..7999519 100644 --- a/src/handlers/radarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/library_handler_tests.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod tests { - use core::sync::atomic::Ordering::SeqCst; use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use std::cmp::Ordering; @@ -17,341 +16,8 @@ mod tests { MOVIE_DETAILS_BLOCKS, }; use crate::models::servarr_models::Language; - use crate::models::stateful_table::SortOption; - use crate::models::HorizontallyScrollableText; use crate::test_handler_delegation; - mod test_handle_scroll_up_and_down { - use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; - use pretty_assertions::assert_eq; - - use super::*; - - test_iterable_scroll!( - test_movies_scroll, - LibraryHandler, - radarr_data, - movies, - simple_stateful_iterable_vec!(Movie, HorizontallyScrollableText), - ActiveRadarrBlock::Movies, - None, - title, - to_string - ); - - #[rstest] - fn test_movies_scroll_no_op_when_not_ready( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.is_loading = true; - app - .data - .radarr_data - .movies - .set_items(simple_stateful_iterable_vec!( - Movie, - HorizontallyScrollableText - )); - - LibraryHandler::with(key, &mut app, ActiveRadarrBlock::Movies, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movies - .current_selection() - .title - .to_string(), - "Test 1" - ); - - LibraryHandler::with(key, &mut app, ActiveRadarrBlock::Movies, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movies - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - - #[rstest] - fn test_movies_sort_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let movie_field_vec = sort_options(); - let mut app = App::default(); - app.data.radarr_data.movies.sorting(sort_options()); - - if key == Key::Up { - for i in (0..movie_field_vec.len()).rev() { - LibraryHandler::with(key, &mut app, ActiveRadarrBlock::MoviesSortPrompt, None).handle(); - - assert_eq!( - app - .data - .radarr_data - .movies - .sort - .as_ref() - .unwrap() - .current_selection(), - &movie_field_vec[i] - ); - } - } else { - for i in 0..movie_field_vec.len() { - LibraryHandler::with(key, &mut app, ActiveRadarrBlock::MoviesSortPrompt, None).handle(); - - assert_eq!( - app - .data - .radarr_data - .movies - .sort - .as_ref() - .unwrap() - .current_selection(), - &movie_field_vec[(i + 1) % movie_field_vec.len()] - ); - } - } - } - } - - mod test_handle_home_end { - use pretty_assertions::assert_eq; - - use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; - - use super::*; - - test_iterable_home_and_end!( - test_movies_home_end, - LibraryHandler, - radarr_data, - movies, - extended_stateful_iterable_vec!(Movie, HorizontallyScrollableText), - ActiveRadarrBlock::Movies, - None, - title, - to_string - ); - - #[test] - fn test_movies_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app - .data - .radarr_data - .movies - .set_items(extended_stateful_iterable_vec!( - Movie, - HorizontallyScrollableText - )); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::Movies, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movies - .current_selection() - .title - .to_string(), - "Test 1" - ); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::Movies, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movies - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - - #[test] - fn test_movie_search_box_home_end_keys() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); - app.data.radarr_data.movies.search = Some("Test".into()); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::SearchMovie, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .movies - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 4 - ); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::SearchMovie, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .movies - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } - - #[test] - fn test_movie_filter_box_home_end_keys() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); - app.data.radarr_data.movies.filter = Some("Test".into()); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::FilterMovies, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .movies - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 4 - ); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::FilterMovies, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .movies - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } - - #[test] - fn test_movies_sort_home_end() { - let movie_field_vec = sort_options(); - let mut app = App::default(); - app.data.radarr_data.movies.sorting(sort_options()); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::MoviesSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .movies - .sort - .as_ref() - .unwrap() - .current_selection(), - &movie_field_vec[movie_field_vec.len() - 1] - ); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::MoviesSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .movies - .sort - .as_ref() - .unwrap() - .current_selection(), - &movie_field_vec[0] - ); - } - } - mod test_handle_delete { use pretty_assertions::assert_eq; @@ -477,112 +143,11 @@ mod tests { assert!(!app.data.radarr_data.prompt_confirm); } - - #[test] - fn test_movie_search_box_left_right_keys() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); - app.data.radarr_data.movies.set_items(vec![Movie::default()]); - app.data.radarr_data.movies.search = Some("Test".into()); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.left.key, - &mut app, - ActiveRadarrBlock::SearchMovie, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .movies - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 1 - ); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.right.key, - &mut app, - ActiveRadarrBlock::SearchMovie, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .movies - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } - - #[test] - fn test_movie_filter_box_left_right_keys() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); - app.data.radarr_data.movies.set_items(vec![Movie::default()]); - app.data.radarr_data.movies.filter = Some("Test".into()); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.left.key, - &mut app, - ActiveRadarrBlock::FilterMovies, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .movies - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 1 - ); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.right.key, - &mut app, - ActiveRadarrBlock::FilterMovies, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .movies - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } } mod test_handle_submit { use pretty_assertions::assert_eq; - use crate::extended_stateful_iterable_vec; use crate::network::radarr_network::RadarrEvent; use super::*; @@ -622,148 +187,6 @@ mod tests { assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } - #[test] - fn test_search_movie_submit() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); - app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); - app - .data - .radarr_data - .movies - .set_items(extended_stateful_iterable_vec!( - Movie, - HorizontallyScrollableText - )); - app.data.radarr_data.movies.search = Some("Test 2".into()); - - LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::SearchMovie, None).handle(); - - assert_str_eq!( - app.data.radarr_data.movies.current_selection().title.text, - "Test 2" - ); - assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); - } - - #[test] - fn test_search_movie_submit_error_on_no_search_hits() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); - app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); - app - .data - .radarr_data - .movies - .set_items(extended_stateful_iterable_vec!( - Movie, - HorizontallyScrollableText - )); - app.data.radarr_data.movies.search = Some("Test 5".into()); - - LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::SearchMovie, None).handle(); - - assert_str_eq!( - app.data.radarr_data.movies.current_selection().title.text, - "Test 1" - ); - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::SearchMovieError.into() - ); - } - - #[test] - fn test_search_filtered_movies_submit() { - let mut app = App::default(); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); - app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); - app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); - app - .data - .radarr_data - .movies - .set_filtered_items(extended_stateful_iterable_vec!( - Movie, - HorizontallyScrollableText - )); - app.data.radarr_data.movies.search = Some("Test 2".into()); - - LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::SearchMovie, None).handle(); - - assert_str_eq!( - app.data.radarr_data.movies.current_selection().title.text, - "Test 2" - ); - assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); - } - - #[test] - fn test_filter_movies_submit() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); - app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); - app - .data - .radarr_data - .movies - .set_items(extended_stateful_iterable_vec!( - Movie, - HorizontallyScrollableText - )); - app.data.radarr_data.movies.filter = Some("Test".into()); - - LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::FilterMovies, None).handle(); - - assert!(app.data.radarr_data.movies.filtered_items.is_some()); - assert!(!app.should_ignore_quit_key); - assert_eq!( - app - .data - .radarr_data - .movies - .filtered_items - .as_ref() - .unwrap() - .len(), - 3 - ); - assert_str_eq!( - app.data.radarr_data.movies.current_selection().title.text, - "Test 1" - ); - assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); - } - - #[test] - fn test_filter_movies_submit_error_on_no_filter_matches() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); - app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); - app - .data - .radarr_data - .movies - .set_items(extended_stateful_iterable_vec!( - Movie, - HorizontallyScrollableText - )); - app.data.radarr_data.movies.filter = Some("Test 5".into()); - - LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::FilterMovies, None).handle(); - - assert!(!app.should_ignore_quit_key); - assert!(app.data.radarr_data.movies.filtered_items.is_none()); - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::FilterMoviesError.into() - ); - } - #[test] fn test_update_all_movies_prompt_confirm_submit() { let mut app = App::default(); @@ -815,31 +238,6 @@ mod tests { assert_eq!(app.data.radarr_data.prompt_confirm_action, None); assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } - - #[test] - fn test_movies_sort_prompt_submit() { - let mut app = App::default(); - app.data.radarr_data.movies.sort_asc = true; - app.data.radarr_data.movies.sorting(sort_options()); - app.data.radarr_data.movies.set_items(movies_vec()); - app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); - app.push_navigation_stack(ActiveRadarrBlock::MoviesSortPrompt.into()); - - let mut expected_vec = movies_vec(); - expected_vec.sort_by(|a, b| a.id.cmp(&b.id)); - expected_vec.reverse(); - - LibraryHandler::with( - SUBMIT_KEY, - &mut app, - ActiveRadarrBlock::MoviesSortPrompt, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); - assert_eq!(app.data.radarr_data.movies.items, expected_vec); - } } mod test_handle_esc { @@ -853,52 +251,6 @@ mod tests { const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; - #[rstest] - fn test_search_movie_block_esc( - #[values(ActiveRadarrBlock::SearchMovie, ActiveRadarrBlock::SearchMovieError)] - active_radarr_block: ActiveRadarrBlock, - ) { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); - app.push_navigation_stack(active_radarr_block.into()); - app.data.radarr_data = create_test_radarr_data(); - app.data.radarr_data.movies.search = Some("Test".into()); - - LibraryHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); - - assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.radarr_data.movies.search, None); - } - - #[rstest] - fn test_filter_movies_block_esc( - #[values(ActiveRadarrBlock::FilterMovies, ActiveRadarrBlock::FilterMoviesError)] - active_radarr_block: ActiveRadarrBlock, - ) { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); - app.push_navigation_stack(active_radarr_block.into()); - app.data.radarr_data = create_test_radarr_data(); - app.data.radarr_data.movies = StatefulTable { - filter: Some("Test".into()), - filtered_items: Some(Vec::new()), - filtered_state: Some(TableState::default()), - ..StatefulTable::default() - }; - app.data.radarr_data.movies.set_items(vec![Movie::default()]); - - LibraryHandler::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); - - assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.radarr_data.movies.filter, None); - assert_eq!(app.data.radarr_data.movies.filtered_items, None); - assert_eq!(app.data.radarr_data.movies.filtered_state, None); - } - #[test] fn test_update_all_movies_prompt_blocks_esc() { let mut app = App::default(); @@ -918,18 +270,6 @@ mod tests { assert!(!app.data.radarr_data.prompt_confirm); } - #[test] - fn test_movies_sort_prompt_block_esc() { - let mut app = App::default(); - app.data.radarr_data.movies.set_items(movies_vec()); - app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); - app.push_navigation_stack(ActiveRadarrBlock::MoviesSortPrompt.into()); - - LibraryHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::MoviesSortPrompt, None).handle(); - - assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); - } - #[rstest] fn test_default_esc(#[values(true, false)] is_ready: bool) { let mut app = App::default(); @@ -970,141 +310,6 @@ mod tests { use super::*; - #[test] - fn test_search_movies_key() { - let mut app = App::default(); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.search.key, - &mut app, - ActiveRadarrBlock::Movies, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::SearchMovie.into() - ); - assert!(app.should_ignore_quit_key); - assert_eq!( - app.data.radarr_data.movies.search, - Some(HorizontallyScrollableText::default()) - ); - } - - #[test] - fn test_search_movies_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.search.key, - &mut app, - ActiveRadarrBlock::Movies, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.radarr_data.movies.search, None); - } - - #[test] - fn test_filter_movies_key() { - let mut app = App::default(); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.filter.key, - &mut app, - ActiveRadarrBlock::Movies, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::FilterMovies.into() - ); - assert!(app.should_ignore_quit_key); - assert!(app.data.radarr_data.movies.filter.is_some()); - } - - #[test] - fn test_filter_movies_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.filter.key, - &mut app, - ActiveRadarrBlock::Movies, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); - assert!(!app.should_ignore_quit_key); - assert!(app.data.radarr_data.movies.filter.is_none()); - } - - #[test] - fn test_filter_movies_key_resets_previous_filter() { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); - app.data.radarr_data = create_test_radarr_data(); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); - app.data.radarr_data.movies.filter = Some("Test".into()); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.filter.key, - &mut app, - ActiveRadarrBlock::Movies, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::FilterMovies.into() - ); - assert!(app.should_ignore_quit_key); - assert_eq!( - app.data.radarr_data.movies.filter, - Some(HorizontallyScrollableText::default()) - ); - assert!(app.data.radarr_data.movies.filtered_items.is_none()); - assert!(app.data.radarr_data.movies.filtered_state.is_none()); - } - #[test] fn test_movie_add_key() { let mut app = App::default(); @@ -1276,157 +481,6 @@ mod tests { assert!(!app.should_refresh); } - #[test] - fn test_search_movies_box_backspace_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); - app.data.radarr_data.movies.search = Some("Test".into()); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.backspace.key, - &mut app, - ActiveRadarrBlock::SearchMovie, - None, - ) - .handle(); - - assert_str_eq!( - app.data.radarr_data.movies.search.as_ref().unwrap().text, - "Tes" - ); - } - - #[test] - fn test_filter_movies_box_backspace_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); - app.data.radarr_data.movies.filter = Some("Test".into()); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.backspace.key, - &mut app, - ActiveRadarrBlock::FilterMovies, - None, - ) - .handle(); - - assert_str_eq!( - app.data.radarr_data.movies.filter.as_ref().unwrap().text, - "Tes" - ); - } - - #[test] - fn test_search_movies_box_char_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); - app.data.radarr_data.movies.search = Some(HorizontallyScrollableText::default()); - - LibraryHandler::with( - Key::Char('h'), - &mut app, - ActiveRadarrBlock::SearchMovie, - None, - ) - .handle(); - - assert_str_eq!( - app.data.radarr_data.movies.search.as_ref().unwrap().text, - "h" - ); - } - - #[test] - fn test_filter_movies_box_char_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); - app.data.radarr_data.movies.filter = Some(HorizontallyScrollableText::default()); - - LibraryHandler::with( - Key::Char('h'), - &mut app, - ActiveRadarrBlock::FilterMovies, - None, - ) - .handle(); - - assert_str_eq!( - app.data.radarr_data.movies.filter.as_ref().unwrap().text, - "h" - ); - } - - #[test] - fn test_sort_key() { - let mut app = App::default(); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveRadarrBlock::Movies, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::MoviesSortPrompt.into() - ); - assert_eq!( - app.data.radarr_data.movies.sort.as_ref().unwrap().items, - movies_sorting_options() - ); - assert!(!app.data.radarr_data.movies.sort_asc); - } - - #[test] - fn test_sort_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveRadarrBlock::Movies, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); - assert!(app.data.radarr_data.movies.sort.is_none()); - } - #[test] fn test_update_all_movies_prompt_confirm() { let mut app = App::default(); @@ -1828,16 +882,4 @@ mod tests { }, ] } - - fn sort_options() -> Vec> { - vec![SortOption { - name: "Test 1", - cmp_fn: Some(|a, b| { - b.title - .text - .to_lowercase() - .cmp(&a.title.text.to_lowercase()) - }), - }] - } } diff --git a/src/handlers/radarr_handlers/library/mod.rs b/src/handlers/radarr_handlers/library/mod.rs index b77c47c..d116e17 100644 --- a/src/handlers/radarr_handlers/library/mod.rs +++ b/src/handlers/radarr_handlers/library/mod.rs @@ -9,13 +9,13 @@ use crate::handlers::radarr_handlers::library::movie_details_handler::MovieDetai use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::handle_table_events; -use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::table_handler::TableHandlingConfig; use crate::models::radarr_models::Movie; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, DELETE_MOVIE_SELECTION_BLOCKS, EDIT_MOVIE_SELECTION_BLOCKS, LIBRARY_BLOCKS, }; use crate::models::stateful_table::SortOption; -use crate::models::{BlockSelectionState, HorizontallyScrollableText, Scrollable}; +use crate::models::{BlockSelectionState, HorizontallyScrollableText}; use crate::network::radarr_network::RadarrEvent; mod add_movie_handler; @@ -40,7 +40,7 @@ impl<'a, 'b> LibraryHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, 'b> { fn handle(&mut self) { - let movie_table_handling_props = TableHandlingProps::new(ActiveRadarrBlock::Movies.into()) + let movie_table_handling_config = TableHandlingConfig::new(ActiveRadarrBlock::Movies.into()) .sorting_block(ActiveRadarrBlock::MoviesSortPrompt.into()) .sort_by_fn(|a: &Movie, b: &Movie| a.id.cmp(&b.id)) .sort_options(movies_sorting_options()) @@ -51,7 +51,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, ' .filter_error_block(ActiveRadarrBlock::FilterMoviesError.into()) .filter_field_fn(|movie| &movie.title.text); - if !self.handle_movies_table_events(movie_table_handling_props) { + if !self.handle_movies_table_events(movie_table_handling_config) { match self.active_radarr_block { _ if AddMovieHandler::accepts(self.active_radarr_block) => { AddMovieHandler::with(self.key, self.app, self.active_radarr_block, self.context) diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index 8e096a4..491d755 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -4,7 +4,7 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; use crate::handle_table_events; -use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; use crate::models::radarr_models::{Credit, MovieHistoryItem, RadarrRelease}; use crate::models::servarr_data::radarr::radarr_data::{ @@ -83,19 +83,19 @@ impl<'a, 'b> MovieDetailsHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler<'a, 'b> { fn handle(&mut self) { - let movie_history_table_handling_props = - TableHandlingProps::new(ActiveRadarrBlock::MovieHistory.into()); - let movie_releases_table_handling_props = - TableHandlingProps::new(ActiveRadarrBlock::ManualSearch.into()) + let movie_history_table_handling_config = + TableHandlingConfig::new(ActiveRadarrBlock::MovieHistory.into()); + let movie_releases_table_handling_config = + TableHandlingConfig::new(ActiveRadarrBlock::ManualSearch.into()) .sorting_block(ActiveRadarrBlock::ManualSearchSortPrompt.into()) .sort_options(releases_sorting_options()); - let movie_cast_table_handling_props = TableHandlingProps::new(ActiveRadarrBlock::Cast.into()); - let movie_crew_table_handling_props = TableHandlingProps::new(ActiveRadarrBlock::Crew.into()); + let movie_cast_table_handling_config = TableHandlingConfig::new(ActiveRadarrBlock::Cast.into()); + let movie_crew_table_handling_config = TableHandlingConfig::new(ActiveRadarrBlock::Crew.into()); - if !self.handle_movie_history_table_events(movie_history_table_handling_props) - && !self.handle_movie_releases_table_events(movie_releases_table_handling_props) - && !self.handle_movie_cast_table_events(movie_cast_table_handling_props) - && !self.handle_movie_crew_table_events(movie_crew_table_handling_props) + if !self.handle_movie_history_table_events(movie_history_table_handling_config) + && !self.handle_movie_releases_table_events(movie_releases_table_handling_config) + && !self.handle_movie_cast_table_events(movie_cast_table_handling_config) + && !self.handle_movie_crew_table_events(movie_crew_table_handling_config) { self.handle_key_event(); } diff --git a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs index 319ced6..4f7e0ab 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs @@ -19,15 +19,12 @@ mod tests { use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; use crate::models::servarr_models::{Language, Quality, QualityWrapper}; - use crate::models::stateful_table::SortOption; use crate::models::{HorizontallyScrollableText, ScrollableText}; mod test_handle_scroll_up_and_down { - use pretty_assertions::{assert_eq, assert_str_eq}; - use rstest::rstest; + use pretty_assertions::assert_eq; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; - use crate::simple_stateful_iterable_vec; use super::*; @@ -129,439 +126,9 @@ mod tests { 0 ); } - - #[rstest] - fn test_movie_history_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::MovieHistory.into()); - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_history - .set_items(simple_stateful_iterable_vec!( - MovieHistoryItem, - HorizontallyScrollableText, - source_title - )); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::MovieHistory, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_history - .current_selection() - .source_title - .to_string(), - "Test 2" - ); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::MovieHistory, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_history - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - } - - #[rstest] - fn test_movie_history_scroll_no_op_if_not_ready( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.is_loading = true; - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_history - .set_items(simple_stateful_iterable_vec!( - MovieHistoryItem, - HorizontallyScrollableText, - source_title - )); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::MovieHistory, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_history - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::MovieHistory, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_history - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - } - - #[rstest] - fn test_cast_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Cast.into()); - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_cast - .set_items(simple_stateful_iterable_vec!(Credit, String, person_name)); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Cast, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_cast - .current_selection() - .person_name, - "Test 2" - ); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Cast, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_cast - .current_selection() - .person_name, - "Test 1" - ); - } - - #[rstest] - fn test_cast_scroll_no_op_if_not_ready( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.is_loading = true; - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_cast - .set_items(simple_stateful_iterable_vec!(Credit, String, person_name)); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Cast, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_cast - .current_selection() - .person_name, - "Test 1" - ); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Cast, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_cast - .current_selection() - .person_name, - "Test 1" - ); - } - - #[rstest] - fn test_crew_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Crew.into()); - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_crew - .set_items(simple_stateful_iterable_vec!(Credit, String, person_name)); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Crew, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_crew - .current_selection() - .person_name, - "Test 2" - ); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Crew, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_crew - .current_selection() - .person_name, - "Test 1" - ); - } - - #[rstest] - fn test_crew_scroll_no_op_when_not_ready( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.is_loading = true; - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_crew - .set_items(simple_stateful_iterable_vec!(Credit, String, person_name)); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Crew, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_crew - .current_selection() - .person_name, - "Test 1" - ); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::Crew, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_crew - .current_selection() - .person_name, - "Test 1" - ); - } - - #[rstest] - fn test_manual_search_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into()); - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_releases - .set_items(simple_stateful_iterable_vec!( - RadarrRelease, - HorizontallyScrollableText - )); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::ManualSearch, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .current_selection() - .title - .to_string(), - "Test 2" - ); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::ManualSearch, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - - #[rstest] - fn test_manual_search_scroll_no_op_when_not_ready( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.is_loading = true; - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_releases - .set_items(simple_stateful_iterable_vec!( - RadarrRelease, - HorizontallyScrollableText - )); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::ManualSearch, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .current_selection() - .title - .to_string(), - "Test 1" - ); - - MovieDetailsHandler::with(key, &mut app, ActiveRadarrBlock::ManualSearch, None).handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - - #[rstest] - fn test_manual_search_sort_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let release_field_vec = sort_options(); - let mut app = App::default(); - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal.movie_releases.sorting(sort_options()); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - if key == Key::Up { - for i in (0..release_field_vec.len()).rev() { - MovieDetailsHandler::with( - key, - &mut app, - ActiveRadarrBlock::ManualSearchSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .sort - .as_ref() - .unwrap() - .current_selection(), - &release_field_vec[i] - ); - } - } else { - for i in 0..release_field_vec.len() { - MovieDetailsHandler::with( - key, - &mut app, - ActiveRadarrBlock::ManualSearchSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .sort - .as_ref() - .unwrap() - .current_selection(), - &release_field_vec[(i + 1) % release_field_vec.len()] - ); - } - } - } } mod test_handle_home_end { - use crate::extended_stateful_iterable_vec; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use super::*; @@ -666,507 +233,6 @@ mod tests { 0 ); } - - #[test] - fn test_movie_history_home_end() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::MovieHistory.into()); - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_history - .set_items(extended_stateful_iterable_vec!( - MovieHistoryItem, - HorizontallyScrollableText, - source_title - )); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::MovieHistory, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_history - .current_selection() - .source_title - .to_string(), - "Test 3" - ); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::MovieHistory, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_history - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - } - - #[test] - fn test_movie_history_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_history - .set_items(extended_stateful_iterable_vec!( - MovieHistoryItem, - HorizontallyScrollableText, - source_title - )); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::MovieHistory, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_history - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::MovieHistory, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_history - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - } - - #[test] - fn test_cast_home_end() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Cast.into()); - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_cast - .set_items(extended_stateful_iterable_vec!(Credit, String, person_name)); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::Cast, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_cast - .current_selection() - .person_name, - "Test 3" - ); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::Cast, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_cast - .current_selection() - .person_name, - "Test 1" - ); - } - - #[test] - fn test_cast_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_cast - .set_items(extended_stateful_iterable_vec!(Credit, String, person_name)); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::Cast, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_cast - .current_selection() - .person_name, - "Test 1" - ); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::Cast, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_cast - .current_selection() - .person_name, - "Test 1" - ); - } - - #[test] - fn test_crew_home_end() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::Crew.into()); - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_crew - .set_items(extended_stateful_iterable_vec!(Credit, String, person_name)); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::Crew, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_crew - .current_selection() - .person_name, - "Test 3" - ); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::Crew, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_crew - .current_selection() - .person_name, - "Test 1" - ); - } - - #[test] - fn test_crew_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_crew - .set_items(extended_stateful_iterable_vec!(Credit, String, person_name)); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::Crew, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_crew - .current_selection() - .person_name, - "Test 1" - ); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::Crew, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_crew - .current_selection() - .person_name, - "Test 1" - ); - } - - #[test] - fn test_manual_search_home_end() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into()); - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_releases - .set_items(extended_stateful_iterable_vec!( - RadarrRelease, - HorizontallyScrollableText - )); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::ManualSearch, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .current_selection() - .title - .to_string(), - "Test 3" - ); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::ManualSearch, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - - #[test] - fn test_manual_search_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_releases - .set_items(extended_stateful_iterable_vec!( - RadarrRelease, - HorizontallyScrollableText - )); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::ManualSearch, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .current_selection() - .title - .to_string(), - "Test 1" - ); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::ManualSearch, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - - #[test] - fn test_manual_search_sort_home_end() { - let release_field_vec = sort_options(); - let mut app = App::default(); - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal.movie_releases.sorting(sort_options()); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::ManualSearchSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .sort - .as_ref() - .unwrap() - .current_selection(), - &release_field_vec[release_field_vec.len() - 1] - ); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::ManualSearchSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .sort - .as_ref() - .unwrap() - .current_selection(), - &release_field_vec[0] - ); - } } mod test_handle_left_right_action { @@ -1355,45 +421,6 @@ mod tests { ); assert_eq!(app.data.radarr_data.prompt_confirm_action, None); } - - #[test] - fn test_manual_search_sort_prompt_submit() { - let mut app = App::default(); - let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal.movie_releases.sort_asc = true; - movie_details_modal.movie_releases.sorting(sort_options()); - movie_details_modal.movie_releases.set_items(release_vec()); - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into()); - app.push_navigation_stack(ActiveRadarrBlock::ManualSearchSortPrompt.into()); - - let mut expected_vec = release_vec(); - expected_vec.reverse(); - - MovieDetailsHandler::with( - SUBMIT_KEY, - &mut app, - ActiveRadarrBlock::ManualSearchSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::ManualSearch.into() - ); - assert_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .items, - expected_vec - ); - } } mod test_handle_esc { @@ -1437,7 +464,7 @@ mod tests { #[values( ActiveRadarrBlock::AutomaticallySearchMoviePrompt, ActiveRadarrBlock::UpdateAndScanPrompt, - ActiveRadarrBlock::ManualSearchConfirmPrompt, + ActiveRadarrBlock::ManualSearchConfirmPrompt )] prompt_block: ActiveRadarrBlock, #[values(true, false)] is_ready: bool, @@ -1454,18 +481,6 @@ mod tests { assert!(!app.data.radarr_data.prompt_confirm); assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); } - - #[rstest] - fn test_manual_search_sort_prompt_esc() { - let mut app = App::default(); - app.data.radarr_data = create_test_radarr_data(); - app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); - app.push_navigation_stack(ActiveRadarrBlock::ManualSearchSortPrompt.into()); - - MovieDetailsHandler::with(ESC_KEY, &mut app, ActiveRadarrBlock::ManualSearchSortPrompt, None).handle(); - - assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); - } } mod test_handle_key_char { @@ -1474,7 +489,6 @@ mod tests { use rstest::rstest; use strum::IntoEnumIterator; - use crate::handlers::radarr_handlers::library::movie_details_handler::releases_sorting_options; use crate::models::radarr_models::RadarrRelease; use crate::models::radarr_models::{MinimumAvailability, Movie}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; @@ -1559,76 +573,6 @@ mod tests { assert_eq!(app.get_current_route(), active_radarr_block.into()); } - #[test] - fn test_sort_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into()); - let mut modal = MovieDetailsModal::default(); - modal.movie_releases.set_items(release_vec()); - app.data.radarr_data.movie_details_modal = Some(modal); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveRadarrBlock::ManualSearch, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::ManualSearchSortPrompt.into() - ); - assert_eq!( - app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .sort - .as_ref() - .unwrap() - .items, - releases_sorting_options() - ); - assert!( - !app - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .sort_asc - ); - } - - #[test] - fn test_sort_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into()); - app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal { - movie_details: ScrollableText::with_string("test".to_owned()), - ..MovieDetailsModal::default() - }); - - MovieDetailsHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveRadarrBlock::ManualSearch, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveRadarrBlock::ManualSearch.into() - ); - } - #[rstest] fn test_edit_key( #[values( @@ -2249,11 +1193,4 @@ mod tests { vec![release_a, release_b, release_c] } - - fn sort_options() -> Vec> { - vec![SortOption { - name: "Test 1", - cmp_fn: Some(|a, b| a.age.cmp(&b.age)), - }] - } } diff --git a/src/handlers/radarr_handlers/root_folders/mod.rs b/src/handlers/radarr_handlers/root_folders/mod.rs index 841b762..6ffc3f0 100644 --- a/src/handlers/radarr_handlers/root_folders/mod.rs +++ b/src/handlers/radarr_handlers/root_folders/mod.rs @@ -2,11 +2,11 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; -use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ROOT_FOLDERS_BLOCKS}; use crate::models::servarr_models::RootFolder; -use crate::models::{HorizontallyScrollableText, Scrollable}; +use crate::models::HorizontallyScrollableText; use crate::network::radarr_network::RadarrEvent; use crate::{handle_table_events, handle_text_box_keys, handle_text_box_left_right_keys}; @@ -32,10 +32,10 @@ impl<'a, 'b> RootFoldersHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RootFoldersHandler<'a, 'b> { fn handle(&mut self) { - let root_folder_table_handling_props = - TableHandlingProps::new(ActiveRadarrBlock::RootFolders.into()); + let root_folder_table_handling_config = + TableHandlingConfig::new(ActiveRadarrBlock::RootFolders.into()); - if !self.handle_root_folders_table_events(root_folder_table_handling_props) { + if !self.handle_root_folders_table_events(root_folder_table_handling_config) { self.handle_key_event(); } } diff --git a/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs b/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs index 670eb9e..420204a 100644 --- a/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs +++ b/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod tests { - use pretty_assertions::assert_str_eq; use strum::IntoEnumIterator; use crate::app::key_binding::DEFAULT_KEYBINDINGS; @@ -12,110 +11,12 @@ mod tests { use crate::models::servarr_models::RootFolder; use crate::models::HorizontallyScrollableText; - mod test_handle_scroll_up_and_down { - use rstest::rstest; - - use crate::models::servarr_models::RootFolder; - use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; - - use super::*; - - test_iterable_scroll!( - test_root_folders_scroll, - RootFoldersHandler, - radarr_data, - root_folders, - simple_stateful_iterable_vec!(RootFolder, String, path), - ActiveRadarrBlock::RootFolders, - None, - path - ); - - #[rstest] - fn test_root_folders_scroll_no_op_when_not_ready( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.is_loading = true; - app - .data - .radarr_data - .root_folders - .set_items(simple_stateful_iterable_vec!(RootFolder, String, path)); - - RootFoldersHandler::with(key, &mut app, ActiveRadarrBlock::RootFolders, None).handle(); - - assert_str_eq!( - app.data.radarr_data.root_folders.current_selection().path, - "Test 1" - ); - - RootFoldersHandler::with(key, &mut app, ActiveRadarrBlock::RootFolders, None).handle(); - - assert_str_eq!( - app.data.radarr_data.root_folders.current_selection().path, - "Test 1" - ); - } - } - mod test_handle_home_end { + use pretty_assertions::assert_eq; use std::sync::atomic::Ordering; - use pretty_assertions::assert_eq; - - use crate::models::servarr_models::RootFolder; - use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; - use super::*; - - test_iterable_home_and_end!( - test_root_folders_home_end, - RootFoldersHandler, - radarr_data, - root_folders, - extended_stateful_iterable_vec!(RootFolder, String, path), - ActiveRadarrBlock::RootFolders, - None, - path - ); - - #[test] - fn test_root_folders_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app - .data - .radarr_data - .root_folders - .set_items(extended_stateful_iterable_vec!(RootFolder, String, path)); - - RootFoldersHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveRadarrBlock::RootFolders, - None, - ) - .handle(); - - assert_str_eq!( - app.data.radarr_data.root_folders.current_selection().path, - "Test 1" - ); - - RootFoldersHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveRadarrBlock::RootFolders, - None, - ) - .handle(); - - assert_str_eq!( - app.data.radarr_data.root_folders.current_selection().path, - "Test 1" - ); - } + use crate::models::servarr_models::RootFolder; #[test] fn test_add_root_folder_prompt_home_end_keys() { diff --git a/src/handlers/radarr_handlers/system/system_handler_tests.rs b/src/handlers/radarr_handlers/system/system_handler_tests.rs index 8ee7065..46d3cba 100644 --- a/src/handlers/radarr_handlers/system/system_handler_tests.rs +++ b/src/handlers/radarr_handlers/system/system_handler_tests.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod tests { - use pretty_assertions::assert_eq; use rstest::rstest; use strum::IntoEnumIterator; diff --git a/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs index 026f842..05a80e6 100644 --- a/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs +++ b/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs @@ -14,242 +14,6 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, BLOCKLIST_BLOCKS}; use crate::models::servarr_models::{Language, Quality, QualityWrapper}; use crate::models::sonarr_models::BlocklistItem; - use crate::models::stateful_table::SortOption; - - mod test_handle_scroll_up_and_down { - use pretty_assertions::{assert_eq, assert_str_eq}; - use rstest::rstest; - - use crate::models::sonarr_models::BlocklistItem; - use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; - - use super::*; - - test_iterable_scroll!( - test_blocklist_scroll, - BlocklistHandler, - sonarr_data, - blocklist, - simple_stateful_iterable_vec!(BlocklistItem, String, source_title), - ActiveSonarrBlock::Blocklist, - None, - source_title, - to_string - ); - - #[rstest] - fn test_blocklist_scroll_no_op_when_not_ready( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); - app.is_loading = true; - app - .data - .sonarr_data - .blocklist - .set_items(simple_stateful_iterable_vec!( - BlocklistItem, - String, - source_title - )); - - BlocklistHandler::with(key, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .blocklist - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - - BlocklistHandler::with(key, &mut app, ActiveSonarrBlock::Blocklist, None).handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .blocklist - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - } - - #[rstest] - fn test_blocklist_sort_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let blocklist_field_vec = sort_options(); - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); - app.data.sonarr_data.blocklist.sorting(sort_options()); - - if key == Key::Up { - for i in (0..blocklist_field_vec.len()).rev() { - BlocklistHandler::with(key, &mut app, ActiveSonarrBlock::BlocklistSortPrompt, None) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .blocklist - .sort - .as_ref() - .unwrap() - .current_selection(), - &blocklist_field_vec[i] - ); - } - } else { - for i in 0..blocklist_field_vec.len() { - BlocklistHandler::with(key, &mut app, ActiveSonarrBlock::BlocklistSortPrompt, None) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .blocklist - .sort - .as_ref() - .unwrap() - .current_selection(), - &blocklist_field_vec[(i + 1) % blocklist_field_vec.len()] - ); - } - } - } - } - - mod test_handle_home_end { - use pretty_assertions::{assert_eq, assert_str_eq}; - - use crate::models::sonarr_models::BlocklistItem; - use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; - - use super::*; - - test_iterable_home_and_end!( - test_blocklist_home_and_end, - BlocklistHandler, - sonarr_data, - blocklist, - extended_stateful_iterable_vec!(BlocklistItem, String, source_title), - ActiveSonarrBlock::Blocklist, - None, - source_title, - to_string - ); - - #[test] - fn test_blocklist_home_and_end_no_op_when_not_ready() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); - app.is_loading = true; - app - .data - .sonarr_data - .blocklist - .set_items(extended_stateful_iterable_vec!( - BlocklistItem, - String, - source_title - )); - - BlocklistHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::Blocklist, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .blocklist - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - - BlocklistHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::Blocklist, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .blocklist - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - } - - #[test] - fn test_blocklist_sort_home_end() { - let blocklist_field_vec = sort_options(); - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); - app.data.sonarr_data.blocklist.sorting(sort_options()); - - BlocklistHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::BlocklistSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .blocklist - .sort - .as_ref() - .unwrap() - .current_selection(), - &blocklist_field_vec[blocklist_field_vec.len() - 1] - ); - - BlocklistHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::BlocklistSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .blocklist - .sort - .as_ref() - .unwrap() - .current_selection(), - &blocklist_field_vec[0] - ); - } - } mod test_handle_delete { use pretty_assertions::assert_eq; @@ -444,31 +208,6 @@ mod tests { assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); } - - #[test] - fn test_blocklist_sort_prompt_submit() { - let mut app = App::default(); - app.data.sonarr_data.blocklist.sort_asc = true; - app.data.sonarr_data.blocklist.sorting(sort_options()); - app.data.sonarr_data.blocklist.set_items(blocklist_vec()); - app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); - app.push_navigation_stack(ActiveSonarrBlock::BlocklistSortPrompt.into()); - - let mut expected_vec = blocklist_vec(); - expected_vec.sort_by(|a, b| a.id.cmp(&b.id)); - expected_vec.reverse(); - - BlocklistHandler::with( - SUBMIT_KEY, - &mut app, - ActiveSonarrBlock::BlocklistSortPrompt, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); - assert_eq!(app.data.sonarr_data.blocklist.items, expected_vec); - } } mod test_handle_esc { @@ -520,23 +259,6 @@ mod tests { assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); } - #[test] - fn test_blocklist_sort_prompt_block_esc() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); - app.push_navigation_stack(ActiveSonarrBlock::BlocklistSortPrompt.into()); - - BlocklistHandler::with( - ESC_KEY, - &mut app, - ActiveSonarrBlock::BlocklistSortPrompt, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); - } - #[rstest] fn test_default_esc(#[values(true, false)] is_ready: bool) { let mut app = App::default(); @@ -635,51 +357,6 @@ mod tests { assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); } - #[test] - fn test_sort_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); - app.data.sonarr_data.blocklist.set_items(blocklist_vec()); - - BlocklistHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveSonarrBlock::Blocklist, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::BlocklistSortPrompt.into() - ); - assert_eq!( - app.data.sonarr_data.blocklist.sort.as_ref().unwrap().items, - blocklist_sorting_options() - ); - assert!(!app.data.sonarr_data.blocklist.sort_asc); - } - - #[test] - fn test_sort_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveSonarrBlock::Blocklist.into()); - app.data.sonarr_data.blocklist.set_items(blocklist_vec()); - - BlocklistHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveSonarrBlock::Blocklist, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::Blocklist.into()); - assert!(app.data.sonarr_data.blocklist.sort.is_none()); - assert!(!app.data.sonarr_data.blocklist.sort_asc); - } - #[rstest] #[case( ActiveSonarrBlock::Blocklist, @@ -931,15 +608,4 @@ mod tests { }, ] } - - fn sort_options() -> Vec> { - vec![SortOption { - name: "Test 1", - cmp_fn: Some(|a, b| { - b.source_title - .to_lowercase() - .cmp(&a.source_title.to_lowercase()) - }), - }] - } } diff --git a/src/handlers/sonarr_handlers/blocklist/mod.rs b/src/handlers/sonarr_handlers/blocklist/mod.rs index c227d4e..5a88c4f 100644 --- a/src/handlers/sonarr_handlers/blocklist/mod.rs +++ b/src/handlers/sonarr_handlers/blocklist/mod.rs @@ -3,12 +3,11 @@ use crate::app::App; use crate::event::Key; use crate::handle_table_events; use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; -use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, BLOCKLIST_BLOCKS}; use crate::models::sonarr_models::BlocklistItem; use crate::models::stateful_table::SortOption; -use crate::models::Scrollable; use crate::network::sonarr_network::SonarrEvent; #[cfg(test)] @@ -33,13 +32,13 @@ impl<'a, 'b> BlocklistHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for BlocklistHandler<'a, 'b> { fn handle(&mut self) { - let blocklist_table_handling_props = - TableHandlingProps::new(ActiveSonarrBlock::Blocklist.into()) + let blocklist_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::Blocklist.into()) .sorting_block(ActiveSonarrBlock::BlocklistSortPrompt.into()) .sort_by_fn(|a: &BlocklistItem, b: &BlocklistItem| a.id.cmp(&b.id)) .sort_options(blocklist_sorting_options()); - if !self.handle_blocklist_table_events(blocklist_table_handling_props) { + if !self.handle_blocklist_table_events(blocklist_table_handling_config) { self.handle_key_event(); } } @@ -70,69 +69,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for BlocklistHandler<'a, !self.app.is_loading && !self.app.data.sonarr_data.blocklist.is_empty() } - fn handle_scroll_up(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::Blocklist => self.app.data.sonarr_data.blocklist.scroll_up(), - ActiveSonarrBlock::BlocklistSortPrompt => self - .app - .data - .sonarr_data - .blocklist - .sort - .as_mut() - .unwrap() - .scroll_up(), - _ => (), - } - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::Blocklist => self.app.data.sonarr_data.blocklist.scroll_down(), - ActiveSonarrBlock::BlocklistSortPrompt => self - .app - .data - .sonarr_data - .blocklist - .sort - .as_mut() - .unwrap() - .scroll_down(), - _ => (), - } - } + fn handle_scroll_down(&mut self) {} - fn handle_home(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::Blocklist => self.app.data.sonarr_data.blocklist.scroll_to_top(), - ActiveSonarrBlock::BlocklistSortPrompt => self - .app - .data - .sonarr_data - .blocklist - .sort - .as_mut() - .unwrap() - .scroll_to_top(), - _ => (), - } - } + fn handle_home(&mut self) {} - fn handle_end(&mut self) { - match self.active_sonarr_block { - ActiveSonarrBlock::Blocklist => self.app.data.sonarr_data.blocklist.scroll_to_bottom(), - ActiveSonarrBlock::BlocklistSortPrompt => self - .app - .data - .sonarr_data - .blocklist - .sort - .as_mut() - .unwrap() - .scroll_to_bottom(), - _ => (), - } - } + fn handle_end(&mut self) {} fn handle_delete(&mut self) { if self.active_sonarr_block == ActiveSonarrBlock::Blocklist { @@ -168,18 +111,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for BlocklistHandler<'a, self.app.pop_navigation_stack(); } - ActiveSonarrBlock::BlocklistSortPrompt => { - self - .app - .data - .sonarr_data - .blocklist - .items - .sort_by(|a, b| a.id.cmp(&b.id)); - self.app.data.sonarr_data.blocklist.apply_sorting(); - - self.app.pop_navigation_stack(); - } ActiveSonarrBlock::Blocklist => { self .app @@ -215,17 +146,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for BlocklistHandler<'a, .app .push_navigation_stack(ActiveSonarrBlock::BlocklistClearAllItemsPrompt.into()); } - _ if key == DEFAULT_KEYBINDINGS.sort.key => { - self - .app - .data - .sonarr_data - .blocklist - .sorting(blocklist_sorting_options()); - self - .app - .push_navigation_stack(ActiveSonarrBlock::BlocklistSortPrompt.into()); - } _ => (), }, ActiveSonarrBlock::DeleteBlocklistItemPrompt => { diff --git a/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs index 6ed8032..dd2ac0f 100644 --- a/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs +++ b/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod tests { - use pretty_assertions::assert_str_eq; use strum::IntoEnumIterator; use crate::app::key_binding::DEFAULT_KEYBINDINGS; @@ -11,113 +10,6 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DOWNLOADS_BLOCKS}; use crate::models::sonarr_models::DownloadRecord; - mod test_handle_scroll_up_and_down { - use rstest::rstest; - - use crate::models::sonarr_models::DownloadRecord; - use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; - - use super::*; - - test_iterable_scroll!( - test_downloads_scroll, - DownloadsHandler, - sonarr_data, - downloads, - DownloadRecord, - ActiveSonarrBlock::Downloads, - None, - title - ); - - #[rstest] - fn test_downloads_scroll_no_op_when_not_ready( - #[values( - DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key - )] - key: Key, - ) { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); - app.is_loading = true; - app - .data - .sonarr_data - .downloads - .set_items(simple_stateful_iterable_vec!(DownloadRecord)); - - DownloadsHandler::with(key, &mut app, ActiveSonarrBlock::Downloads, None).handle(); - - assert_str_eq!( - app.data.sonarr_data.downloads.current_selection().title, - "Test 1" - ); - - DownloadsHandler::with(key, &mut app, ActiveSonarrBlock::Downloads, None).handle(); - - assert_str_eq!( - app.data.sonarr_data.downloads.current_selection().title, - "Test 1" - ); - } - } - - mod test_handle_home_end { - use crate::models::sonarr_models::DownloadRecord; - use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; - - use super::*; - - test_iterable_home_and_end!( - test_downloads_home_end, - DownloadsHandler, - sonarr_data, - downloads, - DownloadRecord, - ActiveSonarrBlock::Downloads, - None, - title - ); - - #[test] - fn test_downloads_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Downloads.into()); - app.is_loading = true; - app - .data - .sonarr_data - .downloads - .set_items(extended_stateful_iterable_vec!(DownloadRecord)); - - DownloadsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::Downloads, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.downloads.current_selection().title, - "Test 1" - ); - - DownloadsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::Downloads, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.downloads.current_selection().title, - "Test 1" - ); - } - } - mod test_handle_delete { use pretty_assertions::assert_eq; diff --git a/src/handlers/sonarr_handlers/downloads/mod.rs b/src/handlers/sonarr_handlers/downloads/mod.rs index 2482d76..6b1fe51 100644 --- a/src/handlers/sonarr_handlers/downloads/mod.rs +++ b/src/handlers/sonarr_handlers/downloads/mod.rs @@ -3,11 +3,10 @@ use crate::app::App; use crate::event::Key; use crate::handle_table_events; use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; -use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DOWNLOADS_BLOCKS}; use crate::models::sonarr_models::DownloadRecord; -use crate::models::Scrollable; use crate::network::sonarr_network::SonarrEvent; #[cfg(test)] @@ -32,10 +31,10 @@ impl<'a, 'b> DownloadsHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for DownloadsHandler<'a, 'b> { fn handle(&mut self) { - let download_table_handling_props = - TableHandlingProps::new(ActiveSonarrBlock::Downloads.into()); + let download_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::Downloads.into()); - if !self.handle_downloads_table_events(download_table_handling_props) { + if !self.handle_downloads_table_events(download_table_handling_config) { self.handle_key_event(); } } diff --git a/src/handlers/sonarr_handlers/history/history_handler_tests.rs b/src/handlers/sonarr_handlers/history/history_handler_tests.rs index b037f37..67fe9df 100644 --- a/src/handlers/sonarr_handlers/history/history_handler_tests.rs +++ b/src/handlers/sonarr_handlers/history/history_handler_tests.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod tests { - use core::sync::atomic::Ordering::SeqCst; use std::cmp::Ordering; use chrono::DateTime; @@ -15,349 +14,6 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS}; use crate::models::servarr_models::{Language, Quality, QualityWrapper}; use crate::models::sonarr_models::{SonarrHistoryEventType, SonarrHistoryItem}; - use crate::models::stateful_table::SortOption; - use crate::models::HorizontallyScrollableText; - - mod test_handle_scroll_up_and_down { - use pretty_assertions::{assert_eq, assert_str_eq}; - use rstest::rstest; - - use crate::models::sonarr_models::SonarrHistoryItem; - use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; - - use super::*; - - test_iterable_scroll!( - test_history_scroll, - HistoryHandler, - sonarr_data, - history, - simple_stateful_iterable_vec!(SonarrHistoryItem, HorizontallyScrollableText, source_title), - ActiveSonarrBlock::History, - None, - source_title, - to_string - ); - - #[rstest] - fn test_history_scroll_no_op_when_not_ready( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.is_loading = true; - app - .data - .sonarr_data - .history - .set_items(simple_stateful_iterable_vec!( - SonarrHistoryItem, - HorizontallyScrollableText, - source_title - )); - - HistoryHandler::with(key, &mut app, ActiveSonarrBlock::History, None).handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .history - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - - HistoryHandler::with(key, &mut app, ActiveSonarrBlock::History, None).handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .history - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - } - - #[rstest] - fn test_history_sort_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let history_field_vec = sort_options(); - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.data.sonarr_data.history.sorting(sort_options()); - - if key == Key::Up { - for i in (0..history_field_vec.len()).rev() { - HistoryHandler::with(key, &mut app, ActiveSonarrBlock::HistorySortPrompt, None).handle(); - - assert_eq!( - app - .data - .sonarr_data - .history - .sort - .as_ref() - .unwrap() - .current_selection(), - &history_field_vec[i] - ); - } - } else { - for i in 0..history_field_vec.len() { - HistoryHandler::with(key, &mut app, ActiveSonarrBlock::HistorySortPrompt, None).handle(); - - assert_eq!( - app - .data - .sonarr_data - .history - .sort - .as_ref() - .unwrap() - .current_selection(), - &history_field_vec[(i + 1) % history_field_vec.len()] - ); - } - } - } - } - - mod test_handle_home_end { - use pretty_assertions::{assert_eq, assert_str_eq}; - - use crate::models::sonarr_models::SonarrHistoryItem; - use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; - - use super::*; - - test_iterable_home_and_end!( - test_history_home_and_end, - HistoryHandler, - sonarr_data, - history, - extended_stateful_iterable_vec!(SonarrHistoryItem, HorizontallyScrollableText, source_title), - ActiveSonarrBlock::History, - None, - source_title, - to_string - ); - - #[test] - fn test_history_home_and_end_no_op_when_not_ready() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.is_loading = true; - app - .data - .sonarr_data - .history - .set_items(extended_stateful_iterable_vec!( - SonarrHistoryItem, - HorizontallyScrollableText, - source_title - )); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::History, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .history - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::History, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .history - .current_selection() - .source_title - .to_string(), - "Test 1" - ); - } - - #[test] - fn test_history_search_box_home_end_keys() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); - app - .data - .sonarr_data - .history - .set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.history.search = Some("Test".into()); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::SearchHistory, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .history - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 4 - ); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::SearchHistory, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .history - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } - - #[test] - fn test_history_filter_box_home_end_keys() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::FilterHistory.into()); - app - .data - .sonarr_data - .history - .set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.history.filter = Some("Test".into()); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::FilterHistory, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .history - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 4 - ); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::FilterHistory, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .history - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } - - #[test] - fn test_history_sort_home_end() { - let history_field_vec = sort_options(); - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.data.sonarr_data.history.sorting(sort_options()); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::HistorySortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .history - .sort - .as_ref() - .unwrap() - .current_selection(), - &history_field_vec[history_field_vec.len() - 1] - ); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::HistorySortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .history - .sort - .as_ref() - .unwrap() - .current_selection(), - &history_field_vec[0] - ); - } - } mod test_handle_left_right_action { use pretty_assertions::assert_eq; @@ -411,113 +67,11 @@ mod tests { ActiveSonarrBlock::RootFolders.into() ); } - - #[test] - fn test_history_search_box_left_right_keys() { - let mut app = App::default(); - app.data.sonarr_data.history.set_items(vec![SonarrHistoryItem::default()]); - app.push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); - app.data.sonarr_data.history.search = Some("Test".into()); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.left.key, - &mut app, - ActiveSonarrBlock::SearchHistory, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .history - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 1 - ); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.right.key, - &mut app, - ActiveSonarrBlock::SearchHistory, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .history - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } - - #[test] - fn test_history_filter_box_left_right_keys() { - let mut app = App::default(); - app.data.sonarr_data.history.set_items(vec![SonarrHistoryItem::default()]); - app.push_navigation_stack(ActiveSonarrBlock::FilterHistory.into()); - app.data.sonarr_data.history.filter = Some("Test".into()); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.left.key, - &mut app, - ActiveSonarrBlock::FilterHistory, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .history - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 1 - ); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.right.key, - &mut app, - ActiveSonarrBlock::FilterHistory, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .history - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } } mod test_handle_submit { use pretty_assertions::assert_eq; - use crate::extended_stateful_iterable_vec; - use super::*; const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; @@ -547,74 +101,20 @@ mod tests { assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; #[test] - fn test_search_history_submit() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); - app - .data - .sonarr_data - .history - .set_items(extended_stateful_iterable_vec!( - SonarrHistoryItem, - HorizontallyScrollableText, - source_title - )); - app.data.sonarr_data.history.search = Some("Test 2".into()); - - HistoryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchHistory, None).handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .history - .current_selection() - .source_title - .text, - "Test 2" - ); - assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); - } - - #[test] - fn test_search_history_submit_error_on_no_search_hits() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); - app - .data - .sonarr_data - .history - .set_items(extended_stateful_iterable_vec!( - SonarrHistoryItem, - HorizontallyScrollableText, - source_title - )); - app.data.sonarr_data.history.search = Some("Test 5".into()); - - HistoryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchHistory, None).handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .history - .current_selection() - .source_title - .text, - "Test 1" - ); - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SearchHistoryError.into() - ); - } - - #[test] - fn test_search_filtered_history_submit() { + fn test_esc_history_item_details() { let mut app = App::default(); app .data @@ -622,201 +122,6 @@ mod tests { .history .set_items(vec![SonarrHistoryItem::default()]); app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); - app - .data - .sonarr_data - .history - .set_filtered_items(extended_stateful_iterable_vec!( - SonarrHistoryItem, - HorizontallyScrollableText, - source_title - )); - app.data.sonarr_data.history.search = Some("Test 2".into()); - - HistoryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchHistory, None).handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .history - .current_selection() - .source_title - .text, - "Test 2" - ); - assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); - } - - #[test] - fn test_filter_history_submit() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.push_navigation_stack(ActiveSonarrBlock::FilterHistory.into()); - app - .data - .sonarr_data - .history - .set_items(extended_stateful_iterable_vec!( - SonarrHistoryItem, - HorizontallyScrollableText, - source_title - )); - app.data.sonarr_data.history.filter = Some("Test".into()); - - HistoryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::FilterHistory, None).handle(); - - assert!(app.data.sonarr_data.history.filtered_items.is_some()); - assert!(!app.should_ignore_quit_key); - assert_eq!( - app - .data - .sonarr_data - .history - .filtered_items - .as_ref() - .unwrap() - .len(), - 3 - ); - assert_str_eq!( - app - .data - .sonarr_data - .history - .current_selection() - .source_title - .text, - "Test 1" - ); - assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); - } - - #[test] - fn test_filter_history_submit_error_on_no_filter_matches() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.push_navigation_stack(ActiveSonarrBlock::FilterHistory.into()); - app - .data - .sonarr_data - .history - .set_items(extended_stateful_iterable_vec!( - SonarrHistoryItem, - HorizontallyScrollableText, - source_title - )); - app.data.sonarr_data.history.filter = Some("Test 5".into()); - - HistoryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::FilterHistory, None).handle(); - - assert!(!app.should_ignore_quit_key); - assert!(app.data.sonarr_data.history.filtered_items.is_none()); - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::FilterHistoryError.into() - ); - } - - #[test] - fn test_history_sort_prompt_submit() { - let mut app = App::default(); - app.data.sonarr_data.history.sort_asc = true; - app.data.sonarr_data.history.sorting(sort_options()); - app.data.sonarr_data.history.set_items(history_vec()); - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.push_navigation_stack(ActiveSonarrBlock::HistorySortPrompt.into()); - - let mut expected_vec = history_vec(); - expected_vec.sort_by(|a, b| a.id.cmp(&b.id)); - expected_vec.reverse(); - - HistoryHandler::with( - SUBMIT_KEY, - &mut app, - ActiveSonarrBlock::HistorySortPrompt, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); - assert_eq!(app.data.sonarr_data.history.items, expected_vec); - } - } - - mod test_handle_esc { - use pretty_assertions::assert_eq; - use ratatui::widgets::TableState; - use rstest::rstest; - - use crate::models::{ - servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data, - stateful_table::StatefulTable, - }; - - use super::*; - - const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; - - #[rstest] - fn test_search_history_block_esc( - #[values( - ActiveSonarrBlock::SearchHistory, - ActiveSonarrBlock::SearchHistoryError - )] - active_sonarr_block: ActiveSonarrBlock, - ) { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.push_navigation_stack(active_sonarr_block.into()); - app.data.sonarr_data = create_test_sonarr_data(); - app.data.sonarr_data.history.set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.history.search = Some("Test".into()); - - HistoryHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.sonarr_data.history.search, None); - } - - #[rstest] - fn test_filter_history_block_esc( - #[values( - ActiveSonarrBlock::FilterHistory, - ActiveSonarrBlock::FilterHistoryError - )] - active_sonarr_block: ActiveSonarrBlock, - ) { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.push_navigation_stack(active_sonarr_block.into()); - app.data.sonarr_data = create_test_sonarr_data(); - app.data.sonarr_data.history = StatefulTable { - filter: Some("Test".into()), - filtered_items: Some(Vec::new()), - filtered_state: Some(TableState::default()), - ..StatefulTable::default() - }; - app.data.sonarr_data.history.set_items(vec![SonarrHistoryItem::default()]); - - HistoryHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.sonarr_data.history.filter, None); - assert_eq!(app.data.sonarr_data.history.filtered_items, None); - assert_eq!(app.data.sonarr_data.history.filtered_state, None); - } - - #[test] - fn test_esc_history_item_details() { - let mut app = App::default(); - app.data.sonarr_data.history.set_items(vec![SonarrHistoryItem::default()]); - app.push_navigation_stack(ActiveSonarrBlock::History.into()); app.push_navigation_stack(ActiveSonarrBlock::HistoryItemDetails.into()); HistoryHandler::with( @@ -830,24 +135,6 @@ mod tests { assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); } - #[test] - fn test_history_sort_prompt_block_esc() { - let mut app = App::default(); - app.data.sonarr_data.history.set_items(vec![SonarrHistoryItem::default()]); - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.push_navigation_stack(ActiveSonarrBlock::HistorySortPrompt.into()); - - HistoryHandler::with( - ESC_KEY, - &mut app, - ActiveSonarrBlock::HistorySortPrompt, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); - } - #[rstest] fn test_default_esc(#[values(true, false)] is_ready: bool) { let mut app = App::default(); @@ -856,7 +143,11 @@ mod tests { app.push_navigation_stack(ActiveSonarrBlock::History.into()); app.push_navigation_stack(ActiveSonarrBlock::History.into()); app.data.sonarr_data = create_test_sonarr_data(); - app.data.sonarr_data.history.set_items(vec![SonarrHistoryItem::default()]); + app + .data + .sonarr_data + .history + .set_items(vec![SonarrHistoryItem::default()]); HistoryHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::History, None).handle(); @@ -868,147 +159,8 @@ mod tests { mod test_handle_key_char { use pretty_assertions::assert_eq; - use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; - use super::*; - #[test] - fn test_search_history_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app - .data - .sonarr_data - .history - .set_items(vec![SonarrHistoryItem::default()]); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.search.key, - &mut app, - ActiveSonarrBlock::History, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SearchHistory.into() - ); - assert!(app.should_ignore_quit_key); - assert_eq!( - app.data.sonarr_data.history.search, - Some(HorizontallyScrollableText::default()) - ); - } - - #[test] - fn test_search_history_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app - .data - .sonarr_data - .history - .set_items(vec![SonarrHistoryItem::default()]); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.search.key, - &mut app, - ActiveSonarrBlock::History, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.sonarr_data.history.search, None); - } - - #[test] - fn test_filter_history_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app - .data - .sonarr_data - .history - .set_items(vec![SonarrHistoryItem::default()]); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.filter.key, - &mut app, - ActiveSonarrBlock::History, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::FilterHistory.into() - ); - assert!(app.should_ignore_quit_key); - assert!(app.data.sonarr_data.history.filter.is_some()); - } - - #[test] - fn test_filter_history_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app - .data - .sonarr_data - .history - .set_items(vec![SonarrHistoryItem::default()]); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.filter.key, - &mut app, - ActiveSonarrBlock::History, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); - assert!(!app.should_ignore_quit_key); - assert!(app.data.sonarr_data.history.filter.is_none()); - } - - #[test] - fn test_filter_history_key_resets_previous_filter() { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.data.sonarr_data = create_test_sonarr_data(); - app - .data - .sonarr_data - .history - .set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.history.filter = Some("Test".into()); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.filter.key, - &mut app, - ActiveSonarrBlock::History, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::FilterHistory.into() - ); - assert!(app.should_ignore_quit_key); - assert_eq!( - app.data.sonarr_data.history.filter, - Some(HorizontallyScrollableText::default()) - ); - assert!(app.data.sonarr_data.history.filtered_items.is_none()); - assert!(app.data.sonarr_data.history.filtered_state.is_none()); - } - #[test] fn test_refresh_history_key() { let mut app = App::default(); @@ -1045,151 +197,6 @@ mod tests { assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); assert!(!app.should_refresh); } - - #[test] - fn test_search_history_box_backspace_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); - app.data.sonarr_data.history.search = Some("Test".into()); - app - .data - .sonarr_data - .history - .set_items(vec![SonarrHistoryItem::default()]); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.backspace.key, - &mut app, - ActiveSonarrBlock::SearchHistory, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.history.search.as_ref().unwrap().text, - "Tes" - ); - } - - #[test] - fn test_filter_history_box_backspace_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::FilterHistory.into()); - app - .data - .sonarr_data - .history - .set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.history.filter = Some("Test".into()); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.backspace.key, - &mut app, - ActiveSonarrBlock::FilterHistory, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.history.filter.as_ref().unwrap().text, - "Tes" - ); - } - - #[test] - fn test_search_history_box_char_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SearchHistory.into()); - app - .data - .sonarr_data - .history - .set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.history.search = Some(HorizontallyScrollableText::default()); - - HistoryHandler::with( - Key::Char('h'), - &mut app, - ActiveSonarrBlock::SearchHistory, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.history.search.as_ref().unwrap().text, - "h" - ); - } - - #[test] - fn test_filter_history_box_char_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::FilterHistory.into()); - app - .data - .sonarr_data - .history - .set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.history.filter = Some(HorizontallyScrollableText::default()); - - HistoryHandler::with( - Key::Char('h'), - &mut app, - ActiveSonarrBlock::FilterHistory, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.history.filter.as_ref().unwrap().text, - "h" - ); - } - - #[test] - fn test_sort_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.data.sonarr_data.history.set_items(history_vec()); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveSonarrBlock::History, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::HistorySortPrompt.into() - ); - assert_eq!( - app.data.sonarr_data.history.sort.as_ref().unwrap().items, - history_sorting_options() - ); - assert!(!app.data.sonarr_data.history.sort_asc); - } - - #[test] - fn test_sort_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveSonarrBlock::History.into()); - app.data.sonarr_data.history.set_items(history_vec()); - - HistoryHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveSonarrBlock::History, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); - assert!(app.data.sonarr_data.history.sort.is_none()); - assert!(!app.data.sonarr_data.history.sort_asc); - } } #[test] @@ -1400,16 +407,4 @@ mod tests { }, ] } - - fn sort_options() -> Vec> { - vec![SortOption { - name: "Test 1", - cmp_fn: Some(|a, b| { - b.source_title - .text - .to_lowercase() - .cmp(&a.source_title.text.to_lowercase()) - }), - }] - } } diff --git a/src/handlers/sonarr_handlers/history/mod.rs b/src/handlers/sonarr_handlers/history/mod.rs index c374f23..65ec286 100644 --- a/src/handlers/sonarr_handlers/history/mod.rs +++ b/src/handlers/sonarr_handlers/history/mod.rs @@ -3,12 +3,11 @@ use crate::app::App; use crate::event::Key; use crate::handle_table_events; use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; -use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::{handle_clear_errors, KeyEventHandler}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS}; use crate::models::sonarr_models::SonarrHistoryItem; use crate::models::stateful_table::SortOption; -use crate::models::Scrollable; #[cfg(test)] #[path = "history_handler_tests.rs"] @@ -32,7 +31,7 @@ impl<'a, 'b> HistoryHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for HistoryHandler<'a, 'b> { fn handle(&mut self) { - let history_table_handling_props = TableHandlingProps::new(ActiveSonarrBlock::History.into()) + let history_table_handling_config = TableHandlingConfig::new(ActiveSonarrBlock::History.into()) .sorting_block(ActiveSonarrBlock::HistorySortPrompt.into()) .sort_by_fn(|a: &SonarrHistoryItem, b: &SonarrHistoryItem| a.id.cmp(&b.id)) .sort_options(history_sorting_options()) @@ -43,7 +42,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for HistoryHandler<'a, ' .filter_error_block(ActiveSonarrBlock::FilterHistoryError.into()) .filter_field_fn(|history| &history.source_title.text); - if !self.handle_history_table_events(history_table_handling_props) { + if !self.handle_history_table_events(history_table_handling_config) { self.handle_key_event(); } } diff --git a/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs index 826927e..9a4173d 100644 --- a/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod tests { - use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use strum::IntoEnumIterator; @@ -15,111 +14,6 @@ mod tests { use crate::models::servarr_models::Indexer; use crate::test_handler_delegation; - mod test_handle_scroll_up_and_down { - use rstest::rstest; - - use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; - - use super::*; - - test_iterable_scroll!( - test_indexers_scroll, - IndexersHandler, - sonarr_data, - indexers, - simple_stateful_iterable_vec!(Indexer, String, protocol), - ActiveSonarrBlock::Indexers, - None, - protocol - ); - - #[rstest] - fn test_indexers_scroll_no_op_when_not_ready( - #[values( - DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key - )] - key: Key, - ) { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); - app.is_loading = true; - app - .data - .sonarr_data - .indexers - .set_items(simple_stateful_iterable_vec!(Indexer, String, protocol)); - - IndexersHandler::with(key, &mut app, ActiveSonarrBlock::Indexers, None).handle(); - - assert_str_eq!( - app.data.sonarr_data.indexers.current_selection().protocol, - "Test 1" - ); - - IndexersHandler::with(key, &mut app, ActiveSonarrBlock::Indexers, None).handle(); - - assert_str_eq!( - app.data.sonarr_data.indexers.current_selection().protocol, - "Test 1" - ); - } - } - - mod test_handle_home_end { - use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; - - use super::*; - - test_iterable_home_and_end!( - test_indexers_home_end, - IndexersHandler, - sonarr_data, - indexers, - extended_stateful_iterable_vec!(Indexer, String, protocol), - ActiveSonarrBlock::Indexers, - None, - protocol - ); - - #[test] - fn test_indexers_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); - app.is_loading = true; - app - .data - .sonarr_data - .indexers - .set_items(extended_stateful_iterable_vec!(Indexer, String, protocol)); - - IndexersHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::Indexers, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.indexers.current_selection().protocol, - "Test 1" - ); - - IndexersHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::Indexers, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.indexers.current_selection().protocol, - "Test 1" - ); - } - } - mod test_handle_delete { use pretty_assertions::assert_eq; @@ -515,7 +409,11 @@ mod tests { #[test] fn test_indexer_settings_key() { let mut app = App::default(); - app.data.sonarr_data.indexers.set_items(vec![Indexer::default()]); + app + .data + .sonarr_data + .indexers + .set_items(vec![Indexer::default()]); app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); app .data diff --git a/src/handlers/sonarr_handlers/indexers/mod.rs b/src/handlers/sonarr_handlers/indexers/mod.rs index 78baf98..72e6908 100644 --- a/src/handlers/sonarr_handlers/indexers/mod.rs +++ b/src/handlers/sonarr_handlers/indexers/mod.rs @@ -6,7 +6,7 @@ use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::sonarr_handlers::indexers::edit_indexer_handler::EditIndexerHandler; use crate::handlers::sonarr_handlers::indexers::edit_indexer_settings_handler::IndexerSettingsHandler; use crate::handlers::sonarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler; -use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, @@ -14,7 +14,6 @@ use crate::models::servarr_data::sonarr::sonarr_data::{ }; use crate::models::servarr_models::Indexer; use crate::models::BlockSelectionState; -use crate::models::Scrollable; use crate::network::sonarr_network::SonarrEvent; mod edit_indexer_handler; @@ -38,9 +37,10 @@ impl<'a, 'b> IndexersHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for IndexersHandler<'a, 'b> { fn handle(&mut self) { - let indexers_table_handling_props = TableHandlingProps::new(ActiveSonarrBlock::Indexers.into()); + let indexers_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::Indexers.into()); - if !self.handle_indexers_table_events(indexers_table_handling_props) { + if !self.handle_indexers_table_events(indexers_table_handling_config) { match self.active_sonarr_block { _ if EditIndexerHandler::accepts(self.active_sonarr_block) => { EditIndexerHandler::with(self.key, self.app, self.active_sonarr_block, self.context) diff --git a/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler.rs b/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler.rs index cd97b9f..91ff186 100644 --- a/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler.rs +++ b/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler.rs @@ -1,8 +1,10 @@ use crate::app::App; use crate::event::Key; +use crate::handle_table_events; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::KeyEventHandler; +use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; -use crate::models::Scrollable; #[cfg(test)] #[path = "test_all_indexers_handler_tests.rs"] @@ -15,7 +17,33 @@ pub(super) struct TestAllIndexersHandler<'a, 'b> { _context: Option, } +impl<'a, 'b> TestAllIndexersHandler<'a, 'b> { + handle_table_events!( + self, + indexer_test_all_results, + self + .app + .data + .sonarr_data + .indexer_test_all_results + .as_mut() + .unwrap(), + IndexerTestResultModalItem + ); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for TestAllIndexersHandler<'a, 'b> { + fn handle(&mut self) { + let indexer_test_all_results_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::TestAllIndexers.into()); + + if !self + .handle_indexer_test_all_results_table_events(indexer_test_all_results_table_handling_config) + { + self.handle_key_event(); + } + } + fn accepts(active_block: ActiveSonarrBlock) -> bool { active_block == ActiveSonarrBlock::TestAllIndexers } @@ -48,57 +76,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for TestAllIndexersHandl !self.app.is_loading && table_is_ready } - fn handle_scroll_up(&mut self) { - if self.active_sonarr_block == ActiveSonarrBlock::TestAllIndexers { - self - .app - .data - .sonarr_data - .indexer_test_all_results - .as_mut() - .unwrap() - .scroll_up() - } - } + fn handle_scroll_up(&mut self) {} - fn handle_scroll_down(&mut self) { - if self.active_sonarr_block == ActiveSonarrBlock::TestAllIndexers { - self - .app - .data - .sonarr_data - .indexer_test_all_results - .as_mut() - .unwrap() - .scroll_down() - } - } + fn handle_scroll_down(&mut self) {} - fn handle_home(&mut self) { - if self.active_sonarr_block == ActiveSonarrBlock::TestAllIndexers { - self - .app - .data - .sonarr_data - .indexer_test_all_results - .as_mut() - .unwrap() - .scroll_to_top() - } - } + fn handle_home(&mut self) {} - fn handle_end(&mut self) { - if self.active_sonarr_block == ActiveSonarrBlock::TestAllIndexers { - self - .app - .data - .sonarr_data - .indexer_test_all_results - .as_mut() - .unwrap() - .scroll_to_bottom() - } - } + fn handle_end(&mut self) {} fn handle_delete(&mut self) {} diff --git a/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler_tests.rs b/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler_tests.rs index 7750b4d..be828d1 100644 --- a/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler_tests.rs +++ b/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler_tests.rs @@ -2,7 +2,6 @@ mod tests { use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; - use crate::event::Key; use crate::handlers::sonarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler; use crate::handlers::KeyEventHandler; use crate::models::servarr_data::modals::IndexerTestResultModalItem; @@ -10,224 +9,6 @@ mod tests { use crate::models::stateful_table::StatefulTable; use strum::IntoEnumIterator; - mod test_handle_scroll_up_and_down { - use pretty_assertions::assert_str_eq; - use rstest::rstest; - - use crate::models::servarr_data::modals::IndexerTestResultModalItem; - use crate::models::stateful_table::StatefulTable; - use crate::simple_stateful_iterable_vec; - - use super::*; - - #[rstest] - fn test_test_all_indexers_results_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); - let mut indexer_test_results = StatefulTable::default(); - indexer_test_results.set_items(simple_stateful_iterable_vec!( - IndexerTestResultModalItem, - String, - name - )); - app.data.sonarr_data.indexer_test_all_results = Some(indexer_test_results); - - TestAllIndexersHandler::with(key, &mut app, ActiveSonarrBlock::TestAllIndexers, None) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 2" - ); - - TestAllIndexersHandler::with(key, &mut app, ActiveSonarrBlock::TestAllIndexers, None) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 1" - ); - } - - #[rstest] - fn test_test_all_indexers_results_scroll_no_op_when_not_ready( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); - app.is_loading = true; - let mut indexer_test_results = StatefulTable::default(); - indexer_test_results.set_items(simple_stateful_iterable_vec!( - IndexerTestResultModalItem, - String, - name - )); - app.data.sonarr_data.indexer_test_all_results = Some(indexer_test_results); - - TestAllIndexersHandler::with(key, &mut app, ActiveSonarrBlock::TestAllIndexers, None) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 1" - ); - - TestAllIndexersHandler::with(key, &mut app, ActiveSonarrBlock::TestAllIndexers, None) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 1" - ); - } - } - - mod test_handle_home_end { - use crate::extended_stateful_iterable_vec; - use crate::models::servarr_data::modals::IndexerTestResultModalItem; - use crate::models::stateful_table::StatefulTable; - use pretty_assertions::assert_str_eq; - - use super::*; - - #[test] - fn test_test_all_indexers_results_home_end() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); - let mut indexer_test_results = StatefulTable::default(); - indexer_test_results.set_items(extended_stateful_iterable_vec!( - IndexerTestResultModalItem, - String, - name - )); - app.data.sonarr_data.indexer_test_all_results = Some(indexer_test_results); - - TestAllIndexersHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::TestAllIndexers, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 3" - ); - - TestAllIndexersHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::TestAllIndexers, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 1" - ); - } - - #[test] - fn test_test_all_indexers_results_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); - app.is_loading = true; - let mut indexer_test_results = StatefulTable::default(); - indexer_test_results.set_items(extended_stateful_iterable_vec!( - IndexerTestResultModalItem, - String, - name - )); - app.data.sonarr_data.indexer_test_all_results = Some(indexer_test_results); - - TestAllIndexersHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::TestAllIndexers, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 1" - ); - - TestAllIndexersHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::TestAllIndexers, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .current_selection() - .name, - "Test 1" - ); - } - } - mod test_handle_esc { use super::*; use crate::models::stateful_table::StatefulTable; diff --git a/src/handlers/sonarr_handlers/library/add_series_handler.rs b/src/handlers/sonarr_handlers/library/add_series_handler.rs index 03bce76..80d3c35 100644 --- a/src/handlers/sonarr_handlers/library/add_series_handler.rs +++ b/src/handlers/sonarr_handlers/library/add_series_handler.rs @@ -1,11 +1,13 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, }; +use crate::models::sonarr_models::AddSeriesSearchResult; use crate::models::{BlockSelectionState, Scrollable}; use crate::network::sonarr_network::SonarrEvent; -use crate::{handle_text_box_keys, handle_text_box_left_right_keys, App, Key}; +use crate::{handle_table_events, handle_text_box_keys, handle_text_box_left_right_keys, App, Key}; #[cfg(test)] #[path = "add_series_handler_tests.rs"] @@ -18,7 +20,31 @@ pub(super) struct AddSeriesHandler<'a, 'b> { _context: Option, } +impl<'a, 'b> AddSeriesHandler<'a, 'b> { + handle_table_events!( + self, + add_searched_series, + self + .app + .data + .sonarr_data + .add_searched_series + .as_mut() + .unwrap(), + AddSeriesSearchResult + ); +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for AddSeriesHandler<'a, 'b> { + fn handle(&mut self) { + let add_series_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::AddSeriesSearchResults.into()); + + if !self.handle_add_searched_series_table_events(add_series_table_handling_config) { + self.handle_key_event(); + } + } + fn accepts(active_block: ActiveSonarrBlock) -> bool { ADD_SERIES_BLOCKS.contains(&active_block) } @@ -47,14 +73,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for AddSeriesHandler<'a, fn handle_scroll_up(&mut self) { match self.active_sonarr_block { - ActiveSonarrBlock::AddSeriesSearchResults => self - .app - .data - .sonarr_data - .add_searched_series - .as_mut() - .unwrap() - .scroll_up(), ActiveSonarrBlock::AddSeriesSelectMonitor => self .app .data @@ -107,14 +125,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for AddSeriesHandler<'a, fn handle_scroll_down(&mut self) { match self.active_sonarr_block { - ActiveSonarrBlock::AddSeriesSearchResults => self - .app - .data - .sonarr_data - .add_searched_series - .as_mut() - .unwrap() - .scroll_down(), ActiveSonarrBlock::AddSeriesSelectMonitor => self .app .data @@ -167,14 +177,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for AddSeriesHandler<'a, fn handle_home(&mut self) { match self.active_sonarr_block { - ActiveSonarrBlock::AddSeriesSearchResults => self - .app - .data - .sonarr_data - .add_searched_series - .as_mut() - .unwrap() - .scroll_to_top(), ActiveSonarrBlock::AddSeriesSelectMonitor => self .app .data @@ -243,14 +245,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for AddSeriesHandler<'a, fn handle_end(&mut self) { match self.active_sonarr_block { - ActiveSonarrBlock::AddSeriesSearchResults => self - .app - .data - .sonarr_data - .add_searched_series - .as_mut() - .unwrap() - .scroll_to_bottom(), ActiveSonarrBlock::AddSeriesSelectMonitor => self .app .data diff --git a/src/handlers/sonarr_handlers/library/add_series_handler_tests.rs b/src/handlers/sonarr_handlers/library/add_series_handler_tests.rs index 63d28b2..d00fdf1 100644 --- a/src/handlers/sonarr_handlers/library/add_series_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/add_series_handler_tests.rs @@ -20,125 +20,11 @@ mod tests { use crate::models::servarr_data::sonarr::modals::AddSeriesModal; use crate::models::servarr_data::sonarr::sonarr_data::ADD_SERIES_SELECTION_BLOCKS; - use crate::models::stateful_table::StatefulTable; use crate::models::BlockSelectionState; use crate::simple_stateful_iterable_vec; use super::*; - #[rstest] - fn test_add_series_search_results_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - let mut add_searched_series = StatefulTable::default(); - add_searched_series.set_items(simple_stateful_iterable_vec!( - AddSeriesSearchResult, - HorizontallyScrollableText - )); - app.data.sonarr_data.add_searched_series = Some(add_searched_series); - - AddSeriesHandler::with( - key, - &mut app, - ActiveSonarrBlock::AddSeriesSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .add_searched_series - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 2" - ); - - AddSeriesHandler::with( - key, - &mut app, - ActiveSonarrBlock::AddSeriesSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .add_searched_series - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - - #[rstest] - fn test_add_series_search_results_scroll_no_op_when_not_ready( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.is_loading = true; - let mut add_searched_series = StatefulTable::default(); - add_searched_series.set_items(simple_stateful_iterable_vec!( - AddSeriesSearchResult, - HorizontallyScrollableText - )); - app.data.sonarr_data.add_searched_series = Some(add_searched_series); - - AddSeriesHandler::with( - key, - &mut app, - ActiveSonarrBlock::AddSeriesSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .add_searched_series - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 1" - ); - - AddSeriesHandler::with( - key, - &mut app, - ActiveSonarrBlock::AddSeriesSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .add_searched_series - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - #[rstest] fn test_add_series_select_monitor_scroll( #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, @@ -486,119 +372,9 @@ mod tests { use crate::extended_stateful_iterable_vec; use crate::models::servarr_data::sonarr::modals::AddSeriesModal; - use crate::models::stateful_table::StatefulTable; use super::*; - #[test] - fn test_add_series_search_results_home_end() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - let mut add_searched_series = StatefulTable::default(); - add_searched_series.set_items(extended_stateful_iterable_vec!( - AddSeriesSearchResult, - HorizontallyScrollableText - )); - app.data.sonarr_data.add_searched_series = Some(add_searched_series); - - AddSeriesHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::AddSeriesSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .add_searched_series - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 3" - ); - - AddSeriesHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::AddSeriesSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .add_searched_series - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - - #[test] - fn test_add_series_search_results_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.is_loading = true; - let mut add_searched_series = StatefulTable::default(); - add_searched_series.set_items(extended_stateful_iterable_vec!( - AddSeriesSearchResult, - HorizontallyScrollableText - )); - app.data.sonarr_data.add_searched_series = Some(add_searched_series); - - AddSeriesHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::AddSeriesSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .add_searched_series - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 1" - ); - - AddSeriesHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::AddSeriesSearchResults, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .add_searched_series - .as_ref() - .unwrap() - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - #[test] fn test_add_series_select_monitor_home_end() { let monitor_vec = Vec::from_iter(SeriesMonitor::iter()); diff --git a/src/handlers/sonarr_handlers/library/library_handler_tests.rs b/src/handlers/sonarr_handlers/library/library_handler_tests.rs index 2e193c2..39aea05 100644 --- a/src/handlers/sonarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/library_handler_tests.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod tests { - use core::sync::atomic::Ordering::SeqCst; use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use std::cmp::Ordering; @@ -11,343 +10,13 @@ mod tests { use crate::event::Key; use crate::handlers::sonarr_handlers::library::{series_sorting_options, LibraryHandler}; use crate::handlers::KeyEventHandler; - use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, LIBRARY_BLOCKS, SERIES_DETAILS_BLOCKS}; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, LIBRARY_BLOCKS, + SERIES_DETAILS_BLOCKS, + }; use crate::models::sonarr_models::{Series, SeriesStatus, SeriesType}; - use crate::models::stateful_table::SortOption; - use crate::models::HorizontallyScrollableText; use crate::test_handler_delegation; - mod test_handle_scroll_up_and_down { - use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; - use pretty_assertions::assert_eq; - - use super::*; - - test_iterable_scroll!( - test_series_scroll, - LibraryHandler, - sonarr_data, - series, - simple_stateful_iterable_vec!(Series, HorizontallyScrollableText), - ActiveSonarrBlock::Series, - None, - title, - to_string - ); - - #[rstest] - fn test_series_scroll_no_op_when_not_ready( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.is_loading = true; - app - .data - .sonarr_data - .series - .set_items(simple_stateful_iterable_vec!( - Series, - HorizontallyScrollableText - )); - - LibraryHandler::with(key, &mut app, ActiveSonarrBlock::Series, None).handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .series - .current_selection() - .title - .to_string(), - "Test 1" - ); - - LibraryHandler::with(key, &mut app, ActiveSonarrBlock::Series, None).handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .series - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - - #[rstest] - fn test_series_sort_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let series_field_vec = sort_options(); - let mut app = App::default(); - app.data.sonarr_data.series.sorting(sort_options()); - - if key == Key::Up { - for i in (0..series_field_vec.len()).rev() { - LibraryHandler::with(key, &mut app, ActiveSonarrBlock::SeriesSortPrompt, None).handle(); - - assert_eq!( - app - .data - .sonarr_data - .series - .sort - .as_ref() - .unwrap() - .current_selection(), - &series_field_vec[i] - ); - } - } else { - for i in 0..series_field_vec.len() { - LibraryHandler::with(key, &mut app, ActiveSonarrBlock::SeriesSortPrompt, None).handle(); - - assert_eq!( - app - .data - .sonarr_data - .series - .sort - .as_ref() - .unwrap() - .current_selection(), - &series_field_vec[(i + 1) % series_field_vec.len()] - ); - } - } - } - } - - mod test_handle_home_end { - use pretty_assertions::assert_eq; - - use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; - - use super::*; - - test_iterable_home_and_end!( - test_series_home_end, - LibraryHandler, - sonarr_data, - series, - extended_stateful_iterable_vec!(Series, HorizontallyScrollableText), - ActiveSonarrBlock::Series, - None, - title, - to_string - ); - - #[test] - fn test_series_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app - .data - .sonarr_data - .series - .set_items(extended_stateful_iterable_vec!( - Series, - HorizontallyScrollableText - )); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::Series, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .series - .current_selection() - .title - .to_string(), - "Test 1" - ); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::Series, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .series - .current_selection() - .title - .to_string(), - "Test 1" - ); - } - - #[test] - fn test_series_search_box_home_end_keys() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); - app - .data - .sonarr_data - .series - .set_items(vec![Series::default()]); - app.data.sonarr_data.series.search = Some("Test".into()); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::SearchSeries, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 4 - ); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::SearchSeries, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } - - #[test] - fn test_series_filter_box_home_end_keys() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); - app - .data - .sonarr_data - .series - .set_items(vec![Series::default()]); - app.data.sonarr_data.series.filter = Some("Test".into()); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::FilterSeries, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 4 - ); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::FilterSeries, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } - - #[test] - fn test_series_sort_home_end() { - let series_field_vec = sort_options(); - let mut app = App::default(); - app.data.sonarr_data.series.sorting(sort_options()); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::SeriesSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series - .sort - .as_ref() - .unwrap() - .current_selection(), - &series_field_vec[series_field_vec.len() - 1] - ); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::SeriesSortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series - .sort - .as_ref() - .unwrap() - .current_selection(), - &series_field_vec[0] - ); - } - } - mod test_handle_delete { use pretty_assertions::assert_eq; @@ -471,112 +140,11 @@ mod tests { assert!(!app.data.sonarr_data.prompt_confirm); } - - #[test] - fn test_series_search_box_left_right_keys() { - let mut app = App::default(); - app.data.sonarr_data.series.set_items(vec![Series::default()]); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); - app.data.sonarr_data.series.search = Some("Test".into()); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.left.key, - &mut app, - ActiveSonarrBlock::SearchSeries, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 1 - ); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.right.key, - &mut app, - ActiveSonarrBlock::SearchSeries, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } - - #[test] - fn test_series_filter_box_left_right_keys() { - let mut app = App::default(); - app.data.sonarr_data.series.set_items(vec![Series::default()]); - app.push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); - app.data.sonarr_data.series.filter = Some("Test".into()); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.left.key, - &mut app, - ActiveSonarrBlock::FilterSeries, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 1 - ); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.right.key, - &mut app, - ActiveSonarrBlock::FilterSeries, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } } mod test_handle_submit { use pretty_assertions::assert_eq; - use crate::extended_stateful_iterable_vec; use crate::network::sonarr_network::SonarrEvent; use super::*; @@ -616,156 +184,6 @@ mod tests { assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); } - #[test] - fn test_search_series_submit() { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); - app - .data - .sonarr_data - .series - .set_items(extended_stateful_iterable_vec!( - Series, - HorizontallyScrollableText - )); - app.data.sonarr_data.series.search = Some("Test 2".into()); - - LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchSeries, None).handle(); - - assert!(!app.should_ignore_quit_key); - assert_str_eq!( - app.data.sonarr_data.series.current_selection().title.text, - "Test 2" - ); - assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); - } - - #[test] - fn test_search_series_submit_error_on_no_search_hits() { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); - app - .data - .sonarr_data - .series - .set_items(extended_stateful_iterable_vec!( - Series, - HorizontallyScrollableText - )); - app.data.sonarr_data.series.search = Some("Test 5".into()); - - LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchSeries, None).handle(); - - assert!(!app.should_ignore_quit_key); - assert_str_eq!( - app.data.sonarr_data.series.current_selection().title.text, - "Test 1" - ); - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SearchSeriesError.into() - ); - } - - #[test] - fn test_search_filtered_series_submit() { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app - .data - .sonarr_data - .series - .set_items(vec![Series::default()]); - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); - app - .data - .sonarr_data - .series - .set_filtered_items(extended_stateful_iterable_vec!( - Series, - HorizontallyScrollableText - )); - app.data.sonarr_data.series.search = Some("Test 2".into()); - - LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchSeries, None).handle(); - - assert!(!app.should_ignore_quit_key); - assert_str_eq!( - app.data.sonarr_data.series.current_selection().title.text, - "Test 2" - ); - assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); - } - - #[test] - fn test_filter_series_submit() { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); - app - .data - .sonarr_data - .series - .set_items(extended_stateful_iterable_vec!( - Series, - HorizontallyScrollableText - )); - app.data.sonarr_data.series.filter = Some("Test".into()); - - LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::FilterSeries, None).handle(); - - assert!(app.data.sonarr_data.series.filtered_items.is_some()); - assert!(!app.should_ignore_quit_key); - assert_eq!( - app - .data - .sonarr_data - .series - .filtered_items - .as_ref() - .unwrap() - .len(), - 3 - ); - assert_str_eq!( - app.data.sonarr_data.series.current_selection().title.text, - "Test 1" - ); - assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); - } - - #[test] - fn test_filter_series_submit_error_on_no_filter_matches() { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); - app - .data - .sonarr_data - .series - .set_items(extended_stateful_iterable_vec!( - Series, - HorizontallyScrollableText - )); - app.data.sonarr_data.series.filter = Some("Test 5".into()); - - LibraryHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::FilterSeries, None).handle(); - - assert!(!app.should_ignore_quit_key); - assert!(app.data.sonarr_data.series.filtered_items.is_none()); - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::FilterSeriesError.into() - ); - } - #[test] fn test_update_all_series_prompt_confirm_submit() { let mut app = App::default(); @@ -817,109 +235,17 @@ mod tests { assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); } - - #[test] - fn test_series_sort_prompt_submit() { - let mut app = App::default(); - app.data.sonarr_data.series.sort_asc = true; - app.data.sonarr_data.series.sorting(sort_options()); - app.data.sonarr_data.series.set_items(series_vec()); - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.push_navigation_stack(ActiveSonarrBlock::SeriesSortPrompt.into()); - - let mut expected_vec = series_vec(); - expected_vec.sort_by(|a, b| a.id.cmp(&b.id)); - expected_vec.reverse(); - - LibraryHandler::with( - SUBMIT_KEY, - &mut app, - ActiveSonarrBlock::SeriesSortPrompt, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); - assert_eq!(app.data.sonarr_data.series.items, expected_vec); - } } mod test_handle_esc { use pretty_assertions::assert_eq; - use ratatui::widgets::TableState; use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; - use crate::models::stateful_table::StatefulTable; use super::*; const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; - #[rstest] - fn test_search_series_block_esc( - #[values(ActiveSonarrBlock::SearchSeries, ActiveSonarrBlock::SearchSeriesError)] - active_sonarr_block: ActiveSonarrBlock, - ) { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.push_navigation_stack(active_sonarr_block.into()); - app.data.sonarr_data = create_test_sonarr_data(); - app.data.sonarr_data.series.search = Some("Test".into()); - app.data.sonarr_data.series.set_items(vec![Series::default()]); - - LibraryHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.sonarr_data.series.search, None); - } - - #[test] - fn test_series_block_esc_resets_filter_if_already_set() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.data.sonarr_data = create_test_sonarr_data(); - app.data.sonarr_data.series = StatefulTable { - filter: Some("Test".into()), - filtered_items: Some(Vec::new()), - filtered_state: Some(TableState::default()), - ..StatefulTable::default() - }; - app.data.sonarr_data.series.set_items(vec![Series::default()]); - - LibraryHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::Series, None).handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); - assert_eq!(app.data.sonarr_data.series.filter, None); - assert_eq!(app.data.sonarr_data.series.filtered_items, None); - assert_eq!(app.data.sonarr_data.series.filtered_state, None); - } - - #[test] - fn test_filter_series_error_block_esc() { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.push_navigation_stack(ActiveSonarrBlock::FilterSeriesError.into()); - app.data.sonarr_data = create_test_sonarr_data(); - app.data.sonarr_data.series = StatefulTable { - filter: Some("Test".into()), - filtered_items: Some(Vec::new()), - filtered_state: Some(TableState::default()), - ..StatefulTable::default() - }; - app.data.sonarr_data.series.set_items(vec![Series::default()]); - - LibraryHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::FilterSeriesError, None).handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.sonarr_data.series.filter, None); - assert_eq!(app.data.sonarr_data.series.filtered_items, None); - assert_eq!(app.data.sonarr_data.series.filtered_state, None); - } - #[test] fn test_update_all_series_prompt_blocks_esc() { let mut app = App::default(); @@ -939,18 +265,6 @@ mod tests { assert!(!app.data.sonarr_data.prompt_confirm); } - #[test] - fn test_series_sort_prompt_block_esc() { - let mut app = App::default(); - app.data.sonarr_data.series.set_items(vec![Series::default()]); - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.push_navigation_stack(ActiveSonarrBlock::SeriesSortPrompt.into()); - - LibraryHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::SeriesSortPrompt, None).handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); - } - #[rstest] fn test_default_esc(#[values(true, false)] is_ready: bool) { let mut app = App::default(); @@ -981,143 +295,6 @@ mod tests { use super::*; - #[test] - fn test_search_series_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app - .data - .sonarr_data - .series - .set_items(vec![Series::default()]); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.search.key, - &mut app, - ActiveSonarrBlock::Series, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SearchSeries.into() - ); - assert!(app.should_ignore_quit_key); - assert_eq!( - app.data.sonarr_data.series.search, - Some(HorizontallyScrollableText::default()) - ); - } - - #[test] - fn test_search_series_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app - .data - .sonarr_data - .series - .set_items(vec![Series::default()]); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.search.key, - &mut app, - ActiveSonarrBlock::Series, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.sonarr_data.series.search, None); - } - - #[test] - fn test_filter_series_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app - .data - .sonarr_data - .series - .set_items(vec![Series::default()]); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.filter.key, - &mut app, - ActiveSonarrBlock::Series, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::FilterSeries.into() - ); - assert!(app.should_ignore_quit_key); - assert!(app.data.sonarr_data.series.filter.is_some()); - } - - #[test] - fn test_filter_series_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app - .data - .sonarr_data - .series - .set_items(vec![Series::default()]); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.filter.key, - &mut app, - ActiveSonarrBlock::Series, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); - assert!(!app.should_ignore_quit_key); - assert!(app.data.sonarr_data.series.filter.is_none()); - } - - #[test] - fn test_filter_series_key_resets_previous_filter() { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app.data.sonarr_data = create_test_sonarr_data(); - app - .data - .sonarr_data - .series - .set_items(vec![Series::default()]); - app.data.sonarr_data.series.filter = Some("Test".into()); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.filter.key, - &mut app, - ActiveSonarrBlock::Series, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::FilterSeries.into() - ); - assert!(app.should_ignore_quit_key); - assert_eq!( - app.data.sonarr_data.series.filter, - Some(HorizontallyScrollableText::default()) - ); - assert!(app.data.sonarr_data.series.filtered_items.is_none()); - assert!(app.data.sonarr_data.series.filtered_state.is_none()); - } - #[test] fn test_series_add_key() { let mut app = App::default(); @@ -1289,158 +466,6 @@ mod tests { assert!(!app.should_refresh); } - #[test] - fn test_search_series_box_backspace_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); - app.data.sonarr_data.series.search = Some("Test".into()); - app - .data - .sonarr_data - .series - .set_items(vec![Series::default()]); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.backspace.key, - &mut app, - ActiveSonarrBlock::SearchSeries, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.series.search.as_ref().unwrap().text, - "Tes" - ); - } - - #[test] - fn test_filter_series_box_backspace_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); - app - .data - .sonarr_data - .series - .set_items(vec![Series::default()]); - app.data.sonarr_data.series.filter = Some("Test".into()); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.backspace.key, - &mut app, - ActiveSonarrBlock::FilterSeries, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.series.filter.as_ref().unwrap().text, - "Tes" - ); - } - - #[test] - fn test_search_series_box_char_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeries.into()); - app - .data - .sonarr_data - .series - .set_items(vec![Series::default()]); - app.data.sonarr_data.series.search = Some(HorizontallyScrollableText::default()); - - LibraryHandler::with( - Key::Char('h'), - &mut app, - ActiveSonarrBlock::SearchSeries, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.series.search.as_ref().unwrap().text, - "h" - ); - } - - #[test] - fn test_filter_series_box_char_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::FilterSeries.into()); - app - .data - .sonarr_data - .series - .set_items(vec![Series::default()]); - app.data.sonarr_data.series.filter = Some(HorizontallyScrollableText::default()); - - LibraryHandler::with( - Key::Char('h'), - &mut app, - ActiveSonarrBlock::FilterSeries, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.series.filter.as_ref().unwrap().text, - "h" - ); - } - - #[test] - fn test_sort_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app - .data - .sonarr_data - .series - .set_items(vec![Series::default()]); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveSonarrBlock::Series, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SeriesSortPrompt.into() - ); - assert_eq!( - app.data.sonarr_data.series.sort.as_ref().unwrap().items, - series_sorting_options() - ); - assert!(!app.data.sonarr_data.series.sort_asc); - } - - #[test] - fn test_sort_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveSonarrBlock::Series.into()); - app - .data - .sonarr_data - .series - .set_items(vec![Series::default()]); - - LibraryHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveSonarrBlock::Series, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into()); - assert!(app.data.sonarr_data.series.sort.is_none()); - } - #[test] fn test_update_all_series_prompt_confirm() { let mut app = App::default(); @@ -1852,16 +877,4 @@ mod tests { }, ] } - - fn sort_options() -> Vec> { - vec![SortOption { - name: "Test 1", - cmp_fn: Some(|a, b| { - b.title - .text - .to_lowercase() - .cmp(&a.title.text.to_lowercase()) - }), - }] - } } diff --git a/src/handlers/sonarr_handlers/library/mod.rs b/src/handlers/sonarr_handlers/library/mod.rs index a5ff91e..969ed25 100644 --- a/src/handlers/sonarr_handlers/library/mod.rs +++ b/src/handlers/sonarr_handlers/library/mod.rs @@ -15,7 +15,7 @@ use crate::{ }, sonarr_models::Series, stateful_table::SortOption, - BlockSelectionState, HorizontallyScrollableText, Scrollable, + BlockSelectionState, HorizontallyScrollableText, }, network::sonarr_network::SonarrEvent, }; @@ -23,7 +23,7 @@ use crate::{ use super::handle_change_tab_left_right_keys; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::handlers::sonarr_handlers::library::series_details_handler::SeriesDetailsHandler; -use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::table_handler::TableHandlingConfig; mod add_series_handler; mod delete_series_handler; @@ -46,7 +46,7 @@ impl<'a, 'b> LibraryHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, 'b> { fn handle(&mut self) { - let series_table_handling_props = TableHandlingProps::new(ActiveSonarrBlock::Series.into()) + let series_table_handling_config = TableHandlingConfig::new(ActiveSonarrBlock::Series.into()) .sorting_block(ActiveSonarrBlock::SeriesSortPrompt.into()) .sort_by_fn(|a: &Series, b: &Series| a.id.cmp(&b.id)) .sort_options(series_sorting_options()) @@ -57,7 +57,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' .filter_error_block(ActiveSonarrBlock::FilterSeriesError.into()) .filter_field_fn(|series| &series.title.text); - if !self.handle_series_table_events(series_table_handling_props) { + if !self.handle_series_table_events(series_table_handling_config) { match self.active_sonarr_block { _ if AddSeriesHandler::accepts(self.active_sonarr_block) => { AddSeriesHandler::with(self.key, self.app, self.active_sonarr_block, self.context) diff --git a/src/handlers/sonarr_handlers/library/series_details_handler.rs b/src/handlers/sonarr_handlers/library/series_details_handler.rs index 7365481..66bc3c8 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler.rs @@ -3,13 +3,13 @@ use crate::app::App; use crate::event::Key; use crate::handle_table_events; use crate::handlers::sonarr_handlers::history::history_sorting_options; -use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EDIT_SERIES_SELECTION_BLOCKS, SERIES_DETAILS_BLOCKS, }; use crate::models::sonarr_models::{Season, SonarrHistoryItem}; -use crate::models::{BlockSelectionState, Scrollable}; +use crate::models::BlockSelectionState; use crate::network::sonarr_network::SonarrEvent; #[cfg(test)] @@ -41,8 +41,8 @@ impl<'a, 'b> SeriesDetailsHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler<'a, 'b> { fn handle(&mut self) { - let season_table_handling_props = - TableHandlingProps::new(ActiveSonarrBlock::SeriesDetails.into()) + let season_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::SeriesDetails.into()) .searching_block(ActiveSonarrBlock::SearchSeason.into()) .search_error_block(ActiveSonarrBlock::SearchSeasonError.into()) .search_field_fn(|season: &Season| { @@ -51,8 +51,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler .as_ref() .expect("Season was not populated with title in handlers") }); - let series_history_table_handling_props = - TableHandlingProps::new(ActiveSonarrBlock::SeriesHistory.into()) + let series_history_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::SeriesHistory.into()) .sorting_block(ActiveSonarrBlock::SeriesHistorySortPrompt.into()) .sort_options(history_sorting_options()) .sort_by_fn(|a: &SonarrHistoryItem, b: &SonarrHistoryItem| a.id.cmp(&b.id)) @@ -63,8 +63,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler .filter_error_block(ActiveSonarrBlock::FilterSeriesHistoryError.into()) .filter_field_fn(|history_item: &SonarrHistoryItem| &history_item.source_title.text); - if !self.handle_season_table_events(season_table_handling_props) - && !self.handle_series_history_table_events(series_history_table_handling_props) + if !self.handle_season_table_events(season_table_handling_config) + && !self.handle_series_history_table_events(series_history_table_handling_config) { self.handle_key_event(); } diff --git a/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs index a5d9a43..76e4244 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs @@ -10,522 +10,10 @@ mod tests { }; use crate::models::sonarr_models::Season; use crate::models::sonarr_models::SonarrHistoryItem; - use crate::models::stateful_table::{SortOption, StatefulTable}; - use core::sync::atomic::Ordering::SeqCst; - use pretty_assertions::assert_str_eq; + use crate::models::stateful_table::StatefulTable; use rstest::rstest; use strum::IntoEnumIterator; - mod test_handle_scroll_up_and_down { - use super::*; - use pretty_assertions::assert_eq; - - #[rstest] - fn test_seasons_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); - app.data.sonarr_data.seasons.set_items(vec![ - Season { - season_number: 1, - ..Season::default() - }, - Season { - season_number: 2, - ..Season::default() - }, - ]); - - SeriesDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SeriesDetails, None).handle(); - - assert_eq!( - app - .data - .sonarr_data - .seasons - .current_selection() - .season_number, - 2 - ); - - SeriesDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SeriesDetails, None).handle(); - - assert_eq!( - app - .data - .sonarr_data - .seasons - .current_selection() - .season_number, - 1 - ); - } - - #[rstest] - fn test_series_history_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![ - SonarrHistoryItem { - source_title: "Test 1".into(), - ..SonarrHistoryItem::default() - }, - SonarrHistoryItem { - source_title: "Test 2".into(), - ..SonarrHistoryItem::default() - }, - ]); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SeriesHistory, None).handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .current_selection() - .source_title - .text, - "Test 2" - ); - - SeriesDetailsHandler::with(key, &mut app, ActiveSonarrBlock::SeriesHistory, None).handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .current_selection() - .source_title - .text, - "Test 1" - ); - } - - #[rstest] - fn test_series_history_sort_scroll( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let series_history_item_field_vec = sort_options(); - let mut app = App::default(); - app.data.sonarr_data.series_history = Some(StatefulTable::default()); - app - .data - .sonarr_data - .series_history - .as_mut() - .unwrap() - .sorting(sort_options()); - - if key == Key::Up { - for i in (0..series_history_item_field_vec.len()).rev() { - SeriesDetailsHandler::with( - key, - &mut app, - ActiveSonarrBlock::SeriesHistorySortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .sort - .as_ref() - .unwrap() - .current_selection(), - &series_history_item_field_vec[i] - ); - } - } else { - for i in 0..series_history_item_field_vec.len() { - SeriesDetailsHandler::with( - key, - &mut app, - ActiveSonarrBlock::SeriesHistorySortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .sort - .as_ref() - .unwrap() - .current_selection(), - &series_history_item_field_vec[(i + 1) % series_history_item_field_vec.len()] - ); - } - } - } - } - - mod test_handle_home_end { - use super::*; - use pretty_assertions::{assert_eq, assert_str_eq}; - - #[test] - fn test_seasons_home_and_end() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); - app.data.sonarr_data.seasons.set_items(vec![ - Season { - season_number: 1, - ..Season::default() - }, - Season { - season_number: 2, - ..Season::default() - }, - Season { - season_number: 3, - ..Season::default() - }, - ]); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::SeriesDetails, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .seasons - .current_selection() - .season_number, - 3 - ); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::SeriesDetails, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .seasons - .current_selection() - .season_number, - 1 - ); - } - - #[test] - fn test_series_history_home_and_end() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![ - SonarrHistoryItem { - source_title: "Test 1".into(), - ..SonarrHistoryItem::default() - }, - SonarrHistoryItem { - source_title: "Test 2".into(), - ..SonarrHistoryItem::default() - }, - SonarrHistoryItem { - source_title: "Test 3".into(), - ..SonarrHistoryItem::default() - }, - ]); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::SeriesHistory, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .current_selection() - .source_title - .text, - "Test 3" - ); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::SeriesHistory, - None, - ) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .current_selection() - .source_title - .text, - "Test 1" - ); - } - - #[test] - fn test_season_search_box_home_end_keys() { - let mut app = App::default(); - app - .data - .sonarr_data - .seasons - .set_items(vec![Season::default()]); - app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeason.into()); - app.data.sonarr_data.seasons.search = Some("Test".into()); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::SearchSeason, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .seasons - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 4 - ); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::SearchSeason, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .seasons - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } - - #[test] - fn test_series_history_search_box_home_end_keys() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![SonarrHistoryItem::default()]); - series_history.search = Some("Test".into()); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::SearchSeriesHistory, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 4 - ); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::SearchSeriesHistory, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .search - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } - - #[test] - fn test_series_history_filter_box_home_end_keys() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - app.push_navigation_stack(ActiveSonarrBlock::FilterSeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![SonarrHistoryItem::default()]); - series_history.filter = Some("Test".into()); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::FilterSeriesHistory, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 4 - ); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::FilterSeriesHistory, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .filter - .as_ref() - .unwrap() - .offset - .load(SeqCst), - 0 - ); - } - - #[test] - fn test_series_history_sort_home_end() { - let series_history_item_field_vec = sort_options(); - let mut app = App::default(); - let mut series_history = StatefulTable::default(); - series_history.sorting(sort_options()); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::SeriesHistorySortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .sort - .as_ref() - .unwrap() - .current_selection(), - &series_history_item_field_vec[series_history_item_field_vec.len() - 1] - ); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::SeriesHistorySortPrompt, - None, - ) - .handle(); - - assert_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .sort - .as_ref() - .unwrap() - .current_selection(), - &series_history_item_field_vec[0] - ); - } - } - mod test_handle_left_right_actions { use super::*; use pretty_assertions::assert_eq; @@ -597,7 +85,6 @@ mod tests { use pretty_assertions::assert_eq; use crate::extended_stateful_iterable_vec; - use crate::models::HorizontallyScrollableText; use crate::network::sonarr_network::SonarrEvent; use super::*; @@ -743,441 +230,16 @@ mod tests { ); assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); } - - #[test] - fn test_search_seasons_submit() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeason.into()); - app - .data - .sonarr_data - .seasons - .set_items(extended_stateful_iterable_vec!(Season, Option)); - app.data.sonarr_data.seasons.search = Some("Test 2".into()); - - SeriesDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchSeason, None) - .handle(); - - assert_str_eq!( - app - .data - .sonarr_data - .seasons - .current_selection() - .title - .as_ref() - .unwrap(), - "Test 2" - ); - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SeriesDetails.into() - ); - } - - #[test] - fn test_search_seasons_submit_error_on_no_search_hits() { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeason.into()); - app - .data - .sonarr_data - .seasons - .set_items(extended_stateful_iterable_vec!(Season, Option)); - app.data.sonarr_data.seasons.search = Some("Test 5".into()); - - SeriesDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SearchSeason, None) - .handle(); - - assert!(!app.should_ignore_quit_key); - assert_str_eq!( - app - .data - .sonarr_data - .seasons - .current_selection() - .title - .as_ref() - .unwrap(), - "Test 1" - ); - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SearchSeasonError.into() - ); - } - - #[test] - fn test_search_series_history_submit() { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(extended_stateful_iterable_vec!( - SonarrHistoryItem, - HorizontallyScrollableText, - source_title - )); - series_history.search = Some("Test 2".into()); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - SUBMIT_KEY, - &mut app, - ActiveSonarrBlock::SearchSeriesHistory, - None, - ) - .handle(); - - assert!(!app.should_ignore_quit_key); - assert_str_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .current_selection() - .source_title - .text, - "Test 2" - ); - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SeriesHistory.into() - ); - } - - #[test] - fn test_search_series_history_submit_error_on_no_search_hits() { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(extended_stateful_iterable_vec!( - SonarrHistoryItem, - HorizontallyScrollableText, - source_title - )); - series_history.search = Some("Test 5".into()); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - SUBMIT_KEY, - &mut app, - ActiveSonarrBlock::SearchSeriesHistory, - None, - ) - .handle(); - - assert!(!app.should_ignore_quit_key); - assert_str_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .current_selection() - .source_title - .text, - "Test 1" - ); - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SearchSeriesHistoryError.into() - ); - } - - #[test] - fn test_filter_series_history_submit() { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - app.push_navigation_stack(ActiveSonarrBlock::FilterSeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(extended_stateful_iterable_vec!( - SonarrHistoryItem, - HorizontallyScrollableText, - source_title - )); - series_history.filter = Some("Test".into()); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - SUBMIT_KEY, - &mut app, - ActiveSonarrBlock::FilterSeriesHistory, - None, - ) - .handle(); - - assert!(app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .filtered_items - .is_some()); - assert!(!app.should_ignore_quit_key); - assert_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .filtered_items - .as_ref() - .unwrap() - .len(), - 3 - ); - assert_str_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .current_selection() - .source_title - .text, - "Test 1" - ); - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SeriesHistory.into() - ); - } - - #[test] - fn test_filter_series_history_submit_error_on_no_filter_matches() { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - app.push_navigation_stack(ActiveSonarrBlock::FilterSeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(extended_stateful_iterable_vec!( - SonarrHistoryItem, - HorizontallyScrollableText, - source_title - )); - series_history.filter = Some("Test 5".into()); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - SUBMIT_KEY, - &mut app, - ActiveSonarrBlock::FilterSeriesHistory, - None, - ) - .handle(); - - assert!(!app.should_ignore_quit_key); - assert!(app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .filtered_items - .is_none()); - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::FilterSeriesHistoryError.into() - ); - } - - #[test] - fn test_series_history_sort_prompt_submit() { - let mut app = App::default(); - let mut series_history = StatefulTable::default(); - let series_history_vec = vec![ - SonarrHistoryItem { - id: 3, - source_title: "Test 1".into(), - ..SonarrHistoryItem::default() - }, - SonarrHistoryItem { - id: 2, - source_title: "Test 2".into(), - ..SonarrHistoryItem::default() - }, - SonarrHistoryItem { - id: 1, - source_title: "Test 3".into(), - ..SonarrHistoryItem::default() - }, - ]; - series_history.set_items(series_history_vec.clone()); - series_history.sorting(sort_options()); - series_history.sort_asc = true; - app.data.sonarr_data.series_history = Some(series_history); - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistorySortPrompt.into()); - - let mut expected_vec = series_history_vec; - expected_vec.sort_by(|a, b| a.id.cmp(&b.id)); - expected_vec.reverse(); - - SeriesDetailsHandler::with( - SUBMIT_KEY, - &mut app, - ActiveSonarrBlock::SeriesHistorySortPrompt, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SeriesHistory.into() - ); - assert_eq!( - app.data.sonarr_data.series_history.as_ref().unwrap().items, - expected_vec - ); - } } mod test_handle_esc { use super::*; - use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; use crate::models::stateful_table::StatefulTable; use pretty_assertions::assert_eq; use ratatui::widgets::TableState; const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; - #[rstest] - fn test_search_season_block_esc( - #[values(ActiveSonarrBlock::SearchSeason, ActiveSonarrBlock::SearchSeasonError)] - active_sonarr_block: ActiveSonarrBlock, - ) { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); - app.push_navigation_stack(active_sonarr_block.into()); - app.data.sonarr_data = create_test_sonarr_data(); - app.data.sonarr_data.seasons.search = Some("Test".into()); - - SeriesDetailsHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SeriesDetails.into() - ); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.sonarr_data.seasons.search, None); - } - - #[rstest] - fn test_search_series_history_block_esc( - #[values( - ActiveSonarrBlock::SearchSeriesHistory, - ActiveSonarrBlock::SearchSeriesHistoryError - )] - active_sonarr_block: ActiveSonarrBlock, - ) { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - app.push_navigation_stack(active_sonarr_block.into()); - app.data.sonarr_data = create_test_sonarr_data(); - app.data.sonarr_data.series_history.as_mut().unwrap().search = Some("Test".into()); - - SeriesDetailsHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SeriesHistory.into() - ); - assert!(!app.should_ignore_quit_key); - assert_eq!( - app.data.sonarr_data.series_history.as_ref().unwrap().search, - None - ); - } - - #[rstest] - fn test_filter_series_history_block_esc( - #[values( - ActiveSonarrBlock::FilterSeriesHistory, - ActiveSonarrBlock::FilterSeriesHistoryError - )] - active_sonarr_block: ActiveSonarrBlock, - ) { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - app.push_navigation_stack(active_sonarr_block.into()); - app.data.sonarr_data = create_test_sonarr_data(); - app.data.sonarr_data.series_history = Some(StatefulTable { - filter: Some("Test".into()), - filtered_items: Some(Vec::new()), - filtered_state: Some(TableState::default()), - ..StatefulTable::default() - }); - - SeriesDetailsHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SeriesHistory.into() - ); - assert!(!app.should_ignore_quit_key); - assert_eq!( - app.data.sonarr_data.series_history.as_ref().unwrap().filter, - None - ); - assert_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .filtered_items, - None - ); - assert_eq!( - app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .filtered_state, - None - ); - } - - #[test] - fn test_series_history_sort_prompt_block_esc() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistorySortPrompt.into()); - - SeriesDetailsHandler::with( - ESC_KEY, - &mut app, - ActiveSonarrBlock::SeriesHistorySortPrompt, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SeriesHistory.into() - ); - } - #[test] fn test_series_history_details_block_esc() { let mut app = App::default(); @@ -1271,224 +333,14 @@ mod tests { mod test_handle_key_char { use super::*; - use crate::handlers::sonarr_handlers::history::history_sorting_options; use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; use crate::models::servarr_data::sonarr::sonarr_data::SonarrData; use crate::models::sonarr_models::{Series, SeriesType}; - use crate::models::HorizontallyScrollableText; + use crate::network::sonarr_network::SonarrEvent; use crate::test_edit_series_key; use pretty_assertions::{assert_eq, assert_str_eq}; - use ratatui::widgets::TableState; use serde_json::Number; use strum::IntoEnumIterator; - use crate::network::sonarr_network::SonarrEvent; - - #[test] - fn test_search_season_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); - app - .data - .sonarr_data - .seasons - .set_items(vec![Season::default()]); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.search.key, - &mut app, - ActiveSonarrBlock::SeriesDetails, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SearchSeason.into() - ); - assert!(app.should_ignore_quit_key); - assert_eq!( - app.data.sonarr_data.seasons.search, - Some(HorizontallyScrollableText::default()) - ); - } - - #[test] - fn test_search_season_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); - app - .data - .sonarr_data - .seasons - .set_items(vec![Season::default()]); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.search.key, - &mut app, - ActiveSonarrBlock::SeriesDetails, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SeriesDetails.into() - ); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.sonarr_data.seasons.search, None); - } - - #[test] - fn test_search_series_history_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.search.key, - &mut app, - ActiveSonarrBlock::SeriesHistory, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SearchSeriesHistory.into() - ); - assert!(app.should_ignore_quit_key); - assert_eq!( - app.data.sonarr_data.series_history.as_ref().unwrap().search, - Some(HorizontallyScrollableText::default()) - ); - } - - #[test] - fn test_search_series_history_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.search.key, - &mut app, - ActiveSonarrBlock::SeriesHistory, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SeriesHistory.into() - ); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.sonarr_data.seasons.search, None); - } - - #[test] - fn test_filter_series_history_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.filter.key, - &mut app, - ActiveSonarrBlock::SeriesHistory, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::FilterSeriesHistory.into() - ); - assert!(app.should_ignore_quit_key); - assert_eq!( - app.data.sonarr_data.series_history.as_ref().unwrap().filter, - Some(HorizontallyScrollableText::default()) - ); - } - - #[test] - fn test_filter_series_history_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.filter.key, - &mut app, - ActiveSonarrBlock::SeriesHistory, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SeriesHistory.into() - ); - assert!(!app.should_ignore_quit_key); - assert_eq!(app.data.sonarr_data.seasons.filter, None); - } - - #[test] - fn test_filter_series_history_key_resets_previous_filter() { - let mut app = App::default(); - app.should_ignore_quit_key = true; - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![SonarrHistoryItem::default()]); - series_history.filter = Some("Test".into()); - series_history.filtered_items = Some(vec![SonarrHistoryItem::default()]); - series_history.filtered_state = Some(TableState::default()); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.filter.key, - &mut app, - ActiveSonarrBlock::SeriesHistory, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::FilterSeriesHistory.into() - ); - assert!(app.should_ignore_quit_key); - assert_eq!( - app.data.sonarr_data.series_history.as_ref().unwrap().filter, - Some(HorizontallyScrollableText::default()) - ); - assert!(app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .filtered_items - .is_none()); - assert!(app - .data - .sonarr_data - .series_history - .as_ref() - .unwrap() - .filtered_state - .is_none()); - } #[rstest] fn test_series_details_edit_key( @@ -1563,12 +415,9 @@ mod tests { active_sonarr_block, None, ) - .handle(); + .handle(); - assert_eq!( - app.get_current_route(), - active_sonarr_block.into() - ); + assert_eq!(app.get_current_route(), active_sonarr_block.into()); } #[rstest] @@ -1588,7 +437,7 @@ mod tests { active_sonarr_block, None, ) - .handle(); + .handle(); assert_eq!( app.get_current_route(), @@ -1611,12 +460,9 @@ mod tests { active_sonarr_block, None, ) - .handle(); + .handle(); - assert_eq!( - app.get_current_route(), - active_sonarr_block.into() - ); + assert_eq!(app.get_current_route(), active_sonarr_block.into()); } #[rstest] @@ -1637,12 +483,9 @@ mod tests { active_sonarr_block, None, ) - .handle(); + .handle(); - assert_eq!( - app.get_current_route(), - active_sonarr_block.into() - ); + assert_eq!(app.get_current_route(), active_sonarr_block.into()); assert!(app.is_routing); } @@ -1662,208 +505,12 @@ mod tests { active_sonarr_block, None, ) - .handle(); + .handle(); - assert_eq!( - app.get_current_route(), - active_sonarr_block.into() - ); + assert_eq!(app.get_current_route(), active_sonarr_block.into()); assert!(!app.is_routing); } - #[test] - fn test_sort_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveSonarrBlock::SeriesHistory, - None, - ) - .handle(); - - assert_eq!( - app.get_current_route(), - ActiveSonarrBlock::SeriesHistorySortPrompt.into() - ); - assert_eq!( - app.data.sonarr_data.series_history.as_ref().unwrap().sort.as_ref().unwrap().items, - history_sorting_options() - ); - assert!(!app.data.sonarr_data.series_history.as_ref().unwrap().sort_asc); - } - - #[test] - fn test_sort_key_no_op_when_not_ready() { - let mut app = App::default(); - app.is_loading = true; - app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); - app.is_routing = false; - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.sort.key, - &mut app, - ActiveSonarrBlock::SeriesHistory, - None, - ) - .handle(); - - assert_eq!(app.get_current_route(), ActiveSonarrBlock::SeriesHistory.into()); - assert!(app.data.sonarr_data.series_history.as_ref().unwrap().sort.is_none()); - assert!(!app.data.sonarr_data.series_history.as_ref().unwrap().sort_asc); - assert!(!app.is_routing); - } - - #[test] - fn test_search_season_box_backspace_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeason.into()); - app.data.sonarr_data.seasons.search = Some("Test".into()); - app - .data - .sonarr_data - .seasons - .set_items(vec![Season::default()]); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.backspace.key, - &mut app, - ActiveSonarrBlock::SearchSeason, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.seasons.search.as_ref().unwrap().text, - "Tes" - ); - } - - #[test] - fn test_search_series_history_box_backspace_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![SonarrHistoryItem::default()]); - series_history.search = Some("Test".into()); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.backspace.key, - &mut app, - ActiveSonarrBlock::SearchSeriesHistory, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.series_history.as_ref().unwrap().search.as_ref().unwrap().text, - "Tes" - ); - } - - #[test] - fn test_filter_series_history_box_backspace_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::FilterSeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![SonarrHistoryItem::default()]); - series_history.filter = Some("Test".into()); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - DEFAULT_KEYBINDINGS.backspace.key, - &mut app, - ActiveSonarrBlock::FilterSeriesHistory, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.series_history.as_ref().unwrap().filter.as_ref().unwrap().text, - "Tes" - ); - } - - #[test] - fn test_search_season_box_char_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeason.into()); - app - .data - .sonarr_data - .seasons - .set_items(vec![Season::default()]); - app.data.sonarr_data.seasons.search = Some(HorizontallyScrollableText::default()); - - SeriesDetailsHandler::with( - Key::Char('h'), - &mut app, - ActiveSonarrBlock::SearchSeason, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.seasons.search.as_ref().unwrap().text, - "h" - ); - } - - #[test] - fn test_search_series_history_box_char_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::SearchSeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![SonarrHistoryItem::default()]); - series_history.search = Some(HorizontallyScrollableText::default()); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - Key::Char('h'), - &mut app, - ActiveSonarrBlock::SearchSeriesHistory, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.series_history.as_ref().unwrap().search.as_ref().unwrap().text, - "h" - ); - } - - #[test] - fn test_filter_series_history_box_char_key() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::FilterSeriesHistory.into()); - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![SonarrHistoryItem::default()]); - series_history.filter = Some(HorizontallyScrollableText::default()); - app.data.sonarr_data.series_history = Some(series_history); - - SeriesDetailsHandler::with( - Key::Char('h'), - &mut app, - ActiveSonarrBlock::FilterSeriesHistory, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.series_history.as_ref().unwrap().filter.as_ref().unwrap().text, - "h" - ); - } - #[rstest] #[case( ActiveSonarrBlock::AutomaticallySearchSeriesPrompt, @@ -1876,20 +523,24 @@ mod tests { fn test_series_details_prompt_confirm_confirm_key( #[case] prompt_block: ActiveSonarrBlock, #[case] expected_action: SonarrEvent, - #[values(ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory)] active_sonarr_block: ActiveSonarrBlock, + #[values(ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory)] + active_sonarr_block: ActiveSonarrBlock, ) { let mut app = App::default(); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(active_sonarr_block.into()); app.push_navigation_stack(prompt_block.into()); - SeriesDetailsHandler::with(DEFAULT_KEYBINDINGS.confirm.key, &mut app, prompt_block, None).handle(); + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + prompt_block, + None, + ) + .handle(); assert!(app.data.sonarr_data.prompt_confirm); - assert_eq!( - app.get_current_route(), - active_sonarr_block.into() - ); + assert_eq!(app.get_current_route(), active_sonarr_block.into()); assert_eq!( app.data.sonarr_data.prompt_confirm_action, Some(expected_action) @@ -1969,16 +620,4 @@ mod tests { assert!(handler.is_ready()); } - - fn sort_options() -> Vec> { - vec![SortOption { - name: "Test 1", - cmp_fn: Some(|a, b| { - b.source_title - .text - .to_lowercase() - .cmp(&a.source_title.text.to_lowercase()) - }), - }] - } } diff --git a/src/handlers/sonarr_handlers/root_folders/mod.rs b/src/handlers/sonarr_handlers/root_folders/mod.rs index 3cb8950..6e1d850 100644 --- a/src/handlers/sonarr_handlers/root_folders/mod.rs +++ b/src/handlers/sonarr_handlers/root_folders/mod.rs @@ -2,11 +2,11 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; -use crate::handlers::table_handler::TableHandlingProps; +use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ROOT_FOLDERS_BLOCKS}; use crate::models::servarr_models::RootFolder; -use crate::models::{HorizontallyScrollableText, Scrollable}; +use crate::models::HorizontallyScrollableText; use crate::network::sonarr_network::SonarrEvent; use crate::{handle_table_events, handle_text_box_keys, handle_text_box_left_right_keys}; @@ -32,10 +32,10 @@ impl<'a, 'b> RootFoldersHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for RootFoldersHandler<'a, 'b> { fn handle(&mut self) { - let root_folders_table_handling_props = - TableHandlingProps::new(ActiveSonarrBlock::RootFolders.into()); + let root_folders_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::RootFolders.into()); - if !self.handle_root_folders_table_events(root_folders_table_handling_props) { + if !self.handle_root_folders_table_events(root_folders_table_handling_config) { self.handle_key_event(); } } diff --git a/src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs b/src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs index b83da4e..1578234 100644 --- a/src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs +++ b/src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod tests { - use pretty_assertions::assert_str_eq; use strum::IntoEnumIterator; use crate::app::key_binding::DEFAULT_KEYBINDINGS; @@ -12,113 +11,13 @@ mod tests { use crate::models::servarr_models::RootFolder; use crate::models::HorizontallyScrollableText; - mod test_handle_scroll_up_and_down { - use rstest::rstest; - - use crate::models::servarr_models::RootFolder; - use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; - - use super::*; - - test_iterable_scroll!( - test_root_folders_scroll, - RootFoldersHandler, - sonarr_data, - root_folders, - simple_stateful_iterable_vec!(RootFolder, String, path), - ActiveSonarrBlock::RootFolders, - None, - path - ); - - #[rstest] - fn test_root_folders_scroll_no_op_when_not_ready( - #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, - ) { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); - app.is_loading = true; - app - .data - .sonarr_data - .root_folders - .set_items(simple_stateful_iterable_vec!(RootFolder, String, path)); - - RootFoldersHandler::with(key, &mut app, ActiveSonarrBlock::RootFolders, None).handle(); - - assert_str_eq!( - app.data.sonarr_data.root_folders.current_selection().path, - "Test 1" - ); - - RootFoldersHandler::with(key, &mut app, ActiveSonarrBlock::RootFolders, None).handle(); - - assert_str_eq!( - app.data.sonarr_data.root_folders.current_selection().path, - "Test 1" - ); - } - } - mod test_handle_home_end { + use crate::models::servarr_models::RootFolder; + use pretty_assertions::assert_eq; use std::sync::atomic::Ordering; - use pretty_assertions::assert_eq; - - use crate::models::servarr_models::RootFolder; - use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; - use super::*; - test_iterable_home_and_end!( - test_root_folders_home_end, - RootFoldersHandler, - sonarr_data, - root_folders, - extended_stateful_iterable_vec!(RootFolder, String, path), - ActiveSonarrBlock::RootFolders, - None, - path - ); - - #[test] - fn test_root_folders_home_end_no_op_when_not_ready() { - let mut app = App::default(); - app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); - app.is_loading = true; - app - .data - .sonarr_data - .root_folders - .set_items(extended_stateful_iterable_vec!(RootFolder, String, path)); - - RootFoldersHandler::with( - DEFAULT_KEYBINDINGS.end.key, - &mut app, - ActiveSonarrBlock::RootFolders, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.root_folders.current_selection().path, - "Test 1" - ); - - RootFoldersHandler::with( - DEFAULT_KEYBINDINGS.home.key, - &mut app, - ActiveSonarrBlock::RootFolders, - None, - ) - .handle(); - - assert_str_eq!( - app.data.sonarr_data.root_folders.current_selection().path, - "Test 1" - ); - } - #[test] fn test_add_root_folder_prompt_home_end_keys() { let mut app = App::default(); diff --git a/src/handlers/sonarr_handlers/system/system_handler_tests.rs b/src/handlers/sonarr_handlers/system/system_handler_tests.rs index 3f62287..6843b5e 100644 --- a/src/handlers/sonarr_handlers/system/system_handler_tests.rs +++ b/src/handlers/sonarr_handlers/system/system_handler_tests.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod tests { - use pretty_assertions::assert_eq; use rstest::rstest; use strum::IntoEnumIterator; diff --git a/src/handlers/table_handler.rs b/src/handlers/table_handler.rs index 5a144ee..abe26fe 100644 --- a/src/handlers/table_handler.rs +++ b/src/handlers/table_handler.rs @@ -4,8 +4,12 @@ use derive_setters::Setters; use std::cmp::Ordering; use std::fmt::Debug; +#[cfg(test)] +#[path = "table_handler_tests.rs"] +mod table_handler_tests; + #[derive(Setters)] -pub struct TableHandlingProps +pub struct TableHandlingConfig where T: Clone + PartialEq + Eq + Debug + Default, { @@ -35,36 +39,36 @@ where macro_rules! handle_table_events { ($self:expr, $name:ty, $table:expr, $row:ident) => { paste::paste! { - fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + fn [](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool { if $self.is_ready() { match $self.key { - _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.up.key => $self.[](props), - _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.down.key => $self.[](props), - _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.home.key => $self.[](props), - _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.end.key => $self.[](props), + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.up.key => $self.[](config), + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.down.key => $self.[](config), + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.home.key => $self.[](config), + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.end.key => $self.[](config), _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.left.key || $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.right.key => { - $self.[](props) + $self.[](config) } - _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.submit.key => $self.[](props), - _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.esc.key => $self.[](props), - _ if props.searching_block.is_some() - && $self.app.get_current_route() == *props.searching_block.as_ref().unwrap() => + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.submit.key => $self.[](config), + _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.esc.key => $self.[](config), + _ if config.searching_block.is_some() + && $self.app.get_current_route() == *config.searching_block.as_ref().unwrap() => { $self.[]() } - _ if props.filtering_block.is_some() - && $self.app.get_current_route() == *props.filtering_block.as_ref().unwrap() => + _ if config.filtering_block.is_some() + && $self.app.get_current_route() == *config.filtering_block.as_ref().unwrap() => { $self.[]() } _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.filter.key - && props.filtering_block.is_some() => $self.[](props), + && config.filtering_block.is_some() => $self.[](config), _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.search.key - && props.searching_block.is_some() => $self.[](props), + && config.searching_block.is_some() => $self.[](config), _ if $self.key == $crate::app::key_binding::DEFAULT_KEYBINDINGS.sort.key - && props.sorting_block.is_some() => $self.[](props), + && config.sorting_block.is_some() => $self.[](config), _ => false, } } else { @@ -72,14 +76,16 @@ macro_rules! handle_table_events { } } - fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + fn [](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool { + use $crate::models::Scrollable; + match $self.app.get_current_route() { - _ if props.table_block == $self.app.get_current_route() => { + _ if config.table_block == $self.app.get_current_route() => { $table.scroll_up(); true } - _ if props.sorting_block.is_some() - && $self.app.get_current_route() == *props.sorting_block.as_ref().unwrap() => + _ if config.sorting_block.is_some() + && $self.app.get_current_route() == *config.sorting_block.as_ref().unwrap() => { $table.sort.as_mut().unwrap().scroll_up(); true @@ -88,14 +94,16 @@ macro_rules! handle_table_events { } } - fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + fn [](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool { + use $crate::models::Scrollable; + match $self.app.get_current_route() { - _ if props.table_block == $self.app.get_current_route() => { + _ if config.table_block == $self.app.get_current_route() => { $table.scroll_down(); true } - _ if props.sorting_block.is_some() - && $self.app.get_current_route() == *props.sorting_block.as_ref().unwrap() => + _ if config.sorting_block.is_some() + && $self.app.get_current_route() == *config.sorting_block.as_ref().unwrap() => { $table .sort @@ -108,14 +116,16 @@ macro_rules! handle_table_events { } } - fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + fn [](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool { + use $crate::models::Scrollable; + match $self.app.get_current_route() { - _ if props.table_block == $self.app.get_current_route() => { + _ if config.table_block == $self.app.get_current_route() => { $table.scroll_to_top(); true } - _ if props.sorting_block.is_some() - && $self.app.get_current_route() == *props.sorting_block.as_ref().unwrap() => + _ if config.sorting_block.is_some() + && $self.app.get_current_route() == *config.sorting_block.as_ref().unwrap() => { $table .sort @@ -124,8 +134,8 @@ macro_rules! handle_table_events { .scroll_to_top(); true } - _ if props.searching_block.is_some() - && $self.app.get_current_route() == *props.searching_block.as_ref().unwrap() => + _ if config.searching_block.is_some() + && $self.app.get_current_route() == *config.searching_block.as_ref().unwrap() => { $table .search @@ -134,8 +144,8 @@ macro_rules! handle_table_events { .scroll_home(); true } - _ if props.filtering_block.is_some() - && $self.app.get_current_route() == *props.filtering_block.as_ref().unwrap() => + _ if config.filtering_block.is_some() + && $self.app.get_current_route() == *config.filtering_block.as_ref().unwrap() => { $table .filter @@ -148,14 +158,16 @@ macro_rules! handle_table_events { } } - fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + fn [](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool { + use $crate::models::Scrollable; + match $self.app.get_current_route() { - _ if props.table_block == $self.app.get_current_route() => { + _ if config.table_block == $self.app.get_current_route() => { $table.scroll_to_bottom(); true } - _ if props.sorting_block.is_some() - && $self.app.get_current_route() == *props.sorting_block.as_ref().unwrap() => + _ if config.sorting_block.is_some() + && $self.app.get_current_route() == *config.sorting_block.as_ref().unwrap() => { $table .sort @@ -164,8 +176,8 @@ macro_rules! handle_table_events { .scroll_to_bottom(); true } - _ if props.searching_block.is_some() - && $self.app.get_current_route() == *props.searching_block.as_ref().unwrap() => + _ if config.searching_block.is_some() + && $self.app.get_current_route() == *config.searching_block.as_ref().unwrap() => { $table .search @@ -174,8 +186,8 @@ macro_rules! handle_table_events { .reset_offset(); true } - _ if props.filtering_block.is_some() - && $self.app.get_current_route() == *props.filtering_block.as_ref().unwrap() => + _ if config.filtering_block.is_some() + && $self.app.get_current_route() == *config.filtering_block.as_ref().unwrap() => { $table .filter @@ -188,10 +200,10 @@ macro_rules! handle_table_events { } } - fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + fn [](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool { match $self.app.get_current_route() { - _ if props.searching_block.is_some() - && $self.app.get_current_route() == *props.searching_block.as_ref().unwrap() => + _ if config.searching_block.is_some() + && $self.app.get_current_route() == *config.searching_block.as_ref().unwrap() => { $crate::handle_text_box_left_right_keys!( $self, @@ -200,8 +212,8 @@ macro_rules! handle_table_events { ); true } - _ if props.filtering_block.is_some() - && $self.app.get_current_route() == *props.filtering_block.as_ref().unwrap() => + _ if config.filtering_block.is_some() + && $self.app.get_current_route() == *config.filtering_block.as_ref().unwrap() => { $crate::handle_text_box_left_right_keys!( $self, @@ -214,12 +226,12 @@ macro_rules! handle_table_events { } } - fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + fn [](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool { match $self.app.get_current_route() { - _ if props.sorting_block.is_some() - && $self.app.get_current_route() == *props.sorting_block.as_ref().unwrap() => + _ if config.sorting_block.is_some() + && $self.app.get_current_route() == *config.sorting_block.as_ref().unwrap() => { - if let Some(sort_by_fn) = props.sort_by_fn { + if let Some(sort_by_fn) = config.sort_by_fn { $table.items.sort_by(sort_by_fn); } @@ -228,21 +240,21 @@ macro_rules! handle_table_events { true } - _ if props.searching_block.is_some() - && $self.app.get_current_route() == *props.searching_block.as_ref().unwrap() => + _ if config.searching_block.is_some() + && $self.app.get_current_route() == *config.searching_block.as_ref().unwrap() => { $self.app.pop_navigation_stack(); $self.app.should_ignore_quit_key = false; if $table.search.is_some() { - let search_field_fn = props + let search_field_fn = config .search_field_fn .expect("Search field function is required"); let has_match = $table.apply_search(search_field_fn); if !has_match { $self.app.push_navigation_stack( - props + config .search_error_block .expect("Search error block is undefined"), ); @@ -251,21 +263,21 @@ macro_rules! handle_table_events { true } - _ if props.filtering_block.is_some() - && $self.app.get_current_route() == *props.filtering_block.as_ref().unwrap() => + _ if config.filtering_block.is_some() + && $self.app.get_current_route() == *config.filtering_block.as_ref().unwrap() => { $self.app.pop_navigation_stack(); $self.app.should_ignore_quit_key = false; if $table.filter.is_some() { - let filter_field_fn = props + let filter_field_fn = config .filter_field_fn .expect("Search field function is required"); let has_match = $table.apply_filter(filter_field_fn); if !has_match { $self.app.push_navigation_stack( - props + config .filter_error_block .expect("Search error block is undefined"), ); @@ -278,35 +290,35 @@ macro_rules! handle_table_events { } } - fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { + fn [](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool { match $self.app.get_current_route() { - _ if props.sorting_block.is_some() - && $self.app.get_current_route() == *props.sorting_block.as_ref().unwrap() => + _ if config.sorting_block.is_some() + && $self.app.get_current_route() == *config.sorting_block.as_ref().unwrap() => { $self.app.pop_navigation_stack(); true } - _ if (props.searching_block.is_some() - && $self.app.get_current_route() == *props.searching_block.as_ref().unwrap()) - || (props.search_error_block.is_some() - && $self.app.get_current_route() == *props.search_error_block.as_ref().unwrap()) => + _ if (config.searching_block.is_some() + && $self.app.get_current_route() == *config.searching_block.as_ref().unwrap()) + || (config.search_error_block.is_some() + && $self.app.get_current_route() == *config.search_error_block.as_ref().unwrap()) => { $self.app.pop_navigation_stack(); $table.reset_search(); $self.app.should_ignore_quit_key = false; true } - _ if (props.filtering_block.is_some() - && $self.app.get_current_route() == *props.filtering_block.as_ref().unwrap()) - || (props.filter_error_block.is_some() - && $self.app.get_current_route() == *props.filter_error_block.as_ref().unwrap()) => + _ if (config.filtering_block.is_some() + && $self.app.get_current_route() == *config.filtering_block.as_ref().unwrap()) + || (config.filter_error_block.is_some() + && $self.app.get_current_route() == *config.filter_error_block.as_ref().unwrap()) => { $self.app.pop_navigation_stack(); $table.reset_filter(); $self.app.should_ignore_quit_key = false; true } - _ if props.table_block == $self.app.get_current_route() + _ if config.table_block == $self.app.get_current_route() && $table.filtered_items.is_some() => { $table.reset_filter(); @@ -316,11 +328,11 @@ macro_rules! handle_table_events { } } - fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { - if matches!($self.app.get_current_route(), _ if props.table_block == $self.app.get_current_route()) { + fn [](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool { + if matches!($self.app.get_current_route(), _ if config.table_block == $self.app.get_current_route()) { $self .app - .push_navigation_stack(props.filtering_block.expect("Filtering block is undefined").into()); + .push_navigation_stack(config.filtering_block.expect("Filtering block is undefined").into()); $table.reset_filter(); $table.filter = Some($crate::models::HorizontallyScrollableText::default()); $self.app.should_ignore_quit_key = true; @@ -331,11 +343,11 @@ macro_rules! handle_table_events { } } - fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { - if matches!($self.app.get_current_route(), _ if props.table_block == $self.app.get_current_route()) { + fn [](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool { + if matches!($self.app.get_current_route(), _ if config.table_block == $self.app.get_current_route()) { $self .app - .push_navigation_stack(props.searching_block.expect("Searching block is undefined")); + .push_navigation_stack(config.searching_block.expect("Searching block is undefined")); $table.search = Some($crate::models::HorizontallyScrollableText::default()); $self.app.should_ignore_quit_key = true; @@ -345,10 +357,10 @@ macro_rules! handle_table_events { } } - fn [](&mut $self, props: $crate::handlers::table_handler::TableHandlingProps<$row>) -> bool { - if matches!($self.app.get_current_route(), _ if props.table_block == $self.app.get_current_route()) { + fn [](&mut $self, config: $crate::handlers::table_handler::TableHandlingConfig<$row>) -> bool { + if matches!($self.app.get_current_route(), _ if config.table_block == $self.app.get_current_route()) { $table.sorting( - props + config .sort_options .as_ref() .expect("Sort options are undefined") @@ -356,7 +368,7 @@ macro_rules! handle_table_events { ); $self .app - .push_navigation_stack(props.sorting_block.expect("Sorting block is undefined")); + .push_navigation_stack(config.sorting_block.expect("Sorting block is undefined")); true } else { false @@ -384,12 +396,12 @@ macro_rules! handle_table_events { }; } -impl TableHandlingProps +impl TableHandlingConfig where T: Clone + PartialEq + Eq + Debug + Default, { pub fn new(table_block: Route) -> Self { - TableHandlingProps { + TableHandlingConfig { sorting_block: None, sort_options: None, sort_by_fn: None, diff --git a/src/handlers/table_handler_tests.rs b/src/handlers/table_handler_tests.rs new file mode 100644 index 0000000..c908454 --- /dev/null +++ b/src/handlers/table_handler_tests.rs @@ -0,0 +1,1221 @@ +#[cfg(test)] +mod tests { + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::event::Key; + use crate::handle_table_events; + use crate::handlers::table_handler::TableHandlingConfig; + use crate::handlers::KeyEventHandler; + use crate::models::radarr_models::Movie; + use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::servarr_models::Language; + use crate::models::stateful_table::SortOption; + use rstest::rstest; + + struct TableHandlerUnit<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_radarr_block: ActiveRadarrBlock, + _context: Option, + } + + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for TableHandlerUnit<'a, 'b> { + fn handle(&mut self) { + let movie_table_handling_config = TableHandlingConfig::new(ActiveRadarrBlock::Movies.into()) + .sorting_block(ActiveRadarrBlock::MoviesSortPrompt.into()) + .sort_by_fn(|a: &Movie, b: &Movie| a.id.cmp(&b.id)) + .sort_options(sort_options()) + .searching_block(ActiveRadarrBlock::SearchMovie.into()) + .search_error_block(ActiveRadarrBlock::SearchMovieError.into()) + .search_field_fn(|movie| &movie.title.text) + .filtering_block(ActiveRadarrBlock::FilterMovies.into()) + .filter_error_block(ActiveRadarrBlock::FilterMoviesError.into()) + .filter_field_fn(|movie| &movie.title.text); + let minimal_movie_table_handling_config = + TableHandlingConfig::new(ActiveRadarrBlock::Movies.into()); + + match self.active_radarr_block { + ActiveRadarrBlock::MovieDetails => { + self.handle_movies_table_events(minimal_movie_table_handling_config); + } + _ => { + self.handle_movies_table_events(movie_table_handling_config); + }, + } + } + + fn accepts(_: ActiveRadarrBlock) -> bool { + true + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveRadarrBlock, + _context: Option, + ) -> Self { + Self { + key, + app, + active_radarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + } + + 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) {} + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) {} + + fn handle_char_key_event(&mut self) {} + } + + impl<'a, 'b> TableHandlerUnit<'a, 'b> { + handle_table_events!(self, movies, self.app.data.radarr_data.movies, Movie); + } + + mod test_handle_scroll_up_and_down { + use super::*; + use crate::models::HorizontallyScrollableText; + use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; + use pretty_assertions::assert_str_eq; + + test_iterable_scroll!( + test_table_scroll, + TableHandlerUnit, + radarr_data, + movies, + simple_stateful_iterable_vec!(Movie, HorizontallyScrollableText), + ActiveRadarrBlock::Movies, + None, + title, + to_string + ); + + #[rstest] + fn test_table_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::default(); + app.is_loading = true; + app + .data + .radarr_data + .movies + .set_items(simple_stateful_iterable_vec!( + Movie, + HorizontallyScrollableText + )); + + TableHandlerUnit::with(key, &mut app, ActiveRadarrBlock::Movies, None).handle(); + + assert_str_eq!( + app + .data + .radarr_data + .movies + .current_selection() + .title + .to_string(), + "Test 1" + ); + + TableHandlerUnit::with(key, &mut app, ActiveRadarrBlock::Movies, None).handle(); + + assert_eq!( + app + .data + .radarr_data + .movies + .current_selection() + .title + .to_string(), + "Test 1" + ); + } + + #[rstest] + fn test_table_sort_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let movie_field_vec = sort_options(); + let mut app = App::default(); + app.data.radarr_data.movies.sorting(sort_options()); + + if key == Key::Up { + for i in (0..movie_field_vec.len()).rev() { + TableHandlerUnit::with(key, &mut app, ActiveRadarrBlock::MoviesSortPrompt, None).handle(); + + assert_eq!( + app + .data + .radarr_data + .movies + .sort + .as_ref() + .unwrap() + .current_selection(), + &movie_field_vec[i] + ); + } + } else { + for i in 0..movie_field_vec.len() { + TableHandlerUnit::with(key, &mut app, ActiveRadarrBlock::MoviesSortPrompt, None).handle(); + + assert_eq!( + app + .data + .radarr_data + .movies + .sort + .as_ref() + .unwrap() + .current_selection(), + &movie_field_vec[(i + 1) % movie_field_vec.len()] + ); + } + } + } + } + + mod test_handle_home_end { + use pretty_assertions::{assert_eq, assert_str_eq}; + use std::sync::atomic::Ordering::SeqCst; + + use super::*; + use crate::models::HorizontallyScrollableText; + use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; + + test_iterable_home_and_end!( + test_table_home_end, + TableHandlerUnit, + radarr_data, + movies, + extended_stateful_iterable_vec!(Movie, HorizontallyScrollableText), + ActiveRadarrBlock::Movies, + None, + title, + to_string + ); + + #[test] + fn test_table_home_end_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app + .data + .radarr_data + .movies + .set_items(extended_stateful_iterable_vec!( + Movie, + HorizontallyScrollableText + )); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveRadarrBlock::Movies, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .radarr_data + .movies + .current_selection() + .title + .to_string(), + "Test 1" + ); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveRadarrBlock::Movies, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .radarr_data + .movies + .current_selection() + .title + .to_string(), + "Test 1" + ); + } + + #[test] + fn test_movie_search_box_home_end_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + app.data.radarr_data.movies.search = Some("Test".into()); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveRadarrBlock::SearchMovie, + None, + ) + .handle(); + + assert_eq!( + app + .data + .radarr_data + .movies + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 4 + ); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveRadarrBlock::SearchMovie, + None, + ) + .handle(); + + assert_eq!( + app + .data + .radarr_data + .movies + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 0 + ); + } + + #[test] + fn test_movie_filter_box_home_end_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + app.data.radarr_data.movies.filter = Some("Test".into()); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveRadarrBlock::FilterMovies, + None, + ) + .handle(); + + assert_eq!( + app + .data + .radarr_data + .movies + .filter + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 4 + ); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveRadarrBlock::FilterMovies, + None, + ) + .handle(); + + assert_eq!( + app + .data + .radarr_data + .movies + .filter + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 0 + ); + } + + #[test] + fn test_table_sort_home_end() { + let movie_field_vec = sort_options(); + let mut app = App::default(); + app.data.radarr_data.movies.sorting(sort_options()); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveRadarrBlock::MoviesSortPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .radarr_data + .movies + .sort + .as_ref() + .unwrap() + .current_selection(), + &movie_field_vec[movie_field_vec.len() - 1] + ); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveRadarrBlock::MoviesSortPrompt, + None, + ) + .handle(); + + assert_eq!( + app + .data + .radarr_data + .movies + .sort + .as_ref() + .unwrap() + .current_selection(), + &movie_field_vec[0] + ); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use std::sync::atomic::Ordering::SeqCst; + + use super::*; + + #[test] + fn test_movie_search_box_left_right_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + app.data.radarr_data.movies.search = Some("Test".into()); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveRadarrBlock::SearchMovie, + None, + ) + .handle(); + + assert_eq!( + app + .data + .radarr_data + .movies + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 1 + ); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveRadarrBlock::SearchMovie, + None, + ) + .handle(); + + assert_eq!( + app + .data + .radarr_data + .movies + .search + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 0 + ); + } + + #[test] + fn test_movie_filter_box_left_right_keys() { + let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + app.data.radarr_data.movies.filter = Some("Test".into()); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveRadarrBlock::FilterMovies, + None, + ) + .handle(); + + assert_eq!( + app + .data + .radarr_data + .movies + .filter + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 1 + ); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveRadarrBlock::FilterMovies, + None, + ) + .handle(); + + assert_eq!( + app + .data + .radarr_data + .movies + .filter + .as_ref() + .unwrap() + .offset + .load(SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use pretty_assertions::{assert_eq, assert_str_eq}; + + use crate::extended_stateful_iterable_vec; + use crate::models::HorizontallyScrollableText; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_search_movie_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); + app + .data + .radarr_data + .movies + .set_items(extended_stateful_iterable_vec!( + Movie, + HorizontallyScrollableText + )); + app.data.radarr_data.movies.search = Some("Test 2".into()); + + TableHandlerUnit::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::SearchMovie, None).handle(); + + assert_str_eq!( + app.data.radarr_data.movies.current_selection().title.text, + "Test 2" + ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); + } + + #[test] + fn test_search_movie_submit_error_on_no_search_hits() { + let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); + app + .data + .radarr_data + .movies + .set_items(extended_stateful_iterable_vec!( + Movie, + HorizontallyScrollableText + )); + app.data.radarr_data.movies.search = Some("Test 5".into()); + + TableHandlerUnit::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::SearchMovie, None).handle(); + + assert_str_eq!( + app.data.radarr_data.movies.current_selection().title.text, + "Test 1" + ); + assert_eq!( + app.get_current_route(), + ActiveRadarrBlock::SearchMovieError.into() + ); + } + + #[test] + fn test_search_filtered_table_submit() { + let mut app = App::default(); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); + app + .data + .radarr_data + .movies + .set_filtered_items(extended_stateful_iterable_vec!( + Movie, + HorizontallyScrollableText + )); + app.data.radarr_data.movies.search = Some("Test 2".into()); + + TableHandlerUnit::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::SearchMovie, None).handle(); + + assert_str_eq!( + app.data.radarr_data.movies.current_selection().title.text, + "Test 2" + ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); + } + + #[test] + fn test_filter_table_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); + app + .data + .radarr_data + .movies + .set_items(extended_stateful_iterable_vec!( + Movie, + HorizontallyScrollableText + )); + app.data.radarr_data.movies.filter = Some("Test".into()); + + TableHandlerUnit::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::FilterMovies, None).handle(); + + assert!(app.data.radarr_data.movies.filtered_items.is_some()); + assert!(!app.should_ignore_quit_key); + assert_eq!( + app + .data + .radarr_data + .movies + .filtered_items + .as_ref() + .unwrap() + .len(), + 3 + ); + assert_str_eq!( + app.data.radarr_data.movies.current_selection().title.text, + "Test 1" + ); + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); + } + + #[test] + fn test_filter_table_submit_error_on_no_filter_matches() { + let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); + app + .data + .radarr_data + .movies + .set_items(extended_stateful_iterable_vec!( + Movie, + HorizontallyScrollableText + )); + app.data.radarr_data.movies.filter = Some("Test 5".into()); + + TableHandlerUnit::with(SUBMIT_KEY, &mut app, ActiveRadarrBlock::FilterMovies, None).handle(); + + assert!(!app.should_ignore_quit_key); + assert!(app.data.radarr_data.movies.filtered_items.is_none()); + assert_eq!( + app.get_current_route(), + ActiveRadarrBlock::FilterMoviesError.into() + ); + } + + #[test] + fn test_table_sort_prompt_submit() { + let mut app = App::default(); + app.data.radarr_data.movies.sort_asc = true; + app.data.radarr_data.movies.sorting(sort_options()); + app.data.radarr_data.movies.set_items(movies_vec()); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app.push_navigation_stack(ActiveRadarrBlock::MoviesSortPrompt.into()); + + let mut expected_vec = movies_vec(); + expected_vec.sort_by(|a, b| a.id.cmp(&b.id)); + expected_vec.reverse(); + + TableHandlerUnit::with( + SUBMIT_KEY, + &mut app, + ActiveRadarrBlock::MoviesSortPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); + assert_eq!(app.data.radarr_data.movies.items, expected_vec); + } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + use ratatui::widgets::TableState; + + use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils::create_test_radarr_data; + use crate::models::stateful_table::StatefulTable; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_search_movie_block_esc( + #[values(ActiveRadarrBlock::SearchMovie, ActiveRadarrBlock::SearchMovieError)] + active_radarr_block: ActiveRadarrBlock, + ) { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app.push_navigation_stack(active_radarr_block.into()); + app.data.radarr_data = create_test_radarr_data(); + app.data.radarr_data.movies.search = Some("Test".into()); + + TableHandlerUnit::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); + + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); + assert!(!app.should_ignore_quit_key); + assert_eq!(app.data.radarr_data.movies.search, None); + } + + #[rstest] + fn test_filter_table_block_esc( + #[values(ActiveRadarrBlock::FilterMovies, ActiveRadarrBlock::FilterMoviesError)] + active_radarr_block: ActiveRadarrBlock, + ) { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app.push_navigation_stack(active_radarr_block.into()); + app.data.radarr_data = create_test_radarr_data(); + app.data.radarr_data.movies = StatefulTable { + filter: Some("Test".into()), + filtered_items: Some(Vec::new()), + filtered_state: Some(TableState::default()), + ..StatefulTable::default() + }; + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + + TableHandlerUnit::with(ESC_KEY, &mut app, active_radarr_block, None).handle(); + + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); + assert!(!app.should_ignore_quit_key); + assert_eq!(app.data.radarr_data.movies.filter, None); + assert_eq!(app.data.radarr_data.movies.filtered_items, None); + assert_eq!(app.data.radarr_data.movies.filtered_state, None); + } + + #[test] + fn test_table_sort_prompt_block_esc() { + let mut app = App::default(); + app.data.radarr_data.movies.set_items(movies_vec()); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app.push_navigation_stack(ActiveRadarrBlock::MoviesSortPrompt.into()); + + TableHandlerUnit::with(ESC_KEY, &mut app, ActiveRadarrBlock::MoviesSortPrompt, None).handle(); + + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); + } + } + + mod test_handle_key_char { + use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils::create_test_radarr_data; + use crate::models::HorizontallyScrollableText; + use pretty_assertions::{assert_eq, assert_str_eq}; + + use super::*; + + #[test] + fn test_search_table_key() { + let mut app = App::default(); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveRadarrBlock::Movies, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveRadarrBlock::SearchMovie.into() + ); + assert!(app.should_ignore_quit_key); + assert_eq!( + app.data.radarr_data.movies.search, + Some(HorizontallyScrollableText::default()) + ); + } + + #[test] + fn test_search_table_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveRadarrBlock::Movies, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); + assert!(!app.should_ignore_quit_key); + assert_eq!(app.data.radarr_data.movies.search, None); + } + + #[test] + fn test_search_table_key_no_op_when_search_table_block_is_not_defined() { + let mut app = App::default(); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveRadarrBlock::MovieDetails, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); + assert!(!app.should_ignore_quit_key); + assert_eq!(app.data.radarr_data.movies.search, None); + } + + #[test] + fn test_filter_table_key() { + let mut app = App::default(); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveRadarrBlock::Movies, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveRadarrBlock::FilterMovies.into() + ); + assert!(app.should_ignore_quit_key); + assert!(app.data.radarr_data.movies.filter.is_some()); + } + + #[test] + fn test_filter_table_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveRadarrBlock::Movies, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); + assert!(!app.should_ignore_quit_key); + assert!(app.data.radarr_data.movies.filter.is_none()); + } + + #[test] + fn test_filter_table_key_resets_previous_filter() { + let mut app = App::default(); + app.should_ignore_quit_key = true; + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app.data.radarr_data = create_test_radarr_data(); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + app.data.radarr_data.movies.filter = Some("Test".into()); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveRadarrBlock::Movies, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveRadarrBlock::FilterMovies.into() + ); + assert!(app.should_ignore_quit_key); + assert_eq!( + app.data.radarr_data.movies.filter, + Some(HorizontallyScrollableText::default()) + ); + assert!(app.data.radarr_data.movies.filtered_items.is_none()); + assert!(app.data.radarr_data.movies.filtered_state.is_none()); + } + + #[test] + fn test_filter_table_key_no_op_when_filter_table_block_is_not_defined() { + let mut app = App::default(); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveRadarrBlock::MovieDetails, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); + assert!(!app.should_ignore_quit_key); + assert_eq!(app.data.radarr_data.movies.filter, None); + } + + #[test] + fn test_search_table_box_backspace_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); + app.data.radarr_data.movies.search = Some("Test".into()); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveRadarrBlock::SearchMovie, + None, + ) + .handle(); + + assert_str_eq!( + app.data.radarr_data.movies.search.as_ref().unwrap().text, + "Tes" + ); + } + + #[test] + fn test_filter_table_box_backspace_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + app.data.radarr_data.movies.filter = Some("Test".into()); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveRadarrBlock::FilterMovies, + None, + ) + .handle(); + + assert_str_eq!( + app.data.radarr_data.movies.filter.as_ref().unwrap().text, + "Tes" + ); + } + + #[test] + fn test_search_table_box_char_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + app.data.radarr_data.movies.search = Some(HorizontallyScrollableText::default()); + + TableHandlerUnit::with( + Key::Char('h'), + &mut app, + ActiveRadarrBlock::SearchMovie, + None, + ) + .handle(); + + assert_str_eq!( + app.data.radarr_data.movies.search.as_ref().unwrap().text, + "h" + ); + } + + #[test] + fn test_filter_table_box_char_key() { + let mut app = App::default(); + app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + app.data.radarr_data.movies.filter = Some(HorizontallyScrollableText::default()); + + TableHandlerUnit::with( + Key::Char('h'), + &mut app, + ActiveRadarrBlock::FilterMovies, + None, + ) + .handle(); + + assert_str_eq!( + app.data.radarr_data.movies.filter.as_ref().unwrap().text, + "h" + ); + } + + #[test] + fn test_sort_key() { + let mut app = App::default(); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.sort.key, + &mut app, + ActiveRadarrBlock::Movies, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveRadarrBlock::MoviesSortPrompt.into() + ); + assert_eq!( + app.data.radarr_data.movies.sort.as_ref().unwrap().items, + sort_options() + ); + assert!(!app.data.radarr_data.movies.sort_asc); + } + + #[test] + fn test_sort_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.sort.key, + &mut app, + ActiveRadarrBlock::Movies, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); + assert!(app.data.radarr_data.movies.sort.is_none()); + } + + #[test] + fn test_sort_key_no_op_when_sort_table_block_is_undefined() { + let mut app = App::default(); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + + TableHandlerUnit::with( + DEFAULT_KEYBINDINGS.sort.key, + &mut app, + ActiveRadarrBlock::MovieDetails, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into()); + assert!(app.data.radarr_data.movies.sort.is_none()); + } + } + + fn movies_vec() -> Vec { + vec![ + Movie { + id: 3, + title: "test 1".into(), + original_language: Language { + id: 1, + name: "English".to_owned(), + }, + size_on_disk: 1024, + studio: "Studio 1".to_owned(), + year: 2024, + monitored: false, + runtime: 12.into(), + quality_profile_id: 1, + certification: Some("PG-13".to_owned()), + tags: vec![1.into(), 2.into()], + ..Movie::default() + }, + Movie { + id: 2, + title: "test 2".into(), + original_language: Language { + id: 2, + name: "Chinese".to_owned(), + }, + size_on_disk: 2048, + studio: "Studio 2".to_owned(), + year: 1998, + monitored: false, + runtime: 60.into(), + quality_profile_id: 2, + certification: Some("R".to_owned()), + tags: vec![1.into(), 3.into()], + ..Movie::default() + }, + Movie { + id: 1, + title: "test 3".into(), + original_language: Language { + id: 3, + name: "Japanese".to_owned(), + }, + size_on_disk: 512, + studio: "studio 3".to_owned(), + year: 1954, + monitored: true, + runtime: 120.into(), + quality_profile_id: 3, + certification: Some("G".to_owned()), + tags: vec![2.into(), 3.into()], + ..Movie::default() + }, + ] + } + + fn sort_options() -> Vec> { + vec![SortOption { + name: "Test 1", + cmp_fn: Some(|a, b| { + b.title + .text + .to_lowercase() + .cmp(&a.title.text.to_lowercase()) + }), + }] + } +} From a84324d3bc39481118da164090693ae3656c869e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 11 Dec 2024 23:18:37 -0700 Subject: [PATCH 64/82] feat(handler): Full handler support for the Season details UI in Sonarr --- src/app/sonarr/mod.rs | 23 +- src/app/sonarr/sonarr_tests.rs | 81 +- src/handlers/handler_test_utils.rs | 4 + .../library/movie_details_handler.rs | 35 +- .../library/library_handler_tests.rs | 33 +- src/handlers/sonarr_handlers/library/mod.rs | 7 + .../library/season_details_handler.rs | 465 +++++++++ .../library/season_details_handler_tests.rs | 982 ++++++++++++++++++ src/models/servarr_data/sonarr/modals.rs | 14 +- .../servarr_data/sonarr/modals_tests.rs | 12 +- src/models/servarr_data/sonarr/sonarr_data.rs | 7 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 7 +- .../servarr_data/sonarr/sonarr_test_utils.rs | 1 + src/models/sonarr_models.rs | 4 +- src/models/sonarr_models_tests.rs | 2 +- src/network/sonarr_network.rs | 6 +- src/network/sonarr_network_tests.rs | 35 +- src/ui/sonarr_ui/library/season_details_ui.rs | 119 ++- src/ui/sonarr_ui/library/series_details_ui.rs | 2 +- src/ui/widgets/popup.rs | 2 + src/ui/widgets/popup_tests.rs | 1 + 21 files changed, 1727 insertions(+), 115 deletions(-) create mode 100644 src/handlers/sonarr_handlers/library/season_details_handler.rs create mode 100644 src/handlers/sonarr_handlers/library/season_details_handler_tests.rs diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index 6e16140..30f042a 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -55,9 +55,14 @@ impl<'a> App<'a> { .await; } ActiveSonarrBlock::ManualSeasonSearch => { - self - .dispatch_network_event(SonarrEvent::GetSeasonReleases(None).into()) - .await; + match self.data.sonarr_data.season_details_modal.as_ref() { + Some(season_details_modal) if season_details_modal.season_releases.is_empty() => { + self + .dispatch_network_event(SonarrEvent::GetSeasonReleases(None).into()) + .await; + } + _ => (), + } } ActiveSonarrBlock::EpisodeDetails | ActiveSonarrBlock::EpisodeFile => { self @@ -70,9 +75,15 @@ impl<'a> App<'a> { .await; } ActiveSonarrBlock::ManualEpisodeSearch => { - self - .dispatch_network_event(SonarrEvent::GetEpisodeReleases(None).into()) - .await; + if let Some(season_details_modal) = self.data.sonarr_data.season_details_modal.as_ref() { + if let Some(episode_details_modal) = season_details_modal.episode_details_modal.as_ref() { + if episode_details_modal.episode_releases.is_empty() { + self + .dispatch_network_event(SonarrEvent::GetEpisodeReleases(None).into()) + .await; + } + } + } } ActiveSonarrBlock::Downloads => { self diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index 79b0ccc..4cb3dce 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -7,8 +7,11 @@ mod tests { use crate::{ app::App, models::{ - servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, - sonarr_models::{Season, Series}, + servarr_data::sonarr::{ + modals::{EpisodeDetailsModal, SeasonDetailsModal}, + sonarr_data::ActiveSonarrBlock, + }, + sonarr_models::{Season, Series, SonarrRelease}, }, network::{sonarr_network::SonarrEvent, NetworkEvent}, }; @@ -115,6 +118,7 @@ mod tests { #[tokio::test] async fn test_dispatch_by_manual_season_search_block() { let (mut app, mut sync_network_rx) = construct_app_unit(); + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); app .dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualSeasonSearch) @@ -129,6 +133,40 @@ mod tests { assert_eq!(app.tick_count, 0); } + #[tokio::test] + async fn test_dispatch_by_manual_season_search_block_is_loading() { + let mut app = App { + is_loading: true, + ..App::default() + }; + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualSeasonSearch) + .await; + + assert!(app.is_loading); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_season_search_block_season_releases_non_empty() { + let mut app = App::default(); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal + .season_releases + .set_items(vec![SonarrRelease::default()]); + app.data.sonarr_data.season_details_modal = Some(season_details_modal); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualSeasonSearch) + .await; + + assert!(!app.is_loading); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + #[tokio::test] async fn test_dispatch_by_episode_details_block() { let (mut app, mut sync_network_rx) = construct_app_unit(); @@ -183,6 +221,9 @@ mod tests { #[tokio::test] async fn test_dispatch_by_manual_episode_search_block() { let (mut app, mut sync_network_rx) = construct_app_unit(); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episode_details_modal = Some(EpisodeDetailsModal::default()); + app.data.sonarr_data.season_details_modal = Some(season_details_modal); app .dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualEpisodeSearch) @@ -197,6 +238,42 @@ mod tests { assert_eq!(app.tick_count, 0); } + #[tokio::test] + async fn test_dispatch_by_manual_episode_search_block_is_loading() { + let mut app = App { + is_loading: true, + ..App::default() + }; + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualEpisodeSearch) + .await; + + assert!(app.is_loading); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_episode_search_block_episode_releases_non_empty() { + let mut app = App::default(); + let mut episode_details_modal = EpisodeDetailsModal::default(); + episode_details_modal + .episode_releases + .set_items(vec![SonarrRelease::default()]); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episode_details_modal = Some(episode_details_modal); + app.data.sonarr_data.season_details_modal = Some(season_details_modal); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualEpisodeSearch) + .await; + + assert!(!app.is_loading); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + #[tokio::test] async fn test_dispatch_by_history_block() { let (mut app, mut sync_network_rx) = construct_app_unit(); diff --git a/src/handlers/handler_test_utils.rs b/src/handlers/handler_test_utils.rs index 1a49e94..bf70390 100644 --- a/src/handlers/handler_test_utils.rs +++ b/src/handlers/handler_test_utils.rs @@ -351,6 +351,10 @@ mod test_utils { movie_details_modal.movie_crew.set_items(vec![$crate::models::radarr_models::Credit::default()]); movie_details_modal.movie_releases.set_items(vec![$crate::models::radarr_models::RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); + let mut season_details_modal = $crate::models::servarr_data::sonarr::modals::SeasonDetailsModal::default(); + season_details_modal.season_history.set_items(vec![$crate::models::sonarr_models::SonarrHistoryItem::default()]); + season_details_modal.episode_details_modal = Some($crate::models::servarr_data::sonarr::modals::EpisodeDetailsModal::default()); + app.data.sonarr_data.season_details_modal = Some(season_details_modal); let mut series_history = $crate::models::stateful_table::StatefulTable::default(); series_history.set_items(vec![ $crate::models::sonarr_models::SonarrHistoryItem::default(), diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index 491d755..a8d61ac 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -328,31 +328,26 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< } _ => (), }, - ActiveRadarrBlock::AutomaticallySearchMoviePrompt => { - if key == DEFAULT_KEYBINDINGS.confirm.key { - self.app.data.radarr_data.prompt_confirm = true; - self.app.data.radarr_data.prompt_confirm_action = - Some(RadarrEvent::TriggerAutomaticSearch(None)); + ActiveRadarrBlock::AutomaticallySearchMoviePrompt + if key == DEFAULT_KEYBINDINGS.confirm.key => + { + self.app.data.radarr_data.prompt_confirm = true; + self.app.data.radarr_data.prompt_confirm_action = + Some(RadarrEvent::TriggerAutomaticSearch(None)); - self.app.pop_navigation_stack(); - } + self.app.pop_navigation_stack(); } - ActiveRadarrBlock::UpdateAndScanPrompt => { - if key == DEFAULT_KEYBINDINGS.confirm.key { - self.app.data.radarr_data.prompt_confirm = true; - self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateAndScan(None)); + ActiveRadarrBlock::UpdateAndScanPrompt if key == DEFAULT_KEYBINDINGS.confirm.key => { + self.app.data.radarr_data.prompt_confirm = true; + self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateAndScan(None)); - self.app.pop_navigation_stack(); - } + self.app.pop_navigation_stack(); } - ActiveRadarrBlock::ManualSearchConfirmPrompt => { - if key == DEFAULT_KEYBINDINGS.confirm.key { - self.app.data.radarr_data.prompt_confirm = true; - self.app.data.radarr_data.prompt_confirm_action = - Some(RadarrEvent::DownloadRelease(None)); + ActiveRadarrBlock::ManualSearchConfirmPrompt if key == DEFAULT_KEYBINDINGS.confirm.key => { + self.app.data.radarr_data.prompt_confirm = true; + self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::DownloadRelease(None)); - self.app.pop_navigation_stack(); - } + self.app.pop_navigation_stack(); } _ => (), } diff --git a/src/handlers/sonarr_handlers/library/library_handler_tests.rs b/src/handlers/sonarr_handlers/library/library_handler_tests.rs index 39aea05..6b73c88 100644 --- a/src/handlers/sonarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/library_handler_tests.rs @@ -10,10 +10,7 @@ mod tests { use crate::event::Key; use crate::handlers::sonarr_handlers::library::{series_sorting_options, LibraryHandler}; use crate::handlers::KeyEventHandler; - use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, LIBRARY_BLOCKS, - SERIES_DETAILS_BLOCKS, - }; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, LIBRARY_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS}; use crate::models::sonarr_models::{Series, SeriesStatus, SeriesType}; use crate::test_handler_delegation; @@ -543,6 +540,33 @@ mod tests { ); } + #[rstest] + fn test_delegates_season_details_blocks_to_season_details_handler( + #[values( + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::SearchEpisodes, + ActiveSonarrBlock::SearchEpisodesError, + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, + ActiveSonarrBlock::SearchSeasonHistory, + ActiveSonarrBlock::SearchSeasonHistoryError, + ActiveSonarrBlock::FilterSeasonHistory, + ActiveSonarrBlock::FilterSeasonHistoryError, + ActiveSonarrBlock::SeasonHistorySortPrompt, + ActiveSonarrBlock::SeasonHistoryDetails, + ActiveSonarrBlock::ManualSeasonSearch, + ActiveSonarrBlock::ManualSeasonSearchSortPrompt, + ActiveSonarrBlock::DeleteEpisodeFilePrompt, + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + LibraryHandler, + ActiveSonarrBlock::Series, + active_sonarr_block + ); + } + #[rstest] fn test_delegates_edit_series_blocks_to_edit_series_handler( #[values( @@ -768,6 +792,7 @@ mod tests { library_handler_blocks.extend(DELETE_SERIES_BLOCKS); library_handler_blocks.extend(EDIT_SERIES_BLOCKS); library_handler_blocks.extend(SERIES_DETAILS_BLOCKS); + library_handler_blocks.extend(SEASON_DETAILS_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { if library_handler_blocks.contains(&active_sonarr_block) { diff --git a/src/handlers/sonarr_handlers/library/mod.rs b/src/handlers/sonarr_handlers/library/mod.rs index 969ed25..98b1d56 100644 --- a/src/handlers/sonarr_handlers/library/mod.rs +++ b/src/handlers/sonarr_handlers/library/mod.rs @@ -22,6 +22,7 @@ use crate::{ use super::handle_change_tab_left_right_keys; use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::handlers::sonarr_handlers::library::season_details_handler::SeasonDetailsHandler; use crate::handlers::sonarr_handlers::library::series_details_handler::SeriesDetailsHandler; use crate::handlers::table_handler::TableHandlingConfig; @@ -32,6 +33,7 @@ mod delete_series_handler; #[path = "library_handler_tests.rs"] mod library_handler_tests; mod series_details_handler; +mod season_details_handler; pub(super) struct LibraryHandler<'a, 'b> { key: Key, @@ -75,6 +77,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' SeriesDetailsHandler::with(self.key, self.app, self.active_sonarr_block, self.context) .handle(); } + _ if SeasonDetailsHandler::accepts(self.active_sonarr_block) => { + SeasonDetailsHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle(); + } _ => self.handle_key_event(), } } @@ -85,6 +91,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' || DeleteSeriesHandler::accepts(active_block) || EditSeriesHandler::accepts(active_block) || SeriesDetailsHandler::accepts(active_block) + || SeasonDetailsHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block) } diff --git a/src/handlers/sonarr_handlers/library/season_details_handler.rs b/src/handlers/sonarr_handlers/library/season_details_handler.rs new file mode 100644 index 0000000..88b53c3 --- /dev/null +++ b/src/handlers/sonarr_handlers/library/season_details_handler.rs @@ -0,0 +1,465 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::App; +use crate::event::Key; +use crate::handle_table_events; +use crate::handlers::sonarr_handlers::history::history_sorting_options; +use crate::handlers::table_handler::TableHandlingConfig; +use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SEASON_DETAILS_BLOCKS}; +use crate::models::servarr_models::Language; +use crate::models::sonarr_models::{ + Episode, SonarrHistoryItem, SonarrRelease, SonarrReleaseDownloadBody, +}; +use crate::models::stateful_table::SortOption; +use crate::network::sonarr_network::SonarrEvent; +use serde_json::Number; + +#[cfg(test)] +#[path = "season_details_handler_tests.rs"] +mod season_details_handler_tests; + +pub(super) struct SeasonDetailsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> SeasonDetailsHandler<'a, 'b> { + handle_table_events!( + self, + episodes, + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .expect("Season details modal is undefined") + .episodes, + Episode + ); + handle_table_events!( + self, + season_history, + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .expect("Season details modal is undefined") + .season_history, + SonarrHistoryItem + ); + handle_table_events!( + self, + season_releases, + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .expect("Season details modal is undefined") + .season_releases, + SonarrRelease + ); +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeasonDetailsHandler<'a, 'b> { + fn handle(&mut self) { + let episodes_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::SeasonDetails.into()) + .searching_block(ActiveSonarrBlock::SearchEpisodes.into()) + .search_error_block(ActiveSonarrBlock::SearchEpisodesError.into()) + .search_field_fn(|episode: &Episode| &episode.title); + let season_history_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::SeasonHistory.into()) + .sorting_block(ActiveSonarrBlock::SeasonHistorySortPrompt.into()) + .sort_options(history_sorting_options()) + .sort_by_fn(|a: &SonarrHistoryItem, b: &SonarrHistoryItem| a.id.cmp(&b.id)) + .searching_block(ActiveSonarrBlock::SearchSeasonHistory.into()) + .search_error_block(ActiveSonarrBlock::SearchSeasonHistoryError.into()) + .search_field_fn(|history_item: &SonarrHistoryItem| &history_item.source_title.text) + .filtering_block(ActiveSonarrBlock::FilterSeasonHistory.into()) + .filter_error_block(ActiveSonarrBlock::FilterSeasonHistoryError.into()) + .filter_field_fn(|history_item: &SonarrHistoryItem| &history_item.source_title.text); + let season_releases_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::ManualSeasonSearch.into()) + .sorting_block(ActiveSonarrBlock::ManualSeasonSearchSortPrompt.into()) + .sort_options(releases_sorting_options()); + + if !self.handle_episodes_table_events(episodes_table_handling_config) + && !self.handle_season_history_table_events(season_history_table_handling_config) + && !self.handle_season_releases_table_events(season_releases_table_handling_config) + { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveSonarrBlock) -> bool { + SEASON_DETAILS_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + context: Option, + ) -> SeasonDetailsHandler<'a, 'b> { + SeasonDetailsHandler { + key, + app, + active_sonarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + && if let Some(season_details_modal) = &self.app.data.sonarr_data.season_details_modal { + match self.active_sonarr_block { + ActiveSonarrBlock::SeasonDetails => !season_details_modal.episodes.is_empty(), + ActiveSonarrBlock::SeasonHistory => !season_details_modal.season_history.is_empty(), + ActiveSonarrBlock::ManualSeasonSearch => !season_details_modal.season_releases.is_empty(), + _ => true, + } + } else { + false + } + } + + 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_sonarr_block == ActiveSonarrBlock::SeasonDetails { + self + .app + .push_navigation_stack(ActiveSonarrBlock::DeleteEpisodeFilePrompt.into()); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SeasonDetails + | ActiveSonarrBlock::SeasonHistory + | ActiveSonarrBlock::ManualSeasonSearch => match self.key { + _ if self.key == DEFAULT_KEYBINDINGS.left.key => { + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_details_tabs + .previous(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_details_tabs + .get_active_route(), + ); + } + _ if self.key == DEFAULT_KEYBINDINGS.right.key => { + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_details_tabs + .next(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_details_tabs + .get_active_route(), + ); + } + _ => (), + }, + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt + | ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt + | ActiveSonarrBlock::DeleteEpisodeFilePrompt => { + handle_prompt_toggle(self.app, self.key); + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SeasonHistory => self + .app + .push_navigation_stack(ActiveSonarrBlock::SeasonHistoryDetails.into()), + ActiveSonarrBlock::DeleteEpisodeFilePrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DeleteEpisodeFile(None)); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::TriggerAutomaticSeasonSearch(None)); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::ManualSeasonSearch => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt.into()); + } + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + let SonarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_releases + .current_selection(); + let series_id = self.app.data.sonarr_data.series.current_selection().id; + let season_number = self + .app + .data + .sonarr_data + .seasons + .current_selection() + .season_number; + let params = SonarrReleaseDownloadBody { + guid: guid.clone(), + indexer_id: *indexer_id, + series_id: Some(series_id), + season_number: Some(season_number), + ..SonarrReleaseDownloadBody::default() + }; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DownloadRelease(params)); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SeasonDetails | ActiveSonarrBlock::ManualSeasonSearch => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.season_details_modal = None; + } + ActiveSonarrBlock::SeasonHistoryDetails => { + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::SeasonHistory => { + if self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .filtered_items + .is_some() + { + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_history + .filtered_items = None; + } else { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.season_details_modal = None; + } + } + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt + | ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt + | ActiveSonarrBlock::DeleteEpisodeFilePrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.prompt_confirm = false; + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_sonarr_block { + ActiveSonarrBlock::SeasonDetails + | ActiveSonarrBlock::SeasonHistory + | ActiveSonarrBlock::ManualSeasonSearch => match self.key { + _ if self.key == DEFAULT_KEYBINDINGS.refresh.key => { + self + .app + .pop_and_push_navigation_stack(self.active_sonarr_block.into()); + } + _ if self.key == DEFAULT_KEYBINDINGS.auto_search.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::AutomaticallySearchSeasonPrompt.into()); + } + _ => (), + }, + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt + if key == DEFAULT_KEYBINDINGS.confirm.key => + { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::TriggerAutomaticSeasonSearch(None)); + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::DeleteEpisodeFilePrompt if key == DEFAULT_KEYBINDINGS.confirm.key => { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DeleteEpisodeFile(None)); + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt + if key == DEFAULT_KEYBINDINGS.confirm.key => + { + self.app.data.sonarr_data.prompt_confirm = true; + let SonarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_releases + .current_selection(); + let series_id = self.app.data.sonarr_data.series.current_selection().id; + let season_number = self + .app + .data + .sonarr_data + .seasons + .current_selection() + .season_number; + let params = SonarrReleaseDownloadBody { + guid: guid.clone(), + indexer_id: *indexer_id, + series_id: Some(series_id), + season_number: Some(season_number), + ..SonarrReleaseDownloadBody::default() + }; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DownloadRelease(params)); + + self.app.pop_navigation_stack(); + } + _ => (), + } + } +} + +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: "Language", + cmp_fn: Some(|a, b| { + let default_language_vec = vec![Language { + id: 1, + name: "_".to_owned(), + }]; + let language_a = &a.languages.as_ref().unwrap_or(&default_language_vec)[0]; + let language_b = &b.languages.as_ref().unwrap_or(&default_language_vec)[0]; + + language_a.cmp(language_b) + }), + }, + SortOption { + name: "Quality", + cmp_fn: Some(|a, b| a.quality.cmp(&b.quality)), + }, + ] +} diff --git a/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs new file mode 100644 index 0000000..80f525b --- /dev/null +++ b/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs @@ -0,0 +1,982 @@ +#[cfg(test)] +mod tests { + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::handlers::sonarr_handlers::library::season_details_handler::{ + releases_sorting_options, SeasonDetailsHandler, + }; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::modals::SeasonDetailsModal; + use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, SEASON_DETAILS_BLOCKS, + }; + use crate::models::servarr_models::{Language, Quality, QualityWrapper}; + use crate::models::sonarr_models::{SonarrRelease, SonarrReleaseDownloadBody}; + use crate::models::HorizontallyScrollableText; + use pretty_assertions::assert_str_eq; + use rstest::rstest; + use serde_json::Number; + use std::cmp::Ordering; + use strum::IntoEnumIterator; + + mod test_handle_delete { + use super::*; + use crate::event::Key; + use pretty_assertions::assert_eq; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_delete_episode_prompt() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + + SeasonDetailsHandler::with(DELETE_KEY, &mut app, ActiveSonarrBlock::SeasonDetails, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::DeleteEpisodeFilePrompt.into() + ); + } + + #[test] + fn test_delete_episode_prompt_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.is_loading = true; + + SeasonDetailsHandler::with(DELETE_KEY, &mut app, ActiveSonarrBlock::SeasonDetails, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonDetails.into() + ); + } + } + + mod test_handle_left_right_actions { + use super::*; + use crate::event::Key; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_left_right_prompt_toggle( + #[values( + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt, + ActiveSonarrBlock::DeleteEpisodeFilePrompt + )] + active_sonarr_block: ActiveSonarrBlock, + #[values(Key::Left, Key::Right)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + + SeasonDetailsHandler::with(key, &mut app, active_sonarr_block, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + SeasonDetailsHandler::with(key, &mut app, active_sonarr_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[rstest] + #[case(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory)] + #[case( + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] + #[case( + ActiveSonarrBlock::ManualSeasonSearch, + ActiveSonarrBlock::SeasonDetails + )] + fn test_season_details_tabs_left_right_action( + #[case] left_block: ActiveSonarrBlock, + #[case] right_block: ActiveSonarrBlock, + #[values(true, false)] is_ready: bool, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.is_loading = is_ready; + app.push_navigation_stack(right_block.into()); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_details_tabs + .index = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_details_tabs + .tabs + .iter() + .position(|tab_route| tab_route.route == right_block.into()) + .unwrap_or_default(); + + SeasonDetailsHandler::with(DEFAULT_KEYBINDINGS.left.key, &mut app, right_block, None) + .handle(); + + assert_eq!( + app.get_current_route(), + app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_details_tabs + .get_active_route() + ); + assert_eq!(app.get_current_route(), left_block.into()); + + SeasonDetailsHandler::with(DEFAULT_KEYBINDINGS.right.key, &mut app, left_block, None) + .handle(); + + assert_eq!( + app.get_current_route(), + app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_details_tabs + .get_active_route() + ); + assert_eq!(app.get_current_route(), right_block.into()); + } + } + + mod test_handle_submit { + use super::*; + use crate::event::Key; + use crate::models::stateful_table::StatefulTable; + use crate::network::sonarr_network::SonarrEvent; + use pretty_assertions::assert_eq; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_season_history_submit() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeasonHistory, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonHistoryDetails.into() + ); + } + + #[test] + fn test_season_history_submit_no_op_when_season_history_is_empty() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_history = StatefulTable::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeasonHistory, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonHistory.into() + ); + } + + #[test] + fn test_season_history_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeasonHistory, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonHistory.into() + ); + } + + #[rstest] + #[case( + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, + SonarrEvent::TriggerAutomaticSeasonSearch(None) + )] + #[case( + ActiveSonarrBlock::DeleteEpisodeFilePrompt, + SonarrEvent::DeleteEpisodeFile(None) + )] + fn test_season_details_prompt_confirm_submit( + #[case] prompt_block: ActiveSonarrBlock, + #[case] expected_action: SonarrEvent, + #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(active_sonarr_block.into()); + app.push_navigation_stack(prompt_block.into()); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(expected_action) + ); + } + + #[test] + fn test_season_details_manual_search_confirm_prompt_confirm_submit() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearch.into()); + app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt.into()); + + SeasonDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::ManualSeasonSearch.into() + ); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody { + guid: String::new(), + indexer_id: 0, + series_id: Some(0), + season_number: Some(0), + ..SonarrReleaseDownloadBody::default() + })) + ); + } + + #[rstest] + fn test_season_details_prompt_decline_submit( + #[values( + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, + ActiveSonarrBlock::DeleteEpisodeFilePrompt, + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt + )] + prompt_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonDetails.into() + ); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + } + + #[test] + fn test_manual_season_search_submit() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearch.into()); + + SeasonDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::ManualSeasonSearch, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt.into() + ); + } + + #[test] + fn test_manual_season_search_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearch.into()); + + SeasonDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::ManualSeasonSearch, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::ManualSeasonSearch.into() + ); + } + } + + mod test_handle_esc { + use super::*; + use crate::event::Key; + use crate::models::sonarr_models::SonarrHistoryItem; + use crate::models::stateful_table::StatefulTable; + use pretty_assertions::assert_eq; + use ratatui::widgets::TableState; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_season_history_details_block_esc() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistoryDetails.into()); + + SeasonDetailsHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::SeasonHistoryDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonHistory.into() + ); + } + + #[rstest] + fn test_season_details_prompts_esc( + #[values( + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, + ActiveSonarrBlock::DeleteEpisodeFilePrompt, + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt + )] + prompt_block: ActiveSonarrBlock, + #[values(true, false)] is_ready: bool, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.is_loading = is_ready; + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + SeasonDetailsHandler::with(ESC_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonDetails.into() + ); + } + + #[test] + fn test_season_history_esc_resets_filter_if_one_is_set_instead_of_closing_the_window() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + let mut season_history = StatefulTable { + filter: Some("Test".into()), + filtered_items: Some(vec![SonarrHistoryItem::default()]), + filtered_state: Some(TableState::default()), + ..StatefulTable::default() + }; + season_history.set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.season_details_modal.as_mut().unwrap().season_history = season_history; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); + + SeasonDetailsHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::SeasonHistory, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonHistory.into() + ); + assert!(app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .filter + .is_none()); + assert!(app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .filtered_items + .is_none()); + assert!(app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .filtered_state + .is_none()); + } + + #[rstest] + fn test_season_details_tabs_esc( + #[values( + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] active_sonarr_block: ActiveSonarrBlock + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(active_sonarr_block.into()); + + SeasonDetailsHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesDetails.into() + ); + assert!(app.data.sonarr_data.season_details_modal.is_none()); + } + } + + mod test_handle_key_char { + use super::*; + use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; + use crate::network::sonarr_network::SonarrEvent; + use pretty_assertions::assert_eq; + + #[rstest] + fn test_auto_search_key( + #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(active_sonarr_block.into()); + + SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt.into() + ); + } + + #[rstest] + fn test_auto_search_key_no_op_when_not_ready( + #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(active_sonarr_block.into()); + + SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + } + + #[rstest] + fn test_refresh_key( + #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(active_sonarr_block.into()); + app.is_routing = false; + + SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + assert!(app.is_routing); + } + + #[rstest] + fn test_refresh_key_no_op_when_not_ready( + #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.is_loading = true; + app.push_navigation_stack(active_sonarr_block.into()); + app.is_routing = false; + + SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + assert!(!app.is_routing); + } + + #[rstest] + #[case( + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, + SonarrEvent::TriggerAutomaticSeasonSearch(None) + )] + #[case( + ActiveSonarrBlock::DeleteEpisodeFilePrompt, + SonarrEvent::DeleteEpisodeFile(None) + )] + fn test_season_details_prompt_confirm_confirm_key( + #[case] prompt_block: ActiveSonarrBlock, + #[case] expected_action: SonarrEvent, + #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(active_sonarr_block.into()); + app.push_navigation_stack(prompt_block.into()); + + SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + prompt_block, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(expected_action) + ); + } + + #[test] + fn test_season_details_manual_search_confirm_prompt_confirm_confirm_key() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearch.into()); + app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt.into()); + + SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::ManualSeasonSearch.into() + ); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody { + guid: String::new(), + indexer_id: 0, + series_id: Some(0), + season_number: Some(0), + ..SonarrReleaseDownloadBody::default() + })) + ); + } + } + + #[test] + fn test_season_details_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if SEASON_DETAILS_BLOCKS.contains(&active_sonarr_block) { + assert!(SeasonDetailsHandler::accepts(active_sonarr_block)); + } else { + assert!(!SeasonDetailsHandler::accepts(active_sonarr_block)); + } + }); + } + + #[test] + fn test_season_details_handler_is_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.is_loading = true; + + let handler = SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeasonDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_season_details_handler_is_not_ready_when_not_loading_and_season_details_is_none() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + + let handler = SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeasonDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_season_details_handler_is_not_ready_when_not_loading_and_episodes_table_is_empty() { + let mut app = App::default(); + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + + let handler = SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeasonDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_season_details_handler_is_not_ready_when_not_loading_and_history_table_is_empty() { + let mut app = App::default(); + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); + + let handler = SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeasonHistory, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_season_details_handler_is_not_ready_when_not_loading_and_releases_table_is_empty() { + let mut app = App::default(); + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearch.into()); + + let handler = SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::ManualSeasonSearch, + None, + ); + + assert!(!handler.is_ready()); + } + + #[rstest] + fn test_season_details_handler_is_ready_when_not_loading_and_season_details_modal_is_populated( + #[values( + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(active_sonarr_block.into()); + + let handler = SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + active_sonarr_block, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_releases_sorting_options_source() { + let expected_cmp_fn: fn(&SonarrRelease, &SonarrRelease) -> 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(&SonarrRelease, &SonarrRelease) -> 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(&SonarrRelease, &SonarrRelease) -> 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(&SonarrRelease, &SonarrRelease) -> 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(&SonarrRelease, &SonarrRelease) -> 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(&SonarrRelease, &SonarrRelease) -> 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(&SonarrRelease, &SonarrRelease) -> 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_language() { + let expected_cmp_fn: fn(&SonarrRelease, &SonarrRelease) -> Ordering = |a, b| { + let default_language_vec = vec![Language { + id: 1, + name: "_".to_owned(), + }]; + let language_a = &a.languages.as_ref().unwrap_or(&default_language_vec)[0]; + let language_b = &b.languages.as_ref().unwrap_or(&default_language_vec)[0]; + + language_a.cmp(language_b) + }; + 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, "Language"); + } + + #[test] + fn test_releases_sorting_options_quality() { + let expected_cmp_fn: fn(&SonarrRelease, &SonarrRelease) -> 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()[8].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 = SonarrRelease { + 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)), + languages: Some(vec![Language { + id: 1, + name: "Language A".to_owned(), + }]), + quality: QualityWrapper { + quality: Quality { + name: "Quality A".to_owned(), + }, + }, + ..SonarrRelease::default() + }; + let release_b = SonarrRelease { + 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)), + languages: Some(vec![Language { + id: 2, + name: "Language B".to_owned(), + }]), + quality: QualityWrapper { + quality: Quality { + name: "Quality B".to_owned(), + }, + }, + ..SonarrRelease::default() + }; + let release_c = SonarrRelease { + protocol: "Protocol C".to_owned(), + age: 3, + title: HorizontallyScrollableText::from("Title C"), + indexer: "Indexer C".to_owned(), + size: 3, + rejected: false, + seeders: None, + languages: None, + quality: QualityWrapper { + quality: Quality { + name: "Quality C".to_owned(), + }, + }, + ..SonarrRelease::default() + }; + + vec![release_a, release_b, release_c] + } +} diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 7c90581..aeffb89 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -329,22 +329,22 @@ impl Default for SeasonDetailsModal { TabRoute { title: "Episodes", route: ActiveSonarrBlock::SeasonDetails.into(), - help: build_context_clue_string(&SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES), - contextual_help: Some(build_context_clue_string(&SEASON_DETAILS_CONTEXT_CLUES)), + help: build_context_clue_string(&SEASON_DETAILS_CONTEXT_CLUES), + contextual_help: Some(build_context_clue_string(&SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES)), }, TabRoute { title: "History", route: ActiveSonarrBlock::SeasonHistory.into(), - help: build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES), - contextual_help: Some(build_context_clue_string(&SEASON_HISTORY_CONTEXT_CLUES)), + help: build_context_clue_string(&SEASON_HISTORY_CONTEXT_CLUES), + contextual_help: Some(build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES)), }, TabRoute { title: "Manual Search", route: ActiveSonarrBlock::ManualSeasonSearch.into(), - help: build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES), - contextual_help: Some(build_context_clue_string( + help: build_context_clue_string( &MANUAL_SEASON_SEARCH_CONTEXT_CLUES, - )), + ), + contextual_help: Some(build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES)), }, ]), } diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs index e06b06e..d51a47b 100644 --- a/src/models/servarr_data/sonarr/modals_tests.rs +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -338,11 +338,11 @@ mod tests { ); assert_str_eq!( season_details_modal.season_details_tabs.tabs[0].help, - build_context_clue_string(&SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES) + build_context_clue_string(&SEASON_DETAILS_CONTEXT_CLUES) ); assert_eq!( season_details_modal.season_details_tabs.tabs[0].contextual_help, - Some(build_context_clue_string(&SEASON_DETAILS_CONTEXT_CLUES)) + Some(build_context_clue_string(&SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES)) ); assert_str_eq!( @@ -355,11 +355,11 @@ mod tests { ); assert_str_eq!( season_details_modal.season_details_tabs.tabs[1].help, - build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES) + build_context_clue_string(&SEASON_HISTORY_CONTEXT_CLUES) ); assert_eq!( season_details_modal.season_details_tabs.tabs[1].contextual_help, - Some(build_context_clue_string(&SEASON_HISTORY_CONTEXT_CLUES)) + Some(build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES)) ); assert_str_eq!( @@ -372,12 +372,12 @@ mod tests { ); assert_str_eq!( season_details_modal.season_details_tabs.tabs[2].help, - build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES) + build_context_clue_string(&MANUAL_SEASON_SEARCH_CONTEXT_CLUES) ); assert_eq!( season_details_modal.season_details_tabs.tabs[2].contextual_help, Some(build_context_clue_string( - &MANUAL_SEASON_SEARCH_CONTEXT_CLUES + &DETAILS_CONTEXTUAL_CONTEXT_CLUES )) ); } diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 21a2a8a..5137fd0 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -333,11 +333,11 @@ pub static SERIES_DETAILS_BLOCKS: [ActiveSonarrBlock; 12] = [ ActiveSonarrBlock::SeriesHistoryDetails, ]; -pub static SEASON_DETAILS_BLOCKS: [ActiveSonarrBlock; 14] = [ +pub static SEASON_DETAILS_BLOCKS: [ActiveSonarrBlock; 15] = [ ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, - ActiveSonarrBlock::SearchSeason, - ActiveSonarrBlock::SearchSeasonError, + ActiveSonarrBlock::SearchEpisodes, + ActiveSonarrBlock::SearchEpisodesError, ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, ActiveSonarrBlock::SearchSeasonHistory, ActiveSonarrBlock::SearchSeasonHistoryError, @@ -348,6 +348,7 @@ pub static SEASON_DETAILS_BLOCKS: [ActiveSonarrBlock; 14] = [ ActiveSonarrBlock::ManualSeasonSearch, ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt, ActiveSonarrBlock::ManualSeasonSearchSortPrompt, + ActiveSonarrBlock::DeleteEpisodeFilePrompt, ]; pub static ADD_SERIES_BLOCKS: [ActiveSonarrBlock; 13] = [ diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 3e69b23..b78beb4 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -608,11 +608,11 @@ mod tests { #[test] fn test_season_details_blocks_contents() { - assert_eq!(SEASON_DETAILS_BLOCKS.len(), 14); + assert_eq!(SEASON_DETAILS_BLOCKS.len(), 15); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeasonDetails)); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeasonHistory)); - assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeason)); - assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeasonError)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchEpisodes)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchEpisodesError)); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::AutomaticallySearchSeasonPrompt)); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeasonHistory)); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeasonHistoryError)); @@ -623,6 +623,7 @@ mod tests { assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualSeasonSearch)); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt)); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualSeasonSearchSortPrompt)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::DeleteEpisodeFilePrompt)); } } } diff --git a/src/models/servarr_data/sonarr/sonarr_test_utils.rs b/src/models/servarr_data/sonarr/sonarr_test_utils.rs index 2ba4dea..b7f5885 100644 --- a/src/models/servarr_data/sonarr/sonarr_test_utils.rs +++ b/src/models/servarr_data/sonarr/sonarr_test_utils.rs @@ -27,6 +27,7 @@ pub mod utils { season_details_modal .episodes .set_items(vec![Episode::default()]); + season_details_modal.season_history.set_items(vec![SonarrHistoryItem::default()]); season_details_modal .season_releases .set_items(vec![SonarrRelease::default()]); diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 791b002..3a9dadf 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -210,7 +210,7 @@ pub struct Episode { pub season_number: i64, #[serde(deserialize_with = "super::from_i64")] pub episode_number: i64, - pub title: Option, + pub title: String, pub air_date_utc: Option>, pub overview: Option, pub has_file: bool, @@ -220,7 +220,7 @@ pub struct Episode { impl Display for Episode { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.title.as_ref().unwrap_or(&String::new())) + write!(f, "{}", self.title) } } diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index ff55030..0c1ea46 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -21,7 +21,7 @@ mod tests { #[test] fn test_episode_display() { let episode = Episode { - title: Some("Test Title".to_owned()), + title: "Test Title".to_owned(), ..Episode::default() }; diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 614b53c..749d7ca 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -4,6 +4,8 @@ use log::{debug, info, warn}; use serde_json::{json, Value}; use urlencoding::encode; +use super::{Network, NetworkEvent, NetworkResource}; +use crate::models::sonarr_models::DownloadStatus; use crate::{ models::{ radarr_models::IndexerTestResult, @@ -31,8 +33,6 @@ use crate::{ network::RequestMethod, utils::convert_to_gb, }; -use crate::models::sonarr_models::DownloadStatus; -use super::{Network, NetworkEvent, NetworkResource}; #[cfg(test)] #[path = "sonarr_network_tests.rs"] mod sonarr_network_tests; @@ -1568,7 +1568,7 @@ impl<'a, 'b> Network<'a, 'b> { Air Date: {air_date} Status: {status} Description: {}", - title.unwrap_or_default(), + title, overview.unwrap_or_default(), )), ..EpisodeDetailsModal::default() diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 40f14e7..7280e87 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -15,7 +15,10 @@ mod test { use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; - use crate::models::sonarr_models::{AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, DownloadStatus, EditSeriesParams, IndexerSettings, SeriesMonitor, SonarrHistoryEventType}; + use crate::models::sonarr_models::{ + AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, + DownloadStatus, EditSeriesParams, IndexerSettings, SeriesMonitor, SonarrHistoryEventType, + }; use crate::app::{App, ServarrConfig}; use crate::models::radarr_models::IndexerTestResult; @@ -2234,13 +2237,13 @@ mod test { #[tokio::test] async fn test_handle_get_episodes_event(#[values(true, false)] use_custom_sorting: bool) { let episode_1 = Episode { - title: Some("z test".to_owned()), + title: "z test".to_owned(), episode_file: None, ..episode() }; let episode_2 = Episode { id: 2, - title: Some("A test".to_owned()), + title: "A test".to_owned(), episode_file_id: 2, season_number: 2, episode_number: 2, @@ -2249,7 +2252,7 @@ mod test { }; let episode_3 = Episode { id: 3, - title: Some("A test".to_owned()), + title: "A test".to_owned(), episode_file_id: 3, season_number: 1, episode_number: 2, @@ -2271,13 +2274,7 @@ mod test { let mut season_details_modal = SeasonDetailsModal::default(); season_details_modal.episodes.sort_asc = true; if use_custom_sorting { - let cmp_fn = |a: &Episode, b: &Episode| { - a.title - .as_ref() - .unwrap() - .to_lowercase() - .cmp(&b.title.as_ref().unwrap().to_lowercase()) - }; + let cmp_fn = |a: &Episode, b: &Episode| a.title.to_lowercase().cmp(&b.title.to_lowercase()); expected_sorted_episodes.sort_by(cmp_fn); let title_sort_option = SortOption { name: "Title", @@ -2348,13 +2345,13 @@ mod test { #[tokio::test] async fn test_handle_get_episodes_event_empty_seasons_table_returns_all_episodes_by_default() { let episode_1 = Episode { - title: Some("z test".to_owned()), + title: "z test".to_owned(), episode_file: None, ..episode() }; let episode_2 = Episode { id: 2, - title: Some("A test".to_owned()), + title: "A test".to_owned(), episode_file_id: 2, season_number: 2, episode_number: 2, @@ -2363,7 +2360,7 @@ mod test { }; let episode_3 = Episode { id: 3, - title: Some("A test".to_owned()), + title: "A test".to_owned(), episode_file_id: 3, season_number: 1, episode_number: 2, @@ -2537,13 +2534,7 @@ mod test { .push_navigation_stack(ActiveSonarrBlock::EpisodesSortPrompt.into()); let mut season_details_modal = SeasonDetailsModal::default(); season_details_modal.episodes.sort_asc = true; - let cmp_fn = |a: &Episode, b: &Episode| { - a.title - .as_ref() - .unwrap() - .to_lowercase() - .cmp(&b.title.as_ref().unwrap().to_lowercase()) - }; + let cmp_fn = |a: &Episode, b: &Episode| a.title.to_lowercase().cmp(&b.title.to_lowercase()); expected_episodes.sort_by(cmp_fn); let title_sort_option = SortOption { name: "Title", @@ -7255,7 +7246,7 @@ mod test { episode_file_id: 1, season_number: 1, episode_number: 1, - title: Some("Something cool".to_owned()), + title: "Something cool".to_owned(), air_date_utc: Some(DateTime::from( DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap(), )), diff --git a/src/ui/sonarr_ui/library/season_details_ui.rs b/src/ui/sonarr_ui/library/season_details_ui.rs index 8b5765a..42191ac 100644 --- a/src/ui/sonarr_ui/library/season_details_ui.rs +++ b/src/ui/sonarr_ui/library/season_details_ui.rs @@ -1,9 +1,16 @@ use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SEASON_DETAILS_BLOCKS}; use crate::models::sonarr_models::{ - DownloadRecord, DownloadStatus, Episode, SonarrHistoryItem, SonarrRelease, + DownloadRecord, DownloadStatus, Episode, SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, }; use crate::models::Route; +use crate::ui::sonarr_ui::sonarr_ui_utils::{ + create_download_failed_history_event_details, + create_download_folder_imported_history_event_details, + create_episode_file_deleted_history_event_details, + create_episode_file_renamed_history_event_details, create_grabbed_history_event_details, + create_no_data_history_event_details, +}; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{ borderless_block, decorate_peer_style, get_width_from_percentage, layout_block_top_border, @@ -11,12 +18,13 @@ use crate::ui::utils::{ use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{draw_popup, draw_tabs, DrawUi}; use crate::utils::convert_to_gb; use chrono::Utc; -use ratatui::layout::{Constraint, Rect}; -use ratatui::prelude::{Line, Stylize, Text}; +use ratatui::layout::{Alignment, Constraint, Rect}; +use ratatui::prelude::{Line, Style, Stylize, Text}; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use ratatui::Frame; @@ -37,20 +45,12 @@ impl DrawUi for SeasonDetailsUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { if app.data.sonarr_data.season_details_modal.is_some() { - if let Route::Sonarr(active_sonarr_block, _) = app - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .season_details_tabs - .get_active_route() - { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { let draw_season_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { let content_area = draw_tabs( f, popup_area, - "Season Details", + &format!("Season {} Details", app.data.sonarr_data.seasons.current_selection().season_number), &app .data .sonarr_data @@ -64,8 +64,8 @@ impl DrawUi for SeasonDetailsUi { match active_sonarr_block { ActiveSonarrBlock::AutomaticallySearchSeasonPrompt => { let prompt = format!( - "Do you want to trigger an automatic search of your indexers for season packs for the season: Season {}", - app.data.sonarr_data.seasons.current_selection().season_number + "Do you want to trigger an automatic search of your indexers for season packs for: {}", + app.data.sonarr_data.seasons.current_selection().title.as_ref().unwrap() ); let confirmation_prompt = ConfirmationPrompt::new() .title("Automatic Season Search") @@ -89,8 +89,6 @@ impl DrawUi for SeasonDetailsUi { .episodes .current_selection() .title - .as_ref() - .unwrap_or(&String::new()) ); let confirmation_prompt = ConfirmationPrompt::new() .title("Delete Episode") @@ -105,11 +103,14 @@ impl DrawUi for SeasonDetailsUi { ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt => { draw_manual_season_search_confirm_prompt(f, app); } + ActiveSonarrBlock::SeasonHistoryDetails => { + draw_history_item_details_popup(f, app, popup_area); + } _ => (), } }; - draw_popup(f, app, draw_season_details_popup, Size::Large); + draw_popup(f, app, draw_season_details_popup, Size::XLarge); } } } @@ -194,20 +195,20 @@ fn draw_episodes_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Row::new(vec![ Cell::from(episode_monitored.to_owned()), Cell::from(episode_number.to_string()), - Cell::from(title.clone().unwrap_or_default()), + Cell::from(title.clone()), Cell::from(air_date), Cell::from(format!("{size:.2} GB")), Cell::from(quality_profile), ]), ) }; - let is_searching = active_sonarr_block == ActiveSonarrBlock::SearchSeason; + let is_searching = active_sonarr_block == ActiveSonarrBlock::SearchEpisodes; let season_table = ManagarrTable::new(content, episode_row_mapping) .block(layout_block_top_border()) .loading(app.is_loading) .footer(help_footer) - .searching(active_sonarr_block == ActiveSonarrBlock::SearchSeason) - .search_produced_empty_results(active_sonarr_block == ActiveSonarrBlock::SearchSeasonError) + .searching(is_searching) + .search_produced_empty_results(active_sonarr_block == ActiveSonarrBlock::SearchEpisodesError) .headers([ "🏷", "#", @@ -329,13 +330,14 @@ fn draw_episode_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) fn draw_season_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { match app.data.sonarr_data.season_details_modal.as_ref() { Some(season_details_modal) if !app.is_loading => { - let current_selection = if season_details_modal.season_releases.is_empty() { - SonarrRelease::default() + let (current_selection, is_empty) = if season_details_modal.season_releases.is_empty() { + (SonarrRelease::default(), true) } else { - season_details_modal + (season_details_modal .season_releases .current_selection() - .clone() + .clone(), + season_details_modal.season_releases.is_empty()) }; let season_release_table_footer = season_details_modal .season_details_tabs @@ -409,16 +411,22 @@ fn draw_season_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let release_table = ManagarrTable::new(Some(&mut season_release_table), season_release_row_mapping) .block(layout_block_top_border()) - .loading(app.is_loading) + .loading(app.is_loading || is_empty) .footer(season_release_table_footer) .sorting(active_sonarr_block == ActiveSonarrBlock::ManualSeasonSearchSortPrompt) - .headers(["Source Title", "Event Type", "Language", "Quality", "Date"]) + .headers([ + "Source", "Age", "⛔", "Title", "Indexer", "Size", "Peers", "Language", "Quality", + ]) .constraints([ - Constraint::Percentage(40), - Constraint::Percentage(15), - Constraint::Percentage(12), - Constraint::Percentage(13), - Constraint::Percentage(20), + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(5), + Constraint::Percentage(30), + Constraint::Percentage(18), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Percentage(7), + Constraint::Percentage(10), ]); f.render_widget(release_table, area); @@ -479,14 +487,14 @@ fn draw_manual_season_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_> .title(title) .prompt(&prompt) .content(content_paragraph) - .yes_no_value(app.data.radarr_data.prompt_confirm); + .yes_no_value(app.data.sonarr_data.prompt_confirm); f.render_widget(Popup::new(confirmation_prompt).size(Size::Small), f.area()); } else { let confirmation_prompt = ConfirmationPrompt::new() .title(title) .prompt(&prompt) - .yes_no_value(app.data.radarr_data.prompt_confirm); + .yes_no_value(app.data.sonarr_data.prompt_confirm); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), @@ -495,6 +503,47 @@ fn draw_manual_season_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_> } } +fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = + if let Some(season_details_modal) = app.data.sonarr_data.season_details_modal.as_ref() { + if season_details_modal.season_history.is_empty() { + SonarrHistoryItem::default() + } else { + season_details_modal + .season_history + .current_selection() + .clone() + } + } else { + SonarrHistoryItem::default() + }; + + let line_vec = match current_selection.event_type { + SonarrHistoryEventType::Grabbed => create_grabbed_history_event_details(current_selection), + SonarrHistoryEventType::DownloadFolderImported => { + create_download_folder_imported_history_event_details(current_selection) + } + SonarrHistoryEventType::DownloadFailed => { + create_download_failed_history_event_details(current_selection) + } + SonarrHistoryEventType::EpisodeFileDeleted => { + create_episode_file_deleted_history_event_details(current_selection) + } + SonarrHistoryEventType::EpisodeFileRenamed => { + create_episode_file_renamed_history_event_details(current_selection) + } + _ => create_no_data_history_event_details(current_selection), + }; + let text = Text::from(line_vec); + + let message = Message::new(text) + .title("Details") + .style(Style::new().secondary()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowMessage), area); +} + fn decorate_with_row_style<'a>( downloads_vec: &[DownloadRecord], episode: &Episode, diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index 3e763f4..ea135e1 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -106,7 +106,7 @@ impl DrawUi for SeriesDetailsUi { }; draw_popup(f, app, draw_series_details_popup, Size::XXLarge); - + if SeasonDetailsUi::accepts(route) { SeasonDetailsUi::draw(f, app, area); } diff --git a/src/ui/widgets/popup.rs b/src/ui/widgets/popup.rs index 40dca22..6d63126 100644 --- a/src/ui/widgets/popup.rs +++ b/src/ui/widgets/popup.rs @@ -22,6 +22,7 @@ pub enum Size { Small, Medium, Large, + XLarge, XXLarge, Long, } @@ -41,6 +42,7 @@ impl Size { Size::Small => (40, 40), Size::Medium => (60, 60), Size::Large => (75, 75), + Size::XLarge => (83, 83), Size::XXLarge => (90, 90), Size::Long => (65, 75), } diff --git a/src/ui/widgets/popup_tests.rs b/src/ui/widgets/popup_tests.rs index a7bce95..9f82a44 100644 --- a/src/ui/widgets/popup_tests.rs +++ b/src/ui/widgets/popup_tests.rs @@ -18,6 +18,7 @@ mod tests { assert_eq!(Size::Small.to_percent(), (40, 40)); assert_eq!(Size::Medium.to_percent(), (60, 60)); assert_eq!(Size::Large.to_percent(), (75, 75)); + assert_eq!(Size::XLarge.to_percent(), (83, 83)); assert_eq!(Size::XXLarge.to_percent(), (90, 90)); assert_eq!(Size::Long.to_percent(), (65, 75)); } From 12eb453fc7916c31f879562a6bea2de433b0f716 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 12 Dec 2024 16:25:02 -0700 Subject: [PATCH 65/82] feat(ui): Support for the episode details UI --- src/app/sonarr/mod.rs | 6 + src/app/sonarr/sonarr_context_clues.rs | 15 - src/app/sonarr/sonarr_tests.rs | 28 +- .../library/season_details_handler.rs | 16 + .../library/season_details_handler_tests.rs | 107 +++- src/models/servarr_data/sonarr/modals.rs | 4 +- .../servarr_data/sonarr/modals_tests.rs | 4 +- src/models/servarr_data/sonarr/sonarr_data.rs | 12 + .../servarr_data/sonarr/sonarr_data_tests.rs | 22 +- .../sonarr_ui/library/episode_details_ui.rs | 594 ++++++++++++++++++ .../library/episode_details_ui_tests.rs | 18 + src/ui/sonarr_ui/library/library_ui_tests.rs | 6 +- src/ui/sonarr_ui/library/mod.rs | 1 + src/ui/sonarr_ui/library/season_details_ui.rs | 12 +- .../library/season_details_ui_tests.rs | 9 +- src/ui/sonarr_ui/library/series_details_ui.rs | 3 +- .../library/series_details_ui_tests.rs | 3 +- 17 files changed, 800 insertions(+), 60 deletions(-) create mode 100644 src/ui/sonarr_ui/library/episode_details_ui.rs create mode 100644 src/ui/sonarr_ui/library/episode_details_ui_tests.rs diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index 30f042a..d8ae5fb 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -65,6 +65,12 @@ impl<'a> App<'a> { } } ActiveSonarrBlock::EpisodeDetails | ActiveSonarrBlock::EpisodeFile => { + self + .dispatch_network_event(SonarrEvent::GetEpisodes(None).into()) + .await; + self + .dispatch_network_event(SonarrEvent::GetDownloads.into()) + .await; self .dispatch_network_event(SonarrEvent::GetEpisodeDetails(None).into()) .await; diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index 90caa32..ece0638 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -145,21 +145,6 @@ pub static EPISODE_DETAILS_CONTEXT_CLUES: [ContextClue; 3] = [ (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; -pub static EPISODE_HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [ - ( - DEFAULT_KEYBINDINGS.refresh, - DEFAULT_KEYBINDINGS.refresh.desc, - ), - (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), - (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), - (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), - ( - DEFAULT_KEYBINDINGS.auto_search, - DEFAULT_KEYBINDINGS.auto_search.desc, - ), - (DEFAULT_KEYBINDINGS.esc, "cancel filter/close"), -]; - pub static SYSTEM_TASKS_CONTEXT_CLUES: [ContextClue; 2] = [ (DEFAULT_KEYBINDINGS.submit, "start task"), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index 4cb3dce..28942ab 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -176,6 +176,14 @@ mod tests { .await; assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetEpisodes(None).into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDownloads.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), SonarrEvent::GetEpisodeDetails(None).into() @@ -193,6 +201,14 @@ mod tests { .await; assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetEpisodes(None).into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDownloads.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), SonarrEvent::GetEpisodeDetails(None).into() @@ -221,8 +237,10 @@ mod tests { #[tokio::test] async fn test_dispatch_by_manual_episode_search_block() { let (mut app, mut sync_network_rx) = construct_app_unit(); - let mut season_details_modal = SeasonDetailsModal::default(); - season_details_modal.episode_details_modal = Some(EpisodeDetailsModal::default()); + let season_details_modal = SeasonDetailsModal { + episode_details_modal: Some(EpisodeDetailsModal::default()), + ..SeasonDetailsModal::default() + }; app.data.sonarr_data.season_details_modal = Some(season_details_modal); app @@ -261,8 +279,10 @@ mod tests { episode_details_modal .episode_releases .set_items(vec![SonarrRelease::default()]); - let mut season_details_modal = SeasonDetailsModal::default(); - season_details_modal.episode_details_modal = Some(episode_details_modal); + let season_details_modal = SeasonDetailsModal { + episode_details_modal: Some(episode_details_modal), + ..SeasonDetailsModal::default() + }; app.data.sonarr_data.season_details_modal = Some(season_details_modal); app diff --git a/src/handlers/sonarr_handlers/library/season_details_handler.rs b/src/handlers/sonarr_handlers/library/season_details_handler.rs index 88b53c3..6f90701 100644 --- a/src/handlers/sonarr_handlers/library/season_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/season_details_handler.rs @@ -212,6 +212,22 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeasonDetailsHandler fn handle_submit(&mut self) { match self.active_sonarr_block { + ActiveSonarrBlock::SeasonDetails + if self.app.data.sonarr_data.season_details_modal.is_some() + && !self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .is_empty() => + { + self + .app + .push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()) + } ActiveSonarrBlock::SeasonHistory => self .app .push_navigation_stack(ActiveSonarrBlock::SeasonHistoryDetails.into()), diff --git a/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs index 80f525b..8ad0954 100644 --- a/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs @@ -168,6 +168,58 @@ mod tests { const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + #[test] + fn test_season_details_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.data.sonarr_data = create_test_sonarr_data(); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeasonDetails, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EpisodeDetails.into() + ); + } + + #[test] + fn test_season_details_submit_no_op_on_empty_episodes_table() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes = StatefulTable::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeasonDetails, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonDetails.into() + ); + } + + #[test] + fn test_season_details_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeasonDetails, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonDetails.into() + ); + } + #[test] fn test_season_history_submit() { let mut app = App::default(); @@ -418,7 +470,13 @@ mod tests { ..StatefulTable::default() }; season_history.set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.season_details_modal.as_mut().unwrap().season_history = season_history; + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_history = season_history; app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); @@ -457,22 +515,23 @@ mod tests { .filtered_state .is_none()); } - + #[rstest] fn test_season_details_tabs_esc( #[values( - ActiveSonarrBlock::SeasonDetails, - ActiveSonarrBlock::SeasonHistory, - ActiveSonarrBlock::ManualSeasonSearch - )] active_sonarr_block: ActiveSonarrBlock + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] + active_sonarr_block: ActiveSonarrBlock, ) { let mut app = App::default(); app.data.sonarr_data = create_test_sonarr_data(); app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); app.push_navigation_stack(active_sonarr_block.into()); - + SeasonDetailsHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); - + assert_eq!( app.get_current_route(), ActiveSonarrBlock::SeriesDetails.into() @@ -489,7 +548,11 @@ mod tests { #[rstest] fn test_auto_search_key( - #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + #[values( + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] active_sonarr_block: ActiveSonarrBlock, ) { let mut app = App::default(); @@ -502,7 +565,7 @@ mod tests { active_sonarr_block, None, ) - .handle(); + .handle(); assert_eq!( app.get_current_route(), @@ -512,7 +575,11 @@ mod tests { #[rstest] fn test_auto_search_key_no_op_when_not_ready( - #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + #[values( + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] active_sonarr_block: ActiveSonarrBlock, ) { let mut app = App::default(); @@ -525,14 +592,18 @@ mod tests { active_sonarr_block, None, ) - .handle(); + .handle(); assert_eq!(app.get_current_route(), active_sonarr_block.into()); } #[rstest] fn test_refresh_key( - #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + #[values( + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] active_sonarr_block: ActiveSonarrBlock, ) { let mut app = App::default(); @@ -546,7 +617,7 @@ mod tests { active_sonarr_block, None, ) - .handle(); + .handle(); assert_eq!(app.get_current_route(), active_sonarr_block.into()); assert!(app.is_routing); @@ -554,7 +625,11 @@ mod tests { #[rstest] fn test_refresh_key_no_op_when_not_ready( - #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + #[values( + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] active_sonarr_block: ActiveSonarrBlock, ) { let mut app = App::default(); @@ -569,7 +644,7 @@ mod tests { active_sonarr_block, None, ) - .handle(); + .handle(); assert_eq!(app.get_current_route(), active_sonarr_block.into()); assert!(!app.is_routing); diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index aeffb89..230a692 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -5,7 +5,7 @@ use crate::{ context_clues::build_context_clue_string, sonarr::sonarr_context_clues::{ DETAILS_CONTEXTUAL_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES, - EPISODE_HISTORY_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, + MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES, SEASON_DETAILS_CONTEXT_CLUES, SEASON_HISTORY_CONTEXT_CLUES, }, @@ -288,7 +288,7 @@ impl Default for EpisodeDetailsModal { TabRoute { title: "History", route: ActiveSonarrBlock::EpisodeHistory.into(), - help: build_context_clue_string(&EPISODE_HISTORY_CONTEXT_CLUES), + help: build_context_clue_string(&EPISODE_DETAILS_CONTEXT_CLUES), contextual_help: Some(build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES)), }, TabRoute { diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs index d51a47b..aebffb8 100644 --- a/src/models/servarr_data/sonarr/modals_tests.rs +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -7,7 +7,7 @@ mod tests { use crate::app::context_clues::build_context_clue_string; use crate::app::sonarr::sonarr_context_clues::{ - DETAILS_CONTEXTUAL_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES, EPISODE_HISTORY_CONTEXT_CLUES, + DETAILS_CONTEXTUAL_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES, SEASON_DETAILS_CONTEXT_CLUES, SEASON_HISTORY_CONTEXT_CLUES, @@ -275,7 +275,7 @@ mod tests { ); assert_str_eq!( episode_details_modal.episode_details_tabs.tabs[1].help, - build_context_clue_string(&EPISODE_HISTORY_CONTEXT_CLUES) + build_context_clue_string(&EPISODE_DETAILS_CONTEXT_CLUES) ); assert_eq!( episode_details_modal.episode_details_tabs.tabs[1].contextual_help, diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 5137fd0..04bab77 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -245,6 +245,7 @@ pub enum ActiveSonarrBlock { EpisodeDetails, EpisodeFile, EpisodeHistory, + EpisodeHistoryDetails, EpisodesSortPrompt, FilterEpisodes, FilterEpisodesError, @@ -351,6 +352,17 @@ pub static SEASON_DETAILS_BLOCKS: [ActiveSonarrBlock; 15] = [ ActiveSonarrBlock::DeleteEpisodeFilePrompt, ]; +pub static EPISODE_DETAILS_BLOCKS: [ActiveSonarrBlock; 8] = [ + ActiveSonarrBlock::EpisodeDetails, + ActiveSonarrBlock::EpisodeHistory, + ActiveSonarrBlock::EpisodeHistoryDetails, + ActiveSonarrBlock::EpisodeFile, + ActiveSonarrBlock::ManualEpisodeSearch, + ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt, + ActiveSonarrBlock::ManualEpisodeSearchSortPrompt, + ActiveSonarrBlock::AutomaticallySearchEpisodePrompt, +]; + pub static ADD_SERIES_BLOCKS: [ActiveSonarrBlock; 13] = [ ActiveSonarrBlock::AddSeriesAlreadyInLibrary, ActiveSonarrBlock::AddSeriesConfirmPrompt, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index b78beb4..e511d95 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -222,14 +222,7 @@ mod tests { } mod active_sonarr_block_tests { - use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, BLOCKLIST_BLOCKS, - DELETE_SERIES_BLOCKS, DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_INDEXER_BLOCKS, - EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, EDIT_SERIES_BLOCKS, - EDIT_SERIES_SELECTION_BLOCKS, HISTORY_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_BLOCKS, - INDEXER_SETTINGS_SELECTION_BLOCKS, LIBRARY_BLOCKS, ROOT_FOLDERS_BLOCKS, - SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, SYSTEM_DETAILS_BLOCKS, - }; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, BLOCKLIST_BLOCKS, DELETE_SERIES_BLOCKS, DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_INDEXER_BLOCKS, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, EDIT_SERIES_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, EPISODE_DETAILS_BLOCKS, HISTORY_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, LIBRARY_BLOCKS, ROOT_FOLDERS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, SYSTEM_DETAILS_BLOCKS}; #[test] fn test_library_blocks_contents() { @@ -625,5 +618,18 @@ mod tests { assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualSeasonSearchSortPrompt)); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::DeleteEpisodeFilePrompt)); } + + #[test] + fn test_episode_details_blocks_contents() { + assert_eq!(EPISODE_DETAILS_BLOCKS.len(), 8); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::EpisodeDetails)); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::EpisodeHistory)); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::EpisodeHistoryDetails)); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::EpisodeFile)); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualEpisodeSearch)); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualEpisodeSearchSortPrompt)); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt)); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::AutomaticallySearchEpisodePrompt)); + } } } diff --git a/src/ui/sonarr_ui/library/episode_details_ui.rs b/src/ui/sonarr_ui/library/episode_details_ui.rs new file mode 100644 index 0000000..ea3d7eb --- /dev/null +++ b/src/ui/sonarr_ui/library/episode_details_ui.rs @@ -0,0 +1,594 @@ +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS}; +use crate::models::sonarr_models::{ + DownloadRecord, DownloadStatus, Episode, SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, +}; +use crate::models::Route; +use crate::ui::sonarr_ui::sonarr_ui_utils::{ + create_download_failed_history_event_details, + create_download_folder_imported_history_event_details, + create_episode_file_deleted_history_event_details, + create_episode_file_renamed_history_event_details, create_grabbed_history_event_details, + create_no_data_history_event_details, +}; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{ + borderless_block, decorate_peer_style, get_width_from_percentage, layout_block_bottom_border, + layout_block_top_border, +}; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::{draw_popup, draw_tabs, DrawUi}; +use crate::utils::convert_to_gb; +use chrono::Utc; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; +use ratatui::Frame; + +#[cfg(test)] +#[path = "episode_details_ui_tests.rs"] +mod episode_details_ui_tests; + +pub(super) struct EpisodeDetailsUi; + +impl DrawUi for EpisodeDetailsUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return EPISODE_DETAILS_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if let Some(season_details_modal) = app.data.sonarr_data.season_details_modal.as_ref() { + if season_details_modal.episode_details_modal.is_some() { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let draw_episode_details_popup = + |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + let content_area = draw_tabs( + f, + popup_area, + "Episode Details", + &app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_details_tabs, + ); + draw_episode_details_tabs(f, app, content_area); + + match active_sonarr_block { + ActiveSonarrBlock::AutomaticallySearchEpisodePrompt => { + let prompt = format!( + "Do you want to trigger an automatic search of your indexers for the episode: {}", + app.data.sonarr_data.season_details_modal.as_ref().unwrap().episodes.current_selection().title + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Automatic Episode Search") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt => { + draw_manual_episode_search_confirm_prompt(f, app); + } + ActiveSonarrBlock::EpisodeHistoryDetails => { + draw_history_item_details_popup(f, app, popup_area); + } + _ => (), + } + }; + + draw_popup(f, app, draw_episode_details_popup, Size::Large); + } + } + } + } +} + +pub fn draw_episode_details_tabs(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Some(season_details_modal) = app.data.sonarr_data.season_details_modal.as_ref() { + if let Some(episode_details_modal) = season_details_modal.episode_details_modal.as_ref() { + if let Route::Sonarr(active_sonarr_block, _) = episode_details_modal + .episode_details_tabs + .get_active_route() + { + match active_sonarr_block { + ActiveSonarrBlock::EpisodeDetails => draw_episode_details(f, app, area), + ActiveSonarrBlock::EpisodeHistory => draw_episode_history_table(f, app, area), + ActiveSonarrBlock::EpisodeFile => draw_file_info(f, app, area), + ActiveSonarrBlock::ManualEpisodeSearch => draw_episode_releases(f, app, area), + _ => (), + } + } + } + } +} + +fn draw_episode_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = layout_block_top_border(); + + match app.data.sonarr_data.season_details_modal.as_ref() { + Some(season_details_modal) if !app.is_loading => { + if let Some(episode_details_modal) = season_details_modal.episode_details_modal.as_ref() { + let episode = season_details_modal.episodes.current_selection().clone(); + let episode_details = &episode_details_modal.episode_details; + let download = app + .data + .sonarr_data + .downloads + .items + .iter() + .find(|&download| download.episode_id == episode.id); + let text = Text::from( + episode_details + .items + .iter() + .map(|line| { + let split = line.split(':').collect::>(); + let title = format!("{}:", split[0]); + let style = style_from_status(download, &episode); + + Line::from(vec![ + title.bold().style(style), + Span::styled(split[1..].join(":"), style), + ]) + }) + .collect::>>(), + ); + + let paragraph = Paragraph::new(text) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((episode_details.offset, 0)); + + f.render_widget(paragraph, area); + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading + || app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .is_none(), + block, + ), + area, + ), + } +} + +fn draw_file_info(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + match app.data.sonarr_data.season_details_modal.as_ref() { + Some(season_details_modal) => match season_details_modal.episode_details_modal.as_ref() { + Some(episode_details_modal) + if !episode_details_modal.file_details.is_empty() && !app.is_loading => + { + let file_info = episode_details_modal.file_details.to_owned(); + let audio_details = episode_details_modal.audio_details.to_owned(); + let video_details = episode_details_modal.video_details.to_owned(); + let [file_details_title_area, file_details_area, audio_details_title_area, audio_details_area, video_details_title_area, video_details_area] = + Layout::vertical([ + Constraint::Length(2), + Constraint::Length(5), + Constraint::Length(1), + Constraint::Length(6), + Constraint::Length(1), + Constraint::Length(7), + ]) + .areas(area); + + let file_details_title_paragraph = + Paragraph::new("File Details".bold()).block(layout_block_top_border()); + let audio_details_title_paragraph = + Paragraph::new("Audio Details".bold()).block(borderless_block()); + let video_details_title_paragraph = + Paragraph::new("Video Details".bold()).block(borderless_block()); + + let file_details = Text::from(file_info); + let audio_details = Text::from(audio_details); + let video_details = Text::from(video_details); + + let file_details_paragraph = Paragraph::new(file_details) + .block(layout_block_bottom_border()) + .wrap(Wrap { trim: false }); + let audio_details_paragraph = Paragraph::new(audio_details) + .block(layout_block_bottom_border()) + .wrap(Wrap { trim: false }); + let video_details_paragraph = Paragraph::new(video_details) + .block(borderless_block()) + .wrap(Wrap { trim: false }); + + f.render_widget(file_details_title_paragraph, file_details_title_area); + f.render_widget(file_details_paragraph, file_details_area); + f.render_widget(audio_details_title_paragraph, audio_details_title_area); + f.render_widget(audio_details_paragraph, audio_details_area); + f.render_widget(video_details_title_paragraph, video_details_title_area); + f.render_widget(video_details_paragraph, video_details_area); + } + _ => (), + }, + _ => f.render_widget( + LoadingBlock::new(app.is_loading, layout_block_top_border()), + area, + ), + } +} + +fn draw_episode_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + match app.data.sonarr_data.season_details_modal.as_ref() { + Some(season_details_modal) if !app.is_loading => { + if let Some(episode_details_modal) = season_details_modal.episode_details_modal.as_ref() { + let current_selection = if episode_details_modal.episode_history.is_empty() { + SonarrHistoryItem::default() + } else { + episode_details_modal + .episode_history + .current_selection() + .clone() + }; + let episode_history_table_footer = episode_details_modal + .episode_details_tabs + .get_active_tab_contextual_help(); + + let history_row_mapping = |history_item: &SonarrHistoryItem| { + let SonarrHistoryItem { + source_title, + language, + quality, + event_type, + date, + .. + } = history_item; + + source_title.scroll_left_or_reset( + get_width_from_percentage(area, 40), + current_selection == *history_item, + app.tick_count % app.ticks_until_scroll == 0, + ); + + Row::new(vec![ + Cell::from(source_title.to_string()), + Cell::from(event_type.to_string()), + Cell::from(language.name.to_owned()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let mut episode_history_table = &mut app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_history; + let history_table = + ManagarrTable::new(Some(&mut episode_history_table), history_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(episode_history_table_footer) + .headers(["Source Title", "Event Type", "Language", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(40), + Constraint::Percentage(15), + Constraint::Percentage(12), + Constraint::Percentage(13), + Constraint::Percentage(20), + ]); + + f.render_widget(history_table, area); + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading + || app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .is_none(), + layout_block_top_border(), + ), + area, + ), + } +} + +fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = + if let Some(season_details_modal) = app.data.sonarr_data.season_details_modal.as_ref() { + if let Some(episode_details_modal) = season_details_modal.episode_details_modal.as_ref() { + if episode_details_modal.episode_history.is_empty() { + SonarrHistoryItem::default() + } else { + episode_details_modal + .episode_history + .current_selection() + .clone() + } + } else { + SonarrHistoryItem::default() + } + } else { + SonarrHistoryItem::default() + }; + + let line_vec = match current_selection.event_type { + SonarrHistoryEventType::Grabbed => create_grabbed_history_event_details(current_selection), + SonarrHistoryEventType::DownloadFolderImported => { + create_download_folder_imported_history_event_details(current_selection) + } + SonarrHistoryEventType::DownloadFailed => { + create_download_failed_history_event_details(current_selection) + } + SonarrHistoryEventType::EpisodeFileDeleted => { + create_episode_file_deleted_history_event_details(current_selection) + } + SonarrHistoryEventType::EpisodeFileRenamed => { + create_episode_file_renamed_history_event_details(current_selection) + } + _ => create_no_data_history_event_details(current_selection), + }; + let text = Text::from(line_vec); + + let message = Message::new(text) + .title("Details") + .style(Style::new().secondary()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowMessage), area); +} + +fn draw_episode_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + match app.data.sonarr_data.season_details_modal.as_ref() { + Some(season_details_modal) if !app.is_loading => { + if let Some(episode_details_modal) = season_details_modal.episode_details_modal.as_ref() { + let (current_selection, is_empty) = if episode_details_modal.episode_releases.is_empty() { + (SonarrRelease::default(), true) + } else { + ( + episode_details_modal + .episode_releases + .current_selection() + .clone(), + episode_details_modal.episode_releases.is_empty(), + ) + }; + let episode_release_table_footer = episode_details_modal + .episode_details_tabs + .get_active_tab_contextual_help(); + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let episode_release_row_mapping = |release: &SonarrRelease| { + let SonarrRelease { + protocol, + age, + title, + indexer, + size, + rejected, + seeders, + leechers, + languages, + quality, + .. + } = release; + + let age = format!("{age} days"); + title.scroll_left_or_reset( + get_width_from_percentage(area, 30), + current_selection == *release + && active_sonarr_block != ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt, + app.tick_count % app.ticks_until_scroll == 0, + ); + let size = convert_to_gb(*size); + let rejected_str = if *rejected { "⛔" } else { "" }; + let peers = if seeders.is_none() || leechers.is_none() { + Text::from("") + } else { + let seeders = seeders.clone().unwrap().as_u64().unwrap(); + let leechers = leechers.clone().unwrap().as_u64().unwrap(); + + decorate_peer_style( + seeders, + leechers, + Text::from(format!("{seeders} / {leechers}")), + ) + }; + + let language = if languages.is_some() { + languages.clone().unwrap()[0].name.clone() + } else { + String::new() + }; + let quality = quality.quality.name.clone(); + + Row::new(vec![ + Cell::from(protocol.clone()), + Cell::from(age), + Cell::from(rejected_str), + Cell::from(title.to_string()), + Cell::from(indexer.clone()), + Cell::from(format!("{size:.1} GB")), + Cell::from(peers), + Cell::from(language), + Cell::from(quality), + ]) + .primary() + }; + let mut episode_release_table = &mut app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_releases; + let release_table = ManagarrTable::new( + Some(&mut episode_release_table), + episode_release_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading || is_empty) + .footer(episode_release_table_footer) + .sorting(active_sonarr_block == ActiveSonarrBlock::ManualEpisodeSearchSortPrompt) + .headers([ + "Source", "Age", "⛔", "Title", "Indexer", "Size", "Peers", "Language", "Quality", + ]) + .constraints([ + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(5), + Constraint::Percentage(30), + Constraint::Percentage(18), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Percentage(7), + Constraint::Percentage(10), + ]); + + f.render_widget(release_table, area); + } + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading + || app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .is_none(), + layout_block_top_border(), + ), + area, + ), + } +} + +fn draw_manual_episode_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_releases + .current_selection(); + let title = if current_selection.rejected { + "Download Rejected Release" + } else { + "Download Release" + }; + let prompt = if current_selection.rejected { + format!( + "Do you really want to download the rejected release: {}?", + ¤t_selection.title.text + ) + } else { + format!( + "Do you want to download the release: {}?", + ¤t_selection.title.text + ) + }; + + if current_selection.rejected { + let mut lines_vec = vec![Line::from("Rejection reasons: ".primary().bold())]; + let mut rejections_spans = current_selection + .rejections + .clone() + .unwrap_or_default() + .iter() + .map(|item| Line::from(format!("• {item}").primary().bold())) + .collect::>>(); + lines_vec.append(&mut rejections_spans); + + let content_paragraph = Paragraph::new(lines_vec) + .block(borderless_block()) + .wrap(Wrap { trim: false }) + .left_aligned(); + let confirmation_prompt = ConfirmationPrompt::new() + .title(title) + .prompt(&prompt) + .content(content_paragraph) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + f.render_widget(Popup::new(confirmation_prompt).size(Size::Small), f.area()); + } else { + let confirmation_prompt = ConfirmationPrompt::new() + .title(title) + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } +} + +fn style_from_status(download: Option<&DownloadRecord>, episode: &Episode) -> Style { + if !episode.has_file { + if let Some(download) = download { + if download.status == DownloadStatus::Downloading { + return Style::new().downloading(); + } + + if download.status == DownloadStatus::Completed { + return Style::new().awaiting_import(); + } + } + if !episode.monitored { + return Style::new().unmonitored_missing(); + } + + if let Some(air_date) = episode.air_date_utc.as_ref() { + if air_date > &Utc::now() { + return Style::new().unreleased(); + } + } + + return Style::new().missing(); + } + + if !episode.monitored { + Style::new().unmonitored() + } else { + Style::new().downloaded() + } +} diff --git a/src/ui/sonarr_ui/library/episode_details_ui_tests.rs b/src/ui/sonarr_ui/library/episode_details_ui_tests.rs new file mode 100644 index 0000000..21fea80 --- /dev/null +++ b/src/ui/sonarr_ui/library/episode_details_ui_tests.rs @@ -0,0 +1,18 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS}; + use crate::ui::sonarr_ui::library::episode_details_ui::EpisodeDetailsUi; + use crate::ui::DrawUi; + use strum::IntoEnumIterator; + + #[test] + fn test_episode_details_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if EPISODE_DETAILS_BLOCKS.contains(&active_sonarr_block) { + assert!(EpisodeDetailsUi::accepts(active_sonarr_block.into())); + } else { + assert!(!EpisodeDetailsUi::accepts(active_sonarr_block.into())); + } + }); + } +} \ No newline at end of file diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs index 874b38f..a3d9847 100644 --- a/src/ui/sonarr_ui/library/library_ui_tests.rs +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -1,9 +1,6 @@ #[cfg(test)] mod tests { - use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, - SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, - }; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS}; use crate::models::{ servarr_data::sonarr::sonarr_data::LIBRARY_BLOCKS, sonarr_models::SeriesStatus, }; @@ -28,6 +25,7 @@ mod tests { library_ui_blocks.extend(EDIT_SERIES_BLOCKS); library_ui_blocks.extend(SERIES_DETAILS_BLOCKS); library_ui_blocks.extend(SEASON_DETAILS_BLOCKS); + library_ui_blocks.extend(EPISODE_DETAILS_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { if library_ui_blocks.contains(&active_sonarr_block) { diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index 7fb879d..b805488 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -36,6 +36,7 @@ mod series_details_ui; #[path = "library_ui_tests.rs"] mod library_ui_tests; mod season_details_ui; +mod episode_details_ui; pub(super) struct LibraryUi; diff --git a/src/ui/sonarr_ui/library/season_details_ui.rs b/src/ui/sonarr_ui/library/season_details_ui.rs index 42191ac..97885ec 100644 --- a/src/ui/sonarr_ui/library/season_details_ui.rs +++ b/src/ui/sonarr_ui/library/season_details_ui.rs @@ -27,6 +27,7 @@ use ratatui::layout::{Alignment, Constraint, Rect}; use ratatui::prelude::{Line, Style, Stylize, Text}; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use ratatui::Frame; +use crate::ui::sonarr_ui::library::episode_details_ui::EpisodeDetailsUi; #[cfg(test)] #[path = "season_details_ui_tests.rs"] @@ -37,13 +38,14 @@ pub(super) struct SeasonDetailsUi; impl DrawUi for SeasonDetailsUi { fn accepts(route: Route) -> bool { if let Route::Sonarr(active_sonarr_block, _) = route { - return SEASON_DETAILS_BLOCKS.contains(&active_sonarr_block); + return EpisodeDetailsUi::accepts(route) || SEASON_DETAILS_BLOCKS.contains(&active_sonarr_block); } false } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + let route = app.get_current_route(); if app.data.sonarr_data.season_details_modal.is_some() { if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { let draw_season_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { @@ -111,6 +113,10 @@ impl DrawUi for SeasonDetailsUi { }; draw_popup(f, app, draw_season_details_popup, Size::XLarge); + + if EpisodeDetailsUi::accepts(route) { + EpisodeDetailsUi::draw(f, app, _area); + } } } } @@ -123,7 +129,7 @@ pub fn draw_season_details(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { { match active_sonarr_block { ActiveSonarrBlock::SeasonDetails => draw_episodes_table(f, app, area), - ActiveSonarrBlock::SeasonHistory => draw_episode_history_table(f, app, area), + ActiveSonarrBlock::SeasonHistory => draw_season_history_table(f, app, area), ActiveSonarrBlock::ManualSeasonSearch => draw_season_releases(f, app, area), _ => (), } @@ -234,7 +240,7 @@ fn draw_episodes_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } } -fn draw_episode_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { +fn draw_season_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { match app.data.sonarr_data.season_details_modal.as_ref() { Some(season_details_modal) if !app.is_loading => { let current_selection = if season_details_modal.season_history.is_empty() { diff --git a/src/ui/sonarr_ui/library/season_details_ui_tests.rs b/src/ui/sonarr_ui/library/season_details_ui_tests.rs index 64264fc..ea62998 100644 --- a/src/ui/sonarr_ui/library/season_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/season_details_ui_tests.rs @@ -2,16 +2,17 @@ mod tests { use strum::IntoEnumIterator; - use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, SEASON_DETAILS_BLOCKS, - }; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS}; use crate::ui::sonarr_ui::library::season_details_ui::SeasonDetailsUi; use crate::ui::DrawUi; #[test] fn test_season_details_ui_accepts() { + let mut blocks = SEASON_DETAILS_BLOCKS.clone().to_vec(); + blocks.extend(EPISODE_DETAILS_BLOCKS); + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { - if SEASON_DETAILS_BLOCKS.contains(&active_sonarr_block) { + if blocks.contains(&active_sonarr_block) { assert!(SeasonDetailsUi::accepts(active_sonarr_block.into())); } else { assert!(!SeasonDetailsUi::accepts(active_sonarr_block.into())); diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index ea135e1..159440b 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -30,6 +30,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{draw_popup, draw_tabs, DrawUi}; +use crate::ui::sonarr_ui::library::episode_details_ui::EpisodeDetailsUi; use crate::utils::convert_to_gb; #[cfg(test)] @@ -41,7 +42,7 @@ pub(super) struct SeriesDetailsUi; impl DrawUi for SeriesDetailsUi { fn accepts(route: Route) -> bool { if let Route::Sonarr(active_sonarr_block, _) = route { - return SeasonDetailsUi::accepts(route) || SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block); + return SeasonDetailsUi::accepts(route) || EpisodeDetailsUi::accepts(route) || SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block); } false diff --git a/src/ui/sonarr_ui/library/series_details_ui_tests.rs b/src/ui/sonarr_ui/library/series_details_ui_tests.rs index 0dc52da..ba960e7 100644 --- a/src/ui/sonarr_ui/library/series_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/series_details_ui_tests.rs @@ -2,7 +2,7 @@ mod tests { use strum::IntoEnumIterator; - use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS}; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS}; use crate::ui::sonarr_ui::library::series_details_ui::SeriesDetailsUi; use crate::ui::DrawUi; @@ -10,6 +10,7 @@ mod tests { fn test_series_details_ui_accepts() { let mut blocks = SERIES_DETAILS_BLOCKS.clone().to_vec(); blocks.extend(SEASON_DETAILS_BLOCKS); + blocks.extend(EPISODE_DETAILS_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { if blocks.contains(&active_sonarr_block) { From 82ce38d7b5186fd6e117e369d4e0e507b6533fa6 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 12 Dec 2024 18:52:27 -0700 Subject: [PATCH 66/82] feat(handlers): Support for the episode details popup --- Cargo.lock | 494 +++++++---- .../library/episode_details_handler.rs | 359 ++++++++ .../library/episode_details_handler_tests.rs | 771 ++++++++++++++++++ .../library/library_handler_tests.rs | 23 +- src/handlers/sonarr_handlers/library/mod.rs | 7 + .../library/season_details_handler.rs | 2 +- src/network/sonarr_network.rs | 40 +- src/network/sonarr_network_tests.rs | 116 +++ 8 files changed, 1618 insertions(+), 194 deletions(-) create mode 100644 src/handlers/sonarr_handlers/library/episode_details_handler.rs create mode 100644 src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs diff --git a/Cargo.lock b/Cargo.lock index b013f5b..94bcfc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "allocator-api2" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45862d1c77f2228b9e10bc609d5bc203d86ebc9b87ad8d5d5167a6c9abf739d9" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android-tzdata" @@ -98,9 +98,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.93" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" +checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" [[package]] name = "arc-swap" @@ -142,7 +142,7 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -195,9 +195,9 @@ checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "bstr" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a68f1f47cdf0ec8ee4b941b2eee2a80cb796db73118c0dd09ac63fbe405be22" +checksum = "786a307d683a5bf92e6fd5fd69a7eb613751668d1d8d67d802846dfe367c62c8" dependencies = [ "memchr", "regex-automata", @@ -218,15 +218,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cargo-husky" -version = "1.5.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b02b629252fe8ef6460461409564e2c21d0c8e77e0944f3d189ff06c4e932ad" +checksum = "fa108bb6da8de0669ab0fef3a4afabcc3446938b09b1ffe2e90486c75df8f215" [[package]] name = "cassowary" @@ -245,9 +245,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" +checksum = "27f657647bcff5394bf56c7317665bbf790a137a50eaaa5c6bfbb9e27a518f2d" dependencies = [ "shlex", ] @@ -281,9 +281,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.21" +version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb3b4b9e5a7c7514dfa52869339ee98b3156b0bfb4e8a77c4ff4babb64b1604f" +checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" dependencies = [ "clap_builder", "clap_derive", @@ -291,9 +291,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.21" +version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b17a95aa67cc7b5ebd32aa5370189aa0d79069ef1c64ce893bd30fb24bff20ec" +checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" dependencies = [ "anstream", "anstyle", @@ -303,9 +303,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.38" +version = "4.5.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9647a559c112175f17cf724dc72d3645680a883c58481332779192b0d8e7a01" +checksum = "9646e2e245bf62f45d39a0f3f36f1171ad1ea0d6967fd114bca72cb02a8fcdfb" dependencies = [ "clap", ] @@ -319,14 +319,14 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] name = "clap_lex" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afb84c814227b90d6895e01398aee0d8033c00e7466aca416fb6a8e0eb19d8a7" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "colorchoice" @@ -360,13 +360,13 @@ dependencies = [ [[package]] name = "confy" -version = "0.6.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45b1f4c00870f07dc34adcac82bb6a72cc5aabca8536ba1797e01df51d2ce9a0" +checksum = "15d296c475c6ed4093824c28e222420831d27577aaaf0a1163a3b7fc35b248a5" dependencies = [ "directories", "serde", - "serde_yaml", + "serde_yaml 0.9.16", "thiserror", ] @@ -407,7 +407,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", - "mio", + "mio 1.0.3", "parking_lot", "rustix", "signal-hook", @@ -424,6 +424,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "ctor" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096" +dependencies = [ + "quote", + "syn 1.0.109", +] + [[package]] name = "ctrlc" version = "3.4.5" @@ -455,7 +465,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -466,7 +476,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -498,7 +508,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -575,7 +585,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -619,19 +629,19 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "fastrand" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fnv" @@ -731,7 +741,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -793,6 +803,25 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "h2" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.7.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.7" @@ -804,14 +833,20 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", - "indexmap", + "http 1.2.0", + "indexmap 2.7.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.2" @@ -837,15 +872,37 @@ checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "http" -version = "1.1.0" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ "bytes", "fnv", "itoa", ] +[[package]] +name = "http" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -853,7 +910,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.2.0", ] [[package]] @@ -864,8 +921,8 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -903,6 +960,30 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "hyper" +version = "0.14.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c08302e8fa335b151b788c775ff56e7a03ae64ff85c548ee820fecb70356e85" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.5.1" @@ -912,11 +993,10 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.7", + "http 1.2.0", + "http-body 1.0.1", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -931,8 +1011,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ "futures-util", - "http", - "hyper", + "http 1.2.0", + "hyper 1.5.1", "hyper-util", "rustls", "rustls-pki-types", @@ -949,7 +1029,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.5.1", "hyper-util", "native-tls", "tokio", @@ -966,9 +1046,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.2.0", + "http-body 1.0.1", + "hyper 1.5.1", "pin-project-lite", "socket2", "tokio", @@ -1114,7 +1194,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -1146,12 +1226,22 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.6.0" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -1169,22 +1259,18 @@ dependencies = [ [[package]] name = "indoc" -version = "2.0.5" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" +checksum = "6fe2b9d82064e8a0226fddb3547f37f28eaa46d0fc210e275d835f08cf3b76a7" [[package]] name = "instability" -version = "0.3.3" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b829f37dead9dc39df40c2d3376c179fdfd2ac771f53f55d3c30dc096a3c0c6e" +checksum = "b23a0c8dfe501baac4adf6ebbfa6eddf8f0c07f56b058cc1288017e32397846c" dependencies = [ - "darling", - "indoc", - "pretty_assertions", - "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -1216,10 +1302,11 @@ checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "js-sys" -version = "0.3.72" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -1231,9 +1318,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.165" +version = "0.2.168" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcb4d3d38eab6c5239a362fa8bae48c03baf980a6e7079f063942d563ef3533e" +checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" [[package]] name = "libredox" @@ -1245,6 +1332,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -1269,10 +1362,11 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" dependencies = [ + "cfg-if", "serde", ] @@ -1284,9 +1378,9 @@ checksum = "a94d21414c1f4a51209ad204c1776a3d0765002c76c6abcb602a6f09f1e881c7" [[package]] name = "log4rs" -version = "1.3.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0816135ae15bd0391cf284eab37e6e3ee0a6ee63d2ceeb659862bd8d0a984ca6" +checksum = "d36ca1786d9e79b8193a68d480a0907b612f109537115c6ff655a3a1967533fd" dependencies = [ "anyhow", "arc-swap", @@ -1297,13 +1391,11 @@ dependencies = [ "libc", "log", "log-mdc", - "once_cell", "parking_lot", - "rand", "serde", "serde-value", "serde_json", - "serde_yaml", + "serde_yaml 0.8.26", "thiserror", "thread-id", "typemap-ors", @@ -1316,7 +1408,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -1357,7 +1449,7 @@ dependencies = [ "rstest", "serde", "serde_json", - "serde_yaml", + "serde_yaml 0.9.16", "strum", "strum_macros", "tokio", @@ -1398,11 +1490,21 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.2" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ - "hermit-abi", "libc", "log", "wasi", @@ -1411,9 +1513,9 @@ dependencies = [ [[package]] name = "mockall" -version = "0.13.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +checksum = "d4c28b3fb6d753d28c20e826cd46ee611fda1cf3cde03a443a974043247c065a" dependencies = [ "cfg-if", "downcast", @@ -1425,31 +1527,27 @@ dependencies = [ [[package]] name = "mockall_derive" -version = "0.13.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +checksum = "341014e7f530314e9a1fdbc7400b244efea7122662c96bfa248c31da5bfb2020" dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] name = "mockito" -version = "1.6.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "652cd6d169a36eaf9d1e6bce1a221130439a966d7f27858af66a33a66e9c4ee2" +checksum = "8c1eecc3baf782e3c8d6803cc8780268da1f32df6eb88c016c1d80b0df7944cf" dependencies = [ "assert-json-diff", - "bytes", "colored", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", + "futures", + "hyper 0.14.31", + "lazy_static", "log", "rand", "regex", @@ -1503,6 +1601,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -1556,7 +1664,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -1594,15 +1702,24 @@ dependencies = [ [[package]] name = "os_info" -version = "3.8.2" +version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae99c7fa6dd38c7cafe1ec085e804f8f555a2f8659b0dbe03f1f9963a9b51092" +checksum = "e5ca711d8b83edbb00b44d504503cd247c9c0bd8b0fa2694f2a1a3d8165379ce" dependencies = [ "log", "serde", "windows-sys 0.52.0", ] +[[package]] +name = "output_vt100" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628223faebab4e3e40667ee0b2336d34a5b960ff60ea743ddfdbcf7770bcfb66" +dependencies = [ + "winapi", +] + [[package]] name = "parking_lot" version = "0.12.3" @@ -1706,11 +1823,13 @@ dependencies = [ [[package]] name = "pretty_assertions" -version = "1.4.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +checksum = "a25e9bcb20aa780fd0bb16b72403a9064d6b3f22f026946029acb941a50af755" dependencies = [ + "ctor", "diff", + "output_vt100", "yansi", ] @@ -1795,9 +1914,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ "bitflags", ] @@ -1859,11 +1978,11 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.7", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.5.1", "hyper-rustls", "hyper-tls", "hyper-util", @@ -1932,7 +2051,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.89", + "syn 2.0.90", "unicode-ident", ] @@ -1953,22 +2072,22 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.41" +version = "0.38.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7f649912bc1495e167a6edee79151c84b1bad49748cb4f1f1167f459f6224f6" +checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "rustls" -version = "0.23.18" +version = "0.23.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9cc1d47e243d655ace55ed38201c19ae02c148ae56412ab8750e8f0166ab7f" +checksum = "5065c3f250cbd332cd894be57c40fa52387247659b14a2d6041d121547903b1b" dependencies = [ "once_cell", "rustls-pki-types", @@ -2055,15 +2174,15 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" [[package]] name = "serde" -version = "1.0.215" +version = "1.0.214" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" dependencies = [ "serde_derive", ] @@ -2080,23 +2199,22 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.215" +version = "1.0.214" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] name = "serde_json" -version = "1.0.133" +version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +checksum = "877c235533714907a8c2464236f5c4b2a17262ef1bd71f38f35ea592c8da6883" dependencies = [ "itoa", - "memchr", "ryu", "serde", ] @@ -2124,11 +2242,23 @@ dependencies = [ [[package]] name = "serde_yaml" -version = "0.9.34+deprecated" +version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b" dependencies = [ - "indexmap", + "indexmap 1.9.3", + "ryu", + "serde", + "yaml-rust", +] + +[[package]] +name = "serde_yaml" +version = "0.9.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92b5b431e8907b50339b51223b97d102db8d987ced36f6e4d03621db9316c834" +dependencies = [ + "indexmap 1.9.3", "itoa", "ryu", "serde", @@ -2158,7 +2288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio", + "mio 1.0.3", "signal-hook", ] @@ -2194,9 +2324,9 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" dependencies = [ "libc", "windows-sys 0.52.0", @@ -2245,7 +2375,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -2267,9 +2397,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.89" +version = "2.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" dependencies = [ "proc-macro2", "quote", @@ -2293,7 +2423,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -2353,7 +2483,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -2368,9 +2498,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.36" +version = "0.3.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", "libc", @@ -2399,31 +2529,32 @@ dependencies = [ [[package]] name = "tokio" -version = "1.41.1" +version = "1.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" +checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 0.8.11", + "num_cpus", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.48.0", ] [[package]] name = "tokio-macros" -version = "2.4.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -2438,26 +2569,26 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.0" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" dependencies = [ "rustls", - "rustls-pki-types", "tokio", ] [[package]] name = "tokio-util" -version = "0.7.12" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", + "tracing", ] [[package]] @@ -2487,7 +2618,7 @@ version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ - "indexmap", + "indexmap 2.7.0", "serde", "serde_spanned", "toml_datetime", @@ -2502,9 +2633,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", "tracing-core", @@ -2603,9 +2734,9 @@ dependencies = [ [[package]] name = "urlencoding" -version = "2.1.3" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9" [[package]] name = "utf16_iter" @@ -2666,9 +2797,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" dependencies = [ "cfg-if", "once_cell", @@ -2677,36 +2808,36 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.45" +version = "0.4.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" +checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2" dependencies = [ "cfg-if", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2714,28 +2845,28 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" +checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" [[package]] name = "web-sys" -version = "0.3.72" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" dependencies = [ "js-sys", "wasm-bindgen", @@ -2982,10 +3113,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] -name = "yansi" -version = "1.0.1" +name = "yaml-rust" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" [[package]] name = "yoke" @@ -3007,7 +3147,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", "synstructure", ] @@ -3029,7 +3169,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -3049,7 +3189,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", "synstructure", ] @@ -3078,5 +3218,5 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] diff --git a/src/handlers/sonarr_handlers/library/episode_details_handler.rs b/src/handlers/sonarr_handlers/library/episode_details_handler.rs new file mode 100644 index 0000000..026fa11 --- /dev/null +++ b/src/handlers/sonarr_handlers/library/episode_details_handler.rs @@ -0,0 +1,359 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::App; +use crate::event::Key; +use crate::handle_table_events; +use crate::handlers::sonarr_handlers::library::season_details_handler::releases_sorting_options; +use crate::handlers::table_handler::TableHandlingConfig; +use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS}; +use crate::models::sonarr_models::{SonarrHistoryItem, SonarrRelease, SonarrReleaseDownloadBody}; +use crate::network::sonarr_network::SonarrEvent; + +#[cfg(test)] +#[path = "episode_details_handler_tests.rs"] +mod episode_details_handler_tests; + +pub(super) struct EpisodeDetailsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> EpisodeDetailsHandler<'a, 'b> { + handle_table_events!( + self, + episode_history, + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .expect("Season details modal is undefined") + .episode_details_modal + .as_mut() + .expect("Episode details modal is undefined") + .episode_history, + SonarrHistoryItem + ); + handle_table_events!( + self, + episode_releases, + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .expect("Season details modal is undefined") + .episode_details_modal + .as_mut() + .expect("Episode details modal is undefined") + .episode_releases, + SonarrRelease + ); +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EpisodeDetailsHandler<'a, 'b> { + fn handle(&mut self) { + let episode_history_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::EpisodeHistory.into()); + let episode_releases_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::ManualEpisodeSearch.into()) + .sorting_block(ActiveSonarrBlock::ManualEpisodeSearchSortPrompt.into()) + .sort_options(releases_sorting_options()); + + if !self.handle_episode_history_table_events(episode_history_table_handling_config) + && !self.handle_episode_releases_table_events(episode_releases_table_handling_config) + { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveSonarrBlock) -> bool { + EPISODE_DETAILS_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, + ) -> Self { + Self { + key, + app, + active_sonarr_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + && if let Some(season_details_modal) = self.app.data.sonarr_data.season_details_modal.as_ref() + { + if let Some(episode_details_modal) = &season_details_modal.episode_details_modal { + match self.active_sonarr_block { + ActiveSonarrBlock::EpisodeHistory => !episode_details_modal.episode_history.is_empty(), + ActiveSonarrBlock::ManualEpisodeSearch => { + !episode_details_modal.episode_releases.is_empty() + } + _ => true, + } + } else { + false + } + } else { + false + } + } + + 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) { + match self.active_sonarr_block { + ActiveSonarrBlock::EpisodeDetails + | ActiveSonarrBlock::EpisodeHistory + | ActiveSonarrBlock::EpisodeFile + | ActiveSonarrBlock::ManualEpisodeSearch => match self.key { + _ if self.key == DEFAULT_KEYBINDINGS.left.key => { + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_details_tabs + .previous(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_details_tabs + .get_active_route(), + ); + } + _ if self.key == DEFAULT_KEYBINDINGS.right.key => { + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_details_tabs + .next(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_details_tabs + .get_active_route(), + ); + } + _ => (), + }, + ActiveSonarrBlock::AutomaticallySearchEpisodePrompt + | ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt => { + handle_prompt_toggle(self.app, self.key); + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EpisodeHistory => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::EpisodeHistoryDetails.into()); + } + ActiveSonarrBlock::AutomaticallySearchEpisodePrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::TriggerAutomaticEpisodeSearch(None)); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::ManualEpisodeSearch => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt.into()); + } + ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + let SonarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_releases + .current_selection(); + let episode_id = self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .current_selection() + .id; + let params = SonarrReleaseDownloadBody { + guid: guid.clone(), + indexer_id: *indexer_id, + episode_id: Some(episode_id), + ..SonarrReleaseDownloadBody::default() + }; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DownloadRelease(params)); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::EpisodeDetails + | ActiveSonarrBlock::EpisodeFile + | ActiveSonarrBlock::EpisodeHistory + | ActiveSonarrBlock::ManualEpisodeSearch => { + self.app.pop_navigation_stack(); + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = None; + } + ActiveSonarrBlock::EpisodeHistoryDetails => { + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::AutomaticallySearchEpisodePrompt + | ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.prompt_confirm = false; + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_sonarr_block { + ActiveSonarrBlock::EpisodeDetails + | ActiveSonarrBlock::EpisodeHistory + | ActiveSonarrBlock::EpisodeFile + | ActiveSonarrBlock::ManualEpisodeSearch => match self.key { + _ if self.key == DEFAULT_KEYBINDINGS.refresh.key => { + self + .app + .pop_and_push_navigation_stack(self.active_sonarr_block.into()); + } + _ if self.key == DEFAULT_KEYBINDINGS.auto_search.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::AutomaticallySearchEpisodePrompt.into()); + } + _ => (), + }, + ActiveSonarrBlock::AutomaticallySearchEpisodePrompt + if key == DEFAULT_KEYBINDINGS.confirm.key => + { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::TriggerAutomaticEpisodeSearch(None)); + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt + if key == DEFAULT_KEYBINDINGS.confirm.key => + { + if self.app.data.sonarr_data.prompt_confirm { + let SonarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_releases + .current_selection(); + let episode_id = self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .current_selection() + .id; + let params = SonarrReleaseDownloadBody { + guid: guid.clone(), + indexer_id: *indexer_id, + episode_id: Some(episode_id), + ..SonarrReleaseDownloadBody::default() + }; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DownloadRelease(params)); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } +} diff --git a/src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs new file mode 100644 index 0000000..2bf88bf --- /dev/null +++ b/src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs @@ -0,0 +1,771 @@ +#[cfg(test)] +mod tests { + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::handlers::sonarr_handlers::library::episode_details_handler::EpisodeDetailsHandler; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::modals::EpisodeDetailsModal; + use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, + }; + use crate::models::sonarr_models::SonarrReleaseDownloadBody; + use crate::models::stateful_table::StatefulTable; + use rstest::rstest; + use strum::IntoEnumIterator; + + mod test_handle_left_right_actions { + use super::*; + use crate::event::Key; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_left_right_prompt_toggle( + #[values( + ActiveSonarrBlock::AutomaticallySearchEpisodePrompt, + ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt + )] + active_sonarr_block: ActiveSonarrBlock, + #[values(Key::Left, Key::Right)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); + + EpisodeDetailsHandler::with(key, &mut app, active_sonarr_block, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + EpisodeDetailsHandler::with(key, &mut app, active_sonarr_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[rstest] + #[case(ActiveSonarrBlock::EpisodeDetails, ActiveSonarrBlock::EpisodeHistory)] + #[case(ActiveSonarrBlock::EpisodeHistory, ActiveSonarrBlock::EpisodeFile)] + #[case(ActiveSonarrBlock::EpisodeFile, ActiveSonarrBlock::ManualEpisodeSearch)] + #[case( + ActiveSonarrBlock::ManualEpisodeSearch, + ActiveSonarrBlock::EpisodeDetails + )] + fn test_episode_details_tabs_left_right_action( + #[case] left_block: ActiveSonarrBlock, + #[case] right_block: ActiveSonarrBlock, + #[values(true, false)] is_ready: bool, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.is_loading = is_ready; + app.push_navigation_stack(right_block.into()); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_details_tabs + .index = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_details_tabs + .tabs + .iter() + .position(|tab_route| tab_route.route == right_block.into()) + .unwrap_or_default(); + + EpisodeDetailsHandler::with(DEFAULT_KEYBINDINGS.left.key, &mut app, right_block, None) + .handle(); + + assert_eq!( + app.get_current_route(), + app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_details_tabs + .get_active_route() + ); + assert_eq!(app.get_current_route(), left_block.into()); + + EpisodeDetailsHandler::with(DEFAULT_KEYBINDINGS.right.key, &mut app, left_block, None) + .handle(); + + assert_eq!( + app.get_current_route(), + app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_details_tabs + .get_active_route() + ); + assert_eq!(app.get_current_route(), right_block.into()); + } + } + + mod test_handle_submit { + use super::*; + use crate::event::Key; + use crate::models::stateful_table::StatefulTable; + use crate::network::sonarr_network::SonarrEvent; + use pretty_assertions::assert_eq; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_episode_history_submit() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + + EpisodeDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EpisodeHistory, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EpisodeHistoryDetails.into() + ); + } + + #[test] + fn test_episode_history_submit_no_op_when_episode_history_is_empty() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_history = StatefulTable::default(); + app.push_navigation_stack(ActiveSonarrBlock::EpisodeHistory.into()); + + EpisodeDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EpisodeHistory, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EpisodeHistory.into() + ); + } + + #[test] + fn test_episode_history_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::EpisodeHistory.into()); + + EpisodeDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::EpisodeHistory, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EpisodeHistory.into() + ); + } + + #[rstest] + #[case( + ActiveSonarrBlock::AutomaticallySearchEpisodePrompt, + SonarrEvent::TriggerAutomaticEpisodeSearch(None) + )] + fn test_episode_details_prompt_confirm_submit( + #[case] prompt_block: ActiveSonarrBlock, + #[case] expected_action: SonarrEvent, + #[values( + ActiveSonarrBlock::EpisodeDetails, + ActiveSonarrBlock::EpisodeHistory, + ActiveSonarrBlock::EpisodeFile, + ActiveSonarrBlock::ManualEpisodeSearch + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(active_sonarr_block.into()); + app.push_navigation_stack(prompt_block.into()); + + EpisodeDetailsHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(expected_action) + ); + } + + #[test] + fn test_manual_episode_search_confirm_prompt_confirm_submit() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearch.into()); + app.push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt.into()); + + EpisodeDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::ManualEpisodeSearch.into() + ); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody { + guid: String::new(), + indexer_id: 0, + episode_id: Some(0), + ..SonarrReleaseDownloadBody::default() + })) + ); + } + + #[rstest] + fn test_episode_details_prompt_decline_submit( + #[values( + ActiveSonarrBlock::AutomaticallySearchEpisodePrompt, + ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt + )] + prompt_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + EpisodeDetailsHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EpisodeDetails.into() + ); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + } + + #[test] + fn test_manual_episode_search_submit() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearch.into()); + + EpisodeDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::ManualEpisodeSearch, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt.into() + ); + } + + #[test] + fn test_manual_episode_search_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearch.into()); + + EpisodeDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::ManualEpisodeSearch, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::ManualEpisodeSearch.into() + ); + } + } + + mod test_handle_esc { + use super::*; + use crate::event::Key; + use pretty_assertions::assert_eq; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_episode_history_details_block_esc() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::EpisodeHistory.into()); + app.push_navigation_stack(ActiveSonarrBlock::EpisodeHistoryDetails.into()); + + EpisodeDetailsHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::EpisodeHistoryDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EpisodeHistory.into() + ); + } + + #[rstest] + fn test_episode_details_prompts_esc( + #[values( + ActiveSonarrBlock::AutomaticallySearchEpisodePrompt, + ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt + )] + prompt_block: ActiveSonarrBlock, + #[values(true, false)] is_ready: bool, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.is_loading = is_ready; + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + EpisodeDetailsHandler::with(ESC_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EpisodeDetails.into() + ); + } + + #[rstest] + fn test_episode_details_tabs_esc( + #[values( + ActiveSonarrBlock::EpisodeDetails, + ActiveSonarrBlock::EpisodeHistory, + ActiveSonarrBlock::EpisodeFile, + ActiveSonarrBlock::ManualEpisodeSearch + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.push_navigation_stack(active_sonarr_block.into()); + + EpisodeDetailsHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonDetails.into() + ); + assert!(app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .is_none()); + } + } + + mod test_handle_key_char { + use super::*; + use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; + use crate::network::sonarr_network::SonarrEvent; + use pretty_assertions::assert_eq; + + #[rstest] + fn test_auto_search_key( + #[values( + ActiveSonarrBlock::EpisodeDetails, + ActiveSonarrBlock::EpisodeHistory, + ActiveSonarrBlock::EpisodeFile, + ActiveSonarrBlock::ManualEpisodeSearch + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(active_sonarr_block.into()); + + EpisodeDetailsHandler::with( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AutomaticallySearchEpisodePrompt.into() + ); + } + + #[rstest] + fn test_auto_search_key_no_op_when_not_ready( + #[values( + ActiveSonarrBlock::EpisodeDetails, + ActiveSonarrBlock::EpisodeHistory, + ActiveSonarrBlock::EpisodeFile, + ActiveSonarrBlock::ManualEpisodeSearch + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(active_sonarr_block.into()); + + EpisodeDetailsHandler::with( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + } + + #[rstest] + fn test_refresh_key( + #[values( + ActiveSonarrBlock::EpisodeDetails, + ActiveSonarrBlock::EpisodeHistory, + ActiveSonarrBlock::EpisodeFile, + ActiveSonarrBlock::ManualEpisodeSearch + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(active_sonarr_block.into()); + app.is_routing = false; + + EpisodeDetailsHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + assert!(app.is_routing); + } + + #[rstest] + fn test_refresh_key_no_op_when_not_ready( + #[values( + ActiveSonarrBlock::EpisodeDetails, + ActiveSonarrBlock::EpisodeHistory, + ActiveSonarrBlock::EpisodeFile, + ActiveSonarrBlock::ManualEpisodeSearch + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.is_loading = true; + app.push_navigation_stack(active_sonarr_block.into()); + app.is_routing = false; + + EpisodeDetailsHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + assert!(!app.is_routing); + } + + #[rstest] + fn test_episode_details_prompt_confirm_confirm_key( + #[values( + ActiveSonarrBlock::EpisodeDetails, + ActiveSonarrBlock::EpisodeHistory, + ActiveSonarrBlock::EpisodeFile, + ActiveSonarrBlock::ManualEpisodeSearch + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(active_sonarr_block.into()); + app.push_navigation_stack(ActiveSonarrBlock::AutomaticallySearchEpisodePrompt.into()); + + EpisodeDetailsHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveSonarrBlock::AutomaticallySearchEpisodePrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::TriggerAutomaticEpisodeSearch(None)) + ); + } + + #[test] + fn test_episode_details_manual_search_confirm_prompt_confirm_confirm_key() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearch.into()); + app.push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt.into()); + + EpisodeDetailsHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::ManualEpisodeSearch.into() + ); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody { + guid: String::new(), + indexer_id: 0, + episode_id: Some(0), + ..SonarrReleaseDownloadBody::default() + })) + ); + } + } + + #[test] + fn test_episode_details_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if EPISODE_DETAILS_BLOCKS.contains(&active_sonarr_block) { + assert!(EpisodeDetailsHandler::accepts(active_sonarr_block)); + } else { + assert!(!EpisodeDetailsHandler::accepts(active_sonarr_block)); + } + }); + } + + #[test] + fn test_episode_details_handler_is_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); + app.is_loading = true; + + let handler = EpisodeDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::EpisodeDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_episode_details_handler_is_not_ready_when_season_details_modal_is_empty() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); + app.is_loading = false; + + let handler = EpisodeDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::EpisodeDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_episode_details_handler_is_not_ready_when_episode_details_modal_is_empty() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = None; + app.push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); + app.is_loading = false; + + let handler = EpisodeDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::EpisodeDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_episode_details_handler_is_not_ready_when_episode_history_table_is_empty() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_history = StatefulTable::default(); + app.push_navigation_stack(ActiveSonarrBlock::EpisodeHistory.into()); + app.is_loading = false; + + let handler = EpisodeDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::EpisodeHistory, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_episode_details_handler_is_not_ready_when_episode_releases_table_is_empty() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_releases = StatefulTable::default(); + app.push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearch.into()); + app.is_loading = false; + + let handler = EpisodeDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::ManualEpisodeSearch, + None, + ); + + assert!(!handler.is_ready()); + } + + #[rstest] + fn test_episode_details_handler_is_ready_with_empty_tables_for_details_and_file_routes( + #[values(ActiveSonarrBlock::EpisodeDetails, ActiveSonarrBlock::EpisodeFile)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + app.push_navigation_stack(active_sonarr_block.into()); + app.is_loading = false; + + let handler = EpisodeDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + active_sonarr_block, + None, + ); + + assert!(handler.is_ready()); + } + + #[rstest] + fn test_episode_details_handler_is_ready( + #[values( + ActiveSonarrBlock::EpisodeDetails, + ActiveSonarrBlock::EpisodeFile, + ActiveSonarrBlock::EpisodeHistory, + ActiveSonarrBlock::ManualEpisodeSearch + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(active_sonarr_block.into()); + app.is_loading = false; + + let handler = EpisodeDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + active_sonarr_block, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/sonarr_handlers/library/library_handler_tests.rs b/src/handlers/sonarr_handlers/library/library_handler_tests.rs index 6b73c88..30577ea 100644 --- a/src/handlers/sonarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/library_handler_tests.rs @@ -10,7 +10,7 @@ mod tests { use crate::event::Key; use crate::handlers::sonarr_handlers::library::{series_sorting_options, LibraryHandler}; use crate::handlers::KeyEventHandler; - use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, LIBRARY_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS}; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, EPISODE_DETAILS_BLOCKS, LIBRARY_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS}; use crate::models::sonarr_models::{Series, SeriesStatus, SeriesType}; use crate::test_handler_delegation; @@ -567,6 +567,26 @@ mod tests { ); } + #[rstest] + fn test_delegates_episode_details_blocks_to_season_details_handler( + #[values( + ActiveSonarrBlock::EpisodeDetails, + ActiveSonarrBlock::EpisodeHistory, + ActiveSonarrBlock::AutomaticallySearchEpisodePrompt, + ActiveSonarrBlock::EpisodeHistoryDetails, + ActiveSonarrBlock::ManualEpisodeSearch, + ActiveSonarrBlock::ManualEpisodeSearchSortPrompt, + ActiveSonarrBlock::DeleteEpisodeFilePrompt, + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + LibraryHandler, + ActiveSonarrBlock::Series, + active_sonarr_block + ); + } + #[rstest] fn test_delegates_edit_series_blocks_to_edit_series_handler( #[values( @@ -793,6 +813,7 @@ mod tests { library_handler_blocks.extend(EDIT_SERIES_BLOCKS); library_handler_blocks.extend(SERIES_DETAILS_BLOCKS); library_handler_blocks.extend(SEASON_DETAILS_BLOCKS); + library_handler_blocks.extend(EPISODE_DETAILS_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { if library_handler_blocks.contains(&active_sonarr_block) { diff --git a/src/handlers/sonarr_handlers/library/mod.rs b/src/handlers/sonarr_handlers/library/mod.rs index 98b1d56..7f01a39 100644 --- a/src/handlers/sonarr_handlers/library/mod.rs +++ b/src/handlers/sonarr_handlers/library/mod.rs @@ -22,6 +22,7 @@ use crate::{ use super::handle_change_tab_left_right_keys; use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::handlers::sonarr_handlers::library::episode_details_handler::EpisodeDetailsHandler; use crate::handlers::sonarr_handlers::library::season_details_handler::SeasonDetailsHandler; use crate::handlers::sonarr_handlers::library::series_details_handler::SeriesDetailsHandler; use crate::handlers::table_handler::TableHandlingConfig; @@ -34,6 +35,7 @@ mod delete_series_handler; mod library_handler_tests; mod series_details_handler; mod season_details_handler; +mod episode_details_handler; pub(super) struct LibraryHandler<'a, 'b> { key: Key, @@ -81,6 +83,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' SeasonDetailsHandler::with(self.key, self.app, self.active_sonarr_block, self.context) .handle(); } + _ if EpisodeDetailsHandler::accepts(self.active_sonarr_block) => { + EpisodeDetailsHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle(); + } _ => self.handle_key_event(), } } @@ -92,6 +98,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' || EditSeriesHandler::accepts(active_block) || SeriesDetailsHandler::accepts(active_block) || SeasonDetailsHandler::accepts(active_block) + || EpisodeDetailsHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block) } diff --git a/src/handlers/sonarr_handlers/library/season_details_handler.rs b/src/handlers/sonarr_handlers/library/season_details_handler.rs index 6f90701..6b159c9 100644 --- a/src/handlers/sonarr_handlers/library/season_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/season_details_handler.rs @@ -409,7 +409,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeasonDetailsHandler } } -fn releases_sorting_options() -> Vec> { +pub(in crate::handlers::sonarr_handlers::library) fn releases_sorting_options() -> Vec> { vec![ SortOption { name: "Source", diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 749d7ca..5d0a622 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -1542,6 +1542,28 @@ impl<'a, 'b> Network<'a, 'b> { self .handle_request::<(), Episode>(request_props, |episode_response, mut app| { + if app.cli_mode { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + if app + .data + .sonarr_data + .season_details_modal + .as_mut() + .expect("Season details modal is empty") + .episode_details_modal + .is_none() + { + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + } + let Episode { id, title, @@ -1559,8 +1581,8 @@ impl<'a, 'b> Network<'a, 'b> { } else { String::new() }; - let mut episode_details_modal = EpisodeDetailsModal { - episode_details: ScrollableText::with_string(formatdoc!( + let episode_details_modal = app.data.sonarr_data.season_details_modal.as_mut().unwrap().episode_details_modal.as_mut().unwrap(); + episode_details_modal.episode_details = ScrollableText::with_string(formatdoc!( " Title: {} Season: {season_number} @@ -1570,9 +1592,7 @@ impl<'a, 'b> Network<'a, 'b> { Description: {}", title, overview.unwrap_or_default(), - )), - ..EpisodeDetailsModal::default() - }; + )); if let Some(file) = episode_file { let size = convert_to_gb(file.size); episode_details_modal.file_details = formatdoc!( @@ -1624,16 +1644,6 @@ impl<'a, 'b> Network<'a, 'b> { ); } }; - - if !app.cli_mode { - app - .data - .sonarr_data - .season_details_modal - .as_mut() - .expect("Season details modal is empty") - .episode_details_modal = Some(episode_details_modal); - } }) .await } diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 7280e87..31512f1 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -3147,6 +3147,122 @@ mod test { #[tokio::test] async fn test_handle_get_episode_details_event() { + let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(None), + Some("/1"), + None, + ) + .await; + let mut episode_details_modal = EpisodeDetailsModal::default(); + episode_details_modal.episode_details_tabs.next(); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + season_details_modal.episode_details_modal = Some(episode_details_modal); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episode(episode) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_details_tabs + .get_active_route(), + ActiveSonarrBlock::EpisodeHistory.into() + ); + assert_eq!(episode, response); + + let app = app_arc.lock().await; + let episode_details_modal = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap(); + assert_str_eq!( + episode_details_modal.episode_details.get_text(), + formatdoc!( + "Title: Something cool + Season: 1 + Episode Number: 1 + Air Date: 2024-02-10 07:28:45 UTC + Status: Downloaded + Description: Okay so this one time at band camp..." + ) + ); + assert_str_eq!( + episode_details_modal.file_details, + formatdoc!( + "Relative Path: /season 1/episode 1.mkv + Absolute Path: /nfs/tv/series/season 1/episode 1.mkv + Size: 3.30 GB + Language: English + Date Added: 2024-02-10 07:28:45 UTC" + ) + ); + assert_str_eq!( + episode_details_modal.audio_details, + formatdoc!( + "Bitrate: 0 + Channels: 7.1 + Codec: AAC + Languages: eng + Stream Count: 1" + ) + ); + assert_str_eq!( + episode_details_modal.video_details, + formatdoc!( + "Bit Depth: 10 + Bitrate: 0 + Codec: x265 + FPS: 23.976 + Resolution: 1920x1080 + Scan Type: Progressive + Runtime: 23:51 + Subtitles: English" + ) + ); + } + } + + #[tokio::test] + async fn test_handle_get_episode_details_event_empty_episode_details_modal() { let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap(); let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, From 39f8ad210610434dd3e345e0ee2fe3512ffc8392 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 13 Dec 2024 11:51:23 -0700 Subject: [PATCH 67/82] refactor: Fixed a couple of typos in some test function names --- src/app/sonarr/sonarr_tests.rs | 2 +- src/ui/sonarr_ui/library/edit_series_ui_tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index 28942ab..35245ef 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -487,7 +487,7 @@ mod tests { } #[tokio::test] - async fn test_dispatch_by_add_movie_search_results_block() { + async fn test_dispatch_by_add_series_search_results_block() { let (mut app, mut sync_network_rx) = construct_app_unit(); app diff --git a/src/ui/sonarr_ui/library/edit_series_ui_tests.rs b/src/ui/sonarr_ui/library/edit_series_ui_tests.rs index d201aec..c138fa1 100644 --- a/src/ui/sonarr_ui/library/edit_series_ui_tests.rs +++ b/src/ui/sonarr_ui/library/edit_series_ui_tests.rs @@ -7,7 +7,7 @@ mod tests { use crate::ui::DrawUi; #[test] - fn test_edit_movie_ui_accepts() { + fn test_edit_series_ui_accepts() { ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { if EDIT_SERIES_BLOCKS.contains(&active_sonarr_block) { assert!(EditSeriesUi::accepts(active_sonarr_block.into())); From 98619664cfe6b146267f9b0e5f28f0af18d99c48 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 13 Dec 2024 13:10:57 -0700 Subject: [PATCH 68/82] refactor(ui): Tweaked some of the color schemes in the series table --- src/app/sonarr/mod.rs | 6 --- src/app/sonarr/sonarr_tests.rs | 16 ------- .../sonarr_ui/library/episode_details_ui.rs | 2 +- src/ui/sonarr_ui/library/library_ui_tests.rs | 47 ++++++++++++++----- src/ui/sonarr_ui/library/mod.rs | 16 ++++--- src/ui/sonarr_ui/library/series_details_ui.rs | 20 ++++++-- 6 files changed, 60 insertions(+), 47 deletions(-) diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index d8ae5fb..30f042a 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -65,12 +65,6 @@ impl<'a> App<'a> { } } ActiveSonarrBlock::EpisodeDetails | ActiveSonarrBlock::EpisodeFile => { - self - .dispatch_network_event(SonarrEvent::GetEpisodes(None).into()) - .await; - self - .dispatch_network_event(SonarrEvent::GetDownloads.into()) - .await; self .dispatch_network_event(SonarrEvent::GetEpisodeDetails(None).into()) .await; diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index 35245ef..df7b438 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -176,14 +176,6 @@ mod tests { .await; assert!(app.is_loading); - assert_eq!( - sync_network_rx.recv().await.unwrap(), - SonarrEvent::GetEpisodes(None).into() - ); - assert_eq!( - sync_network_rx.recv().await.unwrap(), - SonarrEvent::GetDownloads.into() - ); assert_eq!( sync_network_rx.recv().await.unwrap(), SonarrEvent::GetEpisodeDetails(None).into() @@ -201,14 +193,6 @@ mod tests { .await; assert!(app.is_loading); - assert_eq!( - sync_network_rx.recv().await.unwrap(), - SonarrEvent::GetEpisodes(None).into() - ); - assert_eq!( - sync_network_rx.recv().await.unwrap(), - SonarrEvent::GetDownloads.into() - ); assert_eq!( sync_network_rx.recv().await.unwrap(), SonarrEvent::GetEpisodeDetails(None).into() diff --git a/src/ui/sonarr_ui/library/episode_details_ui.rs b/src/ui/sonarr_ui/library/episode_details_ui.rs index ea3d7eb..d9d526b 100644 --- a/src/ui/sonarr_ui/library/episode_details_ui.rs +++ b/src/ui/sonarr_ui/library/episode_details_ui.rs @@ -226,7 +226,7 @@ fn draw_file_info(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { f.render_widget(video_details_title_paragraph, video_details_title_area); f.render_widget(video_details_paragraph, video_details_area); } - _ => (), + _ => f.render_widget(layout_block_top_border(), area), }, _ => f.render_widget( LoadingBlock::new(app.is_loading, layout_block_top_border()), diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs index a3d9847..a3c082b 100644 --- a/src/ui/sonarr_ui/library/library_ui_tests.rs +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -1,6 +1,9 @@ #[cfg(test)] mod tests { - use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS}; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, + EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, + }; use crate::models::{ servarr_data::sonarr::sonarr_data::LIBRARY_BLOCKS, sonarr_models::SeriesStatus, }; @@ -36,14 +39,24 @@ mod tests { }); } + #[test] + fn test_decorate_row_with_style_unmonitored() { + let series = Series::default(); + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_series_row_with_style(&series, row.clone()); + + assert_eq!(style, row.unmonitored()); + } + #[test] fn test_decorate_row_with_style_downloaded_when_ended_and_all_monitored_episodes_are_present() { let seasons = vec![ Season { monitored: false, statistics: SeasonStatistics { - episode_count: 1, - total_episode_count: 3, + episode_file_count: 1, + episode_count: 3, ..SeasonStatistics::default() }, ..Season::default() @@ -51,14 +64,15 @@ mod tests { Season { monitored: true, statistics: SeasonStatistics { + episode_file_count: 3, episode_count: 3, - total_episode_count: 3, ..SeasonStatistics::default() }, ..Season::default() }, ]; let series = Series { + monitored: true, status: SeriesStatus::Ended, seasons: Some(seasons), ..Series::default() @@ -76,8 +90,8 @@ mod tests { Season { monitored: true, statistics: SeasonStatistics { - episode_count: 1, - total_episode_count: 3, + episode_file_count: 1, + episode_count: 3, ..SeasonStatistics::default() }, ..Season::default() @@ -85,14 +99,15 @@ mod tests { Season { monitored: true, statistics: SeasonStatistics { + episode_file_count: 3, episode_count: 3, - total_episode_count: 3, ..SeasonStatistics::default() }, ..Season::default() }, ]; let series = Series { + monitored: true, status: SeriesStatus::Ended, seasons: Some(seasons), ..Series::default() @@ -107,6 +122,7 @@ mod tests { #[test] fn test_decorate_row_with_style_indeterminate_when_ended_and_seasons_is_empty() { let series = Series { + monitored: true, status: SeriesStatus::Ended, ..Series::default() }; @@ -124,8 +140,8 @@ mod tests { Season { monitored: false, statistics: SeasonStatistics { - episode_count: 1, - total_episode_count: 3, + episode_file_count: 1, + episode_count: 3, ..SeasonStatistics::default() }, ..Season::default() @@ -133,14 +149,15 @@ mod tests { Season { monitored: true, statistics: SeasonStatistics { + episode_file_count: 3, episode_count: 3, - total_episode_count: 3, ..SeasonStatistics::default() }, ..Season::default() }, ]; let series = Series { + monitored: true, status: SeriesStatus::Continuing, seasons: Some(seasons), ..Series::default() @@ -158,8 +175,8 @@ mod tests { Season { monitored: true, statistics: SeasonStatistics { - episode_count: 1, - total_episode_count: 3, + episode_file_count: 1, + episode_count: 3, ..SeasonStatistics::default() }, ..Season::default() @@ -167,14 +184,15 @@ mod tests { Season { monitored: true, statistics: SeasonStatistics { + episode_file_count: 3, episode_count: 3, - total_episode_count: 3, ..SeasonStatistics::default() }, ..Season::default() }, ]; let series = Series { + monitored: true, status: SeriesStatus::Continuing, seasons: Some(seasons), ..Series::default() @@ -189,6 +207,7 @@ mod tests { #[test] fn test_decorate_row_with_style_indeterminate_when_continuing_and_seasons_is_empty() { let series = Series { + monitored: true, status: SeriesStatus::Continuing, ..Series::default() }; @@ -202,6 +221,7 @@ mod tests { #[test] fn test_decorate_row_with_style_unreleased_when_upcoming() { let series = Series { + monitored: true, status: SeriesStatus::Upcoming, ..Series::default() }; @@ -215,6 +235,7 @@ mod tests { #[test] fn test_decorate_row_with_style_defaults_to_indeterminate() { let series = Series { + monitored: true, status: SeriesStatus::Deleted, ..Series::default() }; diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index b805488..a29ec77 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -32,11 +32,11 @@ mod delete_series_ui; mod edit_series_ui; mod series_details_ui; +mod episode_details_ui; #[cfg(test)] #[path = "library_ui_tests.rs"] mod library_ui_tests; mod season_details_ui; -mod episode_details_ui; pub(super) struct LibraryUi; @@ -192,20 +192,24 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } fn decorate_series_row_with_style<'a>(series: &Series, row: Row<'a>) -> Row<'a> { + if !series.monitored { + return row.unmonitored(); + } + match series.status { SeriesStatus::Ended => { if let Some(ref seasons) = series.seasons { return if seasons .iter() .filter(|season| season.monitored) - .all(|season| season.statistics.episode_count == season.statistics.total_episode_count) + .all(|season| season.statistics.episode_file_count == season.statistics.episode_count) { row.downloaded() } else { row.missing() - } - } - + }; + } + row.indeterminate() } SeriesStatus::Continuing => { @@ -213,7 +217,7 @@ fn decorate_series_row_with_style<'a>(series: &Series, row: Row<'a>) -> Row<'a> return if seasons .iter() .filter(|season| season.monitored) - .all(|season| season.statistics.episode_count == season.statistics.total_episode_count) + .all(|season| season.statistics.episode_file_count == season.statistics.episode_count) { row.unreleased() } else { diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index 159440b..911aa44 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -1,3 +1,4 @@ +use chrono::Utc; use deunicode::deunicode; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Style, Stylize}; @@ -12,6 +13,7 @@ use crate::models::sonarr_models::{ Season, SeasonStatistics, SonarrHistoryEventType, SonarrHistoryItem, }; use crate::models::{EnumDisplayStyle, Route}; +use crate::ui::sonarr_ui::library::episode_details_ui::EpisodeDetailsUi; use crate::ui::sonarr_ui::library::season_details_ui::SeasonDetailsUi; use crate::ui::sonarr_ui::sonarr_ui_utils::{ create_download_failed_history_event_details, @@ -30,7 +32,6 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{draw_popup, draw_tabs, DrawUi}; -use crate::ui::sonarr_ui::library::episode_details_ui::EpisodeDetailsUi; use crate::utils::convert_to_gb; #[cfg(test)] @@ -42,7 +43,9 @@ pub(super) struct SeriesDetailsUi; impl DrawUi for SeriesDetailsUi { fn accepts(route: Route) -> bool { if let Route::Sonarr(active_sonarr_block, _) = route { - return SeasonDetailsUi::accepts(route) || EpisodeDetailsUi::accepts(route) || SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block); + return SeasonDetailsUi::accepts(route) + || EpisodeDetailsUi::accepts(route) + || SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block); } false @@ -239,9 +242,10 @@ fn draw_seasons_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .. } = season; let SeasonStatistics { + episode_file_count, episode_count, - total_episode_count, size_on_disk, + next_airing, .. } = statistics; let season_monitored = if season.monitored { "🏷" } else { "" }; @@ -250,13 +254,19 @@ fn draw_seasons_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let row = Row::new(vec![ Cell::from(season_monitored.to_owned()), Cell::from(title.clone().unwrap()), - Cell::from(format!("{}/{}", episode_count, total_episode_count)), + Cell::from(format!("{}/{}", episode_file_count, episode_count)), Cell::from(format!("{size:.2} GB")), ]); - if episode_count == total_episode_count { + if episode_file_count == episode_count { row.downloaded() } else if !monitored { row.unmonitored() + } else if let Some(next_airing_utc) = next_airing.as_ref() { + if next_airing_utc > &Utc::now() { + return row.unreleased(); + } else { + return row.missing(); + } } else { row.missing() } From a88d43807ee82b87eb07f7991097c7313baeaf77 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 13 Dec 2024 13:59:02 -0700 Subject: [PATCH 69/82] feat(network): Support for toggling monitoring/unmonitoring a season --- src/network/sonarr_network.rs | 110 +++++++++++++++++++++++-- src/network/sonarr_network_tests.rs | 122 +++++++++++++++++++++++++++- 2 files changed, 223 insertions(+), 9 deletions(-) diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 5d0a622..18b3668 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -87,6 +87,7 @@ pub enum SonarrEvent { StartTask(Option), TestIndexer(Option), TestAllIndexers, + ToggleSeasonMonitoring(Option<(i64, i64)>), TriggerAutomaticEpisodeSearch(Option), TriggerAutomaticSeasonSearch(Option<(i64, i64)>), TriggerAutomaticSeriesSearch(Option), @@ -139,7 +140,8 @@ impl NetworkResource for SonarrEvent { | SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) | SonarrEvent::DeleteSeries(_) - | SonarrEvent::EditSeries(_) => "/series", + | SonarrEvent::EditSeries(_) + | SonarrEvent::ToggleSeasonMonitoring(_) => "/series", SonarrEvent::SearchNewSeries(_) => "/series/lookup", SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", SonarrEvent::TestIndexer(_) => "/indexer/test", @@ -321,6 +323,10 @@ impl<'a, 'b> Network<'a, 'b> { .test_all_sonarr_indexers() .await .map(SonarrSerdeable::from), + SonarrEvent::ToggleSeasonMonitoring(params) => self + .toggle_sonarr_season_monitoring(params) + .await + .map(SonarrSerdeable::from), SonarrEvent::TriggerAutomaticSeasonSearch(params) => self .trigger_automatic_season_search(params) .await @@ -1268,6 +1274,86 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn toggle_sonarr_season_monitoring( + &mut self, + series_id_season_number_tuple: Option<(i64, i64)>, + ) -> Result<()> { + let detail_event = SonarrEvent::GetSeriesDetails(None); + let event = SonarrEvent::ToggleSeasonMonitoring(series_id_season_number_tuple); + let (series_id, season_number) = + if let Some((series_id, season_number)) = series_id_season_number_tuple { + (Some(series_id), Some(season_number)) + } else { + (None, None) + }; + + let (series_id, _) = self.extract_series_id(series_id).await; + let (season_number, _) = self.extract_season_number(season_number).await; + info!("Toggling season monitoring for season {season_number} in series with ID: {series_id}"); + info!("Fetching series details for series with ID: {series_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{series_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_series_body, _| { + response = detailed_series_body.to_string() + }) + .await?; + + info!("Constructing toggle season monitoring body"); + + let mut detailed_series_body: Value = serde_json::from_str(&response).unwrap(); + let monitored = detailed_series_body + .get("seasons") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|season| season["seasonNumber"] == season_number) + .unwrap() + .get("monitored") + .unwrap() + .as_bool() + .unwrap(); + + *detailed_series_body + .get_mut("seasons") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|season| season["seasonNumber"] == season_number) + .unwrap() + .get_mut("monitored") + .unwrap() = json!(!monitored); + + debug!("Toggle season monitoring body: {detailed_series_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_series_body), + Some(format!("/{series_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn get_all_sonarr_indexer_settings(&mut self) -> Result { info!("Fetching Sonarr indexer settings"); let event = SonarrEvent::GetAllIndexerSettings; @@ -1563,7 +1649,7 @@ impl<'a, 'b> Network<'a, 'b> { .unwrap() .episode_details_modal = Some(EpisodeDetailsModal::default()); } - + let Episode { id, title, @@ -1581,18 +1667,26 @@ impl<'a, 'b> Network<'a, 'b> { } else { String::new() }; - let episode_details_modal = app.data.sonarr_data.season_details_modal.as_mut().unwrap().episode_details_modal.as_mut().unwrap(); - episode_details_modal.episode_details = ScrollableText::with_string(formatdoc!( - " + let episode_details_modal = app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap(); + episode_details_modal.episode_details = ScrollableText::with_string(formatdoc!( + " Title: {} Season: {season_number} Episode Number: {episode_number} Air Date: {air_date} Status: {status} Description: {}", - title, - overview.unwrap_or_default(), - )); + title, + overview.unwrap_or_default(), + )); if let Some(file) = episode_file { let size = convert_to_gb(file.size); episode_details_modal.file_details = formatdoc!( diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 31512f1..f8f8b06 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -162,7 +162,8 @@ mod test { SonarrEvent::ListSeries, SonarrEvent::GetSeriesDetails(None), SonarrEvent::DeleteSeries(None), - SonarrEvent::EditSeries(None) + SonarrEvent::EditSeries(None), + SonarrEvent::ToggleSeasonMonitoring(None) )] event: SonarrEvent, ) { @@ -6640,6 +6641,125 @@ mod test { } } + #[tokio::test] + async fn test_handle_toggle_season_monitoring_event() { + let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *expected_body + .get_mut("seasons") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|season| season["seasonNumber"] == 1) + .unwrap() + .get_mut("monitored") + .unwrap() = json!(false); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/1"), + None, + ) + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1", + SonarrEvent::ToggleSeasonMonitoring(None).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.seasons.set_items(vec![season()]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::ToggleSeasonMonitoring(None)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_toggle_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_toggle_season_monitoring_event_uses_provided_series_id_and_season_number() { + let mut detailed_response: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *detailed_response + .get_mut("seasons") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|season| season["seasonNumber"] == 1) + .unwrap() + .get_mut("seasonNumber") + .unwrap() = json!(2); + let mut expected_body: Value = detailed_response.clone(); + *expected_body + .get_mut("seasons") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|season| season["seasonNumber"] == 2) + .unwrap() + .get_mut("monitored") + .unwrap() = json!(false); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(detailed_response), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/2"), + None, + ) + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v3{}/2", + SonarrEvent::ToggleSeasonMonitoring(Some((2, 2))).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.seasons.set_items(vec![season()]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::ToggleSeasonMonitoring(Some((2, 2)))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_toggle_server.assert_async().await; + } + #[tokio::test] async fn test_handle_trigger_automatic_episode_search_event() { let (async_server, app_arc, _server) = mock_servarr_api( From 91ad50350d5ca3d7f07f99eb3d5d85de344350b9 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 13 Dec 2024 14:09:11 -0700 Subject: [PATCH 70/82] feat(cli): Support for toggling monitoring for a specific season in Sonarr --- src/cli/sonarr/mod.rs | 24 ++++++++ src/cli/sonarr/sonarr_command_tests.rs | 79 ++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 1cb1e42..f0c8314 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -120,6 +120,23 @@ pub enum SonarrCommand { }, #[command(about = "Test all Sonarr indexers")] TestAllIndexers, + #[command( + about = "Toggle monitoring for the specified season that corresponds to the specified series ID" + )] + ToggleSeasonMonitoring { + #[arg( + long, + help = "The Sonarr ID of the series that the season belongs to", + required = true + )] + series_id: i64, + #[arg( + long, + help = "The season number to toggle monitoring for", + required = true + )] + season_number: i64, + }, } impl From for Command { @@ -245,6 +262,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .await?; serde_json::to_string_pretty(&resp)? } + SonarrCommand::ToggleSeasonMonitoring {series_id, season_number } => { + let resp = self + .network + .handle_network_event(SonarrEvent::ToggleSeasonMonitoring(Some((series_id, season_number))).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index 4490d23..3754522 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -142,6 +142,55 @@ mod tests { assert!(result.is_ok()); } + + #[test] + fn test_toggle_season_monitoring_requires_series_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "toggle-season-monitoring", + "--season-number", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_toggle_season_monitoring_requires_season_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "toggle-season-monitoring", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_toggle_season_monitoring_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "toggle-season-monitoring", + "--series-id", + "1", + "--season-number", + "1", + ]); + + assert!(result.is_ok()); + } } mod handler { @@ -616,5 +665,35 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_list_toggle_season_monitoring_command() { + let expected_series_id = 1; + let expected_season_number = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::ToggleSeasonMonitoring(Some((expected_series_id, expected_season_number))).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let toggle_season_monitoring_command = SonarrCommand::ToggleSeasonMonitoring { + series_id: 1, + season_number: 1, + }; + + let result = + SonarrCliHandler::with(&app_arc, toggle_season_monitoring_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } From d1ffd0d77fbc5fab7407c02c5e06b00cd0354c2c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 13 Dec 2024 14:40:11 -0700 Subject: [PATCH 71/82] feat(network): Support for toggling the monitoring status of an episode in Sonarr --- src/models/sonarr_models.rs | 7 +++ src/network/sonarr_network.rs | 66 ++++++++++++++++++++- src/network/sonarr_network_tests.rs | 89 ++++++++++++++++++++++++++++- 3 files changed, 160 insertions(+), 2 deletions(-) diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 3a9dadf..e2f5c47 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -279,6 +279,13 @@ pub struct MediaInfo { pub subtitles: Option, } +#[derive(Default, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct MonitorEpisodeBody { + pub episode_ids: Vec, + pub monitored: bool, +} + #[derive(Derivative, Serialize, Deserialize, Debug, Clone, PartialEq)] #[derivative(Default)] pub struct Rating { diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 18b3668..d1fe0cb 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -5,7 +5,7 @@ use serde_json::{json, Value}; use urlencoding::encode; use super::{Network, NetworkEvent, NetworkResource}; -use crate::models::sonarr_models::DownloadStatus; +use crate::models::sonarr_models::{DownloadStatus, MonitorEpisodeBody}; use crate::{ models::{ radarr_models::IndexerTestResult, @@ -88,6 +88,7 @@ pub enum SonarrEvent { TestIndexer(Option), TestAllIndexers, ToggleSeasonMonitoring(Option<(i64, i64)>), + ToggleEpisodeMonitoring(Option), TriggerAutomaticEpisodeSearch(Option), TriggerAutomaticSeasonSearch(Option<(i64, i64)>), TriggerAutomaticSeriesSearch(Option), @@ -146,6 +147,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", SonarrEvent::TestIndexer(_) => "/indexer/test", SonarrEvent::TestAllIndexers => "/indexer/testall", + SonarrEvent::ToggleEpisodeMonitoring(_) => "/episode/monitor", } } } @@ -323,6 +325,10 @@ impl<'a, 'b> Network<'a, 'b> { .test_all_sonarr_indexers() .await .map(SonarrSerdeable::from), + SonarrEvent::ToggleEpisodeMonitoring(episode_id) => self + .toggle_sonarr_episode_monitoring(episode_id) + .await + .map(SonarrSerdeable::from), SonarrEvent::ToggleSeasonMonitoring(params) => self .toggle_sonarr_season_monitoring(params) .await @@ -2532,6 +2538,64 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn toggle_sonarr_episode_monitoring(&mut self, episode_id: Option) -> Result { + let event = SonarrEvent::ToggleEpisodeMonitoring(episode_id); + let detail_event = SonarrEvent::GetEpisodeDetails(None); + + let (id, monitored) = if let Some(episode_id) = episode_id { + info!("Fetching episode details for episode id: {episode_id}"); + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{episode_id}")), + None, + ) + .await; + + let mut monitored = false; + + self + .handle_request::<(), Value>(request_props, |detailed_episode_body, _| { + monitored = detailed_episode_body + .get("monitored") + .unwrap() + .as_bool() + .unwrap(); + }) + .await?; + + (episode_id, monitored) + } else { + let app = self.app.lock().await; + let current_selection = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .current_selection(); + (current_selection.id, current_selection.monitored) + }; + + info!("Toggling monitoring for episode id: {id}"); + + let body = MonitorEpisodeBody { + episode_ids: vec![id], + monitored: !monitored, + }; + + let request_props = self + .request_props_from(event, RequestMethod::Put, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn trigger_automatic_series_search(&mut self, series_id: Option) -> Result { let event = SonarrEvent::TriggerAutomaticSeriesSearch(series_id); let (id, _) = self.extract_series_id(series_id).await; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index f8f8b06..2c9800d 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -17,7 +17,8 @@ mod test { use crate::models::sonarr_models::{ AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, - DownloadStatus, EditSeriesParams, IndexerSettings, SeriesMonitor, SonarrHistoryEventType, + DownloadStatus, EditSeriesParams, IndexerSettings, MonitorEpisodeBody, SeriesMonitor, + SonarrHistoryEventType, }; use crate::app::{App, ServarrConfig}; @@ -294,6 +295,7 @@ mod test { #[case(SonarrEvent::SearchNewSeries(None), "/series/lookup")] #[case(SonarrEvent::TestIndexer(None), "/indexer/test")] #[case(SonarrEvent::TestAllIndexers, "/indexer/testall")] + #[case(SonarrEvent::ToggleEpisodeMonitoring(None), "/episode/monitor")] fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { assert_str_eq!(event.resource(), expected_uri); } @@ -6641,6 +6643,91 @@ mod test { } } + #[tokio::test] + async fn test_handle_toggle_episode_monitoring_event() { + let expected_body = MonitorEpisodeBody { + episode_ids: vec![1], + monitored: false, + }; + + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Put, + Some(json!(expected_body)), + Some(json!({})), + None, + SonarrEvent::ToggleEpisodeMonitoring(None), + None, + None, + ) + .await; + { + let mut app = app_arc.lock().await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app.data.sonarr_data.season_details_modal = Some(season_details_modal); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::ToggleEpisodeMonitoring(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_toggle_episode_monitoring_event_uses_provided_episode_id() { + let expected_body = MonitorEpisodeBody { + episode_ids: vec![2], + monitored: false, + }; + let body = Episode { + id: 2, + ..episode() + }; + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!(body)), + None, + SonarrEvent::GetEpisodeDetails(None), + Some("/2"), + None, + ) + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v3{}", + SonarrEvent::ToggleEpisodeMonitoring(None).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(json!(expected_body))) + .create_async() + .await; + { + let mut app = app_arc.lock().await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app.data.sonarr_data.season_details_modal = Some(season_details_modal); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::ToggleEpisodeMonitoring(Some(2))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_toggle_server.assert_async().await; + } + #[tokio::test] async fn test_handle_toggle_season_monitoring_event() { let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); From 4001dee1bd5c134dbb26402048af690e4ea432af Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 13 Dec 2024 14:45:06 -0700 Subject: [PATCH 72/82] refactor(network): Changed the toggle episode monitoring handler to simply return empty since the response is always empty from Sonarr --- src/network/sonarr_network.rs | 4 ++-- src/network/sonarr_network_tests.rs | 11 ++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index d1fe0cb..2656c63 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -2538,7 +2538,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn toggle_sonarr_episode_monitoring(&mut self, episode_id: Option) -> Result { + async fn toggle_sonarr_episode_monitoring(&mut self, episode_id: Option) -> Result<()> { let event = SonarrEvent::ToggleEpisodeMonitoring(episode_id); let detail_event = SonarrEvent::GetEpisodeDetails(None); @@ -2592,7 +2592,7 @@ impl<'a, 'b> Network<'a, 'b> { .await; self - .handle_request::(request_props, |_, _| ()) + .handle_request::(request_props, |_, _| ()) .await } diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 2c9800d..1ae375f 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -6653,7 +6653,7 @@ mod test { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Put, Some(json!(expected_body)), - Some(json!({})), + None, None, SonarrEvent::ToggleEpisodeMonitoring(None), None, @@ -6682,10 +6682,7 @@ mod test { episode_ids: vec![2], monitored: false, }; - let body = Episode { - id: 2, - ..episode() - }; + let body = Episode { id: 2, ..episode() }; let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, @@ -6696,7 +6693,7 @@ mod test { Some("/2"), None, ) - .await; + .await; let async_toggle_server = server .mock( "PUT", @@ -6704,7 +6701,7 @@ mod test { "/api/v3{}", SonarrEvent::ToggleEpisodeMonitoring(None).resource() ) - .as_str(), + .as_str(), ) .with_status(202) .match_header("X-Api-Key", "test1234") From a28f8c3dd2c42972cdeafe3f7862c960cb27d713 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 13 Dec 2024 14:49:00 -0700 Subject: [PATCH 73/82] feat(cli): Support for toggling monitoring on a specific episode in Sonarr --- src/cli/sonarr/mod.rs | 18 +++++++++ src/cli/sonarr/sonarr_command_tests.rs | 56 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index f0c8314..4b2305f 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -120,6 +120,17 @@ pub enum SonarrCommand { }, #[command(about = "Test all Sonarr indexers")] TestAllIndexers, + #[command( + about = "Toggle monitoring for the specified episode" + )] + ToggleEpisodeMonitoring { + #[arg( + long, + help = "The Sonarr ID of the episode to toggle monitoring on", + required = true + )] + episode_id: i64, + }, #[command( about = "Toggle monitoring for the specified season that corresponds to the specified series ID" )] @@ -262,6 +273,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .await?; serde_json::to_string_pretty(&resp)? } + SonarrCommand::ToggleEpisodeMonitoring { episode_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::ToggleEpisodeMonitoring(Some(episode_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } SonarrCommand::ToggleSeasonMonitoring {series_id, season_number } => { let resp = self .network diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index 3754522..c7cfaf8 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -143,6 +143,34 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_toggle_episode_monitoring_requires_episode_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "toggle-episode-monitoring", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_toggle_episode_monitoring_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "toggle-episode-monitoring", + "--episode-id", + "1", + ]); + + assert!(result.is_ok()); + } + #[test] fn test_toggle_season_monitoring_requires_series_id() { let result = Cli::command().try_get_matches_from([ @@ -666,6 +694,34 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_list_toggle_episode_monitoring_command() { + let expected_episode_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::ToggleEpisodeMonitoring(Some(expected_episode_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let toggle_episode_monitoring_command = SonarrCommand::ToggleEpisodeMonitoring { + episode_id: 1, + }; + + let result = + SonarrCliHandler::with(&app_arc, toggle_episode_monitoring_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_list_toggle_season_monitoring_command() { let expected_series_id = 1; From cfac433861332230e7e32440eb33687050505962 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 13 Dec 2024 14:53:39 -0700 Subject: [PATCH 74/82] feat(keybindings): Added a new keybinding for toggling the monitoring of a highlighted table item --- src/app/key_binding.rs | 5 +++++ src/app/key_binding_tests.rs | 1 + src/app/sonarr/sonarr_context_clues.rs | 6 ++++-- src/app/sonarr/sonarr_context_clues_tests.rs | 10 ++++++++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/app/key_binding.rs b/src/app/key_binding.rs index 0726c14..f939a6d 100644 --- a/src/app/key_binding.rs +++ b/src/app/key_binding.rs @@ -28,6 +28,7 @@ generate_keybindings! { tasks, test, test_all, + toggle_monitoring, refresh, update, events, @@ -127,6 +128,10 @@ pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { key: Key::Char('T'), desc: "test all", }, + toggle_monitoring: KeyBinding { + key: Key::Char('m'), + desc: "toggle monitoring", + }, refresh: KeyBinding { key: Key::Ctrl('r'), desc: "refresh", diff --git a/src/app/key_binding_tests.rs b/src/app/key_binding_tests.rs index 7b3cbb9..ac2231c 100644 --- a/src/app/key_binding_tests.rs +++ b/src/app/key_binding_tests.rs @@ -27,6 +27,7 @@ mod test { #[case(DEFAULT_KEYBINDINGS.tasks, Key::Char('t'), "tasks")] #[case(DEFAULT_KEYBINDINGS.test, Key::Char('t'), "test")] #[case(DEFAULT_KEYBINDINGS.test_all, Key::Char('T'), "test all")] + #[case(DEFAULT_KEYBINDINGS.test_all, Key::Char('m'), "toggle monitoring")] #[case(DEFAULT_KEYBINDINGS.refresh, Key::Ctrl('r'), "refresh")] #[case(DEFAULT_KEYBINDINGS.update, Key::Char('u'), "update")] #[case(DEFAULT_KEYBINDINGS.home, Key::Home, "home")] diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index ece0638..bb5dc4a 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -25,12 +25,13 @@ pub static SERIES_CONTEXT_CLUES: [ContextClue; 10] = [ (DEFAULT_KEYBINDINGS.esc, "cancel filter"), ]; -pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 7] = [ +pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [ ( DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh.desc, ), (DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc), + (DEFAULT_KEYBINDINGS.toggle_monitoring, DEFAULT_KEYBINDINGS.toggle_monitoring.desc), (DEFAULT_KEYBINDINGS.submit, "season details"), (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), (DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc), @@ -76,11 +77,12 @@ pub static SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES: [ContextClue; 2] = [ (DEFAULT_KEYBINDINGS.delete, "delete episode"), ]; -pub static SEASON_DETAILS_CONTEXT_CLUES: [ContextClue; 4] = [ +pub static SEASON_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [ ( DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh.desc, ), + (DEFAULT_KEYBINDINGS.toggle_monitoring, DEFAULT_KEYBINDINGS.toggle_monitoring.desc), (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), ( DEFAULT_KEYBINDINGS.auto_search, diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index 73499d9..46ec883 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -189,6 +189,11 @@ mod tests { let (key_binding, description) = series_details_context_clues_iter.next().unwrap(); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.toggle_monitoring); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.toggle_monitoring.desc); + + let (key_binding, description) = series_details_context_clues_iter.next().unwrap(); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); assert_str_eq!(*description, "season details"); @@ -225,6 +230,11 @@ mod tests { let (key_binding, description) = season_details_context_clues_iter.next().unwrap(); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.toggle_monitoring); + assert_str_eq!(*description, DEFAULT_KEYBINDINGS.toggle_monitoring.desc); + + let (key_binding, description) = season_details_context_clues_iter.next().unwrap(); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); assert_str_eq!(*description, DEFAULT_KEYBINDINGS.search.desc); From 9269b66aa8f305ff3a84e8fa51ac8f8722758292 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 13 Dec 2024 16:10:06 -0700 Subject: [PATCH 75/82] feat(handlers): Support for toggling the monitoring status of a season in the Sonarr UI --- src/app/key_binding_tests.rs | 2 +- src/app/sonarr/mod.rs | 3 + src/app/sonarr/sonarr_tests.rs | 6 +- .../library/series_details_handler.rs | 7 + .../library/series_details_handler_tests.rs | 50 ++++++ src/network/sonarr_network.rs | 143 +++++++++--------- src/network/sonarr_network_tests.rs | 20 ++- 7 files changed, 158 insertions(+), 73 deletions(-) diff --git a/src/app/key_binding_tests.rs b/src/app/key_binding_tests.rs index ac2231c..c78c210 100644 --- a/src/app/key_binding_tests.rs +++ b/src/app/key_binding_tests.rs @@ -27,7 +27,7 @@ mod test { #[case(DEFAULT_KEYBINDINGS.tasks, Key::Char('t'), "tasks")] #[case(DEFAULT_KEYBINDINGS.test, Key::Char('t'), "test")] #[case(DEFAULT_KEYBINDINGS.test_all, Key::Char('T'), "test all")] - #[case(DEFAULT_KEYBINDINGS.test_all, Key::Char('m'), "toggle monitoring")] + #[case(DEFAULT_KEYBINDINGS.toggle_monitoring, Key::Char('m'), "toggle monitoring")] #[case(DEFAULT_KEYBINDINGS.refresh, Key::Ctrl('r'), "refresh")] #[case(DEFAULT_KEYBINDINGS.update, Key::Char('u'), "update")] #[case(DEFAULT_KEYBINDINGS.home, Key::Home, "home")] diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index 30f042a..3d2bb82 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -29,6 +29,9 @@ impl<'a> App<'a> { .await; } ActiveSonarrBlock::SeriesDetails => { + self + .dispatch_network_event(SonarrEvent::ListSeries.into()) + .await; self.is_loading = true; self.populate_seasons_table().await; self.is_loading = false; diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index df7b438..8237f7a 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -56,7 +56,7 @@ mod tests { #[tokio::test] async fn test_dispatch_by_series_details_block() { - let (mut app, _) = construct_app_unit(); + let (mut app, mut sync_network_rx) = construct_app_unit(); app.data.sonarr_data.series.set_items(vec![Series { seasons: Some(vec![Season::default()]), @@ -68,6 +68,10 @@ mod tests { .await; assert!(!app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::ListSeries.into() + ); assert!(!app.data.sonarr_data.seasons.items.is_empty()); assert_eq!(app.tick_count, 0); assert!(!app.data.sonarr_data.prompt_confirm); diff --git a/src/handlers/sonarr_handlers/library/series_details_handler.rs b/src/handlers/sonarr_handlers/library/series_details_handler.rs index 66bc3c8..00224b4 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler.rs @@ -257,6 +257,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler self.app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); } + _ if key == DEFAULT_KEYBINDINGS.toggle_monitoring.key => { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::ToggleSeasonMonitoring(None)); + + self.app.pop_and_push_navigation_stack(self.active_sonarr_block.into()); + } _ => (), }, ActiveSonarrBlock::SeriesHistory => match self.key { diff --git a/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs index 76e4244..20437ee 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs @@ -375,6 +375,56 @@ mod tests { assert!(app.data.sonarr_data.edit_series_modal.is_none()); } + #[test] + fn test_toggle_monitoring_key() { + let mut app = App::default(); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.series_history = Some(series_history); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.is_routing = false; + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesDetails.into() + ); + assert!(app.data.sonarr_data.prompt_confirm); + assert!(app.is_routing); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::ToggleSeasonMonitoring(None)) + ); + } + + #[test] + fn test_toggle_monitoring_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.is_routing = false; + + SeriesDetailsHandler::with( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::SeriesDetails.into()); + assert!(!app.data.sonarr_data.prompt_confirm); + assert!(app.data.sonarr_data.prompt_confirm_action.is_none()); + assert!(!app.is_routing); + } + #[rstest] fn test_auto_search_key( #[values(ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory)] diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 2656c63..112486c 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -879,7 +879,7 @@ impl<'a, 'b> Network<'a, 'b> { info!("Constructing edit indexer body"); - let mut detailed_indexer_body: Value = serde_json::from_str(&response).unwrap(); + let mut detailed_indexer_body: Value = serde_json::from_str(&response)?; let ( name, @@ -1127,7 +1127,7 @@ impl<'a, 'b> Network<'a, 'b> { info!("Constructing edit series body"); - let mut detailed_series_body: Value = serde_json::from_str(&response).unwrap(); + let mut detailed_series_body: Value = serde_json::from_str(&response)?; let ( monitored, use_season_folders, @@ -1294,70 +1294,75 @@ impl<'a, 'b> Network<'a, 'b> { }; let (series_id, _) = self.extract_series_id(series_id).await; - let (season_number, _) = self.extract_season_number(season_number).await; - info!("Toggling season monitoring for season {season_number} in series with ID: {series_id}"); - info!("Fetching series details for series with ID: {series_id}"); + if let Ok((season_number, _)) = self.extract_season_number(season_number).await { + info!("Toggling season monitoring for season {season_number} in series with ID: {series_id}"); + info!("Fetching series details for series with ID: {series_id}"); - let request_props = self - .request_props_from( - detail_event, - RequestMethod::Get, - None::<()>, - Some(format!("/{series_id}")), - None, - ) - .await; + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{series_id}")), + None, + ) + .await; - let mut response = String::new(); + let mut response = String::new(); - self - .handle_request::<(), Value>(request_props, |detailed_series_body, _| { - response = detailed_series_body.to_string() - }) - .await?; + self + .handle_request::<(), Value>(request_props, |detailed_series_body, _| { + response = detailed_series_body.to_string() + }) + .await?; - info!("Constructing toggle season monitoring body"); + info!("Constructing toggle season monitoring body"); - let mut detailed_series_body: Value = serde_json::from_str(&response).unwrap(); - let monitored = detailed_series_body - .get("seasons") - .unwrap() - .as_array() - .unwrap() - .iter() - .find(|season| season["seasonNumber"] == season_number) - .unwrap() - .get("monitored") - .unwrap() - .as_bool() - .unwrap(); + let mut detailed_series_body: Value = + serde_json::from_str(&response).expect("Request for detailed series body was interrupted"); + let monitored = detailed_series_body + .get("seasons") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|season| season["seasonNumber"] == season_number) + .unwrap() + .get("monitored") + .unwrap() + .as_bool() + .unwrap(); - *detailed_series_body - .get_mut("seasons") - .unwrap() - .as_array_mut() - .unwrap() - .iter_mut() - .find(|season| season["seasonNumber"] == season_number) - .unwrap() - .get_mut("monitored") - .unwrap() = json!(!monitored); + *detailed_series_body + .get_mut("seasons") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|season| season["seasonNumber"] == season_number) + .unwrap() + .get_mut("monitored") + .unwrap() = json!(!monitored); - debug!("Toggle season monitoring body: {detailed_series_body:?}"); + debug!("Toggle season monitoring body: {detailed_series_body:?}"); - let request_props = self - .request_props_from( - event, - RequestMethod::Put, - Some(detailed_series_body), - Some(format!("/{series_id}")), - None, - ) - .await; + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_series_body), + Some(format!("/{series_id}")), + None, + ) + .await; - self - .handle_request::(request_props, |_, _| ()) - .await + self + .handle_request::(request_props, |_, _| ()) + .await + } else { + warn!("Season number was not provided. Aborting..."); + Ok(()) + } } async fn get_all_sonarr_indexer_settings(&mut self) -> Result { @@ -2003,7 +2008,7 @@ impl<'a, 'b> Network<'a, 'b> { }; let (series_id, series_id_param) = self.extract_series_id(series_id).await; - let (season_number, season_number_param) = self.extract_season_number(season_number).await; + let (season_number, season_number_param) = self.extract_season_number(season_number).await?; info!("Fetching releases for series with ID: {series_id} and season number: {season_number}"); @@ -2053,7 +2058,7 @@ impl<'a, 'b> Network<'a, 'b> { }; let (series_id, series_id_param) = self.extract_series_id(series_id).await; - let (season_number, season_number_param) = self.extract_season_number(season_number).await; + let (season_number, season_number_param) = self.extract_season_number(season_number).await?; info!("Fetching history for series with ID: {series_id} and season number: {season_number}"); @@ -2629,7 +2634,7 @@ impl<'a, 'b> Network<'a, 'b> { }; let (series_id, _) = self.extract_series_id(series_id).await; - let (season_number, _) = self.extract_season_number(season_number).await; + let (season_number, _) = self.extract_season_number(season_number).await?; info!("Searching indexers for series with ID: {series_id} and season number: {season_number}"); let body = SonarrCommandBody { @@ -2767,11 +2772,11 @@ impl<'a, 'b> Network<'a, 'b> { (series_id, format!("seriesId={series_id}")) } - async fn extract_season_number(&mut self, season_number: Option) -> (i64, String) { - let season_number = if let Some(number) = season_number { - number - } else { - self + async fn extract_season_number(&mut self, season_number: Option) -> Result<(i64, String)> { + if let Some(number) = season_number { + Ok((number, format!("seasonNumber={number}"))) + } else if !self.app.lock().await.data.sonarr_data.seasons.is_empty() { + let season_number = self .app .lock() .await @@ -2779,9 +2784,11 @@ impl<'a, 'b> Network<'a, 'b> { .sonarr_data .seasons .current_selection() - .season_number - }; - (season_number, format!("seasonNumber={season_number}")) + .season_number; + Ok((season_number, format!("seasonNumber={season_number}"))) + } else { + Err(anyhow!("No season number provided")) + } } async fn extract_episode_id(&mut self, episode_id: Option) -> i64 { diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 1ae375f..39ebc16 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -5129,12 +5129,14 @@ mod test { ) .await; let mut filtered_series = StatefulTable::default(); + filtered_series.set_items(vec![Series::default()]); filtered_series.set_filtered_items(vec![Series { id: 1, ..Series::default() }]); app_arc.lock().await.data.sonarr_data.series = filtered_series; let mut filtered_seasons = StatefulTable::default(); + filtered_seasons.set_items(vec![Season::default()]); filtered_seasons.set_filtered_items(vec![Season { season_number: 1, ..Season::default() @@ -7024,12 +7026,14 @@ mod test { ) .await; let mut filtered_series = StatefulTable::default(); + filtered_series.set_items(vec![Series::default()]); filtered_series.set_filtered_items(vec![Series { id: 1, ..Series::default() }]); app_arc.lock().await.data.sonarr_data.series = filtered_series; let mut filtered_seasons = StatefulTable::default(); + filtered_seasons.set_items(vec![Season::default()]); filtered_seasons.set_filtered_items(vec![Season { season_number: 1, ..Season::default() @@ -7341,7 +7345,7 @@ mod test { }]); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - let (id, season_number_param) = network.extract_season_number(None).await; + let (id, season_number_param) = network.extract_season_number(None).await.unwrap(); assert_eq!(id, 1); assert_str_eq!(season_number_param, "seasonNumber=1"); @@ -7361,7 +7365,7 @@ mod test { ..Season::default() }]); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - let (id, season_number_param) = network.extract_season_number(Some(2)).await; + let (id, season_number_param) = network.extract_season_number(Some(2)).await.unwrap(); assert_eq!(id, 2); assert_str_eq!(season_number_param, "seasonNumber=2"); @@ -7371,6 +7375,7 @@ mod test { async fn test_extract_season_number_filtered_seasons() { let app_arc = Arc::new(Mutex::new(App::default())); let mut filtered_seasons = StatefulTable::default(); + filtered_seasons.set_items(vec![Season::default()]); filtered_seasons.set_filtered_items(vec![Season { season_number: 1, ..Season::default() @@ -7378,12 +7383,21 @@ mod test { app_arc.lock().await.data.sonarr_data.seasons = filtered_seasons; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - let (id, season_number_param) = network.extract_season_number(None).await; + let (id, season_number_param) = network.extract_season_number(None).await.unwrap(); assert_eq!(id, 1); assert_str_eq!(season_number_param, "seasonNumber=1"); } + #[tokio::test] + async fn test_extract_season_number_empty_seasons_table() { + let app_arc = Arc::new(Mutex::new(App::default())); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let season_number = network.extract_season_number(None).await; + + assert!(season_number.is_err()); + } + #[tokio::test] async fn test_extract_episode_id() { let app_arc = Arc::new(Mutex::new(App::default())); From 54006c378fabdfc278dc8d0e3dfd736e94a0317d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 13 Dec 2024 16:18:02 -0700 Subject: [PATCH 76/82] feat(handler): Support for toggling the monitoring status of a specified episode in the Sonarr UI --- .../library/season_details_handler.rs | 7 +++ .../library/season_details_handler_tests.rs | 48 +++++++++++++++++++ .../library/series_details_handler_tests.rs | 4 +- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/handlers/sonarr_handlers/library/season_details_handler.rs b/src/handlers/sonarr_handlers/library/season_details_handler.rs index 6b159c9..a04d288 100644 --- a/src/handlers/sonarr_handlers/library/season_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/season_details_handler.rs @@ -338,6 +338,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeasonDetailsHandler fn handle_char_key_event(&mut self) { let key = self.key; match self.active_sonarr_block { + ActiveSonarrBlock::SeasonDetails if self.key == DEFAULT_KEYBINDINGS.toggle_monitoring.key => { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::ToggleEpisodeMonitoring(None)); + + self.app.pop_and_push_navigation_stack(self.active_sonarr_block.into()); + } ActiveSonarrBlock::SeasonDetails | ActiveSonarrBlock::SeasonHistory | ActiveSonarrBlock::ManualSeasonSearch => match self.key { diff --git a/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs index 8ad0954..aabf913 100644 --- a/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs @@ -546,6 +546,54 @@ mod tests { use crate::network::sonarr_network::SonarrEvent; use pretty_assertions::assert_eq; + #[test] + fn test_toggle_monitoring_key() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.is_routing = false; + + SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveSonarrBlock::SeasonDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonDetails.into() + ); + assert!(app.data.sonarr_data.prompt_confirm); + assert!(app.is_routing); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::ToggleEpisodeMonitoring(None)) + ); + } + + #[test] + fn test_toggle_monitoring_key_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.is_routing = false; + + SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveSonarrBlock::SeasonDetails, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveSonarrBlock::SeasonDetails.into()); + assert!(!app.data.sonarr_data.prompt_confirm); + assert!(app.data.sonarr_data.prompt_confirm_action.is_none()); + assert!(!app.is_routing); + } + #[rstest] fn test_auto_search_key( #[values( diff --git a/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs index 20437ee..56210fb 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs @@ -378,9 +378,7 @@ mod tests { #[test] fn test_toggle_monitoring_key() { let mut app = App::default(); - let mut series_history = StatefulTable::default(); - series_history.set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.series_history = Some(series_history); + app.data.sonarr_data = create_test_sonarr_data(); app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); app.is_routing = false; From 8dd63b30e895dceb7da01a145836d07a9be91d10 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 13 Dec 2024 16:28:42 -0700 Subject: [PATCH 77/82] feat(docs): Updated the README with new screeshots for the Sonarr release --- README.md | 58 ++++++++++-------- screenshots/{ => radarr}/add_new_movie.png | Bin .../{ => radarr}/collection_details.png | Bin screenshots/{ => radarr}/indexers.png | Bin screenshots/{ => radarr}/logs.png | Bin screenshots/{ => radarr}/manual_search.png | Bin screenshots/{ => radarr}/new_movie_search.png | Bin .../radarr_library.png} | Bin screenshots/sonarr/add_series.png | Bin 0 -> 215673 bytes screenshots/sonarr/manual_episode_search.png | Bin 0 -> 312406 bytes screenshots/sonarr/season_details.png | Bin 0 -> 200307 bytes screenshots/sonarr/series_details.png | Bin 0 -> 126541 bytes screenshots/sonarr/sonarr_library.png | Bin 0 -> 208000 bytes src/ui/sonarr_ui/library/series_details_ui.rs | 6 +- 14 files changed, 37 insertions(+), 27 deletions(-) rename screenshots/{ => radarr}/add_new_movie.png (100%) rename screenshots/{ => radarr}/collection_details.png (100%) rename screenshots/{ => radarr}/indexers.png (100%) rename screenshots/{ => radarr}/logs.png (100%) rename screenshots/{ => radarr}/manual_search.png (100%) rename screenshots/{ => radarr}/new_movie_search.png (100%) rename screenshots/{library.png => radarr/radarr_library.png} (100%) create mode 100644 screenshots/sonarr/add_series.png create mode 100644 screenshots/sonarr/manual_episode_search.png create mode 100644 screenshots/sonarr/season_details.png create mode 100644 screenshots/sonarr/series_details.png create mode 100644 screenshots/sonarr/sonarr_library.png diff --git a/README.md b/README.md index 56e687b..aa3f41b 100644 --- a/README.md +++ b/README.md @@ -88,22 +88,21 @@ Key: | TUI | CLI | Feature | |-----|-----|--------------------------------------------------------------------------------------------------------------------| -| 🕒 | ✅ | View your library, downloads, blocklist, episodes | -| 🕒 | ✅ | View details of a specific series, or episode including description, history, downloaded file info, or the credits | -| 🕒 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | -| 🕒 | ✅ | Search your library | -| 🕒 | ✅ | Add series to your library | -| 🕒 | ✅ | Delete series, downloads, indexers, root folders, and episode files | -| 🚫 | ✅ | Mark history events as failed | -| 🕒 | ✅ | Trigger automatic searches for series, seasons, or episodes | -| 🕒 | ✅ | Trigger refresh and disk scan for series and downloads | -| 🕒 | ✅ | Manually search for series, seasons, or episodes | -| 🕒 | ✅ | Edit your series and indexers | -| 🕒 | ✅ | Manage your tags | -| 🕒 | ✅ | Manage your root folders | -| 🕒 | ✅ | Manage your blocklist | -| 🕒 | ✅ | View and browse logs, tasks, events queues, and updates | -| 🕒 | ✅ | Manually trigger scheduled tasks | +| ✅ | ✅ | View your library, downloads, blocklist, episodes | +| ✅ | ✅ | View details of a specific series, or episode including description, history, downloaded file info, or the credits | +| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | +| ✅ | ✅ | Search your library | +| ✅ | ✅ | Add series to your library | +| ✅ | ✅ | Delete series, downloads, indexers, root folders, and episode files | +| ✅ | ✅ | Trigger automatic searches for series, seasons, or episodes | +| ✅ | ✅ | Trigger refresh and disk scan for series and downloads | +| ✅ | ✅ | Manually search for series, seasons, or episodes | +| ✅ | ✅ | Edit your series and indexers | +| ✅ | ✅ | Manage your tags | +| ✅ | ✅ | Manage your root folders | +| ✅ | ✅ | Manage your blocklist | +| ✅ | ✅ | View and browse logs, tasks, events queues, and updates | +| ✅ | ✅ | Manually trigger scheduled tasks | ### Readarr @@ -141,7 +140,7 @@ To see all available commands, simply run `managarr --help`: ```shell $ managarr --help -managarr 0.3.0 +managarr 0.4.0 Alex Clarke A TUI and CLI to manage your Servarrs @@ -186,6 +185,8 @@ Commands: start-task Start the specified Sonarr task test-indexer Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}' test-all-indexers Test all Sonarr indexers + toggle-episode-monitoring Toggle monitoring for the specified episode + toggle-season-monitoring Toggle monitoring for the specified season that corresponds to the specified series ID help Print this message or the help of the given subcommand(s) Options: @@ -282,13 +283,22 @@ with all items tagged `Beta`. ## Screenshots -![library](screenshots/library.png) -![manual_search](screenshots/manual_search.png) -![logs](screenshots/logs.png) -![new_movie_search](screenshots/new_movie_search.png) -![add_new_movie](screenshots/add_new_movie.png) -![collection_details](screenshots/collection_details.png) -![indexers](screenshots/indexers.png) +### Radarr +![radarr_library](screenshots/radarr/radarr_library.png) +![manual_search](screenshots/radarr/manual_search.png) +![new_movie_search](screenshots/radarr/new_movie_search.png) +![add_new_movie](screenshots/radarr/add_new_movie.png) +![collection_details](screenshots/radarr/collection_details.png) + +### Sonarr +![sonarr_library](screenshots/sonarr/sonarr_library.png) +![series_details](screenshots/sonarr/series_details.png) +![season_details](screenshots/sonarr/season_details.png) +![manual_episode_search](screenshots/sonarr/manual_episode_search.png) + +### General +![logs](screenshots/radarr/logs.png) +![indexers](screenshots/radarr/indexers.png) ## Dependencies * [ratatui](https://github.com/tui-rs-revival/ratatui) diff --git a/screenshots/add_new_movie.png b/screenshots/radarr/add_new_movie.png similarity index 100% rename from screenshots/add_new_movie.png rename to screenshots/radarr/add_new_movie.png diff --git a/screenshots/collection_details.png b/screenshots/radarr/collection_details.png similarity index 100% rename from screenshots/collection_details.png rename to screenshots/radarr/collection_details.png diff --git a/screenshots/indexers.png b/screenshots/radarr/indexers.png similarity index 100% rename from screenshots/indexers.png rename to screenshots/radarr/indexers.png diff --git a/screenshots/logs.png b/screenshots/radarr/logs.png similarity index 100% rename from screenshots/logs.png rename to screenshots/radarr/logs.png diff --git a/screenshots/manual_search.png b/screenshots/radarr/manual_search.png similarity index 100% rename from screenshots/manual_search.png rename to screenshots/radarr/manual_search.png diff --git a/screenshots/new_movie_search.png b/screenshots/radarr/new_movie_search.png similarity index 100% rename from screenshots/new_movie_search.png rename to screenshots/radarr/new_movie_search.png diff --git a/screenshots/library.png b/screenshots/radarr/radarr_library.png similarity index 100% rename from screenshots/library.png rename to screenshots/radarr/radarr_library.png diff --git a/screenshots/sonarr/add_series.png b/screenshots/sonarr/add_series.png new file mode 100644 index 0000000000000000000000000000000000000000..6a2907ad7d3293b5c2289fa388f94d35c5d9646d GIT binary patch literal 215673 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⪼(o$2ua$mKQuSgVc2%W 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

x&Dl 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

f;RXwr#-@@YGna+Ym=)g${{=nh#V zNxJp>x~Y^sU##julZH(Yrboz^O*d+cI}0(5UBNp{Yk5?Diydy z?j%MJI;u0kH~>u}*{1618V!2Lm$$OZTM{xEcXEdZ<`!x|sg;;Wf7tY?ntShR7fi~4 zeC~6-;g-qt^TQZkHutV-b*i6yvYX{&uxd%i*iPb8#H8szxA=mQ2`#Kir^yBLR%vPv z<4@`*b!%D6OBg;m$|rYabx$Hh0Wn;~HoEVLIzYE%#O<#yIpn%O%jLJ-7;c!)(HLDD z6_T5cq?=J7@Igc9J=7eR51utb_UUg=rqv{W;V?ehfBvgz6oFbW9N2Gi84#_^ku%<{ zK;UYp(>b|vY7bem$XJ_$vN1fgi<;O|JWJ`~8Jr2k2@cjkf!>4=7)3r$yJk!iy|As#muT z;O{3tR$E_mC6KHeE(Cg>KRv!5DxJdcq!IDX>T49>KjWN((k4cyvO%t% zhC3}eUk*0smij3oVbhTHnA$$H?xA~s9G&;y_~c8#C;bTTe*TM3s$=-%qCLe`Xx4P* zl@~)jKl$VbEsrAQt3-_xv96o4+;Y)~YQ3BVj|qu^jdn;L^Be4AsG)^E+89^1jZS)C zVyWtT3_6P&9EDveRNRwuTn2^dJ>I#x-C6WQVOi8>IG$>I-6E zZP)Z}HeG|3%{(eqrle848h|nP{ASqC~CB0Y+LIf7|w0gHwW6)jYr?mq|BE_EmcUjE85-{)#b%S5@_11A<^1F zw<$ckfW4L!lyC`gT|7f!myg@&1SdMFA^@NqJXu~GE|zSv0U5kAwQpEYf?pE=lplWq z%5h4M5vWTK6(e*<2VMU&2S@$jBNv<5s?oysbqWYab1v5IKqa&&_dlGFO-Ov!EXA+; z-Czq>|7B!^pd-@$bR?Z1{XDxsaOm zZeiBaoR;=<81uIENCgE@?_S|GP4P^c>Bp#d6`d4VvujRFvJN)Wa2B^xyyhY6xEs2_ zZqhnFx4t>cvM-2>Z2w}o{UuVj;5|&kMLEju`u41S(;c^Q$Ao0sc8=`0B+rI^5<78gyJeU`TjXtd~~_c6?E! z($_XOonYROj&44~(R?l1!HY`BS|)PK{@ya_j}ks*k({Z%xwyJ49}~fBY3%&ui*j`R zhzw`veoE+TBDg01P1bBC_tWD6WXh$ot-OqKQXKMBE!Dh4@8r{~-fsRJ>?)xLfpP(T z{O|d54A+UKAai`-9fuaSy_3PJ(BZ*+QcI26Xu{-|b^JvhuIiTs7RQ_y?4ts#hsrMT zOS=YYA$Xr#y=7e7C*au=GWbCCNjy(m#i*X;UB$PmmyVF8uk{>ry?=!@HwQVqkdLng z3=+e;Eb_|$IO4HAerh5TXB$j-aMLn8iaWllk_`B|cWyVejpln#*Pvegy3E}K87a(V z?t^xFRr^!y58C_vWU`8Keu=* ztvM5y7cuP-Rq7uZO4kezss&!x6a2}++eFnk7H7#SJvWklj}mLHDie}o34?|8{cbff zgy7tR6Hj@QhBs)jCVDkiSvvDrehqQ^14!geLsG_NB3)l5KErC41U2rx`hq19?^OEu z2`_rN3+Wcy=e>J4JdvShePxF`ISp=?5&U|3z8}xgge2PB!{_v9j?3rKm%FG98EM^~ znf@I1nCTrjNgs5!4Y+iXV3Yr0DY3G5`NjGJVYM2$GFv=*!2{A0Yxfh^&61oC(Ohl3 zFLc}lJ^gL(w7dV&pW+4dy}QKKe-TPkKqy-Yn-o}v+Lx$7bpVG5{yU+3!f)|9TnqzeG!!V;~hrgc@$}Fqb z_-21!XxKz}E|C(zzdKIj!!amP4BP+l1MUv}Nhl>#9&%%vk*~$mwcLt|%wAabJ7H$+ zC?ioO1cb6f;kOH6y_`=z4fX!*ECaMg3pPJ`=+%oOUc+f3cxvCl zH6MmJ|?kGz>P9+byb8WZ*nX27~wN3zqypSh7%(GUAXDq+Sjs^ zxC>T48t_FM5cup@cH*7@J;2x&e8D>%*1a3<^fFRMJGN;oUxqfBUMgDe^_I5cQxa8A zA4wuzvCt+}3nf;V>(#H(3(@Hp)#OY_b#)NmYAq79wS{C2r>^@vdGm*V70nI?P_}_; zM=n+sZsv6#j&LR(|MsX^c?eOvt_vNw=qCkVj2Yolo<9SW96K2az|ZT6J%0Jccc%jp zn3RiAqSECwK3Snru2y&J(c6=g3ZQ&TuIaAtaqln;ovOx{b9uLEf`<3^t?zYRBzYM= zq}n)i>8X!v=a9o6>Unj%BdNRy{qDvz@uN_<-wyYQjIJ*;UvT=o&z{cKe0Uiqd-)g^ z_T6Oaps3pgHoKs{eh6y&tD{pG(=3M9?`+wwe{6}zg#vEZ%^ z$!qAtpr^kVwR$IL<{VA`0MF}_Ma~G<)6>kO6uhh{4!2R2>Lt5CVz_{CnN7nD__hyt zrz9WZjmn=tyQ3PyjLVJppWDA&gW_z?kM~dd?~s%wV0tv1ZSldwIQh(@dxYb&049D- zGa#L-E?_*mYrK1Qy*>9{rAd|YFs`?E-`K0qhzyPdSGbDShVrla&lP>FPZsyVtk(_Z z(f41%(p53DUPFtNx64@pdBm?FPCihMshuFiw)z9$(~Ysd=K!^6<1>O?FZPdlk=1zN zmc*sXwIn)RU1I3EjnZD31~d~&NbSwp8SRSNMQQ#KE=r{I5C!n}Wn5&%z@*sqj6VTo zaXxhAPz)ce9iM!VmMw7j*!m4CC2L_aT|%6zi=>UiK`LsYO9D8i$Lpoq6hKE^Mpjck zK|h|iDdm^dir6_u3ybS(z2y{#y}f7q*fw#yDeniqIO9u>gN1fBRg<3^Xj8J#pGf?h zNnS%J?t`n8i&u}*deaEa&%*yLD;>m}a@KblsM{=`?Jud$zJ7JbBuBckRx3(OIabzD z<7i(n@6p!Bwv1%~Aso8zMlq_64VL*CBFrCpzU>$%ZP+vw3fTA(bzswbKE}~MTv)b3 zd8>&LS)@q{jThx${TlK*hw13~d2vy-=hsN5;Kxj2+W~9}+)AHe*p$)lWw{Q^_3Bx@ z2v_6JYeM&^*H?F-OKvyH>nLs{RKjlW^^!7djh8+yB$1Xl>ysQ5nZBk=&?74PEvVG` zeEgPn*$&=`J1ek)Ki>$``BHp9#nG+F851N$MsO=&>`g=$en4-@u4{t!it|wrOCK;X z%3wdR8U3NihkfgMrwUj88X*;#Q0TX(8SWq?xxl-}phIa=&27n%hi2cKv{aDWchz_w z5a`TrNC|EgVW-_7B&qy3fgW-Tmco>E8a)nDlPOT#YOo72T)5?Qu7S*8CT|R`hzp$p zwHYZQMzY{fbJf>FZGB;qn)1hjCP%hXKFjJ)5ql1_XQWupa7*j2$X&?TZ}9W=h8 zoh~%jv6?B6F}B)DuX2+mi(fMv!} zty--DUJrdcq~<%BOBY>4g14oE_)@0w@%_|>1)6n_ZsSa|@0^SMS|1&T3?Acrmv(Km zoqNqvTt|^K;V1^a^E7`uO=ShtMXuHyT6H|5y?#?@QPo@e`${Jsy&b>2z_jq7tAml# zERhk1DkxJ_PXXcWpX-B**m*hC0bP&r-PZTq8#liO2tOuJ%k%c^{4}u8cxJrS36=kk zyM~FGkF~88sh_t$p4CE`7eQV2+KrH9T;_PjHc;2f*+kQS=BD=&Ed+ij2~xjLX$5c@ z;Tl_ynpWhYsJgaI6vt0i8R|HrG>=^RjaA+e`u3AmiUC&1mQ_!SM0=)R{x??XN)ezr zqc<5)ba&zk7WVlnf~p%&3%R;n8czB{(_cvLDm3TRyExU7Js3j=|D#HX!5}uF>C*p3#!M_VF@=4 zL*)X@*F)Tt(0+9q0grqo&l_XAH!zy}KMRlr!)5yTIlUyGzOs+cAc=S2B#qHm;nkN5 z%&J4sc+t#5@}d@Qeh6f%Y}$z<90!xNm@MG72iTp{4>T2g11-IsY?|G{&G-9t3+izC zp<~^n1uFP<{ictXWLIqF`=?*}_p4Xn4h2|&lEYNyxM6Pn%8xOP{=Dtr|6yAFx(nYg zfEWjgVVAOJ?DE4;c1aEz2CGX-agiBZS<-w)f9_$~P*6a^>AJ=zp?58SnK0gQMbL*f z_ExjikE#A8f`ecPYW-Rgykk2VL)9ArRac7$S?H3S2F-}nt3!mlV1#tx2gwkzW1d-J z)WLJcKSaB({8n@UdzVZ)9*C%~QlGN{=!0ikNQ50sn&F51R)7rxK=R{}0bW)+9`ZAW zS}ra%%7>~%(<0NhoQm>GKYZXj{r(C(>B0v&LREffaCfeGG|1YEqr4*7p!1}CPlZxK zI|=>usf>@Da%Wv}V{O1N1Ou896ct_7j~$)P3t}eQ9l3Wh>*Jj$-7%?MoXg>oD$8}Z z&*?jjBxq?B3EY2EwtE(4L}t*gl?RDQ1%@P+_@)QX|ujLt;XZR8`mCeiIrl9*f=S2JBQu`#C%&x8*sHwo-f5-Y%r@EQ0zn-ch zH@+3Xq;v~gDJn_4bAm8c=)gdQ#m2YFx1N-vLghp?!E0WBSTE6#^W@%*cM#8R(n;co zO0l7^J6Yo9$(v!HmsJ=33r73{9N+(sL1IkC7xV6M&Z{^(jN4@K%0dSH(ejYCPY9NK zCV3e%SO(azTNXmxedqP}B@*hL6`$eT^d72_Yfg*OezMNn1HMF|_Qe=ZuXhtQ-{eal^<=HhR>yyG#@HbIynGE!2$w8LML zaWsJF9|ajj@j;ML^hz?GkJ*QVjN*67&a;f-OCSXjenZpDqiT@fG7cuA&XI^1xZp0t z_qe9}PHG@gKcy`p3=5Q3_?+zlMYqBjlIQuU*4$KiwuD3!|G6SDNWjrh?rDUQ1<4v4 z$5}EFGD9^!^&?WaMV=-WdlT*1#oYHW26$yBl@~s%c_7T9xJ98&SV8(O9nm0~_>@0M z0ks*^q(k=o=p>+eaRW9N?d_j6V@+<0i%gob5^!yD7JXy1)HRCYQH;qW2@o7gCAo#i zT$R>MS!O_9#h(}q=@R2BG`m}16P(4|u(~f-SY3Af6a%b$$@TKE{6{M3oM~Y|TlY7b z5j1JJZu#h^3^T;2#ol&&5`&CrnuI637)?J3k^4SO>v+6>J3o0W1OLNxr8O&_K-IHV z4e#j7R@;r$NacC;Z1=;5;|dK=5@%#6We7qRF3pV@V5@vw@~F2rMbdbrG&Jy;dzTPo zN|`UXnBn-jK=KKlYN=8#t#Wh_ilZdC z?m!{}VX;uE^&Ba$F4a9A`7E}R&UiSGU|e=&`mE-_$KFx~I8#UO!>T2D;v&n8*1Gtv zJDTtdG!IoV4PhAvpYQeGWv~ppmI=X7IuknorT_RI5&lbp8~js(D^cL%b8T)Xe4rQj zZ!@q@rXYE-Uw*#)KS5mq$3KznzvIZij}V3aFLe61-w*yR#Xor6zebc6Fm!p6JwEBd zR!0|qVS^5(*ePF|i2M~z{2Pxf_$T!H+b;=g%d%fG8Q8H_ZRNp2r$MkPh)C8EcKuNqccYasLnmif}PZbh7c-tVJ z)mhrKx615y=5f2#m1^&n+429F9xk9gWlV^N6WoJj5~V$k3WvM& zw+AiZYZ#EclfG3K&|ijtZh@Cr(uK31o%dv#-(p(_%phGX_!}|Ad=lOG7O}Ve9RB$! zYl3?hDEtoqIw;8sb-2)yYP#Mbx)Wa~vV4%W=;0pRibRVhTw?*85Lhep)L?)MX0%rx zs#Y5Dmu7=9L6*(*4Kcxyf=rUwh@O#>%=)U^o(RiZ?r700%P}Ed07V|JmIisEB>=_C z)IRjyOQJ4v%x)bk$e0sj-}Ocd(;Fa%rsQw4{$G(p#(8mraWAcqq3YNZC$WR5MVP<< zGeSX6E#L>TF2i|zqh&Cx{mZP`QIn0KD}vTJZL_z-wD0K1d|-&(I_rb5bEa*+)uYBD zuT^vrgrzT%v3>c7&-GOhVY|Gl>3KaBo%}(vMakB z=4uz+!#}P@+m-V;_0(Up9|Bt5q`9{tP?D$4{Zo!4y(WEJhlVh{@5#r86w)YXlrW?^lrO2Z)P8hvc47wzt25Ti`6-oyB*4`eq+x71OLp+aA} z9viL;ZVgRmxiob>#+>kxFTqgm(a>z}dFA2veuDTr#M59z5kHtY&$HLG!_{eD7i}-N z&`-#6+?21Y+(kRg;yle@#<8~5nQ6=)59W-1*AWX1hG%eW@uy52J&E}YCSn9v$MDR( zzON9K+*GymX|dv%1Sv}C(Oh%%<&bJc2+i=(Z=)kzkqm4C1_N3Ch`^A3bCDS^sX<`r zLT4Gz@HK2zow+vbC7~aYunqkH>Cy9%6ERQjN2=PXsJ-?^|)F900sqZ6(%6K+`P>hN9|fqKcN()Z@8I?2>iCll-nuM;b;br4&duhc z-S_*Vv%nlSJCa9r;&xlFR`16*X_;a+3dKcN|W1Ctf zQEQ=+*NukVuS=E&NBT9Tq66K&vsbh*XV=L~CUU)$*oEJkxpk3|wCWoeeKSl!XyZZ} zI{GzMgsx(>D%*`-j@so7d6!^5lg^zIQ!E|W4AfvZp*RYm@g$+0Et|s{uHE zt=(|Hxj)vAW8^@5=w5j;&o>?0#yhOQJ>UcP#W z8=WBK2gR;c0fB}7zKQIMO9pYM*ASFlZy=3Ext>@+dJLL+bD9@wvsm5)xH2-3MqWXn z1}7QDQPHJoYzL!FsB><*^Q^QupV~u7Vl=9DpS^P^`(iBNx%B08mcbqFDqzM|$vzID zr)krN%1LRo!0VUp7<~AhK7m=C5mXXdDexX`Dc|p8Higs6P$&hK z%~CSB_B!u`aTVAfK2$%DO;CP91n&XS?a{1cHt3Z9#r&!52++^OcG~SsW=--CTWj%s zk-)d9KU%Gqzxku?>901vxPD(imK~c^%~z)7cn&vxHN8jjqh#->r?NV35X&8ETRtYI z9}@>f_OPoB`1T z<`p}KtMw1XD;-!_sjETo87G|8;mPJQ@G^9imid;~S=sdvF}x z=iU3FIsD7PQfOKhhxM~WG4aI!F>LEgr@X1kxyxTi+9)go`EKUJ-bb>F__)YQWoY{L zr=U6(@Wv?wm14iV#7o|)S`vF8{U#E++16MKjFmg;lu?Na-{$arJtRewNaw5$*jsQ! z4OBS>Y|%^V0dahdxCG%1?j7nG2xBbf_!58C@Q(&6~_5d}fek!kQ(9=TxjEbN|fbj|Wyp)P&3_>EY4#Aq@dS6mI zR8i(dryWiTLT6ohGVs=ACX$$TGI?f@;86W5Sj_Dj>AAiV0;Z2G{~-}A`Sb4 zkH4{An;E=1aNUvq@FMta%x%FX;*UYmc!Ks;)#LWnMCm&o)rAkNN85jIzl#~182Jj% z-W`rPPqny>T3(t~_iB;g<@VDeN!xk`>KGY<4UwH2$1!Z3q$G#8aginfUz;lB({u_> z&{fsh<~vj%3y^7Kt-uIQMBNtz4s2B>|y;7goyPZ}nH?6>uaFU0_N+({VI zsbDpdccwFD0hJ_ymj4f$Uh#rxUWr9@m#TY>ACqpYZj8O zSgu#fvFT$Ir9wiXEKf(k1DK$N3nyTLhip_kdR6uZdJ8hlWyt2BVl_Uu!0P!(_?k@h zEb$+j~4G8NsCWYrTvoF$k+jQg@^eqL2DLsn5-d)l!EiOFb+(qaN zuXUktVwxV`CJqs#vzrsR%YuDCnR7)3YL*Wgw%wS3zy8KNncywcy*WNqyehMF-h=|~hw!ymN0auf5u#7ApzK!>93k;C_ z)ero5^_}kU1}ZYJV@@@Vn;l%X$4XybA4E%M)NSCHxI@_Qrj%L{i=r!=CJ24@47a`$ zR!nE6o6SrO_m8!!YjvrdD+Rgt&7VlzgLAD6w_wNc0O#@e87)P!8N3Cy1PSTugU{KA$VZ+vz4=}kj{TyO^XC!Ovo z27*T-~ z|EZ3ll}McE!FvRmUzn1*Qxp}*S9}aVQgWa0xV^mkE&47#yK(Dt3X%HdTE=hA9Jsj- z=@3;>gb&EbxrSEhZ3I(X@a=7?FP#IP)|_47X?=hnAA>a5PT<)GeVLwoDo;+)3(Si_ z9Pl?dcAN)ZqkYrzZPIegarfoiC^a`8;gd3Za?DeGYh!G}0tApx@qGBa%M)3BIveqK z-4KtEbvaefuV($R3ouViQt<-mWx0`c^7CLri^H(!48GKYn#e9SKra<(Zof5#J#e&i zvo)=6qFFI(QDtN*UwtDs^@Z1qyfQ5>WAM-!r=+Y zVc%I{NDm#(H{i+;_`hV)z;+ltn4S4mQNDrX65b=cHoD=|+3v>V|mh$EWbP;B=Rg z^-80t!wjJdwgwz;TJ8U!BHrX#ji^Q!)4j84YPfm7L($d-aWT$T{nCXb)tgi)$#Zg@ zZay=VTi1;{L)^O>tInkq$j${ad)DCj_qoBx{|yQ?41z8zXJ58an>TBfvKNIKD2%bE zwe{5a1}EH$C_Qr9w(fvAo4ux!XLB|iz?@ARamBMao8)uQ`#~(D%=y*(gt(YFn@tbk zYc3a`-Mg!Rz?aHt1el}L#9votlwPmdsIu`!^z>oFD0Glh{jYdF%ft_Npd&} z%==lo#@4vOfsFO&B7a<77`^W`Zi*v;!}|%blDK0p*GH~roqoOuFw6uK*WT1M zq^RQa^#L$n6GisUeKun8YohU`u8RH5@0NX>5`rhTq zw@=!780S}krY5onkBIQsoZW7Q!%*!tEzBUz;q#N86FrybKUnDPg3XMSw%2v{{?0y^ z+LWMK@Z4E{U#08vh;@Gwu=cBy0V1FK;wg;a%FijEO@^&@D7E zVT0adFw0MOmU^e^e)Z$NV+=~%&gsujz4R8eMTxu zeT`S`Lt8vYayZQ>{4%0>-oAFru+w$`8Nzm$?IlGMd~dv{(1z(Yl}5XwIGu8JqK&1M zkWb~3Pq5q25q9Y9t;!FsMxVpJP0;LULF*4FQRNfu<#7(Lhed6)R|4;9yzeD#WnHsx z$u!Qdi-Z@s7Og#<0(Sm?KP)QN@WKo+Yu;^@?po;z4wXDJ- zmNx#G`(xFH80NX6O6tQg=8YbUvBUC(HAiKLPsiC7QymaBspSYSSZtyY`@KO~Qk_GD zKXGJYXrgWuLW`=?B_oh}Nx+^Auk*LM$m4$PmZ#P)%P?!n4ryP`GvX%pZ)NPt+djd6MEvQF_W7?_^Ci%496J#n1TN0ZvwyJ3{vj`Cq7`Uo^~*ptfJBKTt1g zKq`gQf?=9(c)>7D#dB*nd^s9kXG>p3{TZAb^7(T0t&ue*IC&|Jn1JgSj1&;gi&3g& zXnp*}7W6$720UQx)bV{yN2sLMBwc^CI3ob`Hb08qpqg|@)Fv9f!uiLL7 z@5F`Pgc$W$Cys+7&oQnfkst@LKoA9iF!D7L%M**>o~KtVg%UvCxnba}dEw}AM(pdC zQe@;OMZHM&s?bYW_(?bH0c?c0RS~$0_!O%??@@g5&@Y*4#me94f~sW}6{ehGli1I@ zmQ>xNTdcv*$?6*rP9?;Bmo^V9!F;D?v5qaXEE#VmG8r z3`o1qA&QB(i3 zyJ2sdf0g;LK=jUrD{LF}hXTgLimI*sulk-|vytpZg?w(wq&Y7F^CV9*I8Kp?`F2n( zP@=JmX0QUG!?VpRFmhL2513H!92Etjpju%XY}C~!ERZy(dp~0>lJn3HxxJYGS%kIdtO?6Orp*3pWf6I7#<&AA(muOUK;;G?!Sl@~COe~;Cs%DUCG{_zSnpx?^0lyd{v~~6UECJ&*+Ib)sm~qAfU#ZIW513{ z*IX6ue&OXlhH&4vivfph{;|N`gWZ4(q!H^Zw;tB^QBc+(48x$m4S{-?ylY zp&8{ZpM^T&sMl_88tSb0#a$m{-xY6Mc<`q5*|Y@+h7}924ltpQaaL^qVoFjz+a~Xw z6^Q>B>H(sEh#fq-)?l$Ey_6z$h8NjYVB&Kl<-X-rYC=W#IU_twHuI5S>Y`QAAH0gq z^I8<;s6w{(Bj_F-QWbeN64x5o$ zPb017OTztf7hTmLcHPHJyq8b=t6P-l&7JZlPxz0ym)K{NyoEZcES;Ol#^N~@rE>3+ zF7JCW^6DKmok=^KQE0aNEXl zN_Ie6$red|u=qARR_6%KAFxg^>avJYx~ynNt0m?UjGHAX82)>jv}?!LAWrlvW9&H4 zD=Qjzv~4gJtJ!_`G$%`|PR=8dP4&LS{zG$@!K#5nMZPEor!Q!Ed~DwOLKumh>qgtC zTvFB(YE+uL>blAfG+tCj{g{*89Eh{oGwwYvv%d34WJB!}Z4!>{V`3i3TZJ!5ZJyC8 z&^!6{o^|pUGIhXq$e;AHGg}rd7V?Ts3biLYtO6EV16b&Pae`x6;6OiN|B8&nb_;Uh zsV}aJ*kmwxEk|mNWf}ymSw*v><#)6T^gH)m$?@G+nH0J;kPNf^^_13g(3XcpQ)c-c375`i0_qUeP zcJ?1yO8P*;Z`LXS?K@LdF({Ne4-o~8-59(l^V4vK{rsT@FCf0^3%%~fb8OnrQvpcl zBU3UB@B#y_C=ijF7MKvw@QYFNZ>wPotjAo+DYG)&v@wFkm)cxKFExzMIAv!Xth8Yv z|D8Bcl5=^CCkwFSD}gt?F1&dEN9HLcE9AiqAxSW{QKkTXtO3JFJ|RwuK@qX%XJ`B% zCWq}xRk4no{-oSk*6f-7ULtT_Mge7tvrc;1bhCQX}bPS)BgW6Q>4YKoZ_(*p32lfXTGa`M5nNw z&!0lbe`z?87O($}W)=Jp1(t%|PnoJCWlvP=w-jT&KJ~Ev9m*LiY;+*4+xT*;q+I>C zP)>4S@%k?%X$BJx^mp(%Asr>;@N4&EvBpuJwL)qn(XVsksFFFkF9$OO@33R+%|oZr z?#1Hjr?KI}(=OVhg00Y>wVl%!KJXLUW#6BlPg(}O55{%|hIAv6GelBfsHNiW7hE_O zZL%Qpz6qa0;g>-AJH%1sD3Tm8ZUJ4Fxc7Q!$owoQ47$!!dVx-pOF;9w@X&#@!6^yu zJsuM-AQ?7M|KgT{=zxpDrP?)p==YCB!vx7RsgRIU%h^ly@*)O5O;Nxlvc7m!xWk2m zPaMCC^&jHMrNx9_pqB}jz2^aO9*-D*SXZO%)DW*6NP)N0F%~4}G+G!sT`6~KS$nn4 z`VLY<22}b=nbThCKz^tkHdC#13ntXX5C`OqAj2`4e5X%}88PtPycu^1Z_eq$}rs)m~ql3gT6SasMb8w&#%fiU ztTh&+=dzSp*%xv`r_HwR%8VJ9opge^Wt|X{pJZ3*6tpASn6PqIOS&$o5 z=tQ|pMX~#bpDX(~$uynO1H`#q0H8u07rwxs3$T@2y_TvFZj`^JAlx8HFGI7yHt5X< zw77XJ4I=_-pX`S@3M@pD4$uKkJ4};JUMtbr!1A}z-+E$xftXPb>{=Ppib!&xcmg=` zOZ{!s%Y04&Ud%9GPQV%fSSuMe_t<&j&Op8-OHCH|^2LHU^zld4U~&!EO{s6V}p2`h*6Ijk({owi1P}<)Ijbyp zI-Eq#Fxc%!ie>}tW*$Fp4%cnexdr2oAYNtj(|f-jEhl}S%#&B{0VqDNsFTy=5Fm<6 z&Jhtce-&0b(nZ5?kk^*#1QVY{y%sH~oMWEMC0aH@%iE3?Qa*&A{xoku>?0_d#4cJ4`*VTUef>KaOrsOKT` zBfz)9$jS!6&A%fP&q_@Q;W>2No+&RZtQ_mxjF}YwgR?caYvp|(iPPLO*6ZEH3%t<9 zI$S-|EuiK*Ngd#wJS)suAFYn9vm>w)+ZOSs1pa89i{)6m82(i*2GUU?t@^p=_!c-Z z+yG_|S$u3#k4Q+IH#5n9-^;hYS$2@$;;!W4U(89YPrZ4`aC;#W7?vDI*Vfc=iU@tB zCMUll3yd*EIC$8JRH1YXm!hGBj^|SKInhc3t_X7D>qF%u8FqW9D^vlX#`?{Se}6Q9 zP@|D0^P)M((3$oOqC=8QffF9OAGo^dX;9xeqn(6{-61WyH#efXGOWbhxKC^Hd^?Ba zNmx_WHE)U(=toPH6z*R>KB{ghIv;<2{|{Lw9Z{Zk)#OVDoTnniQWV7Mm#(uROs>{? zblErzNKxm|DDN~a*@NdDt{O_UC=Zy^>tRT4i%jjj+%sHqWnDvhG6?H(Xo^^=ZUjhlt&oS%S1=_g%?LbA+-Kj;NV;K$`N)wSRXha z`AN;4(?+w}M9-lhBExFGeDIPiVz=fRGP|>Lr8zsUj`g|x=^D&qAz^%H5Yt6jBAX_y zAy1bTI>tcp1gYWNPC(Sp8r?FGn2UNgwd>l7ePsdcGt?w`KTF+tB?J_cV&6o0qGoyh zQ#gA=Y#Y`<|5}vcDc8g8+TuLrT&F-B5cW{jNc@Y79|m$vObYpIkue_ftANF{L}j09 zAPe}CVrRZKu z=PX6l;ehRK_xto2p%XrH)`0=(lDLwZwwak)KkygNhdzRdbq>k4^ki zk+MP*LHW?Qqs4lqH!+ZrfeMGnw`YFN!q2`*<7piy%2x2h(211SDKX2-2U%Z!$P3aX zf8!7eFMAdj6*UCN!mU6jKq%Ng)yH_?u65x6YbcLrO{mGi7IyQafza;JZ%iaPa{L^$MKtvH z3UT>2b--#jSKBM>Hj~hrcEZ-PyQ_+PJSI@_H+q(88E)eVqJE)bN?&IEYg+5K?zPV* zeLcb}`RH334PEb!V|%IJd;k1L0J#x)j*aUr(;p~5;xebF-jA}~$}*G?X=sFP8c?qF z;gu49MWgae6}s=9fdkd3N}ukNsvqHZ!pLMvaj^`4RBgbxB`bkz;?@2>j~ysK(OS*d z9M~6RNg0z4o7v}jYmOik2t&}T^bidW$RrUgn-jtMAy4AL5 zy?ea{_Zie|@*T&K{jm4nG&fU^Mjts%nIsC?+jynZ`3qt5U6$+}>C4Bz3L(#5&wuxZ zF0O08Zyx}Sp3T8!Zl^^)pD16qb<9qT!FCUH%Tvc*(JHZuQr4t4arKFk!FIBi8gg?S z>6Zf7+>v4CZs8wM3zv`=rdaHo!@CtLkFen~y$dGPZL?Uwto9gVPZD)|(ciBRC_&!? zC1@E%rLjR6cD+=+{v_Y3Y2p~j5Ml}yTXvEgZH}+EO|DGm^h~ZCn68nL7#6z`j9SdP z86Vl}82)j^L-BS?yyp428K=zHJH_OeL124eQ8Wip7c}{T&@*U??I%a3uGMZVtUe-a7}Sc8~8c-5@*a@T+bam#sCULC!Hrpf73^7On=CCPF0jUSF;^9#og1N zR4XXchnH}A_Ba%yC`KBr_khPJGw3EYK%#8-p?($DsshmxG(e{yqoB?R!mA=Mm6AtM>?Cr*5?x4({=s#~>i=5a}^r4-3 z3$6Kt+jMzJX<1i89=0I)OUrUS8)SXQX+=TD#+fz z;3m&){W5}j$pgvz@-t%I^@u!d>Ih|)M^AaWQ)6JA%M8{u3yKlmZz^c(@3f1(WUme; z^PEe6TZ@Hh(VZv3DA1tv66hv+@3*|5u{W#-Q*FK03%VpYU#RnPREa}WjRVWDq0-7| zZBmYTSD^RQVKdgg=x|_ds5C{Vg4G`>o<`d^&042Qwd`)oou;#@p(}&+ETb}`$kB)O zI}{5sa@N<*mGBgjUgZ7rJ4U}w@y7<@p@1F|hzmYGFSsM*b=ArGm;Am~0Bk?jE{U%C zxBIvZ5?0R_m_Y3qaP-;+#@XCjaacJ#_gZ~&)t{9tNA%>~+UU9J?naNWGcDO`xv^X! zi_3Lv9Ev@NX5Z#I1G%fGiSC-pF?wS`ZO+leXQY4z=rD=6x68#Cd0c8yT@wt)IS zxrfcnb_f`-^3mKoa9l3Guve6IYjERRRWV8$oW=S^8(0*=SHNtkkNt!$#$~aA!;X1# z{IRR0!wz+zPWwPYZpu1P!UY6=B@5Beldua`9M_;6@wDkm8WOp#fFCfHcPuOkX44Aj zCqpJEMhCA`y)&Rk^C23@w9YnRVQ9H!mSeNbAdQ}0pg0M&{0VgoZ2#sP#RLF2r+G0< zi1ege62x79M(uy1ElEddltQcXIRz4}x%C@ODgwtUCpc%{OzJTb!Kp&v$~b2fC-fHL z9rhCn$}>&JiFD{AQRTs|8?(!Kv6;b>o$j*SBDrD?xzW)KmxxSHRW#$%#d##QlT!KX z@O;4$>Kz|b0?t`MA(Zy*l~C9Z_~>fHUV|RwS(N8w?^MsW$N3irZV0{D!Oko^qB#iqaZ8bW z;YFG$M{6Gw7;*NFvogbE47u6|s?{@E_An3ec`h3$JG}+!6f7zwTF~eZxuH-Hl~bCW z5rBn->t@C3>lMUHg&qyRD!A9PNhXH{&VlovGSY!r3&>oEgR@<05ioxc0&R2;c(ssH z&FVS)8JFmrME(4se^u)JJ)th)t}4fu)eNj|UN&pC60gxG6$rtemcjNA5)ETLoMeDi zT%uv|-WOSkGdnlSzl&Gj0*op;JgC>XAi%|pxFoI3FCM${DuP=d%izIa&-Sq5#{`q4 z$4UY}zjk&#`I`&7UUK2BMKdDic9oMR6zK~QGDj5Pbejv7WGiZ==2TT|zNQywof_!E}W-6B~m5?$!XE5cNC^q%U%lQlrE%vmS*V{6!(Hdeq>% zQA=y5&Ow*5)f<}onrB#lo$cnUXwp_wxC`I9L^$@zRqU1@6x-zKIIgWW_pYu2K1{v^ z3E@bxu*2c$Q931~i)D{{#4ekf)05@Qf<(k)J#l?|Z!rj6x4OA9LB3su?xk9pjVJ#6 z@t>E;cPOp6LL^A-X>&YS3@g(;Mz&7@Xz!QZxqB*d<4TVG_HdgMSV2d)uKkhVKlTjd z^tS_*c!0vx^E2DmrV>}MUI6*yrT`h}D4avVu6zG(Sn@0e%; zS$L*jp99j>4*9)|oRn@^pZi|ubb|9xgVrbxJLu_GEUwTm;0$9WdWu_zaX*!SBim|o zPo!oXenj%}BeK}gux6^uk}R*8eA0i^v*V1RNoo-H@r9^qBkdh&B1hv6tEv$#fBjyI z?ZBv;XfKOpCqmY}o{9!INNUpF#gBnPs80S%MXEK3?W6^pSUp8# zL0Q5JdNT#=ynmnE42nLWFv;i)EOYG;&uoQn?NTBQ34KeA?x}Au(XFgYSeo2t^LYoAAfz@DiNsSC4 zx1`-NJ+7;D&#|w;Hg_Ac#w0y(G*X`VHKD?4%eh_5UB#y1WkQN!-gr}2*k?psK$X~tw zpd74CBO4kI4cZnT|B_TTzC@17D>hgUsyjxpmMU?dQQ$u%33wPvP>VesWmxD0#kxc4 zX2yW+HU?dL!Mli4|I)boHk@j(&J1w3mzX$QY)Q_k;=ZQL=T}ay5k;LCeZxXpjSP3( z^0&G6)5|{f<=6!LSr`4R5l)L}Ga20jW9yxstTzbgs9h|Kw)4$LBuei1Gu_j)m;=&> zf!C8JLK7S+1)vEna3cG_$jzg=@)a31~`G8lvnpNjj`y%7DjN%^kJu5$Q_xTD18-Z zq4${aOQev)8a|rAe$PCa=-!gVYbaASGWL_^c zO4^D4pmxvFx<4RH$uWYR*X|4DF})1^-TTx-+P#X)wHEaNYWh8sHVskJU=Z_$60+=d(tNO6(kS>Z2F} zIg}^7C(??p)|gd`!gs9ZRXAv`lbvMtgFt}(W5HerJ)O2Av6%wK&-`e*^@bj)3UWOO z%dsT-gkIhmap}W!IdYG@o5XolYNOEy1ylIpUcIA!Bz9%8KB4?K`sdnB*OZ*`|6Ttp z!}?lvEwh3AB&`n^MRIZHYT`;R^tOW7Z?c!u~K5;wgyRi?UQ&8m4 z7xL?unF0RXNRF)TYqKc0R>hX1)S!+nEf6Cpeq>IDY`FHF1pJ3ySQvhIh&QE3fYwtL z>-<>sQ8Mvs`0js<24330jRGo;M@>Chcx2>Wc3G>@a z``610B?a)Ux9}kk0DNy6hp*EC&aFWb`%7v3@4%`Lb|z7f+rNB%e-P%1ep2aq-~hwx z)#aRtBc1{mx?RWC(b?pJ8Vk`sVniSHQGgiHUO8IJ&})-miq*<>DbjUwS;pIJ!22~4 z$dqE8=2UjGWNztNH z*dJbkxG(7mGe*A;mH+n0eX*+rajEWv4lf6t;8xE_CuZZK(nXnZGI23DzrP(C4u@t_ z&TgC6=$;!%R`-!0yhYR10lT^b*cEtHYP5_3!L);fu9n~5lblda-9cykd`;+ZcGISF zLK?XE{?H)WtHs9+bTe_kigig|H*k8+SbkTj{fC8Z5DL+Uy>rCWcY&)2tChoP?*p6Y zlfl&R4fWS4bGtca=WBSq6;usg=B2KOn10{qxyqs*o8sbI)0j9fP^w$f`d=kY&m?#^ z9#cUE;kDKw%y=&VVmI)w>+AM+!#RG_BmY0G?f<3L_V16c5($-yJHaj{IKI<31fidv zn(XH_&-;7X^SpXS#lv}gzvy|kK?FjZ;g;1&PoDxbhi5y=lc#&W8M-hO7qVQ?IAfoD zGUK3d&%b!N%-8#%t;5UQ$wssz$!rwk1Q!3NWiHZ+xRycw(=<$|CiDdBr05 z$O|`7%kTeUS*u%ZNv+KrTYP*xg4D+5|ILNV8vT{v^$XFPoq;VPT#TxFp+jUjgiUr=!)kLhUboF+YrU8PfljK^;E9^AjAnru{kw zISt;+-C$ftraAqVJn*-zjAjvkxFheP_2Ce%D$wLEh9$f{s$;Iom|zu@{c93zee9xQ zydpXf`mMs+`H>wuggAvNz^J@E{mMg(S=e?0`1_?r<5~S?b-mnCtc-b$ zSA{qfb?Vkd>TK*{6&G}h9Si+jde`SLuk!qPgxmT<4o7pcNnTx%_qb%_DBdo*U|}6t z_d2v9d>?(3MRbHueSQ))Q{qdswk$k|qeHYad1;ZG2drC%n1AX^JM~Ly&-2OUop0HXr0m$R`oQE>L4pSo^axBQj@v<_8K>dxL4{$_tM*tAp4pK8E}1YlS}p^@Of7 zESTc{E~W;oaM1-`9Ur;h!X(H*6^Nh+buBV*AYh?lK-Ff#H3D8vF^jN(Pi2>hBvxVMvPC0db;vV5t5Xx(9 z*zKIH_yZ{?5x|`Olm)K}FPuKaO+S`lBm~BRrZn)AW-xALS5_9~tahbuf2 zO?s11lF1UH%FzMOk7FW8OoU8O79?zUd%QV7~*w87$b3{E6 zm`SX8T7+7Zczlw(Rm@kf_7Rq_n8GJ!G)&zJndfwSNdS3VGg~F2BhqV(;nLl;DoIJ$!>^e>`mV*9-hOLULBR?$4a*7{1@c|Wk zYR1j~1uFjGsXo(|aWg1nL)#^~8Oy$USlG%q!!b>kvh*}_4fn||#MY;OIw@Svamqc3 z+=7u*sdX|V>1Ic~o?T;A=j4Xhv-W2d`F3i{#x753wOpFT>SoKFHmXyDM>i=OJRL8GIOF(tU*uBHoc&w^{{E zpL?t2l2C44j7s~>?325L1-Lo)A#mr`escUzvtJ$z*VQ6s!fQ~%$H)<$cmjLDvcqbO zvi6-bJJBK?gV^NvxX>)6F5n1`DEuJq%x}DvaDI-8#tf-=IbzVDN zx$qNNNa&ohy@Nb2U6*IyE;R(%yT$HN`tc}yiCjByoE6H^ns5DZxRP>*^#+{VfhyK* z!;a%9k%I9ssQS#-XkLtLe55G5!WX*Cc|;Dxq>sXM8`GD`tEP6yC`Q?w-A7!2xVM1A z4$SjHVYBN(7mS&QSu&U@R|jL=sBD+Dd@3~N{IEJKP^a%J_9uE_!gxZy!HQeuJ-7zl)n|Xg|f;%E@ymR{gfFcs3kF;YyF)I1)H;>V&^x!{OvL=A6Dry04Urh$7 znlGA?9=w&6OLXXU3X}G*3ugz0!i4=V9^5(K=+DkFJRtY%BUb$Q{Y6JvgsL|0%slzD z5Zy@HbKjd%n%G%vg;!vvH14*L7qn zU#`DzzP0d;AL-9KhTU>a1dip;nkxlX8;IUrv-&z*jrmh=;mPPTUa5pHG7NdxB;BZ^ zdg6lP_eY{W2tS}A440++tH9IQ%;ov<$4MkD6LpjAMB7~n(T364D4(B12+O0+zz_;2zN>#90aSStHdSwvO;}qeaaa{u!?NXKqE|^2e zEvUsb#g!c{EGFE~M5e8lQ=^_@fD?b#Lvb`|gm6x%E`G+o@n_gY3i@AM4|z`*zN8{# zNyv-Bf9yfTH392GGUbpF$>5?Jh>W5f`cZ zd0S>iCp3~1?&oU1+?8-9cC``*)`J{ytK)RtBR!nCLXW_Tap12lh^zh%A{q6`vmNND znTW)?JMe=w6JE=nR&p}e2-lWsAT;5MZX(g6dxy#;^nOmxZ%_JAQWC``COjwgPWf$y2QHIGwHd_2Hi~0Ada)oZi_~~wv{^k0Sb}=qcM;45jWjGn(N(7b1c%9 zW-Z4+n1LlD=O9)dA=f3gORfyIE}-U?Ltkb?&J-DsLcCYW^&kS{>S#0WL*U+;A>?`^ z$QOlm5W_AI9&hl8ej}S=q+5We;X5C&TA1?-yT=CcD8Bd5LqmX`GJ&tTv$w}#QtOKh zxSAij=!T2{Q0{dt&;OD5Gbgq2r#keqha; z-4|ohU%lk{zlJ>=oy-V}J;L_a32QT%d0bWKgh@x9MDtOWk`d*SD8J>x2g!uN^y7kz zh3q*~=|A=*&6fG`xwOomT zull9;a2$b|#Bp9&{hOj!Z z5hYA_^7^R&TweGMF8`wl^|}O=GXH}sZ*Uxa(BD2cF9%CG-)K*}UczKur=6j~#@pi? zpF5$Gc^ra`A3sxkc5K{Vc;sIQg1bhSo+f4!o^(jW^*8=_Q~OsnQtZi6PlFX>ZFI1U zt8$nvANzW{F5jbb+RXy^IUvDZUb5_tvuV_|;XDgql}#1LXM*~)|1O6ry?nNI+RG-$^@hklCB_(7 zZ|n7ZalTQBn;F=QxNVMHQ9ZX ztu_MppkjvW%1He$FYp20xosLwMk+O%9uTL!d)NMi`{WeLapVVTR3+*8=y2Cc-sC~! z$FN)9IAIyJJPn;(`m9s#2)<5rBfUxm78bAsL8&L3Zkw92mnhc%IXF97B}hVJjNfe; z2#Xy3mbFOT+2Bc_QX6GpGm{}^y}k%qY_%+voh%STytvc|wTH&Ys zz`04!N2uNf4$`zA=_ZvoQv^F4$Wcu!uf|U}rlge=bm+Q0pTdSh&TI|&BAZ55Sxu$N zudb1vGXWoG3w;<>&eWWlFZVSv8^}rXznJpLo0=^dOe^DEOZ&a%WCuGc9pPm}(`@2q zf8sjZk7mW73wGkPznSuJwkhg=%aq&fzKlMe));-TCIrXOd}l(km%v1U#QpDrgI{0MLoJ~@wmkHy|;(X!sF5ciJL#U;y^D_8}`DZ3aWQ6wtfyz zYZpIE=PET32rVtO^SSXb;@Z16mVWGGh9M6!#Pmu^+Lf+hYh0JXxO><4t{=v=yT150 zrCl#Z%U&O`(!j^2%2tjdD%g));~Z?yrxDKT3Qi4jn>3$QiHR; z*)H_#?Q=eDxEnrmyBZR3r+)fV_WY&1jjKH{*sIBWn)k|eZKMs1PG0hL_CIh|R^{^V! ziA-$aM`_x$&rw2+x37FTRoy+I9KQuK6E&{p$LZDl=Bn2vNsv|IXTl`Zy1#N4Zbk%w z1}<(j6gv)EUFb2r^BMWjeY$-uu*x^t`#B%1<^5b54)loToL8J?!U|ijNbJ3^JWlxR zmwXo+7L9zh+VN>o&b`rPBm3eZ@ z#LSa2ab|-D1rUD$1Kc5ppA0K8Fl#7e+Tn4$*hJYtDA+w)@m7LC9!vToTC5rT>H@;- zT2QCg1Rn6ClL~I@#GAZ831F|lfjZai9w4hb^KqiX2GAq9um1ROMT8RJ$b`lXgCE$V}0SL;iZ8wXVA=2gWz85Bg-D z>-(U?8$^|A<<{~thAbqxeUdxq#@?CVM65Z;=`3yWjlO0z(qfX3jpTS1+#B0+ja+h; z*1z~hU{niB^R7vNteJMzG=us%)y3m39;7p_-aLhibM*Z$g5s$aAL$H~`DJgbx7LS$ z4?J{Y0c0DbwrB&g&2=O$a|7iQ)4<8RWNJU2;J!>#!|c=NCur_>HAgoY^2(Gtoz#jEtu9P!qzel4j0% zO67C@4iEBJ7x|fM?FG&=BC*=85@}UpZP5xJLLCn2Kl%K?tPsJI3i3DG&gL9&hO1e@ ziVJuyq4w)9NT@x%Go7%>gwcBzc?abQv)j^#2vnc`$kwZO%pc!Oi^1VEw{Js|Jffy$ zEh<_S-etoR&TuBNY10K?-etWMDHHx;R&YJ_V;gz2No2d?Z3hE3&1uEWiC>^B)iR@` zId9k6{KV>7++c?XjN8+y%e8ai1;AH`10QB#MjMpXxWzMGb; ztWr~l$C7-mDdIxnXw{y-kvXFh$a%u3!i8PWcDE%h^i<2=M%ep46Qp*wy*j812GKmz z#%!~)en5NDAOkCNE1quLJnc1|eM)wFk%G8&+u8?#-khiZ+?d>xoxpE5SX_%Z=lga1 zch5L2vFKkZIyaME^{1(qCU}Z!;-c1nBi?ijO&6KSa>({5=qfs$g1&9Jk=9kWuL+Uu zcp@!?Xp$5I>+kPmrk4mO>F#Q;`0c%Bx@6cr*)w7_6KJ|8I00yh!V#AU`~LZrae%Ph zOJ6mc0jp*!^RY{-=J8@R1deF^Yw|B$>DKAn`Je-Yv3eStfJ+T!;0_;9*CHhgf{uKw#0h!x` zu`aH;b8hWdLU=M$+e}3v^=19~?Y1dncOSqZCd;AVp1`);-U{Ll< zXgkUBjqUyslQ*|z1w&siC!|0QB{~@`_jjhzN%0B%2&t2;U}R{I(QXCC#^H)LJks}H z>9{y5eEXz~qIR-|`|! z;?t{6t1*5NG;je1x1q#ly`}5Iua|G~=-3W(WR}v~^w>*P4a> zS^3PxoP^`llLcF&O1aDUB_vgp(SkG}nIN67i+NA7pi|dDddQ3Poqq1Rr^mHG^(|MM z&^4W%gU%O)|Em9W7&hK?cPoocBu#$R)(~oJT^o)8t8F$myP4l}3@j_gO?+b#YRZpI zB=2N4I3BkXGJ3UcdNyVqm119wXVbs^R%X3gyUw)K2B@dOea(>>=5&&<*dY$PZeRe6 zpYMyM`_@b3y}7}NowE2y2@)kO>Hqn$@YqNVK-CXNnF4H8mw--5qGU``jb^x z{F7Br09FmZpD!BIf@Iav=W$RSCujI}t8cm3s1##^o|wPxL#Y^POdQ6G@1rXWPd$*}YxyQOVR) z)8`gs_G?6j#kwk(v0K6@om6m$8x6IkvFS*0?8Qj>=vCr*=aegC4mYz}-uFFTq8iyb ze=1feduzpKu<*WYC0QQ|^SX@qA+mO za1`(_eb8bqs?%6pJmB_mx_S!40j4JrHm8*qXjQr*-Ybye$|is-gT{1?SBPXB;o7&K zxL=mJ_StjQ8Ard9NX!{`4ebh1lAY?1g0HR@duQ>h?PTt_wI&t=J@**fH7YA=vuNzK z;b;l+MW^Q#{CB*)Nf`_%Jg+vx4MIufO-(HM9($pMDNVA+8IWePbx%~qEw9^HtBzR| z7t}^L`|l1sRds##+@sz)o%LaDBN9?e(insfP1ewWhZDz@lr-Z-%qF>N14@kbwkzYL z)6ez;Ns~%&skR7&e++Dg&rxL|rZkn!;a*=>UjyVR8N#(PdXOS2)kMWQ|KF80h|7p> z$-@c{wcu*zT(Leb>a3rXNy61DuWE6N;0aRY;IqarLni?mPXaN<|CwVm3fJ~1B-$&y*F zDG)G{*UVWY2lFEe(Ahq>u*L6(ngKQo{@{ky)8A~c+x+L{=IiV_?d20gGE>OGyNQl@ z&%K~iWLIvcq3@$ory~CC1oq%&#yyvFLKBq+v>N7qkRZQn=h+v#t(Qf9QQ!$AOtDGOlLE3yA#)gPS5AQjfSfK_w+V%2xe7P;U52(!pZ=!%SX2@j3L)pr7V zX7pb-IP#qYZ3*!IpPZ$QKy7$ZQCOubyye??6uMqn_%EC1(7PGDMajl267tjh9O422 zL%?vEDnOZpl5E8EtBErXlf&0-#=>i(Ws~v*QoLV=d0cosudaY@XE0N~&QM^f+eYKITh}z- zNWmM*Qm@KAOAsYf6KF|Kz=oo`Tc&cP{j?u1T?_e!HKc3PsB{jmVTf*0-&@_ddQg{l zZZ0=-rDMXZT6!dEVlcpETFF4<P#=%BAjB^hF?PGqcCSL7+;^N7|;9q;h{ z=no(hwRTuk8XlQK=diqx!`K4m5ham!UE@^_-CDcQnNt7GvHIakV-P>^Q;Tmoc)Fs( ze9{B1L}5Kv|6&OU&*O+gd95r))LL^sge9r`+yFvuTEsv|Jx{|31x)Q|%kGT_E1wbH z+^4Es`EJLFRId-a#iDHo3G_))hFJstK~pQCS# zXWOek$gNGuI?s|BnL_XZ`SJdPY1b4-OCm}1-fBvzpO8IIFSK@T&pZVl-0q5(x}NV_ zlkKJj--t|Wk<<($?eV^4h@yp{Oi{bMm&fM zV637FUd~5)v85x6F3KlEyJ*1MS*I>CG*LsVsXeMepWxxf=Md|{cCq8hUHh>g{CvqV zgM`!GNudNlXuSE2p`XtG=@)o7*Z6Ew-M;;h!{`QGC_i0!0v=irh-we;VCeYBq)#V*wC^y#@#P;S?OPyNm2lYxe{P~2m?%-=TmM~C5 z&y!q()2b7WmWM&Z%@0S-!QQE7EZd^6$=52wNrZ?f6AoS2o)C}B#zgeS(ZB9oH7^{2 zCBiKl#?2dY>zYOiB+B0w)omS4J?r=G|9a1#HO-m3(+Kcmia7p7mbwfM8cvzo4;`sa zJlX#2H*jhhkcAw*^7;3E}ANX8m#aPT1O5IZR2L#?U6#PAMoe(FMxau9hvY+;5 zRJ<>LKdgJnJj)_p11Cmd|DPW8HG;Tm$uNvziqZLHT1+M&|*`}rPD=|xD2_SbVj zi>bv9tfmxAz;kQS?&-#kyydO&>&HyF5ii^1fI`S*#n)#@GL=OSJ*Mx7yh+w70k1m| zp^sJkZoJhHL8KFTCwn)P|82i72SLgs_u$)Z@~&b`Rv#B`DoWE%>MkY5(J)Sw7%1u9 zXR04o^TJDcrtLBv%SgqNfF2hWcb6EDWY8ZDveK_Ac1E0U-`V~0qjqJw^Bgn>Ol}-0 z4mzr}@sR^}HSb%;Z!$b2-X|Gia0poonXpz#)pZVseX&4Mn0Zb0L)8R#ym}ruQaabcws@%dR z)ON_MFuZd38@qHGb8i#!iY~SvVMBFJC8;YIRw$LxYb=cZ21goly5oIJr^t)N$#2Xw#`Ta2?6-f5&#z+H=kYdRZhsw!&5Xw2s%1%T&Mw^0>?bvUS8|dE4dXlonZ!cy9Kdm&s*arK=O^Tx{liKLf zJC(NPjcW-)FP2si&*M!4LR!7Rsfqac&<%IRdxr{s`dR_u5YkvMjyMlvrqtc-&Tj*A zYYf+@d_iT^~}s9GKz!u;uQz}ZBGC%(J{nx9cMg4))EB#iG+xrwkqB=$Uyz3 zG_eO8F#I62ak!C}_y!qzM=**{4V`{`^J&rKy*O3vV0!``K38NOKT-*bWMdm{W|UsNFy|*z{!T&?E>iTg19UOUrT#o`D>=3rlHcOc zKkzR`Z8eHPb^HI=d+Vqu)OUYdMahv+grPwcFhIH+1wp!z7LbzeZcs`om2N?hlCB{} zK)R(%I%nt-c%MOcf6v)xt>62{8)vP(_Tn!c_I~Dx`?{~sO&ncNny)M8^4Z9Oc{0l8 z#i;bw5?Oe|yKNooHvDd%s|+T1b38q(Tqd^19Tle%*9Xfh(6ij7nXSZ*n~(K%7E4s1 z4hmVrZs0)v? z9_-1{8JL|0B90Lx^=w?#J1IHl+N;I>rw}XcuQ$T6RLcP34b&bNGE3PxdwrzR9eVQw zFWsW{wWx#O7>IH|nYkC?I}1fV^i+gN3#EPGHC%U-pXi%&smOu}mW_M>u9&5#>a7`9swZhgb&^ce+^sf@2?uh)9OO0>|{18U;* zOgyab+^Ok2R%tV4fIrX`MyGOvUudMKrFhYnQs{;ypv1v^O`ezQe%ckrMvqZe8~?-^ z^W>|+f|sOT{J@)y;w&@MLpE)r&@mH@6>W6yV^AMg3A9079i$tHCMur4D_Rd|M)fg( z9k;?I=-P!t5kkxzJI9BYL1baw3tC>6S9PEbwX)Hh1*hAG)8DuukmSXi20ExSn;F{O zET)d)stW;pZ2UBp(;Y0CiOJ508^hcOr(b4Rf@K@b% zjsxF{eP5-(_p5sQLL?X3DdA-zQDerY0Ux0M}WJzhQTJ;zjtq%PXNz%3;*7Xs}j z&d=f`?tX=fp3#;aY^d1jyW(CZy2RtRUT7_Nm*O;E+Pb;FATb;rp}ZWJZ{r8o4MN6Y4j3|F8j4bxgPfktrshbd(7nKDvQHk@b zj;+a{1BxQE=`W4eq=9O~N8UYm@E$T+@|+10L)WlCW)`;3v*WZ}eG|l#>D8O+aGaO% z#}jl$io_NIlOL$>zciVN?19%dZqloIU_?nlmG#zgr#(L4zcz|hgtiQ-O!+-)gPgTU zX5AbUM%P`-|B`V%kcxp5Kk{0XY%xCr`P>a0<$W0;S~Szs9X81b3)dj)zE@&$bIa1o zv##LR)4$6{<(5DY%}LjyGBtLjnt8S1`j^R4z?)3BvPRHW`bQq^dU+i^^f*0S z`~{DL(nKnxXDY6nut-RifOs0X>oNLF=zRmMdSE7>tp(^S>J2m? zA{6MRJ;_sz4@ha=W78)|*P)*B0Vxy4DO5fUSRsHNZ;f({9)m?HQrpIyBg=CVC<&Mu zf>PoXyv!EQut@9l*c+f`LTbzs|Z_yYpLwD%>^U#Ks_ARgR~A7*oZy_%7S4Ad|!wWN=omkMvKI;QjndD9z!4>QAtAc1R ztvB|~c^qEPw60Pzmd~cvT*kno_9(;tB`NRyMN;N?2_)qJds>SM-58RpdZ@(uA{K--dR;-11v|xJu2ZYORT1QZ}^sVjku`3ic#AF8lE$V$$ z!YU9{;|aljy*DUkKmdr^CeEy3bnh+9XqGg}`U?RQJ0`ie@!C=;780tM#mY^s9q$~e zYH|kOqDf4B)A(LkTzi$WZ9oMn@LRK7zy4NdEWKM_=vdaV^3UyE=Yt6GwL+PfQf&y+51?>rV<42pokzeY& zjNGCwg7)nOVx7l(mx$2O=G45tfi5=PT|SOJF#6M;J;KF!z_br2e!ZOvbk=?6fJ>G) zp+@OUU2suRY*zdr1s?Sts7U`Hbu8W;fy_9q6#)_djd_b)^Tvg1M(Xd^FVzId+?)$b{spidaA zXP1wQ&r5E{dP34dNNu%b&;w)cPA|?JI_9#X?r?aoh69$(eh28#IvSeU!Dw8<=VUTZ zYfhx@*|450_7K3XDS!D`UuPETzJ9(Rl{~gP-;WTp+(dvd^S*mB?A)N?6$VmLIkbRl z#@eYBm{4P9=x>jii?aw`?{*=EhMhHisatB4Y1qcQFY*OwxA8`K z9>r82Pxp<~&D%MN3S}`KMy+J)@Uztkj|K z>Wh;Mm|206v8KqThnQ_t*fZ)0qSu8PUUe2SzJE#cf;>8(`Jf?$Fjd*w(fD?v_p7%S z$oY(PpJ#a24WZWfqXg&@n$0%~*$8IBSzZOsU@vyW8WW?Yh$;{Xm|YUXbI3s^NEG>a$e88y63Dw$pD>5kw2uPc!PLixkl;MWn)dbxPimb(#1R zCsDMqG9_|@3E+X^W4ny#@0gTk%Msb`KrOyYGB(Y}ojA(JpMZ?d$bNWWk`CDsg^I7` z;P+oN7XJ1$FoAb|hm1}?WFwfhp)1M;{T@n~S3uFIR=#H91g?;x%gHkVnDCTkn=VO= zSz-&;r|$PkC862b6B_!e%UrZu0W8Fnh$DX4ohjyw*hK@|6<Cp-xXbAMkwvb=0vA&;JZ zH9j7_@;3lmdzz*YZc5PDVkEOvF&mZ(^zP>1KLJ=FQjBKZ{|dn7RcF``ut94eh_XsP9c8z_T{hd zqs~LC?;^(Szi;6hv4Un;`@(}PR{#B{jjUe|uJ~bhg^3EGTUGszT#udtiZLXR#eUvir2qg?f)iTtr^x5ZJlGH51-WZ8k1K26ua9Lo$1(Ic$cq*H4fL0i6_a zovP)?+ykNTzwgVVfj&9xrNGlIdGP%;<(QLHQ|Zm_vejMH-i-;h#wv7!n>& z{zr)vpfX~H1nWq|(;NNIo7+X~Blt>2NlEvTJ%MwuaPQZvx~u#hmAV4n5fp(9DQpEl zS)5sB#E{LuaY6k$y=L;5+7+`=Fc6&=b{uY-(vsVBakoWdMqGFNxD!kY_)w5Mx6TfN zekgr!Dr5S}Q+@3xr-ASCR0Va?A&i~Z8q&w!Iz{Z=xVNlMcnnP7Mp@fh@1GwbP>i-< z_Mw#)-=7aMkno22XBO6YdZBT(p3P5m_42q=V)YUFK%LC8TAk1f;S+t;S5w_k`MTZw z^|e*CCtr%y@ck#7RS7YDSE{f5(swB9u6;A!Zs@b*|%RUYjIC78<{EQq&Jn$$$F|e$v1{{{8B2Sn!`j_nbYS z^KjJPz)bL80`Xq}`j0>R9LduC+y7P(YqW$xHx(-dfp0=7Nqg$w=#3Bl5~H0ClBEAa zoqK!Gr+)Z~DR)Pp!B!=W%x)heMeY`7f{u=gKOW^YM*j!QRl>33oVn5n@KPOS#_b%f z!d6QoYmc4+@KcPadP0r_dT$5Ndkb7br^+Urge6gp3LDb1%mwzMHQ4W}u+i5i-o=l;$^mr0Bv7H*XvqBUjv`r6}GUf)3#J=$BbtUux zU0FU=k0R&Pqt$h;oHK!aL)HT`!9;-+Pp(qkyk#D;|^=?o#Qz|XYeHCHte|K`65|KzGgB5MLq-r zb?Huru;`E%x|PQjTEwrwjEdv}qh#}_Pxc18kMjK_HOsQgjFZ#PcpQ3Rwkq?yZ5W5` z_q7+EaD{L3Im+|I4~>5NlxjPajMMU?r@Blv%nc!624{@t%KfoyeAMT=`N3j+wC!Nv zvrSZ6vPZrg?wqG<#L$O**Vz&5DFIv?6g;D^*(Dl$6>E81+^u-JyI3LEe=Fx~X6GC| zxR-5TrmH#WdLx|(;29Ll&&u-j(TcZrwKCWPRfeKUlG&H5jw$IOFz=)$vhJwxu*Ri& zl6SLTlkJiUx-V3QJlF*?Z>8#L$b-HLwk|h}-j?{)n?tBa8!DOiQide!@J{Kh_F$E# zUt6-fS&Z~JD7Yq^lQg6MMAAsz*{n*k^NFjK_j4%0jHs4l-7t;?Tf@IBwG??vLO-kH z92GMi-ar6YE~w4@;@Kr;`_wU?b+lkqXyC?m;y^gQ{nMwqtMD4HAZA zO&umO0gSAuNY})Kwzr~UZ6lcaYh9(PCqR^Mzcfg-J zS{Goxq?FqRRsR|1Ob&+e&Lu)Z0MB1@z-j%8XFx6(AAA8!&b=V@`fcaAudBmH<>`;& zQiObrWL$j?P{V33*!$4r=VQ|VPl?+276mpZUYAXCLxG^JHX?LU{_D|)^3}lyNMqY0 zPfatA*n#W7)!`*)ufxy;%uRAsF0R55d1WA9yi)oyrOhmB(72JB@`3@KyNfofQZlv}Jnp%EhfRMwUm7Jr zfOfR!;AVr+#~-(YP(0N+(SFxs+#Eorxr^hleJg>}@UJ!F5syC@%SQ0O0;?$}TtHVf zajfOz_yopr@?y&gDByo{dt2}^Tw7NmR)$1{yP({5${3lbp&UCjs=UAA`+yPmXTQ|1 z+WoBKhB6S66&$Wg!R{7Zx3ayGd4vBtbFA+oI-TOEby_P^m86XOwoD|=ZPw7BNvB;8 z>AR^Zo#Sp)AmLd;Y92J*Wuh6~&+bM5^E9%-f9tg>o>9?f8R27}` z@{TC^-+{@y_Y`=wSn-%{Kajjnt(WlmDlA)9x-T`F>Uc(C&ZpO*yEf+dkSbAe3l;C2 zW6wg9C0V9A&J0=&?=L;bky{`F0BRyzux^b!*=Gnn33kTYliRhyc+_Y6>VKK*|3{cB z_#QYei{zM#ah*&Rv5kunqmLK5k;nc>L%7sMF(z*F6=d5!fhl3rdi18=h;1xF+kt3z zFDlP`5>z>uxj^DvyziBJ6VSIrehpL))lkUfHt|VatIz*}*8iJ8>;J;le}$_F><5i} zoK9-3{X~J^HnQqAqAr32gIlb0iB>YV(DF~mPXVD$=wO7ebKH;=-_~TdJ5xa3h}La11OZzp89jvz9O~}&q&XQ}Mj2e# z+=LGwQ+7Rotbh4pNEW5RaF>NeJUg!Ab5KrOXcR66!wmBbN5KdsOr%HSRr7wzKp;!` zHP#k4ItI<{#U$N`8E`8)?2bjdEw(YK0cP2FwC%s_G? z)b1pwLh9TvlDMy3MZ-)EwJ*Hm*SK+eM|!aSvGDSE;Y%WvmD`T8atk~-8ICX+iZ+Ep zV+m#ZspqRQcD0$&1qhn@)|SdGJow6f>5l{cN0ebJ@>$XY$6rnFR`|3v(C> z)IHrJ1O(j6dOtpgEzR9N1!H3tvC*3E^J9$SCtV?XUMPYduQ%P9eKi~Kg6@?|gc}`5 z|2g0NsZ&OLBV-fsbDuF6^d8J~k}W}Wt#ndpC6oh%)TM5t3m_zLLz+pd`fI3ciwa{2@8Q zhu!NaHsdR2tr++6msI*Kd%7GO0pOA{$0P#N|N0At4SXrGsM#EZg-32*;561V#ONYw zh4zD9)#bIcqdFmvG{LhU+dmVZYsF^Hv_DvnW0kz8y**mCJ~xwr5IK|%5%zBzFE5z$ z@Us?wTXcC^nx@8UuaWr<^UC!8SBC4qW&q<$2V9r?FjcSX-YnX)!PfJ-e%il@$>o~M zI_bJc1}gE{2&$5m;0eF=qBqa!HLcf;=iS?_O+S@e@D?&~;wPD{5yW-~pV;7@#YWG$ z1#CoJ<~Th_Y4SbD54zI%L*jlWPv`Fq2V&URv=p-1Z(4pdZ)s>x@B7*=pn(?^q;n)Ey}CXn#D)4);w+%% zyH_+>nwqd_s9;ddW?s6|cc7M3>JG5cNAym|($!w0NK&&7LM`JXsjcS5Kx%}C#IRyb zHPp{ygt^e>btvKd%S+rF=*bs38BBO&&p?7XGigRG6DI4yGfRo>zO7Bh1P9B=N?uG< zQTM^SZ#L-x|0wDv0Gv5h-oCi;wrl)0{`14{VjiCjJ05W_c!L0`Lvxs%@z!&yv%ogp z;Z??r5JIV{w(083#Er2j4zTeAK7)Qhbzt#2p84HP;BhZt`lGrA%*OBrwWxS60+|x) zWd0!{9ieDkI@`%xgQww=Jr8l(boxR3qlB1obqu_k{U+h_Zh3Z55&cDqwnplCGvKih zh6})IDzb4S%*ot~hnVimOuiFRz|P`xKo7wF9OH?+&}W?cMudy{2j@#OA=68MoL7LD zO70dXT|a<&)iE2US(jtf?hg4jJ_(G_wl|QBb=z=5^WAtrYT6{jQHVZ1(Z!#_!UsN{!MVU+W{m zz7vXNI8eHgD!p#6x72HZ7JfCw$sRF@jnql8d&(%+m2tV2IDvHlT?Wv1ECTj5*TtPb z5&a7b*n}gq1JjtepUQZ-6GPu!5Ep%Mtv_j+6t#i+?O(OPMNo&zmiROr<=vvD81qvF z7G-qcJ7YJd{D$XPW^$^JjcTl7?BtC*QXSu&wes9H06(Z9VuM)7il((-jR=B z{PqhiN+zW0kbWyq{jkq|BY!)oo>GJbvUDt_;0)A}ptt+9h%b}46U1I2AO;5Tsut7F zHmOlw%m1j)Hue)ymJbf`s_b9KI5OlzwX)VNyTMkqfRbT;raFW(h(5IuBY^U|5Y-^> z+tD2&2rR$=UHMqzOzs9#_TI}j!2`y@ zN{bpfn7IKliG^D@seHgJ`Xw1GRgP4jT2^`blk}Slxw+ieQ~D#4_W5_MSOPpN&D?-z(TRlv!C}P#~u?{ zF|Ad!8P~H&m%JE#$ZC?iCosS@8nR~#CkaC~7K>>#XI?i(HjVU{RKuF4`;5$f>`}yY zk4|ge21*@9mXcdg&~?u!!Wc{{i)bGmO!!Of8_#&BAzz=izft;ilAo)xtX+EZ8Br+h z8-x(Lv>fT;-_d4#LJbwsdUTB=vPHuU1 zN*2hz?JfCF|bzlfiC-Kn}rW!!}}z zTU+_F9+{XooyM1sJKGQtaI&}ngT#cC{o)bB&UJ7sjGqSepEsFWC$W)039x&ZR)eM8 zH3J%;2&2;yHuvt-^M)1PjN;SKD&d#HV<~N!bFri~K8*P%Cf_0H*%;WlwMcYVosKIr znbNcJ8t8$R9aum8*un@rJ1#x}3S8ngy%K%9e(ImS2eHAQhIHB6SQteQDn4ORIMvw1 z5N=MYN1XNE*-26)gOhizNX*tBJeg@$%S%H%yN&@NW*@o_vV4BI;tnEB)2pr2%B+y2 z14eFqli1&PRt_+Hd*~kxs0pkG3md%eyLw9th-Fu^3cCWfbFVg3;J(&_ni9xXnrky5 ztMyqA+i_JW`08>%3t()Gb1|O#6CKIH(A>$KzlM2?nMurPEqqaYH@NAUjntz+5ZzC1aGP+#gjQsx z^3Hgqi}>iYz1f&Z;Ayr!xPRH7hVL^C|C>7dWtR-8$0pXP2-U`;umrJ9mWx<#Sm;G~ z$w_3CCo7k~%u}=(hVn5l>21mxVIoTXflGyhej|Y_z!NfQKfyJz=4Oc>^I)g+>ub&z zOZc<)N%|UrD#SWeIR*VADU;A);K}-cPqa7#b>GxJqR=KbdLefyw4Xg0*=)IW6#9yg zRqJOFHr1l%p|0PN-0vL-&)SW@jVr2ec)Q0^-Ubad`-H%dopg}HHGMPms;J1DO(-;~ z2+SFt*=G@l(3h}rl8fq-vWqvBQeACf4zBXvdiy6scAtDcD<%i|_RAuuDT7hM9#*HA za>hF$3+kPT$2Li^v}n^qeLYO+xKh0>pxE02APSN3nZ*FPN1HR6u<>7UiKWvU+&h_^URDd6rn zmvX0^a^D+Y3URB89nboD)XQea$NS9|R?{XlTGit!$b{r>(B?f(N{_tGnA38EG0;!h zel!Qs{WFo)x5xh*FD9J?)hD_F-l-P~0wevE;v zmML9@7{7qSW`rhDE;R)|rFCeP9gr}}-0p=I|UYB@&R3)my8L`Q~whF)%RZ2OaH zDlk+~Y81icR6)Z6m}=>Z(Y0zOOnByYgl>*xLET2R&b_c6RlI{~G>(PY*Mmd+%~oR@ zAlW$p{#rRo$4zElPGI7N|2b4T1C=mw_`qbky<%Aq%HY-`R`_QV)>6Tb) z$`kQP(?=@Y<*$ z*}$-X8_1PbfFUZxfywwWd|vJ#-|jB}Sr@wM_6r{q4iX=sM-NscB69bPRdXBHTZkNRY;uVT+oBE9jv&J%uoa47m zf+TNRRw^&;L6jV61jmgZ?F?T#=R{4#6RyLR<%fw()18((&3(jWcd(;hHC^~iez zk<}%Zn8}DT{Xw}u>Bj!e;3T5l+@}u} z?d_+Wm2SrFspLLpxpW01+5m(NWN%J>DbM0=+K-nsrSn6)ZAd$6*Pzd?n=DrM@H})` zDTZU@hSt*6#rGAw<$9Ar>C+qi%ctlbeSB+^2Ptjfa$26BOt9L+?|*(|NdJrSqP;w)yaJ>5 z1&s@;#KTUIa5J&1kQAL!h9^JhTECam$&tBIipM+UL3k`^y-ov^DdkfL!?^>KZeUwg zC-jQ@ye;*@8A67u4&EpXZMOlQ_OP?V%d$xTRaJ&kxy?p?MCwi&{0OJW*`ASD@{)z6 zkej1~TH^4-)Roi2XRL+s^BuRD5D7u^DRFw+Md6w1G7}FE{ z-pR9hJUnh@={o-@zTyigz4GLl+s*b0|LwfB9-4O3FF4bQ%@5O_$8YP^kDj@q#a*X1 zZ+V(e&!*t8b{M(x^j&Ub>lD%>#-&!pVeJ$HyaE&2nE0~q^4sst_$^U-2GxYM*TuV3 z!uQ?u!P8IjBJc}xsAy_;zpGyhiMm+nT ziFhe^u$E`TD%Qn!4~Eq8-rC%-#-T9K-BZ(&WcFjoO z>hkC(9JRXh-Q%pG=^jl|to1AVL@vz;d-^p^&_-(IV>!j6vcJ@rPOomT&SCNnSJ_;_(gpOUI$^sWJ_09W{ZT&Fnz+36t(v(q z_}NBjki(K^mW{J=Mw`7QY;9L;R%2YfK4O!^-MBN8-%Ktp!@pFeei<&ZHtdgh6xz;i zH8k%syry`jxzWy-q?0kLvOv%x?=g}j4r1nZ9pH`T#m-auCA$+L z7Zk$jYL9XdRaoVya@phflEo5{cnzCRzRPzyG`Mcxeo-^sVX7TZ_8R@~Cf6e|Qg@lg z&`%dYL5v>veqwnw@;Jv@r=U&O(PZA!vv*&)Wy4FkG9H%Y`hlpGd;GBLa!l?17_3C^ zptI&X*H+v^fhR*ppH@7NRtJZ^%`G3k(D++{mN=LON?LTC410I{2WmQRad`=ZUl?CB z^LRnC#>pD}JW$zVU(JxY*Y|t&`r)D}>k~>%w>@Q8oQbQoK| zE!NfaPSr4F+(NhV^IeO9pQ*~A+q}#45r_=Q%6(v2s{?`#@Z8T+m7jv%^C74Xtd!Kf zsPHBpW5H?fDbh2|VvZ?6=vGvU?ChkH|9U`C#OYMYNICxe4i;ccDd(1`q9OdHx93-7 zX1Z45?561uC%kzn9eokKXiLBA7f9FYDxS25aV%jj-PkF5O0?Btk6RPP{et;5%9A_;ZPE8~LvjxT~q-YMv0ll{OeptSn| zM~pS+fwf!Ptdt-6!C|(!$ChxM_EW(VY)&t54|pPu!T0&$t;y;T&-`O!E?~6&ZX)tn z09CJB??b0H*KL~rdhG@jqwB^u%H`A=a43kYLfx0wrd%2*Rb#oHDuZ@}!R3il5S58WHYf=1}y4jPxBpA`w?~!psFYux-s|F3#($q&I(+Q&O5a0F8FEvM$)b&<-Cxt(~_gbbJ= z8A(oZrbM87A17jj=s)&X>=UMX^#&GQ^cOvNsuLr5boqL9-ldY`?BYvNb0Kr)%m=;z z6HDosFl>XZ-KVie*XX|Y=J%~Db4AaskT36vF)^>m^XeS?T)H%)VF5ysy8u}wQo`NE zNJl%h(yoI&JC9plOnoy=tSvgJ&yXI$dhjh5vieACOt@0`@FJXu@D8R$AY$0y6P@&k zueqc4$Yf9gYU}h#$M)@V3>3lde(CzCJ67U4-ZPWY!y-K(1wL z%yzzVSW01IF|`#mYJG_Ugx3vyzIrxCgXb5#tEHgFgo(gpee+hJ3l6l1o-f;%12`6< z*$5L=dZ}vm32Z+=jPFnX=EBZ9vXM8A-ksi?u`y5@uL$L-{#|}8YUDhZU!h&jZ85!i z{7uy_P~z*S0)yFFyV3aMqXK_2Cg!Yqr|rM8S+Gz3Vzcq_m8&1l*(?vEHR`7LPc}>H z?g4yG{kJk{p`V!d7B)8BR?S9*enhSSHG#E86)EVn!CjA9ZN&Dlku=+KkE~nOb9{tS zqi-+%W37S7yWUQB_+i#~i|uh!4H?(4U7^Q!os(x`GL4vfKZ_BH{l;U%SZ?vOPTP;x zzQTebD8l4=}k!fv(ZU%VfcP{!2}B6)SF< zQ#mCx&c4(gvV#Z~)Y3ye)oH#(J2Y;{Xpc0kRQw5yBYKy zx}JCQU5YFZ;!f9t#%jw4qRYn}fr*|q!0t9Ujf35iD0~>`a|7GiIb+$4 znW!g?B2{L8{hrH{nf?2-R4?zsZ|R}odcF<~y|_P%DF7dWG(hOo3fB<13k$MMpS=Za z*S@Q)2j8EnkIhE2@iX=yB7b7ocvo(NixQyMa+A#D2@1pNTimcdMrIeiVq;2|p)NR} znh!L=lqXGJpLSY5hpxL&xk^@2{{D>fF}oZ1--d2qv}1a)V-?4tXB!CD2f zz$XB#)jV6n?n(C-b*|gm2vh^X&s%K$janPpKSz6kYpiD0n=a;0qeC3rLR?PmAf(SP zI|#fNS{guycj9UHi?+*w;pl%`j|FsGXE)uYfD9c+D1asmyT#vO35qs2xsTc?|@Xs9rV@3qb zij5NmzN1f7MbBX34{)aRluhB3yrTwTdrD;{weW3n5z8_B3xk&lIs z@3^GmuJRF~_(gRLZ5uvb8bQI|9j zpQQ^4`x6|OUpUf*lIhA&#Fc5i`?S?AJD|FE;4)8r3a)Ml2FSI-=}hgLwqA9SKA z`O+*GoSbGao`touE%dm(0km|!xc5QUk@%NO&eUV3!02D)NwPI$M>ZFYy_#my_n5F2 z8AjaN#{Dnjg-1aH$IW8uho4Uq3@5()cXvM}GWTju$B!Ub7`7@>2!WM*NjTK-eRHa9 zEJ<5&KFiHSc2%D#?2;3>m(l+D(LzJ_I5xBWZgP=#AOZK+X>-xkxx$dbA0#?id8^-! z=W^<1lYfP`g4$A-rR`PdeSF&lzVJ^pSx4=BJ~{8 z%H-R;NUV=aU2ddCg|j@tqL<;eK$C)aJkJVDV~lQZ1BYLu!c800 zXmAWdZGFUDPWqYJo|zZ=J-#g07KOnqM3`^1fjqRy<3TWLa|vm-o&97lYyl&ejRo^` zwQ>nhxnfy5*$?_+O@G>{DrD}vBU4~l+A%CpsSA0DWAy>%H}(2AJ~v}(Y}@r0QrR;+ zn_h~(@y{-6TZhj!({)vZ7Vdu~o5b7Ef4|tPu{>|rBZI%d+mV}@GbMa!{@vu z2`20J@*(prJxCeiO27eA=SAOIcw@a=Na5N7M|i`~#&02#sDtl^VA}nqZpm*l=Xt>i z-%2YtO{V{z7tCJTP;M9td>qx{&boj;tVzw%z{hj zF~FE)7@6!4Pvp%<3^)3bgzUYb;TTx+@@x9O5Wfqex{cE&?G@+&W1tj);8jECBPZY_ ziLwDX5m3%<;RRnd-(MNPx8XP1CO8lJ?5Np$)?7-C39%mn3$KSLF)Cste@bZ7P>|`nov_fKfhW(WBO9rderr0>--BPjF z%>>5CM_$D_IsP zKMEVr^{28sg|?oMRF63Sxct#}TSSo?=5%{k!gxVy_O8YfLh_|Pxn|i=OKP9TXDaFe!=P2%nv6{dHs`P z-pyKEa31)A9{os~@>xGG zWq>C?ROzfK9wr}o#e=D|%uA>a5JIM^r209j`)({FrKenGR!6Lg&TEn#w_xlNN*9Q8~YZq z)Q2>1$fGmp9)p#;i)L_P{%~ST!y3*k`!mCr5}i>xuIR}m`@zkJG@?K1`|-fMS;G|M zzi0Sb^}c%>0`@b`qcx$c0d6})uLd5}RH_&i)aI{vMxxGDo4~RPL7y?z+30S`tt)0M z2aWJ6Qc^D}d>SJs#qMu}DBWe%GPwBi9-Nzj|E3@d}sFCMvk%Mu*A;G5=cy|H8mBvPJVixuljSUWR+`i~MtFea?; zumQy~`qGXkhp)&Pmv9s#3WmT_0;P=7LNP5H*H&=7q9}#52+usmm2+EGwz=Moj;h;qGkm#m=Lli?7xbl-V%;bS zGgpz0CwVaIgT=<*v({Rk`{KP;hMkMWZ2nb4&uu@!(wcIX8aI*X2B*5f5!F>mMsxs@ z(i%407_59neX(bvIfn#ht2bDkpRZ4dLS)q{?-9M}>k~}xf3#O!7Wds5o0bpaw?MM- z6Q}K_5iB;7{!Mrhg}_W%(z8J#f#^(v9hXP9*Od1k-;g_E|6{>a>k0t+HW~75l=_nb zjY#ijy6+fk^1e*p!=#ls4vw)O@b}Qhq-Tf=UadDk^Dv)BrTa!@3G(mxb{*RjnC7w8^ZgaTw~ zO{(xQ0J+XxPP$m}42=X*k4X_Gk0|@p{#a8cJUaFqM$$^sc7<{LyS3@($4|6FH28Va zbGHKH^gCPr*yrC-Ch7;~$iKsi|MMK)KVT{9Z_vB>uh8|+EK&qt`d{tXX>{(~{hY}( z*H2!;0_#Pe%*mSa!kZ%|Pjd|?M`}}H{rNlaDRC`E zEw@pjHaP&Cf5dT$d=AA}HYa;b>c-f56x-ctx%lW5CO8Nbv4n z?(N#;&>OoM+nc%{-*(2|Gc3?tiW-^Xp7z5Vc>h{RRd*X&XHktfwKj1(b>RScur-xm zQV>a<){}`HwficAN<-^T2ZsJTSh@QVCI-iBBe?}CjPabu`8cHt+&><3ESC-`pAPhU z>|dr#eEmaF#`VNuYWJzBKPTd;5Z$`QIR&O6|J&~ja_12dXgMBJQwK{xZGNCBQI*Uu zZ^Y?%m~YNI6`Qi9M*h88@Y-Kp+}Uy~waP1Mhxyf*cZNZnFBK5w;(%B-Dm}Ql({DC@ zjEMT452D&sK##Xa9T$0^L5Z0%?#{5Fk?_fUiNPRo2ysr`q-Q#6JS`mHHzRuuU4K4e||hl?aiv$9(uAwJBX4} zr$w=+RzS22Pj+sQK0ce2^l34sKsbN{LQv6c9%Ni$UbM0}yTv^hWK{tUhby7n> zhbKS&9w5Bfc8dnQQysjA%IEUc(y@ zSC%^5TW~ViTUVNOgvrh7=f$5c9X|#ki%>(h>A`%4Wzy>d~c~OSGp0-*FfK{ zUlKiB3gZI+2((>i9Mz}sdhJyz@2mG16HhEfS==f)2b_cJHlxzXFXBQ~$;g;YG%p~G z-+vCEg+Je7=a^Y9R~gcFQqz`5?*DvvyRN3?fPznX5&pX8?7$;*-I2=BQqNm4*JxI} zN(baa=NvDFm=?nU7t)&CUer~uRO9{*9hJTa_Z`8t6RLd~twAW0>LqVfP#G>5-$p0P zD@hGs1sHv36$=7m7|P*!X@tylWm&m%p6|=W`QAvQ8!Q1Jod%Y;T=09)YH0x$KvAGUXYAgDqxN^H^Kb#AyWquODAmpr%#h@Q|KUEaYfdqIe*;QX>qD_On; zxhgR)+=rDXx!*m z27;rSBdL#*u2T+o8h@B&eIE@UHqw0^Eif}ShKX*Dz4e>8$@62$kSiyTc3a2Oj=q^D)WXRy`smuGab6=O3t%hNlo*o_#-?!sT=w(oQo2oYk!>5baJ4T!*n6O%E!OzmxNO$7C)u)$)8#HAd+3r0`CE3eAx(B;mpG6xEW zlv+Urx(sRFGtr~a)5}xo2=h!;;Zf2ZE=R))H3qP-dsvw5ZmsyGfOys{ZF;VDYdej9 zq;`lX z&!Im`LZ$u+u_ra{&XAKNH#mLMYD3M8l~Rw#P(hz!eQ&3ftfwU=6jME&HCK8Gaf88h z#zkqj_9aWe!g>`yUcB}SSG$L7OaHx^)BLKg#v>N|p#n2h221LcbgBT(?(I+AcS`$TUJ`d2 z#afnmi$kA~!P@>dB65)ZX)05SbZT+rB1LAEWp%BgcjZA}FeJKq{hpd?X4p|cq>|SH zu%||N^0H6eckFkY@`iG|anF3Z>&d=&$*ij79+Se`xdx_JV+0_I=c%#}0zvgpG{n4# zF?#2KU(2t+5+OpVnBbWGO7R)}vSwP}_ZmL+gvfXC`yki6w?%}`X~tdG6%@-JB%zrq zjRZh%+(~$=wx3w%9xZxDQN!7VIp!e2DYofTcVB z{I3npJv#UO+|ToVdcVvu$1!uvuyO6{TG#rm^E~s@WLX$8HgRbWEoLiuF_#ppO_3_sPEUMzS*VuE=~M06_AX8h zY0m!jXOE)42y-%S-m}mvi3`DfQ~F82+l=pWyY=1P1Q@vn3&xn4m;s~V(1#Xb>(>F* zkB?QqFVS5D4CGz{1343%8t!!Jz2Ua{8M))5ZY=Jc7iODXBF>MR<_|{fiH}!XxJPhapy$2M z^RMeo#LT*VRLajkWbOv`q+cTnpR^dgf=YrFS~Az+?bC1J#yMHbRQrhv$I2tN}^XV2HIQ zQqXB;k000-Z({`rMh@>kG$nBq7?flzjM!ySF|(aYd|O|1rpt!fUult!M#g^NyOEBk zUNvf?S3sq|#8!|B;}UU2b}^Qy*A!Z;>d@}xen(v_!N7sPIP~!i?MpnpVneMT-(Qj& zZ3Y+&uezDH6EX-_nb4vHE-2tOtR&hAb#chYUl#J0CZvYEQK&&{>bDp>BB}YhVd?Bfn$o`6XLncs;W2bEYxWLbcu7I z-MH1o5v0}N7&y#+t{*N7f{9jBV1Ev%^b0Mck@I5>>7FdGS9!X;fir0z-VYD7K3TX} z9Ont|Vl?`9b%%H1oIV?92gOtr&d?+aTq!7>eOjs}8La z?yE}p{-fEI)TyUNZJ9-ALncKXD}N@z9)C(zD;Ec9&1ROPOqIFqtw!CWD0>*IErkUJ{N`lWK#47XL>#EhaaK}rSdsjaU;EF|F7m%60{_!Ss zlJrA1{Cm(gCG4{Yb#E4kSe`a?|K3>l*1fSvK@V%iZOeV>yvxoO_UihVcWCQ@*uBWZ z^XQX}!mn&LymxMc#r=%E-oiy_+phVKjrsX46RX z$XNLNI@$CLuplI6GzoFcswS~dBq)LorAz6~ujU>{x~$*v+T(g)T^MRAgYM=is5IMt zjmc?1$#nMd)-GqZLcDJLW}SB-&R$q4dN}4!yq<(RlpAj5y733*_PhhqAQ#dJFH&TT zI?}9F53FB6Un;5NWY)}>`S5{s*ml+BxN4FhL@5H&1s4SyMnWE(QH^)Cte>8m)gsX%Mt|I(-3>NoBf~% zkiSxc$Ho&7#DA0V&NGts!~tO0iHI?zS*@@IKZBjXPh^5P1|@7i>wZ||7qG<1LzTx@ zDu+PxFEl|wQ4dJW{0b5?C%6tk6w)}V4k5?ERNU8kC$ZP{WwKkJgrmHE>VL!A-)7Nu zictzgxxDa0TpV#Ooco7}7WL!_S+wb4c>%+C>3(k#cw|bv46i@v6EDAUsU4NhGIx%C zfsAU-z?ItY(bhwcSlm8xlE{7VDStV2 z^K+30svhnV-sUVSOpYSrdJWmf$BmXjG_r3J)2uP%f_FAVeMpVuC7;dGPSxN&YMjZf zM`PQ6lytC?TVf~X!FuF;d{_>9b#Ea%O6Y;pMw-cv4g!l(40B62&cthyX2@UG6x(M& z(#ggi9;O7zphB*aG%`=6O60DVJnlHl!&n)f%9Mv8TXnp`aC6)Zb*x3Eg_!N$Y0M(M z-fa=2oZ}$Kv)gUkKUc@K4;ueBV>j*)flK2aL9&KO1PZEqmboBYR zhvbUX-S5TX{R~Lf@pWm7ojyAh+>tCfOn%obHWRyEQ69*Z(HSEFQ{8$9eL|E3?(%ii zd2YdX>kq&|R_>`r7zMq<5%=DzNBo=rUTotvgW8Br;&MAp^$St5tPvj%F2})b4lMCI ztA>C{F(L$_R)U&#FQXAMrq1G5q^}?#0n-R1V6GLRPUn}=Wu2Vrk@NFZ%1h78lDP|r z9C+n85Ab+E8<(ENYY4F}xm<~@Y*)}o-4z%jDbxwn!)G1F zN?+tD<)87K{qjhy|IT9tqZ{^CLBEBzz9VD&U}X3zggAqT8f~oTXPCfhcfBZZApq-Z zK0hMO+{~(#mV-9aiN>7j)zWskG_(T^%98FK;!*ayOcCuJ_5nBES8tD!V!NpON$Zl| z;(uJ^)8a$5#&BO-He}c;QPD^m0QF^FBBo!Cs<$&Ac~f9*QVYt`WEDh(K1o{om`7$> z+$@0MJ0A>Jc&y9wMsc2w++pbeN+^lDU)2JEE8!H!5pNbO&mW~Ea<$Pp+D~*z#razU zO3xOwPnkjbaJ~ZR(3mIx`Jnpej-j!#aDQi1s0HB!l6oXg$ zF_y&&PK|5N#_HeWjT^tKp$7T237bxdTf&hosMuQ&94A(lCnbb0??n%vW*Y%$5(B-c9+)94t! z^W1cyF@NtNjM@Fy#dq^rH{#+e7XcJ>{vP0r0DpGoiapKTRy1&w%IMTy3KUHRG(unUAw~KhBD>ikz=un%v82U zqsRZtm*GX{MFa!-VIZC%i5+INuHx(<8zXRMjzJeY^gCsnsR+QJslx0;*zndXqe!3- zO>Fv0{5uUzC5${!{xZ&y@6&dW7Rq|uZD;(YfxtkVoiymy)j$I~Jfr&~v@yVW_cf2j zebNLF5K5aaSqe)$B4EpFGvHY2IBzUh&E_<+K2~Bx~l?4 z^!{nRAw7Z;FfyX4C}mgcVI$?*O=U^bJK8PBGR+baq`*M@#h4k1dwm+tlv{QE)2knn zDOd2l4(g;#MeS}$eyF+TPkP1+!}XNAr3}VzFs3Oj=g4d8q*$O;`;)qZ7K_oN0NZOWr4s(WSx&JiQ+9)R&4jYB=nkg!4om|wwBZ@@A#Ujg z@Ym)Cp&w@-;b*)AT>fE`9ILYd$|CvZ(GR!x&(|8NSa*G6%bE zRhf~{_3?-HT37=!Sey4#VPHT&sU)Jq&UTw9S;vGc_Tb33p2S#Pa2;Du_yOYKVYo4i z)yqTE?{BHRfGAiHxzkKU@{OHdGI56FxZi;N`jM2tZ`Ghx-|>PdQ2{y<-Png(``2YOS;)nVcYeWbhDh80=+ooO!w1afJd$Oi5kLH*`3yd1ajl~t3^l)mPEdgCusre?v)Kp<6?A&eAP zo)a?*VyoPoVFRh#G%ZXL4D~6y^+EiMo*^=Hb^aKHGV+=&9tXEP*5V;Lpp~aj)T~hy ziP$Gj6%?zWnrWILV?c>4@PIJ;9r^eKK@O`|bZw^6CO-zFUJ&Qa`SrP{*R#_avLCUH^pLgMU3hlUaUc(_ zkwRoWv1lO{sryXd@j|uN*ua|@4jl)EU*=Dvk2J3E$ZaP*;A z!rSS}VRI0q`XPClHL^jVNOz^`aWykU$mU_|9qf9hryt?%ObWH=%~1$%eAufZWBE>*Ekvfll#+mDuqmb zM?+9Ey(iWuFzvHQ>!g0(@*YU7r;0JNZ(?PZhoL-YLF1RK#(`9YUIBDGnR&{eX%Rn# z0-y=YO(NF;0yGOLxXLo095gEomi67Bj=r-&AWckyZckZnVeu2EX0Pvt`-A1mHKNCXT>R>S3R;pQ z&;T1=7-Pe(Z8cpyhV#p0rO%d>Hs4>qeSi{PoqQ^+BRS^&YJX_|-UaRxuvr|*2c)mr zY=PsaAjkl14g5U=6w%!PdFB2!d|fmMDUl}q%(vBvQIV`O%N3w=Bk)^+7cre!H76g1 zI$61$du#SfLaKf7QK7VDqtnL3?>IiU;9g^uY;;kouX&n}UJzE@S{TlJQ@#!0LO453QHxKyTn}C4P(LVx4Np}#DnZ7fqLC*Ju zoYl|Xa@dXKNmq54vEsRTYx+ENQG6& zmH!Bj3KP^+a6ffW6Wp_(=DPR=`>Q_$soMXT67ZaF*5??)^s?r-rnhzGdr!^jPiqW& z^m6ho=b!Y%`7)jA*U@LT3Awym(Vd!sIPR`|w3VssW0nS|~&w zRjL)7PUJM#?u3yQx$&H?yPibnIJNJN?RC>ft5*6oICV}{ zn@{ZHJ!vWG$?gZj%W9jZEBw~XdVTj>HYT!Vcfz(N?b3X1^>|v>mBXiY+8l+p8FV_% z5BR4`fD}{S^)M>M4n_6Rp(WGVxJK=K5haBhaYzGd&I*7Zgd2Lp(S{x63 zwh**4;xTCkBL5U?hgJR9>mY?myCQkReIiCc(`YE%Ho3|f=G0%PLPFV3!YbS1*|Yjk z{laW{=aURMPtF{02L8#bpFgzUFK0dhf_4!}sArY-4RDL$U-0u;j|SgPWAWlAeZ7;9 zq&WZl7Il-kS57CSx$3T{RG7GW4D;5_#CxqJJ%JHfKlU<6T6q?skw|@7&6IfEYS-b~ zC9AqTMM6;w706`I?lEY4-c@+a{)T&*MJcb1sHavrbh0Fv?t6M_*xh+lB9dw#qU@J@ zOX9vd`XK%Dnf;G9D?LuE>U!F?kfW1UMu%qIh-pPi<&LGb2%W8$we@Bj+f1GzY1Z??D{Sf5OI zrmpt2ptD#^V?lc}e?+l=!TyYpXU@$CR?_7adK+GyZA}x?2AS*CHhoQLMs0d!?{4zy z-?|#6a2K6EvDh1#p!s+rlxL`W6!rTZm>Y@meTA`uuP{O0nMo(LVF|o}^8{|ZYh!79 ztIL8RL={Oa87AoFK`Mh&Ub~gL)imL3_AyuzydGF+7quY=kBysS%*-v+6|pECEpBIH zc?|4#=TVybAObM(eGjQf6;nSYAvV`Y)us~{Gz%qGZg%?oRLtY?^7n|W@P?DFkHM+V zKSm!EG{Oo;aDz?9H44-x;isSIeyKOxTx;56sWZ5UuN}{ob(+%r3N`Qteu%bHKYl z=Oh&^cZzR;zoH)xjY)a|4p&X2KJ#0d>dljd?hUmOS)KDwrrJR@)ZpZnInHWd;2git zjcg|N*K?qyb82LI;2%L@E=N2+3t*ktaaq{6j4C?jMUV3n|nXoPCBV(Kr| z=A@fBGf|se0}AF)Q{WP?CD4mXs=9g;Dk+)wrv>;?SMIOf>ZjR>t*xk}t`M%lAc+DuzlSoC zg%RA5rOVfFK(&4xq9VakM`_d{Q`veevF;U~#Vv0riCAAL(4^$_}%{z?ZMW+%(o z@aL4RM@dCEuP5L_m_HTVVxD(?g72MREt8yzZY(DCfie#FyfS6D$QPMnraXgRJcE+* zQUQjoINji5$BLV9^wmrY{wc25Q(}_Oigb>I%4CqcKeyexe7>2KwKYOYj zO(nOB*uQR{hL@z+TOCU`y{=x0&w-o5j;eu~HSnWO$65((__g78`QU2>Cf@A+HwK#q zx@k}z8JyiUlKeiUrHM*KgP!u?c)2SFzWEE2PH99SFlp*Ov2HTpq$BGS&v-65DH~2f z{=K?8#1X2;RTvjRr`ZfE9~f^LC}*mLO-Yh4Lo1{2-@TS&wkgFYp(5_4*DWBuH{q%! z)n?@%6F65oznr<+FFh&dlO4`y*~nF@#GEVQ>GbVr`{U{m^r2^q$qde1Z3a#j^k{N_ zTgoF9($>+sNx_5hMA&WHvHXd@aA5t%v*|B=aNE}BKeSz^9V%0!T3FEvo`?BbAb}pL zek|yIzz=O3wMhqeUMJzbCkmt3jaR}RHq2o4Bce{})_EF)zwxz$*hm@U2E++$e(w=S zh9s#NFGqqW7vn`W&*3Shvfn(cXFoCqYD;2}eL6d`zmfiKS-Dd+*40q{9RUNV=PxrT zF3C|x9-b(-;U3{v?8xq+)}1HTYD-HqhXkj?sPr=B=ZjrbeFB}R*I!K>j}ww-_(r~L z&2;gyn!b(()urIwGxl}DUI^oU4uMAA3i;+dUC4|27X)>RrByWn_|bWH$S5sr*`%Sa z$c&YOd?O}!zI&mgt@Z6!QSyM6=j%McO?)e1AA$!%U+j9KOWd8g%D_5^a~7l#J_?&T zE45_~^}Kh#N1Ie({Ae#_fuS7Pq>2RBS&6;*U5jSjo@kX5ac$e_&&)Fm)L+?qt$&^0 zT%*x7#uuq*f4=7;fZTX^ysh``xq)PtkVCQ(T^Qij*exT&!w-I#H(=KsqT!_odCRo~ zGOycp;62gNqLX&LO^ZYFwaZS(V#lAQ6+2i2C8?(^kSQkuaVOg zE%J>w@}Q5=4jfQozhGPZ(J%`p({BgoDxkwgyrASjj;Vgrzj91p;N)0(oc^6-T78cE z*g1Z9HL^!JP_NKiFd1L7CsKk}EzM7gln9kC#Uuw+`id+xP-<|4U}FHmA(fQ;54je4 zABw&SJs&^VRib7|M;HWP&7`mxrS#@SRix5-qhIUe_Ol?qdB!-MhL)RWp z)ZE3r>;O>k6e4w$pBgHXg4|Nq#nf~E1ja^}Xg-dN`e?zJEwAem5lzTbPp=nS3(ka9 z#FR;`8)`ihR6Ex;9kyhxd30|08?CH+PX26Qsby~a(pwjn?V)Rf*6qwr@ui#yYkqA< zMPEMoY#Tv_<$qZ`z1wq-;uMj$xNrWI)uST>p=$q=e}e$EbV4+S?;K}y<(oU!S@}+I zcv};J>86e{*FpDBa(6dqlp35_CkI^f4Dy_UcC+;s)68H@dv48T*KV)ppT*rno0so& z0{{7+U}nn5OiGYNI$@0-oLrvCK-ov^OP~`rD?f|hn~&9HbVRBz4n!3Bxkl0N)>=f? zc)c=SxD}GQkWKUnttYN>nVyRj^P$_q6@D~YbAaaBS`FNSBva^@i_cSRg#p{gFV_KH zx)CM}3;1L=2&cbITe{@_5T02uaAUL^3$$?OcT@J@uKauk8&Db~)^jJo`l-KrTHZt5 zR4*mllJtn$uK0`oD))9m?{KnvMmWMtx3X*w;iZcl_+X9EAKr7uCXad3e#X=23syPR z!zBnr8lA3653z(yLhb9AvnuF~y-_4z~+MnBd^x!9>Ih-k?DzC4Za`9t%+ zB1+{cRQ@VJ3fe~r*qM3?;!i;vN(n(O#&1|{d{6&*w*>(D?z=MdrfbrC`e*u3W=swm z*IJ3nhTG^2Chx4?)$^=)groF4H0!MOuev+4g-%P(=o=zvHzp`W_f&6^u&YDsY{g=U zPu`ER9ka)9;P86{S1iq4a!Arqthw{R9S=F81UK86!Dv)y!x4odX~V#k9dwQ*nw>`X{5faCDtLiL{TQxqs_W!3=|W;(=!}0pPuX1bFT-3X zA3^%OJk|cCmQULWlG1*H3hrDWhLr7_O>=OXe&N-C9qxU@pWXp(Mrm2nM6G&uER-ns z{!SmgJON)VeoAmDXsg*};k?ij{{bx%I1e6Y%~yLFtmPeR+%R0eyVu;$3mEi>M48>O zr|`3HH(zY#J+8`9TA=28>R<_xbUVCRAn?PU&CUD>=vc#QP=CE-cYNAdEjvb>>8**kV0k1hHKlqoXI3YU!1=`z zzB>{o=JDu78fl;k|Azlckc0=X{P;|Kt)qeQ^P06cvP+$fCdwoDDK?9o&6#2yrc^1(P7VrbPY396k z(a_T^rh|8C>_oMsuVRuM&6Id^Ki+ymsoBcX@WE(Kc0KHy_V+x zRaC^Hl|h9B(&7R*bxe(nzv4&5Id=ONt$AS_4^h{V0u73B?tP~E^KixXE51|46b4w$kzU(+k zG0JOKe^ooWUC8zw=-&}rVN!5Gz1=onUcUm2fajj<8Uo!yleyofU+%QU*2(dCo%cd& zBD=8yzS%$cV?9Xz-8br|ngY#ef9H^@`41WT%bRHx{h-%4CqI|$0AZW`efo3cWlj+t zq8P8MyLQ>>(ld}r-UV5z&9(>Z(^kxPAK7zT=W`y+O|kXHaagkE!5MdF-pk*zA=Z|{ z)Uo3NYR_?-&hYCX2?FY% zeXhS^NZXCwZe0N}q{!$Xh7|1{Hmb9($7f)QUbEt4o`zGSvTS-gDN!X;<~a0A_+?1_ z^;qd79YU^~cSu$7kGQPTog%R-_11$u9V5W~IVe!Iy-SruX1+7DSv6tr+q@ym`>fWM zZlyvV<@S1giD8hrmqFJT@E?XZ+@>E4!gaw)CC2`^o~w>%5QHE3pr${bfW1WYYP9>h zLoG5#AG1uL6Tv8%HRf(58jWaZAllK{C(eQJDpY6j7v^M}1(=ifU0v|RRoPLtR63{5 zP|gr>f|uPx$ffs1&p(A1Dj-8}975UA?8L^#JuQu=T_YXCwGooAB`W*ie&Y-MJe}j1BWAsgA0dfboY`*^@6Ns zYcOd8shQ)(fk2ejv&ZSwxV?5n8^V!cUAwkk+t?6Q^?=Hne11m{sTe@?f$8Rh& zaAWy95|THPjHK8TLuZ~ka@^mrsKjh(yQ%2Ah?Em=7g)0S`4Iu7#{AWUn>GzXGAeIufStt>o&ibWs(AMmX6w+8Aejka6iz+K zW$b$@?}}&rf?vjn%wu1ZpTJP;1G`kvYk%LX>r4#p_L1@*Jh@gz6&k?g2u7k+$a)(> z+Ok>aUq7*13QhAXj1O4}woZW)V7dauCw`HA&ub_L&TAx(D^nd{4=2C3*{f3lee)Y_%-HZIA1)BI;qvwy*-9kuQgwl?iZ zxgw8_azy;-A9S<So3Zp{$H|9`vt)QDb_9Z)i15 z=ZqHrB^)Ia=X0tNkzP=zSi*_iJ4TA`Ue*v=b$bFuJAV|Yvj)eS{{o|eIWMoBFu7bM z_BvsiK1KR{n)I8f)cHbGN^l!D7n@-Fs%(<&xJVX*=RFA|I{BDK&-?i|8i8Hy^zIUf z6y{qV+;%vM{W<#3#aE02$VzYEZ{YPHr{CTV=D@=givvvX?!B)(W>TcdYryy=xvJ5c z{aMh~4Z`Q2y#c>&#Z&*$xOyE^oIw&SzTI#|%H4f`v*F5NKKpo}Tqle*cUq&7-|pah z#A)=Iu!CcgSC4bF=*UBc>M!0Xm~1(2?)N@s{xG^QdR7$zlvG{V%GCV$!+EuM76 zwRYv4Ut6FqsQgskJA@CTf40@M-`NqL=cY{ykvCh$=9%9onwR0VY_WGv%?FF@I@t23 z3^p5Lf^E@U(mDL0x()k&h2+h5)?x!V0_hDlGW5e&vY=maUOSiB_FR^=ZFqk+**kj% zC;!bMj?dh%p+sRNw=b^ixps%DKB!>~rt^C4;-;R6O&zZnoy7C@I1-r#bYAAcISp4g0RCs<|ZjJE6n zuVE~K3(x$ch#$OtarV*n-%+V5s$R;Kze^0LGSe7;XQdq2J^XIZCp$mKYiD@;l8>Gy zdX{|dv4rW0;ZyjG7H|_rHE9AyiY2a@9ExzVZMRn|e}&9=fkCfAZYw_s3@EVV=U8je zHwr>leu#3B^(giz0{le(D-iAGGz@Fk^T5f&mwHVxy+^l56F%JTw}MyH5L#2dzuIw{6|o)3 zDDe+?iZHVM-#(RhwQIo!b8ZVN>X0gx+_5p9^Jh#gpV7*JFNsYb_WAz(c!@Na z{|Wpnqr&*Qopd)jXS;aR;C^_A-DUiEI3I_sGw^^JNPPC0bLWG-!-TI#_WQ}eo_1nm z8?b3;xASduakkK~YsX%@@5y$yTFgblQA(|LbfE1(QTxhX=;U?WK{I*HPHMoahxl2X z#ZVd!xd&9Ip@icerAu+#!O?0C$TP07xcr4Z`)4hI25G#+VOgIml^YE7h*<;9b^oqQM;{D{_`t;#R;&4^=GlpE);Oe zep6uixQWy2rxYThC<1!uDf4}+>7b^E=E=gtP*8Uj{LgTYD3P8L?AF&stCW4YPq z_%T1*%j#o@xmKm(4JF|cMY??J*@g<@pyGa+VDJ=}IHS)MzJ(ERNSu2ra-w`AUt8Hp z*b0|Tv;0Y5BymBl48M9ArdbGax?wGJ?>Kj;96p`t(w7!Wt_J>39EJ*vJ%$&xjmK>gHEDJ=|zo31;qAO`_&Y?;f$(9?wQGe@5thv{<0?NLs;N`$mtj z#)9=zNovPKDV`rj$!m$bvO`OJ#{7%{Ew!iIr2Hk=`Qy|{kAfm6k>QkP#mHGUqc?Kk zuSX@WRuK~d&tF70d#%(1$2fIbi~?G4#$IjhfO?f-r@C0>V>x}>oNh!W&O*@Etps&2 z{D~a5wih}gNikk`o257!{KkR$(TDavWxhN`g2`|i*G-!cT#DESz@JXBn)v>=n8|H&-Ai7o zS&j#>OzNaqK{s&NH=PTZOv8xBcbUB`f|X&|;Rl*0Z#bD52Kt&Sas z?8es}pvjS3LSw*;_5(TuDMmijt8#8R8HYaZI%f;j@1W#s!Y0Xfx0vLKHc~OpC5td zFUD=&TthJi>dvo6+ds&Mh@+GKrS3c)9QBYe3=6gwKD^`6ItpDi#x}OTQFZ<#bI9T*kO6k|-#m;F>v0*_mickp@>J?%MvG2 zB%JN$Ppd;d%K1j;xr|{H9CS9SIY>r|M&lvAVlFlxVl7x!61$YIG7Z^ zH{EIMKQAZNFKhAIY&7}A<1Uf~VBNnRj^(T?T901ve$1g+FoIKL_rhzoG)+kVfh|k` zstXVe;SjwIgfdyEew<)Fa5QP5K<}pOq{kUpA+7cFOI+ zlt9W6?1Qk9i;V}fy3C(i)a=li?ahLV)hq%rzb}ycpXc|8&>gK>G3lo#NnqE5NqVXh zPp!HI`v<94aBxjF_`W1EP3$TRxjDy%WTAIxb9Ud(6R6GS7db&w<986NU^IIbcUeF%4;qxDfkz@s&QL>{P=bpsvhvdyju)ks4{%glUJq|xhDO3xzvHx zl^RcJI6d^6UxP9=0tviKQdjpn*|6VRU;zTr@RP7hfhf6h&tB#FLhzBaP=rSKET7f& zthK$L>o(cw9@u^?r?p_MYyKUOcJ9`!IzD6$*TdCqqPRAUG*xN;7AlyLx>s=fb2A{q&l7GX&Z)3!JYX115?whC~ z(uVi8{S0?IoO#&p-;qHxL%c1>&Vm!|cQ)f8XzN)SjD!j$;s4dVv4i4RCvAwMN=5Peeq(MWu z+fF>?Sh5=8LTBRn6u~`T>=s7+u+Cbg=0(&!xB#+*eRkFuI47{M{O~nNWjlCj)rm|C z#+X|J1<+pSsxs(rg{4$&b$?o0BE6rWG`xlFT(%`r6>=%jv{W%}UDudLK&s+AJ!;Lc zJj{P!P+_FMFzEk>GH>drQIJ-1o^9wTHL=v)`^@yA@FrFH=GOpicznc@GoPc)ZnkfZ zwNttR-3a++>i&qcu^S?5P%Dv6(6vqOVCwCAVo2lI)8ONg!;~tw7$^;zEA%k5o9>Sx zZWsq9CLYjumTZ$j+}X-wpiIo$Q=cg{8h*U|9N|+IPRbn`5k@E51|w7Ee*Q|93Ulf^ z73L#1-#2aCbVbTfo<9#!=9)yPeV$P$n;LLfK#GLi{gQ=NLZ<8WQ4OtFD7r)V(jKuu z+&mQm2e`H8MNDwju`BH8kFhHaP7Hn|kNp^i$#rn`>bB-$1pYXtrU@u>sa71tGCsJ0 z)rx|dvM0~yuPVi_?5|4l(;t#eM&iJbNPbhLk`?knkjQid0zoe_YflOH?k71f%hS=~ zEH&5Pl=5Uafi*>caKdXh1$mEVQ3l$-;Jj`3eZ%q9;0WN7#xMu@HRk;kf-7SrzjjIJ z^PCt&m7_OZn|ZxGzI@2=D;!r}iU>McEjZr3ac%MDQ%j}5${e^+O#N`P=X9#~EOu>u zeYw_Tb(vAdNyYL>r4uycvyBRmfsW?N*nF@_oq6m|eqzZ(hj4 zZABR|G~zmFKr{lB!Of7aNp2cFan;tF`ZGoC4yaBL63JE<-vj-TrYQu)R;q2 z6#^#wgH5M14lf~oh0%f^QMUE}e!@0Jar|URO(yXvLl#e}m%tmArFXC0-~nb4$yfaJ zabP66_L``KEN<2c48E-rv||5PmMYU`>Sp*KS!(?sELH8lv(y6Ne!tvE;Fu!3XlhXZ z!}2FzN`cs@9JLQRafOq0)A+U7kSj!^BdX}Iq*H?!B8EO&k;gG;+TpbJN=md>38z>m zP`qqI@Wd*6L`ANI)3~pFfQAk4Zz+ZVF+!SJpvqrhE4O9K9qfKyrjpWyWtj|1hF+6t zCJO7jr5HF+KzHjiHs2m@50WPO?A~lEq56@b7pp(B!Ux6Pk)zrg71Q%`K}@0rUoh2j(oYDcNRZooQbZk6BVk?y%*olmsFMa52$;5 zP05s>&rjY_QnX->&f>TVR%MuaWQ-oP4t&=Lf|*sbHjuJ@@NL2%5*{B3e~)r=BZaa- zHMqZGQIj9_DSq+-uSYo|-RT1T7tD7`@$KkFcr0tZxCns!L*G|!yt%h@GDB@9qRTPW z%@VP~zBfYH*N=JMw= z+1AC5n!GPctozU=s;WDj0JP=>y8fV<;0zgB!7X&}!tXr$&!1_b#2*rb{P?VKNdTpIF?ZT#jdG<^%MQg0A!vw^~A>q8z`(x;BaB3=ldwr4;j!^So-sT~u`+{FL_zfYXgUxG)w^r~_bp{!4 z_P&1)vQSqynw&uaetomz_37wpcM)7MRx$9g_t)>Y`Hw3;cyAX9nQ~3=i9ShuoOT7RS&1Too6^+o?WI?@l+$_!G8n zW3atSwqm^MR<{RS3q=JUz{60{4b92j*Ig+TsqcgN(RCv}u%EZu|3B~N@%ga|niY!V z;|5_jGKJYO2Xn6*JAyutwN~_c-f^0Emejr2%{Z-4AokuyxX{;ar`N1^3KJ%$I4rs znGra4l994mh^Vm**#q@j_B#N=`mz2e2+NK|ONib1pCK&&ZxEIzk(ZXtW?7h^VA}Y$(WwG6~SGxE9+D;y}NmZ}po(hs7VFK+f>lXNz%$M_iqUX5ziQMBGJ% zhN-KfyqW6=$)_HooA~#>tGHX%uj{ZFM5!@~#oOF2L(csUdo0HT$MTA$tlQ}GBys0Q zqbaoW{ckvt49-o9t*2XNP~?V?$EWfrE&Qw#Lo7EFITEKZoQY;3X<3rI&r-2>gV;hw z9B!H99^Z?7p!u&C5!iFy=Uf)5ie0|*-fVZ&Vp%c-ai3G(zK(glI5&ASPX&;rp)3;~ z48-)y@MTlYYv&gfwD*{>)icV3=qW=cjh^VaV^xPM_YMe{1lXs$Nef1sZC+a<+|WRp54(@MD>Ocw(dQ`52nyVO zGE;pI;uy%fKC8SXkE3hfg6K*arwoe&z|=a6YQ_TvW?^2bMoKbOn8~qk(!{>s);K4z z8|>)%uQyV5o7FVcH#N~*aH~I1@gNec>4SeLB(@Wu?77y7ms&?6Ia{6N61W}MM8l@t zt6jttq5k^``(M8k4X@>HeaMy8SaS)UZg3^e{Cg z@hCK5-M=2_Q+{CR_1cvEfLrfk!xRxmO2VL1*npSf4ALiaf7noM_KVVvh zQQki>twr4pd|iNP7w-HInD*X(#5#%+p~XFi@9Y7}*2Q-=s(NhiFO5dPVr8K(-2iX zr8>x7!X8;R+?^bGCp_LCl}#3QKq%uwzKd%MMY46kWWe4jwTvV+_@H zYIj+0kInFUO^WMj+6$#&SDZ4@_fU``^$gNjTix=!INx~-#w$mMN2kmv*jgmwrQ+z{r%i7$tKbL&#)>Q^iV&zoZCK-t>!jFVLdXifGM?KgD@+* z2L=ovfUQQMGZLx{WuhbkAo?$6#MT=BQ*ZUR$?PvJ<_-uR-PX{PV3s7f-QraJhOc$L zWIOq&@v}`TYyPS0t6I$=5z%c&GBDW76MgqEjRt)g$@&JSWiI@-@N;e#q(&_QNOP%R zbl43gRdloCDtz1q{a4lA;B9(U%oO+kLQKnX#i4Z_J`IakwWo>8i@cOi#|M%3Drr%N zU?yq@lObYzRr#q>k9*%>ZlPUOdzk{mgcSzi@^iS25sjQ!MPh3^I(B!H>ZNaa|@?Yp` zTSF&RwuJ&Jz=|D?VO^8`joQ7{$y0Cf|0G4#^aCOhWuRe{+ryR57N|XcNS)12?e%$o zhZpQIPs2c|YQ6`CXc%VQtNpD?*SCkYbLlKcmIPqe^F#I0X~WeC^}KX$Aksq>F8*9n zhW@#Qz-zq8GU1W6GIC6uLd1H;-DYgIxjr?R@`_n*530NVq+$W>$cUEDwHGd!3`j}R zz26on8k_m_$GO#>+LXYmBIM(<`IB5_Lyu=r9+#I?AADd+-1JD&_MF8rZ}?a?IFeX7 z_A!*IBw*e zi6Ja_-$U+}R=2A8r^`(xewcR=QH<~zOejcU(WHYp$#t+TZNdU z_8;QW0ftAl^A-P(xVH?8a$&oM-CIRb5Cq(GBZxFok_rOSAOg}VAl=fXQc8EHbax3e zl(aA~#E?TX62rg@H4O1y=&pP3=X>t&c>lcq^Kd){X3p!Zb*{DI!I+mz`6|UetC&5h z0#&gL-+WiPRe!{$@5>^dIe~k$IEitvRUOsg8O{j1m3vt0ew^&v1bp(y5*=9gfWoG5 z;nEc1c;(UJ=TMNFNu)5ie8*7fG6kIme61-Wo&$tG;U zINp^6@)~bM(icXUXKLn6VZ1F(KPfGfuXa&GlmL3JA?d9DgIi8dp;GeiyMDFh0X`~5XObw+tae7OHhrT zn8$cG2q-*aa~~M!^({18D(arDmy(WWqKrzxjZ5PD*l5j-3s=!}(X0f)_XQV9RJmC_ zJ$K^XV5xr_2{!Z{374FDBTemnGtqD{ysjpg7als&b~n(2Cau1G-R}`BC-S!51ekm% z+rGSn<0WO{GIX)uRKdVk#O!HM7Z65gUjj`ZT~M1vh3=37==>$cq<;djng!|tDtfIM zL!At%xT)8A2D~JvL}U*sa27P;yDXj&`1Gp%wxHG~K3>cuPdgfUP0C&NyNv0QE>)1gOST~O+xm=U-72uwVA@s_rGg(*7P`>01 zAA?1}OUj|o%=F1N7)36a%-O>Vlt30!2_gi*xDz<)23(a;w29zUxtssY=K6TX{!8)m z#T4GJuGE)Dw;%TTKVKZGC@JS6)j~wH<~f5NY=jqdQQy*>v#%Z5FE3ku?z_$?nBz#| zIkCpE?se`4?C%xW9&wY{?tm&GeuIBIcKJK-x^o+T8j1@Z@`&ZQSGJJ+u5aw9fdl?Z z55KVQZ=}-Y?AwY&fQBB;m;d0DKA6BY2D3#N{m|7_|21{J@d_Eq^+O#ogalEX?Te~H zh!&Yq$)@i$wH5$^RgSxrxDBk~c@(@Ky>z)4tS`0_L>8egSii!FgQM`MF*rEU?fuzL)?;;OVo-r*5KMu%?ea@=X zf$n7Q{6Qe)^r!4@o?X%>$s;}Fc77**Y;qELpl~At!;@=5fpZc*4?9DQv6HFnRP#d? zo0Pl6t^q3rYr8pnMYeQhkKhkVnVU`fLg-6Dd_&-1!hVv~$+MH<+k!{+7x-+cq&c^MjA^Tm?wdo05lZtaIf4JYcKvKg2u??ZYe|%Z6u&jfyl=G6h5j-)@AZ8cMed{N z0^D(HHLe46(4Ff0>GK6&;FJ~ULxMgB_H=l~#TEA|>O5t`)6FJJ$Bq-&Q}zGYQ~jWa zUg>-afTQ1+TX@e&_=%Zl5)tAX$TGgF4OfEF8kVdWNJt$QjW4IFtAoimGH`~W2 zu-aet4O(Z6z=cX9LM^bd{){p-Yz&gyVvX5mOu{Kk|Ru$qcd)yHhl7|hiPj$Jtq!W zcl{Bl7u--sBHKGX->==iF=69>p>OL%LxFo`k+bL56c#2%L9IA10ajiATBNBta>eZS z^lal{1Do&yLC$=e_%CLU;kU+}ZLML@2UFETNq$jPcGJpeUK#~}Kp9eKjehnW^GB3k zW#3bWIl5R}He;@j8pac)uyCMRTcn;~bIMR66|Tq$6dOp4<>U7|Wx9VE;95H|a7uMr znrLLrS@Y1OJ_0O0iDvXkzDkgjWw`5TBv6oQ%zqyR^dSm1)!%-WSoCw>Pg)Jd0WP1z z)$OR{{X2=~fYGPz>G2PvPartkkeST8V76ft!WXT1F6ckjeUWnG`aJ1|lIa0K+Et+9 z?g16|UyHAl3E*G~TyQqK@nORQ{6cKo*EyzNN*(n{zY$5jl5Jhk!`QI&sGkU0Sy6R; zsn4;fR(&T+Uw!Fl|)9s ziGp^EgZRrfR!ACou=eZM)!}`dVRK0o%+FO0q1QJ1!(wk;ySK=@C|!mT+y_bazaz6f+b7v+N8y(wSXjT0#ks>Cc`-uV@719E z-uC5gspXckC z1stG@Ed!2Z+^yAUqi+vG&>sSC@Upztxb@ppi_7;5R0lzwmjaEOeWOtsG%*L%FARQx zsA6cs&Eeuf2Osy=9*V}u8YVR=aWm)L?)b3e4sdC91Rk0Gg0!`#_V%yrF&GlR|1>*T z^3#TByEF#{@F3n3+l$^Aelj0Dwm=J<*4_yuiY;@_3&slkDCYuykF85O7sdRaT2iv3RNhDl?+aG3 z1kb8f&rUCL7ORE+9p-H1k+?O-S>CVrjr_nWDU ze5c_brMtr&NPeX}UiHX#&Yrg@z=>EcoC?ktxK-T$yn|Iz=TPikK5N;0)V=f+n=l~W zUK(`dlFBi-b25Lu&zks;<}&Ko^z)ZNH!(ObcsUAR736a!*#ZeP#*Bdt4_SfzO&@? z-(3|Pe2Rq)b))HI6{i)dt{|miR5k0ZNsu7()r2-Riunjb)Ro8lk`sz;0qi0 z^Avj`;zLy%pyB^~KM=;{NM=?uxUgaNdm$|nv1!UDN0A#PAHJkkr z!71Ut!>_v=Q)1w*@WV{-Fy0QbJL=C%WXvV?_3CzQmt4nm5eNUTy+Bs>>hsI zhtO1B4_tEsdWxROZ8>Vu%14g}@+5@tKQQFVPGB5{PNGZz@oq1b|D`@(X!W@Q)RH@P zXXf+v!IuwymGkV{yp?nMPKk|x`AI>*Tf0At?w?Fg^RNG4dOmKlx_|$I8TDxNg6Mgp z#+>3sc2;F3ZNIcWqm4-csr>I!wv0}Hm$F@a`-hZmOTten+v}}_pQqIl+7@nJwj=T9 zIoO?>VZk#dlnlsvl=@SC7R3N}(cc{?8-3BQ**E-2U3mw-`@8(Cw)_wI*@#PzZJ904 zNe_0*<4JzU7-R4Lh!@JUZ#%Dy^aFp*UvA1ijETKi|2vkrxZFIMujpQ z@P{rTZi8?!&elQ_?!kocz$yDjkAn^~!tf>pLl%1vN@|S+%8pIEdUo zYZ$dAyFew~S)XiHZVHssUw*uA2g&(B=6Ri&!2S)!fM$9j)QM$vUe}yxU}3<6TV=N8 z7Ty!DAu72SzSrMSFTZo^ImjBn68QEO(?(OJ2%|-sH0aS!Lg&;ESaH{5>dxNQbu8fz zSh0F>Lg#r#Y_G*axXB?S;M5-OPqTDvev8+20mOS)jrZcK2>>X%3T{;?07s-|)oB-l zC?K->>}M!g1FhP>R6j04foa7Va`WoEJ*j3!=@)oP7P|F_iq_5kyLMCj-BtsdKU=Vm ze?f(RdUFC$Awk6`aEtBZwBNd`R%;`B7XYJK!H!qz`~-62atX}hsznBHENJG&90HA*lj(ym;kFcQWlae6pYJR70qDh-HeT$T+ z0B4vCuUvhFo?-0bm}S&w{Qkto`K#^g!sVY0a9Xah8N-xVzxSc8~%Y%8Y0cSY4 zNaSSpwDAkgg0PUyp6?=AaHPlcC~2bou$`i{CJ{i}`qhpLJQ#R5#V-;?;@5W+3G{82 zCI(q?gaevXc*bAO&TuZyF7JHQH6o5F7#p%#m_~MTakiK2fzc{%nKrke<14 zDMp`;?#F~JuCJc9r44|E(#{@U-;=#=X`cabxPrxgqyv?Ni*&p5ydg@U5|K{^v z$j>T~?-fPN`Dx;}dyi$^rxkuGH?y=KjD?1Nb~wx~1~yg~r;rh^Dv^_?usARm^(PAN zRQ@0O>r5-ul3|NY8jX8SHwuCjk}Y?-0Mw5DM)Hdb)Xx1*(kp%5+cJ|P6t`baz9VOs zE_&%#t0(#kDVM%RpMw15WnR`^b-51r%bg)y%Qs#mf2=G|deZ3-2nrdLIJ}fW8^q|& z(Y|x5i*}=fpsPnASO(XR;9#_`bXzQKT3P(y)IbQ16#^UJ69$}!@A0z#Av^obuQUlT z;Y_fRXtCN+X=|mI%*xoLmwWP&;Jx{kI5PmWlNfYTvdaTuz7dJF_~)tvpcv(|SDcSQ zGucd!pQk%L^2q^Z8E5bLHifk)BGnTd9f16A>%u|+7ENQ2%&!u582%h8H^9p!@<*kD z#gV)@vL&t-Homykt!CkrO5^|P9(?+SuYQptTl1B>Fo@vcI*SKTrD!f0MR@wDhq3X> zrQlqEgLX2N7hjh_n!FZFEvy}b0K{fGywbKVya4)m5>-WxaPcLe48VSM9_e63i60Y% zSUBNm!CqF?{71ivlM@sJM;*oVm(x6+t7Id_uSzdI&JGmq`jt=2Si=HPt+H#xn{PxE z_jP~UjG-XzL<0@DLZN#;9?wgqfrU`>Z1shk_E~%c&X3(cr{W-;?}8pO9@KX80aC2e zl5q{`Mq@`^Wfd1BV5@34j`GfszC&oGqxu8OPCP3Uiq>zsOy6Ezn%KHb+FLi{QU6|=X5AZwmk^(d8dKy4TYTd@TlNTwCE2#QH>&-8(5el^Ku93W0ASQ2O0kX-l;8>aEPWWceC38Ai6R$d-*BS5T$^0Bbc+?zo z&Hf|5sZuNb+D&p#*}uCG(0YBBVLnse@9l&6Exz^O%a=`F@zG{|tzuX1iY@xznTvG% zzK+0kC7}EIj;)Z=QQCoYTjcsM-AF`7y}iP;RPg(*8}23A@Jn zO?`4Q)?Pr)>jph#zFfoE@iKs{kBgC}z=|BWqqMOfsk$zx+(E+n;jq}KvZ zgd;rRKRg`+qW?QP@bA#l2Zp47?%4kyh_?#28G&>TqRt{D4mCTNUB(aNsUA4;eS33TMgdHXCtfdy?+Jn6j`su-V0~^Al zfFDWE;B!CmHz~*7s=7Xakk4s@o1L4-Bd&Goy;C{jD-9NJIVaC^B7b;O;yh?7T~Yy? zYdK=QQcC3kUm8fJGqvwy^+g^F9R+e}yE z*1?@_=;ZFu*0@zrHJn}^-`#R97Vx4obDYry2kHs!q@lp>p=24d53#HdI&P+wMj`e6e9iKS10Q)e%SiIxq2P+KfV{ zKop6u4@X1i^H{<43U?qU(LCwfUMNl5%?ZGM@}O`Ql6X|UoOU{!Wji?i1Q6a70r)cj zlWlL-#jW&q0COx}+J$IHKk;hKc?7N1$wb+GH_Yoy0=9I4ce{j#d=QsXgQ97jvSL~` zkNM6v3DFGwbul7VyXo9%9Z9uw=V|YrQOMX(4!3&391-V!9`&_trS-PqgKaOsHd0he z96KhO+z1Z5d7=2`ldph47mVeGhf|7=dL}-m!Y%?dl&b0TP9i{R<*Ex}xqEhmsl+$z z@@kh?YivY#$M-hM)EMGpm9JNv%jy%2KHC@k5MiA;aaAlKBERzZX=*8=Bn|sY&sn35 zl1F5of%`&J>)#CF{C5=I9)FM(v)q-H_>%4z@b!Mz9Yhf%GVPi9mLL{6_DfwUzPJwt#?5k7 zLb5!>JhKEX%=ww9`Q$UTHNU!~nrB{dTPA;c+}QHGZz@5$9v%!Csg^6#UH=XjAuxUE zdE?fNaj&mijQN9meX5hyvMSS+@5wd}1mg%L$##EgtBwPx#zr_fFRQ0z4yU79Oc#h% zjk=#`Sw^G`_}VOY0sY8_JB)^+8!x_GBg=Hf{)@dR_#f;=hCkSgu}nUIZf~m{Bjsi# z@vlAkBmuFidccId?6cjSYJ~t~?9vV;2<|1a+Imm9(b4Q|Hy3Ya2e137rUw6N3q9U{ z3x2zG_6fKX+wLWPYGbyuQIsANz8$=zYpzKjD0wRsc_YzatuAN07zZ{UGyOXcz&B~afb1)P zblDTbE`<61ksD;4sk{+!T4#&?T8F8&7vgAIuzJU02N-PA_p(Q>X5*&%c%9kjjoICX zc!4)@v3;c|4O*|W;(kEcxuFM#R>qq*_tXNs0*V0zt76P4-~IDRsqtXnH!He-6?5SB zpQgd5d7}w=@WolZKZVXV$>6c&Rmc_P{O(r6-CC&PBNg8f6}UX^O-wsWJVBV;JqhLe z*)1qA!sr=bBZ=@d2W%vVUZ9rgO4|2t$fDDB+N>lW(2}=fT*YyvR(`(mYE8f7U6uZI zV#otR5J;mfIKBS$UxY)4d%6J7`v0k<$}I5TlvJ~KzsmlBcKuCB)o~Hc!9oAbhuob?S(d0A&4Cu6tE^$FVEcr}l-n;ZQZ{oUZ;$xBMP z_f4|66hQ&Hb?0on`J@X)RYH^7XFW->s7)+6l<)Zc?4VT6`9oW}(k12+#s}APGixA(kq(u=57|a4*KC^PWh6AOxkL87ZO{?K9BRl3(&3MTF7LpT@ zclJBd^)tk;J&{g1n*Jw|@P`l}`h2RH$o*k)_3rY9wNKe~b~i7LE3%4CC|L&L>l9H; zT8}~=y&Lv_s_>my@j4g7{VWEk*wY7Oq)+c68RhYO-RvtrMlf-ZGYaXGGJA2b{w5XZ1;N?L&A!oDg!)5>VppR$i(r*A6n6F=kD z6t~BI5#;mD^Pmq^KDs5oubnzvwk2ZGpcd0ZLT{cbk@98uYa?s;%odeSzk2LWd=R68 zT!PztC+j1{2SoJ>PdpA1$eATvO|QpyD6pk&$Z6VMj-*vwYr#$9z}~?S`BZxD ztiQ^Y*9{b}0<+HE{o8ep6=%+s>I}DW7@et_J#zC->TC`ce4=Db}pba|1dX`WsUR{2|}(z^iwg zexs-1)(YBNTC??5G*g`9~<++K4_k?F13j~;X;^UZF*f8hdj3&Y#`z$!O9g|wMrmm zYgm%DLYC{wF`I(m6dq3LR3QiW3cRQk82Zm2BpTYqYC9R0+JFo-O;Xm94u!M&%#9-k zv=TVO)03>mds%1J{rr$kd8C70mR}#5hZI8O(jcqt^ zU(q(5>fMarAGb`c=#XSTP7iuJB-xn4?&}!d zJx{^|az5q5QY*6h89vkwGj0yBUrbRP6<H7HaN}=O1J7 z&ih3!1H;(adBqqN4JV0l*oBmwGugjdMjclQ4nj0z;gV;sW(78P-&#?;{Ui|AH&{d< z#p>5!H5+whBtYN3?9jbDIv@af)yEoBAt)A&C)AX@54m};W7{RkJbEaqkfgxhz(=EM zo66$&4DKe!Eaz;~xaKEJTg>Lv@IoqXG8_#X4EnaD5G%9kq_X`vbQ468$A{~W4 zPv1jns;bpHQ*XEDJH+g>2LZetI@ZQr!j5p%L&k*t2*|{fOHG%6bX_ZIPrA3 zb?u#z(y_0Wv)^QimqzQ)^y#qP{H=dMvDqw76^vQT&EdKn`2SF8=l5D)x z074dTj^40<6H*2jIpg{#2nofwjIKibe{&r(P#M{ z>Ge}NMK@K#tf7sA<&Jp-g80Ukh0%;Pxz~#KK17+tBi(E~!)Ko>E@!b^p|7CX zcj5H|Y%myGMgr!)Vt2riIGt*485g@qw%XmW?m{F!DI%Gh7)+2oy5}2W(kbYdDP(Gt zCS+rDiktstT>+Yi{;B{y6pLjIoLK$b?45s8MXguN!?Mnjen;~9o)qbwDK$QaB&F#rynX-|IvQHK4X+fTP z;Cdg7Sj}jkoM^a8o#2bHb4+DsebZ^3_-F~FO`JXCL8`6B{5Az5!Bqg2cN>w$KJ6n^ zn;GhwX~tL)59evemetPCjUem#Oh~)I^c`5s3ZS91#`+jyuJgtW$9u_QTfKu;XIak^ zAuW4f6XZ;=oV_?a`gCZKjlll6UFz~zchl7FxjA2VqekY9>9J1-Vj65{X0p`W$s*r* zw#cv1t$`e7B-QO&mR7l+}W@kHDvsv$CM7IrrGk=MN{(SGvv*k z%D(w$4)+&1I;C+Yf-_qqV}Y#Qf|j#yV2#g$QUWRp1TYLld+ z70{hIi|6#OjF+jI=9G+j`D|KyxA&WRS7a+lz!K~-nc6P*2~*ph^T%qWKCO}O=gn9v zUmuDjsmo~O(I?-}FsjB;O1o=Lbk>$|Q3$F;)Vbe@fKZj`U2JsGaSrpj$VA|aDxSy-BaJRwxeFBR^NkBy=t)i;0tO&YcPdVLTbTGSd4HjTu`vKp0 z&{N!Ld1ie|r5t*aVwj=J1iG)cZ(-dd8M(6Mp@PYMHJZ5-kM~qz_}GMX*43-*1B-W zXG5r_QczZ$(GrwY#n|*zs1L?3^AJs26^9LpEm12aiEl#`jcR8OMT`Sx z%H(A<3(#g0UamPk2jA$Yda??6=2E(^3<7!R$;j64RZ=#{b9+w-<;u5#u_DkgD=c#J zI;HUFF6g1dFn6+B!+;nuq*oO(a_Y|9se>$dF5y4e?M{`uyT~Es6mz^2)DoYY$dbfn zvP#saD+l(VpFd810`$INMpuV~nUAhmx7@f1*G*k8Ia)F0l~j)XVQJ zO|L}DO%>Wgjhw}hi2_$}sPRDYFOT|1aQZ>B339KCtee()L0LZar@ar7PCXszHfsl` zDI(KMMCZsmX*Ga`E=-T*)0Kx6R}-&Vf=TO`5KJBdQGOY$i_J_3hf5TM3h)Z6Z6Yrh zo(*yr(6IM4N96xd7GVQ7Yn5Vz8 z&nbs1d=9;(aE=MDV>R+J^bw)<5SnY6{Hr}Ty{ z6*Y*5$u|Y4i=jeP3P;^Lo8fqag0aEUn@*pAR1J^hPm2~GE#*BVz-@`2lZ)9d*L)Y! zW$y`~(KOyVTS0UH|q%mfkur>syk}2b;}$Z!!zaV?r>dAZ7gwH z&P3WFv&Gz<1NI+t1DrS-`iQAVtAu{0HuRIT(Rj#x85Ph#oy!TW8_+|wngMe{63$D& z3h3MHQa_6A3*bEB81<0m+`M0H*vx)k`G#Qo#vS$FX~*XszEw39fm@wp>!O~eCW>Kn(S#B zH}2ya#1mD0x7b!`4UBn)Ow#LhsYgE=eyK$|z#Wvv>iQm>MTl+jvBKgOs|$A1^!HvnXq9vmvU@2wO`dv~t~|8oh`JZkEyga@q?@=>GD|$bvv7)UOzEp0 zgo@7KfVou`8I~l4ro4J9+u0=v>zUglB5!-6$t%empxoIFG1Jq{eFTYj1q#)DiZS(=N zdh*g(7#dZkVAP9vW<)x+r;Lf_>39sske+_w0*2X;B}eCt&LUX-<9tc8&=A`B@>w5V=;&e$ctLyBmbj;JRP z8*gT5TutGCG{@N1ncb5(d`&0igiJ7v5r3f6|H6~xH`R~Zfs}WXcH$xREeH!1@6|8n zum>@Z&_Q>((_B572B&Cj$uGI!I#@Co}&MlF_8P8N8NxO=Rhezc`bE;u2f?7n|VRL zr*h8?MG|+kQu%G}&os|D-+$6H_}6-+pE#$ix>GgVLnYs4`eDADT~MC6;ZC>cpq;MY zq-3IS(kx+2Ql|Io>L%}J2#mxf3rLCOv-7Hn@oen7X^InrBL%hFmKI|K#;G^(ClbTt zm_I$P=n=eh=@CyJwwVCgJ@@xQzsKs`!~18X6PjH|sLaihN%_T{1{M-Tlj>!>rY~Rrg&^j(7CDPIZSJ2VTQW zzkm9Tk2A(`ScT3t4u6@o{*+Kw41w{_|5{4(uODP0&b-XJSf4O(t%CD!;Zy0=#T>~W zuVzb~5_zX)x+9R)ZI@gI(^JiJi!jyLM%>hB)>RXOF33+x6rvZpmf9z)6%f!ANpHx0$Ulnuk2eRZu`ONoFug# z-la>!sDFHBBB?uEM(EoQcD|)5V+&nm20XcNNG%oY#}PkzuD^XslOQ5)BOApUN%|BN z(@fie@%f$V=~JSwAw{ZFW2WUT&{FjGoccw(jzcDZ)eI@Rn*6x6tCIhvReAVvE~~t!-I8fSYO<=-s8O5ET8{WGaJ^(id(Hn`nGCJI0);9NOYDSBkfZU zCHCV^bz=6%35D!9A>POv`o>OnQPp~8>p5>0HmsG+rr$QtcJarVs_f9PYJ03itJ@qX z8P^Yl7>lshZk^WgQ=ShtCqSrY+cGfSKAZaC9;Eh-e0!2iZtZ~s4F#QisMnMssy#tb zwg$-e*Q{r-xs+}YP<{&np1r>^BsQb>xZ?OSo^Z^<2f1wpR`HrKBXnh*0hcf}vb^R{ChpBVCJKjEpDFBz}8FPh$| zlW_77lm7JRMl)C4W@o=BjEQcrsXVq{wRv*$@V-*QBqq%J{2m=Gm}of4`Y>R|u4X<| zyT}J`l?HWgG&X<{zqI!ZdRSYnO3f^F43KgjEUxJ*dnC@KycYGb50P;O4bP5#d28Ro zKm4DMmV?k@s=f$(#eN{QL%2BG43yxpC&A)P8U<8zLbFK;OZP3U&IOCuR0Qh`%7R+- zMo%U&$D0M}`AR}&Xd7d=$z|j7FW8sJ?gr78SKlR;Hwpq!AZmhCzwJ7I50kT^wYtv3 z2RuGb{`saA^A$og0*M8M1r^r#^RAhiQxgm1hFE^9SbeS~!%>xv2KT+H`fN=wL84Sc zMcIR?!=z;Z7BoSjz#GDFL$P>B~e( zyKb|uNEzDAo(ZA5j3RWg%|}?Cq(dpJsdKdgNmiYUl5W4(^b3!Q{Q}N;x-LGH9;H=8 zI6CaIjmOi=ie>}0TlI?mZk1`P6nBhlc0i^X-g2oMidSL=(kF7lHA=>_X*RJQ2!X-G zpqyPd-v>0tnD$_Lp(!4t*XI>eJeD-nemJD7uUV>EY6pA4^p)n;v^rpUm=9LGMrTUh zwN*&qPd!O5-8+9K$1&*XDZ5X(Rpko1X)a4sBJF(3n9@M;DCE6$h2gr{uj#4?(eN!D zZo%XMZsFCrL*CVNw>kvh!^3_L5sDdAj|#|?bDRPBN{@b?KT#R2JW%YgqtbHD7Bsia1D+(-;4{OWks4EEmkt5TTkF;HB1(JK_&H95& z&H0t!I${dA1f+lkUWRXI(UxG-y5K75gwRx131?;lwi03a z|BoHCf!R8AI4YO_N=9pN+^m5@PW+y7RBDkHeet0Xn(7ycu7_+%yqI}g$?>(s3B zMEV8z`anhM@>Zg;F#h~E82C-A`!$4c&r!@(<`r*SDPv|o>w(JH_Z7xwq?K@l_JJNF(Z*^D-VVjb-_`B60f3 zC~_?crA2uVvNl=B_SDWIg-zo9Y7KSRoqBG4fqDU|XuX#F_`?0K;p@2$X!iX@_sf`7 z3~Qzmw$;GeW^PEN?To7@tYrvnRAua>tN`IX7k>yjioNP6dlBkr^K@J7n>6#)5`mg( zp>=dk6)|!h?@KDm4?Rn&5Jhd~rKE%A>Os5*Lr9IzE7n7hl+D2JZ*ReD)GCHtG73$! zEQS_8kNQr*>YBJ`aIUDvIa9sU6R~Q)WR6xlRwFkb%$I2vZ^8Y$0yCwbs!&aglmZ%Wo_X7oWl{fVBiQzJqxY6*e)T+yrX=Ty z5!71O$oOQ5D%0l|6tJ+3)G`#SQ1fAuulA&4@)q0g#EWT}qCX7GdRpx%U zS)R-->yY?G-wS|{7Wou9cScg}TJtABxU&NQgxtA&U6PuA0EC~?{E2M80_AXGnmnu| zWrAy;BRmb*W-YTRC>0Q-CN{t)TYRS-*YebSnc@~~SKTx(HUXm}0}V3)72Z`b-EKvG z56@OG>^*Yp?~(^hUXNK8K#B+`?-TAEoV(3Z9y1j`WV49SVho2QbCg#SPIWD>O=6;2 z-}4RmezGG0qo-n>>OdV#p5Ggke;Zr!?__qnFB3mlUx_eB(Y*9yKO3BA6}6X7-5E6W zcFcLzaVt=Cvp0VgI?UJ!^XmJB68Y$iE|#`gNP2G{jG6@BtOV4m3c!SI&RAY0T-Kiwe6*T0&me1d;untO3Zv1I>g2kd}10}d9}acoavt=R+Rdw&!C zA42zXm^dP5PF)|WFHN;`irHxG)t@|Smb6j9V8&SCZ#74G?SNgls35_YepZXt-SA;= zl`xjOth1ihAf312EFc@Tb^TPok*QAt9esoqenyqS_8bSgF<_WNj2ww+IHR-G3`<`V z8vKBY?;;A%o7dGPqMN&pv%lKKwb}`p{-$Q-XXqr`fIsgkgH;Q*0!uM@A(du=a-`0KacreiI2B6SQq1@$%dI|ik4)0YgaEZcq zZ?a7J9C6NcK0LY{scKeux0SnLvxrs-dPxIbhi$5-(tzy5(^nlW=bo{wGn{8SU)T@SObAunU<45wL;dUD zuIaJ5p&q##{thArTO^p463iPMc{-zCyj2ADnkK=X8RsLz);a-y&WyjK*V%GG-(ui- z(3{c;eHgRTifEuYpoudzmOJ7-djHT#GRSmTfxz6%G0_jfYgKsZCW&Y_Ivzaqt4%ksL>d zt~lW`p7q~ncj1)i&vooLI4K@4OUwKjmv`n8vb_&hW@+NE8!+iADr5wq7|p@$8s)_R zQe-4qf9Bligl?s9Hd}F3*QOxD>eydc5#-3sprpw7SkPf06hjZ#K;E47%t>;6HThJ71-U$(V#%aP~yB z*zb(6r}xWg8YAl;s)oteTh7iQgAsvZpNCu=dnFQ2lAd@L4n&gGO4XC1MSq}#^o)_s zyCk&e%U5~q1coq|hHom&kJc+5DSjUF4W70MJr-gI3wND@NrA;aFeN}rYBdzOT^)~BW`)wx2FjD1c$dF_2_)hkpo0Om6x0>bJ;{!RRgqe)nJ#-D;5>CuI z%?gCz?uzm71cHVowI92EJOO+6#M-DJWd+tc>Uw3;W%J<8&EW^Yb_sdFY|Nb!Y(NNf zF!J>Jtsbo>CHsrIKnD?gAESTg7d55h=y73w)bN>eBTC#l7AGMSJg0 z(RBDuC#F2u7$0ycdCnBi`HmZ%hf$;Vntzp^2Clq_=i$5nA(xI=q{8xSo8SN{RbbQ7 zrQ+`4TO^5h*+ph7Wchq>!U+sQwFrmXbrRY{K-1~kQ+z-Jw`+Y4TP;eRspbHzQ9z5B z;Z7uY;Nw;3<%f+!u4Xx96|OThY~9Qt#}AqEjT{>W4WCUXQ6ibL3a-6~j&n|H#?BeJt3Qw?AnN1DjMH9c&$e_QV2+=9>Z}%$4NzY zbP{_te@}{W)VZ@CLaF=)$So9pZBRTqp2}WX(FtJoQ>qxfSZ;(^`>GttaU|_roR|rN z(=+B)s4O|-8Lj*=%iZoPO{`E%$2>-)z$}^nn%@z{h0GIk~_fQDWu?Y6SrU zjWH-YGbcs`d0^=L&3Yo1Ww44doC5wpOv1G`FuM9ih?UKV0FxzfQkG9@`90*gv_CR- z#d(p?oCBZBWA?rHHE8I_mQG1GpC#|c-WOM(xM~_7=xuvxf2r&_0UdVhsg-z@9q+X6 z##lN^`U7ZVp~R0QC4$%~MBWC8z&xgDOQxOO3r?X1iDtdFmMjU7;Iz&aNr)te&kE%? zU-XNBLL;u}VI&|R1a=JTb5g33w^}qCaMFI9^sf-VH#{81{&juwvhme+bpJVa4!16xJnT6fo~{z#VdZb7UsCZJJ=bFkWoxeJTfRH$Zewh zqQjm~Xpl(ohg>=s{c{-&96#`{qa zj)HBYsre{{RH^drF0z51n%%qOw^TG9uFbnxq8lks^4Q6rGd!AqFOP-BpRc^C6X5d? z4tv{%opfjfsU)O2Dil6_J$X)G%H<(fNsG?cM)Cij;@&%~$+UYHb;fZRY>XnJA}}gN zYNSY!HVTMHQ;}Yx^cF&qnm{ZF(nJWoN)e<>CqNPb0RbTtkrGIx_ZkuiCD|_u4)cBU z{q}eEbAgbWI^e`(spDaG{42IJ7+I?jJWj%5VQsC zh(K1nc4Kt0vO9Tn40={qFJg;k3f+khqcswfz2moKTV7RB#8}UqtYq%7?%?IG&5={U z_Z|O!=DwYu?m@M8FX;Cq*8`>VnJRd98ztH4YfkPQamDx=^+HOdhm#M%sj};g;G_jT z?ANmIRI<>%H){CflgBEQl4fOro7Z!gyS%hB)Rie1Lv^{BA~~jAWLxEusHIcGYM70xD&U20hiwNshpG^CZJZ}#7|vov7d@75(euSGd*kLcd~fJKtt$u zSBd^f)Jo0QEsZyQvz_1a{Zct(Yf~Px;l$s_J}%u{p*OnT@nV;dscO6gPi^fwJ*L3gueyIoUpNN8*XW`MViZEzMuVFH_K>Jj>e1y(XmG}f zyXP!29*Xx(@s7o86$_uecDYpDYm3WrV(cNQ?&G`mbBNpq+S-`vgex*l#nma0XP9kF zZWCwD)?yDnIqx-0V^Ck7GLMgL`ljmIY5CpK*I{|?cJPwBE z*CN6A&=(Uw^iWu@{>|_ikETXVQE8a znq+PTn{XJHmwcS~{eJJ57Ek`%kTC7Ag8kiA6EgxC~o)PtVn6y>a0 z%K()1-@_|bD{E`Ad0{`Ls2lQA@(z5S< z`Qr7(9MgOi)qRDtxDAMXtKk3(9pol_VBaS#CevG$uT7k-TN*yAs$u zv7*{+B3acVJS>yR7uI7s!!vy3wH%d<7AKURJ9(;eMxmhknmFQ(A>-sES18K-YVAfu zxD}Vk6vAHyD0vo2c|r4(s-4&NAqQp-ym0N?`<7y+9$SUt^QB$h6u5WxCU5EB-Tzqg zEQkQ8B%?L~jd_7HL(f<)9osbRzYP^QiFrSDc6~#Ka+kT5`r?b7I7^89%bs!-OoMd{Ckc42rn}x>KW73Q5scC zTi&POEPV}|Fw^1R8q+5AK9907o4G|zk2lq>Srru+FLWdpZq41B}YwRZ5GKs5~uW} zA7t_kI;ERHo#IsX>H2@FS4#m6BZIDC&09+W6#6LGyE(EwxwjPlmA*YVhDE=%HJxBk zxI$G1AY#m_W%cn*IDy?=q9M1iC~#o^e`vFBEzCOp>i_iG7Av#kJYLSGxWDQS6mm4T=AjzHnF;gs5+dx& z7zeDz)@wNj8q0{Fwyt>L;R6(m5T~<+jfpRFGOT@!RrqKdG-e6%rU!tn1-SUmhpY(< z`e?}oqQ9nf?5oi5p&%pyQ9@GM(x}GpmM<7E-sC8+ zfKb<&6DC*}s#?|8NhylUfHVmfSQ0yuTFZ2!mFsQ-wI8SM77WP2m$WuyokC(${~Yv5 z3txNC%4wbisblSh=iNPh^e3g8 zB-?OASV|Ijqbo+SFnn_JC1RR8Au(xvlbBGKL=>k&L5I2R&}227Szy%UA#1?&DBh)j z*oM~n$Wf<_Pwfx8{2Oeot2QkA`XR@8#?f=39{@XeBpKSa8kc;vk1odD0 z=fY?Ig^nL$1T)zFU=;snjr0&+2Y!Yp_ zDqX)n*6uC8dTsZv=0@l6p(fq?D{I_vHIjneugPB887tRk`zw{0F$qlgwSqZGz8Z(f zzf7)6-yc;%vuKe?VvbKa4Zx=`)MCS>nE0(gg(GcikyeB1b*$g-GNowiAnC5Lk5VKR zwUl1~E@^(J1K@BHo&1b?P*b;e94d8stl#0KceiU0^cvf59Y{*3wkaeJbk*7&vWtR<~THf2@5yy*XuRwBud<_z>2?C5zr}fkX6+wbRt(hNszX zS6-Ri%zX47l37X@a%VruOUg;=3B)sV>df#PuD$9rm zE(0mCBT5d|o?`NOUy;nJZrdoNv3O~76kXo^stv-7DT4^K8=4hQZlP(WW!D5aD6fY6 z?+AVu(Bu2lmetPKnSHGJ&pdFkuo~i6AqcPJK>g^ZF|q8??`JnkcKsp&uuFk~`gxEX zwqpq`A*wt(%%I08TYqd`iEmrjY)vP1t?Sh< z+PB;tZPn zsW_T_-WY83P3R7wua;1;D4eE5&+rE2CU?r!A29(?%UGR@BxJwAnXns9gr~kJnZ#O+ zo*P^ALO?#;5-nSL&YNJWx3y(X9!`BWYmZ$0Todz3$-$Oxq*w@5_1@sXd_3MPDCeZu zn(k1khz^)PZY|!%!~;B%gE@Y#`mKZW+xaL#MY&e_mZ~EvawmeA=TBC#$3RP6=jD7K z2Gq5utkTt|-^*xNcKg&h@A|!aS$KEzRgWKCat*N>>sHYF`@Jw~f(M+NZ7Hn8^-Vf55&^&y#3<7AQ{VK%4(j*Vs{ zUgCrxmy$T+LE?+t4s{+W_KKuu{6@HPIVVe*5gu^??X_Z{MX0<8;R_&cLsp%R#MTs4 z$MF(lS+gSGVVZ5Tk#|+sU%v7I?DVw{p@_Z8yEpiT$nN61-muO$ZWruhayB~D>G{Lk ziTa1p5)#UFdL+{~RL-lG=d&smX8g>nRxr=WW!7-sdp_swYcJiN`cZ7QZ-k2X{f*qfA}fmNTmbxOq_G?k7CRHIm{~mk z>Kk{RvqICx2Lhmjtzx`!Gd%TeW;J@tHDU2{&qmcPD{7Zuzx-t?-X{Lz^IDRCE=!KH z;~YOBIo<_SjFOi{e1XNxl#+VdY7uoSPBQzVU0btG7A2H(HT$ zl|t6SHXM~DbMla;VCuB0;%vuKSuerAqLK7_{@4}kg?CmH{mR<2h`m7)1sh)b4=>+M&@6M#qt@k6o`hT4Vz55*hnk4*Ri9LfwfU1G)hNIZh zs*xSRp_nQEoMA!eblJDT3c0BraD^YIbf{#KXNzsBz2KjS@N%;O!ryr704mWM zP+*;ta1Z;y0r}9SD~K#* zX8=;06cX!?#(T%Qwa2Q%mX}0GVWuf`QQ%R9)@LDzn>JzJreDmYW+To~{2Lb-7PXjG%_+!?{3ma3h?x zogB5*uG+-ies#st?4Mo{66Urtne%dl&pCNdE&G1lzOEnWe79U|4vhr@{S^ZKXxvQJ z3|-R zD>&2g;U8X~d6A_j)Qp^SEkKaPq%2)a7QuSo*L$rBfXEebOJt_7M8LIHQQuiIWAMk~ zW;SMfE0$lA9TJcBB<{o;tre*h4dtfp=hr?jXt|~$q>kPBDP~+zbUCV2GUhg zJZYgV? zrZMQ{GWGSKI?LPy;Fu{^9R{Mf!$M&}Kruy``iiW+u%HfM|91RbrQ%mI;SW4H`Kx=& zw+jphnHS&T*4v1u7S8@}Zd@6&q-{H4Mtw+ zd%-_BhXN&hdS00D>8YgpLf)ieA=|O2{Ff5_BvZyVPKdxgR)KW*mT}*NDMx^6(6tGb z?x4@RzGp-69Ela3wr6q+-0+HXq6cv`d|cI@QBHX`Yza-*cwDcFpd+>^IhpVeRjE|p zJzb+rdv9dx}D}Zb`PnrY@JF?=WEVTmt5>=HBZ-;j zb-u+~TmhV3*5=IRbw1NXlKkD=9~b3MQiD$ceb3)o!H;+_V;IyAgV@5o#>^NiMWVKO`jZ=;x9V2ZE3jfK{rOC5{F`MdDVa_hZ}`e(VeH7wFTWtY zM%a0P07SdS=9o6SF-DfOUj6Ak5}LcF@4L}pv!5~yy3b@3$D&c&MkhVXuoO``O_g9&O4|0|dP62A{7=%#%1JFYOyJAXO3>Ah)M<{}7S0@;q0irR@>17l=~iBPVSazGK@eZR@?MF&E$gPKimQ!o08|b zIhl_(46VL4SH#!#uxMW+kGiu7;CoXVjFL;r0mhA{_O*u)?ID}K`&R13iju6jW6&@z zFO`yjLVN98$Pq_Z!Drf#i$XGVW$l5<_zoA_nivZmAj#2i^E{GZ6LmpLjbV`RBK(6N z*VnPL5mAtdKBXsITDKVpCk#`#c+$Lawgk0Xb*Y|XCll^pUZgZ^z!p4pNgEc}D1aF=KL8;ZbCfKS z%ZmI!oxR}V!xtN=U#N6(pyHd0O@?@@K zX^EUJw}>8X)izbX8ggPh&!8YXSjszMX?{t1-p!;`Af=J=h)TWfk}#T=)$I(>_+;YA zt0dI!sW;6Ug*}t2o$GUS0XFz;s8*uJ`mZ{a1ypZ&%OdX&nr@BQf9fOucbDiL_wS&z-BrcU211^hjim58g*3_0b ztG-Z5lmMU&zr1?Rom?W*X$}$arB^sE%?rdPxp*kyLG%Z(6Z`cjh?XcyjCN zw1yqnM(-m{vfZb&rDhnB3gq5Ya(}N1mt_WT%Bp{^W3dWQYN1G1$~~7>cy~@D;;6zZ zZKC&(_pmKRExmWKXQ9uA+hf8t)AM6p2mFLid;EBO)r)k1>^|cKm;83ZvInqGWb$JX_aei^G zp7XbS*HUJT@T_~no2(eFwA(4w*GfNKLe~J+{R8Ca`|yD^*}>annH1I&kaVn|P(xIi zWP0Ny)0=JRF*JsII?!|dqRcPZt5QdVpo%4Oz$_2STbW5{D!C`7Ota$vge->!e&j_)?UOV@E&CbK&Ma$`c z0n<049}3PNEOu0Vd}vh3LfSssBPTevN^>1xU%$LzQYyrWW!Z*v*R%nA&jqQFl4N(F z3l8tP3#~T#IRXZnxo4mY0M2-98tyLx^u9!_#dS4vGNCan2}?=JbsoyiuadeHL5M9T z4a*rq>iEiF(~7B{-^6CwZS_-O@KTLB4__NhM@`H-Mxsx_u{Ne02RS@N3OTPI?w4c* zGn}vHx^Kv1YGH8yo|T_$dYNb6Gs@X)_w~;vpd1k1W-?mwOzWL&R0-V}G5;Cu5CUi! zvwDjSB^TN0S?Jz$lPbnK@X&VrY-U&P{fJ+!eE1Bh>n%ucn|vfbb3;eo z1dfRy{7n~T_pHQR5wyZ;`)2Q10sP&H)41&v$k>%yV_Pr5I4C*{n=o`YaKiB1+#z2j zOH$d?QhO^RyjF)Y1#ugYqEFukC3%;(DJJy5rps*dVjfx?DZ#S(hHB-0P`Y2I=lyf^ zTBV>k7#JA!2pCPLSyUhUCB?(r#1j?qZ41~Blh--@&6Kk;)Scw0qlnMzM&8X)R)n7O zVw?RFz>JPbUrF(Xk52DtJ8~BH-Zq7Vg(7_&VQUg?x$|p>ckmeD;w%e+TEGSX_Gd0Y zpXzn%EQfoq?s|uL=QHMV^EO|)aX*|e7`MX8m$C1;Cjitp0}5bXneZiV9uK`QlwwHK zQq<&J%SHx6foyPv=%pIM+cOXV&p`++WLLzGaC3_E2-z1B-9MfJ>32tl8S$=~Mx1XS ziYvkW675!Yj$rUD=vnnnXvL>Ex>(CYTV#@lVtM6`h_P-})8-oaQi|lzx5cowE!8Gn zFKnY?UjVG%8-MJfH(Cw2k1cEtgQF!6>Gks_yx5$3lS=kXix33@ zPtoV*^j6^#OV@VdY_ocHSjQ~gA|;p)J=>CkdhKPBF!!)k?<80v+jZ*T4rHAWAqw=2 zWv{NI2iVyF*e(Z6dE@7!PbBk3#N`!(s+QbW+!!SD5g$G|kAzY?|m1+TG`#l!m+R4lOop!3sCe!|7)@g+swE6eo;X}iX@bEM%1T6N%nDl+l8 zy>twh?H)FOWCTyw8Vy-g_Wvqm#`0|@9;A@)b?XBvd5L}C)2T51K@nTFol@h=!tGJc zloI?_i$G!Qv*Rf&hR64I<<+TXom=fUc$y#n60>$t1EczV#9lfuuG?>-q6_Gq0lEiy zNFQVz7ESSLeaC;=J`JS*-KX@DzgHSV+k_1pPJs;T?=!@JaK!tiiZ^1~sc)O*ue3Kd z*UWq#?tqn#13*aeP&1E5Bc}$Ywsra76Ewo{neNy0LV$#ab>b6#|D z0Qe3>?{)8sFz**@h1Yl|vm+xLa{pSdm$}l&~a;CRa6%A1LHA7@| z%l>Nqy^1uTNFcGt7+hLM3D9od${cL3Y$cV*f;arf0K?1Fxo+Qf^+!qxPm7 zi#>~JGdsDiT*P|Y&`{zSIdU=&0zhTmk(&DW`;zC%8O$m8-1rd`C*;G&T3!Yn+D5V5 zQj1D~y=WM=^%TyCBYniI&ci8(k7MI;b339ll7iwXtr2xA94L-ae@H3GEbh4r)H>yX z$nJ*rwWanud0bvH4*ESJXx>5vDe>71V0PQHm}n$uY@8xIrm zggC1;WX5y`+_RPkh{9HhyOHp>}+A zbX#r|lOcvfsds0L;#U%k8_yY1Qe*;*6gvVWj!&<~_j>JgL=itU5+im(0%%Y(@s22U zwk4HZHm+Y+AqqhtWwt$d5F=U|mL+FX$AKg|eMhwFZY%z&me+wnb+4Ew_7JqpBBp<$ z5$*g#_Kl&$PwCu=2SP|uz;6@7Y6Et0WY*C&JWF|#9)D=06Be1ZUIBKX9-E3msz#*BSu+ude&q(l8w%ppzg9cXS{z`RGqV z2$%?u;1Zfx>G;UH=kuY7Db(< zj|6MXvagd1HO5O|an6O9bKba434kQ0XerPFmTDC4y|0@as~pT45WRP>95^t4>m{G2 zCcl(I?2&rlNtJOYG5w2j_M!^w*KK@_nGu^t+Wo#N$n6xm2(#&W?%DO8LiOngP{I>1 zE$74~;3x)0YSOniX07T-Ko#_?bv)2yEoo-qHM{@=`rBulg-n1|aCtHk0}<=#XV(@H zlHud=Ei0Qlr?kkwRfB+C5r{B~IFD41a6K%%7TjWV$o1hF_V}tPgB@sTyrf;C0T@)! zNFU`)9XF&Z2RoQaNMZSaQHSi|)Xp&V@mKZd5L#RHoa$t4d_2Gf99)Mfd#_C_BHl-~ z$3b;rqsKPaDtk>!7B8V+Ju8|A)&-DnwQb@$?nkH4^;lP))Bw~@)vX^c^R-YjpBGU1 zxfaSZt5Hj>HDaRPbLaHRlL?J25T*ujNLj&9xG7s*NP^qZw)P~TNe75tu2s` zZcRwK%Zb)Jvc#bkald|f;q70z8WIf%>~I9r*Y79Y07c|KzHE7#g;ZZ=YZq6T5ct0< zRKr^?I7Ex65*^#(yBQEsX1GH?Mj91=;L&g8O?KefqT)g=BbM@yLu_)`pjJ6X4wf0# z(1qauBaGg1kbs6|?~vYGk3#TpIevcA5=%4%4);*f0no^2Td2cBDtogDeORTWyIV)HSGq>_6FG&2Wtb)VF1!sZ!6BQAit5 z3hir)aP58{CM0aNXvyWdDbw^{rXEuMcHa)`DSMNJjjZ??*v&*0wjO$-?&WehFD&z_ zQ&&-;*io);)NJy${*%-#vjk4@laI?yj-|X)UBj%tQI(@9k8mkob7FreqI99@5g(CV^_= zgx>Ds0XWwtE8IxL*C*p7Q^Zgt?izmVH0XFkx#AuR5Ll`$b5zCQ@1ClB-Jo-O#p z5C5Th*DH6wC;fl+uTgr1$0V^la{S3-#LMm6nBipVApL83`OtCa-|{ajzGlDj;mOy> z7k>&B4OG8~{3A0UFGEk9F-VZ>CU++{*KlL$S@pTo9j}Fo-(A1pbRzJu%k|O?xLaRB z$(p@B$eozpUFp<4ogurLFAZN_tSXm( zRa;x#RW6^`jy;?@KB-^viW|Erq5PeSnXnwpou&B;KHvT5pPX-v$SOME%j=X^yRv=F z^f`YcJqL7r{psS0WejU>p|qo!w@XKQ*l8TC0Tg6SUx{g`Ws;W?LFBTpJmWld1iW7N zVfA{VE8!3`>HEgMK9a5L_WH~3)^~NltHocdACY?+F3`OZMdrlOhExxtNuIIO@CVVm z4D_XdhLtxl8tO!w}z8uCd$-b+Y3-;Nhu<$0i%zW6*ar2xN4iyZsT23cI%8pr^L;NA%ih84g5^)cSBhaDx_D zq4-lf)VD-`s~dMJxEBmQ??OK~&WmiF_i4zziKlKI8z*L6Tpd){cD1ba7_H{+`m@R% zvUzGZcbdA9qj4+<%?B5NC>%9|dlw$YLVE`=SXbMmbp&$zfK zp0Z>@(k|~CVA65%(S1`vcCezU6wkQd5D;9>N>cjjk)pL1;5|-{gBlTx6{pbziNt0 zgFQQwLdXSlghekprE!S-lr>BfyH#%)?$#?1CP*g&&m_jMIAkOUpH{1sbKLSy5ioE{&q`X1ydLAh4T00oPuqd z|32Eisk5}b#4>w`$cldC;yqAae;h<7y46bhtauKC=s8>0rtVj2fLY||YxFCLDTgs6 z@b=6{;wQxTVo(y1M%kc;U9;NOuH~$;v52dB3i__G@Bh@1tI#_{?5Rj9H9aFo9=g;W zQ$W4%+@XcIwuLZQ+8(=lsrB<|IH($KwsF(#5OF-rSm*p|NeT-|a*heeo91x}2{G8Fls>;=p8~0N=IhaXl`knEvVt z{hDF_tQhH$sp8*i3~JY2t8fZM`_h?sGNGl9+OCch!?d)HZ!aV<<4Z_JM7#S-8t!wj z8vTvWWtUpdyDXv)?;7u)y1-+293s96qu-@QHD^o3%TLSDB@x)jU$-p3O6P+sojk1RtVBXkU|?)xMMU0OI#MHQu6o>^5m`l?}uPUeWqoSn-&^N_*uJu^32MZjhb%uRWKyS5N3VsfR zdXxh3~km3RsE11XOy*AX);>8;BNei2ukePL3=>n1NTeQ_l z6jJyJ*a{D_Nme8lo$$qNsh+Mox78@g6RkS2Hpk~KOVKxw(!^qAVlgYJ!NJcsyfU^n z6&I=t^|uBmgP&tDDCb&9mI$V8!dgq+N-dMz{T(Yswf)#DO<=EL4iO1n{jI@0l1W#j zeH2Q|IKJz{gH1qK{CmgJs^_AYWjPEQA?v&Zw8LgjcD?5WhBIGr9> z7Wi(!>RphNoV&LV9j-cL<3qc1DIX^T-sr!*RoiL!^Zf?-%|0J-CiFm>W`RZRGu7{w z+gab2=O|-}13tIA5Kk*$St;Z^MAnbhSOcp``Ty@3{`>8cv7$ZYJf*g{bHHS_-Zzlj zLN^SuYywsx>DJ`59AXqiWun;^%@P$5W~Rj}U}2Cuda5#TT4OqRy3HH>dHi7%ib=t6 zu4aa{p*@UN9E)AA4Wt_7GvNnyu`06KsM_w+xDBhKbs2C{ECyS(&^=z^n+fz@51TEt z@CH$jEDGtGu5|`l4xF^jojKQQk?1b{>m}fAiMC-{NQ~4gECq7{325b;Qu_= z4fQ;Z#sKV#hB|2vb#+eKe1n29%(K$L%v*cI%)5lZ>gE+&0w2jLVL}T9vf z9Ct%|t}lvq;|s|YaO5)~La<@J0o{djtY@=KUe>-4-V{&EA7!2nKByy}QZXQyg0LDA zUt@!W)|M+Zx>&@$*jX)EPur)tN1|X664xM`VQNHWMq?_%agdjQgS-i7Oho~3T*jPM z!=kg6kC|3E8`JXbc{4q@SKgm7$h0Rtqgn zt%czR^2-~YSQD6(Dww^*sof)}CnoVq(Zahwjha{R(907bpUN$}_=YtaDRW0^X#?=} z9NBhzT~j)Yjk2vWi?1G@^)!@euO>mbQRcDb>05TM;|m$$t4E5=CRKbf64=YcsB8MZ z!!?T{ zV3t}^j?PAlLwXVL9wpcblhx@i8SH&W>$ogSjOK)$k0hds=w`^IEm$kPXmE1bB z#%t6IZiZ<+BF=7HE(L7l`5R}Mdy}Tvn(H|B(E{Sll=%)rRfTD>M zR2IGbjc-_~4oAHQyhwj531Q4BW8Wmh;@F5|?B5K1@5X!W*Amo;6Me}~KI*>|WbRac zxwESqaz2pR6{CW}^D+>M-a=xBZaZ(k@lXLfmg0nfl;%t1S-;A zpldOFnZ?fD@i#bkWt+m~b9vLhCl8 zwxz_zrZ`TW#$nKx5v`+#3WZ}-C7%eWfUlsE%NU+KGOyWcR7lp>Qp(7G^eov?SdXx@ zyi)s+nxO!$7V&Wzdm>V-&YUj`KlIYo&U}2GZZNmO6c8o(aeDSqG;zZkIcqyCH927C zeRP(CBVzSkN282#^;GaUv%HS{QpRW`$Ez43rGI7En*7m~r&r7pESTbHLj41^kwVld zfRf*mT3D~UqrdiL#V|T*dA~EfHVS(SOt#$=@6a7PbF;u%)lskzvwqCJX*_)8IkKmoSQ2dw z6$bx)4s<#rt!`wxB9FeETX-3FX?jZpgV@oDKL=u-lW4>btKnqz{;Y%j<;nLXk6$7o zB^nO`jn?4r4JvAPng^l54TY@&85 z|Mqcf$GOr@Fv|5t_VdKB2|J4fJS85JmAOa&@>?W)ay0I(84Oz;-Mx#`J9=Qa1NSV<0VUN?6hnoT-b5=ww#!YOW&MEPHIGR`otPUX;9OZf%jI zy=t40zb)rK+FR*jXeF4X)&9okEJAYormaHvGFzN`V}8TPG&n_HA}v^+{RQUjRgIM6 zO86YMfN-iRoB5R(HjPJ}S>eX)Z)N0F9}zG4gfl|~;v|?{A) zj?YZsC!>iJSKz>J8Tap^gSIOIE!DKoPdXTFIv;gqVsc|TuHoPXK1$i{Rge;uLz5)Y zFgG)*MG)o|)Ab{hLpr`~oplTtBTQcmK~*-9_yE@BY2e$MqFF9tHL-CK8PE?W{1KC- zxQWkVhq@xkpUE&JS;@i6kqcEM=7`u7XDnrr^DpbI%o=NS@vgO={tVJmC%zRt9kT5| z(N#@%+|J#WFp$qe%KO}2RnE`eSmo<1pJ{w(;oH@C=F|91W|LK0E)~Yc2 zQfuui+G?YX6(R$=wk@)s%2?V$xngknr7TjM?ITp)dw%R9J7OG|I}@E!WweQ3q1OUH zpUaTyOyh&gl26m{+LF-;-#o7J%VDc+)2qAk`#_t3U(rj)8w(qiEUJn(MHnCNX=mCq zwcKVhx)e(-ks;_75HF1ibm>lgbGYX|nh+UW=Eo;^-9`nf7DIPx+4x&P=w0>{8i6@2 zf&Tc;i89|X6HEjUh4C`IeC!zt9@gSvmC3eRI%mbCF26$% z4?w{Qmt(S;#eD|I5|Rd5CRpId2i?iWk1jgq)}x}@tJ5F7?FNF**AHHG{6jK&5{I?k zt~f6lC;4tV*f7h-vYR#ZQq^4d$6!MU8L-q0$MZKQcp)LR#*X5h$|*Np++HuNN6sEz zfMtMO(jaBw0jTmcRQT8ms;zwHo8=jR>P*E%AvJ)*?A>vg_9f=A6HG2L-q;i91ui%9 zcdCw`ZHwi0B2S*Qg!8SO^=%tyyYKF??!CA)XIsyd1h3mBfon%(JA#<3=h=t|sCVm` z(?iZ~ic^tF13{}W3%K%toy*ZnROTNJa;8!55i$Ln$pKLm-mbP{bugv?JL-aDt9;Hh zXn@gnu^8wlp7XKsZa>mmNHd`4$9A$_iGNY5L-Xx{n#dHZZ_K2HGc>*z)hJzF(#er& zpM+=W%aY2QvHfrJN6*`QcZ?Yxq=NHw2IlUBnKV$B%>5XmZ~e#8RT+6c)_`uqnc%jJ zLOVjAt_{q+i8IeC2lTVondZqSt5MqZT1_GL+5h4eCB)?22WGn4LMm9K{lo2zBiLG} z8*(DGN4r&`8EfA5D8={~Zl>wB`WWKB{ED(S9+G0WF+V!tk{&3$As&%?(5;C#@S8Y_ zA!V`q#36A`;hP2dkS9N)zaXwveM%313RU|q5`9`?Q-1{$owJJgo2f?&xhJG{yA2?y zm-C87Uuso)6mA}FUTJ{U3r&*P5b)5n5tvj&og18R@c{($M^gk4d9#J4d^@%rt>D-j zy=&1cyj{8(JErtm_nCR0I3j1q6GFyZ=hp#cp#VIOcN#!ojt8yFTaNdF1Sb?$9~1$} zK)vnsgx7GQOppmqrUrgJt;QA%v%!0kYPKl27%;M8!ycNVZ_ONDeww&hwjyb%3tnC* zvwSHSt%=EovyL<}G=2nxMOh+8w!L)qmRx8LlSR=sZ-U?us^ionW4)J~i}jWR*V@C& z<2mXF>y^EwZs9Sr%7q{zq{e0L4KwhpovgOtE*U>P(=~8w;RPcguwI!B)(w-VPg?@Q zT&*+lmNM~aupL))rXB~*>2p;6?2i<#PKI2Anc!O-H&?tW=LSRs6hx9tx7K?`gL^Ku zbF|y%|4R^a(L5_({#fY`5$9sv?|H#liEG&4o_d18M8k@af;J6N+4h9BY$qgP0KT!y zE*JPUFmTx;G?y*w;J+9lSEo z$>ROVdvUDt%ZJFK?wg45cE)_*o6rD{n)rDhhCHv9iNdI~2WYJFFdf`vL18MQ6vE3d zZgZRk1HYP_DjhVwhc4P4X#CTtDFshZ44;niO zswu2VRa-nW1W11)f|;kcg!3Zg4O{nWwczy4GWe1{?18cD=$FnL78Y^84*0I~hq6y{ zp6E5j|g=HC-TA<^1~tf@4zd6@bW)jSqGkelzQ$Crro<8?>!= zx3H>6!g%${Kv>8pXf8;X387Wwxk2X(Gx2f|-fZ>;W^50<+eu=kp7p4cm9H?BP;0Jl z#226hI0VAnHij~5SIaj_TOp3c$;McfULe|cRBF`xmTWY$*LPfX^l$(|)RonhLviRKH31JFs1U+^5)U}sNAU3hethfs`fv&Um8`%)vd@EfILBKy zu&M>o^_$bR1Mtp@@~_o#WeJ-S&_4S^csu*J7eZd^@h2ec_2mwen5hKe%`~Xj8IfT8 zV6?ES`NS`U&OYf{OtKr121V?yZs;q^Va(lEhUM zq*W;q$bsYmIAVwuULnl{-cAK=D1z@OJD&GkoqnQ{A}UaBUf@Vk6)$gh|0J@Z!twKg z_fPV3k~amgD|r!5p(?U)asXzE$>%x?%K2+&76XS*OJ3hgRL)m?{(P+YYMAUV2Ob|Z z+5yli%yU67`y$UTSatO**hZSd>LH!6M&KSCo~wK%Hwub-#?JWs!SvICukUsL4^g$j zz^9ASlO|V+_NE@;LhjceV}(NrCDvvacFR7=XS4kigx2Mk-V?%1!ndcU)8VG@V=;479#XZ3B!A-tBAoA1Y zAE@EkH+%TqmGo14;GR|F-sj(k+}lI+V(em)N&jB^fB4Mjnz^p;avtY#9_R55)zVO*yKwEosZ*!u9zRli zcIp%*^wcQ|6xDg)6F#M{bEi(dIrUgk{<){g>I`M##df&(h7TMMb47SWb~wpkS}Uks zA8PgL_j0{bY=8gZ3>TN=W39kjOsAQ-F7RYrzn*mN)?@L{XP$gYeX1oi`T*}= zkZYGXX=dex^YUXrj*6xE=pM`z9Myi%>DN*G*A)vo+DbTPJhtk;&h_i{x%Wp)!uww@ z9#!Fu+rQqz)%}bX8oK;ZI(JJ4yD56DFlC^zLp18w?*wM^ZTNnDMp4Z}6m_;-y7p`w zzc_B`{j7Zjw)=cvAP-uc9Mi1T|BbRkbECJ`8J9%NXW|ZzUh3sZ%ngP~JTo>W(Q8V7 zws4awZZOInNE>St73`Jfj&yzQB28)DFX2GyUt;}#g*&|j1#P-tY_*JpYQ;g~Mbihw;aP1iPwUx*?I zW&czgI26j&t5dvmsXyLQO)z!gFx>y1UfnbTw*0cm|1iV_JfoLiVr_EeYJiy;euJY7pwy5t~0n6a- z{4$?XBhN1H(M#DTZl6+mG&~o6r{K7YBAc82ObkYIZK28zs(f!}-dTI3@vt5Hc7eM9 zp4-R;Hb|d+iY7{Br>n_9lvQ_r-W4p&f5-WFV&bSuKp013NPj&eNy|AIfqtib-g9b# z4^zyHiX+z-xTx#@C^|ehOtTRxntW&EPFjBQ{r7KhH-qd^Nfj~$02b3uZ~aQ{OIYISVd(2J@2a!qLlABmx7!Bi!T zy~|FR8c}?LxPk`TT?AEdS@=qOrm%M$7Bb0~JG#PdAsY^Is5t9w#hS8oYL{ls%`^Bq zYmaM)p*VQ>G0{k|RC!g-PoqlDp}PBlbQ7EKVu>Uti z>?(9}U2nq0ewRj}x!G%2DA!t`|8&m$rG@?S0CI;*nT#t(My>$5R;H;*JB0BXWWvz{ zX%)Q?;eGfms4i8A@Bg^+1v;Igmy+wwN(4C0oTnQ-A|Lb(c+1YI5HGMfw^IH#b>049 z=Udw#&1JCnlengszVe|-V2u^ zA8BkutBfOkP-UEZe6yr{x%5Y@tXD2KAoB%1zf$nBN;oV#hnfd}WN90E)jUWsY|nZg zWVWf_qV;v%Bw?aC=^Vkrgn&qomYNa$?R2JtwJTJJPf&Ke=4Xqc6xjL*L1%RFFZBk7ddCx0P*;4dgo5mqvVhnbnHAyu#+Adw#?|}%V^3m#72%y(z@`_iX1ErI zoca3&Gj2GyTvkM%crGEiR?<}bz_OApg*+U5nRs^0Z1>yn%?pWbq7)@JdtF2NqI6#0 zg6!{oHKW2Ui$ivLZ~lTwPgpE!IvpVLId(P9XG}q{qDRGk$TV2+=F5qf)COF~kuqjQ z+xLqg=(o=v90m;yD~G7GX+TjVBcirxtQ771hZO*2Zz8Vj>(maVw;d>g6W)G2kMIo1 zchKIFov;A{>&^P+`x}Ljw5}ZW`c$2z4~g@BGgGOvU5ME+avX>28CxRbOrkQ?^v~^G z;qAfRm#$E^EAPz#1o??M&CTG3ww@xj3jKI-KC)*nW;Po3d09QeJixoEd=l+uLJ-IF zST)(;Vn)|AY&kQv zQwa6TlqhDOyILZEmeHI`>D20<0y4K2Cx+iSc>f%G0m#?eF6l>eqH^N>{k_d$noBo+ z!wOaLPGzWY=gfelnWQr5PsgvhFiPBs2Vf!c4eA#kj!St**)w@lN=N>lJKF~7yMxQHS;qWRS zlbkd``;L)LIG54)>f?3ltkld%;VPm3_t*t#KyI7HflEe)q?$x=Gnh=)JHDHF317%C6W)V^wUv6L{VuB0K zTi=hXq3Rl}6pUVa$uV_(+XbJVqMqUw8tjBH$C}72S>|^%JnP%tyf2#-ch-gqKInR} z0|R>!^}VWYt@j{}qt52-Zki_UE=Uo=id=Xr#JpCPO2cc4OGgXI|FXr2B-ud64Y3` zGXI3`mfH_+#(lWoFj9Ct>8M|1h4_*ApP67{epRA(u&0rv9t5nKP-B{IVIP>A*ca&cQ6=B{XYuJqU6 z{#?P&Gr%7c@axJkF+J7)EfyoPp?-ry;n(-z;-!<Fq14wP z4i86Zr#pn24Ak|>K9yvDkpIEjcrGb-N$wEVz0#-}hu;kAUMZ8mG(9A3$sX3X_edd}ep)n_1)hto=PlW$yl>;* zSQ}frsB?emO7+HqT;Ym#lrDv-oQAQ}k|Z#Ze_yd2k^^A9bG2?eBS>D{ji(hIA*&Wo zcw&}!Qi+RvI+;5cG@Ep_Dl2)%aZxs%KI@|e8~awUxm!BpFDiN-yPrLTIZas^g&#!|v@ zt4!51<>X0E(YM%M6WiiZxi7uAYEw&ZTjt)cLWN${Wb1=x03 z?)*xDyDRfPt&^An4v>Z}BA31Rvfm+#nAcA^9Rxb=Rs7riNzo{+opcH92u@=2>u-(6 zTFf2Csx$86`Wh*e93`KF;l_-{{fBaeQ8}FX`-)>rCK@U3xjnm$GIM8lI?dxomx=c} zD+UP?(c*uvreac2nYUpa!Zxg65Q|HMy<2KHF6!Pn)QQ6tf1!L;XVM&YVNEvydK8$V zW75sJIS9^#e? zHYzA(kTx{9){U(ae|biskFKov8oK*8E;vrrMyGqpN@uXbB`~*qW4KM>}2) z=jh%!Qdp9~_@ZINJw;NHk+ch)S84ilV{_e}atZtjJb}Q$TI#~;&!(@rSr1Eu4T_bv zH+b0NX;NG-P^}7Sj*Xf%@qVrL={?*sCgvR;pd#~oR;u)u=V0OQ=&LE@ubH?_0pkp#|s^JkAuz;$v67ICeyx@&5k^IaYmZ4Mj!<@h)%BE zcX(W$!B;VI1|{oz(6tTFah+@$!MJ**u!KkKfzxZ!?#ns_wIQ^S_)3eFXZZ(fwWwR3 zqwVZ_Q+z5Rb<1Yq;NMA038o{-)ppFUW2lUKy$z`xnP8XILCT<+`{Lh6&(liCPw^!m zIsJ9X11xSrOWviyJbzeIILSzBr(_Z;pn4&Po+egMs>Nv)ZQ1 zIb-z*7;^4mxaCbnjW2Zn2}Nb8gJ)iQk#l#Bk7^qUVe|7~tpWD4=j!+qlu#&-`Qi#T znLYlm`AvxP-_+}1%}Qfpk1v@#RgrxgLZ<4KL2COK1WA*K^jk$e=f~bLp2oBCHDML)R{)#^5cWGJi zElzK}IR7JV9Zwx!$@A|bk>EJ(JB}h&`;2B~()U~Pop^cAU z6|(~9GXb{lGwE+!e%fq8%*qu%G0oy2kGX#wkm#G+@nGaMtP<2gy@JsuJs!XYoUO7%S6nl0B)=!0CT z?0sWr5=NZ)^!iV9f}s2b6{K)0047O+Is6*o*xAYiQIN-M$FNoVhh=}BX}^IcY?R#5 z$NGeQhL836hz}N1?0dodlx~S(6~YxD2uC6J&iDvVT{L*=) z>m&yp;uuvmRZCm$guR-2^kzTYzayrV71{aLdPOYXXTqS>x=d3T(kH7^r>+V^pxvhP zwy=?5wlT*ojB$vggG8xD?MtV%zoq%!{(iS;_p7z<$K_4oOqQG8qdJX?4g)fM?kP-x z*YlJUUkND4x}s8sAS?9*$4|a|jp)ak$i$^eJ3zsUm{#vmT^n_ zjHw*jOOx1SUs+OS*NdKgybY%U2%HMnk9tP$Kv)g+lzoW;E5My4Wrqb8CAht5)0Q~y zp!k;>RyWtJ4|Zl-^aMpieLAd+#m6%Z0ra@NKl3R(gHL2BptJ$Hvoo%NH_3C^TzcJE z=5Z@=rrb4m`=`>70*?o7Rv|}Nt@5aMS$NjtuSN5Mu-@OcF9oM4F25S%{;a!JIdXts zk^*_mSO4_b6ZhOwOERh0IL>nw3_D+-`4zi8I3TrAY*<>M_Q2uU;~Y5{0oCuVA$M%F zGwquDq(!=N`BAm!w`P^M3;DOdmD9U2i8O?~b+5El_&Ybm+72RU7{1OZT%@G-`Dpl} z7`FYWl`+zjDvAS4Wwi~Zb_gf>JsGGh@sa8IZFznq<5BQu#VFB%Ly5z&xN#c|H_yJG zw)UnZj^@rIX`va*S`ROExoGl$|2D;djJ4N|4(^e6waSq*yjMS6Y-U*-G&UPg9Bnf9GPQwYwgXeLnsn^k!odffJS)P5 zxy6CvVYtB$?Jh-;nsFNo_CB<2CBlUe{hD$eEQ+6HY`aOkmr?x3w^`|UTtz*-F|^bx zp$s4Y{6aUEHpkwpN0`u%M8=^7h;6@2}yNOYcmaEomhY>06eHg3HN`d zBe=r=WflKW<4g&^sj~Q(eU^2)79Wa@cn}$5ON%c;E(?v%h)!4_g8!CPd6>--pUTV? z54t4rHg1~?K>7Et*QVrhTyVf&qDz1!3V=Rsyu8*!Ll??%b?so|>&8BMc!734>8jeq zh9S#rEtY;^L$)Vys?#*`bvNBenlzPgk6qiSpXX?UZKAw%%kHlpxsG z>OW*$xB-7Wi+KSJXT1E=w_}|oju~+`76%rHwYyV!Dh3&N4dtw>{nFH4SR;)ARJ++GtZ9 zvX%8$G|nN?U6@nUJyi{tu{*R+_nDRHF1xfxbp50&y1R-V3XZ1`H!D&9Bf^l_!Oi<( zPIJy!ELO*|enxai2r_>tr#gq@7_L()T>jWE*v#geTiRgl@M6yATO39L^uAneWM|!1 zgu?XiYC-Dod6Udk7*D@~|F#hC*8@vUP^S+oPBWs|j-+Gr=vG^5jgjs%k6k5^Lz``K zNI63$^L(v{MPe#p3e z*y^k-bp1w%@U4PA$!g;SbymZCD$AgDF}xLw2ADE_KAv)Fs5T^`-7vDxi-;t;&Gx_` zI@HZS?D1)32d(_esh{=uW)rX_J>Vm+-krBM!p)~%E}I8Ao|aFkWLuDud4YDZkJ6lQ z4lXmdagCTP>B)&Gc7xV-`oJSj83iNUDdBhPJ?zKgmLXT%`s_C0hR^tU!uaA^SP-rl zC7&6GnR<0^(yJ?IQ$LatcFb|&I({GEDI;7HO7broy$F|cBY=rdFlR;sm41+bRa3-0 z$Gigc09lY)(`&`9Azs2Ee^e&C~-K+?gUi#(?u-+~y2rF$;IOL)UDbJc5xuxqV`q%08`^9B*>`#JElUl&-RBTrj6Nefc4y*Z9&Ti;|BAWR^+v zR%xI3TQF6iYZ}s`JV*{Llj8ciK_ErE=Go=$i-ZO=xc^ae;*OwLMI6iE=@84gM+fxOEuPYGw}7d+A={VJ$Yki~4hK&McLXwsA& zA)Qx@TE2Vr!Zl0WSn=z<_t9IGK98(ZmL`Jg#Q(rLM_gpXz%x8NMi4)ulp*aqHB^8P zlxGKshx`}{EgR#-KP^>u=%<7 zx_sVT-TS-#8#2R97v6pWv@}W`uHy~AzxCRFsSqUwV!+!eo(l4kWEbzIunWqvdK4tJ zz1XS%@nUq?KFiyUr`$*y&rWfqme zg?m8lFPa?0c7y8`IdXbL>W>&feiED|?q*2Znj2Jsm#_NO=*kWJvzqMPR0}7hv1UB+ z--~J6oRH#wr$eWP8dq=kCN~XO16Ta-jX(&_N=Eb}OEx&BQ|Vcse_I2YqZim~F2VF! z(@;k~S4|{qCv%16Q~Dq_ZSIod9UW*1g~oq{;`POTU+;kJp!TMa@w3l8t_?6|LSJZhNrw{gSkL93P4toLqGNGwH`)(SJ7YvY6K(MI@ z@A&w4I(q;9Nn7Rx%jLlh+++3U*XTz*RHYOq+)4_o0C=W_YQO& z&2cr(25{ss{g|kC9-iN7{^;-UR#eQ+1S&lpKqvw2C@OZ%d8oQmcXLg){a07A4UmO^ zHsn9=1NYbt8@%YFKNBxBchAfBYA6Uc?C3QfssCrunNP4nV%t{g3m( z=i!Dm_HpMAh%$^h_2tQmaav zti_Ym&_a}Z?yc7Uqiyr&$4NRLm$(pgv{2!I^XY!ONd^rLX(X@z-NN|u)A;)=7PXyD z{Se}MDPflj{tZ*?sG7jXUKp4jXw{|Uf9iM{3B^YDMqlm3VQU;Pa4O$t;&f{u1t zy_-%%iWsq1Rk0xcKUDY6QH2h3?9p?QU{m#w!u^xtxdx0JDVlU0(HV7&v_(`|+P$lA zS*nE%aC7PO+%0VFZ=dx&Slr7z-0a}Q{-LI^@IUhyft#WSV4`~HV&jG}k{VF8>&PAL zWsNYfZRc30WrKe%cJ+%l^e{w&U1|$~rU{ma@=x$W);dNdg~%}uW*ZVYo)PEws6`^o zvytR&grT6Ve>StRZP0SyWf$`*lMD?;11{Y zov!oM`34&L7Onzra}CajJJn`w=ee(s&M*2h$QAehYM%*ea?bN6u82Ip$O%#=1C@g9#kZpVrau&OS}O=`4Q998Yocs z@zMlKb7P(Zt_~VRSOTB(RM0tik|*u2dz#1Ri`d3YqgU8j{g_;hxRq-jBcEU~EV>Q4 zIX>_~l@oQ$J0{t(?MFnvOLFz=o0(HJYslMW3PR!zho08(H5Z{pLa%-$S2~Kn=iAn0 zev=}snEm49ZpEEdqc`->1|pUp5jEyG$A)OY`#-|Q3y>9aFuiH_5LDw&mEm&TmHpRi zXLXP({9$FxkgSgI=#{>d2ZhXqCUYb;M)o}cQ+TWBz#2gi{?Q$fL}UFs!dWrBhycMe z(>u8F9!Ow@2Y$9mIG{8blBt#?)h8;*xAE>d+rQLeDVg@O@M2`Op3m-TblGDT0!7#F zf=^?M4QumFd`-4{9mmUKf{c+Ks~_E|riJKSF@3I6{05DRX#8|_k8Rl2?#E1Oe(SN- zNfFLAvcLiM-qqPFmJ=K&MbongE## zGx@eWdN}+hA)93#P>ED;Z>Wz7)3&w#@9WbedEIxt-Mwsy(qEdtOS5Rqg=N?tACb}4 zAkIh1C_nOTA;dY*Ys$v0g4~%sYGzBnL9Jc}ITKR|ezGSCCXF|@ZGP3>9TfleBf~i} z$KnHykM+9X^vx>Ocy;I6fmqjhdZ^u4y`g%V?kxnB9Mcij9mQuVu(M1TCgSy^YTSuy zFpPMBZ%!DvX`@5}N-fO>A*l@|20XuEjg%B*1zYva$LpzXkTcxOMrrqTx7mOX#N0a-sU9kdK5^IzTZENYEq}aYje4${ zB#YFBSkG4m)!>2iLHYG_zBN}kri>EkXh(}_9=Vw^cNIh3d z3=?r0Oc4PZO0@VU0hhz4chq5A<9fE|;Q3PjG!6jR>Ut>tE*$8_>RGWqVMuYEKMVKX zdwFO`u8{`OY16TWaK!Ua+0Wd#H`@`mwdR5HlBHZGg$5EEG!i8q_a#Yd=6I}5Fd+o& zCEvhWO`)W00=@plM(t7i1dSw@zfgm^k43=XR+D+(01%$6*=vE1SY2Vo@^4WeV5csz z2OqqAsF`vjZ75~3beUC=5)LGwx@$mPZe^*W4y@t9)VFA z>oHJ*D*JXdlBJy%y17T(!z z2aSQ<=EBU9XYd!5@ouijPro#lfc=plRP=MuCVmFQo+)uV5*&!V=cK7dl$oH9N<`3GtfzDbC-Eebk1ib9r5UM^moNQm|i5Q zeJpG77%dnT;lz8mkK;_>o+0I6pp-s8Z9LuN1(~zEyjofZ?)n#^ejd&U)7M;KaT6H6 zwwLqUL;Vwens9E?UEo(vmuLUVAx<)|Uuo<--16$cxxa%hCMU1X&-+Yo)+xqX_Lt#hn{>O}dtxwH_Y!!{Gl;-evbdhV zafRaZ<=sYWzjlCkkmy+R11WKy=%t$1Yf#-D|1TxE>`!HM1PgNj$Sk<_z-~P$ArB_F z?Yti05gg@&J=n$JM{vVMI;;bjU(z>^bA94Yte-rlRceD>b_-Mu!RhYw2sQ)Vu$p}R zN=#m10czD&FCKc-&f=mbOWH;x?gM?!$K*uk1)226?=oCBo8QiE2bDQog82&M_|8X& zjSL}j^0TaZpPyrpS=2WN1*C$YBX3nz>T$Ivs;t3dWc5}LrSZR5Yj&`J6)4+uu&(R^RPKuCn10KE-0|w+;ZV^etj4m-P$9U}Z+)t| zCtn)>p>Xts{_N1PKG=lbx#T8{6P6)7RYJ5g5m+8k9T4kW)9RDO4qdZuNvi<0!t-F zug6+4!ViM3GEI|Q)3er~9F4mTEP!qR9^GeXF}s+rZCU?=y?W6&86Z$&wyv*6X+{pR zR0>aelLOtrOBMh4qAIP~3A^YNQ)wAvT-E89GZf-13;AKLl_20RjMJ*a3)>7WW= zK&;ty_OUo#o0@hJGzQFfWz9#Q7C+3ULI{52f9Kem^8(OWWm3 zkis$CKJhG<_t46_nB}|LBTO`x^RjX@^(1}w^FCOo<82e@#m9Mr(gag*jBS?&QpN6x zIM!ClBbB1fbxO@{=BgL0)37ejBkPTQ#Drbwp90!%IUieXX){SV$GLHO?I*heZF4xz z5GA)BXr7T^=9&uRH#32UkhR&i%9(XAkp>FxXZaDb|3~}J*&V&WjWm>*u1x;n%noeUG<9t zro{Ml!@XrMpH3}6+0tw#uS-|^AN!H6_Lx+Kk@$lnbybSE8>n0QHjf``12R8jdr(~%lv8J`gOEnw!rEYtXcd{IZ%?Mi>_`> zdl6D0lWf*iL+PVPccFmNKGKtO|Eso)U%lJOw@q-`%KZzri_Vw^X-maElppwdy0j77 zt3nktN15&KOb$;HwY--3!67#xVDKVBN(MrV_ubpvFYp;K>EE9A;#5t!bWc|bA|<0V z`PDhAfbas8-<+e|7c@1IC<005qX3;2j%c~8MCrafl-DLeYZ_M14f8e#1U1?{7vzp! z8)`4|2xHl+C;QFebu9C~;1#J$%MBTo* zYETV2nW%!%!fg6^v*J#_tP&lhbt);pQSi5DY)fr-dxEsQSfCNooN9qvE!JILK?=!c z{Q&fR`x}rKG52&{{I9E`@Or}|w~@ZK(0{-t(=RJ`WZcxBT~zU2uu6Ml{|qd_Hi$pL z*duFXB{S-2ZU|!Z+n8t?EN+FP$rLb#N_c~)5`t-cRL-OWYPD2A63VGBk;RQ-s;Ydb zMnT1zoD1`ap5DGfzx&CqB!MI$Ab;+lBq64A7ibhUSU#LHbi$LL;VILF69_BT>5>P* zCYfd(^Do;Qbio5aZ*gz^J(F-cs>-&qQQ=B@`{DChme5B)eL&R31xf+KH3OF1*ZqK7ONSFBi1l3ngLjTFlmP$99tZ10X6zC2Ic`kTl5?6<|3diNNq zAO|TnvA6s|;ymjwaR8csaUh`kUblXK{JMYIOC*Bmi4~WUi5RCC&WWJgwwiA6DCJY7 zbiUWjPhGkWKfaN#@NJ`MIW4~qrIh^Vu(EG$_?DOU9&}8U!@L|c<_>J^=L}yE#JT7A zPZB7!`RF!Rk9a~>h|7=WQO5>^sNCowu%HA>SN1dBxv!Qj*%d$Y8SjVosxJ9Pzkf~% z=SNn>*v0~h%-)ywG=gXOs$&VIeN?3t>t2n`%$O*{&osfmReeC|X}n(OWX*zxo{XH5 zkXH8}tUee`I)QFCE#sOKpT`9#C7qvYA0P+J0R31I@>I)%FZ|y1T#9Xj)lxQI`4VsV zl4Xqn58O^`Ab-%~dc7P!J)T+W4=#CUO!pQMnwI*gVuRbqk>cLpf2L*dc$VguGyth> zSoE1DcLfMY3J&>H(0elD`e=625TDNGVHk*zW(gF&YrJmjKa)HbfpU@ozcmVZ_h3xx zl3;x=LO0=gv*;-Qs)3R*;P`5ycP9VIpgGSkXD03lzI#5y68gk)E==oMljGB|Qd4{t zwRG?lkPRO-{6WHpr#P%@0Wyb&`-&W|Wwyx>T7LypcE6AM%dh&g@;o~_Xf^w6|AN%N z_|8g3y*<$N;=HyuRs^Xfz%^oh+XF6iAD*L0b5|0z0-OzE+hYj%Nc#Hgd=n3^e?b?tnGPuTQ7%)r{xc&qj&~g;LX!ERBN0MHqId^UX{wX52XJb&M*EGY$ zdwZKtC1Lnl_vEetu-ySbpsSr0=;?q&il_T;zN38{zD2^AO5mQf`^GDNfkHi&}zVZld8b;OmMRGh}n&I%cLyWU!Z|H zb7++{NzAIsh3?J*$n#5FdCFU5hDkw3SqQf$Wg~;A5pxDH)pn=2c=NiF>(|eR2x9>qlRbl6t3=Z@eSX& z5*Yxv(C2PEYIV;_xbaY&cBK&W&lb8-s&tRqndy*&$@UVld|%2z=>lMAIDg5cvEmn?^DE$=apLmN;< ztRmQ0dMhRS9n1nw6auXi1)v8BP>~as*DsSoQ2J+{LO20W%ZHzvU2Fg?<=s$!a8~Hb z`e>1yk;QP`qilO*X&YHejfKPdm!OTU;BS6Z3v5g_j>-Qp@Lx8lXp(@Hukv|#NJE9U zKsVO!7n<;N_mv-$DbUvm&i?OEKVfNUT>xLWx@VQ~zrV;CrTJIgprsFDv4k}LQo!)| z2sgiv>fm-gn?`dUel@QM7FJPjI{6;M1@cV&hl7i7rZ4^IScm~Cos-Jsx$c0H{!FR; zch_hRnf`xfB`IpaWg*N1+DkVm=qG>xuuYd&Ka|41MnH zu&&!|mzK5ie68^pkp06!EoqE<_rvL+CuQ>vpijPj{WQ9MD>y}l&{Lr%&65Ag_5d<0 zi78+?Nd0nRE=u!nmxPfl^`!>R;g`1Aeq9I&Xgup*b7IQ?Ei}(IGKz@MFQamqE+S_L;wg#{TPrR zNb3inok@X7J6Rq25FhT^Np9ZUg*!T%MKm&m>oXWGd!OO_qFK|)AXL&y9B{AUhEy+G z(!P&AOXZQ4!G0I_YXlIm-wVo{HRXRfYQk9YYxmUjwo~}D#%fnRPIal`%*w|(Lb4ezO!&Y6gAn@ODBg6 zXMW}^i1c%`xCVqHD^)FIWvnS=Bs2H^g+QfB-#a#d=$U$R@ODy*$6hCKWUScG(tnfl zcBR$8Q$3yLNrZs5(8<}c&qDt(m2&eQXU<>UapFt6@Y-E>$(zhX=QYq|aLEL<`cY;C ztza&lUys(#=0iz)Y4$(6WKg;>Vz(m8Vup;-u-rN7zB9R>mIN3G0ZU^cZ-|`Ghr^Wd zm&pY$c3dV~Dg>Mmlw)2)M zscItCm>rw#ms>n$Q;z``C&${ALn|fV)6q)IJj-6r!x}$FfR`biDB&j=Z>ARI%Z*67 zwpTx2+Mz`+zCnK3`@;Gdu##G1Fi}>0yH$M`0iAOL+z&LHK8t+DF7e_+S18Gh)zDgB zO0s^fn+#i<)D|ZjI$v&N3m2&kY+G+Ef`5VY8s}nlGL`@<0JiC{Gv1;A2-213y7*H2 zPZ6l3bG5`z(YJ&O&W`ChS)1x)&b-#q!rjhQ&w(M@NPbb=tfg&kQWwWvbI2xR?L`BM zNtgGM@ooPqlTLDaWf}mP-DgZ?mX83bP3&hAaWpbbdg(OzQ?}~p?QtVPf^ZS;h)nr~ zvPfUto{yAX%`WYpSgAAqa~7<1-?x4wg1n&sh=dN*znl2_X6(;*aE@X}?MA%xjMl1r z!E>y`GwjG3-vf6st7!?%agV)|a=#^j;&u0kEzC=tak<7}+(bQ9M!%+y7OH*)=5A== z<0OL>w^>dq4bwOEoQc4`?^F-}_y9v%FZel$DfFjfzk}#d9E%XYR1e}zgWbQ2u2k{6 zuZksb7UM>iYVaA6FcBs8@|OWpye>wr``gi$QiOC{!SuVt?n2(<&3){|P?jZG<&wIl zr!ixdX^q?5!Bk;~589SS?kFr-8dr)XVKkqLhDAQ-1Uw94_KyMzyluOry{#{@FRk33 zE*nY`?#XwXm&8_&N9u9*?KK$OKOT6!UzIfqm@P-sh_TyeW#?C2b)q=9dt{0{w!Zrp zIDDln%p9Qn;$rtHgnd=ZhB4Q6UTEMn-d}PK4&Zq7Wcu- zb-SAhPhZp=Sw7fXyCLFF-ro^UZ-d6xuuJ_IL7q_BObXLOXQ~?Njtd7h54J!!dMHz# zK;*RJga?~K>Rac9jt)MWHR9^zP1MZZO=c*{ms3CI=*T(h;Tu9&{U-CB?kZ`ETK$B0 z`?&_d{XshXTh90rLU=b?5@E}-3v#UNGaC=yT|}slR+Pe@$sKMeSRDCPyU$4NJVbpu z9s(LOCAXUwP7eD`7XRa4w$qFnZhalqqwsyp*#G%b`tf7$Y5+H;lT6_U0|iC{raE=A zPrP%x8QqyYomNdWG!l#pHiF{|=3wI|c9AbCPaOyYAmpbHA?9YdRs}WM4ui}c7r>E8 zanka$H`dZBOs5?%f_I#bLwMzO`>eJlX1*kFr1KCK_qv>A_RO!;2pp!4Z)A0n4sT4| zahWAxRgN0A13wK}Il&3ARCW+cG|48U{sTrSJ>V8^976=TM8_yfLfrFXxY#l4SMMDPls|-t41M zvSp&-sIgR< z`1mXfd6~So_VYABp1T@hCkuPrpGb<9kycCrIyaq_>csc&#K9l+<|kh_&L=YM^_!Oc ze$tm9?=3`CGy<+wD512hg=Ju99B>XH-`X=wrl4GYvU7@R{ckLKLScHb?o_R#2gmL} z+5=5AE>pIaCFFN1;EY&$JsqDx01x$I zX5;z|ajVs@iR3o-l|bL$^ImaK7&^jtYWTSMHk8W3MP6@H!n^=sI(@uIsmdDP!3l6> zlUtAz8FPD&zj5mt^}FL5o9OL*e!inmo&I}Ill-4M3(EigGF+;P+z~`wp+^15ba|m_ zC;%__BQl~l>UqKG z&26aYhA(+%;fr6tioXs%qE|;rJ2HAjz*qw^MSI05aD*T5a~IfPg)21F9ymG&BhH(N zVlNB6uNgXBDZQ~awRSO=4vPU`DQSJ?U@@qMo2e_6rWt>>x#hvCNw9vUwJPZ7C58O8 zV7$^d!5h7``n=eG#X&5kqmebnzaHS}kcL%jEHG6g|+zGB8N5-U15!w!KK^|NE3 ztMJ5_tgP8}ztxWDjs(hL04PwSc4M8JKDI4A75)<@9&=#};T57Tt2@pQ|CGm${C>Dh zH(8#jXGxB%ds9WKs^S|njHD>qi>U)9zK6#lvuK}LtCY49x)#!42h%w9kvZ+bGMs*< znUWkS3h;Bw{7S(%%IKvAa^4o+zelurV|Xgs%V#Ome7;&UzqMyQ;bRB+lx|EJmqsZa z^#|DDBaTNmMtA^QId2{*HP?8eO_)^|*9sp`=Vicc;n^4UgNw@`^EcQy5-(4aKNNUs zhyjZ|&qLt!LsjyzAup_rd1AHeN)AJ2<3eKI>5V3sYrSYn>-P1|Ebuf_&sE>Pf#!%q zZpSQ3IjjjVOffx6LR#Fs%@DC*ROs3Hp@kNdBBW>wc;CBZmrw`6XDNgk+_l|iZ_J9N zAGc^pT1jwwevL2>$@skSV@!iJRl(lwTi-yz2Mp?>-6s$Wqq~1QpXf^;``vrduCKGI@PcKeS*Q4z#gmXv!CeKr z7z$`_!N+MFw~^+EAN)K;K%dt7GxHsd zPu2|+0&Xvd21dd0r-ugC^vEWDN)_cmz4#>en%nAqCy6W$n!C7O@^8&ganhT2$+3<|Y^I?B5 z?b1-WmLd+`wO8P>KZCmG`1LWFtuCO<@kEHTmIXK(Mlq72btG#B6g|*9K@BBvhVJHp zcHc5s)IY=sh#137k8Lz(^i0G2(5r;BHxFth!tZ{?pQd(*N)|@w*U&>xgWl7uH>zT3 zN_khzSn355GRPH|w&_tcC44JQg%l`_0oWbH@=BO(P#${g6v$B+MGKWN-1<|uIO{aNu#`;@L1ixN3)v~qpql<{S=IOEXz1e$O89bnA%)2d zy2NMT(?F(5+1lB zb(z;am1q8tWKUN(D4rqq=>f^7Ui@r&}|d<4q*%DYnt@B3$b;r0}! zDG=MP^KdHMj(gS|L+Cc=_M0_gW1l$Eh}ByHoJvWaiQs5ne|j4*{GP;idh0!G=Ub(f z6(^DcjPwS^j_}%hQ~7Qf1{FGd|PfO&MhA zk5d9aj-1_(C={M+9Na|Z>Mz;AzD0#*4Qc= zCtpnwH)!39ZJxa*q6kw+0WYv~J}Ct{br`wh z+tmU16aCp%hH6}il4=f!t4*3vpv}D5<(&C>&I}~r6gy=8L!xQx@WMkXR}jOfuaUs3 zvnuFDMZmo(MfJc@`2hd~Gxr_{Z4?vUkLY-oRH_{lnur$riJ{EqXXXQa@E)?}`^cZ| z{S_xpl*V%&K5@GAX`g*+gKV`e*3I8 zqovkv+(xTNJpN$(`Xu`OA_0a`x$X1P+yRx2dK8H zUnkh7Q}>k5z!Y!03QVJ@zp4<|k(lidv=?d)*DXCgzEfH2^Xz5OG&WQNVt+?f7_n)n z4oMJUAlA_T6iN*)K`5NUX)(+@4Kg+WO}1$9eU|Hf@}I9ix{sXtix|^7H#y8mk-UZ% z?^b}gy)NHFhf zGX`8UiJlsq7dgWArRKh4KT%G)WtVTiq4ZkLx9&$U%D$RVshrhpq%3G!0_Y8J6i+ea)waH4;ujzuj0!avLvPUEyafeL6iC+kf$|kU zVc5@Q)rL3R`6G;h%cX|(EDf=aUpeYPe#QV1vrlobqm8l9B?CbEM zCcoq+sO+z~$!xQU>1NVF&nx4aaaXfy@8J}N)6)HdvIz&zyB_H>y3d0B7^YH*ieHve zu{F^?5~bhnzk>yT%^8v&w7>aDGFPW{KnXZ%e0H6v)ku$UVK-n`2y-=ag}%Dw4C1aR z$v|Bi8!u>w8?v{(0%VUX4p-ZuOPt=M&oLih%J86mE>Gq6*3m$pws<{{y#XW+o(^m^ zX!h9qOcXM~S=Z1RoXcskP06-6HKP>Fw^BaFDl@7^a)~jq;9U9bvqU==0Rh zQOUAx`Wj|^$kc&YAJV0jIGksBDMEu1uF_9?6i9EFU^wVE`NZOpHT$0I-lMpm8{3)% zTS~XTm0iBL_b}^6+{pWRFAdf!;#I$+SDaXyppZFaMkYP<_{+xn><@L`uI<21@fFMD z0Q}B_^ff+K1;ys=;m}A~UyX{{KO*Pwi0ZrJBkKpvKKcrH)x+Dd4sR>mR=9=1KYbm) zwiyFY9j{IqnLkizRxt|kmPh8z8_)4v(V#e;abWx{^e#!_eK`EgduD41Lz1d%t_X|Mz#!@dNl_ zm^EuXcV5?Z-y_rP$hDxikL$)ggVeL$pYR?X?~;-9iz}Jg(FOSiQadNKU8q}igA`F2 z0zIDxY3vc9AO0tRUn&*;LF8Lqxp>4sYhh=(G>*iw?|2&jCvPxgQwE^r66qiVLziFOwU}gtFzi*U()h>Y4O#dVU_tcQeqPmP zFNA9@C8RklySpHUUM_ZrTJx5|NPjqk@j{b-xOdS|^gdb9&C?Rk7#5vA!u6WG^s72A zByD9|xBql?BR$@`)?66)Xcj2gR&_2fVe$d!Dlz^9tdpaoaA=nP# z6FP~odl^SR1UYI&X21ooX^qm2v2|T3f~M7FSxCf?AUqM|i< z1{7>T`D~kx(rJ@s_3kRa$hO~3Jj{?bo#*)EI#e?VsbNV9NLwAqQcCz0_dw8(N|6#B z)BFlxaXURMeWH<^U=X$Jpi8-7 z&&edC1}CT(!wt4S&jtt$Ai${m7Vtq|jzdNYhWO4EE5Ds3F8~OW@687`-}AWEti85_VYb zmjVo_I4(LyUxpaQ5HzLD=f}N}O>~vrASoCC#wI~wxRW7{CvVRybr=n;8kp01X`gbP zbLhi_+z#~h$CmZLwfni0c^&u00PykWy8&rdWRPOIHoHd$euE3T?h)r#z$2DO3vo#g zlHQDMK`rDEG9c&_KbA}xUZ9<_5^ZU%nQ zpqeZmNNNdswN`<*Y`N01bAZ#Nu**ajeomoaJ!H`R$8Z8$)fVBP$vn`sSOOUV&DFAc zn#Lpn;R0B0AM_tj+aUq?#G4@ipwND>F(zZW2ompugla5;tk!ES3O6tDqy5+ptvH zBf$89q3%Jg9aa(u?66rsZyn5Ri|xR@gOvEu{xl;s9amrTL#2b{i|ltZ_xvh6yf~wM z1~`r12)_|Xt}+KmD4OZ;U*X~8#s8vI{-|=$T_pjP>6Z}{yjceunEeP|Ew|^wM8Zf< z@DA#|x}R^jAYvJzxS!{}tS@l0pPeAq6m*0PeAREBuX|B7{mGT^YhR75CeVVJKxV5{ zz-)+#FBq5qob=h{5$9V`+S3MAUL*g$V2>M%Phd~`JJ|fGG)qEvX8pX^QkZk5JE9RD z;kdL@=!gbJ7d>(045JSV<_Nk6E*3cy3G#+gwUX|v0=n2)2h(#QauHh_dzFg;gwONjJkyJHroM4`Aw81ezOLAH5Sag}xT7J<$#@`tS5J9DH#6xje zH>NFfQ;bcr3!y!=hsB&#-ETEt?b$d7yt;0MbeEqw3x8~h=yj@MmPqlA*L%AjAkt?) z;XKMS3`NI7dANf`rT-)h=rzR7y)5$TJ;zKu9srKAz`o{koprda^o>TtFJ>b8gmmup zle-f3P|I2YafYuRp@Po<|-Wov81`9&Ypm;7R=I@Y;h2K8%>Frv+PK^Lg6hvInr z)v^aM*HyRakz9;DKV=%sTAvFxnGPx+UYQr-pOhinS+yuZ)K=n1dC|KypNlVg*+2Zd z?f;GQ0ZqU78@~xQjdr`DDCkS?BpNhA+!>2Mh1Kd0fYD@baK_r-=4=f%n$mO5{>k=3 zar{}uGg4sRNYEVVo6IVq$|{LjO+x&0K!9Am*a{U?9CY|#hg~q>*M1;3eqvF^k!|^7 zv6p``cO3kNN)lnI5z@=#ph41eS>{MUTNtsCT`&`Ef!CsC?<>XpRei8dHVLvq{_B|h zw*S|)ez_Dp;J3-(vvKkvV58WB8mA_Ga>G@cCBEKNi9T;Vd7rMB~0XBI+u|s#)N#S?>RXlDQbWesu z8uO%&ZuI)UTwZigcIE`pj z#NVZPac<*RuTFUoKVv@E+uNs4w4BHsuZ19;VR4tw|DoxV_z#xET%eQhr&-|a3tL3r1woV+Ie3{7s=JNo%Gb5t98J$qRw1? zXfb{QPB&r&&~4O14?*EBsUnnPwU2{%uCZJGsfSQeapB$ei;ccB!%Q0zgIN&r#xD|R3TM);07q+T&@u-b;KHS-L(+axG@et4`qnQMt3 z*K>qxIO)cFe~tq(B59rCJ*#kyuBU5XM@4G!(4Pfm&ZI^uCc0jIC@WN<-j#Xxw$G=x zSR`ub@V=|XzGbJ{zJL8iFUb*6$2HqgWc|k6{($UtNmyp0q&bUhy8=~GB9u<;`3E9HMH(15~GG9$>gnjnQYoGQBeJ`xZ z$<5w>Dc@PsWt^u~6us)RoY?OiBs*y%^E|BaG0%>Iq~K%}>vVNLLbhP<^e8HzJ*0=3UR{OuU$CQ9w@*X6!ilF zYV1XN`?U_j!k_t;{;ME0J$w`M+C(`972tS*)k3u=m4~Sn!|=ZTf_8_5LyGoV)o@H zxszvWSDL5LA8JfX4zEt3udUpG4?%AGulgW{snH=0P=c8P*Z@Ka4nVVUtkf6nO_R!q zE4K{Qk;x;s-ko&LPcrdH`=j;fLZzj+XvObx3NSt^bba?qtv&|GN`&NvykGgh>#4ab zJ9|cn2xouCaH5VD`HzYM*y{Aip=Y~>CGR3C)QRH~AvQZ4ur0ebICy$H(fKvBg^fn5 zh8=u7Mx=V^^grJOkhTB+m8bkia3b}+Ar{m|3W3K$3jStf1n2us5^iO1f8G4=&-M@b zj?{GUne=}>wM47+^IOGOEiZt+@&Ka!_b0mF$FKe);dzj@+kW{#lqR{4z@9^m0Zut5?z~x_6K| z0(A2E$cxJl7uDZXS!p(29tO`;S|-HvxrWu|p7(QG##dUf_r9t*d^5Pwtv^-s^uF;x zd}cH!XgNnkifR9!GGgili=$wTcZ}0rr|_3sfD#fAYoi%;2x7s{Hj)#Q#p3ydvgQ@8 z&}2n`O$flvnbZLypZ|V}F-n~q1`FvsF!Hj2TYZwx_BTK+XHAR{l}WEFB?)R~na`;z zxIgvqt}ULacrt(FCu(+cjf~jy@=+_J*FTgpaV;hI;x93{iecZ*Xcefh7QwB%S?LVh zk7v1~ohe{Erfm4)kN|AB`UymZE?WdY3`iRO?6lH#ZdEdCZt*h%M&3F|9`ON#NXm7S zs9^eeNQubo!(K`rs~^_dm)79dg$P-BmSW<1T#OlzH9qT)6EQ1kQSw_v>`jwT!+^9O zXzH_<%zgCmq!mw0_LlLhv4eG0n)MvWE*h;!}OJCQoMw;R>8Jt{%01p0J*3|u5X-UA5W&!Y2L@VNTVTl>q zkW*@*jCrv8#XE}ifrO3n1O%1k2@E~Hj>Zi{3_2hvI6!@oeF|CR6nhw=SN5r3)OdJ> zQ7yb!y@+THC?gX2(S20`TgnGU8r09ociK&6KW(yD`iGNkjTPxt4Qw?C8}B|Hyx#J* zY_9v#ZxkU9JO${A!qr$zZ*&eId{v*Ecgv&Tg|nL?6zkdj#g2=bVpqqJE(o>rh+j-@ zwB=0B85T*dx!IWF&(PUo$AAlupkU(>nSpWNIgSDBnN0$tfeTt^$^G{j_2 zTnFgQ=4Vn#)%06F1>a?3Ad!34qm{i%=py{iv`7)cd(oN?D8+2jctRHjrUK$C_D{=| zGD6=B#gIu{YUXJPjiPrBk4{>kiC?t;?|H+PZv!ha>ak~jk675`c9>)^LzPr z>(R#<9sFrA?5E_Nn+>Yz5eS6SyznK8EtB0_%eTmd=Gq$xrIkA&*)E_?5ZYec#)AI# zb^#r5Ox}uiVpHS z^QS-g@sAn(kHTpdKim0pmb>6kI?ssL|1%Un5WEk>yp(*Yvfno&SEbQFJ8AvQ_^+WA zMqrj@ffd^B`qpGHE)Oxw6X$_Y^pJ=s%zJUVG^On>G%#n;Kqqm~^;GpKprvEPO356f z`w0&pL))Gwd#(M)5{ofzJ~Ol)m#U%xblQ{iF4~q33&h%; z<*gq_Tx3n^2d8wEa{;Uf*+nJYv8c{!S{rnR3bsNFHkR}3SkB&a4_cmDcJDTBNOrpT zXEw5DnOY|me;t(U6sJ&GA|kpKoy#@;U-u%)3vG8=J=d#Zxj5ak^XS0Jp0^~(eeh0N zoKpjPug-W+$HZeeg!~Df<&cmzT9x1(e*g~?FU18RqzgMkz-~5=MvsQEW#f1u>m+Qx zkfb`Yh1XT&fROp<_3=x+v%RWAQqqTuw8UitCW2RA2_r*SA(~PB4jUwyW1_Tq)?os`AOg3i^ zeDNjF>Hq$*g{ylK%u>Kmu&!oV9gX+;T53L6s+n{N$^CH9Gy2Mw-bsQgk@x)Mwc}&z{eXxz1p$$IEorRU&5TEfr(Z^-om|deq1&vrRaBNdK9lBe3o$zXcXFg z%bg2HHd~L;Os=nW<bxfq8yg(Q4aiM#YGq z@)iNfH)?=XMR6q9_0G#M1sYf3W4E@o;D6mNeTLA-an7{zZ{_g{70WuJ@6L!EIDgz* ze0-OumkVNXj+`&wfpxN5HW+nDrwBe8sQdOJuxH)6RBEK*IZDZn{)O*loliC}!=e=X zwB%O@=5E=5h>9;r{i#Y1>3<$7;(uLV{LiI@E3`zj;@*#19`Vfci;YsRO%fj)o@*B@ zQKA{EEj9J1?-GS*7Ke*mR|iJWdceO1HX^RYH;a>07eoXMVV!=YNPXB-1aV{zFNs8S{CC3_4mJ-CxPV~pj!2d8c;GNVO{(c`6 zltpZ{JXo66k@UY_6S3_FJU5#)qkUELT1$^CS7{^tMf=%H#j5^6#4kr3;0dZj`{^oN zI7=gn9AqwEiac@mpRt&|QD;tcsf~ZOGyIb5pC(T4drVcD0(uu$eaMZXczYIX6i4S- zv+E{^3m|@trs0hydt0OCjJtmIa|@k%YksnD0m)+w7b)z}b*OOJJDHR;M9;aub7i1@ zvXs$nHgmjXmPhSDD5&!L!*9_{X)*h12<3OK7@m#M)Mo<^qE?yYIQVhvVK=TS>}?G? zI9%+&*X5{R;5}caGe?kIWcJU-n^ju+n#%~4>!kkvkGeIFB(bRjnXOw^u+2!G(&&0Pn;+5HDX!H2mc)+aVCkFxz* z2!7xFNaUE-Acc)PnuP3N#>{pJv66Q0#;S_w*F}Ao_RXPZYUCqf?u_%JI@em)2ECSJ zz}ki}U^&K5@Hw#3z^8FJE1|bVDoSO>cE1~NMrP5+$-DTs&JqLikRAZ!O_SR`c%m;o zJ+>cJOkSh;x0nJ$@dZh)`)3i2SW)-gAoV*|Cu8a=d7f80pj6D_kvaQb_U(Dtf;uo& zUE{Km=s3Wl&$p*JiVA zzc~X&y`n~fO%7JW#k7J+IJN3_OB`7&GGn>zqIDsQz=qDba9G2W&k>Z^M4F+)__3_UKcK^)kPCL%s)OP3y3D(kdv)A~_44!dI|6-dL z^yxQ>v|7p54zk}-@u10t2=}<#hRS7ZIF*YHb3Ux|^E0M^)z6F58;P`-3BKlwUifPB zb7IR#&XYCtPvuzr(LKhQ&erZ;!4Gn zp6%6gKm*PY9r{955+;T0hoR-0EglnjY6JhREdxFw+%l_skj5L2E$7KjlynY`#7;9$ z#XHSHMl?l@2Qn<+j1ID-*Ri(EBCV|c7(~o6@j#$L0pQbfCu3rXha&eqY?LM>Xqq8-5{DiEK@C3ER zh>vBPyUnL|>u^{*+Ry6-k%G9ZuvD>EL&zZGMz)33V|g5F$fJ$L8t~-+TDke)a-hGG zqPA^Q&E(2j!b-(K+uj20gH2d0EAEo8HVZO_9XKvNguzNWX-NgzlzgYJ1TM}{cBqT< zk;FpOSJA%|$SqdW*%*Q3U}I}h_Sfc z{QNfe{z<{g!J>*c%2?o^45~G8hRxKg`{kc}_sIm5+q$ptL^W{8Yib0~%6Z0GPNmIe z6FsyriSD}*pyqKJT9GD%B^=azV}NgWAQf6B*%>Ayil}U|(>aC@u{xZ}cbA ze&HtXux553*L2{mmUTrs&mrz~!&*N|+&|n>{Vo)BTR2 z7vq9$WG{%VMZaz2JVrU1rNY_nV{g99_Q6W4h)~+ahb<=gx)&sI(7vow#1s`y(#CCk zIoWtsQ0_vwmeqO>1%&?P+b2kZ0S}|D!lt{y!Bwjxu}86P4&LK58)COeu#Nai2l(55 zPHz>V7XL}^N6RQ8t>+(~bMY0C2Rwp5u(Eu8Z*>D|XR1(LFEoS{fk!R2U{Zz?p&=^1 z(o>`6Ai?*Kz`I$#lqaOZf50gZZ^fpWis%HVtUptF33XrTW`4hB@sZ?>i7cU;s;T?o zR`&La0w-3Yopf-aLJasZ&lp`7>fov*vN$}~yeRT`snvUD=CnSvmK5L3CgqZ=6%S2| z*{D&ID*MNE-H>&yEvIhFy`b7?CRWgVJ3%K^w!YJ_)yk?D8twOPRQ$SryNZGL>789E z=oEER0;pxwCEqC5yZVbo;+{4O1X?N1Q0P>46o>xoAY)?O?1U*B{833bDg8;sYIJIg}YO; zV#yWO0F!&YJflZ0R7g_38PKjUgSV*R{`{RIv726_4V~F;FT-}y-8U58eSnO7K*fCu zBpzO3O*KjrM}tZE;w7}b`k6;KGQ``X^Vp-Wi@^R-`AhtTKBp1DLq8Gs z*`9ai74_tr?WBE%t7S`{%TMKN2@XHS(p; z7zDfvA2&OcBMRrRVz@Xa5uFZ18Q?-wtiTQyU9>4kKCfR*Va-%d+OqqQN5@k8--w-W zKIC?`0^w=Q!Vbn%04wHnZ&#L4sUj+8>I_?T2y9 zc0zEUZD>&<`2vVKEI8Nk?mIx)a)IJIM&guhxX1M&iDW&X|05l6Qhl*oSh8HH*au8b zp+6rgHP|fpkw$r>cLokNe$ePv;anN^hCl|+8mj4ktUN#Of4cfHN83j#YOwlXRiUvJ ziJR(zIbC_e5O51^Ei=203fvA)8z3FLVH6WN4MASpWrf#ztUe!`ijf-=L8;`u*SwOv zbYiGa^#VHoQ5*}r&VA5~Yd0=ui81N;qO2Ze@qZi)t9kwxzE*FBWk`m)3aR1!)1~1h z^noym_TAaz8(FRX6}OK!z5~VeC?H(HM6FG8@_}bJ&1EG5VURkaIrsQC0X)5dk_%PmFa>I<^YFPj;wvM~?^c^cb zd^^rJ_LV^rRvH9jp*=wGFyJCS*rGLjah&){p`%1VE!*i5?BKCIb1sa1p;#73Jh-0qD=joJwm zkE()2C?F~_yms!>Qz40iF^X5JVaT(@W?~@;+sJHbKHfzOgHueEe;vb|Na%%@8JBT5 z@*wU)-jaTG;fH4J1nrLK(BdbA*pXw=AKe{!qe$HPZ3{ILU+&$u0Eo6s?zSyMbWK9R z>@=gdP1!$tGe;e$`^mWW__t0PJUj|W$}||)!O*J^QY3mJuOS=_;ZvJKyr%?SfXsJC*@yhVbD{c z&GK~g>0mPUnAOTd7Ip=CDyi_-YnY?#AG+R#8`Z;}LiOMJ)sj~-t7A07CT6W7?TRrC zcB)P7B}XPohN$u91omJ+LomFml}Us+wumGgrZ20{Z*(C>0VVgSK13G5e3T>`fe6Tq zGg3=ZFtPGeeb+s{XWl=dzY}3_k%?G9!VKHGM#B z|LOjVaU*ra9(~g_SCV%15y5VBlSnk0);==RGBq|05~8>MfWWB zZq{EQ!T#e)Cq=QpkO>4pVLBYJl`IdCu;@qaE~lfSXjJzxvC@3S`nJ7H8FxX!10?qp zHK1IaX|4BQP!#ynly6|`7NDOhWWWFq3SZ>^qMqa*thvL(28XBL^#y#dco0buX@?8n zw?tgX;?TOHRb|X4l1ZVqZ(5LUTVF#B>QF<av55~K3&aG(wnoJ;^z7B+@3_^S}GJ74$^qPz(CQu zUAA(EZNzRJG8lEVX<|z7t6~zw8JE&wi0jXMLOsPvmvP>#pW=e2L{+=cR8rjFMi2y~ zw^kC%^H)I6O5S=$MdZZ(kz(d{D55l{`kBsUoGLBK{?TrJPZ^~gS`@w;lCeZux_1XPMOUohhY4z~n4?|0!EV=T zi|Lpr%?wU7IO4oXHZ-Biiu#{T$&Pe>gHdCwi1KOA%I-mx{+jvh&6C`M{U@?J-ZbG& zUL|+SN5h4-ewA{O33sNj%^V_L>eC5NwB!=bfi!s_(U0Si=skbgHuYe^A&?_7OA&+p z<&f04QS&*oQ|w)H#%-K2$*CKXyL27ahQlBGG__;=a>|#2ALiaxBwQOID8f@}_zut) zwC)T>NmJ)a0o*8{kjT^@-X0a09Uk-_&5;NPfv+3zF?^Zjq41;3|f2c|s&I?}1_*Um(0R=hjPk5MGMc%Pb@{kHy>^lm7~U&@?2$ z#e+{oPlfhM^Wp_OJz}(?Z{rrCgOhu1u(?a3jsP1E@3INmxRqGM?P7!;jQnIjNb6b= zz@6n<`Rr_RM71@o;o~u!*cKITnl|+pmgY$EMmQ_^uW$hLE&Js_jL-g!+-WF)4Ysjd zJr$DQ0R|=uFof5@pQaKTMskLB{Tj4LjW5`zW-kkB17G!ElfnZiT?^? z_`hHKfEDCJdL#BbVgK%8fE+;VAM7FS_DXb`ep~?@V!>)?>qh*K<*_Xz4C?w zPqBu%;IHTQzr(0Xvw+Xoc0*uK%-a)F1swhA(%j?;m9;`0A^W7i-}3ef6`_dB zuk>`@=TiDBlbV#Y8~vY-(i0O*o}mSi)&Td(f2tX|R??hujO__M2b;ydEINE)bszV4 zQTX?b#dkK|Vd^41@QshutEl8RPQ{kfZMlVerCeZzQG6+JJYC{z~ZbY6Wr+eo};c{ zic3T5c__zXao^;Jdsqh2X8IeH#r{f-JdxAbA*XAUl^CJ(S2nOj(T7x`Uh7wQ-xqO@ zN6eGn&`7QI=D*XvcQSgB+rm*`dv(c8nX_Itp^XFM_iZ`Rps*gyTR)mmXMav-y(M86YTR;&r!F+MHBlyBClGMn@ro7LN*(cPsd4&{ z=j&T2S1Qh!L>nX6!x+Nm5MG!SKlpmFNXTQF)rhjWR`PuP{A_M+Cjla$$TzQp-Y!|{ zRdHS8G_Q7r%*FHoR%U6WIb~99Cc0R+QP%iI5Tf>iPm!FVu_ysnP--4@V{EDB7-t&X zx>mP(=+W&ocCGJzM#S0Y)vD3zzRYbD<0R{V@5YgSjP<8ydk_Uo_L~>wOfEV}0!Z@> zAuO1);XYtn2S?AG>bNd(oyaKQLOosze4C#=YE{u=PfDftSxU}2#4lE%e0(Ndsr~z3GoDv zObQkW1|^ZDi`%ZaNBv}skK!odEw)f9-~P9A_&x^%Kk91moiCP_Ig_%c4o^e8Bo9Kh!6QnBDd({^FAfR>N+>I z{C9b~kAz%^k4-Q9{6kZd{dM4BcVO&gaCdMhjMv4o=0;0tvWW36ithj~!ep8mMJUSE zx$lG)1PMM2snx~W+qfL2JUjE2-PA+#nXi<~ma9Hx)0P73wp1?>hAWVf=6fqVhdS&L z8Ln_wR`$?}NwuVM2Jo(N2%Lv85NT|m+2o`HuYD<2R)uZ^7daomKcr(2SeQ7tj6Ppq zD8oo459WBc`y6&4msLC=M$)XU($|?mjb89Xc|}Bpup=|+P#0nicIsp$Mw)y}`RIEejUaBp7w zz!68i>>P9vppA$c5hT*Q-J<&~IRSD%i9&CU2?v)xPRii&tlqV)0LqeWV5jgz_aka_ z^DH)8cDN{P?kLqRKI8ZvcP|}!j{&fzark@uzu$7DzE!%)kKK)gE=rjmVgyYq2&@2uHmo4MPbn$&;zZ7*G zd9+-P7d!wY9T}ulM$f62o*jnFCS);CL1Fx?Auo3KVi7R{cL;_6x(n+o6iaiulXpX^ zGOOI1lWrZTBgNzUPnxUHjnU*+RqE*B*Y&NI>5BP}y=53E#>K^Wv#`WymK}0Yzw5PT z81_8BNem{>55ijg=2)-YPwECj4Jrqj+yn9M_q)ju#28OLQT&3IVw5f

elfudcv? z{X2%q+$C{%KK}zEdx5*+eHvN0^)oCe_Rhq+9wt(uw$=L`NZH5t#oye2K>c{oUWUw} zhD0jCzuTv$m5M80VU~{ZgiY_zPtz!L)fW89;D=pzj6&>wwHB3DGb0NeHE^ewSFbMm zc+ER9SngBK*wL!SiyB{>JJEOOXcDf)e@%;I*IS3l(TJ7VT)yx>HmJuvv4ILO zP~LO+!t%Qycw`WdAYKtg)WF-|&f{CQ_YditNCKRBBB2}omz7rl$3^kP*6tvI-@O)o zb+LG(1*t!>k}Gc)tri4~ZN~S|71GFgo$P-4x;x{r{_IF{>r1ONR7qY1zYN=3_!PdH z=~VpX6|HzPY~a8#s-@{Bm=lZ-^F?FwUl|S9DneQG0JgP`Z}hACSl4K-g$Xh-!1;A~ za~ZJlVufFy=odeq!->)3*q~o|t7#3GTRW{yGY;yaTbVC8bjioIa4WwIg}^pkM43<5 zB@RA$b%2VY?bu&&T|vSQfcuAI4{S#tW&4lNMsaH7_h(zvt;EP}N^bIDzni!(I;VHv z_`t9*Sukaec0G^d+(UM3J4cEy!i5!IYpq70uiSv?!Ku%LRo;>iM>eJ_4&J4nG8 z(y?sIUb19l_1fpujkz#8Xh*vi$mk(MTGqAPbJ~voF{ex-Z%(Zb>3Bxxkrrw>8=Y;L zVYV1VEw1au?rN=9C(Cw7-rk%{>M4Wa9u7nIxVmvC&O61SLLRfVckXD6l0L{hggOqY~tz)(wP4(nQ>quM(-Tz5tM^|E_Q zu90HaXGY=I#FyFYrHfzpnSZKFZIQP9Vc%JsVYEeH);xm>*KVWm-m^W=b1Z@K5W$UntbNCqWnd3#cH6V|2J6ubG${;ltu8U5D5{IplQ68e93#O|JH-YjIyE1IUgT?&5%Im7^<8bj!k2{+ zx^?`kP@)vRKJDYl9B&XO8br{T@I=qypWha{3aacd(~Gp_nkR)3sKgJINE&=bID#iW z=mdAnK)@Jlu5cQFyj`iJ4xCi1126`5_^JL>VLaZ`E>gpy`dme5KcuFr+VWVZG~+S& zX>--`A=@u1Edp4wu=GjdGj*KW3oE|O!n!sw$K3N8TDhs7Rrnm zZC(qmQcPhO>K<_bV86*OQAMQKL&6J8M;R!J!x4C`aYui_P#zbONzMeAM<%x3%J8oS z!PNH`1QYJxF%W4?_EQ8#@N6i05Pm6fk0i2YtFH^9qE$R>|0OXhp`s?tVaU}?7Kx)| zVAH`TQd-(|)aem(F2HYCPB{5(r~kSTUG(tc%D)@ZK6dy%aS<{;QDomAgT--Xi?Qmh z0Q|M?&%mXfn)jUYm|#uG%%o&GRR@h3F8=N8lNb|~aJeor@sb-HqAEdM(GwJG=jh=yTft4Q zROd?1w3N>z+P5u6oVi8o-8HVEp_k#7q_9J2W+Izr-2!LjbeNUw6BYH~+Z4sM^8li3 zgIbF^D7zeUz?7&o5^o~5}Pi?*l zSYenCb7%<)mNT(1skC#-XDbZ*wm3hWf7ofkaARb1FG-F^k-}n;dg;&urL)5K)LLHO zF6L8U701EUJL-$y;8@y7{2ixS(W&5Egt)B!70DBn}~gs*&Bmm za-YuiXLR1vjB9&O74P!H#uUDE@+lG)ZaunCV%Bc?w662yz^<1+FOynZ_ae&Xtfj~3 zxV!zyw=nA#*It+v<8*47H>cF>+)rq%0#MGhxy)yQ;~?_+pF_|0tLs6B{YMMI z24srh;o0b#4kIGB<;f^^!mh(+1o8y%MVPU-3cK`6Rxft_|H~F-lejG>33!657o9ie zM_3mD`1}j4B4s1M98zD*khE(|av8uDcbN@8x?P`blsZD$Ja1fC^lSjmp@~mqDJMHQ z+HML)YDnw35;XD*z3wTUFYvdshhkwVORl5rRo^d51ke3QLEoW{^LEyoIzXsVYOX3? zT?d3Y(AS>iK6J&>_?+bbk>bH$r&+vji2i1sjYV*={l9Wf;scP6z%H^)1-l5fiuhc57;Rq)9 zm1g*OJy$q#Bml+@vT5FIn{vz_1M>popH@gsi#xp#{@7~&fyF{GS(a&KpIf&L^Vp|&gLS3F zSL=<_NJECR$J=Dh00er3b%KW%QF8aV@vDch5t)Kd6bH4;`MOCF=O9&*Ts}HU*})Il zL^(AK@PbH`A)_dW{&0t~qnZ0AlUsLw6 zD7w|&+I_s9)s2GOPlI&h+kL!9Y(bj%2AOz`R)u$3~CVejJ0jNkBar4PM_Rluj%gdb+ zRW?AqH1b*1i7>$Lq8DD#y2##JMs#n;fY@1)E5T-6Sy0+n2feakV~{kmpWPB181C*$ zAfWiH)tC2l&Chk|3g+0h>}OfM`-A}CK*-p<6GGg>_-+Fiiv`;?D~8l#0(WFu6JBB0 z_$e$RM)A?6sVd?d$N))-7ea_gyOGG*d=P09D0)w zUgywv(-+7OG6)1v@8HKg!xAg>3T-ClVZXC(?7jT$&@Hqvq@vgz|K#%ODf?(0%<`#COg~ zF1B+WIRh+OMK|eWbZ^Nq{dybs2QLLYH6MX?r5|a<)?epNgBYfpPtJl|we+@GVz>$e zmo`MS@h=VH@UEZ+wZj6OdPeUO6eYU!YQi+TnC8RtmhyH!xpBr1zEK(PFX0@K%T2#i zi6TiRe6#+HRJTz`4k(>U=mZtqYZUUtX#CpIWM(2)zK++E<}*X?f^)cv!h?j3Yvo`> z`8R2QgOn|DP)4iW(#{3?#(|f4Fo1_JW}Hn7?2e1%KNgbIH@eTp*w1zAnt%NW#Npm@ zbDq>@i(Cu;!w>MPTKs=WqHlSNc^pNmZtznib?U#~>=u-lB?MX~xm#TRKV0xWaj3KV zUx3+PH1I$93&MYqCFAHQi`)NjAr`oCgiZtLZ9Dg`7y3r51^pjZ#fn#8KX`*j&zmsU};z9`W(f?L3q+iy01Fg&-T7~J5uT*JV1(HW$-J5sWO_>WIPc4Rj zktx49n-QZfUyrYbzqulNKM-o_39FD#VNvo&Q=q(~dF z+eabI1*)RF2+wFaSI+#9IbA+lo9{h0#*NS#UJceHL*gE76qa2P-SPgjFthsf*W*B! zz`8q=8$YNoc0cUD_#zdpXY+u=$feP5vUY+s(?{!X%aACo$#wa_ArdUI`_QawZ4vg<-i?x5$fDxa|k>4Wch0IhPgQaogm)nes05^o>xJJLz8;&nCs2uDFeV=lm`WneyYI>Lkk#K;#QY_o>2L$in6Zr57V? zVmoXWL~f)zdDhiltcB0XdVr=%R$n8XJruq)5##7h@v^AumX{u+$VQ zXfzXLr;1S!&N%P-=J8efj~&Yc63}-+@uywK5shp_G_gPRJy$~`JWYZW2D6jhqsf~c zdnDDz0eWs6(Ed-G0?mDCuheIh2GYkYS0Kk$ocGJ|%FhZONLXgBAlaP#KdilFT$EcI zH>%P|qoj08cY}0;h?EQ^NOw3OB_JT(Qc6flcQZ6dcXu~Kcbt3J?q@&yIqx}N&iOEY zli$p}?scu}`q$dVNVL&X8)Lp;-P$a-8mIL-rb9#V+c8g#^ik#^+VT4#Ei3WMusij2 zb+Y_jJjKy$Dt4e4rVyvu>blCV%g4c-wkG7{JdP(n?RxMau801CzBJ1DLIXg3wy6D; z`K)8QsS+7N@EN-b-|Mb8jq*x7MkzM=8J8mArf!)k_uOoxhUThkZ572TX2inmhlS%g z*`zq>7<7Rr%sS6UUSO40EGCH>o4G=G_BOHJ3P2xfBz0Z&$p>_0q z8R!_^l#~8W05b-RURw-H6@dUZvg4#dASFt{YeI6LSlN7_;T2`FqN|i!JYh#zFO$et zOoE#9YPo_-w>M6KuolLtkOCq<>NBJv-j`>#;K%)j;xKMCNNxo7ps{6cC^AxYNPUfTeq{2Gb$lUBmLnu;hhqZV-URMGsW9cvg3 zQkwXD$vIw5E$geO#`*HafY%)ra5;+u1DeKG|M%0JDxkr(E+({I5MHob|xWRS=O24vI*BIb*I zGGWe!DA&Kl_k^8ktiI}DSd>0X1{K+tv!`#`|0X{~A5rR#Op0fJ86wweup~z7{3xS^ zZD#ESj>@q#2OS|SX?MfF_qOi`#>Ez=&WdHbEli8$DX>K9bKtj4@Gtn_wVx|EA z#W+*Q<@rvYKN1k@(OgFKg7sq{(udf(8@D45++f1+v#2Y+Jw77s5@Y$s-{jiNDg8me z)}j9~XT|7i4gzAXcLxoZ<0adG;RWRq!19ypVcsW!v$p8bOZ-rrq0>UQeRRJcT&_Xf z7E%u>QE z!t-38GR<_4_;?I@S4(+u=4FDq66G5W$)X}3kErc z&e+BsdzkjU^g{^UNZxp%qW%=pdgC9IkA`*?exjsl$U`97B*Ny!Z1R@vy59kk?Ttuk zawSJq$n37y!uK_V4JFzb@_V}JBbK|8>NJgT;VR3m-mhqX;o>1P-qtp6?If%a8kQoX zR855{D`;?4mUc-*W~Qo*ktgSrQ46u}@yjJUg0+QNm^Vc;A| zdovIoSP+)`yQ1C{V@6?3Ws1$6b>~bV`I=K=a(9(ed0b&N;khvdt zUriO(`SnkIl@F&?KJeSTf`FO#<^j*q@7!fTJ;d=0&m>0_uI*i%*Z=zVDxMz%cpnK_yAw z8Dg)>*mS~u_KE%0jiW|kB37E{jK1sEP&a`dODJ5LhNx6*aFWUIkf?+WDcZOeSWaL| z^V;I#j<97u+a09KqC=QGuDx_`v=3?~7a}u>=VR*)MZyAbS~h!j1rmL9m~(k)cx=EE?roi z{IB(Hs~Eih=2=~tj4w7t3VAfJcIeJhhPqAW$bfjeu+L6i5!sse?AC z>;Z#LnjJZda3WvxIg*YIRbIrv&27yxsoTunHrf4!3_U1wD+mC|-1Z9)=N)YmYSwB+ z=U?N|D!V-Axs0?p=|*^ByO6-CUeq4!@pwvW?ghR|#ZyS?Ru&$+_J=FDUe7j9>{5IS ze1WPlw6kBIiwS45eJ-AP3bp8`2|#snJ>QAC>Yea=sc(VZaSalg;Raq43(PUneXP|L zxG>2^+48-CM^$5QRi1?uSL{$kcm<9rWyY*kae~iO8 zv^F(YY2;U=AoQuuXYsV;a|_RzRH+2sX)sgs!+-&(YXnc1%*UVCHL+au%Q0VAC<~lA zoequ$j3wDZ81iha87)_a)4dnnfEmaChMNt)VC1$Xn6;a<%_LraM7>!vdchi#```|R zvRJ&>?`z(Gp{U*krpu!DAhap9ZvWfyByP25Z>&Xe94WTDX+P6be0-IJMV;B1>S;jg z%O?ScdmZK9|9r@eS2ke60iuB|)^90fSefV=27f^0PVlIC_0itOdugp+i?l;4eoa(g zVFeauUMfUffytTNRgCoJ z0An2*=tuhpE;U3N2_kT9DLzbnOaQ`e9NsX3&KWXMK(lAXX$2B8Z)uB+A=TQCC!%E9GNy`E;`s<=BbEWO74~S6k1G`ekg63M1wn8&cP&7JKGllIz+fU%*##}*{Sd|&1)chL94Dd>9u z8%W)!2uTA;KD{O0L*wUIiaC>8W#WcKOp9l0hPMgN{#Q*N(XIHQ?B;pp$wrAfT}kO) zaY;C$vh|*RgkA|FL<+@U2m`!|q;D(ZVrl|d6hJFD)?LSenh(JESl|iK#D?(ywO&4>-+Hka~n1+Bx*m9`&}g`eHb{b6_HUw zCHRmbSiNnOaWu-q4Ne+BK1H~;t#`Em>Q^cLT?CKWh@ijlbfIH5)=)4Vfpq1aTdGkc zKnC(OYgTd-FR1!cyBVuD{ze^pEAj(d>wj0Y-vO_8E8fCi>_i5mt&)UXef(+PN6pVlo)HC}LiB<;xBtPJ%uKV_mD9+BO`-{Y5I(?7)n-BLTMYF_wU=58_V$m+fO6Z`l2`d>YZ{j*wQ6a`ii zj+5o$Jqx`uQc4T7=;l;jHc!-Lv-zU2;~EM2{ZIqSI;6|jopZrEw)_XpHlUU4H3~t$ z*2VvLg74n+k{ZwT_ct%|<-=~&r&z{2K6}!?Crd*nNWS;WlLF1VcH{3SfBgIi`ti#C zfgeWn0U`Q1Fq&z=%)JfQ`*rWTFJYE+$Bn+Y+mF6WQN!u{uq*-~(nA3R3#+&~R#Wk7 zQFOP9TD=Wo*Yxixoxk<%nA?KZ z%|K+~HRFwK2y0yFnKb5Fj5%9QlSTE>500jW&m{OK?1t#s;pW^7>xkG6#bO2bU9hc{ znt2(LXNNkwZOP9t>NiNo+}l*u87`hUnGx4xemYp0b+V)wZTjWRcCDsp`MmGcqBkL4 z7)Wi(NcZVy7vKV9s~4e=$}l#ww}?H;RE%A|_ST{;Q24%G=Fs=~4pLP71k$|?y_+lg zJ(dQEZmbDDMHh2#4g@vYx%ji`5PRQ93MhK*s`*s=xDn8KAGO#HF2+@}o+wEfE&E~k zHj{ETJX%u?>QO!5UXe}u&kJ<+0iXdR#Xg{ozi%RXqBZRTs3D_bNTc}-xiHXvB2}3j zD$y4`Q~@X`bxZd|DTM(Q7Lxz8rORG&FW&FUoX;9CBUL=6m$H z9Zk{c^Ol2FRjVW^=Nz?;v2Nq0&0l~KAIgIX{_6b{M+H<2DWKmONn1fBGq4UnHshdi zCeYP%#LKnh=h4`617$>7iy=aBqi1>e|2Kd;NzOFNBUh|(Q+YThyzfg%AL~xMIS

j7nUvCUiMIA!_{R{jh=z#zKBMNDUI#IH1YOfnEca-~w29P$3;(>F}*QG6xes3F3d7ET9SMya$5T3WC2JEi+sQofkRzeRI8k?GdI~5 z2DKt{=Jj)-aCPPX>H?Q9oH20)yd-(7r}NTLDesJrFemOhf>avKe@<@I-KW-y#WlK9 z`X9{7yP?ia^6B-m{M;U9gqvrCUffwX8fHgtmutY|l_P5i3b4KR`_b+O=cZiml!)sv z0m=xTrg4%IPz1o0fU%)1We06)%-kmLkx;m}Z%E78r1-5%@6Mn*5s0(1Qq9(|8kVMq zb`(x~pFGI|D3+r|U@1$(`GZnKSXzTg6%z7B47r-MgzdpZwa-yC7}nX@9o6_{py?** zR>So*m2jJ=jg~hKx#pGYcEU*ppq3-&N4(n?>p)U?Z}8*Ee4m8AuThYnC8F4^lVYt0 zB=VCt>~kc)^jm4CF5&ApUvOXoa%J(}HUXH@T?%RJ&Ch?y2FK?URpu8)u_T|=vln?X{XR|~Z7I>>WHZvQe%^XkSFU)n z49><4)z2HI`N1&olQNBu7Z=Mzj~cd|4fZhdQaMVKAVw}_z5Q2buHp_dG|E~`C8aSK z{Y-yA+{40C%aeyh4 z8G_jvB!Y^QxAqXZr2*$Z>5KjJid)uu_nlty|DwyWjK{pxKUxN*8QX(V=xa z_q*gt8F%&uDh0_|Rc-WeSIZ zBeUAsRn*tAb1s6&h8gfG77^BWxXHi%X{qy|@&O78_^0Jmtnd+~6XEgdK#hP$1op(- z0civnIr$rT9Pa*kCu2HDka49*SmR9ULc}eq$@ueK+SD!+8&uilumhXP&no<&AyYZ> ztogE=XtVm&|Ha)~MpfCh?ZQeUElLOyBGTQ`Arb=8-6gR|0qK;G6s4q8x*Mbx5+Wd> zbT1m|MK|oZQ10h>-gob@zp?k&f4)BshXJhXnsc7#dCViY0%GvVbBYAX^tVuW-o|QJaJt6tPxy*u!Q4kI6SS%)iE{@5aK({<;%!VmtkZ-q{`0Yb zuMaTE+)B>w(S4}kXg8?O$g|uiA!jwsZ^zve63-96CYHUe z5aAdm8E+7U}Fz?TbD80%tWk)?| zzVSkp1d*Zr58wcDc1xv;3ROMdhWciw%C14t2vi%JsHL)@O2@V;Yvqhp?JMKU&l|h% z3`2U&gG&Z?YkkuP+arv|H6%dpjK~W@KPo%BU7&Iikb_6o^#Y{P-&EY8y$UD{f&Z6- ziw_a}#VJoTo7CJ6^LHE!D9L&F+pcMuS!eM0CS=lICws4$J2JYE@kcD3G$ra|a|{)h z)CaHu#x@l$gESKzfDo&uI45iF2cTPQ#NYd5J7AIL+l*w|)KiSySj$WnU1|e3*vgfK zTGZO4dz>hu1l{sY$MK=HGX8HaeyvXkAu@P|Y}7zI?(fu({AG(-7UGGUM`oVd#MZPE zM@;x`*8Pd;LGWU2Dq% zAEvVPV@aX}!Na^8H@*$WKe?aBqF-TZt^t?V=aLa03n~I2Bv`Cg(@&(2tcIi0e(p=|s>-U;q z_i{^a2e5Tad8eZwv$v3LwCDQTlOdsMmGn(MrOWS;c}?4UAy4SdOY&su@cG5#vC-c_ z`lb>X45-7EQt&=K5W8XM+HV$@wjaFec@&D64Vs$){Ey_~C)4cK!7h8gjA@=3;s`4P z*ZmgkCOs+`W*8o(ohT^QE@bcNL@yqe+6(MUH7a?o32UjqU92XdjZ`F5_;zp5ABq@h zP(YbD0`U$_&VS(^>aKfgCrnmW-JHV3Zx7+G(aG+fkMM6ug$RNvG*rWeDpM*8KfQY%L2RevD;yo&T$F*kY&C@p+ zP=XQk&{vO7>%Lw{BteLpiB~FmJkX>KE&X`hD(v+sM@KTvl6m0C=FkSYAiIJmJ3e0MsxrTh2BVHc$3tbh#9@yQAxcm2G!iZ^Lf)7qdCW1{cwcfX}+cxa`Iwk z9sT}as%6r>m{onYH2^7Sz$5dQy-ZGOtGD!$d5L#lm+48;PX7Y!5X1H0uTFg=!TgN; zRDO1Ap*pjBa|pt|Rz==S+;P&lWw=%$yJ^h`3onBy|zg@~x2(<{cqrNLQ|@Es<4A4yU%t4X^zBlz*ZcYUA~|oO~Fu$GD5s5>Sfq z!Q?)7gPc~7$ufpU4*Rl(*7S8;&Etfp{eup1ynN^%O!BvmZwy=zN#^dPGwlZWCLt4+N` z1B)hHp`>eZB*=U3(U7ZdefK?x!0stn_Uj=dT@j{9R*%c|%|+{Yo2oZ=FUDFwJ_xXH z+rOziXC(ju8f2Sk*Ab(`4Bt|=#)=-7USY1Ejm5qlB?(^Njgs_JlMXc(8#dFE$cB~x zlMmy(_UR<7V5;bF#h;T#03ft}8?T?~0jldKEa(lJ?cr1bRVYIC>&W>BpQB%5|1g(_ zKCBi<_@*j$M$J^=(LnYcoO;(zddp1qCm&Won^7MBmV(6LVz(5`UOLxO;8aR!k$L;R z_UxW7k798>Pp~mjjL_Z`V&xXCn3>M|bE4q0)8Ik+2c8p3gyIg%f4@PB=_I*c?;=H^ za3o1Z?tR}vnA%%(*fu0Le1Ez7L=4bF#f;{wH$X*lHu571sKt0?nca||@;zM*p%fmQ zO#2e%mV2hSc}~?-Uw=POv1g`2fn6y7Z|eXy6qxg(y|9wDJZ`hWr(U~gBe1}xx50N{ zsoyM?ciD!*~xVinsV5oz5gRdx!2F;GZXbhI*8_BWSchw|g$A0+kGE6GYph znJTW|`U1`=mHppFm?$e5*kwaRWTGQWsWt-^M=hNAryFi+o5dVEDYEHSnc@GXgCJfB ze$CaCkd(VZtC?FpyMan(ITy+J%Q37FIeBvG;hZs$@BSe2a?U^xKYC*-&L2dTq?5p+ z3rEF&PTk-EamprA^4G&`=371Squ;(WS}kx0r_yF3{ros8Vf-hL?#R+(*%KS~ z2#lM^u2bIMK^nC(c-vxk+D0w}-O#GXjMkQ=8=WEJ5o_a>#uZE9hv*=Xws5vo%f z>0j|}gC3(g9{7{ne2n{1Oc@krRARhH6%F6RUO4-}cisGf!D<-cUrt)kDkAXq$tY%o3t>HCe zZkafRQ_Kz2dUGn5%fgwl}*UI6xcil`S`IGt} z6ZhcKd__|yR-L6!$?d17xqDr_L%g4bjgrsdHGM!Ik0s&dF}Q}rZdg#2GklzY`WQQXUM zI-FuVmua1D6e0VodBqG^-Xvi`R-qcL{&%(=))q>)-HWpe&qbl*N)!!c;p%I2fFww zkblD^Tw_zcQ@i(gAo91MG!yCwN)n(i(JG#&BT(UTO8vD8|Bp!kB-Ztb4DAYg=lj0A zdiG+h?V37h=y8c zTv)g*;SqLCjyrA^%-CXhUsY(pQsD%GIv$9L66E%wdaITBxm)*o_U90JDk1I~8Wl!Q z!x4;p5Uo+G$^#kw%~Y`+_SuX3T2^|~q4STdbS;`(IqVzk4SWOvCb@u4XMTdM%TQWy z1`9D%4N?|5uWdNDZ^4kTk@jraL&+qpTGJYxHod0~vX*?-ZLq}ZEBb_T`t2KwPVNiQ zkL9!L>h!MpmvIgd1)g~=vqL<5W{gf@$onqn=`$0UKBfbGQFRDuAy@*u93i=|&p9R3 z)WnScoc8!$qF*#no()vLpIPfZOO}IlEsW&eA7cH?(WYS85{XI{nvly^HLP5lgU+}d zM<#{QaSD;XX}wsD0y1mTO^i#5}*ptA|2>U%ej zcawkD+{^+6?j661=FUmX6?k6^ZqglICXW;)B$+t8=LonLkZ<|!0>A`!Kltpj)F_d# zM$xE{CCx$ZT;+JYLsKS}uy>t%7)Qn-*A{gldhxrWOn~j*Xm11Y*R(hP>0CrhSL=CM z1dyhDeEJ)_8wlW!KmN63QlsP#7(y>nyq(=AKlRzt$87I~J{DAWLire)5 z7;QWY9tyQ^3)0f>CcxcC?*b=t3_m`CxPs85eP%`(X@L=myp4>u0L{U=WcDNG-9Xby zU?KoB;KbO1o__0XHw+fV@V{--9()WEUWr;Q;^wpzl4}-5;_tg8V2=+Z*zVwe@&IJE zr?aB-iDLPp1Tpvz=6dRM(0;^g$eYOh9T?YoV|kjPu}`hcC_R z7f#Jc={PCt!TR;c`*BDCCD5qSr+~X8h3Wm?84!2B9S=indw;|)oS_~ow-*njoUJ=M zitTUUYqHDL4@Llnh|GDrOEBSBrTZTyS~RePp!yg@is?ZbkdEO&%^z&UKfLuM-TZ)$ z!uaNH@KinR7Y3Un*OxMl7%x8LeL}p6X{vQVxdtLBd)FN%cmMr#w=Nj()!U#wa<$Ox zJYTarZYrb`@-j1ckpllbI(GB}k%qzWwv41>6Ny8YEpR4$4aIn{68%edVxHd7uyY?~ zK*8?dk>9oC^V#jF)D+PsBU5LbaOL$$b3=LHf>Jza|LfgIObpiD>q5+o3iJ9f3(EsC zs*XN2{g+vxP1%t5Gs$FvGJvuz*MBF2B9mQ_ZR>Vac#$%eu|O5A{t?+Jl?$iEF_9SK zVrv3wl%||e)u+xsw*)HB=e`_LLQnD+A57Sm%B|Qs8i=95#zs&N z_!x#PlGh~Xo+3tB6%cs(>u23O!_D~1#`~i+dW>=Xq$VgS?luw0R-j8o@KhUX z1dfT)M3SFq3=jpOb`#v;IkiN=B%8eGsYKjddweJboL6DMdDSvNUrE~b)lKhce_8<6 zz&zX2Uy;s|K9q-yT*!Px32$n~t-hr&BjlJ|;n>j& z68@Z#cc4a`p{XfL0uu&jzmKNCBQL3_A-L>OYN^f zb-S~#ExdzoCs&7^ZA4g{fWRJ*#iPx!5JF2i>lJWOs5WtblcOD5Bgx%Yy>|^6X~UnN z8PfEls@~J#Mg9W&Er!%z7O9`~tx`ex9K8k28n<=!mN~oXi|Uw;BpiMN%?L)SgAp)R zh@0phfwe?nfUQBe^GJh5kyCrr zJDxO$w4;d%gu?4ozhti$yr*HULyP+azI#GjMN|35IRP*Wt%re)rmkga*XNJ zxR+FMRWGwdz=6%Y^qk)!4A#=RgwS$bPo@&;SWuIu-*R#M+jO#x*c|;#ZjYvO0S&Fg zqrP)W;X%Iz6k2ZEthws3Egg48Jxj4{QqwCxwZX6`G(yYjxj;ejaBWq>@7ukmJRDc9 zKw&`3KKX_Q=-sGf$-79^K$O8dVL*AC4RP-h2egAK-{lFv9S2iA_dWY!Jz1wt&Qk$Z z0W9U$$QaA_mxJ!eJ7814>B!wuq;5NC_$<~{iacnj^HF`Y0w9mGYPLCob{=Ljm`VGq!;i1*&zX2LqqNDh2ADhs4ct#3(JKDOf z*JpZ4oVDhz8obrfMqTslrec7|(gP$(`J%i?l>Ynu!`wn;EDqJ6wn+U8r-Q}Lok%UW zamem;<|z}o{;`^9Mcr6@-WDJFt||hOv1k+MX&T@fo%tSTq?JKsNAcr!GDFRst{h2jeKx3 zEgKn-&%1B-JnDFWd~=w2%aQTbW?aj`e{AXrAZ{e4@``6;Hp^yLN%643n0hQBZESjn z-D2Ux!8P`Qv0`>46PL7;->vhlpUQvZo=i;yX)D48Yl8Jf*O%a+80YJO^REDK=QXN| zrLn$#AsgoMt*gM*sn~x3Au+70(XKKc3mq=*Ay*b-&wnxp>;pDY3fKRC@Q|d@fd-$( z)f)SBYclTtt;-mKID}9nvcBxvPyT#1@DC&c{_J0y`TqtAan1t5bR9YeMXKFS9`iAL zLBy5~TcAtNN0@EBU&ms*YpYtZ96GP#c2c?8kS*fEN-zP}?pxs4K%L?n6ET~6Vu zc9a+MN?q=I+vRIzWr%0t@j>p0DzgsZ`E{oLFV8RH>TA!hZ2HT}txuAS2%#Zx-20ah zHZ*G{c{0BHQh32)QHbw(6_CgQg^C|!5(U;)CNJ?#m5l1hm_P#1z~{J^rKgqYccp28lc%zG?-{dV z@n4Z)kOG*pz4j3VpBu_YdVwr|a1tb@V*ON4;h%QPVL565DH*y1Q3w`GzR1;CxoVzW z&Ablc7-4ky_W$k6#1HC(Z!(c`Ptpu*v`@kG1g_5H$bB5e?zvP)njRRm$5h@u{c1tHh`6z? zf9dveM=lP7buyelE_VyCfR?eXi!*QagjZ4XGKhK*D8jK5?vKXk{u_R#pX{Vdt;e) z#L4ZQQ#M{lI!vkwhknRHFTL$;UF-+|?hpw`DjyJ37}{s>Z_ilY3q}hqF)1}3LV>z& zTTJ+)U^5X}vnE~lc5p7%Cq6;P*se$tT9c=jl)igop-1DtX62#L zCd}t)RNM(XN9_JhLvS$c32ehTuZ+@0R!-1-3*upiv26 z5JU0f%VhvN!@A!5i&qU*YWb#3OSaW+{8?RH#->W1z!qve?@lQ=X4k29f_vbQ@yapb zn!8ZWLd+Dca&9A#3;yt0p#T3Ab67N@0r`3{{a45X!lY`Io7A72K7Ge}dOC3%wT{r% zf2N>>W?i{>CRwZU3%kxeGeZw;DWxeimk(=9Jockh0R`HIcRG);c4)o>pslk{B;jKK z^#0r}i;Y#9E(P0hnte7cqxBkX9?0|`pDF|ZgmK#>KFlC^&>zF>!A#f%>DuAvKF-tR zT#skAwx9;ZqBO z^%;Pb}S3^TrvvhY=8Ff||0#cPgg)yk^{!x035+{ufr5c5I!&95YEV4bs z_LvbY6Nt7Y3D>)cOV%~T552;x+#}tuQw4M;<0q#MWf?iTi-Gd&&E!Pzuca{i1eAkc zV8B(0>I~hz(w+qpXAD6gFI$Q!sWVT$-nfq~64~5}n_C)XUx_fjfj+9E68@sD{x%GH=dne@W>>|9&4C~GGo>bj6$Zm!yhFMhKoq@O z01FuQ-%9O1c?Z}sEQ zeC$=I1MC+8{nfaiPG%F70pqH>zZ)H(oWm2nTW5LH6=HI`z=&+F<+3 zR6%4YLwxaz@luY`l0J*?QGV%I>3O*`HY!rtZB~Mll^y)B<%^wGTqt>&9EOK z3|281uCGvYt(!;OeLjW;Ci*9n32QC31M>>W`<+F0!Zk|+4=joFihyr`R`pD>r^Uz2 z_+B7ms+yXmFzq&qyJA3yQ2nF`ZS6m(*Q>8b56PCp&4}qIg$$F9#|@7yHTV_H=;rtE zj(mSZ%_r~4A*kJ^mzghp>Y@y4x)y>{UHtyG*T7Z-Q(h7KI7x-s+Yclwr7gp^R~1XM zOaiuYcBLF#D1084_OwbHRo$d1NYLk$>QW~H~+IhYTb8iR1mpgp3gZE+bF&@zoJ)8&d-_MKSt&Yhy0lZ0aZ z;s#YqyB}%o+=Cb23o(~(kseE*fVrHA_0iGziGuNk)okKKb*H2AWu0z3N_{gF4Q2n3lAWFxC>du#P({Pl(#n> zSzXr}2B*?gdY$#>%Aalm|M6m06s^MTyIU9qtr z_MmDQGlj6jtG}wfG!mzb2GLkC&>kV7D8*!x;Id0UPi6?z)hLSHQxh9jSv_k?i0X<5 z2aLliwZli2e%6t(kmtV|?DWM`N(r;6H5r(7fa-A>-9-t6P#jFj6l0 zSmKJTfUsv-Auh}Q0!$ptM@&txUctk-oj1h(MQ+nsWE zR2qZ2?Vn2izZ|Gvefal*ddKtM2I?5Bh)*>LVn6y1&$mDyOl$gHz%LP;SNPYMI0OqC zq~{0MhL*-8wa5rQejx*0A0}WF*y17`izKbh7Dfy*h{>+s=C2?psY&Y%O(~f7WN!TZ zBG>EzVj>xVNEeqi!N+aJ!B^RcpFlR^)&B;+G|B>Yp)AJ~5C>eDh`;%lnnzwHve`1} z&-eNB&#jUF%h&$z@ zB4XomFGA7lcX7nP?_Zf2xT`O2-Xo;voAnI2(|T}G8&eyTo+_78zagvOtOKGyC#|9rFa#^?;Otba~>mfpf4c9+U5ox7sm$@KZSQa zG=)!3chEKqn5>;jBI*ejCpPrJb@g_=6LRU)r>)u6 ze%8i9_YEDZ!2g((cN$bGmUDg3alu*B+tE}6V^)Dk2BWUaDR&->#-6;et3iP|`LapK zn0H1(zk4qY=bdCcRR><({gjXADR4&3v-_R{+s9ho9CgrmtMq0>r>hFTn%?$0JU+Gr zcG9j!f&!PL4fR|=`*l;emgDf`;7MsNpb9Vow+G~X>0c*{nkv2)tCe4?<&-x)O9f#! zGV5Z*t)FZjg1H5`T6re92+Y*0G$9i%24lzRK9Yiu)XA*U-6i;oRce`slGGc z$LGkWj){|#(k{n(b*j8^@&e3{Sr z)VCl-U>R*E`-#x&ndGSymP-oXPk_CUQRfD>|1G*0J{FgQ4)PO1RM=zLZ@|WtHDw0c z`Yx!2t74I+iu?GD+y_3!QVn!lVeBlw#6kDj@yM4r)PobXrv~bYT$&N`uS4_oY=@v*I#NBM9JV^@=ZT8@V@L?YQLD}9u# z54pIDd;(9kT!QclaTFho5}w1P#ZEUBK3I7A#O~O0GkLZ5qn}@S`14Zh1H*7B(SHoX zU+QQVPEKWWKE2TG_jq(-St{|qXY+T=#?+~fn(;-_-Cv`?`)*{>+lsyd-mC530GOCd zA#d}Kx&)w=oBA91@ZzQvsntR(sQ1ih;)=~uWIk8hK}!plnrn`3dPbA#)Tyr8c2R}5 zyTcR*SN+kjw#!s_x03I3{qH^#d|bzx-m}@hMdi9&%^q)M`VUxhaJ+=(GStaC+j&Vs zlW66o$jKYsO88Z!ekSveC6utcrCCo^$ybNtf0@bnAlW&J5xSAg`{P;kmzcJ(!)anm zDsuN}49j)ZSFXjjHNqwQoZ&=aH_ft>`D>oV?NC=5X~>=~v2=kr;V##SlZEM5s=e+D zW%EvrSX>rpQReTS?%=$gvDvjXlCs_Q4>8Nev6wXC=8(=DN}4|DxUIvpW|OHe=j8nxGPl77Yyi(7hOYgh<$@;a6!OH+ z-@$-l!iTO`QC!%F&m88?R~aq;=FLn&4g^;Gc(`ys>)V*2qc>kMZC8iab{2-p6qYEF zLy@3nVJRz}EThS!VBjNF%#yPLa0%GghXQaQeT<+nwZie_)V{h2jr zdTw4Rzdw`VforArX%Sm=kNemSI_ju6&)uTurdY;2O2S^FJRe6Gmy12kEN)4}tVFpb zpL|sgi}um8C%HHJX!&@im1i+;0bvwAI>|O+DVc&AP8`k=aT;Rt+{?tnydHc-a;#-+ z)2`YLSNG{~Jkif}@hp_jLscdE(bC-cgUsH$8{SKiK29(=Sp30!+_fCam8TJfo4UF} zmA@6dOVq2`7xp@7!U0L(vVkRrFKRsiHQ%%q?U>SA+wnvdy1~1_IKCXUPu67{S*0v1`L`mx&}!zZ;aFDERo{Ef+`DP3`5LcpZ;=7& z8r5aDh5erRwLOlZ_V>e@uB7|e%gl>ULvsN9XyP)A3JdoHw(iIIo-q12!?R_v>DO#(iV&%|EfbG zz5?$-!qY~RH<76-Hk6upLnwwAb1zO`5^(fh%A zc!vLZLEO8C#%l#8pmEmxUZ})!_~YPsn?2fm_x|fsbmcF%c}E-D@!3*6x9}De0v`Ui zcWmw5-qJgj-Ahs3r!`vEKm4DE1TY2;O{&H5=5o}h>VAODVG*w(rZtD0gVq%;Vc1%; z+~vp_cvk{a^!GGq5-MWj-YHQZ*@kssPo6F3gdbg%Ao?jzjk_WOk`hcAhX?ut4ASR7;BZR%K%=L3^w4$Cje!ndC z@^^~E#pY1?h-Xzj8HBFqwq>=3)qQB>xEPdX6v(4#;FZt({Az12M7g0`-GQMKkS~W=?Y@gM|-s0(G|=Z7AdC4$Aprc(ZR6C zIVqIEN>PL96L`@jjA$lY>L-)HXq^-)wF{LOAIh2^WYt3Rsq?=L<;0)fJv?i>|KmU~ z-{p{Er3o%-af^nzB#BOTb>IHCnx~uJzwh>Rj_5zTeUfWQc|*&}HX}Cp;wtqk`DbMS z+i(Gk$mPgaXq5^CAc#-Ht6uaV$^ZtmL>dbkHdHGov&{Z^b-nv#-!A+zx5YAtX}Ou> z0m;11Amh=nlfi?d2SG36AZ<~9OM%`-D_#b{KAxy!X#?EE2&PkpNl>V|5vW1p8|Kx3 zM550AF-36eV6jY~#Q#PPwX}6LWI{vMW}-}Tn3r>RwQUGJ_@fm+GJ-8LA%yTdzny$) zerh`-;*M4rw&6ZTfpv#6?NKnFMT7VxW48})Oh}CM%}+4)RYQsQe>{)a@Go$-@SiN5yOCbBP&!A!P~2 zb^*1GPv!@u;R;fNq)Wh_~UfJtNkr9x`)``=m&=qjZc+} zU}aUR)*F9LZy8Dq%7UCHV}?}4Th({H=DTEXCp*;h?&mG~JP{V<{k{`=5{0A{IHVh5 z5Hx#Fk^t|${|VpA{6@jA?LIZN#Ju}u0`WMY#4C>0P7IFGtdb7oxfXI)|Jzxu(@bm} z>|7A;?`@OyVFqiP)A@I_e*=apw(-a_^xLJSy(hQNzvHG-49UQ?;n zc3B4%rqhcxGSsZoq+U%Rh-e8T>2u0@LMSDgUKVPTjn-(`qDZA$Q&!$k-0Y1jKw5bW z*d&rwziKdk1-WoF!uWBAr8#$3MhDR|lqx8D54UPui)^^<3K^o&B}j}LrMRYuuHIXl z06Aa)9%wzf-TT56euCeRVYETZ4{xz}F_XHk1D#BG#Dy}=7$RQD@-#$o|0VvA8*cT`gtYM|pA0%ReMaaAx{w;$ z01@;siU{IjWfqd$*W*x(D<;jkwRQPKpijflmoGw2#&U3e$6&FWU&+N+RA(_E0m(fz zbV^?ktEPNadjm}sdR z2bA|nzg)rt@o;4ryG+W29UgE~0;DbWE}{&50@l0KnY$$oj5e9SrK@-QCfexN^vYBO zK*zuD4oE~Bi)DET5>e1>Lx^&uHe3F{pdE zkme4#Pvvuq670d+TP@I=GP?oGPtr0NCxpN}7<-6xkhDU5jM>+l zZH%uUC@ywC)?C!M3t}B*G7*a?Ry4D>cYe9vBrBW0AAXx&_)hpX)qgU+$ zvWaYmtrRA~5!Rc8?6!SOxIrB&I(2F-*(OUEy*H&%B6x(;owv?+K1F%Vn34rkVmsK^ zL%bJK4!08aE!ZDa!n+H_Qf+tCqPD*!l(XWcm}zBvo1}QXabE7uJ4cTDb*W)4YH`vf zy8EKt8QHXcapqHbv+!)Wn08R+{qBzaae=dav0Q#(!`ZRgMYw}qedrW#dmLv)^H>^}`nj;)o6lgsuNc@C} zuDi6$hPJMUY7EdJ_hzpCS`P&(eL6v3e<2$S`e%Myl0v3h^~5mYogTE1FWWuR_t3#f zgf{_|?KfFx^ET|h7cYWsGCn?T+t*c%A(#~}f_recEwOrI0vY5n)}$S>&gjgJ_A1n% z>|70zHNdD#GfJ^>*^Z`tY!FfY_s>Sje=G^+#~v=}{(!kSFQZnw4|?Me?%4m9YDCP6 z8(>ycn!K77Uw%e3g0XCKq!q#%u@JTK>k}zc15V3eEK$O!&&QgmsDcGsoJAGUsK6rH z%ZY4~x|^P2fC~{YwV)D3fV5~ymyN;+vpQc=a*4QCO%vv%Zp zdYvp>>)5@o(^gm2i-$DUMn*>N`dc9a4?Ab-g&Ja#axfHDi*%h=9>#fJB9nd#BPg)& zxdw3;%mRN_Bzo&6@(7j|jgj^)Q*^d<=CjXRB~68y>mW_=yu#^p2>jc;Ah=YI0=IVH zNV6HGo6mkU2b$awzagD#9MrlWr{{ClX%_1Pa7j`mV5H0MOCSwUws6nUm-YO#0v4G z`YSgl^UASAZe_5!>uRAn$81VW zBg6rl+m|_Iyx3V7g73fYbTsa}7S8B;9TD)%EvF-|RL`V5 z=CuVFjot)wqS%8q1;4}a?}vLgi#qO5iA}Z9WSHu)5PGlCKD|ry^0R%tbD#I>`^z&rvF*3tk<8yVqehwRiulD- z`d}{P`O%COMyvCIX_l9XS)Zs{=$>JX6uV?N&k<`$iBakOD|cUK*^4cvvQ ztfQaYJab2wHQr~8rIFl3W#?2@I(4EKQsV(F-P-<;D$buG#TxJ;^($DHQ*B@NjdTOC ztl!qVV)Gy0N;nZmD%8BdY15M`X3NZx6BSlmlF-HFFG3D;VnN62YAjl+p~|GVt_yj0 zyqwo|^OyS#)ayJ_7)35M;{;B07rO=c075&*fC5`zvOPXL!U8t$Btwn$3X2;2{tZ?D z2Uz-Yed4A0g$f7d^Er|?y2o9SO*^7_3P^QoE8D?D0w)<;lr z)w0Fhzaj5NR3r2;QCbpP10zM1)mp%fthYJRtQ#JNWKY)YRM72?pnU-<2AW3Ea z&U`fEWr@3g1Mf%dP*(g1h9h1PNATngblkQy0Fa4OJEe*DdkuJ!NM{giJ()T?cdB6% z_&8o|pRh#+lIPVAbJA-^W|EDj-?eoTc}?)yF#^Clj8Q~DU&^p@#KJ&g)&KAAj{$K| z`qK&VQ@=jl+Cs4mln%>DqLj{`V_H7yuXC$Hw}_LFkv>kMxh$$XtjcXU<5KS%HF8%5 z{3sCzTOhaFl^!CMcwszC%2+>8)b|UPw|UNze>6M>?5ufE)xA!c`OcXM;*{+(_n+BhPDZ|9$-Od9r%ZZlTAD6_#JkKM>P^eL$mJ#APg83r|#Q~e;0)CO2A z9rWmW(O4BlEyjAzD+o}AmKN2!=Cwp5`R|r@(j0?_DYzMX@?oX=cYwX|rjS6%T;)@5 z_qk2tVnPdlbLf!@IWB(W&DEh=lhDjtkoUWx211TX??hzVlw5>R2N`wEYU_ zNy6v<(X+GJaJ>DVPc1F@A3GtriI74-&A@-sTr^m#G z`Dd)%NlY7l+9ISLi~(B+XjN!#T-|8EVtgSFmA_W`(7HkpzopEy$oFfKwxz_4l#cz- zTZ{hwurDxtCa^vTjKTU)WRHPlo<^#Atvl8pS{xaY3DOovS5%1`6-czTP~9Gl$e8qY zvTvGa!g6_{c5{jd2?F!6)TfN*KOJ0}eD_e{5UFjfRQB!h85Y3(Ej~=V{Jg2zzZ`cy zBguegZoE{+dh{3oyj&21s_&Ycg=34S@o*|dpvyz|5`@6^MR%#f)=YPC$Av#fm zCyW)wIj1}7s4%`>qUPalGIh&Ax|-tW2AXbiC4-bmd0ZQ3aulKU^u)%o@`sViy{gM$ zuLOVY26X6-V8cWJ(Jfgh(+6=Z;~@BK&jy1W&^hq9zRJDJ&IJ&!-u%9MbFg#H>p0iO>4)sE6!nsJOV~lfueN+-?(07 z#Yz3^^4U@LX!+o@OccZW$-D!Pl*obMN=s;K(y~A|{Or@l_g$+c+E(H%vJMNJZtEGc zC&R%h4oO~gDsTuz{y|`-RVS;~b5e|O`}n!N9Ipw;`khp3@021Oq{iG5g1+ii*Y*6S_6U3bA1%-Cmjb zmS842h_krD=q^WP{yHP;1iG?{60o4ajl79;DACFYJeTqC?Uo$qe81G*U@o zUEfB<1(etqjhHs6_pTf9TjB$;9|m+Rb#n`Vy9vkR0*v*IT+a>eh%_J087vCv z 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