From 9b4eda6a9d65f3a3c2d833289a132cfcfd69a7b2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 6 Jan 2026 12:47:10 -0700 Subject: [PATCH] feat: Support for updating all Lidarr artists in both the CLI and TUI --- src/app/lidarr/lidarr_context_clues.rs | 3 +- src/app/lidarr/lidarr_context_clues_tests.rs | 4 + src/cli/lidarr/lidarr_command_tests.rs | 23 +++ src/cli/lidarr/mod.rs | 12 ++ src/cli/lidarr/refresh_command_handler.rs | 64 +++++++ .../lidarr/refresh_command_handler_tests.rs | 72 ++++++++ .../library/library_handler_tests.rs | 171 +++++++++++++++++- src/handlers/lidarr_handlers/library/mod.rs | 46 ++++- src/models/servarr_data/lidarr/lidarr_data.rs | 14 +- .../servarr_data/lidarr/lidarr_data_tests.rs | 87 ++++++++- src/models/servarr_data/sonarr/modals.rs | 3 + .../servarr_data/sonarr/sonarr_data_tests.rs | 133 ++++++-------- .../library/lidarr_library_network_tests.rs | 22 +++ src/network/lidarr_network/library/mod.rs | 17 ++ .../lidarr_network/lidarr_network_tests.rs | 5 + src/network/lidarr_network/mod.rs | 3 + src/ui/lidarr_ui/library/library_ui_tests.rs | 13 ++ src/ui/lidarr_ui/library/mod.rs | 20 +- ..._ui_renders_update_all_artists_prompt.snap | 38 ++++ src/ui/sonarr_ui/library/library_ui_tests.rs | 13 ++ ...y_ui_renders_update_all_series_prompt.snap | 38 ++++ 21 files changed, 701 insertions(+), 100 deletions(-) create mode 100644 src/cli/lidarr/refresh_command_handler.rs create mode 100644 src/cli/lidarr/refresh_command_handler_tests.rs create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_artists_prompt.snap create mode 100644 src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_series_prompt.snap diff --git a/src/app/lidarr/lidarr_context_clues.rs b/src/app/lidarr/lidarr_context_clues.rs index cf0b3c9..7c696c2 100644 --- a/src/app/lidarr/lidarr_context_clues.rs +++ b/src/app/lidarr/lidarr_context_clues.rs @@ -7,7 +7,7 @@ use crate::models::Route; #[path = "lidarr_context_clues_tests.rs"] mod lidarr_context_clues_tests; -pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 7] = [ +pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 8] = [ ( DEFAULT_KEYBINDINGS.toggle_monitoring, DEFAULT_KEYBINDINGS.toggle_monitoring.desc, @@ -20,6 +20,7 @@ pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 7] = [ DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh.desc, ), + (DEFAULT_KEYBINDINGS.update, "update all"), (DEFAULT_KEYBINDINGS.esc, "cancel filter"), ]; diff --git a/src/app/lidarr/lidarr_context_clues_tests.rs b/src/app/lidarr/lidarr_context_clues_tests.rs index 542469a..b219954 100644 --- a/src/app/lidarr/lidarr_context_clues_tests.rs +++ b/src/app/lidarr/lidarr_context_clues_tests.rs @@ -43,6 +43,10 @@ mod tests { DEFAULT_KEYBINDINGS.refresh.desc ) ); + assert_some_eq_x!( + artists_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.update, "update all") + ); assert_some_eq_x!( artists_context_clues_iter.next(), &(DEFAULT_KEYBINDINGS.esc, "cancel filter") diff --git a/src/cli/lidarr/lidarr_command_tests.rs b/src/cli/lidarr/lidarr_command_tests.rs index 52e1276..484c62d 100644 --- a/src/cli/lidarr/lidarr_command_tests.rs +++ b/src/cli/lidarr/lidarr_command_tests.rs @@ -77,6 +77,7 @@ mod tests { use tokio::sync::Mutex; use crate::cli::lidarr::get_command_handler::LidarrGetCommand; + use crate::cli::lidarr::refresh_command_handler::LidarrRefreshCommand; use crate::{ app::App, cli::{ @@ -170,6 +171,28 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_lidarr_cli_handler_delegates_refresh_commands_to_the_refresh_command_handler() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(LidarrEvent::UpdateAllArtists.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let refresh_series_command = LidarrCommand::Refresh(LidarrRefreshCommand::AllArtists); + + let result = LidarrCliHandler::with(&app_arc, refresh_series_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_toggle_artist_monitoring_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/cli/lidarr/mod.rs b/src/cli/lidarr/mod.rs index dc533ab..fcc37c8 100644 --- a/src/cli/lidarr/mod.rs +++ b/src/cli/lidarr/mod.rs @@ -5,6 +5,7 @@ use clap::{Subcommand, arg}; use delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler}; use get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler}; use list_command_handler::{LidarrListCommand, LidarrListCommandHandler}; +use refresh_command_handler::{LidarrRefreshCommand, LidarrRefreshCommandHandler}; use tokio::sync::Mutex; use crate::network::lidarr_network::LidarrEvent; @@ -15,6 +16,7 @@ use super::{CliCommandHandler, Command}; mod delete_command_handler; mod get_command_handler; mod list_command_handler; +mod refresh_command_handler; #[cfg(test)] #[path = "lidarr_command_tests.rs"] @@ -37,6 +39,11 @@ pub enum LidarrCommand { about = "Commands to list attributes from your Lidarr instance" )] List(LidarrListCommand), + #[command( + subcommand, + about = "Commands to refresh the data in your Lidarr instance" + )] + Refresh(LidarrRefreshCommand), #[command( about = "Toggle monitoring for the specified artist corresponding to the given artist ID" )] @@ -92,6 +99,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, ' .handle() .await? } + LidarrCommand::Refresh(refresh_command) => { + LidarrRefreshCommandHandler::with(self.app, refresh_command, self.network) + .handle() + .await? + } LidarrCommand::ToggleArtistMonitoring { artist_id } => { let resp = self .network diff --git a/src/cli/lidarr/refresh_command_handler.rs b/src/cli/lidarr/refresh_command_handler.rs new file mode 100644 index 0000000..60c23cf --- /dev/null +++ b/src/cli/lidarr/refresh_command_handler.rs @@ -0,0 +1,64 @@ +use std::sync::Arc; + +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, lidarr_network::LidarrEvent}, +}; + +use super::LidarrCommand; + +#[cfg(test)] +#[path = "refresh_command_handler_tests.rs"] +mod refresh_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum LidarrRefreshCommand { + #[command(about = "Refresh all artist data for all artists in your Lidarr library")] + AllArtists, +} + +impl From for Command { + fn from(value: LidarrRefreshCommand) -> Self { + Command::Lidarr(LidarrCommand::Refresh(value)) + } +} + +pub(super) struct LidarrRefreshCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: LidarrRefreshCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrRefreshCommand> + for LidarrRefreshCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: LidarrRefreshCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + LidarrRefreshCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> anyhow::Result { + let result = match self.command { + LidarrRefreshCommand::AllArtists => { + let resp = self + .network + .handle_network_event(LidarrEvent::UpdateAllArtists.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/lidarr/refresh_command_handler_tests.rs b/src/cli/lidarr/refresh_command_handler_tests.rs new file mode 100644 index 0000000..5efdde8 --- /dev/null +++ b/src/cli/lidarr/refresh_command_handler_tests.rs @@ -0,0 +1,72 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::Cli; + use crate::cli::{ + Command, + lidarr::{LidarrCommand, refresh_command_handler::LidarrRefreshCommand}, + }; + use clap::CommandFactory; + + #[test] + fn test_lidarr_refresh_command_from() { + let command = LidarrRefreshCommand::AllArtists; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Lidarr(LidarrCommand::Refresh(command))); + } + + mod cli { + use super::*; + + #[test] + fn test_refresh_all_artists_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "lidarr", "refresh", "all-artists"]); + + assert_ok!(&result); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{app::App, cli::lidarr::refresh_command_handler::LidarrRefreshCommandHandler}; + use crate::{ + cli::{CliCommandHandler, lidarr::refresh_command_handler::LidarrRefreshCommand}, + network::lidarr_network::LidarrEvent, + }; + use crate::{ + models::{Serdeable, lidarr_models::LidarrSerdeable}, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_refresh_all_artists_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(LidarrEvent::UpdateAllArtists.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let refresh_command = LidarrRefreshCommand::AllArtists; + + let result = LidarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/handlers/lidarr_handlers/library/library_handler_tests.rs b/src/handlers/lidarr_handlers/library/library_handler_tests.rs index f3bdc24..a987d04 100644 --- a/src/handlers/lidarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/library_handler_tests.rs @@ -2,18 +2,18 @@ mod tests { use std::cmp::Ordering; - use pretty_assertions::assert_str_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use serde_json::Number; use strum::IntoEnumIterator; use crate::app::App; use crate::app::key_binding::DEFAULT_KEYBINDINGS; - use crate::assert_modal_absent; use crate::handlers::KeyEventHandler; use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options}; use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS}; use crate::network::lidarr_network::LidarrEvent; + use crate::{assert_modal_absent, assert_navigation_popped, assert_navigation_pushed}; #[test] fn test_library_handler_accepts() { @@ -267,6 +267,173 @@ mod tests { assert!(!app.is_routing); } + #[test] + fn test_update_all_artists_key() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveLidarrBlock::Artists, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + } + + #[test] + fn test_update_all_artists_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveLidarrBlock::Artists, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); + } + + #[test] + fn test_update_all_artists_prompt_confirm_submit() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + app.data.lidarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.submit.key, + &mut app, + ActiveLidarrBlock::UpdateAllArtistsPrompt, + None, + ) + .handle(); + + assert!(app.data.lidarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.lidarr_data.prompt_confirm_action, + &LidarrEvent::UpdateAllArtists + ); + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + } + + #[test] + fn test_update_all_artists_prompt_decline_submit() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.submit.key, + &mut app, + ActiveLidarrBlock::UpdateAllArtistsPrompt, + None, + ) + .handle(); + + assert!(!app.data.lidarr_data.prompt_confirm); + assert_none!(app.data.lidarr_data.prompt_confirm_action); + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + } + + #[test] + fn test_update_all_artists_prompt_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + app.data.lidarr_data.prompt_confirm = true; + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::UpdateAllArtistsPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + assert!(!app.data.lidarr_data.prompt_confirm); + } + + #[test] + fn test_update_all_artists_prompt_left_right() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveLidarrBlock::UpdateAllArtistsPrompt, + None, + ) + .handle(); + + assert!(app.data.lidarr_data.prompt_confirm); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveLidarrBlock::UpdateAllArtistsPrompt, + None, + ) + .handle(); + + assert!(!app.data.lidarr_data.prompt_confirm); + } + + #[test] + fn test_update_all_artists_prompt_confirm_key() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveLidarrBlock::UpdateAllArtistsPrompt, + None, + ) + .handle(); + + assert!(app.data.lidarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.lidarr_data.prompt_confirm_action, + &LidarrEvent::UpdateAllArtists + ); + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + } + fn artists_vec() -> Vec { vec![ Artist { diff --git a/src/handlers/lidarr_handlers/library/mod.rs b/src/handlers/lidarr_handlers/library/mod.rs index 82c87c0..d9e4482 100644 --- a/src/handlers/lidarr_handlers/library/mod.rs +++ b/src/handlers/lidarr_handlers/library/mod.rs @@ -1,7 +1,7 @@ use crate::{ app::App, event::Key, - handlers::{KeyEventHandler, handle_clear_errors}, + handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}, matches_key, models::{ BlockSelectionState, @@ -108,21 +108,39 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' } fn handle_left_right_action(&mut self) { - if self.active_lidarr_block == ActiveLidarrBlock::Artists { - handle_change_tab_left_right_keys(self.app, self.key); + match self.active_lidarr_block { + ActiveLidarrBlock::Artists => handle_change_tab_left_right_keys(self.app, self.key), + ActiveLidarrBlock::UpdateAllArtistsPrompt => handle_prompt_toggle(self.app, self.key), + _ => (), } } - fn handle_submit(&mut self) {} + fn handle_submit(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::UpdateAllArtistsPrompt { + if self.app.data.lidarr_data.prompt_confirm { + self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::UpdateAllArtists); + } + + self.app.pop_navigation_stack(); + } + } fn handle_esc(&mut self) { - handle_clear_errors(self.app); + match self.active_lidarr_block { + ActiveLidarrBlock::UpdateAllArtistsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.lidarr_data.prompt_confirm = false; + } + _ => { + handle_clear_errors(self.app); + } + } } fn handle_char_key_event(&mut self) { let key = self.key; - if self.active_lidarr_block == ActiveLidarrBlock::Artists { - match key { + match self.active_lidarr_block { + ActiveLidarrBlock::Artists => match key { _ if matches_key!(toggle_monitoring, key) => { self.app.data.lidarr_data.prompt_confirm = true; self.app.data.lidarr_data.prompt_confirm_action = Some( @@ -133,11 +151,25 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' .app .pop_and_push_navigation_stack(self.active_lidarr_block.into()); } + _ if matches_key!(update, key) => { + self + .app + .push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + } _ if matches_key!(refresh, key) => { self.app.should_refresh = true; } _ => (), + }, + ActiveLidarrBlock::UpdateAllArtistsPrompt => { + if matches_key!(confirm, key) { + self.app.data.lidarr_data.prompt_confirm = true; + self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::UpdateAllArtists); + + self.app.pop_navigation_stack(); + } } + _ => (), } } diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index 01b0bed..e2f6cb6 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -55,7 +55,7 @@ impl<'a> Default for LidarrData<'a> { quality_profile_map: BiMap::new(), root_folders: StatefulTable::default(), selected_block: BlockSelectionState::default(), - start_time: Utc::now(), + start_time: DateTime::default(), tags_map: BiMap::new(), version: String::new(), main_tabs: TabState::new(vec![TabRoute { @@ -112,19 +112,21 @@ pub enum ActiveLidarrBlock { DeleteArtistConfirmPrompt, DeleteArtistToggleDeleteFile, DeleteArtistToggleAddListExclusion, - SearchArtists, - SearchArtistsError, FilterArtists, FilterArtistsError, + SearchArtists, + SearchArtistsError, + UpdateAllArtistsPrompt, } -pub static LIBRARY_BLOCKS: [ActiveLidarrBlock; 6] = [ +pub static LIBRARY_BLOCKS: [ActiveLidarrBlock; 7] = [ ActiveLidarrBlock::Artists, ActiveLidarrBlock::ArtistsSortPrompt, - ActiveLidarrBlock::SearchArtists, - ActiveLidarrBlock::SearchArtistsError, ActiveLidarrBlock::FilterArtists, ActiveLidarrBlock::FilterArtistsError, + ActiveLidarrBlock::SearchArtists, + ActiveLidarrBlock::SearchArtistsError, + ActiveLidarrBlock::UpdateAllArtistsPrompt, ]; pub static DELETE_ARTIST_BLOCKS: [ActiveLidarrBlock; 4] = [ diff --git a/src/models/servarr_data/lidarr/lidarr_data_tests.rs b/src/models/servarr_data/lidarr/lidarr_data_tests.rs index 0b67bbf..5e032c5 100644 --- a/src/models/servarr_data/lidarr/lidarr_data_tests.rs +++ b/src/models/servarr_data/lidarr/lidarr_data_tests.rs @@ -1,11 +1,15 @@ #[cfg(test)] mod tests { - use pretty_assertions::assert_eq; - - use crate::models::{ - Route, - servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LidarrData}, + use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES; + use crate::models::servarr_data::lidarr::lidarr_data::{ + DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, }; + use crate::models::{ + BlockSelectionState, Route, + servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS, LidarrData}, + }; + use chrono::{DateTime, Utc}; + use pretty_assertions::{assert_eq, assert_str_eq}; #[test] fn test_from_active_lidarr_block_to_route() { @@ -36,4 +40,77 @@ mod tests { assert!(!lidarr_data.delete_artist_files); assert!(!lidarr_data.add_import_list_exclusion); } + + #[test] + fn test_lidarr_data_default() { + let lidarr_data = LidarrData::default(); + + assert!(!lidarr_data.add_import_list_exclusion); + assert_is_empty!(lidarr_data.artists); + assert!(!lidarr_data.delete_artist_files); + assert_is_empty!(lidarr_data.disk_space_vec); + assert_is_empty!(lidarr_data.downloads); + assert_is_empty!(lidarr_data.metadata_profile_map); + assert!(!lidarr_data.prompt_confirm); + assert_none!(lidarr_data.prompt_confirm_action); + assert_is_empty!(lidarr_data.quality_profile_map); + assert_is_empty!(lidarr_data.root_folders); + assert_eq!(lidarr_data.selected_block, BlockSelectionState::default()); + assert_eq!(lidarr_data.start_time, >::default()); + assert_is_empty!(lidarr_data.tags_map); + assert_is_empty!(lidarr_data.version); + + assert_eq!(lidarr_data.main_tabs.tabs.len(), 1); + + assert_str_eq!(lidarr_data.main_tabs.tabs[0].title, "Library"); + assert_eq!( + lidarr_data.main_tabs.tabs[0].route, + ActiveLidarrBlock::Artists.into() + ); + assert_some_eq_x!( + &lidarr_data.main_tabs.tabs[0].contextual_help, + &ARTISTS_CONTEXT_CLUES + ); + assert_none!(lidarr_data.main_tabs.tabs[0].config); + } + + #[test] + fn test_library_blocks_contains_expected_blocks() { + assert_eq!(LIBRARY_BLOCKS.len(), 7); + assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::Artists)); + assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::ArtistsSortPrompt)); + assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::SearchArtists)); + assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::SearchArtistsError)); + assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::FilterArtists)); + assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::FilterArtistsError)); + assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::UpdateAllArtistsPrompt)); + } + + #[test] + fn test_delete_artist_blocks_contents() { + assert_eq!(DELETE_ARTIST_BLOCKS.len(), 4); + assert!(DELETE_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::DeleteArtistPrompt)); + assert!(DELETE_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::DeleteArtistConfirmPrompt)); + assert!(DELETE_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::DeleteArtistToggleDeleteFile)); + assert!(DELETE_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::DeleteArtistToggleAddListExclusion)); + } + + #[test] + fn test_delete_artist_selection_blocks_ordering() { + let mut delete_artist_block_iter = DELETE_ARTIST_SELECTION_BLOCKS.iter(); + + assert_eq!( + delete_artist_block_iter.next().unwrap(), + &[ActiveLidarrBlock::DeleteArtistToggleDeleteFile] + ); + assert_eq!( + delete_artist_block_iter.next().unwrap(), + &[ActiveLidarrBlock::DeleteArtistToggleAddListExclusion] + ); + assert_eq!( + delete_artist_block_iter.next().unwrap(), + &[ActiveLidarrBlock::DeleteArtistConfirmPrompt] + ); + assert_none!(delete_artist_block_iter.next()); + } } diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 026c974..d1d12d8 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -23,6 +23,7 @@ use crate::{ mod modals_tests; #[derive(Default)] +#[cfg_attr(test, derive(Debug))] pub struct AddSeriesModal { pub root_folder_list: StatefulList, pub monitor_list: StatefulList, @@ -130,6 +131,7 @@ impl From<&SonarrData<'_>> for EditIndexerModal { } #[derive(Default)] +#[cfg_attr(test, derive(Debug))] pub struct EditSeriesModal { pub series_type_list: StatefulList, pub quality_profile_list: StatefulList, @@ -260,6 +262,7 @@ impl Default for EpisodeDetailsModal { } } +#[cfg_attr(test, derive(Debug))] pub struct SeasonDetailsModal { pub episodes: StatefulTable, pub episode_files: StatefulTable, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 92b9275..2a70d40 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -82,39 +82,39 @@ mod tests { let sonarr_data = SonarrData::default(); assert!(!sonarr_data.add_list_exclusion); - assert!(sonarr_data.add_searched_series.is_none()); - assert!(sonarr_data.add_series_search.is_none()); - assert!(sonarr_data.add_series_modal.is_none()); - assert!(sonarr_data.blocklist.is_empty()); + assert_none!(sonarr_data.add_searched_series); + assert_none!(sonarr_data.add_series_search); + assert_none!(sonarr_data.add_series_modal); + assert_is_empty!(sonarr_data.blocklist); assert!(!sonarr_data.delete_series_files); - assert!(sonarr_data.downloads.is_empty()); - assert!(sonarr_data.disk_space_vec.is_empty()); - assert!(sonarr_data.edit_indexer_modal.is_none()); - assert!(sonarr_data.edit_root_folder.is_none()); - assert!(sonarr_data.edit_series_modal.is_none()); - assert!(sonarr_data.history.is_empty()); - assert!(sonarr_data.indexers.is_empty()); - assert!(sonarr_data.indexer_settings.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()); - assert!(sonarr_data.log_details.is_empty()); + assert_is_empty!(sonarr_data.downloads); + assert_is_empty!(sonarr_data.disk_space_vec); + assert_none!(sonarr_data.edit_indexer_modal); + assert_none!(sonarr_data.edit_root_folder); + assert_none!(sonarr_data.edit_series_modal); + assert_is_empty!(sonarr_data.history); + assert_is_empty!(sonarr_data.indexers); + assert_none!(sonarr_data.indexer_settings); + assert_none!(sonarr_data.indexer_test_errors); + assert_none!(sonarr_data.indexer_test_all_results); + assert_is_empty!(sonarr_data.language_profiles_map); + assert_is_empty!(sonarr_data.logs); + assert_is_empty!(sonarr_data.log_details); 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_none!(sonarr_data.prompt_confirm_action); + assert_is_empty!(sonarr_data.quality_profile_map); + assert_is_empty!(sonarr_data.queued_events); + assert_is_empty!(sonarr_data.root_folders); + assert_is_empty!(sonarr_data.seasons); + assert_none!(sonarr_data.season_details_modal); assert_eq!(sonarr_data.selected_block, BlockSelectionState::default()); - assert!(sonarr_data.series.is_empty()); - assert!(sonarr_data.series_history.is_none()); + assert_is_empty!(sonarr_data.series); + assert_none!(sonarr_data.series_history); assert_eq!(sonarr_data.start_time, >::default()); - assert!(sonarr_data.tags_map.is_empty()); - assert!(sonarr_data.tasks.is_empty()); - assert!(sonarr_data.updates.is_empty()); - assert!(sonarr_data.version.is_empty()); + assert_is_empty!(sonarr_data.tags_map); + assert_is_empty!(sonarr_data.tasks); + assert_is_empty!(sonarr_data.updates); + assert_is_empty!(sonarr_data.version); assert_eq!(sonarr_data.main_tabs.tabs.len(), 7); @@ -123,84 +123,77 @@ mod tests { sonarr_data.main_tabs.tabs[0].route, ActiveSonarrBlock::Series.into() ); - assert!(sonarr_data.main_tabs.tabs[0].contextual_help.is_some()); - assert_eq!( - sonarr_data.main_tabs.tabs[0].contextual_help.unwrap(), + assert_some_eq_x!( + &sonarr_data.main_tabs.tabs[0].contextual_help, &SERIES_CONTEXT_CLUES ); - assert_eq!(sonarr_data.main_tabs.tabs[0].config, None); + assert_none!(sonarr_data.main_tabs.tabs[0].config); 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].contextual_help.is_some()); - assert_eq!( - sonarr_data.main_tabs.tabs[1].contextual_help.unwrap(), + assert_some_eq_x!( + &sonarr_data.main_tabs.tabs[1].contextual_help, &DOWNLOADS_CONTEXT_CLUES ); - assert_eq!(sonarr_data.main_tabs.tabs[1].config, None); + assert_none!(sonarr_data.main_tabs.tabs[1].config); 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].contextual_help.is_some()); - assert_eq!( - sonarr_data.main_tabs.tabs[2].contextual_help.unwrap(), + assert_some_eq_x!( + &sonarr_data.main_tabs.tabs[2].contextual_help, &BLOCKLIST_CONTEXT_CLUES ); - assert_eq!(sonarr_data.main_tabs.tabs[2].config, None); + assert_none!(sonarr_data.main_tabs.tabs[2].config); 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].contextual_help.is_some()); - assert_eq!( - sonarr_data.main_tabs.tabs[3].contextual_help.unwrap(), + assert_some_eq_x!( + &sonarr_data.main_tabs.tabs[3].contextual_help, &HISTORY_CONTEXT_CLUES ); - assert_eq!(sonarr_data.main_tabs.tabs[3].config, None); + assert_none!(sonarr_data.main_tabs.tabs[3].config); 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].contextual_help.is_some()); - assert_eq!( - sonarr_data.main_tabs.tabs[4].contextual_help.unwrap(), + assert_some_eq_x!( + &sonarr_data.main_tabs.tabs[4].contextual_help, &ROOT_FOLDERS_CONTEXT_CLUES ); - assert_eq!(sonarr_data.main_tabs.tabs[4].config, None); + assert_none!(sonarr_data.main_tabs.tabs[4].config); 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].contextual_help.is_some()); - assert_eq!( - sonarr_data.main_tabs.tabs[5].contextual_help.unwrap(), + assert_some_eq_x!( + &sonarr_data.main_tabs.tabs[5].contextual_help, &INDEXERS_CONTEXT_CLUES ); - assert_eq!(sonarr_data.main_tabs.tabs[5].config, None); + assert_none!(sonarr_data.main_tabs.tabs[5].config); 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].contextual_help.is_some()); - assert_eq!( - sonarr_data.main_tabs.tabs[6].contextual_help.unwrap(), + assert_some_eq_x!( + &sonarr_data.main_tabs.tabs[6].contextual_help, &SYSTEM_CONTEXT_CLUES ); - assert_eq!(sonarr_data.main_tabs.tabs[6].config, None); + assert_none!(sonarr_data.main_tabs.tabs[6].config); assert_eq!(sonarr_data.series_info_tabs.tabs.len(), 2); @@ -209,36 +202,22 @@ mod tests { sonarr_data.series_info_tabs.tabs[0].route, ActiveSonarrBlock::SeriesDetails.into() ); - assert!( - sonarr_data.series_info_tabs.tabs[0] - .contextual_help - .is_some() - ); - assert_eq!( - sonarr_data.series_info_tabs.tabs[0] - .contextual_help - .unwrap(), + assert_some_eq_x!( + &sonarr_data.series_info_tabs.tabs[0].contextual_help, &SERIES_DETAILS_CONTEXT_CLUES ); - assert_eq!(sonarr_data.series_info_tabs.tabs[0].config, None); + assert_none!(sonarr_data.series_info_tabs.tabs[0].config); 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] - .contextual_help - .is_some() - ); - assert_eq!( - sonarr_data.series_info_tabs.tabs[1] - .contextual_help - .unwrap(), + assert_some_eq_x!( + &sonarr_data.series_info_tabs.tabs[1].contextual_help, &SERIES_HISTORY_CONTEXT_CLUES ); - assert_eq!(sonarr_data.series_info_tabs.tabs[1].config, None); + assert_none!(sonarr_data.series_info_tabs.tabs[1].config); } } diff --git a/src/network/lidarr_network/library/lidarr_library_network_tests.rs b/src/network/lidarr_network/library/lidarr_library_network_tests.rs index 5533dba..6cb6c3e 100644 --- a/src/network/lidarr_network/library/lidarr_library_network_tests.rs +++ b/src/network/lidarr_network/library/lidarr_library_network_tests.rs @@ -162,4 +162,26 @@ mod tests { get_mock.assert_async().await; put_mock.assert_async().await; } + + #[tokio::test] + async fn test_handle_update_all_artists_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "RefreshArtist" + })) + .returns(json!({})) + .build_for(LidarrEvent::UpdateAllArtists) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + assert!( + network + .handle_lidarr_event(LidarrEvent::UpdateAllArtists) + .await + .is_ok() + ); + + mock.assert_async().await; + } } diff --git a/src/network/lidarr_network/library/mod.rs b/src/network/lidarr_network/library/mod.rs index 98987c6..9765cd1 100644 --- a/src/network/lidarr_network/library/mod.rs +++ b/src/network/lidarr_network/library/mod.rs @@ -5,6 +5,7 @@ use serde_json::{Value, json}; use crate::models::Route; use crate::models::lidarr_models::{Artist, DeleteArtistParams}; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; +use crate::models::servarr_models::CommandBody; use crate::network::lidarr_network::LidarrEvent; use crate::network::{Network, RequestMethod}; @@ -151,4 +152,20 @@ impl Network<'_, '_> { } } } + + pub(in crate::network::lidarr_network) async fn update_all_artists(&mut self) -> Result { + info!("Updating all artists"); + let event = LidarrEvent::UpdateAllArtists; + let body = CommandBody { + name: "RefreshArtist".to_owned(), + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } } diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index 3a43da1..837885f 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -27,6 +27,11 @@ mod tests { assert_str_eq!(event.resource(), "/config/host"); } + #[rstest] + fn test_resource_command(#[values(LidarrEvent::UpdateAllArtists)] event: LidarrEvent) { + assert_str_eq!(event.resource(), "/command"); + } + #[rstest] #[case(LidarrEvent::GetDiskSpace, "/diskspace")] #[case(LidarrEvent::GetDownloads(500), "/queue")] diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index af16346..bdb5810 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -31,6 +31,7 @@ pub enum LidarrEvent { HealthCheck, ListArtists, ToggleArtistMonitoring(i64), + UpdateAllArtists, } impl NetworkResource for LidarrEvent { @@ -43,6 +44,7 @@ impl NetworkResource for LidarrEvent { LidarrEvent::GetDiskSpace => "/diskspace", LidarrEvent::GetDownloads(_) => "/queue", LidarrEvent::GetHostConfig | LidarrEvent::GetSecurityConfig => "/config/host", + LidarrEvent::UpdateAllArtists => "/command", LidarrEvent::GetMetadataProfiles => "/metadataprofile", LidarrEvent::GetQualityProfiles => "/qualityprofile", LidarrEvent::GetRootFolders => "/rootfolder", @@ -108,6 +110,7 @@ impl Network<'_, '_> { .toggle_artist_monitoring(artist_id) .await .map(LidarrSerdeable::from), + LidarrEvent::UpdateAllArtists => self.update_all_artists().await.map(LidarrSerdeable::from), } } diff --git a/src/ui/lidarr_ui/library/library_ui_tests.rs b/src/ui/lidarr_ui/library/library_ui_tests.rs index 56164aa..1be94f4 100644 --- a/src/ui/lidarr_ui/library/library_ui_tests.rs +++ b/src/ui/lidarr_ui/library/library_ui_tests.rs @@ -247,5 +247,18 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_library_ui_renders_update_all_artists_prompt() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } } } diff --git a/src/ui/lidarr_ui/library/mod.rs b/src/ui/lidarr_ui/library/mod.rs index db3bb28..064181b 100644 --- a/src/ui/lidarr_ui/library/mod.rs +++ b/src/ui/lidarr_ui/library/mod.rs @@ -6,6 +6,10 @@ use ratatui::{ }; use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::{ + confirmation_prompt::ConfirmationPrompt, + popup::{Popup, Size}, +}; use crate::utils::convert_to_gb; use crate::{ app::App, @@ -42,8 +46,20 @@ impl DrawUi for LibraryUi { let route = app.get_current_route(); draw_library(f, app, area); - if DeleteArtistUi::accepts(route) { - DeleteArtistUi::draw(f, app, area); + match route { + _ if DeleteArtistUi::accepts(route) => DeleteArtistUi::draw(f, app, area), + Route::Lidarr(ActiveLidarrBlock::UpdateAllArtistsPrompt, _) => { + let confirmation_prompt = ConfirmationPrompt::new() + .title("Update All Artists") + .prompt("Do you want to update info and scan your disks for all of your artists?") + .yes_no_value(app.data.lidarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + _ => (), } } } diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_artists_prompt.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_artists_prompt.snap new file mode 100644 index 0000000..2f2678c --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_artists_prompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/lidarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags +=> Continuing 0 0.00 GB + + + + + + + + + + + + + + ╭────────────────── Update All Artists ───────────────────╮ + │ Do you want to update info and scan your disks for all of │ + │ your artists? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs index 7d65fdc..0f73c61 100644 --- a/src/ui/sonarr_ui/library/library_ui_tests.rs +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -382,5 +382,18 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_library_ui_renders_update_all_series_prompt() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::UpdateAllSeriesPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } } } diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_series_prompt.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_series_prompt.snap new file mode 100644 index 0000000..b43cea3 --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_series_prompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/sonarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title ▼ Year Network Status Rating Type Quality Profile Language Size Monitored Tags +=> Test 2022 HBO Continuin TV-MA Standard Bluray-1080p English 59.51 GB 🏷 + + + + + + + + + + + + + + ╭─────────────────── Update All Series ───────────────────╮ + │ Do you want to update info and scan your disks for all of │ + │ your series? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯